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.
138 lines
6.0 KiB
138 lines
6.0 KiB
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.
|
|
})
|