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.
svelte-kit-vice/src/uix/morfo/compile.test.ts

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)
})
})

Powered by TurnKey Linux.