mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
f71a9377c0
commit
568d79dc16
3 changed files with 217 additions and 5 deletions
120
apps/mana/apps/web/src/lib/data/seeds/wiring.test.ts
Normal file
120
apps/mana/apps/web/src/lib/data/seeds/wiring.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 } = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue