// @vitest-environment jsdom import { tick } from 'svelte'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createActiveDom } from '$adom'; import { state } from '$libs/reactive'; import type { Morfo } from '$uix/morfo'; import { Soma } from '$soma/core/soma.svelte'; import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte'; import { TimelineItemProvider, TimelineProvider } from './timeline-provider.svelte'; function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { let result!: T; const cleanup = $effect.root(() => { result = fn(); }); return { result, cleanup }; } function installSomaHarness() { const dom = createActiveDom(); const soma = { dom, langs: { ts: vi.fn(() => 'Timeline') }, runtime: (morfo: Morfo, sources: Omit) => createSomaRuntime(morfo, { dom, translate: (key) => key, ...sources }) } as unknown as Soma; vi.spyOn(Soma, 'require').mockReturnValue(soma); vi.spyOn(TimelineProvider.ctx, 'set').mockImplementation((value) => value); vi.spyOn(TimelineItemProvider.ctx, 'set').mockImplementation((value) => value); return { dom }; } function timelineOpts(active: number | null, live = false, root = document.createElement('ol')) { return { id: state('timeline-root'), ref: state(root), active: state(active), live: state(live), ariaLabel: state(undefined) }; } function itemOpts(index: number | undefined, opts?: { status?: 'done' | 'current' | 'upcoming' }) { return { id: state(`timeline-item-${index ?? 'auto'}`), ref: state(document.createElement('li')), index: state(index), status: state<'done' | 'current' | 'upcoming' | undefined>(opts?.status), intent: state<'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss' | undefined>(undefined) }; } describe('TimelineProvider', () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ''; }); it('derives item status from the active index and projects runtime attrs', async () => { const { dom } = installSomaHarness(); const root = document.createElement('ol'); const firstEl = document.createElement('li'); const currentEl = document.createElement('li'); const lastEl = document.createElement('li'); document.body.append(root, firstEl, currentEl, lastEl); const { result, cleanup } = withEffectRoot(() => { const provider = TimelineProvider.create(timelineOpts(1, false, root)); vi.spyOn(TimelineProvider, 'require').mockReturnValue(provider); const first = TimelineItemProvider.create({ ...itemOpts(0), ref: state(firstEl) }); const current = TimelineItemProvider.create({ ...itemOpts(1), ref: state(currentEl) }); const last = TimelineItemProvider.create({ ...itemOpts(2), ref: state(lastEl) }); return { provider, first, current, last }; }); expect(result.first.effectiveStatus).toBe('done'); expect(result.current.effectiveStatus).toBe('current'); expect(result.last.effectiveStatus).toBe('upcoming'); expect(result.current.isCurrent).toBe(true); // renderProps resolves the morfo attrs into the props object the wrapper // spreads onto the element; assert the object (no wrapper in this harness). expect(result.provider.props).toMatchObject({ id: 'timeline-root', 'data-timeline': '', role: 'list', 'aria-label': 'Timeline' }); expect(result.first.props).toMatchObject({ 'data-timeline-item': '', 'data-status': 'done' }); expect(result.current.props).toMatchObject({ 'data-status': 'current', 'aria-current': 'step' }); expect(result.last.props).toMatchObject({ 'data-status': 'upcoming' }); expect(result.first.props['aria-current']).toBeUndefined(); await tick(); cleanup(); dom.dispose(); }); it('lets an explicit status override the derived value and tints with intent', async () => { const { dom } = installSomaHarness(); const root = document.createElement('ol'); const el = document.createElement('li'); document.body.append(root, el); const { result, cleanup } = withEffectRoot(() => { const provider = TimelineProvider.create(timelineOpts(2, false, root)); vi.spyOn(TimelineProvider, 'require').mockReturnValue(provider); // index 0 would derive 'done', but the explicit status wins. const item = TimelineItemProvider.create({ ...itemOpts(0, { status: 'current' }), ref: state(el), intent: state<'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss' | undefined>('threat') }); return { provider, item }; }); expect(result.item.effectiveStatus).toBe('current'); expect(result.item.props).toMatchObject({ 'data-status': 'current', 'data-intent': 'threat', 'aria-current': 'step' }); cleanup(); dom.dispose(); }); it('auto-indexes items by registration order when no index is passed', () => { const { dom } = installSomaHarness(); const root = document.createElement('ol'); const { result, cleanup } = withEffectRoot(() => { const provider = TimelineProvider.create(timelineOpts(1, false, root)); vi.spyOn(TimelineProvider, 'require').mockReturnValue(provider); const a = TimelineItemProvider.create(itemOpts(undefined)); const b = TimelineItemProvider.create(itemOpts(undefined)); const c = TimelineItemProvider.create(itemOpts(undefined)); return { a, b, c }; }); expect([result.a.index, result.b.index, result.c.index]).toEqual([0, 1, 2]); expect(result.a.effectiveStatus).toBe('done'); expect(result.b.effectiveStatus).toBe('current'); expect(result.c.effectiveStatus).toBe('upcoming'); cleanup(); dom.dispose(); }); it('leaves status undefined when neither active nor explicit status is set', () => { const { dom } = installSomaHarness(); const { result, cleanup } = withEffectRoot(() => { const provider = TimelineProvider.create(timelineOpts(null)); vi.spyOn(TimelineProvider, 'require').mockReturnValue(provider); const item = TimelineItemProvider.create(itemOpts(0)); return { item }; }); expect(result.item.effectiveStatus).toBeUndefined(); cleanup(); dom.dispose(); }); });