Files
Cyrene_v2/frontend/web/src/store/personaMoodSeed.test.ts
T
AskaEth 2aa3233f84
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(persona,web): 人格读侧(thinking 三态 + 角色级 mood)+ 面板表单 + mood 播种
按 design/04 §3.2(thinking 语义,showToUser 三态)+ design/01 §2.1 + 主人两条定论(心情跟角色不跟会话;mood 初值走口径 ①:由角色读端点暴露、前端加载播种)。
- 管理侧 GET /admin/personas 的 PersonaSummary 增 thinking(budgetTokens/effort/mode/showToUser)——**showToUser 保持三态**(未配置 ≠ false)
- 用户侧 GET /personas 增**角色级 mood**(取自 emotion 层该角色当前状态,非从消息推断)→ 前端加载播种 currentMood,消除「刷新后心情徽标短暂为空」
- 前端 Personas 页加 thinking 四控件(预算/effort/模式/showToUser 三态):只做输入校验,**provider 是否真支持由后端 WARN 决定,前端不假装能力**
- 先拆 useAppStore 选择器(原 494/500)到独立模块再做,避免越线
- Docs/api-client 版本号 +1(两处契约 + 三态与角色级 mood 语义)
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + 前端 build 绿 + personaSession/messageSync 逻辑测试通过
2026-10-03 20:40:44 +08:00

144 lines
7.0 KiB
TypeScript

// 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<string, SessionMeta> {
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();