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.
429 lines
16 KiB
429 lines
16 KiB
import { describe, expect, it } from 'vitest'
|
|
|
|
import { compileMorfo, evalAttrPlan, normalizeTranslationRef } from './compile'
|
|
import { createAttrs } from './create-attrs'
|
|
import { v } from './types'
|
|
import { dialogMorfo } from './components/dialog'
|
|
import { toggleMorfo } from './components/toggle'
|
|
import { switchMorfo } from './components/switch'
|
|
import { toastMorfo } from './components/toast'
|
|
import { accordionMorfo } from './components/accordion'
|
|
|
|
describe('compileMorfo — parts', () => {
|
|
it('walks every part into byKebab + order, including nested ones', () => {
|
|
const compiled = compileMorfo(accordionMorfo)
|
|
// Accordion has top-level Provider + nested Item (with nested Header,
|
|
// Trigger, Content). Every kebab in the tree must appear once.
|
|
expect(compiled.parts.byKebab.has('provider')).toBe(true)
|
|
expect(compiled.parts.byKebab.has('item')).toBe(true)
|
|
expect(compiled.parts.byKebab.has('header')).toBe(true)
|
|
expect(compiled.parts.byKebab.has('trigger')).toBe(true)
|
|
expect(compiled.parts.byKebab.has('content')).toBe(true)
|
|
// order is depth-first declaration order
|
|
expect(compiled.parts.order[0]).toBe('provider')
|
|
})
|
|
|
|
it('builds attrs (kebab → data-attr name) matching createAttrs', () => {
|
|
const compiled = compileMorfo(dialogMorfo)
|
|
const fromCreateAttrs = createAttrs(dialogMorfo) as Record<string, string>
|
|
for (const kebab of Object.keys(fromCreateAttrs)) {
|
|
expect(compiled.parts.attrs[kebab]).toBe(fromCreateAttrs[kebab])
|
|
}
|
|
})
|
|
|
|
it('emits `data-{component}` for the provider part and `data-{component}-{kebab}` for the rest', () => {
|
|
const compiled = compileMorfo(dialogMorfo)
|
|
expect(compiled.parts.attrs.provider).toBe('data-dialog')
|
|
expect(compiled.parts.attrs.trigger).toBe('data-dialog-trigger')
|
|
expect(compiled.parts.attrs.content).toBe('data-dialog-content')
|
|
expect(compiled.parts.attrs.overlay).toBe('data-dialog-overlay')
|
|
})
|
|
|
|
it('leaves parent/child kebab links empty for flat morfos (no `part.parts`)', () => {
|
|
// Real morfos are flat today: every part is a top-level sibling.
|
|
// The compiler only records parent/child links when a part declares
|
|
// `parts` on itself — none of the 66 production morfos do.
|
|
const compiled = compileMorfo(accordionMorfo)
|
|
const provider = compiled.parts.byKebab.get('provider')!
|
|
expect(provider.parentKebab).toBeUndefined()
|
|
expect(provider.childKebabs).toEqual([])
|
|
|
|
const item = compiled.parts.byKebab.get('item')!
|
|
expect(item.parentKebab).toBeUndefined()
|
|
expect(item.childKebabs).toEqual([])
|
|
})
|
|
|
|
it('records parent/child kebab links from declared `part.parts` nesting', () => {
|
|
// Synthetic morfo to exercise the link mechanism — when a future morfo
|
|
// (or the planned `scope` primitive) introduces nested parts, the
|
|
// compiler must report the tree without recursing the raw morfo.
|
|
const nested = {
|
|
name: 'Nested',
|
|
kebab: 'nested',
|
|
scope: ['soma'],
|
|
parts: [
|
|
{
|
|
name: 'Provider',
|
|
kebab: 'provider',
|
|
archetype: 'provider',
|
|
kind: 'public',
|
|
defaultElement: 'div',
|
|
optional: false,
|
|
data: [],
|
|
aria: [],
|
|
parts: [
|
|
{
|
|
name: 'Item',
|
|
kebab: 'item',
|
|
kind: 'public',
|
|
defaultElement: 'div',
|
|
optional: false,
|
|
data: [],
|
|
aria: [],
|
|
parts: [
|
|
{
|
|
name: 'Header',
|
|
kebab: 'header',
|
|
kind: 'public',
|
|
defaultElement: 'div',
|
|
optional: false,
|
|
data: [],
|
|
aria: []
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
} as never
|
|
const compiled = compileMorfo(nested)
|
|
const provider = compiled.parts.byKebab.get('provider')!
|
|
expect(provider.parentKebab).toBeUndefined()
|
|
expect(provider.childKebabs).toEqual(['item'])
|
|
|
|
const item = compiled.parts.byKebab.get('item')!
|
|
expect(item.parentKebab).toBe('provider')
|
|
expect(item.childKebabs).toEqual(['header'])
|
|
|
|
const header = compiled.parts.byKebab.get('header')!
|
|
expect(header.parentKebab).toBe('item')
|
|
expect(header.childKebabs).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('compileMorfo — staticAttrs vs dynamicAttrs split', () => {
|
|
it('hoists role into staticAttrs', () => {
|
|
const compiled = compileMorfo(dialogMorfo)
|
|
const content = compiled.parts.byKebab.get('content')!
|
|
expect(content.staticAttrs.role).toBe('dialog')
|
|
})
|
|
|
|
it('hoists unconditional literal aria into staticAttrs (not dynamicAttrs)', () => {
|
|
// Dialog Trigger has `{ attr: 'aria-haspopup', value: v.literal('dialog') }`
|
|
// with no condition → must end up in staticAttrs.
|
|
const compiled = compileMorfo(dialogMorfo)
|
|
const trigger = compiled.parts.byKebab.get('trigger')!
|
|
expect(trigger.staticAttrs['aria-haspopup']).toBe('dialog')
|
|
expect(trigger.dynamicAttrs.find((a) => a.attr === 'aria-haspopup')).toBeUndefined()
|
|
})
|
|
|
|
it('keeps state/prop-driven attrs in dynamicAttrs with the correct mode', () => {
|
|
const compiled = compileMorfo(toggleMorfo)
|
|
const provider = compiled.parts.byKebab.get('provider')!
|
|
const dataState = provider.dynamicAttrs.find((a) => a.attr === 'data-state')
|
|
expect(dataState).toBeDefined()
|
|
expect(dataState!.mode).toBe('data-state-derived')
|
|
expect(dataState!.enumPair).toEqual(['on', 'off'])
|
|
|
|
const ariaPressed = provider.dynamicAttrs.find((a) => a.attr === 'aria-pressed')
|
|
expect(ariaPressed).toBeDefined()
|
|
expect(ariaPressed!.mode).toBe('aria-boolean')
|
|
|
|
const dataDisabled = provider.dynamicAttrs.find((a) => a.attr === 'data-disabled')
|
|
expect(dataDisabled).toBeDefined()
|
|
expect(dataDisabled!.mode).toBe('html-presence')
|
|
expect(dataDisabled!.condition).toEqual({ when: 'prop-truthy', prop: 'disabled' })
|
|
})
|
|
|
|
it('supports non-enum data attrs that carry raw values', () => {
|
|
const morfo = {
|
|
name: 'Metric',
|
|
kebab: 'metric',
|
|
scope: ['soma'],
|
|
parts: [
|
|
{
|
|
name: 'Provider',
|
|
kebab: 'provider',
|
|
kind: 'public',
|
|
defaultElement: 'div',
|
|
optional: false,
|
|
data: [{ attr: 'data-value', value: v.propRef('value'), emit: 'value' }],
|
|
aria: []
|
|
}
|
|
]
|
|
} as never
|
|
|
|
const compiled = compileMorfo(morfo)
|
|
const provider = compiled.parts.byKebab.get('provider')!
|
|
const dataValue = provider.dynamicAttrs.find((a) => a.attr === 'data-value')
|
|
expect(dataValue).toBeDefined()
|
|
expect(dataValue!.mode).toBe('string')
|
|
expect(evalAttrPlan(dataValue!, { props: { value: 42 } })).toBe(42)
|
|
expect(evalAttrPlan(dataValue!, { props: { value: undefined } })).toBeUndefined()
|
|
})
|
|
|
|
it('keeps a conditional literal in dynamicAttrs (literal + condition !== always)', () => {
|
|
// Toggle has `{ attr: 'aria-disabled', value: v.literal('true'),
|
|
// condition: { when: 'prop-truthy', prop: 'disabled' } }`
|
|
// Even though the source is a literal, the condition makes it dynamic.
|
|
const compiled = compileMorfo(toggleMorfo)
|
|
const provider = compiled.parts.byKebab.get('provider')!
|
|
const ariaDisabled = provider.dynamicAttrs.find((a) => a.attr === 'aria-disabled')
|
|
expect(ariaDisabled).toBeDefined()
|
|
expect(provider.staticAttrs['aria-disabled']).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('compileMorfo — keyboard plans', () => {
|
|
it('parses modifier prefixes once', () => {
|
|
const compiled = compileMorfo(dialogMorfo)
|
|
const content = compiled.parts.byKebab.get('content')!
|
|
const tab = content.keyboard.find((k) => k.action === 'focus-next')
|
|
expect(tab).toBeDefined()
|
|
expect(tab!.key.key).toBe('Tab')
|
|
expect(tab!.key.shift).toBe(false)
|
|
|
|
const shiftTab = content.keyboard.find((k) => k.action === 'focus-prev')
|
|
expect(shiftTab).toBeDefined()
|
|
expect(shiftTab!.key.key).toBe('Tab')
|
|
expect(shiftTab!.key.shift).toBe(true)
|
|
})
|
|
|
|
it('preserves the raw key string for diagnostics', () => {
|
|
const compiled = compileMorfo(dialogMorfo)
|
|
const content = compiled.parts.byKebab.get('content')!
|
|
const escape = content.keyboard.find((k) => k.action === 'close')
|
|
expect(escape).toBeDefined()
|
|
expect(escape!.raw).toBe('Escape')
|
|
})
|
|
})
|
|
|
|
describe('compileMorfo — actions', () => {
|
|
it('compiles morfo events into actions.byName indexed by name', () => {
|
|
const compiled = compileMorfo(dialogMorfo)
|
|
expect(compiled.actions.byName.has('open')).toBe(true)
|
|
expect(compiled.actions.byName.has('close-cancel')).toBe(true)
|
|
expect(compiled.actions.byName.has('close-after-fail')).toBe(true)
|
|
})
|
|
|
|
it('extracts target part kebab from the partRef', () => {
|
|
const compiled = compileMorfo(dialogMorfo)
|
|
const close = compiled.actions.byName.get('close-cancel')!
|
|
expect(close.target).toBe('content')
|
|
})
|
|
|
|
it('preserves prewrite array for transient markers (data-last-action)', () => {
|
|
const compiled = compileMorfo(dialogMorfo)
|
|
const close = compiled.actions.byName.get('close-cancel')!
|
|
expect(close.prewrite.length).toBeGreaterThan(0)
|
|
expect(close.prewrite[0].attr).toBe('data-last-action')
|
|
})
|
|
|
|
it('compiles toggle as a single commit-toggle event with intent binding (anexo Tabla 1)', () => {
|
|
// Per the libro de semántica anexo: Toggle/Switch declares a SINGLE
|
|
// doctrinal event `commit.toggle`. The contact is implicit in the
|
|
// gesture; the user perceives the consolidation as one moment.
|
|
// Intent is bound to the consumer prop (neutral default) — toggle
|
|
// supports neutral / affirm / risk / threat per the anexo.
|
|
const compiled = compileMorfo(toggleMorfo)
|
|
expect(compiled.actions.byName.size).toBe(1)
|
|
expect(compiled.actions.byName.has('commit-toggle')).toBe(true)
|
|
const action = compiled.actions.byName.get('commit-toggle')!
|
|
expect(action.semantic.family).toBe('commit')
|
|
const intent = 'intent' in action.semantic ? action.semantic.intent : null
|
|
expect(intent).toMatchObject({
|
|
fromProp: 'intent',
|
|
default: 'neutral',
|
|
supported: ['neutral', 'affirm', 'risk', 'threat']
|
|
})
|
|
})
|
|
|
|
it('handles morfos without events (synthetic — no events declared)', () => {
|
|
const empty = {
|
|
name: 'Empty',
|
|
kebab: 'empty',
|
|
scope: ['soma'],
|
|
parts: [
|
|
{
|
|
name: 'Provider',
|
|
kebab: 'provider',
|
|
kind: 'public',
|
|
defaultElement: 'div',
|
|
optional: false,
|
|
data: [],
|
|
aria: []
|
|
}
|
|
]
|
|
} as never
|
|
const compiled = compileMorfo(empty)
|
|
expect(compiled.actions.byName.size).toBe(0)
|
|
expect(compiled.actions.order).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('compileMorfo — contracts', () => {
|
|
it('emits dataAttrsByPart for every part with its declared values', () => {
|
|
const compiled = compileMorfo(toggleMorfo)
|
|
const provider = compiled.contracts.dataAttrsByPart.get('provider')!
|
|
const dataState = provider.find((d) => d.attr === 'data-state')
|
|
expect(dataState).toBeDefined()
|
|
expect(dataState!.values).toEqual(['on', 'off'])
|
|
})
|
|
|
|
it('emits cssSelectors with the full composite selector for each (part, attr, value)', () => {
|
|
const compiled = compileMorfo(toggleMorfo)
|
|
const stateSelectors = compiled.contracts.cssSelectors.filter(
|
|
(s) => s.part === 'provider' && s.attr === 'data-state'
|
|
)
|
|
expect(stateSelectors.map((s) => s.value).sort()).toEqual(['off', 'on'])
|
|
expect(stateSelectors.find((s) => s.value === 'on')!.selector).toBe(
|
|
"[data-toggle][data-state='on']"
|
|
)
|
|
})
|
|
|
|
it('emits a presence-only selector when a data attr has no values', () => {
|
|
const compiled = compileMorfo(toggleMorfo)
|
|
const disabledSelectors = compiled.contracts.cssSelectors.filter(
|
|
(s) => s.attr === 'data-disabled'
|
|
)
|
|
expect(disabledSelectors.length).toBeGreaterThan(0)
|
|
expect(disabledSelectors[0].selector).toBe('[data-toggle][data-disabled]')
|
|
expect(disabledSelectors[0].value).toBeUndefined()
|
|
})
|
|
|
|
it('aggregates required sources (states + props + parts + translations)', () => {
|
|
const compiled = compileMorfo(dialogMorfo)
|
|
const sources = compiled.contracts.requiredSources
|
|
// `open` state is referenced by data-state + aria-expanded.
|
|
expect(sources.states).toContain('open')
|
|
// Dialog uses propRef('disabled'), propRef('modal'), propRef('aria-label').
|
|
expect(sources.props).toContain('disabled')
|
|
expect(sources.props).toContain('modal')
|
|
// partRef('content'), partRef('title'), partRef('description').
|
|
expect(sources.parts).toEqual(expect.arrayContaining(['content', 'title', 'description']))
|
|
expect(sources.translations).toContain(
|
|
'#?components.dialog.content.roledescription|dialog window'
|
|
)
|
|
})
|
|
|
|
it('normalizes component-relative translation refs into idlangrefs', () => {
|
|
expect(normalizeTranslationRef('trigger', 'Open dialog', 'dialog')).toBe(
|
|
'#?components.dialog.trigger|Open dialog'
|
|
)
|
|
expect(normalizeTranslationRef('#?common.buttons.close|Close', undefined, 'dialog')).toBe(
|
|
'#?common.buttons.close|Close'
|
|
)
|
|
})
|
|
|
|
it('builds explicit refs for shared and absolute translation catalogs', () => {
|
|
const close = v.commonRef('buttons.close', 'Close')
|
|
expect(close.key).toBe('#?common.buttons.close')
|
|
expect(normalizeTranslationRef(close.key, close.fallback, 'dialog')).toBe(
|
|
'#?common.buttons.close|Close'
|
|
)
|
|
|
|
const shellClose = v.langRef('app.shell.close', 'Close')
|
|
expect(shellClose.key).toBe('#?app.shell.close')
|
|
expect(normalizeTranslationRef(shellClose.key, shellClose.fallback, 'dialog')).toBe(
|
|
'#?app.shell.close|Close'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('compileMorfo — per-part deps', () => {
|
|
it('records only the sources a part uses', () => {
|
|
const compiled = compileMorfo(dialogMorfo)
|
|
const trigger = compiled.parts.byKebab.get('trigger')!
|
|
// Trigger uses stateRef('open') + partRef('content').
|
|
expect(trigger.deps.states).toContain('open')
|
|
expect(trigger.deps.parts).toContain('content')
|
|
// Trigger does NOT use the 'modal' prop.
|
|
expect(trigger.deps.props).not.toContain('modal')
|
|
})
|
|
|
|
it('flags needsTranslations when a translationRef is used', () => {
|
|
// Dialog Content has `aria-roledescription` with translationRef.
|
|
const compiled = compileMorfo(dialogMorfo)
|
|
const content = compiled.parts.byKebab.get('content')!
|
|
expect(content.deps.needsTranslations).toBe(true)
|
|
})
|
|
|
|
it('skips translations when the part has no translationRef', () => {
|
|
const compiled = compileMorfo(toggleMorfo)
|
|
const provider = compiled.parts.byKebab.get('provider')!
|
|
expect(provider.deps.needsTranslations).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('compileMorfo — mapRef sources', () => {
|
|
it('extracts the inner source kind for dep tracking (mapRef wraps another source)', () => {
|
|
// Toast Item has `role: v.mapRef(v.propRef('intent'), { ... })`.
|
|
// The dep should be on the inner propRef, not the wrapper.
|
|
const compiled = compileMorfo(toastMorfo)
|
|
const item = compiled.parts.byKebab.get('item')!
|
|
expect(item.deps.props).toContain('intent')
|
|
})
|
|
})
|
|
|
|
describe('compileMorfo — switch (sanity check on a second simple morfo)', () => {
|
|
it('produces non-empty parts + valid attrs map', () => {
|
|
const compiled = compileMorfo(switchMorfo)
|
|
expect(compiled.parts.order.length).toBeGreaterThan(0)
|
|
expect(compiled.parts.attrs.provider).toBe('data-switch')
|
|
})
|
|
})
|
|
|
|
describe('compileMorfo — cache', () => {
|
|
it('returns the same compiled instance for the same morfo identity', () => {
|
|
const a = compileMorfo(dialogMorfo)
|
|
const b = compileMorfo(dialogMorfo)
|
|
expect(a).toBe(b)
|
|
})
|
|
|
|
it('different morfo identities → different compiled instances', () => {
|
|
const a = compileMorfo(dialogMorfo)
|
|
const b = compileMorfo(toggleMorfo)
|
|
expect(a).not.toBe(b)
|
|
})
|
|
})
|
|
|
|
describe('compileMorfo — invariants', () => {
|
|
it('throws on duplicate part kebabs', () => {
|
|
const broken = {
|
|
...toggleMorfo,
|
|
parts: [
|
|
...toggleMorfo.parts,
|
|
{
|
|
name: 'ProviderTwo',
|
|
kebab: 'provider', // duplicate!
|
|
kind: 'public',
|
|
defaultElement: 'div',
|
|
optional: false,
|
|
data: [],
|
|
aria: []
|
|
}
|
|
]
|
|
} as never
|
|
expect(() => compileMorfo(broken)).toThrow(/duplicate part kebab/)
|
|
})
|
|
|
|
it('produces a frozen output (defensive immutability)', () => {
|
|
const compiled = compileMorfo(dialogMorfo)
|
|
expect(Object.isFrozen(compiled)).toBe(true)
|
|
expect(Object.isFrozen(compiled.parts)).toBe(true)
|
|
expect(Object.isFrozen(compiled.parts.attrs)).toBe(true)
|
|
expect(Object.isFrozen(compiled.actions)).toBe(true)
|
|
expect(Object.isFrozen(compiled.contracts)).toBe(true)
|
|
})
|
|
})
|