diff --git a/apps/mana/apps/web/src/lib/data/seeds/wiring.test.ts b/apps/mana/apps/web/src/lib/data/seeds/wiring.test.ts new file mode 100644 index 000000000..1283665ba --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/seeds/wiring.test.ts @@ -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 }; + +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, + 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); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/seeds/workbench-home.test.ts b/apps/mana/apps/web/src/lib/data/seeds/workbench-home.test.ts index 4e14a9bfa..a5e907ba6 100644 --- a/apps/mana/apps/web/src/lib/data/seeds/workbench-home.test.ts +++ b/apps/mana/apps/web/src/lib/data/seeds/workbench-home.test.ts @@ -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'); diff --git a/apps/mana/apps/web/src/lib/data/seeds/workbench-home.ts b/apps/mana/apps/web/src/lib/data/seeds/workbench-home.ts index 3fd5e3548..450665d0c 100644 --- a/apps/mana/apps/web/src/lib/data/seeds/workbench-home.ts +++ b/apps/mana/apps/web/src/lib/data/seeds/workbench-home.ts @@ -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, spaceId: string ): Promise { 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 } = {