469 lines
15 KiB
JavaScript
469 lines
15 KiB
JavaScript
const assert = require("node:assert/strict");
|
||
const fs = require("node:fs");
|
||
const Module = require("node:module");
|
||
const path = require("node:path");
|
||
const test = require("node:test");
|
||
const ts = require("../node_modules/typescript");
|
||
|
||
const target = path.resolve(
|
||
__dirname,
|
||
"../src/pages/creator/comments/index.tsx",
|
||
);
|
||
const originalLoad = Module._load;
|
||
const createElement = (type, props, ...children) => ({
|
||
type,
|
||
props: { ...props, children: children.length === 1 ? children[0] : children },
|
||
});
|
||
const names = [
|
||
"Alert",
|
||
"Button",
|
||
"Card",
|
||
"Flex",
|
||
"Form",
|
||
"Select",
|
||
"Table",
|
||
"Tabs",
|
||
"Typography",
|
||
];
|
||
const components = Object.fromEntries(names.map((name) => [name, name]));
|
||
components.Form = { Item: "Form.Item" };
|
||
components.Typography = { Text: "Text", Link: "Link" };
|
||
const same = (a, b) =>
|
||
a &&
|
||
b &&
|
||
a.length === b.length &&
|
||
a.every((value, index) => Object.is(value, b[index]));
|
||
let harness;
|
||
Module._load = function (request, parent, isMain) {
|
||
if (parent?.filename === target) {
|
||
if (request === "react") return harness.react;
|
||
if (request === "antd") return components;
|
||
if (request === "@ant-design/icons")
|
||
return { ReloadOutlined: "ReloadOutlined" };
|
||
if (request === "@/services/api")
|
||
return {
|
||
getList: ({ resource, ...query }) =>
|
||
harness.listResource(resource, query),
|
||
creatorGet: (...args) => harness.creatorGet(...args),
|
||
};
|
||
if (request === "@/components/creator/WorksGallery")
|
||
return { WorkCover: "WorkCover" };
|
||
if (request === "@/utils/helpers")
|
||
return {
|
||
conflictMessage: (error, fallback) => error?.message || fallback,
|
||
};
|
||
}
|
||
return originalLoad.call(this, request, parent, isMain);
|
||
};
|
||
function load() {
|
||
const source = ts.transpileModule(
|
||
"import React from 'react';\n" + fs.readFileSync(target, "utf8"),
|
||
{
|
||
compilerOptions: {
|
||
module: ts.ModuleKind.CommonJS,
|
||
jsx: ts.JsxEmit.React,
|
||
esModuleInterop: true,
|
||
},
|
||
},
|
||
).outputText;
|
||
const mod = new Module(target, module);
|
||
mod.filename = target;
|
||
mod.paths = module.paths;
|
||
mod._compile(source, target);
|
||
return mod.exports;
|
||
}
|
||
function makeHarness() {
|
||
const state = [];
|
||
const effects = [];
|
||
const pending = [];
|
||
const calls = [];
|
||
const queries = [];
|
||
let cursor = 0;
|
||
const h = {
|
||
state,
|
||
effects,
|
||
calls,
|
||
queries,
|
||
react: {
|
||
createElement,
|
||
useState(initial) {
|
||
const index = cursor++;
|
||
if (!(index in state)) state[index] = initial;
|
||
return [
|
||
state[index],
|
||
(value) => {
|
||
state[index] =
|
||
typeof value === "function" ? value(state[index]) : value;
|
||
},
|
||
];
|
||
},
|
||
useEffect(fn, deps) {
|
||
const index = cursor++;
|
||
if (!same(effects[index]?.deps, deps))
|
||
pending.push(() => {
|
||
effects[index]?.cleanup?.();
|
||
effects[index] = { deps, cleanup: fn() };
|
||
});
|
||
},
|
||
},
|
||
creatorGet: async () => [
|
||
{
|
||
id: "account-1",
|
||
name: "我的账号",
|
||
nickname: "竞品账号",
|
||
platform_account_key: "uid-1",
|
||
},
|
||
],
|
||
listResource: async (resource, query) => {
|
||
calls.push(resource);
|
||
queries.push(query);
|
||
return { data: [], total: 0 };
|
||
},
|
||
render(Component, props) {
|
||
cursor = 0;
|
||
const tree = Component(props);
|
||
while (pending.length) pending.shift()();
|
||
return tree;
|
||
},
|
||
cleanup() {
|
||
effects.forEach((effect) => effect?.cleanup?.());
|
||
},
|
||
};
|
||
harness = h;
|
||
return h;
|
||
}
|
||
const tick = async () => {
|
||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||
};
|
||
function nodes(tree, type) {
|
||
const matches = [];
|
||
function walk(node) {
|
||
if (!node) return;
|
||
if (Array.isArray(node)) {
|
||
node.forEach(walk);
|
||
return;
|
||
}
|
||
if (typeof node !== "object") return;
|
||
if (node.type === type) matches.push(node);
|
||
walk(node.props?.children);
|
||
}
|
||
walk(tree);
|
||
return matches;
|
||
}
|
||
function deferred() {
|
||
let resolve, reject;
|
||
const promise = new Promise((yes, no) => {
|
||
resolve = yes;
|
||
reject = no;
|
||
});
|
||
return { promise, resolve, reject };
|
||
}
|
||
|
||
test("two independently retained tabs and seven relative publication windows", async () => {
|
||
const h = makeHarness();
|
||
const page = load();
|
||
const root = page.default();
|
||
const tabs = nodes(root, "Tabs")[0];
|
||
assert.deepEqual(
|
||
tabs.props.items.map((item) => [item.key, item.label]),
|
||
[
|
||
["owned", "我的作品评论"],
|
||
["competitor", "竞品作品评论"],
|
||
],
|
||
);
|
||
assert.equal(tabs.props.destroyOnHidden, false);
|
||
const props = { sourceType: "owned" };
|
||
h.render(page.CommentAggregationTab, props);
|
||
await tick();
|
||
const tree = h.render(page.CommentAggregationTab, props);
|
||
assert.deepEqual(h.calls, ["creator-comment-aggregation"]);
|
||
assert.deepEqual(h.queries[0], {
|
||
page: 1,
|
||
pageSize: 25,
|
||
filters: { source_type: "owned", source_id: undefined, hours: 24 },
|
||
});
|
||
const select = nodes(tree, "Select").find(
|
||
(node) => node.props["aria-label"] === "评论发布时间",
|
||
);
|
||
assert.deepEqual(
|
||
select.props.options.map((option) => option.value),
|
||
[1, 6, 12, 24, 72, 120, 168],
|
||
);
|
||
const accounts = nodes(tree, "Select").find(
|
||
(node) => node.props["aria-label"] === "所属账号",
|
||
);
|
||
assert.deepEqual(accounts.props.options, [
|
||
{ value: "account-1", label: "我的账号" },
|
||
]);
|
||
h.cleanup();
|
||
});
|
||
|
||
test("account dropdown never uses UID or internal IDs as names in either tab", async () => {
|
||
for (const sourceType of ["owned", "competitor"]) {
|
||
const h = makeHarness();
|
||
const page = load();
|
||
h.creatorGet = async () => [
|
||
{
|
||
id: "account-1",
|
||
name: "我的账号",
|
||
nickname: "竞品账号",
|
||
platform_account_key: "uid-1",
|
||
},
|
||
{
|
||
id: "account-2",
|
||
name: "",
|
||
nickname: "",
|
||
platform_account_key: "uid-2",
|
||
},
|
||
];
|
||
h.render(page.CommentAggregationTab, { sourceType });
|
||
await tick();
|
||
const tree = h.render(page.CommentAggregationTab, { sourceType });
|
||
const select = nodes(tree, "Select").find(
|
||
(node) => node.props["aria-label"] === "所属账号",
|
||
);
|
||
assert.deepEqual(select.props.options, [
|
||
{
|
||
value: "account-1",
|
||
label: sourceType === "owned" ? "我的账号" : "竞品账号",
|
||
},
|
||
{ value: "account-2", label: "未命名账号" },
|
||
]);
|
||
h.cleanup();
|
||
}
|
||
});
|
||
|
||
test("account and time changes reset pagination and preserve page size", async () => {
|
||
const h = makeHarness();
|
||
const page = load();
|
||
const props = { sourceType: "competitor" };
|
||
h.render(page.CommentAggregationTab, props);
|
||
await tick();
|
||
let tree = h.render(page.CommentAggregationTab, props);
|
||
nodes(tree, "Table")[0].props.onChange({ current: 1, pageSize: 50 });
|
||
h.render(page.CommentAggregationTab, props);
|
||
await tick();
|
||
tree = h.render(page.CommentAggregationTab, props);
|
||
nodes(tree, "Table")[0].props.onChange({ current: 3, pageSize: 50 });
|
||
h.render(page.CommentAggregationTab, props);
|
||
await tick();
|
||
tree = h.render(page.CommentAggregationTab, props);
|
||
assert.equal(h.queries.at(-1).page, 3);
|
||
assert.equal(h.queries.at(-1).pageSize, 50);
|
||
nodes(tree, "Select")
|
||
.find((node) => node.props["aria-label"] === "所属账号")
|
||
.props.onChange("account-1");
|
||
h.render(page.CommentAggregationTab, props);
|
||
await tick();
|
||
tree = h.render(page.CommentAggregationTab, props);
|
||
assert.deepEqual(h.queries.at(-1), {
|
||
page: 1,
|
||
pageSize: 50,
|
||
filters: { source_type: "competitor", source_id: "account-1", hours: 24 },
|
||
});
|
||
nodes(tree, "Select")
|
||
.find((node) => node.props["aria-label"] === "评论发布时间")
|
||
.props.onChange(6);
|
||
h.render(page.CommentAggregationTab, props);
|
||
await tick();
|
||
tree = h.render(page.CommentAggregationTab, props);
|
||
assert.equal(h.queries.at(-1).filters.hours, 6);
|
||
assert.equal(h.queries.at(-1).page, 1);
|
||
nodes(tree, "Button")
|
||
.find((node) => node.props.children === "重置")
|
||
.props.onClick();
|
||
h.render(page.CommentAggregationTab, props);
|
||
await tick();
|
||
tree = h.render(page.CommentAggregationTab, props);
|
||
assert.equal(h.queries.at(-1).filters.hours, 24);
|
||
assert.equal(h.queries.at(-1).filters.source_id, undefined);
|
||
nodes(tree, "Table")[0].props.onChange({ current: 2, pageSize: 25 });
|
||
h.render(page.CommentAggregationTab, props);
|
||
await tick();
|
||
assert.equal(h.queries.at(-1).page, 1);
|
||
assert.equal(h.queries.at(-1).pageSize, 25);
|
||
h.cleanup();
|
||
});
|
||
|
||
test("renders commenter, account and work metadata with server pagination", async () => {
|
||
const h = makeHarness();
|
||
const page = load();
|
||
const props = { sourceType: "owned" };
|
||
const item = {
|
||
id: "comment-1",
|
||
content: "评论内容",
|
||
author_name: "读者",
|
||
author_uid: "reader-uid",
|
||
published_at: "2026-09-20T04:00:00Z",
|
||
source_name: "账号",
|
||
source_account_key: "uid-1",
|
||
source_id: "account-1",
|
||
work_title: "作品标题",
|
||
work_id: "work-1",
|
||
work_key: "work-key",
|
||
work_url: "https://www.douyin.com/video/work-key",
|
||
};
|
||
h.listResource = async () => ({ data: [item], total: 101 });
|
||
h.render(page.CommentAggregationTab, props);
|
||
await tick();
|
||
const tree = h.render(page.CommentAggregationTab, props);
|
||
const table = nodes(tree, "Table")[0];
|
||
assert.deepEqual(table.props.dataSource, [item]);
|
||
assert.equal(table.props.pagination.total, 101);
|
||
assert.equal(table.props.pagination.current, 1);
|
||
assert.deepEqual(
|
||
table.props.columns.map((column) => column.title),
|
||
["评论发布时间", "评论者", "评论内容", "所属账号", "对应作品"],
|
||
);
|
||
const account = table.props.columns
|
||
.find((column) => column.title === "所属账号")
|
||
.render(undefined, item);
|
||
assert.equal(nodes(account, "Text")[0].props.children, "账号");
|
||
assert.equal(nodes(account, "Text").length, 1);
|
||
const work = table.props.columns
|
||
.find((column) => column.title === "对应作品")
|
||
.render(undefined, item);
|
||
const link = nodes(work, "Link")[0];
|
||
assert.equal(link.props.href, item.work_url);
|
||
assert.equal(link.props.target, "_blank");
|
||
assert.equal(link.props["aria-label"], "查看作品:作品标题");
|
||
assert.equal(link.props.style.width, 48);
|
||
assert.equal(nodes(work, "Text").length, 0);
|
||
assert.deepEqual(nodes(work, "WorkCover")[0].props, {
|
||
workID: "work-1",
|
||
title: "作品标题",
|
||
children: [],
|
||
});
|
||
const author = table.props.columns
|
||
.find((column) => column.title === "评论者")
|
||
.render(undefined, item);
|
||
assert.equal(nodes(author, "Text")[1].props.children, "UID:reader-uid");
|
||
const empty = {
|
||
...item,
|
||
source_name: "",
|
||
source_account_key: "",
|
||
author_name: "",
|
||
author_uid: "",
|
||
work_title: "",
|
||
work_url: "",
|
||
};
|
||
assert.equal(
|
||
nodes(
|
||
table.props.columns
|
||
.find((column) => column.title === "所属账号")
|
||
.render(undefined, empty),
|
||
"Text",
|
||
)[0].props.children,
|
||
"未命名账号",
|
||
);
|
||
const untitledWork = table.props.columns
|
||
.find((column) => column.title === "对应作品")
|
||
.render(undefined, empty);
|
||
assert.equal(
|
||
nodes(untitledWork, "Link")[0].props.href,
|
||
"https://www.douyin.com/video/work-key",
|
||
);
|
||
assert.equal(
|
||
nodes(untitledWork, "Link")[0].props["aria-label"],
|
||
"查看作品:work-key",
|
||
);
|
||
assert.equal(nodes(untitledWork, "WorkCover")[0].props.title, "作品封面");
|
||
h.cleanup();
|
||
});
|
||
|
||
test("obsolete responses do not replace current filters and refresh retries errors", async () => {
|
||
const h = makeHarness();
|
||
const page = load();
|
||
const props = { sourceType: "owned" };
|
||
const old = deferred();
|
||
const fresh = deferred();
|
||
let count = 0;
|
||
h.listResource = () => (++count === 1 ? old.promise : fresh.promise);
|
||
h.render(page.CommentAggregationTab, props);
|
||
await tick();
|
||
let tree = h.render(page.CommentAggregationTab, props);
|
||
nodes(tree, "Select")
|
||
.find((node) => node.props["aria-label"] === "评论发布时间")
|
||
.props.onChange(1);
|
||
h.render(page.CommentAggregationTab, props);
|
||
fresh.resolve({ data: [{ id: "fresh" }], total: 1 });
|
||
await tick();
|
||
old.resolve({ data: [{ id: "old" }], total: 9 });
|
||
await tick();
|
||
tree = h.render(page.CommentAggregationTab, props);
|
||
assert.deepEqual(nodes(tree, "Table")[0].props.dataSource, [{ id: "fresh" }]);
|
||
assert.equal(nodes(tree, "Table")[0].props.pagination.total, 1);
|
||
const errors = [];
|
||
const previous = console.error;
|
||
console.error = (...args) => errors.push(args);
|
||
try {
|
||
h.listResource = async () => {
|
||
throw new Error("读取失败");
|
||
};
|
||
nodes(tree, "Button")
|
||
.find((node) => node.props.children === "刷新")
|
||
.props.onClick();
|
||
h.render(page.CommentAggregationTab, props);
|
||
await tick();
|
||
tree = h.render(page.CommentAggregationTab, props);
|
||
assert.equal(nodes(tree, "Alert")[0].props.title, "评论读取失败");
|
||
assert.equal(nodes(tree, "Alert")[0].props.description, "读取失败");
|
||
assert.equal(errors.length, 1);
|
||
assert.deepEqual(nodes(tree, "Table")[0].props.dataSource, []);
|
||
h.listResource = async () => ({ data: [{ id: "retried" }], total: 1 });
|
||
nodes(tree, "Button")
|
||
.find((node) => node.props.children === "刷新")
|
||
.props.onClick();
|
||
h.render(page.CommentAggregationTab, props);
|
||
await tick();
|
||
tree = h.render(page.CommentAggregationTab, props);
|
||
assert.equal(nodes(tree, "Alert").length, 0);
|
||
assert.deepEqual(nodes(tree, "Table")[0].props.dataSource, [
|
||
{ id: "retried" },
|
||
]);
|
||
} finally {
|
||
console.error = previous;
|
||
h.cleanup();
|
||
}
|
||
});
|
||
|
||
test("account errors remain visible and disable account filter until retry", async () => {
|
||
const h = makeHarness();
|
||
const page = load();
|
||
const props = { sourceType: "competitor" };
|
||
const previous = console.error;
|
||
console.error = () => {};
|
||
try {
|
||
h.creatorGet = async () => {
|
||
throw new Error("账号接口失败");
|
||
};
|
||
h.render(page.CommentAggregationTab, props);
|
||
await tick();
|
||
let tree = h.render(page.CommentAggregationTab, props);
|
||
assert.equal(nodes(tree, "Alert")[0].props.title, "账号读取失败");
|
||
assert.equal(
|
||
nodes(tree, "Select").find(
|
||
(node) => node.props["aria-label"] === "所属账号",
|
||
).props.disabled,
|
||
true,
|
||
);
|
||
h.creatorGet = async () => [];
|
||
nodes(tree, "Button")
|
||
.find((node) => node.props.children === "刷新")
|
||
.props.onClick();
|
||
h.render(page.CommentAggregationTab, props);
|
||
await tick();
|
||
tree = h.render(page.CommentAggregationTab, props);
|
||
assert.equal(nodes(tree, "Alert").length, 0);
|
||
assert.equal(
|
||
nodes(tree, "Select").find(
|
||
(node) => node.props["aria-label"] === "所属账号",
|
||
).props.disabled,
|
||
false,
|
||
);
|
||
} finally {
|
||
console.error = previous;
|
||
h.cleanup();
|
||
}
|
||
});
|