import { describe, expect, it } from 'vitest' import { validateMorfo, MorfoInvariantError } from '../schema' import type { Morfo, MorfoEvent } from '../types' import { dialogMorfo } from './dialog' function cloneMorfo(m: typeof dialogMorfo): Morfo { return structuredClone(m as Morfo) as Morfo } describe('dialogMorfo', () => { it('passes shape + invariant validation', () => { expect(() => validateMorfo(dialogMorfo)).not.toThrow() }) it('declares the 7 parts the provider emits', () => { const kebabs = dialogMorfo.parts.map((p) => p.kebab).sort() expect(kebabs).toEqual( ['close', 'content', 'description', 'overlay', 'provider', 'title', 'trigger'].sort() ) }) it('declares two semantic events: open + polymorphic close', () => { const events = dialogMorfo.events?.map((event) => event.name).sort() expect(events).toEqual(['emerge-close', 'emerge-open'].sort()) }) it('declares the close event as polymorphic with allowedFamilies', () => { const close = dialogMorfo.events?.find((e) => e.name === 'emerge-close')! expect(close).toBeDefined() // Default family is emerge; allowedFamilies opens commit + signal. expect('family' in close.semantic ? close.semantic.family : null).toBe('emerge') expect('allowedFamilies' in close.semantic ? close.semantic.allowedFamilies : null).toEqual([ 'emerge', 'commit', 'signal' ]) }) it('declares data-last-action on Content for causal exits', () => { const content = dialogMorfo.parts.find((p) => p.kebab === 'content')! const causal = content.data.find((d) => d.attr === 'data-last-action') expect(causal).toBeDefined() // Values stay declared for eidos / docs / contract narrowing even // though they're now set imperatively by the provider — see book // §5.3 polymorphism rationale on the morfo's `close` event. expect(causal!.values).toContain('saved') expect(causal!.values).toContain('cancelled') }) it('can declare runtime sources for data-* attrs', () => { const trigger = dialogMorfo.parts.find((p) => p.kebab === 'trigger')! const state = trigger.data.find((d) => d.attr === 'data-state') expect(state?.value).toEqual({ kind: 'stateRef', state: 'open' }) }) it('fails validation when a partRef targets a non-existent kebab', () => { const broken = cloneMorfo(dialogMorfo) const trigger = broken.parts.find((p) => p.kebab === 'trigger')! const controls = trigger.aria.find((a) => a.attr === 'aria-controls')! ;(controls.value as { kind: 'partRef'; target: string }).target = 'nonexistent-part' expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError) }) it('fails validation when stateRef refers to a state not declared in the part', () => { const broken = cloneMorfo(dialogMorfo) const trigger = broken.parts.find((p) => p.kebab === 'trigger')! const expanded = trigger.aria.find((a) => a.attr === 'aria-expanded')! ;(expanded.value as { kind: 'stateRef'; state: string }).state = 'nonexistent-state' expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError) }) it('fails validation when data.value.stateRef refers to an undeclared state', () => { const broken = cloneMorfo(dialogMorfo) const trigger = broken.parts.find((p) => p.kebab === 'trigger')! const state = trigger.data.find((d) => d.attr === 'data-state')! ;(state.value as { kind: 'stateRef'; state: string }).state = 'nonexistent-state' expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError) }) it('fails validation when two parts share the same kebab', () => { const broken = cloneMorfo(dialogMorfo) ;(broken.parts as unknown as { kebab: string }[])[1].kebab = 'content' expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError) }) it('fails validation with empty scope', () => { const broken = cloneMorfo(dialogMorfo) ;(broken as unknown as { scope: [] }).scope = [] expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError) }) it('fails when an event targets a non-existent part', () => { const broken = cloneMorfo(dialogMorfo) ;(broken.events as MorfoEvent[])[0].semantic.target.target = 'no-such-part' expect(() => validateMorfo(broken)).toThrow(/targets unknown part/) }) it('fails when a prewrite attr is not declared on the target part', () => { const broken = cloneMorfo(dialogMorfo) const close = (broken.events as MorfoEvent[]).find((event) => event.name === 'emerge-close')! // Synthesize a prewrite for the test — the production morfo has // none on `close` (provider sets data-last-action imperatively). close.prewrite = [{ part: close.semantic.target, attr: 'data-bogus', value: 'saved' }] expect(() => validateMorfo(broken)).toThrow(/data-bogus.*is not declared/) }) it('fails when a prewrite writes a value outside the declared enum', () => { const broken = cloneMorfo(dialogMorfo) const close = (broken.events as MorfoEvent[]).find((event) => event.name === 'emerge-close')! close.prewrite = [ { part: close.semantic.target, attr: 'data-last-action', value: 'not-in-enum' } ] expect(() => validateMorfo(broken)).toThrow(/not-in-enum.*is not declared/) }) it('fails when an event commits a non-existent state', () => { const broken = cloneMorfo(dialogMorfo) const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'emerge-close')! if (!action.commits) throw new Error('close commits missing') action.commits.value = 'zombied' expect(() => validateMorfo(broken)).toThrow(/zombied.*not declared/) }) it('fails when two events share the same name', () => { const broken = cloneMorfo(dialogMorfo) ;(broken.events as MorfoEvent[])[1].name = 'emerge-open' expect(() => validateMorfo(broken)).toThrow(/duplicate event name "emerge-open"/) }) // NOTE: the previous bidirectional check ("data-last-action declares a // value that no event prewrites") was relaxed in the polymorphism // refactor — the provider now sets data-last-action imperatively for // the polymorphic close event. See `validateMorfo` in `schema.ts` for // the rationale and the remaining one-directional invariant (prewritten // ⊆ declared) that still holds. })