Earlier design mistake: `createActiveUix(options)` was internally
calling `createActiveApp(...)` to compose the UIX services. That
violated the contract — active-uix was supposed to be either a
substitute for active-app (when no app exists) or a complement (when
an external app injects services).
Fix: standalone mode now instantiates each UIX service directly via
the canonical factories — no `active-app` involved:
- core: createEngineLogger, createEngineBus, createActiveTimers,
createActivePrefs (with empty schema)
- lang: createActiveLang(schema, defaultLocale, fallbackChain)
- dom: createActiveDom
- frontend: createActiveFrontend (auto-applies to <html>)
- format: createActiveFormat (clock from timers)
- semantic: new EngineSemantic(opts)
active-uix manages every owned lifecycle: dispose() tears them down
in reverse dependency order. Attach mode is unchanged — wraps an
external app, validates UIX services, doesn't touch app.dispose().
Type changes:
- `ActiveUix.app: ActiveApp | undefined`. Present only in attach
mode (it's the underlying app); `undefined` in standalone (no
app exists).
- `ActiveUix.bus: EngineBus` (generic envelope). Standalone gets
a fresh bus, attach gets the App's bus with its event map. UIX
components don't care about typed events; consumers that do
re-narrow at the call site.
- Removed `prefs?` and `extraServices?` from `ActiveUixOptions`.
Apps that need a custom prefs schema or services beyond UIX
should use attach mode (compose `createActiveApp` themselves).
New tests cover both modes: standalone (8 tests including service
disposal verification, idempotent dispose, format opt-out,
componentLangs registration toggle), attach (6 tests including
missing-services error, identity-equal service proxying, no
disposal of external app), and cross-mode parity (1 test).
Verified: vitest 15 files / 211 tests, smoke 68/68, morfo-check 66/66.
morfo-runtime
parent
83b841a7ca
commit
9009cbfe8c
@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 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/lang'
|
||||
|
||||
const minimalLang = { schema: {} satisfies LangNode, defaultLocale: 'es' as const }
|
||||
|
||||
// ── createActiveUix (standalone — no active-app) ─────────────────────────────
|
||||
|
||||
describe('createActiveUix (standalone)', () => {
|
||||
it('exposes the canonical UIX surface', () => {
|
||||
const uix = createActiveUix({ lang: minimalLang })
|
||||
try {
|
||||
expect(uix.lang).toBeDefined()
|
||||
expect(uix.frontend).toBeDefined()
|
||||
expect(uix.format).toBeDefined()
|
||||
expect(uix.dom).toBeDefined()
|
||||
expect(uix.semantic).toBeDefined()
|
||||
expect(uix.logger).toBeDefined()
|
||||
expect(uix.bus).toBeDefined()
|
||||
expect(uix.timers).toBeDefined()
|
||||
expect(uix.prefs).toBeDefined()
|
||||
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({ lang: minimalLang })
|
||||
try {
|
||||
expect(uix.app).toBeUndefined()
|
||||
} finally {
|
||||
uix.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('opts out of format when format=false', () => {
|
||||
const uix = createActiveUix({ lang: minimalLang, format: false })
|
||||
try {
|
||||
expect(uix.format).toBeUndefined()
|
||||
} finally {
|
||||
uix.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('registers componentLangs by default — t() resolves a soma component path', () => {
|
||||
const uix = createActiveUix({ lang: minimalLang })
|
||||
try {
|
||||
const out = uix.lang.t('components.dialog.content.roledescription')
|
||||
expect(typeof out).toBe('string')
|
||||
expect(out.length).toBeGreaterThan(0)
|
||||
} finally {
|
||||
uix.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips componentLangs when registerComponentLangs: false', () => {
|
||||
const uix = createActiveUix({
|
||||
lang: minimalLang,
|
||||
registerComponentLangs: false
|
||||
})
|
||||
try {
|
||||
const out = uix.lang.t('components.dialog.content.roledescription')
|
||||
expect(out).not.toMatch(/dialog window/i)
|
||||
} finally {
|
||||
uix.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('dispose tears down every owned service (standalone owns lifecycle)', () => {
|
||||
const uix = createActiveUix({ lang: minimalLang })
|
||||
const langSpy = vi.spyOn(uix.lang, 'dispose')
|
||||
const formatSpy = vi.spyOn(uix.format!, 'dispose')
|
||||
const semanticSpy = vi.spyOn(uix.semantic, 'dispose')
|
||||
uix.dispose()
|
||||
expect(langSpy).toHaveBeenCalledTimes(1)
|
||||
expect(formatSpy).toHaveBeenCalledTimes(1)
|
||||
expect(semanticSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dispose is idempotent', () => {
|
||||
const uix = createActiveUix({ lang: minimalLang })
|
||||
const langSpy = vi.spyOn(uix.lang, '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({ lang: minimalLang })
|
||||
})
|
||||
const uix = attachActiveUix(app as unknown as ActiveApp)
|
||||
try {
|
||||
// Identity-equal: uix proxies to the same service instances.
|
||||
expect(uix.lang).toBe(app.lang)
|
||||
expect(uix.semantic).toBe(app.semantic)
|
||||
expect(uix.dom).toBe(app.dom)
|
||||
expect(uix.frontend).toBe(app.frontend)
|
||||
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({ lang: minimalLang }),
|
||||
custom: customFactory
|
||||
} as never
|
||||
})
|
||||
const uix = attachActiveUix(app as unknown as ActiveApp)
|
||||
try {
|
||||
expect(uix.lang).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({ lang: minimalLang })
|
||||
})
|
||||
const uix = attachActiveUix(app as unknown as ActiveApp)
|
||||
try {
|
||||
const out = uix.lang.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('lang')
|
||||
expect(err.missing).toContain('frontend')
|
||||
expect(err.missing).toContain('dom')
|
||||
expect(err.missing).toContain('semantic')
|
||||
expect(err.message).toContain('defineUixServices')
|
||||
} finally {
|
||||
app.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('dispose does NOT tear down the underlying app (ownsApp=false)', () => {
|
||||
const app = createActiveApp({
|
||||
services: defineUixServices({ lang: 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)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Cross-mode parity ───────────────────────────────────────────────────────
|
||||
|
||||
describe('cross-mode parity', () => {
|
||||
it('both modes produce an ActiveUix with the same surface keys', () => {
|
||||
const standalone = createActiveUix({ lang: minimalLang })
|
||||
const externalApp = createActiveApp({
|
||||
services: defineUixServices({ lang: minimalLang })
|
||||
})
|
||||
const attached = attachActiveUix(externalApp as unknown as ActiveApp)
|
||||
try {
|
||||
const surface = (uix: typeof standalone) =>
|
||||
[
|
||||
uix.lang !== undefined,
|
||||
uix.frontend !== undefined,
|
||||
uix.format !== undefined,
|
||||
uix.dom !== undefined,
|
||||
uix.semantic !== undefined,
|
||||
uix.logger !== undefined,
|
||||
uix.bus !== undefined,
|
||||
uix.timers !== undefined,
|
||||
uix.prefs !== undefined
|
||||
].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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in new issue