// Logic test for seeding the mood badge from the role catalog (v0.43 read side, // design/01 §2): `GET /personas` reports each role's *role-level* mood, and the // frontend seeds `moods[roleId]` from it on load so a refresh no longer flashes an // empty 心情 badge. The project has no frontend test runner and no jsdom, so this // is a dependency-free plain-node check driving the real seed helper // (store/personaSelectors.ts), the real store and the real WS frame router // (lib/ws.ts), and throwing on the first failure. // // Run it with the esbuild that already ships with vite (no new dependency, no // package.json change); the banner supplies the `window.localStorage` the store's // persist middleware probes, which is simply absent under node: // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/store/personaMoodSeed.test.ts \ // --bundle --platform=node --format=esm \ // --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \ // --outfile=/tmp/cyrene-persona-mood-seed.test.mjs \ // && node /tmp/cyrene-persona-mood-seed.test.mjs // // `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it // imports nothing from node:*. import { dispatchFrame } from "../lib/ws"; import type { PersonaRecord, SessionMeta } from "../lib/types"; import { personaMoodSeed, selectCurrentMood } from "./personaSelectors"; import { useAppStore } from "./useAppStore"; let checks = 0; function ok(cond: boolean, what: string): void { checks += 1; if (!cond) throw new Error(`FAILED: ${what}`); } function same(got: unknown, want: unknown, what: string): void { const g = JSON.stringify(got); const w = JSON.stringify(want); ok(g === w, `${what} — got ${g}, want ${w}`); } const MAIN = "web"; const SUB_A = "web:sub-a"; const SUB_B = "web:sub-b"; /** A catalog shaped like `GET /api/v1/personas` v0.43: 昔涟 has a tracked mood, * 雪 has none (the backend reports ""), and the third role carries no mood field * at all (an older backend). */ const CATALOG: PersonaRecord[] = [ { id: "cyrene", name: "昔涟", character: "cyrene", isDefault: true, mode: "chat", memoryNamespace: "persona.cyrene", mood: "happy" }, { id: "yuki", name: "雪", character: "yuki", isDefault: false, mode: "chat", memoryNamespace: "persona.yuki", mood: "" }, { id: "old", name: "旧端点", character: "old", isDefault: false, mode: "chat", memoryNamespace: "persona.old" }, ]; /** Main session → default role (昔涟); sub-a → 雪; sub-b → default again. */ function sessions(): Record { return { [MAIN]: { id: MAIN, type: "main", createdAt: 1 }, [SUB_A]: { id: SUB_A, type: "sub", createdAt: 2, personaId: "yuki" }, [SUB_B]: { id: SUB_B, type: "sub", createdAt: 3, personaId: "" }, }; } /** 1. The pure seed: role id → role-level mood, and nothing for a role the * backend reported no mood for. */ function testSeedShape(): void { same(personaMoodSeed(CATALOG), { cyrene: "happy" }, "only roles with a mood are seeded"); same(personaMoodSeed([]), {}, "an empty catalog seeds nothing"); same(personaMoodSeed([{ ...CATALOG[0], mood: "" }]), {}, "an empty mood seeds nothing (never a placeholder)"); same(personaMoodSeed([{ ...CATALOG[0], id: "" , mood: "happy" }]), {}, "a role without an id seeds nothing"); // Seeding is keyed by role id, so it can never key onto a session. ok(!Object.keys(personaMoodSeed(CATALOG)).includes(MAIN), "no session id ever becomes a mood bucket"); } /** 2. The refresh scenario this batch exists for: after loading the catalog the * active role's mood is already known — no blank badge while the next WS frame * is awaited. */ function testSeedFillsBadgeOnLoad(): void { useAppStore.setState({ moods: {}, personas: [], sessions: {}, activeSessionId: "" }); same(selectCurrentMood(useAppStore.getState()), null, "before the catalog loads there is no mood"); useAppStore.setState({ personas: CATALOG, sessions: sessions(), activeSessionId: MAIN }); useAppStore.getState().seedMoods(personaMoodSeed(CATALOG)); same(useAppStore.getState().moods, { cyrene: "happy" }, "the catalog's role mood lands in its own bucket"); same(selectCurrentMood(useAppStore.getState()), "happy", "the badge is filled on load (main session → 昔涟)"); } /** 3. The mood is role-level: two sessions of the same role show the same mood, * and switching to another role's session shows that role's own value. */ function testMoodIsRoleLevel(): void { useAppStore.setState({ moods: {}, personas: CATALOG, sessions: sessions(), activeSessionId: MAIN, }); useAppStore.getState().seedMoods(personaMoodSeed(CATALOG)); same(selectCurrentMood(useAppStore.getState()), "happy", "main session (default role 昔涟) shows happy"); useAppStore.setState({ activeSessionId: SUB_B }); same(selectCurrentMood(useAppStore.getState()), "happy", "another session of the same role shows the SAME mood"); useAppStore.setState({ activeSessionId: SUB_A }); same(selectCurrentMood(useAppStore.getState()), null, "a role with no tracked mood shows nothing (no invented placeholder)"); // A live frame for the unseeded role fills it, and the role-scoped keying keeps // the two roles apart. dispatchFrame({ type: "mood", data: { mood: "sad", personaId: "yuki" } }); same(selectCurrentMood(useAppStore.getState()), "sad", "a mood frame updates the active role's badge"); useAppStore.setState({ activeSessionId: MAIN }); same(selectCurrentMood(useAppStore.getState()), "happy", "the other role's badge is untouched"); } /** 4. Seeding merges: it overwrites with the server's truth but never clears a * bucket, so a WS frame that arrived before the catalog loaded survives. */ function testSeedMergesAndNeverClears(): void { useAppStore.setState({ moods: { yuki: "excited" }, personas: [], sessions: {}, activeSessionId: MAIN }); useAppStore.getState().seedMoods(personaMoodSeed(CATALOG)); same(useAppStore.getState().moods, { yuki: "excited", cyrene: "happy" }, "an unmentioned bucket is kept, the seeded one added"); // The server is the truth for a role it does report a mood for. useAppStore.setState({ moods: { cyrene: "angry" } }); useAppStore.getState().seedMoods(personaMoodSeed(CATALOG)); same(useAppStore.getState().moods.cyrene, "happy", "a reported role mood overwrites the stale local value"); // "" / absent (an untracked role) must not wipe a live bucket. useAppStore.setState({ moods: { yuki: "excited" } }); useAppStore.getState().seedMoods(personaMoodSeed(CATALOG)); same(useAppStore.getState().moods.yuki, "excited", "a role the seed skips keeps its bucket"); // An empty seed is a no-op (no needless state churn). const before = useAppStore.getState().moods; useAppStore.getState().seedMoods({}); ok(useAppStore.getState().moods === before, "an empty seed leaves the state object untouched"); } function run(): void { testSeedShape(); testSeedFillsBadgeOnLoad(); testMoodIsRoleLevel(); testSeedMergesAndNeverClears(); console.log(`personaMoodSeed.test: ${checks} checks passed`); } run();