// Logic test for the panel's `thinking` form ↔ request mapping (design/01 §2.1, // v0.43 read side). The two rules it must not lose are the tri-state of // `showToUser` (omitted = 未配置 = keep the current behavior; never flattened into // `false`) and the omission rule: a form that configures nothing must leave the // `thinking` key OUT of the body, because the write side reads an omitted object // as "keep the stored block" (an all-default object would CLEAR it). The project // has no frontend test runner and no jsdom, so this is a dependency-free // plain-node check driving the real module (lib/personaThinking.ts) and throwing // on the first failure. // // Run it with the esbuild that already ships with vite (no new dependency, no // package.json change): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/lib/personaThinking.test.ts \ // --bundle --platform=node --format=esm \ // --outfile=/tmp/cyrene-persona-thinking.test.mjs \ // && node /tmp/cyrene-persona-thinking.test.mjs // // `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it // imports nothing from node:*. import { EMPTY_THINKING_DRAFT, THINKING_EFFORTS, thinkingDraftFrom, thinkingDraftToRequest, thinkingValidationError, type PersonaThinking, type ThinkingDraft, } from "./personaThinking"; 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}`); } /* --- 1. Backfill from the read side ----------------------------------------- */ // An absent block (persona configures no thinking) reads as the empty draft. same(thinkingDraftFrom(undefined), EMPTY_THINKING_DRAFT, "absent read block → empty draft"); same(thinkingDraftFrom(null), EMPTY_THINKING_DRAFT, "null read block → empty draft"); // A configured block backfills field by field, including the tri-state arm. const read: PersonaThinking = { budgetTokens: 4096, effort: "high", showToUser: false, mode: "off" }; same( thinkingDraftFrom(read), { budgetTokens: "4096", effort: "high", showToUser: "off", mode: "off" }, "configured block backfills (explicit false stays 关)" ); same( thinkingDraftFrom({ budgetTokens: 0, effort: "", showToUser: null, mode: "" }), EMPTY_THINKING_DRAFT, "an all-unset read block backfills to the empty draft" ); same( thinkingDraftFrom({ budgetTokens: 1024, effort: "", showToUser: null, mode: "" }), { budgetTokens: "1024", effort: "", showToUser: "unset", mode: "" }, "null showToUser backfills as 未配置, not 关" ); // An out-of-list stored value is preserved (never silently blanked). same( thinkingDraftFrom({ budgetTokens: 0, effort: "tiny", showToUser: null, mode: "always" }).effort, "tiny", "an unknown stored effort is preserved for display" ); /* --- 2. Form → request body, incl. tri-state and omission -------------------- */ // The omission rule: nothing configured → no `thinking` key at all on the wire. const untouched = thinkingDraftToRequest(EMPTY_THINKING_DRAFT); ok(untouched === undefined, "an untouched form maps to undefined (omit the block)"); same( JSON.stringify({ name: "昔涟", thinking: untouched }), JSON.stringify({ name: "昔涟" }), "an untouched form leaves no `thinking` key in the request body (省略保留)" ); // …and a whitespace-only budget is still "untouched", not budget 0. ok( thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, budgetTokens: " " }) === undefined, "a whitespace-only budget is treated as unconfigured" ); // The tri-state of showToUser, verbatim on the wire. const unset = thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, budgetTokens: "2048" }); ok(unset !== undefined, "a budget-only form still maps to a request"); ok(!("showToUser" in (unset as object)), "showToUser:未配置 omits the key (stays nil, not false)"); same(unset?.showToUser, undefined, "showToUser:未配置 is undefined, never false"); const off = thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, showToUser: "off" }); same(off, { showToUser: false }, "showToUser:关 maps to the boolean false"); same(JSON.stringify(off), JSON.stringify({ showToUser: false }), "an explicit false survives JSON.stringify"); const on = thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, showToUser: "on" }); same(on, { showToUser: true }, "showToUser:开 maps to the boolean true"); // Every other knob maps only when set. same( thinkingDraftToRequest({ budgetTokens: "4096", effort: "medium", showToUser: "unset", mode: "on" }), { budgetTokens: 4096, effort: "medium", mode: "on" }, "set knobs map, unset ones are omitted" ); same( thinkingDraftToRequest({ budgetTokens: "0", effort: "", showToUser: "unset", mode: "" }), { budgetTokens: 0 }, "an explicit 0 budget is a configured value (0 = 未设 for the backend)" ); /* --- 3. Round-trip: read → form → request ---------------------------------- */ // What the panel sends back for a persona it just loaded must describe the same // stored block, so opening a persona and saving it changes nothing. const cases: PersonaThinking[] = [ { budgetTokens: 4096, effort: "high", showToUser: false, mode: "off" }, { budgetTokens: 2048, effort: "", showToUser: null, mode: "" }, { budgetTokens: 0, effort: "", showToUser: true, mode: "on" }, { budgetTokens: 0, effort: "low", showToUser: null, mode: "default" }, ]; for (const c of cases) { const back = thinkingDraftToRequest(thinkingDraftFrom(c)); // Built in the mapper's own key order (JSON.stringify is order-sensitive here). const want: Record = {}; if (c.budgetTokens) want.budgetTokens = c.budgetTokens; if (c.effort) want.effort = c.effort; if (c.mode) want.mode = c.mode; if (c.showToUser !== null) want.showToUser = c.showToUser; same(back, want, `round-trip ${JSON.stringify(c)}`); } /* --- 4. Input validation (only what the client can know) -------------------- */ same(thinkingValidationError(EMPTY_THINKING_DRAFT), null, "empty draft is valid"); same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "4096" }), null, "4096 is valid"); same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "0" }), null, "0 is valid (non-negative)"); ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "-1" }) !== null, "a negative budget is rejected"); ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "1.5" }) !== null, "a fractional budget is rejected"); ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "4096t" }) !== null, "a non-numeric budget is rejected"); same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, mode: "on" }), null, "mode on is valid"); same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, mode: "default" }), null, "mode default is valid"); ok( thinkingValidationError({ ...EMPTY_THINKING_DRAFT, mode: "always" as ThinkingDraft["mode"] }) !== null, "an unknown mode is rejected" ); for (const e of THINKING_EFFORTS) { same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, effort: e }), null, `effort ${e} is valid`); } ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, effort: "tiny" }) !== null, "an unknown effort is rejected"); // The panel must not claim a capability: no error is raised merely because the // value may be unsupported by the *active* provider (the backend WARNs instead). same( thinkingValidationError({ ...EMPTY_THINKING_DRAFT, effort: "none", mode: "off" }), null, "a provider-dependent combination is not blocked client-side" ); console.log(`personaThinking: ${checks} checks passed`);