test(workbench): seeder defers to legacy Home + end-to-end wiring test

Two follow-ups to the per-space-seeds refactor:

1. Transitional check in `seedWorkbenchHomeOn`: if a Space already
   carries an uncustomised "Home" row under a legacy random uuid (a
   D-soft dedup survivor from before the deterministic-id contract
   landed), defer to it instead of inserting a parallel
   `seed-home-${spaceId}` row. Avoids an unnecessary
   create-then-soft-delete roundtrip via the +layout dedup pass and
   the sync churn that would follow. Schicht D-hard will rename
   surviving rows to the deterministic id and this branch can go away.

2. `wiring.test.ts` — integration test for the full chain
   (registry → workbench-home seeder → Dexie). Drives the same
   `runSpaceSeeds` entry point that `setActiveSpace` calls in
   production, so the test fails if any seam in the wiring breaks.
   Includes the rapid back-and-forth Space-switch scenario that the
   original bug ran into; with the deterministic id + get-then-add
   guard, 5×2 activations produce exactly 2 rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 14:34:05 +02:00
parent f71a9377c0
commit 568d79dc16
3 changed files with 217 additions and 5 deletions

View file

@ -0,0 +1,120 @@
/**
* Integration test for the full per-Space-seeds wiring:
*
* active-space applyActiveSpace runSpaceSeeds workbench-home Dexie
*
* Replaces the implicit "trust the unit tests" gap: the unit suites
* cover each piece in isolation, but the wiring (which file imports
* which, which side effects fire when) is what the bug was actually
* about. This test boots the chain against a fixture Dexie and asserts
* the Home row lands.
*
* See docs/plans/workbench-seeding-cleanup.md.
*/
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Dexie, { type Table } from 'dexie';
import type { LocalWorkbenchScene } from '$lib/types/workbench-scenes';
// `LocalWorkbenchScene` doesn't model the runtime-stamped scope fields
// (the creating-hook adds spaceId/authorId/visibility); the wiring
// asserts on spaceId directly, so the fixture row type carries it.
type SceneRow = LocalWorkbenchScene & { spaceId?: string };
// Build a fixture Dexie instance that the mocked `db` will point at.
// Top-level so the vi.mock factory can close over it without TDZ.
let fixtureDb: Dexie & { workbenchScenes: Table<SceneRow, string> };
import { vi } from 'vitest';
vi.mock('../database', () => ({
get db() {
return fixtureDb;
},
}));
// Importing the seeds barrel registers every per-Space seeder. Must
// come AFTER vi.mock — otherwise the seeder's top-level
// `import { db } from '../database'` resolves before the mock is
// installed.
import '$lib/data/seeds';
import { runSpaceSeeds, __resetSpaceSeedsForTests } from '../scope/per-space-seeds';
import { workbenchHomeSeedId } from './workbench-home';
beforeEach(async () => {
fixtureDb = new Dexie(`wiring-test-${crypto.randomUUID()}`) as typeof fixtureDb;
fixtureDb.version(1).stores({ workbenchScenes: 'id, order' });
await fixtureDb.open();
// The seeds barrel was imported once at module load — but the
// registry is module-level and persists across tests in the same
// vitest worker. Re-import via dynamic import would be a
// resolution dance; instead we just re-register manually. The
// real seeder does the same thing.
__resetSpaceSeedsForTests();
const { registerSpaceSeed } = await import('../scope/per-space-seeds');
const { seedWorkbenchHomeOn } = await import('./workbench-home');
registerSpaceSeed('workbench-home', async (spaceId) => {
await seedWorkbenchHomeOn(
fixtureDb.workbenchScenes as unknown as Table<LocalWorkbenchScene, string>,
spaceId
);
});
});
afterEach(async () => {
__resetSpaceSeedsForTests();
fixtureDb.close();
await Dexie.delete(fixtureDb.name);
});
describe('per-Space-seeds wiring (registry → workbench-home → Dexie)', () => {
it('runSpaceSeeds drives the workbench-home seeder end-to-end', async () => {
await runSpaceSeeds('space-personal-abc');
const row = await fixtureDb.workbenchScenes.get(workbenchHomeSeedId('space-personal-abc'));
expect(row).toBeDefined();
expect(row?.name).toBe('Home');
expect(row?.spaceId).toBe('space-personal-abc');
});
it('repeated activations of the same Space stay at one row', async () => {
await runSpaceSeeds('space-personal-abc');
await runSpaceSeeds('space-personal-abc');
await runSpaceSeeds('space-personal-abc');
const all = await fixtureDb.workbenchScenes.toArray();
expect(all).toHaveLength(1);
expect(all[0].id).toBe(workbenchHomeSeedId('space-personal-abc'));
});
it('switching to a different Space seeds a separate Home for that Space', async () => {
await runSpaceSeeds('space-personal-abc');
await runSpaceSeeds('space-brand-xyz');
const all = await fixtureDb.workbenchScenes.toArray();
expect(all.map((r) => r.id).sort()).toEqual([
workbenchHomeSeedId('space-brand-xyz'),
workbenchHomeSeedId('space-personal-abc'),
]);
// And critically: each row carries the spaceId of the seed it
// was created for — no cross-space pollution.
const personal = all.find((r) => r.id === workbenchHomeSeedId('space-personal-abc'));
const brand = all.find((r) => r.id === workbenchHomeSeedId('space-brand-xyz'));
expect(personal?.spaceId).toBe('space-personal-abc');
expect(brand?.spaceId).toBe('space-brand-xyz');
});
it('rapid back-and-forth Space switches do not accumulate duplicates', async () => {
// Simulates the original bug pattern (Brand → Personal → Brand → Personal → …)
// where each switch used to drop another Home into the personal Space.
for (let i = 0; i < 5; i++) {
await runSpaceSeeds('space-personal');
await runSpaceSeeds('space-brand');
}
const all = await fixtureDb.workbenchScenes.toArray();
expect(all).toHaveLength(2);
});
});

