feat: unify owned and competitor work analysis
This commit is contained in:
@@ -1,87 +1,115 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const Module = require('node:module');
|
||||
const test = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const filename = path.join(__dirname, '../src/pages/creator/competitors/index.tsx');
|
||||
|
||||
const pagePath = path.join(__dirname, '../src/pages/creator/competitors/index.tsx');
|
||||
|
||||
function createPage() {
|
||||
let filters = {};
|
||||
const gallery = 'WorksGallery';
|
||||
const element = (type, props) => ({ type, props });
|
||||
const mocks = {
|
||||
react: {
|
||||
useMemo: (factory) => factory(),
|
||||
useState: () => [filters, (update) => { filters = update(filters); }],
|
||||
},
|
||||
'react/jsx-runtime': { jsx: element, jsxs: element },
|
||||
antd: {
|
||||
Card: 'Card', DatePicker: 'DatePicker', Flex: 'Flex', InputNumber: 'InputNumber',
|
||||
Select: 'Select', Space: 'Space', Typography: { Text: 'Text' },
|
||||
},
|
||||
'@/components/creator/WorksGallery': { __esModule: true, default: gallery },
|
||||
function harness({ sourceType = 'competitor', failure = false, response } = {}) {
|
||||
let cursor = 0;
|
||||
const states = [];
|
||||
const effects = [];
|
||||
const dependencies = [];
|
||||
const requests = [];
|
||||
const errors = [];
|
||||
const component = name => Object.assign(props => ({ name, props }), { displayName: name });
|
||||
const React = {
|
||||
createElement(type, props, ...children) { return typeof type === 'function' ? type({ ...props, children }) : { name: type, props: { ...props, children } }; },
|
||||
useState(initial) { const index = cursor++; if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial; return [states[index], next => { states[index] = typeof next === 'function' ? next(states[index]) : next; }]; },
|
||||
useMemo(fn) { return fn(); },
|
||||
useEffect(fn, deps) { const index = cursor++; if (!dependencies[index] || deps.some((value, i) => value !== dependencies[index][i])) { dependencies[index] = deps; effects.push(fn); } },
|
||||
};
|
||||
const compiled = ts.transpileModule(fs.readFileSync(pagePath, 'utf8'), {
|
||||
fileName: pagePath,
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true,
|
||||
inlineSourceMap: true, inlineSources: true,
|
||||
},
|
||||
}).outputText;
|
||||
const loaded = new Module(pagePath, module);
|
||||
loaded.filename = pagePath;
|
||||
loaded.paths = Module._nodeModulePaths(path.dirname(pagePath));
|
||||
loaded.require = (name) => Object.hasOwn(mocks, name) ? mocks[name] : module.require(name);
|
||||
loaded._compile(compiled, pagePath);
|
||||
|
||||
function descendants(node) {
|
||||
if (!node || typeof node !== 'object') return [];
|
||||
return [node, ...[node.props?.children].flat(Infinity).flatMap(descendants)];
|
||||
}
|
||||
return () => {
|
||||
const nodes = descendants(loaded.exports.default());
|
||||
return {
|
||||
gallery: nodes.find((node) => node.type === gallery).props,
|
||||
controls: nodes.filter((node) => node.props.onChange),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function assertCompetitorOnly(props) {
|
||||
assert.equal(props.sourceType, 'competitor', '竞品分析必须排除自有账号作品');
|
||||
assert.equal(props.sourceId, undefined, '竞品分析应包含所有监测账号,而非限定某个账号');
|
||||
}
|
||||
|
||||
test('竞品分析首次加载只查询监测账号作品', () => {
|
||||
const render = createPage();
|
||||
assertCompetitorOnly(render().gallery);
|
||||
});
|
||||
|
||||
test('调整及清空各项筛选后仍只查询监测账号作品', () => {
|
||||
const render = createPage();
|
||||
const date = { format: (pattern) => {
|
||||
assert.equal(pattern, 'YYYY-MM-DD');
|
||||
return '2026-05-18';
|
||||
const DatePicker = component('DatePicker');
|
||||
DatePicker.RangePicker = component('RangePicker');
|
||||
const antd = Object.fromEntries(['Card', 'Flex', 'Form', 'InputNumber', 'Select', 'Tabs', 'Button', 'Alert'].map(name => [name, component(name)]));
|
||||
antd.DatePicker = DatePicker;
|
||||
antd.Form.Item = component('Form.Item');
|
||||
antd.Typography = { Text: component('Text') };
|
||||
const message = { error: value => errors.push(value) };
|
||||
antd.App = { useApp: () => ({ message }) };
|
||||
const context = { exports: {}, console, React, require(name) {
|
||||
if (name === 'react') return { ...React, default: React };
|
||||
if (name === '@/utils/helpers') return { conflictMessage: error => error.message };
|
||||
if (name === 'antd') return antd;
|
||||
if (name === '@ant-design/pro-components') return { PageContainer: component('PageContainer') };
|
||||
if (name === '@/components/creator/WorksGallery') return { __esModule: true, default: component('WorksGallery') };
|
||||
if (name === '@/services/api') return { creatorGet: async endpoint => { requests.push(endpoint); if (failure) throw new Error('账号读取失败'); return response ?? (endpoint.endsWith('/accounts') ? [{ id: 'owned-1', name: '我的账号', platform_account_key: 'uid-1' }] : [{ id: 'competitor-1', nickname: '竞品账号', platform_account_key: 'uid-2' }]); } };
|
||||
throw new Error(`Unexpected dependency: ${name}`);
|
||||
} };
|
||||
const cases = [
|
||||
['最低点赞', 'min_likes', 12, 12],
|
||||
['最低评论', 'min_comments', 3, 3],
|
||||
['最低转发', 'min_shares', 5, 5],
|
||||
['全部状态', 'published_at_status', 'verified', 'verified'],
|
||||
['发布开始日期', 'published_after', date, '2026-05-18T00:00:00Z'],
|
||||
['发布结束日期', 'published_before', date, '2026-05-18T23:59:59Z'],
|
||||
];
|
||||
for (const [placeholder, key, value, expected] of cases) {
|
||||
render().controls.find((control) => control.props.placeholder === placeholder).props.onChange(value);
|
||||
assertCompetitorOnly(render().gallery);
|
||||
assert.equal(render().gallery.extraFilters[key], expected);
|
||||
}
|
||||
for (const [placeholder, key] of cases) {
|
||||
const clearValue = key === 'published_at_status' ? undefined : null;
|
||||
render().controls.find((control) => control.props.placeholder === placeholder).props.onChange(clearValue);
|
||||
assertCompetitorOnly(render().gallery);
|
||||
assert.equal(render().gallery.extraFilters[key], undefined);
|
||||
}
|
||||
const compiled = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { compilerOptions: { jsx: ts.JsxEmit.React, module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true }, fileName: filename }).outputText;
|
||||
vm.runInNewContext(compiled, context, { filename });
|
||||
return { requests, errors, render(panel = true) { cursor = 0; return panel ? context.exports.WorkAnalysisTab({ sourceType }) : context.exports.default(); }, async flush() { const cleanups = effects.splice(0).map(fn => fn()); await new Promise(resolve => setImmediate(resolve)); return cleanups; } };
|
||||
}
|
||||
function nodes(tree, name) {
|
||||
if (!tree || typeof tree !== 'object') return [];
|
||||
if (Array.isArray(tree)) return tree.flatMap(child => nodes(child, name));
|
||||
return [...(tree.name === name ? [tree] : []), ...nodes(tree.props?.children, name), ...nodes(tree.props?.extra, name)];
|
||||
}
|
||||
function control(tree, name, label) { return nodes(tree, name).find(node => node.props['aria-label'] === label); }
|
||||
function plain(value) { return JSON.parse(JSON.stringify(value)); }
|
||||
|
||||
test('作品分析只有两个分开挂载的来源 TAB', () => {
|
||||
const page = harness().render(false);
|
||||
const tabs = nodes(page, 'Tabs')[0];
|
||||
assert.deepEqual(plain(tabs.props.items.map(({ key, label }) => ({ key, label }))), [{ key: 'competitor', label: '竞品账号' }, { key: 'owned', label: '我的账号' }]);
|
||||
assert.equal(tabs.props.destroyOnHidden, false);
|
||||
});
|
||||
|
||||
for (const sourceType of ['competitor', 'owned']) {
|
||||
test(`${sourceType}: 筛选、排序、账号、重置与指标范围`, async () => {
|
||||
const app = harness({ sourceType });
|
||||
let tree = app.render();
|
||||
assert.equal(nodes(tree, 'WorksGallery')[0].props.sourceType, sourceType);
|
||||
assert.deepEqual(plain(nodes(tree, 'WorksGallery')[0].props.extraFilters), { growth_hours: 24, sort_by: 'published_at', sort_order: 'desc' });
|
||||
await app.flush();
|
||||
tree = app.render();
|
||||
assert.deepEqual(app.requests, [sourceType === 'owned' ? '/creator/accounts' : '/creator/competitors']);
|
||||
const account = control(tree, 'Select', '所属账号');
|
||||
assert.equal(account.props.options[0].value, `${sourceType}-1`);
|
||||
account.props.onChange(`${sourceType}-1`);
|
||||
for (const [label, value] of [['最低点赞', 0], ['最低评论', 2], ['最低分享', 3], ['最低收藏', 4], ['最低24h点赞增量', 5]]) control(tree, 'InputNumber', label).props.onChange(value);
|
||||
control(tree, 'Select', '时间核验状态').props.onChange('verified');
|
||||
control(tree, 'Select', '排序指标').props.onChange('likes_growth');
|
||||
control(tree, 'Select', '排序方向').props.onChange('asc');
|
||||
const dates = [{ startOf: () => ({ toISOString: () => '2026-01-01T00:00:00.000Z' }) }, { endOf: () => ({ toISOString: () => '2026-01-03T23:59:59.999Z' }) }];
|
||||
control(tree, 'RangePicker', '发布时间').props.onChange(dates);
|
||||
if (sourceType === 'owned') control(tree, 'InputNumber', '最低观看').props.onChange(0);
|
||||
else assert.equal(control(tree, 'InputNumber', '最低观看'), undefined);
|
||||
tree = app.render();
|
||||
const gallery = nodes(tree, 'WorksGallery')[0];
|
||||
assert.equal(gallery.props.sourceId, `${sourceType}-1`);
|
||||
assert.deepEqual(plain(gallery.props.extraFilters), { growth_hours: 24, sort_by: 'likes_growth', sort_order: 'asc', published_after: '2026-01-01T00:00:00.000Z', published_before: '2026-01-03T23:59:59.999Z', published_at_status: 'verified', min_likes: 0, min_comments: 2, min_shares: 3, min_collect_count: 4, min_likes_growth: 5, ...(sourceType === 'owned' ? { min_play_count: 0 } : {}) });
|
||||
const sorts = control(tree, 'Select', '排序指标').props.options.map(option => option.value);
|
||||
assert.equal(sorts.includes('play_count'), sourceType === 'owned');
|
||||
assert(sorts.includes('collect_count'));
|
||||
nodes(tree, 'Button').find(node => node.props.children.includes('重置')).props.onClick();
|
||||
tree = app.render();
|
||||
assert.equal(nodes(tree, 'WorksGallery')[0].props.sourceId, undefined);
|
||||
assert.deepEqual(plain(nodes(tree, 'WorksGallery')[0].props.extraFilters), { growth_hours: 24, sort_by: 'published_at', sort_order: 'desc' });
|
||||
});
|
||||
}
|
||||
|
||||
test('账号读取失败明确提示,支持重试,不显示为正常空列表', async () => {
|
||||
const app = harness({ failure: true });
|
||||
app.render(); await app.flush();
|
||||
const tree = app.render();
|
||||
assert.deepEqual(app.errors, ['账号读取失败']);
|
||||
const alert = nodes(tree, 'Alert')[0];
|
||||
assert.equal(alert.props.type, 'error');
|
||||
nodes(alert.props.action, 'Button')[0].props.onClick();
|
||||
app.render(); await app.flush();
|
||||
assert.equal(app.requests.length, 2);
|
||||
});
|
||||
|
||||
test('TAB 卸载后忽略账号请求结果', async () => {
|
||||
let resolve;
|
||||
const app = harness({ response: new Promise(done => { resolve = done; }) });
|
||||
app.render();
|
||||
const cleanups = await app.flush();
|
||||
cleanups.forEach(cleanup => cleanup?.());
|
||||
resolve([{ id: 'late', nickname: '卸载后返回的账号', platform_account_key: 'late' }]);
|
||||
await app.flush();
|
||||
assert.equal(control(app.render(), 'Select', '所属账号').props.options.length, 0);
|
||||
assert.equal(app.errors.length, 0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user