根因:`emit` 把每次改动都写成 `flattenAccounts(groups)`(写盘形态,空行被丢掉),而 草稿本身就是界面手势的往返载体 ⇒ 刚点出来的占位空行当场蒸发,界面照旧(主人报的 「配置指定适配器主人账号的卡片内的添加账号ID按钮似乎没用」)。`+ 添加平台` 走同一条 通路(空平台组同样被丢),实测同样失效。 改法:分清两种扁平形态 —— · 草稿 `draftAccounts`(编辑期,无损:空行/未选平台的组都留着;不 trim 正在输入的字) · 写盘形态 `accountsForSave`(保存期,只留平台与账号 ID 都非空的条目) 后端契约与 `configs/users/owner.yaml` 格式一个字不改。保存路径写盘前必须收形:半成品 条目(有 id 没平台)会被后端 `Validate()` 直接 400(internal/user/load.go)。 测试:`OwnerAccountsView` 从编辑器里拆出来(无 hook),测试可直接调用它、点它自己的 按钮、把 onChange 收到的真草稿再喂回去重渲染 —— 走的是「手势 → 草稿 → 界面」真往返 (本项目没有 jsdom)。修前该测试红在「点『+ 添加账号 ID』⇒ 界面上真的多出一行 — got 1, want 2」。
481 lines
22 KiB
TypeScript
481 lines
22 KiB
TypeScript
// 主人档案「渠道账号白名单」编辑器的测试(主人 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<T>(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("<select"), "平台是下拉选择,不再是纯文本输入框(消灭拼错平台导致的静默失效)");
|
||
ok(html.includes('value="qq-obv11"') && html.includes("QQ(qq-obv11)"), "下拉里有权威枚举项(含 id,抄不错)");
|
||
ok(html.includes('value="discord"') === false, "存量里没有的平台不凭空塞进下拉");
|
||
ok(html.includes("+ 添加账号 ID"), "每个平台下都有「+ 添加账号 ID」子按钮");
|
||
ok(html.includes("从该平台已配置账号里选:"), "账号 ID 旁给出「从已配置账号里选」的候选");
|
||
ok(html.includes("9616b27a"), "候选里出现该平台已配置的账号 id");
|
||
ok(html.includes("owner.yaml"), "明写保存后仍是 owner.yaml 的既有格式");
|
||
ok(html.includes("有 1 条空白账号 ID"), "空账号 ID 有显式提示(保存时会被忽略,不是静默丢弃)");
|
||
ok(html.includes("该平台下的账号 ID(1 条)"), "分组标题报出该平台的账号条数");
|
||
|
||
// 未注册的平台(没有候选)给出诚实的解释,而不是假装有候选。
|
||
const other = renderToStaticMarkup(
|
||
createElement(OwnerAccountsEditor, {
|
||
accounts: [{ platform: "telegram", accountId: "" }],
|
||
disabled: true,
|
||
platforms: PUBLISHED,
|
||
channels,
|
||
onChange: () => {},
|
||
})
|
||
);
|
||
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<string, unknown>;
|
||
}
|
||
|
||
/** 节点里的可见文字(按钮标签、提示语靠它找)。 */
|
||
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<string, unknown> };
|
||
const props = el.props ?? {};
|
||
if (typeof el.type === "function") {
|
||
walk((el.type as (p: Record<string, unknown>) => 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<T>(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`);
|