test(workbench): pure-helper coverage for toScene + pickActiveId

Adds 10 unit tests for the two helpers we hardened this session:

- toScene round-trips the core presentation fields and the two
  previously-dropped extras (viewingAsAgentId, scopeTagIds). Guards
  against the silent field-loss regression fixed in a1baf1053.
- pickActiveId covers empty lists, surviving current, MRU fallback,
  skipping deleted MRU entries, corrupted-JSON MRU payload, and
  non-string entries. Locks down the fallback ladder introduced in
  4e5c3179f so scenes[0] stays a last resort.

Both helpers are now exported from the .svelte.ts store. The test
file mocks `$app/environment.browser=true` and polyfills localStorage
so it runs without jsdom (the web app doesn't bundle jsdom as a test
dep).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-18 16:19:14 +02:00
parent afdbc436e4
commit 120a191bb0
2 changed files with 167 additions and 2 deletions

View file

@ -87,7 +87,10 @@ function bumpMru(id: string) {
}
}
function toScene(local: LocalWorkbenchScene): WorkbenchScene {
/** Exported for unit tests converts a Dexie row to the public shape.
* Regression guard: this previously dropped `viewingAsAgentId` and
* `scopeTagIds`, silently breaking SceneAppBar's agent badge. */
export function toScene(local: LocalWorkbenchScene): WorkbenchScene {
return {
id: local.id,
name: local.name,
@ -119,7 +122,10 @@ async function ensureSeedScene(): Promise<string> {
return id;
}
function pickActiveId(scenes: WorkbenchScene[], current: string | null): string | null {
/** Exported for unit tests resolves the active scene id against the
* available list, falling back to per-device MRU and finally to the
* first sort-ordered scene. */
export function pickActiveId(scenes: WorkbenchScene[], current: string | null): string | null {
if (scenes.length === 0) return null;
const ids = new Set(scenes.map((s) => s.id));
if (current && ids.has(current)) return current;

View file

@ -0,0 +1,159 @@
/**
* Unit tests for the pure helpers in the workbench-scenes store.
*
* Covers the regressions fixed in a1baf1053 (toScene was silently
* dropping viewingAsAgentId + scopeTagIds) and 4e5c3179f (pickActiveId
* now consults the MRU stack instead of always returning scenes[0]).
*
* Only the pure exports are exercised full integration tests against
* fake-indexeddb would also need to drive Svelte 5's $state through
* reactive updates, which isn't wired up for the store suite yet.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
// The store reads localStorage through `$app/environment.browser`, which
// defaults to false under vitest/Node — that would short-circuit the MRU
// fallback path we want to exercise. Pretending we're in the browser and
// handing vi a minimal localStorage polyfill keeps the test environment
// agnostic (no jsdom dependency).
vi.mock('$app/environment', () => ({ browser: true }));
if (typeof globalThis.localStorage === 'undefined') {
const backing = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (k: string) => backing.get(k) ?? null,
setItem: (k: string, v: string) => void backing.set(k, String(v)),
removeItem: (k: string) => void backing.delete(k),
clear: () => backing.clear(),
key: (i: number) => Array.from(backing.keys())[i] ?? null,
get length() {
return backing.size;
},
},
});
}
import { toScene, pickActiveId } from './workbench-scenes.svelte';
import type { LocalWorkbenchScene, WorkbenchScene } from '$lib/types/workbench-scenes';
const MRU_LS_KEY = 'mana:workbench:sceneMru';
function sceneFixture(overrides: Partial<WorkbenchScene> = {}): WorkbenchScene {
return {
id: 'scene-1',
name: 'Home',
description: null,
openApps: [{ appId: 'todo' }],
order: 0,
...overrides,
};
}
function localFixture(overrides: Partial<LocalWorkbenchScene> = {}): LocalWorkbenchScene {
return {
id: 'scene-1',
name: 'Home',
description: null,
openApps: [{ appId: 'todo' }],
order: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
};
}
describe('toScene', () => {
it('copies the core presentation fields', () => {
const local = localFixture({
name: 'Deep Work',
description: 'Focus time',
openApps: [{ appId: 'todo' }, { appId: 'notes', maximized: true }],
order: 3,
});
const out = toScene(local);
expect(out).toMatchObject({
id: 'scene-1',
name: 'Deep Work',
description: 'Focus time',
order: 3,
});
expect(out.openApps).toHaveLength(2);
expect(out.openApps[1]).toEqual({ appId: 'notes', maximized: true });
});
it('preserves viewingAsAgentId and scopeTagIds', () => {
// Regression: these two were silently dropped, which broke the
// agent avatar pill in SceneAppBar and the auto-inferred scope
// in SceneHeader.
const local = localFixture({
viewingAsAgentId: 'agent-42',
scopeTagIds: ['tag-a', 'tag-b'],
});
const out = toScene(local);
expect(out.viewingAsAgentId).toBe('agent-42');
expect(out.scopeTagIds).toEqual(['tag-a', 'tag-b']);
});
it('tolerates absent optional fields', () => {
const local = localFixture({ description: undefined, openApps: undefined });
const out = toScene(local);
expect(out.description).toBeNull();
expect(out.openApps).toEqual([]);
});
});
describe('pickActiveId', () => {
beforeEach(() => {
localStorage.clear();
});
it('returns null when there are no scenes', () => {
expect(pickActiveId([], null)).toBeNull();
expect(pickActiveId([], 'missing')).toBeNull();
});
it('returns current when it is still in the list', () => {
const scenes = [sceneFixture({ id: 'a' }), sceneFixture({ id: 'b', order: 1 })];
expect(pickActiveId(scenes, 'b')).toBe('b');
});
it('falls back to scenes[0] when no current and no MRU', () => {
const scenes = [sceneFixture({ id: 'a' }), sceneFixture({ id: 'b', order: 1 })];
expect(pickActiveId(scenes, null)).toBe('a');
});
it('falls back to the newest MRU entry that still exists', () => {
// MRU stack with the most recent scene first.
localStorage.setItem(MRU_LS_KEY, JSON.stringify(['gone', 'b', 'a']));
const scenes = [sceneFixture({ id: 'a' }), sceneFixture({ id: 'b', order: 1 })];
// Current 'gone' is no longer available → skip; next MRU 'b' is available.
expect(pickActiveId(scenes, 'gone')).toBe('b');
});
it('skips MRU ids that were deleted and uses the next live one', () => {
localStorage.setItem(MRU_LS_KEY, JSON.stringify(['x', 'y', 'c']));
const scenes = [
sceneFixture({ id: 'a' }),
sceneFixture({ id: 'c', order: 1 }),
sceneFixture({ id: 'd', order: 2 }),
];
expect(pickActiveId(scenes, null)).toBe('c');
});
it('falls back to scenes[0] if MRU is corrupted', () => {
localStorage.setItem(MRU_LS_KEY, '{not-json');
const scenes = [sceneFixture({ id: 'a' }), sceneFixture({ id: 'b', order: 1 })];
expect(pickActiveId(scenes, null)).toBe('a');
});
it('ignores non-string entries in the MRU payload', () => {
// Simulate a future schema regression — any non-string entry
// should be dropped silently, not crash the fallback.
localStorage.setItem(MRU_LS_KEY, JSON.stringify(['a', 42, null, 'b']));
const scenes = [sceneFixture({ id: 'b' }), sceneFixture({ id: 'c', order: 1 })];
expect(pickActiveId(scenes, null)).toBe('b');
});
});