feat(web): B4 枚举标签映射 — labelFor 未知值原样显示
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 新增 lib/enums.ts:platform/mood 标签 + labelFor(kind,value),未知值原样返回(display-raw 红线),注释标明权威来源是 GET /api/v1/meta/enums
- 4 处 mood 渲染改用标签(Nav/TopBar/chat×2);未动 types.ts 的 MODES/Mode
- 新增纯逻辑测试 enums.test.ts(47 项,esbuild+node,零新依赖)
- 未动 admin channelFields 的第二份 PLATFORM_LABEL(记入待办)
This commit is contained in:
AskaEth committed 2026-10-03 16:55:22 +08:00
1 parent 22e8398110
commit f42ea1e41c
5 files changed
+151 -4

No files matched your search

+89
View File
@@ -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", "<script>alert(1)</script>"), "<script>alert(1)</script>", "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`);
+55
View File
@@ -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<Record<string, string>> = {
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<Record<string, string>> = {
calm: "平静",
happy: "开心",
excited: "兴奋",
sad: "低落",
angry: "生气",
};
/** The id → label table of one enum kind. */
function table(kind: EnumKind): Readonly<Record<string, string>> {
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;
}