diff --git a/frontend/web/src/components/Nav.tsx b/frontend/web/src/components/Nav.tsx index 2a9db43..6a45ca6 100644 --- a/frontend/web/src/components/Nav.tsx +++ b/frontend/web/src/components/Nav.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { applyTheme, readTheme, type ThemeMode } from "../lib/theme"; import { createSubSession, getAuthToken } from "../lib/api"; +import { labelFor } from "../lib/enums"; import { useAppStore } from "../store/useAppStore"; import { Icon, type IconName } from "./Icon"; import { Tag } from "./primitives"; @@ -86,7 +87,7 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode {mood ? (
- 心情 · {mood} + 心情 · {labelFor("mood", mood)}
) : null} diff --git a/frontend/web/src/components/TopBar.tsx b/frontend/web/src/components/TopBar.tsx index 4a8a775..92c6cff 100644 --- a/frontend/web/src/components/TopBar.tsx +++ b/frontend/web/src/components/TopBar.tsx @@ -3,6 +3,7 @@ import { useAppStore } from "../store/useAppStore"; import { useModeStore } from "../store/useModeStore"; import { applyTheme, readTheme, type ThemeMode } from "../lib/theme"; import { personaAvatar } from "../lib/persona"; +import { labelFor } from "../lib/enums"; import type { Mode, WsStatus } from "../lib/types"; import { Tag } from "./primitives"; import { Icon, type IconName } from "./Icon"; @@ -67,7 +68,7 @@ export function TopBar({ onMenu }: { onMenu: () => void }): React.ReactNode {
- {mood ? 心情 · {mood} : null} + {mood ? 心情 · {labelFor("mood", mood)} : null} diff --git a/frontend/web/src/components/chat.tsx b/frontend/web/src/components/chat.tsx index 989d4d1..c7be369 100644 --- a/frontend/web/src/components/chat.tsx +++ b/frontend/web/src/components/chat.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import type { ChatMessage, Mode, ToolUsed } from "../lib/types"; import { useAppStore } from "../store/useAppStore"; import { personaAvatar } from "../lib/persona"; +import { labelFor } from "../lib/enums"; import { Tag } from "./primitives"; import { Icon, type IconName } from "./Icon"; import { WakePath } from "./status"; @@ -261,7 +262,7 @@ function AssistantParaBubble({
{full ? : null} {full && message.mood ? ( -
心情 · {message.mood}
+
心情 · {labelFor("mood", message.mood)}
) : null} {full ? : null} {full ? : null} @@ -402,7 +403,7 @@ function AssistantBubble({
{message.mood ? ( -
心情 · {message.mood}
+
心情 · {labelFor("mood", message.mood)}
) : null} diff --git a/frontend/web/src/lib/enums.test.ts b/frontend/web/src/lib/enums.test.ts new file mode 100644 index 0000000..2ea679c --- /dev/null +++ b/frontend/web/src/lib/enums.test.ts @@ -0,0 +1,89 @@ +// Logic test for the Web-side enum mirror (Android collaboration B4, design/07 +// §6): the platform/mood id → label mapping and, above all, the unknown-value +// fallback red line — an unlisted value must come back raw, never dropped and +// never a throw. The project has no frontend test runner, so this is a +// dependency-free plain-node check that drives the real `labelFor` and throws 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/enums.test.ts \ +// --bundle --platform=node --format=esm \ +// --outfile=/tmp/cyrene-enums.test.mjs \ +// && node /tmp/cyrene-enums.test.mjs +// +// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it +// imports nothing from node:*. + +import { labelFor, MOOD_LABELS, PLATFORM_LABELS } from "./enums"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function eq(got: string, want: string, what: string): void { + ok(got === want, `${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`); +} + +// Every known platform id resolves to its default Chinese label. +const platformCases: Array<[string, string]> = [ + ["web", "网页"], + ["kook", "KOOK"], + ["qq-obv11", "QQ"], + ["weixin-ilink", "微信"], + ["telegram", "Telegram"], + ["webhook", "Webhook"], +]; +for (const [id, label] of platformCases) { + eq(labelFor("platform", id), label, `platform ${id} → ${label}`); +} + +// Every known mood id resolves to its default Chinese label. +const moodCases: Array<[string, string]> = [ + ["calm", "平静"], + ["happy", "开心"], + ["excited", "兴奋"], + ["sad", "低落"], + ["angry", "生气"], +]; +for (const [id, label] of moodCases) { + eq(labelFor("mood", id), label, `mood ${id} → ${label}`); +} + +// The mirrored tables are complete and non-empty (a label is always available). +eq(String(Object.keys(PLATFORM_LABELS).length), "6", "platform table size"); +eq(String(Object.keys(MOOD_LABELS).length), "5", "mood table size"); +for (const kind of ["platform", "mood"] as const) { + const source = kind === "platform" ? PLATFORM_LABELS : MOOD_LABELS; + for (const [id, label] of Object.entries(source)) { + eq(labelFor(kind, id), label, `${kind} table entry ${id}`); + ok(label !== "", `${kind} ${id} has a non-empty label`); + } +} + +// RED LINE (display-raw): an unknown value comes back exactly as received. +eq(labelFor("mood", "sleepy"), "sleepy", "unknown mood returns raw"); +eq(labelFor("platform", "discord"), "discord", "unknown platform returns raw"); +eq(labelFor("mood", "此刻心情很好"), "此刻心情很好", "unknown non-ASCII mood returns raw"); +eq(labelFor("platform", "web:v2"), "web:v2", "unknown id-shaped value returns raw"); +eq(labelFor("mood", ""), "", "unknown markup returns raw"); + +// Case variants are not contract ids, so they fall through to display-raw +// without throwing. +eq(labelFor("mood", "CALM"), "CALM", "uppercase mood returns raw"); +eq(labelFor("platform", "Telegram"), "Telegram", "mixed-case platform returns raw"); + +// Blank / absent / whitespace values must not crash. Whitespace is preserved +// verbatim (no trimming — the function only maps, never rewrites). +eq(labelFor("mood", ""), "", "empty mood"); +eq(labelFor("platform", ""), "", "empty platform"); +eq(labelFor("mood", undefined), "", "undefined mood"); +eq(labelFor("mood", null), "", "null mood"); +eq(labelFor("mood", " calm "), " calm ", "padded mood returns raw"); + +console.log(`enums.test.ts OK — ${checks} checks`); diff --git a/frontend/web/src/lib/enums.ts b/frontend/web/src/lib/enums.ts new file mode 100644 index 0000000..69df8a5 --- /dev/null +++ b/frontend/web/src/lib/enums.ts @@ -0,0 +1,55 @@ +/** + * Web-side mirror of the shared client enumerations (Android collaboration B4, + * design/07 §6). + * + * The authority is the backend: `GET /api/v1/meta/enums` publishes + * `{platforms, moods, modes, unknown}` as `{id,label}` pairs. This module mirrors + * the platform and mood tables (the mode union already lives in `types.ts` as + * `MODES`/`Mode` and is deliberately not duplicated here); a client with i18n may + * override any label. + * + * RED LINE — unknown-value fallback (the published `unknown: "display-raw"`): a + * value that is not in the table is returned AS RECEIVED and displayed raw. + * Never drop it, never throw, never substitute a placeholder. The backend adding a + * value is a non-breaking contract change, so a client that lags must degrade + * gracefully instead of losing the badge/label. + */ + +/** The enum tables the Web client renders labels for. */ +export type EnumKind = "platform" | "mood"; + +/** Platform id → default Chinese label. Mirrors the `platforms` table. */ +export const PLATFORM_LABELS: Readonly> = { + web: "网页", + kook: "KOOK", + "qq-obv11": "QQ", + "weixin-ilink": "微信", + telegram: "Telegram", + webhook: "Webhook", +}; + +/** Mood id → default Chinese label. Mirrors the `moods` table. */ +export const MOOD_LABELS: Readonly> = { + calm: "平静", + happy: "开心", + excited: "兴奋", + sad: "低落", + angry: "生气", +}; + +/** The id → label table of one enum kind. */ +function table(kind: EnumKind): Readonly> { + return kind === "platform" ? PLATFORM_LABELS : MOOD_LABELS; +} + +/** + * Resolve `value` to its default display label. A known id yields the table's + * label; anything else — an unknown id, an empty/blank string, a value whose + * case differs from the lowercase contract id — yields `value` unchanged + * (display-raw), and an absent value yields `""`. This never throws and never + * drops a value: see the unknown-value red line above. + */ +export function labelFor(kind: EnumKind, value: string | null | undefined): string { + if (!value) return ""; + return table(kind)[value] ?? value; +}