test(libs,adom): harden the runed/tabbable port (cross-browser + wrapper + edge cases)

Ran the full 65-test port suite on WebKit + Firefox (UIX_CROSS_BROWSER=1),
not just chromium. That caught one real robustness gap: Firefox stores
scrollTop as a subpixel value (~59.93 for a 60px scroll), so ScrollState's
exact-equality assertion is relaxed to toBeCloseTo — the port faithfully
passes the browser's own scrollTop through.

New coverage:
- tabbable.ts WRAPPER (getTabbableCandidates / getTabbableEdges /
  getTabbableFrom / getTabbableFromFocusable) — the surface soma's
  focus-scope actually consumes, previously untested.
- resource: AbortController cancellation + onCleanup on rapid source change
  (aborted request must not clobber state with an AbortError).
- FiniteStateMachine: from/to/event/args lifecycle metadata, send-arg
  forwarding, and no-action-for-event (no transition).

All green on chromium + webkit + firefox. (Repo check baseline is ~214 due
to the unrelated in-progress Knob component; 0 errors in these files.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent d4f9474f06
commit 2bd09ac6bb

@ -30,7 +30,9 @@ describe('ScrollState', () => {
el.scrollTop = 60;
el.dispatchEvent(new Event('scroll'));
flushSync();
expect(ss.y).toBe(60);
// Firefox stores scrollTop as a subpixel value (~59.93), so match with tolerance —
// the port faithfully passes the browser's own scrollTop through.
expect(ss.y).toBeCloseTo(60, 0);
expect(ss.directions.bottom).toBe(true);
expect(ss.arrived.top).toBe(false);
});

@ -0,0 +1,99 @@
/**
* Deep tests for the tabbable WRAPPER (`tabbable.ts`) — the surface soma's
* focus-scope actually consumes (getTabbableCandidates / getTabbableEdges /
* getTabbableFrom). Browser project for real layout + the findVisible filter.
*/
import { afterEach, describe, expect, it } from 'vitest';
import {
getTabbableCandidates,
getTabbableEdges,
getTabbableFrom,
getTabbableFromFocusable
} from './tabbable';
let container: HTMLElement;
function mount(html: string): HTMLElement {
container = document.createElement('div');
container.innerHTML = html;
document.body.appendChild(container);
return container;
}
afterEach(() => container?.remove());
const ids = (els: Element[]) => els.map((e) => e.id);
describe('getTabbableCandidates', () => {
it('collects the container tabbables and excludes tabindex -1', () => {
mount(`
<button id="a">a</button>
<input id="b" />
<button id="c" tabindex="-1">c</button>
`);
expect(ids(getTabbableCandidates(container))).toEqual(['a', 'b']);
});
});
describe('getTabbableEdges', () => {
it('returns the first and last visible tabbable', () => {
mount(`
<button id="a">a</button>
<button id="b">b</button>
<button id="c">c</button>
`);
const [first, last] = getTabbableEdges(container);
expect(first?.id).toBe('a');
expect(last?.id).toBe('c');
});
it('skips a hidden edge element', () => {
mount(`
<button id="hidden" style="display:none">x</button>
<button id="a">a</button>
<button id="b">b</button>
`);
const [first] = getTabbableEdges(container);
expect(first?.id).toBe('a');
});
it('returns [undefined, undefined] when there are no tabbables', () => {
mount(`<span>nothing focusable</span>`);
expect(getTabbableEdges(container)).toEqual([undefined, undefined]);
});
});
describe('getTabbableFrom', () => {
it('walks to the next / previous tabbable in document order', () => {
mount(`
<button id="a">a</button>
<button id="b">b</button>
<button id="c">c</button>
`);
const b = container.querySelector<HTMLElement>('#b')!;
expect(getTabbableFrom(b, 'next').id).toBe('c');
expect(getTabbableFrom(b, 'prev').id).toBe('a');
});
it('falls back to focusable traversal for a non-tabbable start node', () => {
mount(`
<button id="a">a</button>
<div id="start" tabindex="-1">start</div>
<button id="b">b</button>
`);
const start = container.querySelector<HTMLElement>('#start')!;
// start is focusable but not tabbable → next tabbable after it is b.
expect(getTabbableFrom(start, 'next').id).toBe('b');
});
});
describe('getTabbableFromFocusable', () => {
it('finds the next tabbable among focusable siblings', () => {
mount(`
<div id="start" tabindex="-1">start</div>
<button id="next">next</button>
`);
const start = container.querySelector<HTMLElement>('#start')!;
expect(getTabbableFromFocusable(start, 'next').id).toBe('next');
});
});

@ -68,4 +68,41 @@ describe('FiniteStateMachine', () => {
stop();
expect(state).toBe('on');
});
it('passes from/to/event/args metadata to lifecycle hooks and forwards send args to actions', () => {
const meta: Array<{ from: string | null; to: string; event: string | null }> = [];
let actionArgs: unknown[] = [];
const stop = $effect.root(() => {
const fsm = new FiniteStateMachine<'a' | 'b', 'go'>('a', {
a: {
go: (...args) => {
actionArgs = args;
return 'b';
}
},
b: {
_enter: (m) => meta.push({ from: m.from, to: m.to, event: m.event })
}
});
flushSync();
fsm.send('go', 1, 'x');
});
stop();
expect(actionArgs).toEqual([1, 'x']);
expect(meta).toEqual([{ from: 'a', to: 'b', event: 'go' }]);
});
it('returns the current state (and does not transition) for an event with no action', () => {
let result = '';
let after = '';
const stop = $effect.root(() => {
const fsm = new FiniteStateMachine<'a' | 'b', 'go' | 'noop'>('a', { a: { go: 'b' }, b: {} });
flushSync();
result = fsm.send('noop');
after = fsm.current;
});
stop();
expect(result).toBe('a');
expect(after).toBe('a');
});
});

@ -77,4 +77,46 @@ describe('resource', () => {
expect(res.current).toBe('manual');
stop();
});
it('aborts the in-flight request and runs onCleanup when the source changes', async () => {
let aborts = 0;
let cleanups = 0;
let res!: ResourceReturn<string>;
let setId!: (v: number) => void;
const stop = $effect.root(() => {
let id = $state(1);
setId = (v) => (id = v);
res = resource(
() => id,
(newId, _prev, { signal, onCleanup }) => {
signal.addEventListener('abort', () => aborts++);
onCleanup(() => cleanups++);
return new Promise<string>((resolve, reject) => {
const t = setTimeout(() => resolve(`data-${newId}`), 40);
signal.addEventListener('abort', () => {
clearTimeout(t);
reject(new DOMException('aborted', 'AbortError'));
});
});
}
);
$effect(() => {
void res.current;
});
flushSync();
});
// Change the source before the first fetch resolves → the first is aborted.
setId(2);
flushSync();
await vi.waitFor(() => {
flushSync();
expect(res.current).toBe('data-2');
});
expect(aborts).toBeGreaterThanOrEqual(1);
expect(cleanups).toBeGreaterThanOrEqual(1);
// The aborted request must not clobber state with an error.
expect(res.error).toBeUndefined();
stop();
});
});

Loading…
Cancel
Save

Powered by TurnKey Linux.