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.
129 lines
5.2 KiB
129 lines
5.2 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 six semantic events directly in morfo', () => {
|
|
const events = dialogMorfo.events?.map((event) => event.name).sort()
|
|
expect(events).toEqual(
|
|
[
|
|
'open',
|
|
'close-save',
|
|
'close-cancel',
|
|
'close-dismiss',
|
|
'close-dismiss-outside',
|
|
'close-after-fail'
|
|
].sort()
|
|
)
|
|
})
|
|
|
|
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()
|
|
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 action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close-save')!
|
|
action.prewrite![0].attr = 'data-bogus'
|
|
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 action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close-save')!
|
|
action.prewrite![0].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 === 'close-save')!
|
|
if (!action.commits) throw new Error('close-save 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 = 'open'
|
|
expect(() => validateMorfo(broken)).toThrow(/duplicate event name "open"/)
|
|
})
|
|
|
|
it('fails when data-last-action declares a value that no event prewrites', () => {
|
|
const broken = cloneMorfo(dialogMorfo)
|
|
const content = broken.parts.find((part) => part.kebab === 'content')!
|
|
const dataLastAction = content.data.find((entry) => entry.attr === 'data-last-action')!
|
|
;(dataLastAction.values as string[]) = [...(dataLastAction.values ?? []), 'orphan-value']
|
|
expect(() => validateMorfo(broken)).toThrow(/orphan-value.*no event prewrites/)
|
|
})
|
|
})
|