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.
439 lines
13 KiB
439 lines
13 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';
|
|
|
|
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 project prefs attrs; prefs and Eidos own their DOM projection', () => {
|
|
const root = document.documentElement;
|
|
root.removeAttribute('dir');
|
|
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-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);
|
|
} 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');
|
|
} 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();
|
|
}
|
|
});
|
|
});
|