// 「点了连接之后,面板到底说了什么」的真实断言(2026-10-07 UI 撒谎事故的回归测试)。 // // 为什么必须存在:旧代码只按「请求成功」判断 —— `adminChannelOpen` 一 resolve 就弹 // 「已连接」,而 ws+client 的首连失败**刻意非致命**(OneBot 实现可能比 Cyrene 后启动), // 于是「后端受理了」被说成了「渠道连上了」。这里不看组件、不靠记忆:既直接驱动 // `createChannelActions`(stub 掉 globalThis.fetch,读它真正写进 notice 的那句话), // 也直接驱动卡片用的纯函数 `channelStatus`。 // // 跑法(与 channelActions.test.ts 同款 esbuild + node,react-dom 的 CJS 需要 require): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/admin/modules/channels/channelConnectNotice.test.ts \ // --bundle --platform=node --format=cjs \ // --outfile=/tmp/cyrene-channel-connect-notice.test.cjs \ // && node /tmp/cyrene-channel-connect-notice.test.cjs // // `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内 // 导入、不 import node:*。 import type { AdminChannelRecord } from "../../../lib/types"; import { channelConnectStateOf, channelStatus } from "./channelStatus"; import { connectDisconnectNotice, createChannelActions } from "./useChannelActions"; let checks = 0; function ok(cond: boolean, what: string): void { checks += 1; if (!cond) throw new Error(`FAILED: ${what}`); } function notOk(cond: boolean, what: string): void { ok(!cond, what); } function eq(got: unknown, want: unknown, what: string): void { checks += 1; if (got !== want) throw new Error(`FAILED: ${what}(got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`); } const REASON = "qq-obv11: dial onebot ws ws://localhost:10301/: connection refused"; // ── ① 纯函数:提示文案只看**响应**,不看请求是否成功 ───────────────────────────── { // 本次事故的正题:请求 200 + connected:false + pending:true ⇒ 绝不是「已连接」。 const pending = connectDisconnectNotice(true, "qq-obv11", { name: "qq-obv11", connected: false, pending: true, reason: REASON, }); notOk(pending.includes("已连接"), `重试中不许说「已连接」,got ${JSON.stringify(pending)}`); ok(pending.includes("正在连接"), `重试中必须说「正在连接」,got ${JSON.stringify(pending)}`); ok(pending.includes(REASON), `重试中必须带上原因,got ${JSON.stringify(pending)}`); // 连上了才是「已连接」。 eq( connectDisconnectNotice(true, "qq-obv11:9616b27a", { name: "qq-obv11", connected: true }), "已连接 qq-obv11:9616b27a", "真连上才说「已连接」(并回显实际驱动的适配器)", ); // server 已就绪、等待对端接入:既不是「已连接」,也不是一句静态的「未连接」。 const ready = connectDisconnectNotice(true, "qq-obv11", { name: "qq-obv11", connected: false, ready: true }); notOk(ready.includes("已连接"), `就绪等接入不许说「已连接」,got ${JSON.stringify(ready)}`); ok(ready.includes("已就绪"), `就绪等接入必须说清楚,got ${JSON.stringify(ready)}`); // 后端没有这条状态面(两个键都缺):如实说「尚未连上」,绝不乐观。 const bare = connectDisconnectNotice(true, "kook", { name: "kook", connected: false }); notOk(bare.includes("已连接"), `连不上不许说「已连接」,got ${JSON.stringify(bare)}`); ok(bare.includes("尚未连上"), `必须如实说尚未连上,got ${JSON.stringify(bare)}`); // 断开同样不乐观:后端报连接仍在,就不能说「已断开」。 eq( connectDisconnectNotice(false, "kook", { name: "kook", connected: false }), "已断开 kook", "真断开才说「已断开」", ); const stuck = connectDisconnectNotice(false, "kook", { name: "kook", connected: true }); notOk(stuck.includes("已断开"), `仍未断开不许说「已断开」,got ${JSON.stringify(stuck)}`); } // ── ② 卡片状态:三态在**真实渲染逻辑**里与「未连接」分开 ──────────────────────── { const pendingRecord: AdminChannelRecord = { name: "qq-obv11", connected: false, pending: true, reason: REASON, }; const pending = channelStatus([pendingRecord], null, pendingRecord); eq(pending.kind, "connecting", "重试中的卡片是 connecting,不是 disconnected"); eq(pending.label, "连接中", "重试中的卡片写「连接中」"); eq(pending.tone, "warn", "重试中用警示色而不是错误色(它不是故障)"); ok(pending.hint !== null && pending.hint.includes(REASON), "重试中的卡片带上原因"); notOk(pending.label.includes("未连接"), "重试中不许画成「未连接」"); const readyRecord: AdminChannelRecord = { name: "qq-obv11", connected: false, ready: true }; const ready = channelStatus([readyRecord], null, readyRecord); eq(ready.kind, "ready", "就绪等接入的卡片是 ready"); eq(ready.label, "等待接入", "就绪等接入的卡片写「等待接入」"); eq(ready.tone, "neutral", "就绪等接入不用错误色"); // 回归:后端没这条状态面的平台逐字不变。 const plain: AdminChannelRecord = { name: "kook", connected: false }; const disconnected = channelStatus([plain], null, plain); eq(disconnected.kind, "disconnected", "没有状态面的平台仍是 disconnected"); eq(disconnected.label, "未连接", "没有状态面的平台仍写「未连接」"); eq(disconnected.tone, "err", "没有状态面的平台仍用错误色"); // 真连上优先:pending 与 connected 同时给出时不许被降级成「连接中」。 const both: AdminChannelRecord = { name: "qq-obv11", connected: true, pending: true }; eq(channelStatus([both], null, both).kind, "connected", "connected=true 优先于 pending"); eq(channelConnectStateOf({}), null, "两个键都缺省 = 没有这条状态面"); eq(channelConnectStateOf({ pending: true }), "pending", "pending 优先"); eq(channelConnectStateOf({ ready: true }), "ready", "ready 单独给出也认"); } // ── ③ 真实请求:createChannelActions 写进 notice 的那句话 ─────────────────────── // 与前两段不同,这里驱动的是面板按钮真正调用的那一层,并用 stub 的 fetch 回一个 // 「请求成功但没连上」的响应 —— 旧代码在这条路径上会说「已连接」。 interface Captured { url: string; method: string; } const captured: Captured[] = []; // 每次请求各取一个响应:三个动作在同一个 tick 里发出,若共用一个可变变量, // 先发的两次会读到第三次的响应(那样断言的就是错的东西)。 const payloadQueue: unknown[] = []; function installStubs(): void { const store = new Map(); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => void store.set(k, v), removeItem: (k: string) => void store.delete(k), }, }); Object.defineProperty(globalThis, "fetch", { configurable: true, value: (url: string, init?: RequestInit) => { captured.push({ url: String(url), method: String(init?.method ?? "GET") }); return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ data: payloadQueue.shift() ?? {} }), } as unknown as Response); }, }); } installStubs(); const notices: string[] = []; const errors: string[] = []; let reloads = 0; const actions = createChannelActions({ log: () => {}, setBusy: () => {}, setError: (e) => { if (e != null) errors.push(e); }, setNotice: (n) => notices.push(n ?? ""), reload: () => { reloads += 1; }, }); // 场景 A:后端回「请求成功但仍在重试」。 payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", accountId: "9616b27a", connected: false, pending: true, reason: REASON, }); actions.toggle("qq-obv11", true, "9616b27a"); // 场景 B:后端回「真连上」。 payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: true }); actions.toggle("qq-obv11", true, "9616b27a"); // 场景 C:断开成功。 payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: false }); actions.toggle("qq-obv11", false, "9616b27a"); setTimeout(() => { const pendingNotice = notices.find((n) => n.includes("正在连接")); ok(pendingNotice !== undefined, `重试中必须提示「正在连接」,got ${JSON.stringify(notices)}`); ok( !notices[0]?.includes("已连接"), `第一个动作(仍在重试)绝不能说「已连接」,got ${JSON.stringify(notices[0])}`, ); ok(notices[0]?.includes(REASON) === true, `重试提示必须带上原因,got ${JSON.stringify(notices[0])}`); ok( notices.some((n) => n.includes("已连接 qq-obv11:9616b27a")), `真连上时必须回显实际驱动的适配器,got ${JSON.stringify(notices)}`, ); ok( notices.some((n) => n.includes("已断开 qq-obv11:9616b27a")), `断开成功要说「已断开」,got ${JSON.stringify(notices)}`, ); eq(errors.length, 0, "这些路径都不该产生错误提示"); eq(reloads, 3, "每次动作后都刷新列表(卡片状态要靠它才不再撒谎)"); eq(captured[0].url, "/api/v1/admin/channels/open", "连接仍打到既有路径"); eq(captured[2].url, "/api/v1/admin/channels/close", "断开仍打到既有路径"); console.log(`channel connect notice test: ${checks} checks passed`); }, 20);