// 搜索提供商「值 / 显示名分离」的真实渲染测试(切片 LBL-1)。 // // 为什么需要它:主人报障「中文搜索接口在管理后台下拉菜单里全给的英文名」——修好之后最容易 // 走样的两条恰恰是**呈现**与**写回**的分界,而这两条都是「一改就写坏配置文件」的形状: // ① 候选 `{"value":"zhipu","label":"智谱 BigModel(zhipu)"}` ⇒ 下拉/多选里显示中文, // 而 **option 的 value 必须还是英文 id**(value 会被写进 web-search.json,注册表按 // 名字取 provider;写成 label 就是「配置里存了中文名,后端一家都不认识」); // ② 老形状(候选是**裸字符串**)必须仍然可用 —— 别的插件/老声明还在用它,且**绝不能 // 因为取不到标签就渲染成空白项**; // ③ 表格行里的裸 id 要显示成中文标签,**未知 id 原样显示**(不吞、不丢、不空白)。 // // 跑法(与 PluginPanel.render.test.ts 同): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/admin/plugin/ProviderOptionLabels.render.test.ts \ // --bundle --platform=node --format=cjs \ // --outfile=/tmp/cyrene-provider-option-labels.render.test.cjs \ // && node /tmp/cyrene-provider-option-labels.render.test.cjs // // 🔴 渲染类测试必须 `--format=cjs`:`--format=esm` 会整批崩在 bundle 深处。 // `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 // // 范围说明:`PanelTableSection` 的行靠 effect 取(服务端渲染不跑 effect ⇒ 表里没有行), // 所以表格那一档打的是它渲染单元格时用的纯函数(`cellDisplayText` / `optionLabelIndex`), // 而不是 HTML 快照。 import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { splitListValue, toggleListOption } from "../schema/values"; import { PanelFormSection } from "./FormSection"; import { cellDisplayText, optionLabelIndex } from "./tableState"; import { decodeOptions, type PanelOptionDto, type PanelSectionDto } from "./types"; let checks = 0; function ok(cond: boolean, what: string): void { checks += 1; if (!cond) throw new Error(`FAILED: ${what}`); } function eq(got: T, want: T, what: string): void { checks += 1; if (got !== want) throw new Error(`FAILED: ${what}\n got = ${JSON.stringify(got)}\n want = ${JSON.stringify(want)}`); } function has(haystack: string, needle: string): boolean { return haystack.includes(needle); } /** 把 `` 的**文本**取出来 —— 「显示名 ↔ 落盘值」配对的唯一硬证据。 * 属性顺序(React 可能补 `selected`)由 `[^>]*` 容错。 */ function optionText(html: string, value: string): string | null { const m = new RegExp(`]*\\bvalue="${value}"[^>]*>([^<]*)`).exec(html); return m === null ? null : m[1]; } /** web-search 的 `providers` 页表单(与 `plugins/tools/io/search/panel.go` 同形): * provider 单选下拉、fallback 多选(降级链),两者都从 `plugin:providers` 拉候选。 */ const FORM: PanelSectionDto = { type: "form", id: "add", title: "添加 / 修改提供商", fields: [ { key: "provider", label: "提供商", type: "select", optionsFrom: "plugin:providers", required: true }, { key: "fallback", label: "降级链", type: "multiSelect", optionsFrom: "plugin:providers", placeholder: "按顺序依次尝试" }, ], submit: { action: "plugin.saveProvider" }, values: { provider: "zhipu", fallback: "bocha" }, }; /** 后端 `plugin.providers` 动作**新**形状解出来的候选(`{value,label}`)。 */ const NEW_SHAPE: Record = { "plugin:providers": decodeOptions({ options: [ { value: "zhipu", label: "智谱 BigModel(zhipu)" }, { value: "bocha", label: "博查 Bocha(bocha)" }, ], }), }; /** 后端**老**形状(裸字符串数组)解出来的候选 —— 向后兼容的那一半。 */ const OLD_SHAPE: Record = { "plugin:providers": decodeOptions({ options: ["searxng", "zhipu"] }), }; function renderForm(options: Record): string { return renderToStaticMarkup( createElement(PanelFormSection, { pluginId: "web-search", section: FORM, role: "owner", onReload: () => {}, options, optionErrors: {}, }), ); } // ── ① 新形状:显示中文标签,提交/落盘的是英文 id ─────────────────────────────── { const html = renderForm(NEW_SHAPE); eq(optionText(html, "zhipu"), "智谱 BigModel(zhipu)", "候选的 value 是 id,显示名是中文标签"); eq(optionText(html, "bocha"), "博查 Bocha(bocha)", "第二家同样:值 → 显示名配对正确"); ok(has(html, ""), "不出现「有值但没字」的空 option"); } // ── ④ 表格列:把行里的裸值翻成中文标签,未知 id 原样显示 ────────────────────── { const labels = optionLabelIndex(NEW_SHAPE); eq(cellDisplayText("zhipu", labels), "智谱 BigModel(zhipu)", "表格里的 provider 显示中文标签(标签里已括注 id)"); eq(cellDisplayText("tavily", labels), "tavily", "未知 id 原样显示(不吞、不丢)"); eq(cellDisplayText("", labels), "—", "空值仍是占位符,不变成空白单元格"); eq(cellDisplayText(undefined, labels), "—", "缺字段同上"); eq( JSON.stringify([...optionLabelIndex({ a: [{ value: "x", label: " " }], b: [{ value: "y", label: "Y" }] })]), JSON.stringify([["y", "Y"]]), "空白标签不进索引(免得表里出现空白单元格),跨来源合并成一张表", ); } // ── ⑤ 多选写回:写的是 id(不是中文标签) ───────────────────────────────────── { const ZHIPU = { value: "zhipu", label: "智谱 BigModel(zhipu)" }; const BOCHA = { value: "bocha", label: "博查 Bocha(bocha)" }; eq(toggleListOption("bocha", ZHIPU), "bocha, zhipu", "点选把候选项的 id 追加到降级链末尾(不是它的中文标签)"); eq(toggleListOption("zhipu, bocha", ZHIPU), "bocha", "再点一次移出该 id,其余保持次序"); eq(toggleListOption("", BOCHA), "bocha", "空值起手可加入"); eq(JSON.stringify(splitListValue("zhipu,bocha\nsearxng")), JSON.stringify(["zhipu", "bocha", "searxng"]), "逗号 / 中文逗号 / 换行都当分隔符(与提交解析同一处口径)"); } console.log(`provider option labels render test: ${checks} checks passed`);