// 主人档案「渠道账号白名单」编辑器的测试(主人 2026-10-05 要求)。 // // 为什么需要它:此前一行 = `{platform, accountId}`,同一平台多个账号要**重复填平台名**, // 且平台是纯文本输入框 —— 填错(`qq_obv11` / `QQ-OBV11`)会**静默失效**(主人被当陌生人, // 界面无任何提示)。改法:界面**一个平台一条** + 该平台下「+ 添加账号 ID」;平台是权威 // 枚举**下拉**;账号 ID 给出「从该平台已配置账号里选」的候选;**写盘时仍是扁平数组**。 // // 锁这些事: // ① 分组/草稿/写盘形态(纯函数):**草稿无损**(`draftAccounts` 逐行保留,空行/未选平台的组 // 都在 —— 编辑期的中间态不许蒸发),**写盘有损**(`accountsForSave` 只留平台与账号 ID 都 // 非空的条目:半成品条目会让后端 `Validate()` 直接 400,完全空的条目被跳过); // ② 下拉选项 = 权威枚举 + 存量里出现过的平台(未列出的旧值不被下拉吃掉); // ③ 候选账号 = 该平台已配置的渠道账号 id; // ④ 渲染:一个平台一条(两个平台 ⇒ 两块)、有「+ 添加账号 ID」、有候选可点; // ⑤ **手势往返**(主人 2026-10-07 报的缺陷:卡片里「+ 添加账号 ID」似乎没用):点一下必须 // 真的多出一行可输入的账号行;「+ 添加平台」走同一条往返,同样钉住。 // // 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/admin/modules/ownerAccounts.test.ts \ // --bundle --platform=node --format=cjs \ // --outfile=/tmp/cyrene-owner-accounts.test.cjs \ // && node /tmp/cyrene-owner-accounts.test.cjs // // `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、 // 不 import node:*。 import { createElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types"; import { OwnerAccountsEditor, OwnerAccountsView } from "./OwnerAccountsEditor"; import { accountCandidates, accountsForSave, draftAccounts, flattenAccounts, groupAccounts, platformIsConfigured, platformOptions, } from "./ownerAccounts"; let checks = 0; function ok(cond: boolean, what: string): void { checks += 1; if (!cond) throw new Error(`FAILED: ${what}`); } function same(got: T, want: T, what: string): void { ok( JSON.stringify(got) === JSON.stringify(want), `${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}` ); } const PUBLISHED: EnumItem[] = [ { id: "web", label: "网页" }, { id: "kook", label: "KOOK" }, { id: "qq-obv11", label: "QQ" }, { id: "weixin-ilink", label: "微信" }, { id: "telegram", label: "Telegram" }, { id: "webhook", label: "Webhook" }, ]; // ── ① 分组 / 草稿 / 写盘形态(纯函数) ────────────────────────────────────────── { const flat: OwnerAccount[] = [ { platform: "qq-obv11", accountId: "9616b27a" }, { platform: "web", accountId: "web-user" }, { platform: "qq-obv11", accountId: "aaaa1111" }, ]; const groups = groupAccounts(flat); same(groups.length, 2, "两个平台 ⇒ 两块(同一平台的多个账号不重复出现平台)"); same(groups[0], { platform: "qq-obv11", accountIds: ["9616b27a", "aaaa1111"] }, "同平台的账号收进一块"); same(groups[1], { platform: "web", accountIds: ["web-user"] }, "顺序 = 首次出现顺序"); // 草稿(编辑期形态,`draftAccounts`)**无损**:逐行原样回来 —— 空行、还没选平台的组都在。 // 这一条是主人 2026-10-07 那个缺陷的正面表述:中间态不许在往返里蒸发。 same( groupAccounts(draftAccounts(groups)), groups, "组 → 草稿 → 组:原样回来(无损往返)" ); same( draftAccounts([{ platform: "kook", accountIds: ["9616b27a", "", " "] }]), [ { platform: "kook", accountId: "9616b27a" }, { platform: "kook", accountId: "" }, { platform: "kook", accountId: " " }, ], "空行留在草稿里(不 trim、不丢行:编辑期不改写主人正在输入的字)" ); same( groupAccounts(draftAccounts([{ platform: "", accountIds: [""] }])), [{ platform: "", accountIds: [""] }], "「还没选平台」的空组也在草稿里(「+ 添加平台」靠它)" ); // 写盘形态(`flattenAccounts` / `accountsForSave`):分组天然把**同一平台的账号排到相邻位置** // ⇒ 保存后文件的条目序会变成「按平台聚集」。白名单是一个**集合**(命中即 owner,与顺序无关), // 所以这里按集合断言;顺序变化在面板上一眼可见,不是隐藏改动。 const sorted = (list: OwnerAccount[]): OwnerAccount[] => [...list].sort((a, b) => `${a.platform}/${a.accountId}`.localeCompare(`${b.platform}/${b.accountId}`)); same(sorted(flattenAccounts(groups)), sorted(flat), "分组 → 展平 回到同一组白名单条目"); const shape = Object.keys(flattenAccounts(groups)[0]).sort(); same(shape, ["accountId", "platform"], "写盘条目只有 {platform, accountId}(owner.yaml 格式不变)"); // 保存路径(草稿 → 写盘形态):只写填完的条目。 // 草稿里允许有空行,写盘时收掉;半成品条目(缺平台或缺 id)**必须**在这里收掉,否则 // 后端 `Validate()`(internal/user/load.go)对这类条目直接 400,主人会看到「保存失败」。 same( accountsForSave([ { platform: "qq-obv11", accountId: "9616b27a" }, { platform: "qq-obv11", accountId: "" }, { platform: "kook", accountId: "" }, ]), [{ platform: "qq-obv11", accountId: "9616b27a" }], "保存:只写平台与账号 ID 都非空的条目(空行不进 owner.yaml)" ); same( accountsForSave([ { platform: "", accountId: "9616b27a" }, { platform: "kook", accountId: "" }, { platform: "", accountId: "" }, ]), [], "保存:半成品条目一个都不写(写了会被后端 400)" ); same(accountsForSave([]), [], "保存:空白名单写出空数组(不是 null)"); same(sorted(accountsForSave(flat)), sorted(flat), "保存:已生效的条目原样往返(不会被写没)"); same( sorted(accountsForSave(flattenAccounts(groups))), sorted(flat), "保存:写盘形态再收一次仍是同一组(幂等)" ); same( Object.keys(accountsForSave([{ platform: "kook", accountId: "42" }])[0]).sort(), ["accountId", "platform"], "保存:落盘条目仍只有 {platform, accountId}(owner.yaml 格式一个字不改)" ); // 空值:平台或账号 id 任一为空都不写(写了也匹配不上 actor 元数据,是噪声)。 same( flattenAccounts([ { platform: "qq-obv11", accountIds: ["9616b27a", " ", ""] }, { platform: "", accountIds: ["orphan"] }, { platform: "kook", accountIds: [] }, ]), [{ platform: "qq-obv11", accountId: "9616b27a" }], "空白账号 / 空白平台 / 空平台组都不写盘" ); same( flattenAccounts([{ platform: " kook ", accountIds: [" 42 "] }]), [{ platform: "kook", accountId: "42" }], "两侧空白被去掉(避免写出匹配不上的键)" ); } // ── ② 下拉选项:权威枚举 + 存量旧值(display-raw) ─────────────────────────────── { const options = platformOptions(PUBLISHED, ["qq-obv11", "discord"]); ok(options.length === 7, "权威 6 项 + 1 个存量里的未知平台(不重复)"); same(options[0], { id: "web", label: "网页" }, "权威枚举在最前"); same(options[6], { id: "discord", label: "discord" }, "未列出的旧值原样保留(display-raw,不被下拉吃掉)"); ok( platformOptions(undefined, ["qq-obv11"]).length === 1, "取不到枚举(未接线/失败)时仍能列出存量平台,不至于把下拉掏空" ); ok( platformOptions(PUBLISHED, []).some((o) => o.id === "qq-obv11" && o.label === "QQ"), "枚举项用后端给的标签" ); } // ── ③ 候选账号:来自该平台已配置的渠道账号 ────────────────────────────────────── { const channels: AdminChannelRecord[] = [ { name: "qq-obv11", connected: true, accounts: [{ id: "9616b27a", config: {} }, { id: "aaaa1111", config: {} }] }, { name: "kook", connected: false, accounts: [{ id: "", config: {} }] }, ]; same(accountCandidates(channels, "qq-obv11"), ["9616b27a", "aaaa1111"], "候选取该平台已配置账号的 id"); same(accountCandidates(channels, "kook"), [], "空 id 的历史条目不作候选(它没有可用 id)"); same(accountCandidates(channels, "telegram"), [], "未配置的平台没有候选"); same(accountCandidates(null, "qq-obv11"), [], "渠道列表没读到时不编候选"); ok(platformIsConfigured(channels, "qq-obv11"), "已注册平台"); ok(!platformIsConfigured(channels, "telegram"), "未注册平台"); } // ── ④ 渲染:一个平台一条 + 子按钮 + 候选 ──────────────────────────────────────── { const accounts: OwnerAccount[] = [ { platform: "qq-obv11", accountId: "9616b27a" }, { platform: "qq-obv11", accountId: "aaaa1111" }, { platform: "kook", accountId: "" }, ]; const channels: AdminChannelRecord[] = [ { name: "qq-obv11", connected: true, accounts: [{ id: "9616b27a", config: {} }] }, ]; const html = renderToStaticMarkup( createElement(OwnerAccountsEditor, { accounts, disabled: false, platforms: PUBLISHED, channels, onChange: () => {}, }) ); const groupCount = html.split("data-platform=").length - 1; same(groupCount, 2, "界面「一个平台一条」:两个平台 ⇒ 两块(同一平台的账号合在一块)"); ok(html.includes('data-platform="qq-obv11"'), "分组带平台标记,便于核对与测试定位"); ok(html.includes(" {}, }) ); ok(other.includes("该平台未在渠道管理里注册"), "未注册平台说明「账号 ID 只能手填」"); ok(other.includes('disabled=""'), "只读角色下控件被禁用(不假装能改)"); } // ── ⑤ 手势 → 草稿 → 渲染:任何界面手势产生的中间态都不能在往返中蒸发 ───────────── // // 这一节钉的是主人报的那个缺陷:「配置指定适配器主人账号的卡片内的『添加账号ID』按钮似乎没用」。 // 它只发生在「手势 → 草稿 → 再投影回界面」这条往返上:草稿若是**有损**的扁平形态(展平时把空行 // 丢掉),刚点出来的空行当场蒸发、界面照旧 —— 点了等于没点。注意纯函数往返测试 // (`展平(分组(flat)) == flat`)**测不到**它:那个等式把「空行被丢掉」当成恒等,等于把有损性 // 当成了正确性。 // // 本项目没有 jsdom(见 flowTimelineWiring.render.test.ts 的说明)⇒ 这里直接调用**无 hook** 的 // `OwnerAccountsView` 拿到真的元素树,**点真的那个按钮**(调它自己的 `onClick`),把 `onChange` // 收到的**真草稿**再喂回去重渲染 —— 走的正是 `Users.tsx` 的接线(草稿是唯一状态,界面是它的投影)。 /** 元素树里的一个宿主节点(`button`/`input`/`select`/`div`…)。 */ interface HostEl { type: string; props: Record; } /** 节点里的可见文字(按钮标签、提示语靠它找)。 */ function textOf(node: ReactNode): string { if (node === null || node === undefined || typeof node === "boolean") return ""; if (typeof node === "string" || typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(textOf).join(""); return textOf((node as { props?: { children?: ReactNode } }).props?.children); } /** 走一遍元素树,收集宿主节点。函数组件会被**直接调用**(`Button` / `PlatformGroupRow` / * `AccountIdRow` 都是无 hook 的纯呈现)—— 若往视图里塞 hook,这里会以 `Invalid hook call` * 报错,那是提醒你把手势逻辑留在无 hook 的那一层。 */ function hostEls(node: ReactNode): HostEl[] { const out: HostEl[] = []; const walk = (n: ReactNode): void => { if (n === null || n === undefined || typeof n === "boolean") return; if (typeof n === "string" || typeof n === "number") return; if (Array.isArray(n)) { n.forEach(walk); return; } const el = n as { type?: unknown; props?: Record }; const props = el.props ?? {}; if (typeof el.type === "function") { walk((el.type as (p: Record) => ReactNode)(props)); return; } if (typeof el.type === "string") out.push({ type: el.type, props }); walk(props.children as ReactNode); }; walk(node); return out; } /** 必须存在的东西(不存在就报错,而不是把 undefined 一路带下去)。 */ function must(found: T | undefined, what: string): T { checks += 1; if (found === undefined) throw new Error(`FAILED: ${what}`); return found; } /** 界面的替身:草稿存在这里,每次操作都从草稿重新投影(= `Users.tsx` 那条接线)。 */ class Harness { draft: OwnerAccount[]; constructor(accounts: OwnerAccount[]) { this.draft = accounts; } /** 当前这一步的元素树(宿主节点)。 */ tree(): HostEl[] { const groups = groupAccounts(this.draft); return hostEls( createElement(OwnerAccountsView, { groups, options: platformOptions(PUBLISHED, groups.map((g) => g.platform)), channels: null, disabled: false, onChange: (next: OwnerAccount[]) => { this.draft = next; }, }) ); } /** 界面上能看见的字(「提示语说了什么」这类断言靠它)。 */ text(): string { return this.tree() .map((e) => textOf(e.props.children as ReactNode)) .join(" "); } /** 点标签为 `label` 的第 `n` 个按钮(找不到就报错,不静默放过)。 */ click(label: string, n = 0): void { const hits = this.tree().filter( (e) => e.type === "button" && textOf(e.props.children as ReactNode) === label ); const hit = must(hits[n], `界面上第 ${n + 1} 个「${label}」按钮(共找到 ${hits.length} 个)`); (hit.props.onClick as () => void)(); } /** 账号 ID 行数(每行带定位标记)。 */ rows(): number { return this.tree().filter((e) => e.type === "div" && e.props["data-account-row"] !== undefined) .length; } selects(): number { return this.tree().filter((e) => e.type === "select").length; } inputs(): HostEl[] { return this.tree().filter((e) => e.type === "input" && e.props.value !== undefined); } /** 在第 `n` 行输入框里打字(走它自己的 onChange)。 */ type(n: number, value: string): void { const el = must(this.inputs()[n], `第 ${n + 1} 个账号 ID 输入框`); (el.props.onChange as (e: unknown) => void)({ target: { value } }); } /** 在第 `n` 块里选平台(走它自己的 onChange)。 */ pickPlatform(n: number, platform: string): void { const el = must(this.tree().filter((e) => e.type === "select")[n], `第 ${n + 1} 块平台下拉`); (el.props.onChange as (e: unknown) => void)({ target: { value: platform } }); } } { // 起点 = 主人现在的真实样子:qq-obv11 下已有一个账号,他想再加一个。 const ui = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]); same(ui.rows(), 1, "起点:该平台一行账号 ID"); same(ui.inputs().length, 1, "起点:一个账号 ID 输入框"); ui.click("+ 添加账号 ID"); same(ui.rows(), 2, "点「+ 添加账号 ID」⇒ 界面上真的多出一行(空行没在往返里蒸发)"); same( ui.draft, [ { platform: "qq-obv11", accountId: "9616b27a" }, { platform: "qq-obv11", accountId: "" }, ], "新行真的进了草稿(空的、挂在同一平台下)—— 不是「点了没点」" ); same( must(ui.inputs()[1], "新行输入框").props.value, "", "新行是一个可输入的空输入框(接着就能打字)" ); // 在新行里打字 ⇒ 值进草稿、界面仍是两行:这条往返得是**可用**的,不是只能加不能填。 ui.type(1, "aaaa1111"); same( ui.draft, [ { platform: "qq-obv11", accountId: "9616b27a" }, { platform: "qq-obv11", accountId: "aaaa1111" }, ], "在新行里打的字落进草稿" ); same(ui.rows(), 2, "打字后仍是两行(已填的值没在往返里丢)"); ok(!ui.text().includes("有 1 条空白账号 ID"), "两行都填了就不再报空白(提示不自相矛盾)"); // 空行的既有提示语义保留:加了行还没填时,界面明说「保存时会被忽略」,不是静默丢弃。 const hinted = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]); hinted.click("+ 添加账号 ID"); ok( hinted.text().includes("有 1 条空白账号 ID:保存时会被忽略"), "新加的空行有显式提示(保存时会被忽略)" ); } { // 「+ 添加平台」走的是同一条往返(任务 3):点一下必须真的多出一块、块里带一行可填。 const blank = new Harness([]); same(blank.rows(), 0, "起点:一个平台都没有"); blank.click("+ 添加平台"); same( blank.draft, [{ platform: "", accountId: "" }], "点「+ 添加平台」⇒ 草稿里真的多出一个未选平台的空组(不是「点了没点」)" ); same(blank.selects(), 1, "界面上真的多出一块平台(下拉)"); same(blank.rows(), 1, "这块里带一行可填的账号 ID"); blank.pickPlatform(0, "kook"); same(blank.draft, [{ platform: "kook", accountId: "" }], "在块里选平台会落到草稿(块没在往返里蒸发)"); blank.click("+ 添加账号 ID"); same( blank.draft, [ { platform: "kook", accountId: "" }, { platform: "kook", accountId: "" }, ], "选完平台再加一行:两行都挂在 kook 下" ); same(blank.rows(), 2, "界面上两行"); // 已知边界(**扁平草稿的固有性质**,不是本次缺陷):第二块「未选平台」的组会和第一块并成一块 // —— `groupAccounts` 按平台聚合,两个空平台同键。行不会丢,只是同块(有下拉可接着选平台)。 const two = new Harness([]); two.click("+ 添加平台"); two.click("+ 添加平台"); same(two.rows(), 2, "连点两次「+ 添加平台」:两行都在(空平台两块并成一块)"); same(two.selects(), 1, "空平台两块并成一块下拉"); } { // 「删除某平台唯一一行」= 撤掉该平台(平台选择随之消失)。这是**修前就有**的语义,本次不动, // 但在这里显式钉住:日后若被当成新缺陷、或被顺手改掉,这条会先红。 const one = new Harness([{ platform: "kook", accountId: "42" }]); one.click("删除"); same(one.draft, [], "删掉唯一一行 ⇒ 该平台块整体消失(既有语义)"); same(one.rows(), 0, "界面上也不再有这一块"); same(one.selects(), 0, "平台下拉也一并撤掉"); } // ⑤ 收尾:把「手势刚产生的草稿」接到「写盘形态」上。草稿留着空行(上面钉住了),那保存时 // 就必须有人把它收干净 —— 后端 `Validate()`(internal/user/load.go)对「平台空 / 账号 id 空」 // 这种半成品条目**直接 400**(主人看到「保存失败」),只有完全空的条目才是跳过。 { const half = new Harness([]); half.click("+ 添加平台"); half.type(0, "9616b27a"); // 只填了 id,平台还没来得及选 same(half.draft, [{ platform: "", accountId: "9616b27a" }], "草稿里这一行留着(编辑期不丢行)"); same(accountsForSave(half.draft), [], "写盘时收掉半成品条目(不发给后端 ⇒ 不会 400)"); const filled = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]); filled.click("+ 添加账号 ID"); same( accountsForSave(filled.draft), [{ platform: "qq-obv11", accountId: "9616b27a" }], "写盘时收掉空行:owner.yaml 只拿到填完的条目" ); filled.type(1, " aaaa1111 "); // 手滑多打了空格 same(filled.draft[1].accountId, " aaaa1111 ", "编辑期不改写正在输入的字(逐键 trim 会吃掉空格)"); same( accountsForSave(filled.draft), [ { platform: "qq-obv11", accountId: "9616b27a" }, { platform: "qq-obv11", accountId: "aaaa1111" }, ], "写盘时去掉两侧空白(带空格的键永远匹配不上 actor 元数据)" ); } console.log(`owner accounts editor test: ${checks} checks passed`);