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/soma/runtime.svelte.test.ts

1048 lines
31 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { flushSync } from 'svelte';
import { createActiveDom } from '$adom';
import type { SemanticSignal } from '$uix/sema';
import { state } from '$libs/reactive';
import { toggleMorfo } from '@/uix/morfo/components/toggle';
import { toastMorfo } from '@/uix/morfo/components/toast';
import { dialogMorfo } from '@/uix/morfo/components/dialog';
import { prewriteFixtureMorfo } from '@/uix/morfo/test-fixtures';
import { switchMorfo } from '@/uix/morfo/components/switch';
import { progressMorfo } from '@/uix/morfo/components/progress';
import { createSomaRuntime, type EventEngineEmitter } from './runtime.svelte';
/**
* Effect roots are normally provided by Svelte components. For unit tests we
* wrap the runtime construction in `$effect.root` so `$effect` calls inside
* `part(..., { syncAttrs: true })` have a parent to attach to.
*/
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
describe('createSomaRuntime', () => {
let target: HTMLElement;
let dom: ReturnType<typeof createActiveDom>;
beforeEach(() => {
document.body.innerHTML = '';
target = document.createElement('button');
target.id = 'tog-1';
document.body.appendChild(target);
dom = createActiveDom();
});
afterEach(() => {
document.body.innerHTML = '';
});
it('partProps returns identity (id + marker) without mutable attrs', () => {
const { result: runtime, cleanup } = withEffectRoot(() => {
const id = state('tog-1');
const ref = state<HTMLElement | null>(null);
const r = createSomaRuntime(toggleMorfo, {
dom,
states: { on: () => false }
});
r.part('provider', { id, ref, syncAttrs: true });
return r;
});
const props = runtime.partProps('provider');
expect(props.id).toBe('tog-1');
expect(props['data-toggle']).toBe('');
// Mutable attrs are NOT in partProps; they're written via dom.apply by the effect.
expect(props['data-state']).toBeUndefined();
expect(props['aria-pressed']).toBeUndefined();
cleanup();
});
it('part returns the registered runtime handle and sets optional context', () => {
const owner = {};
const context = { set: vi.fn() };
const { result: part, cleanup } = withEffectRoot(() => {
const id = state('tog-1');
const ref = state<HTMLElement | null>(null);
const r = createSomaRuntime(toggleMorfo, { dom });
return r.part('provider', { id, ref, owner, context });
});
expect(context.set).toHaveBeenCalledWith(owner);
expect(part.props.id).toBe('tog-1');
expect(part.props['data-toggle']).toBe('');
expect(part.props['data-state']).toBeUndefined();
expect(part.resolveProps({ states: { on: true } })['data-state']).toBe('on');
expect(part.assert({ ...part.props, 'data-state': 'on' })).toMatchObject({
id: 'tog-1',
'data-toggle': '',
'data-state': 'on'
});
cleanup();
});
it('part handles keep their own registration when a repeated part is registered twice', () => {
const repeatedMorfo = {
name: 'Repeated',
kebab: 'repeated',
scope: ['soma'],
parts: [
{
name: 'Item',
kebab: 'item',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
}
]
} as const;
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(repeatedMorfo, { dom });
const first = r.part('item', { id: state('item-1') });
const second = r.part('item', { id: state('item-2') });
return { first, second };
});
expect(result.first.props.id).toBe('item-1');
expect(result.second.props.id).toBe('item-2');
expect(result.first.props['data-repeated-item']).toBe('');
expect(result.second.props['data-repeated-item']).toBe('');
cleanup();
});
it('part can supply part-id sources for morfo partRef attrs', () => {
const partRefMorfo = {
name: 'PartRefProbe',
kebab: 'part-ref-probe',
scope: ['soma'],
parts: [
{
name: 'Trigger',
kebab: 'trigger',
kind: 'public',
defaultElement: 'button',
optional: false,
data: [],
aria: [
{
attr: 'aria-controls',
value: { kind: 'partRef', target: 'content' },
condition: { when: 'part-present', part: 'content' }
}
]
}
]
} as const;
const ref = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(partRefMorfo, { dom });
r.part('trigger', {
id: state('trigger-1'),
ref,
parts: { content: () => 'content-1' },
syncAttrs: true
});
});
ref.current = target;
flushSync();
expect(target.getAttribute('aria-controls')).toBe('content-1');
cleanup();
});
it('part can opt into runtime DOM attr synchronization', () => {
const ref = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const id = state('tog-1');
const r = createSomaRuntime(toggleMorfo, {
dom,
states: { on: () => false }
});
r.part('provider', { id, ref, syncAttrs: true });
});
ref.current = target;
flushSync();
expect(target.getAttribute('data-state')).toBe('off');
cleanup();
});
it('writes morfo-derived attrs to DOM when ref attaches', () => {
const ref = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const id = state('tog-1');
const r = createSomaRuntime(toggleMorfo, {
dom,
states: { on: () => false },
props: {
disabled: () => false,
readonly: () => false,
required: () => false,
invalid: () => false,
ariaLabel: () => undefined
}
});
r.part('provider', { id, ref, syncAttrs: true });
});
// Simulate Svelte's attachment firing — the runtime registered a setter
// on the ref via attachRef, but in tests we mutate the ref directly.
ref.current = target;
flushSync();
expect(target.getAttribute('data-state')).toBe('off');
expect(target.getAttribute('aria-pressed')).toBe('false');
expect(target.getAttribute('type')).toBe('button');
expect(target.hasAttribute('data-disabled')).toBe(false);
expect(target.hasAttribute('aria-disabled')).toBe(false);
cleanup();
});
it('reapplies attrs when sources change', () => {
const ref = state<HTMLElement | null>(null);
const pressed = state(false);
const disabled = state(false);
const { cleanup } = withEffectRoot(() => {
const id = state('tog-1');
const r = createSomaRuntime(toggleMorfo, {
dom,
states: { on: () => pressed.current },
props: {
disabled: () => disabled.current,
readonly: () => false,
required: () => false,
invalid: () => false,
ariaLabel: () => undefined
}
});
r.part('provider', { id, ref, syncAttrs: true });
});
ref.current = target;
flushSync();
expect(target.getAttribute('data-state')).toBe('off');
pressed.current = true;
flushSync();
expect(target.getAttribute('data-state')).toBe('on');
expect(target.getAttribute('aria-pressed')).toBe('true');
disabled.current = true;
flushSync();
expect(target.getAttribute('aria-disabled')).toBe('true');
cleanup();
});
it('removes conditional attrs when their condition flips back to false', () => {
const ref = state<HTMLElement | null>(null);
const invalid = state(false);
const { cleanup } = withEffectRoot(() => {
const id = state('tog-1');
const r = createSomaRuntime(toggleMorfo, {
dom,
states: { on: () => false },
props: {
disabled: () => false,
readonly: () => false,
required: () => false,
invalid: () => invalid.current,
ariaLabel: () => undefined
}
});
r.part('provider', { id, ref, syncAttrs: true });
});
ref.current = target;
flushSync();
expect(target.hasAttribute('data-invalid')).toBe(false);
expect(target.hasAttribute('aria-invalid')).toBe(false);
invalid.current = true;
flushSync();
expect(target.getAttribute('data-invalid')).toBe('');
expect(target.getAttribute('aria-invalid')).toBe('true');
invalid.current = false;
flushSync();
expect(target.hasAttribute('data-invalid')).toBe(false);
expect(target.hasAttribute('aria-invalid')).toBe(false);
cleanup();
});
it('writes non-enum data attrs as raw values when morfo declares emit:value', () => {
const ref = state<HTMLElement | null>(null);
const value = state<number | null>(20);
const min = state(0);
const max = state(100);
const { cleanup } = withEffectRoot(() => {
const id = state('progress-1');
const r = createSomaRuntime(progressMorfo, {
dom,
states: { loading: () => (value.current === null ? 'indeterminate' : 'loading') },
props: {
value: () => value.current ?? undefined,
min: () => min.current,
max: () => max.current,
valueText: () => undefined,
ariaLabel: () => undefined,
ariaLabelledby: () => undefined
}
});
r.part('provider', { id, ref, syncAttrs: true });
});
ref.current = target;
flushSync();
expect(target.getAttribute('data-value')).toBe('20');
expect(target.getAttribute('data-min')).toBe('0');
expect(target.getAttribute('data-max')).toBe('100');
value.current = null;
flushSync();
expect(target.hasAttribute('data-value')).toBe(false);
expect(target.getAttribute('data-state')).toBe('indeterminate');
expect(target.getAttribute('data-min')).toBe('0');
expect(target.getAttribute('data-max')).toBe('100');
cleanup();
});
it('throws when registering an unknown part', () => {
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toggleMorfo, { dom });
expect(() => r.part('nonexistent', { id: state('x') })).toThrow(/not declared/);
});
cleanup();
});
it('emits data-archetype in partProps when the morfo part declares one', () => {
// Inline a tiny morfo with an archetype so the test doesn't depend on
// the catalog state of any real component.
const archetypedMorfo = {
name: 'Probe',
kebab: 'probe',
scope: ['soma'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
optional: false,
data: [],
aria: []
}
]
} as const;
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(archetypedMorfo, { dom });
r.part('provider', { id: state('p-1'), syncAttrs: true });
return r;
});
const props = result.partProps('provider');
expect(props['data-archetype']).toBe('trigger');
expect(props.id).toBe('p-1');
expect(props['data-probe']).toBe('');
cleanup();
});
it('omits data-archetype when the part does not declare one (default)', () => {
const archetypeless = {
name: 'Plain',
kebab: 'plain',
scope: ['soma'],
parts: [
{
name: 'Provider',
kebab: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
}
]
} as const;
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(archetypeless, { dom });
r.part('provider', { id: state('p-1'), syncAttrs: true });
return r;
});
const props = result.partProps('provider');
expect(props['data-archetype']).toBeUndefined();
cleanup();
});
});
// ── trigger(eventName) ──────────────────────────────────────────────────────
function fakeSemantic(): {
engine: EventEngineEmitter;
calls: SemanticSignal[];
resolve: () => void;
} {
const calls: SemanticSignal[] = [];
let pendingResolve: (() => void) | null = null;
const engine: EventEngineEmitter = {
emit(signal: SemanticSignal) {
calls.push(signal);
return new Promise<string>((res) => {
pendingResolve = () => res(signal.id ?? 'fake-id');
});
}
};
return {
engine,
calls,
resolve() {
pendingResolve?.();
pendingResolve = null;
}
};
}
describe('runtime.trigger', () => {
let dom: ReturnType<typeof createActiveDom>;
let item: HTMLElement;
let content: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
item = document.createElement('div');
document.body.appendChild(item);
content = document.createElement('div');
document.body.appendChild(content);
dom = createActiveDom();
});
afterEach(() => {
document.body.innerHTML = '';
});
it('throws when the event is not declared in the morfo', async () => {
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toastMorfo, { dom });
return r.trigger('nonexistent');
});
try {
await expect(result).rejects.toThrow(/not declared/);
} finally {
cleanup();
}
});
it('throws when the target part has no DOM element registered (with semantic engine)', async () => {
const sem = fakeSemantic();
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toastMorfo, { dom, eventEngine: sem.engine });
// item part declared as the target of `present`, but not registered here
return r.trigger('present');
});
try {
await expect(result).rejects.toThrow(/has no DOM element/);
} finally {
cleanup();
}
});
it('does NOT throw on missing target when no semantic engine is provided', async () => {
// Sema is ornamental — without an engine, the runtime jumps to the
// handler without resolving target. Per the doctrine, components stay
// functional even when sema is absent.
const handler = vi.fn();
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toastMorfo, {
dom,
events: { dismiss: handler }
});
void r.trigger('dismiss'); // no item registered, no engine
});
await Promise.resolve();
await Promise.resolve();
expect(handler).toHaveBeenCalledTimes(1);
cleanup();
});
it('emits the perceptual signal to the resolved target with intent from prop', async () => {
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toastMorfo, {
dom,
eventEngine: sem.engine,
props: {
intent: () => 'risk'
}
});
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('announce');
});
expect(sem.calls).toHaveLength(1);
expect(sem.calls[0]).toMatchObject({
target: item,
name: 'announce',
family: 'signal',
intent: 'risk'
});
sem.resolve();
await Promise.resolve();
cleanup();
});
it('prefers an explicit fallback target for repeated part instances', async () => {
const sem = fakeSemantic();
const registeredItem = state<HTMLElement | null>(item);
const concreteItem = document.createElement('div');
document.body.appendChild(concreteItem);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toastMorfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: registeredItem, syncAttrs: true });
void r.trigger('present', { fallbackTarget: concreteItem });
});
expect(sem.calls[0]).toMatchObject({ target: concreteItem, name: 'present' });
sem.resolve();
await Promise.resolve();
cleanup();
});
it('falls back to the declared default intent when the prop is missing', async () => {
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toastMorfo, {
dom,
eventEngine: sem.engine,
props: {}
});
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('announce');
});
expect(sem.calls[0]).toMatchObject({ family: 'signal', intent: 'neutral' });
sem.resolve();
await Promise.resolve();
cleanup();
});
it('omits signal.hold when the morfo event does not declare one', async () => {
// No `hold` on toastMorfo's announce → soma passes nothing → sema
// applies its own family fallback inside VisualChannel.
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toastMorfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('announce');
});
expect(sem.calls[0].hold).toBeUndefined();
sem.resolve();
await Promise.resolve();
cleanup();
});
it('propagates a numeric hold from a morfo event to the signal', async () => {
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) => (e.name === 'announce' ? { ...e, hold: 1500 } : e))
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(morfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('announce');
});
expect(sem.calls[0].hold).toBe(1500);
sem.resolve();
await Promise.resolve();
cleanup();
});
it('resolves a perceptual label on the morfo event to its ms before emit', async () => {
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) =>
e.name === 'announce' ? { ...e, hold: 'insistent' as const } : e
)
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(morfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('announce');
});
expect(sem.calls[0].hold).toBe(1200);
sem.resolve();
await Promise.resolve();
cleanup();
});
it('omits intent for transitional families', async () => {
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toastMorfo, {
dom,
eventEngine: sem.engine
});
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('present');
});
expect(sem.calls[0]).toMatchObject({ name: 'present', family: 'emerge' });
expect(sem.calls[0].intent).toBeUndefined();
sem.resolve();
await Promise.resolve();
cleanup();
});
it('applies prewrite imperatively before semantic.emit', async () => {
// Synthetic fixture (`prewriteFixtureMorfo`) — decouples this
// test from the production morfo catalogue. Production morfos
// no longer carry per-event prewrite once they migrate to the
// polymorphic close shape (book §5.3); the fixture exercises
// the runtime's prewrite path independently.
const sem = fakeSemantic();
const contentRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(prewriteFixtureMorfo, {
dom,
eventEngine: sem.engine,
states: { open: () => true }
});
r.part('content', { id: state('fx-1'), ref: contentRef, syncAttrs: true });
contentRef.current = content;
void r.trigger('close-cancel');
});
// prewrite ran before semantic.emit (which is still pending — we never resolved it)
expect(content.getAttribute('data-last-action')).toBe('cancelled');
expect(sem.calls).toHaveLength(1);
sem.resolve();
await Promise.resolve();
cleanup();
});
it('invokes the provider handler after semantic.emit resolves', async () => {
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const handler = vi.fn();
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toastMorfo, {
dom,
eventEngine: sem.engine,
events: { dismiss: handler }
});
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('dismiss');
});
// semantic still pending → handler not called yet
expect(handler).not.toHaveBeenCalled();
sem.resolve();
await Promise.resolve();
await Promise.resolve();
expect(handler).toHaveBeenCalledTimes(1);
cleanup();
});
it('skips semantic.emit when no semantic source is provided', async () => {
const itemRef = state<HTMLElement | null>(null);
const handler = vi.fn();
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toastMorfo, {
dom,
events: { dismiss: handler }
});
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('dismiss');
});
await Promise.resolve();
await Promise.resolve();
// Without semantic, the runtime jumps straight to the handler.
expect(handler).toHaveBeenCalledTimes(1);
cleanup();
});
// ── Persistence (book §6.1) — SomaRuntime forwarding ───────────────────
it('forwards persistence from the morfo event semantic to the signal', async () => {
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) =>
e.name === 'announce'
? { ...e, semantic: { ...e.semantic, persistence: 'untilAction' as const } }
: e
)
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(morfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
return r.trigger('announce');
});
expect(sem.calls[0].persistence).toBe('untilAction');
sem.resolve();
const triggerResult = await result;
expect(triggerResult.persistence).toBe('untilAction');
expect(triggerResult.id).toBeDefined();
cleanup();
});
it('defaults persistence to transient when the morfo doesn’t declare it', async () => {
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toastMorfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
return r.trigger('announce');
});
expect(sem.calls[0].persistence).toBe('transient');
sem.resolve();
const triggerResult = await result;
expect(triggerResult.persistence).toBe('transient');
cleanup();
});
it('per-call persistence override beats the morfo declaration', async () => {
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) =>
e.name === 'announce'
? { ...e, semantic: { ...e.semantic, persistence: 'transient' as const } }
: e
)
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(morfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('announce', { persistence: 'untilFix' });
});
expect(sem.calls[0].persistence).toBe('untilFix');
sem.resolve();
await Promise.resolve();
cleanup();
});
// ── Polymorphic events (book §5.3) ─────────────────────────────────────
it('polymorphic event uses the morfo default family when no override is passed', async () => {
// toast `announce` is signal/neutral; we layer `allowedFamilies` on
// top — the morfo's own family/intent ARE the default.
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) =>
e.name === 'announce'
? {
...e,
semantic: {
...e.semantic,
allowedFamilies: ['signal', 'commit']
}
}
: e
)
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(morfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('announce');
});
expect(sem.calls[0]).toMatchObject({ family: 'signal', name: 'announce' });
sem.resolve();
await Promise.resolve();
cleanup();
});
it('polymorphic event concretes the semantic from opts.semantic', async () => {
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) =>
e.name === 'announce'
? {
...e,
semantic: {
...e.semantic,
allowedFamilies: ['signal', 'commit', 'shift']
}
}
: e
)
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(morfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('announce', {
semantic: { family: 'commit', verb: 'discard', intent: 'loss' }
});
});
expect(sem.calls[0]).toMatchObject({ family: 'commit', intent: 'loss' });
sem.resolve();
await Promise.resolve();
cleanup();
});
it('polymorphic event throws when family is not in allowedFamilies', async () => {
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) =>
e.name === 'announce'
? {
...e,
semantic: {
...e.semantic,
allowedFamilies: ['signal', 'commit']
}
}
: e
)
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(morfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
return r.trigger('announce', {
// `shift` is NOT in allowedFamilies and isn't the default — should raise.
semantic: { family: 'shift', verb: 'enter-mode' }
});
});
try {
await expect(result).rejects.toThrow(/not in allowedFamilies/);
} finally {
cleanup();
}
});
it('polymorphic event allows the morfo default family without listing it explicitly', async () => {
// `signal` is the morfo's declared family. It's implicitly always
// allowed even when allowedFamilies enumerates only the alternatives.
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) =>
e.name === 'announce'
? {
...e,
semantic: {
...e.semantic,
allowedFamilies: ['commit', 'shift']
}
}
: e
)
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(morfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('announce', {
semantic: { family: 'signal', verb: 'announce', intent: 'risk' }
});
});
expect(sem.calls[0]).toMatchObject({ family: 'signal', intent: 'risk' });
sem.resolve();
await Promise.resolve();
cleanup();
});
});
// ── runtime.keydown ─────────────────────────────────────────────────────────
function keyEvent(
key: string,
mods: { shift?: boolean; ctrl?: boolean; alt?: boolean; meta?: boolean } = {}
): KeyboardEvent {
return new KeyboardEvent('keydown', {
key,
shiftKey: mods.shift ?? false,
ctrlKey: mods.ctrl ?? false,
altKey: mods.alt ?? false,
metaKey: mods.meta ?? false,
bubbles: true,
cancelable: true
});
}
describe('runtime.keydown', () => {
let dom: ReturnType<typeof createActiveDom>;
beforeEach(() => {
document.body.innerHTML = '';
dom = createActiveDom();
});
it('returns false when the part has no keyboard declarations', () => {
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toggleMorfo, { dom });
r.part('provider', { id: state('t-1'), syncAttrs: true });
return r;
});
expect(result.keydown('provider', keyEvent('Enter'))).toBe(false);
cleanup();
});
it('dispatches the matching action handler with the original event', () => {
const handler = vi.fn();
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(switchMorfo, {
dom,
actions: { toggle: handler }
});
r.part('provider', { id: state('sw-1'), syncAttrs: true });
return r;
});
const ev = keyEvent('Enter');
expect(result.keydown('provider', ev)).toBe(true);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(ev);
cleanup();
});
it('matches multiple keys for the same action (Enter and Space on switch)', () => {
const handler = vi.fn();
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(switchMorfo, {
dom,
actions: { toggle: handler }
});
r.part('provider', { id: state('sw-1'), syncAttrs: true });
return r;
});
expect(result.keydown('provider', keyEvent('Enter'))).toBe(true);
expect(result.keydown('provider', keyEvent(' '))).toBe(true);
expect(handler).toHaveBeenCalledTimes(2);
cleanup();
});
it('returns false when no matching key spec is found', () => {
const handler = vi.fn();
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(switchMorfo, {
dom,
actions: { toggle: handler }
});
r.part('provider', { id: state('sw-1'), syncAttrs: true });
return r;
});
expect(result.keydown('provider', keyEvent('Tab'))).toBe(false);
expect(handler).not.toHaveBeenCalled();
cleanup();
});
it('respects modifier flags — Tab and Shift+Tab dispatch to different actions', () => {
const focusNext = vi.fn();
const focusPrev = vi.fn();
const { result, cleanup } = withEffectRoot(() => {
// dialog content declares both:
// { key: 'Tab', action: 'focus-next' }
// { key: 'Shift+Tab', action: 'focus-prev' }
const r = createSomaRuntime(dialogMorfo, {
dom,
actions: {
'focus-next': focusNext,
'focus-prev': focusPrev,
close: vi.fn()
}
});
r.part('content', { id: state('d-1'), syncAttrs: true });
return r;
});
expect(result.keydown('content', keyEvent('Tab'))).toBe(true);
expect(focusNext).toHaveBeenCalledTimes(1);
expect(focusPrev).not.toHaveBeenCalled();
expect(result.keydown('content', keyEvent('Tab', { shift: true }))).toBe(true);
expect(focusPrev).toHaveBeenCalledTimes(1);
expect(focusNext).toHaveBeenCalledTimes(1); // unchanged
cleanup();
});
it('returns false when the action has no registered handler', () => {
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(switchMorfo, {
dom,
actions: {} // no `toggle` handler
});
r.part('provider', { id: state('sw-1'), syncAttrs: true });
return r;
});
expect(result.keydown('provider', keyEvent('Enter'))).toBe(false);
cleanup();
});
it('does not call preventDefault on its own — caller decides', () => {
const ev = keyEvent('Enter');
const preventDefaultSpy = vi.spyOn(ev, 'preventDefault');
const handler = vi.fn(); // no preventDefault inside
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(switchMorfo, {
dom,
actions: { toggle: handler }
});
r.part('provider', { id: state('sw-1'), syncAttrs: true });
return r;
});
result.keydown('provider', ev);
expect(preventDefaultSpy).not.toHaveBeenCalled();
cleanup();
});
});

Powered by TurnKey Linux.