View file

@ -103,6 +103,68 @@ describe('seedWorkbenchHomeOn', () => {
expect(row?.openApps).toEqual([{ appId: 'todo' }, { appId: 'mood' }, { appId: 'meditate' }]);
});
it('defers to a legacy random-uuid Home in the same Space (transitional)', async () => {
// Simulates a user coming from the pre-deterministic-id world —
// e.g. a Schicht D-soft dedup survivor with a random UUID and
// the default openApps shape. The new seeder must NOT create a
// second deterministic-id row alongside it, otherwise +layout's
// dedup pass would just churn through soft-deleting one of them.
await db.workbenchScenes.add({
id: 'legacy-random-uuid-1234',
name: 'Home',
order: 0,
openApps: [{ appId: 'todo' }, { appId: 'calendar' }, { appId: 'notes' }],
createdAt: '2026-04-23T08:00:00.000Z',
updatedAt: '2026-04-23T08:00:00.000Z',
spaceId: 'space-abc',
} as LocalWorkbenchScene & { spaceId: string });
const inserted = await seedWorkbenchHomeOn(db.workbenchScenes, 'space-abc');
expect(inserted).toBe(false);
const all = await db.workbenchScenes.toArray();
expect(all).toHaveLength(1);
expect(all[0].id).toBe('legacy-random-uuid-1234');
});
it('still seeds when the existing Home is in a different Space', async () => {
await db.workbenchScenes.add({
id: 'legacy-random-uuid-other-space',
name: 'Home',
order: 0,
openApps: DEFAULT_HOME_APPS,
createdAt: '2026-04-23T08:00:00.000Z',
updatedAt: '2026-04-23T08:00:00.000Z',
spaceId: 'space-different',
} as LocalWorkbenchScene & { spaceId: string });
const inserted = await seedWorkbenchHomeOn(db.workbenchScenes, 'space-abc');
expect(inserted).toBe(true);
const seeded = await db.workbenchScenes.get('seed-home-space-abc');
expect(seeded).toBeDefined();
});
it('still seeds when the existing Home in this Space is customised', async () => {
// A user-customised "Home" (renamed-back, with a description or
// wallpaper) shouldn't block fresh seeds — the dedup heuristic
// already excludes such rows from merging, so the symmetric
// behaviour here is to seed anyway. Schicht D-hard will normalise.
await db.workbenchScenes.add({
id: 'user-custom-home',
name: 'Home',
description: 'My personal layout',
order: 0,
openApps: [{ appId: 'todo' }],
createdAt: '2026-04-23T08:00:00.000Z',
updatedAt: '2026-04-23T08:00:00.000Z',
spaceId: 'space-abc',
} as LocalWorkbenchScene & { spaceId: string });
const inserted = await seedWorkbenchHomeOn(db.workbenchScenes, 'space-abc');
expect(inserted).toBe(true);
});
it('seeds independently per Space (no cross-pollination)', async () => {
await seedWorkbenchHomeOn(db.workbenchScenes, 'space-A');
await seedWorkbenchHomeOn(db.workbenchScenes, 'space-B');

View file

@ -49,17 +49,47 @@ export function workbenchHomeSeedId(spaceId: string): string {
/**
* Pure-ish: takes a Dexie Table reference, ensures a Home scene exists
* for the given Space. Returns true when a new row was inserted, false
* when the row was already there. The creating-hook stamps the actor /
* timestamps fields; this function only owns the deterministic-id +
* default-shape contract.
* when an existing Home is honoured. The creating-hook stamps the
* actor / timestamps fields; this function only owns the
* deterministic-id + default-shape contract.
*
* Two reasons we may skip the insert:
* 1. The deterministic-id row already exists the structural
* idempotency case. Re-running for the same Space is a no-op.
* 2. A legacy random-uuid Home already exists for this Space the
* transitional case for users coming from the pre-deterministic
* world (Schicht D-soft survivors). We defer to the user's
* existing layout. Schicht D-hard will rename such rows to the
* deterministic id and this branch becomes dead code.
*/
export async function seedWorkbenchHomeOn(
table: Table<LocalWorkbenchScene, string>,
spaceId: string
): Promise<boolean> {
const id = workbenchHomeSeedId(spaceId);
const existing = await table.get(id);
if (existing) return false;
if (await table.get(id)) return false;
// Transitional check: a Home scene already exists for this Space
// under a different (legacy random) id. Skipping here avoids an
// unnecessary create-then-soft-delete roundtrip via the dedup pass
// in `+layout.svelte`, which would otherwise pick the customised
// legacy row as the survivor and nuke our just-inserted seed.
// Looks at the same "uncustomised default seed" shape the dedup
// function uses, so a deliberately-named "Home" with description /
// wallpaper / agent / scope still triggers a fresh seed.
const legacy = await table
.filter((r) => {
if (r.deletedAt) return false;
if (r.name !== 'Home') return false;
if ((r as { spaceId?: unknown }).spaceId !== spaceId) return false;
if (r.description) return false;
if (r.wallpaper) return false;
if (r.viewingAsAgentId) return false;
if (r.scopeTagIds && r.scopeTagIds.length > 0) return false;
return true;
})
.first();
if (legacy) return false;
const now = new Date().toISOString();
const row: LocalWorkbenchScene & { spaceId: string } = {