按 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 逻辑测试通过
144 lines
7.0 KiB
TypeScript
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();
|