|
|
// @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();
|
|
|
});
|
|
|
});
|