Files
Cyrene_v2/frontend/web/src/admin/plugin/ProviderOptionLabels.render.test.ts
T
AskaEth c46053e276
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 搜索提供商下拉/多选/表格显示中文名(值仍是 ASCII id)
主人报障:「为什么中文搜索接口……下拉菜单里全给的英文名?你甚至不愿意带个括号
把中文名写上去」。根因不是漏写文案,而是整条链路没有「显示名」的位置:字段
声明只有 optionsFrom,候选动作回的就是 KnownNames() 的纯 id 数组。

- 后端:候选改为对象数组 {value,label};label 表与接入位表同源(键用
  NameSearXNG 等常量),未知 id 原样回。value 逐字等于 KnownNames()。
- 前端:下拉/多选「显示 label、写 value」;表格单元格按 value→label 索引显示,
  查不到回落显示原值。多选从裸输入框改成「候选开关 + 落盘值输入框」。
  解码器本就兼容裸字符串/{value,label}/{id} 三形状 ⇒ 其他插件零影响。

中文名逐字:SearXNG 本地兜底(searxng)/ 智谱 BigModel(zhipu)/
阿里云 IQS 信息查询(aliyun_iqs)/ 博查 Bocha(bocha)/
火山引擎·豆包搜索(volcengine)/ 百度千帆 AI 搜索(baidu_qianfan)。

验收(主会话复跑 + 亲自变异):go build ./... + vet 绿;search 包测试绿;
tsc --noEmit 0 错;新渲染测试 26 checks + 既有 plugin panel 55 / data 90 /
schema values 40 / channel config 70 全绿。我用 sed 把 values.ts 的
option.value 全换成 option.label ⇒ 渲染测试红(「点选把候选项的 id 追加到
降级链末尾」got="bocha, 智谱 BigModel(zhipu)")—— 这正是会把配置文件写坏的
错法;还原后 md5 一致且绿。

⚠️ 契约面变更(我在 Docs/api-client.md 登记):plugin.providers 候选响应形状
字符串数组 → 对象数组;label 仅供显示、value 才是落盘值。
⚠️ 已知边界:viewer 角色看不到 form 区块 ⇒ 表格对 viewer 仍显示裸 id(未做)。
⚠️ 真机浏览器点击未跑(项目无 jsdom)⇒ 建议刷新管理页点一次确认观感。
2026-10-07 10:08:19 +08:00

170 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 搜索提供商「值 / 显示名分离」的真实渲染测试(切片 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<T>(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);
}
/** 把 `<option value="…">文本</option>` 的**文本**取出来 —— 「显示名 ↔ 落盘值」配对的唯一硬证据。
* 属性顺序(React 可能补 `selected`)由 `[^>]*` 容错。 */
function optionText(html: string, value: string): string | null {
const m = new RegExp(`<option[^>]*\\bvalue="${value}"[^>]*>([^<]*)</option>`).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<string, readonly PanelOptionDto[]> = {
"plugin:providers": decodeOptions({
options: [
{ value: "zhipu", label: "智谱 BigModel(zhipu)" },
{ value: "bocha", label: "博查 Bocha(bocha)" },
],
}),
};
/** 后端**老**形状(裸字符串数组)解出来的候选 —— 向后兼容的那一半。 */
const OLD_SHAPE: Record<string, readonly PanelOptionDto[]> = {
"plugin:providers": decodeOptions({ options: ["searxng", "zhipu"] }),
};
function renderForm(options: Record<string, readonly PanelOptionDto[]>): 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 value=\"zhipu\""), "option 的 value 就是落盘的 id(不是 label)");
ok(
optionText(html, "智谱 BigModel(zhipu)") === null,
"中文标签**没有**变成任何 option 的 value(那就是「把 label 写进配置文件」的错法)",
);
ok(!has(html, 'value="智谱'), "整个表单里没有任何控件的值是中文标签(多选开关同理)");
// 多选(降级链):候选项显示中文,写回的是 id。
ok(has(html, ">智谱 BigModel(zhipu)</button>"), "多选候选项显示中文标签");
ok(has(html, 'title="zhipu"'), "多选候选项把落盘的 id 挂在 title 上(看得见、可核对)");
ok(!has(html, 'title="智谱'), "title 里也不是中文名(挂的是真正的值)");
ok(has(html, 'value="bocha"'), "多选下面那行是真正落盘的值(当前降级链 = bocha)");
ok(has(html, "英文 id"), "并如实说明「下面这行是真正落盘的值」");
}
// ── ② 老形状(裸字符串候选):仍然可用,且不渲染空白项 ────────────────────────
{
eq(
JSON.stringify(OLD_SHAPE["plugin:providers"]),
JSON.stringify([
{ value: "searxng", label: "searxng" },
{ value: "zhipu", label: "zhipu" },
]),
"裸字符串候选归一成 {value,label},label 回落成值本身",
);
const html = renderForm(OLD_SHAPE);
eq(optionText(html, "searxng"), "searxng", "老形状下拉照旧可用:没有中文标签就显示值");
ok(!has(html, "<option value=\"\">"), "没有空白项(下拉里绝不出现一条空白候选)");
ok(has(html, 'title="searxng"'), "多选项也照旧(显示值、写回值)");
}
// ── ③ 标签缺失 / 空串:一律回落显示值,绝不显示空白 ───────────────────────────
{
const decoded = decodeOptions({ options: [{ value: "bocha" }, { value: "tavily", label: "" }] });
eq(JSON.stringify(decoded), JSON.stringify([{ value: "bocha", label: "bocha" }, { value: "tavily", label: "tavily" }]), "缺 label / 空 label 都回落成 value");
const html = renderForm({ "plugin:providers": decoded });
eq(optionText(html, "bocha"), "bocha", "缺标签的下拉项显示值本身");
ok(!has(html, "<option></option>"), "不出现「有值但没字」的空 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`);