You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/active-uix/active-uix.svelte.test.ts

538 lines
18 KiB

// @vitest-environment jsdom
/**
* Tests for active-uix's two boot paths:
* - `createActiveUix(options)` — STANDALONE: active-uix instantiates
* its own services. No `active-app` involved. Owns lifecycles.
* - `attachActiveUix(app)` — ATTACH: external active-app injects the
* UIX services. active-uix doesn't own anything.
*
* Both produce an `ActiveUix` with the same observable surface.
* Components (soma / morfo / sema / eidos) consume that surface and
* have no way to tell which boot path was used — that decoupling is
* the whole point of active-uix.
*/
import { describe, expect, it, vi } from 'vitest';
import { createActiveApp, type ActiveApp } from '$active-app';
import {
ActiveUixMissingServiceError,
attachActiveUix,
createActiveUix
} from './active-uix.svelte';
import { defineUixServices } from './services';
import type { LangNode } from '$libs/langs';
import { standardPrefsDimensions } from '$prefs';
import { _resetMorfoRegistryForTesting, registerMorfo, v, type Morfo } from '$uix/morfo';
import { alertDialogMorfo } from '$uix/morfo/components/alert-dialog';
import { announceMorfo } from '$uix/morfo/components/announce';
import { breadcrumbMorfo } from '$uix/morfo/components/breadcrumb';
import { carouselMorfo } from '$uix/morfo/components/carousel';
import { clipboardMorfo } from '$uix/morfo/components/clipboard';
import { comboboxMorfo } from '$uix/morfo/components/combobox';
import { commandMorfo } from '$uix/morfo/components/command';
import { drawerMorfo } from '$uix/morfo/components/drawer';
import { feedMorfo } from '$uix/morfo/components/feed';
import { fileUploadMorfo } from '$uix/morfo/components/file-upload';
import { gridListMorfo } from '$uix/morfo/components/grid-list';
import { listboxMorfo } from '$uix/morfo/components/listbox';
import { meterMorfo } from '$uix/morfo/components/meter';
import { navigationMenuMorfo } from '$uix/morfo/components/navigation-menu';
import { paginationMorfo } from '$uix/morfo/components/pagination';
import { pinInputMorfo } from '$uix/morfo/components/pin-input';
import { progressMorfo } from '$uix/morfo/components/progress';
import { ratingGroupMorfo } from '$uix/morfo/components/rating-group';
import { scrollAreaMorfo } from '$uix/morfo/components/scroll-area';
import { searchFieldMorfo } from '$uix/morfo/components/search-field';
import { splitterMorfo } from '$uix/morfo/components/splitter';
import { tagGroupMorfo } from '$uix/morfo/components/tag-group';
import { tagsInputMorfo } from '$uix/morfo/components/tags-input';
import { stepperMorfo } from '$uix/morfo/components/stepper';
import { tableMorfo } from '$uix/morfo/components/table';
import { treeGridMorfo } from '$uix/morfo/components/tree-grid';
import { treeViewMorfo } from '$uix/morfo/components/tree-view';
const minimalLang = { schema: {} satisfies LangNode, defaultLocale: 'es' as const };
const bilingualPrefs = standardPrefsDimensions({
languages: ['es', 'en'],
locales: ['es-ES', 'en-US'],
currencies: ['EUR', 'USD'],
defaults: {
language: 'es',
locale: 'es-ES',
currency: 'EUR'
}
});
// ── createActiveUix (standalone — no active-app) ─────────────────────────────
describe('createActiveUix (standalone)', () => {
it('exposes the canonical UIX surface', () => {
const uix = createActiveUix({ langs: minimalLang });
try {
expect(uix.langs).toBeDefined();
expect(uix.format).toBeDefined();
expect(uix.dom).toBeDefined();
expect(uix.events).toBeDefined();
expect(uix.logger).toBeDefined();
expect(uix.bus).toBeDefined();
expect(uix.timers).toBeDefined();
expect(uix.prefs).toBeDefined();
expect(uix.portal).toBeUndefined();
expect((uix.prefs.effective() as Record<string, unknown>).language).toBe('es');
expect((uix.prefs.effective() as Record<string, unknown>).sound).toBe('allow');
expect(uix.ownsApp).toBe(true);
} finally {
uix.dispose();
}
});
it('does NOT create an active-app — uix.app is undefined in standalone mode', () => {
const uix = createActiveUix({ langs: minimalLang });
try {
expect(uix.app).toBeUndefined();
} finally {
uix.dispose();
}
});
it('opts out of format when format=false', () => {
const uix = createActiveUix({ langs: minimalLang, format: false });
try {
expect(uix.format).toBeUndefined();
} finally {
uix.dispose();
}
});
it('exposes the generic UI portal target when configured', () => {
const uix = createActiveUix({ langs: minimalLang, portal: '#portal-root' });
try {
expect(uix.portal).toBe('#portal-root');
} finally {
uix.dispose();
}
});
it('does not auto-project prefs attrs; composition roots wire prefs projection explicitly', () => {
const root = document.documentElement;
root.removeAttribute('dir');
root.removeAttribute('data-motion');
root.removeAttribute('data-sound');
root.removeAttribute('data-haptic');
root.removeAttribute('data-mode');
root.removeAttribute('data-density');
root.removeAttribute('data-theme');
const uix = createActiveUix({ langs: minimalLang });
try {
expect(root.hasAttribute('dir')).toBe(false);
expect(root.hasAttribute('data-motion')).toBe(false);
expect(root.hasAttribute('data-sound')).toBe(false);
expect(root.hasAttribute('data-haptic')).toBe(false);
expect(root.hasAttribute('data-mode')).toBe(false);
expect(root.hasAttribute('data-density')).toBe(false);
expect(root.hasAttribute('data-theme')).toBe(false);
} finally {
uix.dispose();
}
});
it('registers componentLangs by default — t() resolves a soma component path', () => {
const uix = createActiveUix({ langs: minimalLang });
try {
const out = uix.langs.t('components.dialog.content.roledescription');
expect(typeof out).toBe('string');
expect(out.length).toBeGreaterThan(0);
expect(uix.langs.t('components.calendar.prev-month')).toBe('Mes anterior');
expect(uix.langs.t('components.range-calendar.range-start')).toBe('inicio del rango');
} finally {
uix.dispose();
}
});
it('registers commonLangs by default without requiring app schema entries', () => {
const uix = createActiveUix({ langs: minimalLang });
try {
expect(uix.langs.t('common.buttons.close')).toBe('Cerrar');
expect(uix.langs.ts('#?common.buttons.close|Close')).toBe('Cerrar');
} finally {
uix.dispose();
}
});
it('skips componentLangs when registerComponentLangs: false', () => {
const uix = createActiveUix({
langs: minimalLang,
registerComponentLangs: false
});
try {
const out = uix.langs.t('components.dialog.content.roledescription');
expect(out).not.toMatch(/dialog window/i);
expect(uix.langs.t('common.buttons.close')).toBe('common.buttons.close');
} finally {
uix.dispose();
}
});
it('registers morfo translations that arrive after boot', () => {
_resetMorfoRegistryForTesting();
const uix = createActiveUix({
langs: minimalLang
});
try {
registerMorfo({
name: 'LazySample',
kebab: 'lazy-sample',
scope: ['soma'],
translations: {
label: { es: 'Muestra diferida', en: 'Lazy sample' }
},
parts: [
{
name: 'Provider',
kebab: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: [
{
attr: 'aria-label',
value: v.translationRef('label', 'Lazy sample')
}
]
}
]
} satisfies Morfo);
expect(uix.langs.t('components.lazy-sample.label')).toBe('Muestra diferida');
registerMorfo(fileUploadMorfo);
expect(uix.langs.t('components.file-upload.dropzone')).toBe('Subir archivos');
expect(uix.langs.t('components.file-upload.list')).toBe('Archivos seleccionados');
expect(uix.langs.ts('#?common.buttons.clear|Clear all')).toBe('Limpiar todo');
registerMorfo(progressMorfo);
registerMorfo(meterMorfo);
expect(uix.langs.t('components.progress.label')).toBe('Cargando');
expect(uix.langs.t('components.meter.label')).toBe('Medidor');
registerMorfo(searchFieldMorfo);
registerMorfo(comboboxMorfo);
registerMorfo(commandMorfo);
registerMorfo(drawerMorfo);
registerMorfo(feedMorfo);
expect(uix.langs.t('components.search-field.clear')).toBe('Borrar búsqueda');
expect(uix.langs.t('components.combobox.toggle')).toBe('Alternar');
expect(uix.langs.t('components.command.list')).toBe('Paleta de comandos');
expect(uix.langs.t('components.drawer.trigger')).toBe('Abrir cajón');
expect(uix.langs.t('components.feed.label')).toBe('Feed');
registerMorfo(listboxMorfo);
registerMorfo(navigationMenuMorfo);
registerMorfo(treeViewMorfo);
registerMorfo(stepperMorfo);
registerMorfo(splitterMorfo);
registerMorfo(scrollAreaMorfo);
expect(uix.langs.t('components.listbox.label')).toBe('Opciones');
expect(uix.langs.t('components.navigation-menu.label')).toBe('Principal');
expect(uix.langs.t('components.tree-view.label')).toBe('Árbol');
expect(uix.langs.t('components.stepper.list')).toBe('Pasos');
expect(uix.langs.t('components.splitter.resize')).toBe('Redimensionar paneles');
expect(uix.langs.t('components.scroll-area.scrollbar-horizontal')).toBe(
'Barra de desplazamiento horizontal'
);
registerMorfo(gridListMorfo);
registerMorfo(pinInputMorfo);
registerMorfo(ratingGroupMorfo);
expect(uix.langs.t('components.grid-list.label')).toBe('Lista en cuadrícula');
expect(uix.langs.t('components.grid-list.select-row')).toBe('Seleccionar fila');
expect(uix.langs.t('components.pin-input.input')).toBe('Código de verificación');
expect(uix.langs.t('components.rating-group.label')).toBe('Valoración');
registerMorfo(tagGroupMorfo);
registerMorfo(tagsInputMorfo);
expect(uix.langs.t('components.tag-group.remove', { tag: 'UIX' })).toBe('Eliminar UIX');
expect(uix.langs.t('components.tags-input.remove')).toBe('Eliminar etiqueta');
expect(uix.langs.t('components.tags-input.clear')).toBe('Limpiar todas las etiquetas');
registerMorfo(treeGridMorfo);
registerMorfo(tableMorfo);
registerMorfo(paginationMorfo);
expect(uix.langs.t('components.tree-grid.expand')).toBe('Expandir fila');
expect(uix.langs.t('components.tree-grid.toggle')).toBe('Alternar fila');
expect(uix.langs.t('components.table.row-detail-collapse')).toBe('Ocultar detalles');
expect(uix.langs.t('components.table.toggle-detail')).toBe('Alternar detalle');
expect(uix.langs.t('components.pagination.page', { value: 4 })).toBe('Página 4');
registerMorfo(alertDialogMorfo);
registerMorfo(announceMorfo);
registerMorfo(breadcrumbMorfo);
registerMorfo(clipboardMorfo);
registerMorfo(carouselMorfo);
expect(uix.langs.t('components.alert-dialog.action')).toBe('Confirmar');
expect(uix.langs.t('components.announce.label')).toBe('Anuncios');
expect(uix.langs.t('components.breadcrumb.ellipsis')).toBe('Más');
expect(uix.langs.t('components.clipboard.copy')).toBe('Copiar al portapapeles');
expect(uix.langs.t('components.carousel.indicator', { index: 2 })).toBe(
'Ir a la diapositiva 2'
);
} finally {
uix.dispose();
_resetMorfoRegistryForTesting();
}
});
it('does not re-register morfo translations already covered by componentLangs', () => {
_resetMorfoRegistryForTesting();
const uix = createActiveUix({
langs: minimalLang
});
try {
registerMorfo({
name: 'Dialog',
kebab: 'dialog',
scope: ['soma'],
translations: {
content: {
roledescription: { es: 'otra ventana', en: 'other window' }
}
},
parts: [
{
name: 'Provider',
kebab: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
}
]
} satisfies Morfo);
expect(uix.langs.t('components.dialog.content.roledescription')).toBe('ventana de diálogo');
} finally {
uix.dispose();
_resetMorfoRegistryForTesting();
}
});
it('dispose tears down every owned service (standalone owns lifecycle)', () => {
const uix = createActiveUix({ langs: minimalLang });
const langSpy = vi.spyOn(uix.langs, 'dispose');
const formatSpy = vi.spyOn(uix.format!, 'dispose');
const eventsSpy = vi.spyOn(uix.events!, 'dispose');
uix.dispose();
expect(langSpy).toHaveBeenCalledTimes(1);
expect(formatSpy).toHaveBeenCalledTimes(1);
expect(eventsSpy).toHaveBeenCalledTimes(1);
});
it('dispose is idempotent', () => {
const uix = createActiveUix({ langs: minimalLang });
const langSpy = vi.spyOn(uix.langs, 'dispose');
uix.dispose();
uix.dispose();
uix.dispose();
expect(langSpy).toHaveBeenCalledTimes(1);
});
});
// ── attachActiveUix (integration with externally composed active-app) ────────
describe('attachActiveUix (integration mode)', () => {
it('attaches to an app composed via defineUixServices', () => {
const app = createActiveApp({
services: defineUixServices({ langs: minimalLang })
});
const uix = attachActiveUix(app as unknown as ActiveApp);
try {
// Identity-equal: uix proxies to the same service instances.
expect(uix.langs).toBe(app.langs);
expect(uix.events).toBe(app.events);
expect(uix.dom).toBe(app.dom);
expect(uix.ownsApp).toBe(false);
expect(uix.app).toBe(app);
} finally {
uix.dispose();
app.dispose();
}
});
it('coexists with extra services in the same app schema', () => {
// Real-world case: app composes UIX services + its own (cache,
// session, http, ...). attachActiveUix consumes the UIX slice;
// the rest stays on app for advanced consumers via uix.app.
const customFactory = {
name: 'custom' as const,
coreDependencies: [] as const,
initMode: 'lazy' as const,
create() {
return { tag: 'extra' };
}
};
const app = createActiveApp({
services: {
...defineUixServices({ langs: minimalLang }),
custom: customFactory
} as never
}) as unknown as ActiveApp;
const uix = attachActiveUix(app);
try {
expect(uix.langs).toBeDefined();
expect((app as unknown as { custom: { tag: string } }).custom.tag).toBe('extra');
} finally {
uix.dispose();
app.dispose();
}
});
it('registers componentLangs on attach (default true)', () => {
const app = createActiveApp({
services: defineUixServices({ langs: minimalLang })
});
const uix = attachActiveUix(app as unknown as ActiveApp);
try {
const out = uix.langs.t('components.dialog.content.roledescription');
expect(typeof out).toBe('string');
expect(out.length).toBeGreaterThan(0);
} finally {
uix.dispose();
app.dispose();
}
});
it('throws ActiveUixMissingServiceError when required services are missing', () => {
const app = createActiveApp({ services: {} });
try {
expect(() => attachActiveUix(app as unknown as ActiveApp)).toThrow(
ActiveUixMissingServiceError
);
} finally {
app.dispose();
}
});
it('the error reports every missing service so the integrator can fix the schema', () => {
const app = createActiveApp({ services: {} });
try {
attachActiveUix(app as unknown as ActiveApp);
expect.fail('should have thrown');
} catch (err) {
if (!(err instanceof ActiveUixMissingServiceError)) throw err;
expect(err.missing).toContain('langs');
expect(err.missing).toContain('dom');
expect(err.missing).not.toContain('events');
expect(err.message).toContain('defineUixServices');
} finally {
app.dispose();
}
});
it('rejects attach mode when the external app omits dom', () => {
const app = createActiveApp({
services: defineUixServices({ langs: minimalLang, dom: false })
});
try {
expect(() => attachActiveUix(app as unknown as ActiveApp)).toThrow(
ActiveUixMissingServiceError
);
} finally {
app.dispose();
}
});
it('does not subscribe langs to prefs.language twice in attach mode', () => {
const app = createActiveApp({
prefs: { schema: bilingualPrefs },
services: defineUixServices({
langs: {
schema: {
hello: { es: 'Hola', en: 'Hello' }
},
defaultLocale: 'es'
}
})
});
const uix = attachActiveUix(app as unknown as ActiveApp);
try {
const spy = vi.spyOn(uix.langs, 'setLocale');
(app.prefs as unknown as { language: { set(value: 'en'): void } }).language.set('en');
expect(spy).toHaveBeenCalledTimes(1);
expect(uix.langs.t('hello')).toBe('Hello');
} finally {
uix.dispose();
app.dispose();
}
});
it('dispose does NOT tear down the underlying app (ownsApp=false)', () => {
const app = createActiveApp({
services: defineUixServices({ langs: minimalLang })
});
const spy = vi.spyOn(app, 'dispose');
const uix = attachActiveUix(app as unknown as ActiveApp);
uix.dispose();
expect(spy).not.toHaveBeenCalled();
// External owner is responsible for disposal.
app.dispose();
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe('active-uix degraded DOM mode', () => {
it('dom=false keeps event projection inside the disabled no-op dom', async () => {
const uix = createActiveUix({ langs: minimalLang, dom: false });
const target = document.createElement('div');
try {
const promise = uix.events!.emit({
target,
name: 'announce',
family: 'signal',
hold: 0
});
expect(target.hasAttribute('data-event')).toBe(false);
await promise;
expect(target.hasAttribute('data-event')).toBe(false);
} finally {
uix.dispose();
}
});
});
// ── Cross-mode parity ───────────────────────────────────────────────────────
describe('cross-mode parity', () => {
it('both modes produce an ActiveUix with the same surface keys', () => {
const standalone = createActiveUix({ langs: minimalLang });
const externalApp = createActiveApp({
services: defineUixServices({ langs: minimalLang })
});
const attached = attachActiveUix(externalApp as unknown as ActiveApp);
try {
const surface = (uix: typeof standalone) =>
[
uix.langs !== undefined,
uix.format !== undefined,
uix.dom !== undefined,
uix.events !== undefined,
uix.logger !== undefined,
uix.bus !== undefined,
uix.timers !== undefined,
uix.prefs !== undefined,
'portal' in uix
].every(Boolean);
expect(surface(standalone)).toBe(true);
expect(surface(attached)).toBe(true);
expect(standalone.ownsApp).toBe(true);
expect(attached.ownsApp).toBe(false);
// app is the canonical asymmetry: only present in attach mode.
expect(standalone.app).toBeUndefined();
expect(attached.app).toBe(externalApp);
} finally {
standalone.dispose();
attached.dispose();
externalApp.dispose();
}
});
});

Powered by TurnKey Linux.