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.
180 lines
6.2 KiB
180 lines
6.2 KiB
// @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<T>(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<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
|
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<HTMLElement | null>(root),
|
|
active: state<number | null>(active),
|
|
live: state(live),
|
|
ariaLabel: state<string | undefined>(undefined)
|
|
};
|
|
}
|
|
|
|
function itemOpts(index: number | undefined, opts?: { status?: 'done' | 'current' | 'upcoming' }) {
|
|
return {
|
|
id: state(`timeline-item-${index ?? 'auto'}`),
|
|
ref: state<HTMLElement | null>(document.createElement('li')),
|
|
index: state<number | undefined>(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<HTMLElement | null>(firstEl) });
|
|
const current = TimelineItemProvider.create({ ...itemOpts(1), ref: state<HTMLElement | null>(currentEl) });
|
|
const last = TimelineItemProvider.create({ ...itemOpts(2), ref: state<HTMLElement | null>(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<HTMLElement | null>(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();
|
|
});
|
|
});
|