feat: streamline export field selection
This commit is contained in:
@@ -18,16 +18,166 @@ describe("audience-profile-field-dialog", () => {
|
||||
);
|
||||
|
||||
const fields = Array.from(
|
||||
document.querySelectorAll('[data-audience-profile-field-dialog-field="checkbox"]')
|
||||
document.querySelectorAll(
|
||||
'[data-audience-profile-field-dialog-field="checkbox"]'
|
||||
)
|
||||
) as HTMLInputElement[];
|
||||
expect(fields).toHaveLength(2);
|
||||
expect(fields.every((field) => !field.checked)).toBe(true);
|
||||
expect(
|
||||
document.querySelectorAll(
|
||||
"[data-audience-profile-field-dialog-selected-field]"
|
||||
)
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-audience-profile-field-dialog-selected-empty="text"]'
|
||||
)?.hidden
|
||||
).toBe(false);
|
||||
|
||||
const saveButton = document.querySelector(
|
||||
'[data-audience-profile-field-dialog-save="button"]'
|
||||
) as HTMLButtonElement;
|
||||
saveButton.click();
|
||||
|
||||
clickSave();
|
||||
await expect(result).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps selected fields visible while searching and synchronizes both panes", async () => {
|
||||
const result = promptForAudienceProfileFields(
|
||||
document,
|
||||
[
|
||||
{
|
||||
headers: ["粉丝数"],
|
||||
label: "列表字段"
|
||||
},
|
||||
{
|
||||
headers: ["内容数据-近30天-完播率", "内容数据-近30天-播放量"],
|
||||
label: "内容数据"
|
||||
}
|
||||
],
|
||||
["粉丝数", "内容数据-近30天-播放量"]
|
||||
);
|
||||
|
||||
expect(readSelectedFieldNames()).toEqual([
|
||||
"粉丝数",
|
||||
"内容数据-近30天-播放量"
|
||||
]);
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-audience-profile-field-dialog-selected-count="text"]'
|
||||
)?.textContent
|
||||
).toBe("2 个");
|
||||
|
||||
const search = document.querySelector(
|
||||
'[data-audience-profile-field-dialog-search="input"]'
|
||||
) as HTMLInputElement;
|
||||
search.value = "完播率";
|
||||
search.dispatchEvent(new Event("input"));
|
||||
|
||||
expect(readResultItem("内容数据-近30天-完播率")?.hidden).toBe(false);
|
||||
expect(readResultItem("粉丝数")?.hidden).toBe(true);
|
||||
expect(readSelectedFieldNames()).toEqual([
|
||||
"粉丝数",
|
||||
"内容数据-近30天-播放量"
|
||||
]);
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-audience-profile-field-dialog-result-count="text"]'
|
||||
)?.textContent
|
||||
).toBe("找到 1 个");
|
||||
|
||||
const finishRateInput = readResultInput("内容数据-近30天-完播率");
|
||||
finishRateInput.checked = true;
|
||||
finishRateInput.dispatchEvent(new Event("change"));
|
||||
expect(readSelectedFieldNames()).toEqual([
|
||||
"粉丝数",
|
||||
"内容数据-近30天-完播率",
|
||||
"内容数据-近30天-播放量"
|
||||
]);
|
||||
|
||||
const selectedFansInput = readSelectedInput("粉丝数");
|
||||
selectedFansInput.checked = false;
|
||||
selectedFansInput.dispatchEvent(new Event("change"));
|
||||
expect(readResultInput("粉丝数").checked).toBe(false);
|
||||
expect(readSelectedFieldNames()).toEqual([
|
||||
"内容数据-近30天-完播率",
|
||||
"内容数据-近30天-播放量"
|
||||
]);
|
||||
|
||||
clickSave();
|
||||
await expect(result).resolves.toEqual([
|
||||
"内容数据-近30天-完播率",
|
||||
"内容数据-近30天-播放量"
|
||||
]);
|
||||
});
|
||||
|
||||
test("shows a dedicated empty state when no field matches the keyword", async () => {
|
||||
const result = promptForAudienceProfileFields(
|
||||
document,
|
||||
[{ headers: ["粉丝数"], label: "列表字段" }],
|
||||
undefined
|
||||
);
|
||||
const search = document.querySelector(
|
||||
'[data-audience-profile-field-dialog-search="input"]'
|
||||
) as HTMLInputElement;
|
||||
search.value = "不存在的字段";
|
||||
search.dispatchEvent(new Event("input"));
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-audience-profile-field-dialog-result-empty="text"]'
|
||||
)?.hidden
|
||||
).toBe(false);
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-audience-profile-field-dialog-result-count="text"]'
|
||||
)?.textContent
|
||||
).toBe("找到 0 个");
|
||||
expect(readSelectedFieldNames()).toEqual(["粉丝数"]);
|
||||
|
||||
clickSave();
|
||||
await expect(result).resolves.toEqual(["粉丝数"]);
|
||||
});
|
||||
});
|
||||
|
||||
function clickSave(): void {
|
||||
const saveButton = document.querySelector(
|
||||
'[data-audience-profile-field-dialog-save="button"]'
|
||||
) as HTMLButtonElement;
|
||||
saveButton.click();
|
||||
}
|
||||
|
||||
function readResultItem(field: string): HTMLElement | undefined {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
"[data-audience-profile-field-dialog-result]"
|
||||
)
|
||||
).find(
|
||||
(element) => element.dataset.audienceProfileFieldDialogResult === field
|
||||
);
|
||||
}
|
||||
|
||||
function readResultInput(field: string): HTMLInputElement {
|
||||
const input = Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>(
|
||||
'[data-audience-profile-field-dialog-field="checkbox"]'
|
||||
)
|
||||
).find((element) => element.value === field);
|
||||
if (!input) throw new Error(`Missing result field input: ${field}`);
|
||||
return input;
|
||||
}
|
||||
|
||||
function readSelectedInput(field: string): HTMLInputElement {
|
||||
const input = Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>(
|
||||
'[data-audience-profile-field-dialog-selected-checkbox="checkbox"]'
|
||||
)
|
||||
).find((element) => element.value === field);
|
||||
if (!input) throw new Error(`Missing selected field input: ${field}`);
|
||||
return input;
|
||||
}
|
||||
|
||||
function readSelectedFieldNames(): string[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
"[data-audience-profile-field-dialog-selected-field]"
|
||||
)
|
||||
).map((element) => element.dataset.audienceProfileFieldDialogSelectedField ?? "");
|
||||
}
|
||||
|
||||
@@ -377,7 +377,7 @@ describe("market-content-entry", () => {
|
||||
);
|
||||
expect(audienceProfileFieldButton?.textContent).toBe("选择字段");
|
||||
expect(audienceProfileFieldButton?.title).toBe(
|
||||
"勾选本次CSV需要导出的字段,设置会自动保存"
|
||||
"搜索并勾选本次CSV需要导出的字段,设置会自动保存"
|
||||
);
|
||||
expect(batchSubmitButton?.title).toBe("将当前选中的达人提交到后续业务批次");
|
||||
expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
|
||||
@@ -426,9 +426,6 @@ describe("market-content-entry", () => {
|
||||
const primaryRow = document.querySelector(
|
||||
'[data-plugin-toolbar-row="primary"]'
|
||||
) as HTMLElement | null;
|
||||
const thresholdRow = document.querySelector(
|
||||
'[data-plugin-toolbar-row="thresholds"]'
|
||||
) as HTMLElement | null;
|
||||
const dataGroup = document.querySelector(
|
||||
'[data-plugin-toolbar-group="data"]'
|
||||
) as HTMLElement | null;
|
||||
@@ -441,18 +438,9 @@ describe("market-content-entry", () => {
|
||||
const batchSubmitGroup = document.querySelector(
|
||||
'[data-plugin-toolbar-action-group="batch-submit"]'
|
||||
) as HTMLElement | null;
|
||||
const metricCatalog = document.querySelector(
|
||||
'[data-plugin-metric-catalog="root"]'
|
||||
) as HTMLElement | null;
|
||||
const rulesGroup = document.querySelector(
|
||||
'[data-plugin-metric-rules="root"]'
|
||||
) as HTMLElement | null;
|
||||
const statusText = document.querySelector(
|
||||
'[data-plugin-export-status="text"]'
|
||||
) as HTMLElement | null;
|
||||
const titles = Array.from(
|
||||
document.querySelectorAll("[data-plugin-toolbar-title]")
|
||||
) as HTMLElement[];
|
||||
const exportRangeSelect = document.querySelector(
|
||||
'[data-plugin-export-range="select"]'
|
||||
) as HTMLSelectElement | null;
|
||||
@@ -471,24 +459,10 @@ describe("market-content-entry", () => {
|
||||
const batchSubmitButton = document.querySelector(
|
||||
'[data-plugin-batch-submit="button"]'
|
||||
) as HTMLButtonElement | null;
|
||||
const operators = Array.from(
|
||||
document.querySelectorAll("[data-plugin-metric-filter-operator]")
|
||||
).map((element) => element.textContent);
|
||||
const ruleRows = Array.from(
|
||||
document.querySelectorAll("[data-plugin-metric-filter-rule]")
|
||||
) as HTMLElement[];
|
||||
const thresholdInputs = Array.from(
|
||||
document.querySelectorAll("[data-plugin-metric-filter-threshold]")
|
||||
) as HTMLInputElement[];
|
||||
|
||||
expect(toolbar?.style.flexWrap).toBe("nowrap");
|
||||
expect(panel?.style.flexDirection).toBe("column");
|
||||
expect(panel?.style.alignItems).toBe("center");
|
||||
expect(primaryRow?.style.flexWrap).toBe("nowrap");
|
||||
expect(thresholdRow?.style.flexWrap).toBe("nowrap");
|
||||
expect(thresholdRow?.style.alignItems).toBe("center");
|
||||
expect(panel?.style.overflowX).toBe("visible");
|
||||
expect(panel?.style.overflowY).toBe("visible");
|
||||
expect(panel?.style.overflow).toBe("visible");
|
||||
expect(dataGroup?.parentElement).toBe(primaryRow);
|
||||
expect(
|
||||
document.querySelector('[data-plugin-toolbar-action-group="csv-export"]')
|
||||
@@ -509,31 +483,11 @@ describe("market-content-entry", () => {
|
||||
batchSubmitButton
|
||||
]);
|
||||
expect(statusText?.parentElement).toBe(primaryRow);
|
||||
expect(metricCatalog?.parentElement).toBe(thresholdRow);
|
||||
expect(rulesGroup?.parentElement).toBe(thresholdRow);
|
||||
expect(rulesGroup?.style.flexDirection).toBe("column");
|
||||
expect(primaryRow?.style.justifyContent).toBe("flex-start");
|
||||
expect(thresholdRow?.style.justifyContent).toBe("flex-start");
|
||||
expect(titles.map((element) => element.textContent)).toEqual([
|
||||
"指标筛选"
|
||||
]);
|
||||
expect(titles[0]?.style.background).toBe("rgb(238, 245, 255)");
|
||||
expect(operators).toEqual([]);
|
||||
expect(
|
||||
document.querySelector('[data-plugin-metric-catalog-trigger="button"]')
|
||||
?.textContent
|
||||
).toBe("添加筛选指标");
|
||||
expect(
|
||||
document.querySelector('[data-plugin-metric-selected-count="text"]')
|
||||
?.textContent
|
||||
).toBe("已选 0 项");
|
||||
expect(
|
||||
(document.querySelector(
|
||||
'[data-plugin-metric-catalog-panel="root"]'
|
||||
) as HTMLElement | null)?.hidden
|
||||
).toBe(true);
|
||||
expect(ruleRows).toEqual([]);
|
||||
expect(thresholdInputs).toEqual([]);
|
||||
expect(document.querySelector('[data-plugin-toolbar-row="thresholds"]')).toBeNull();
|
||||
expect(document.querySelector('[data-plugin-metric-catalog]')).toBeNull();
|
||||
expect(document.querySelector('[data-plugin-metric-filter-rule]')).toBeNull();
|
||||
expect(document.body.textContent).not.toContain("指标筛选");
|
||||
expect([
|
||||
audienceProfileExportButton?.textContent,
|
||||
audienceProfileByIdExportButton?.textContent,
|
||||
@@ -1357,237 +1311,6 @@ describe("market-content-entry", () => {
|
||||
expect(customPagesInput?.hidden).toBe(false);
|
||||
});
|
||||
|
||||
test("removes the toolbar resize listener after its root is detached", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const addEventListener = vi.spyOn(window, "addEventListener");
|
||||
const removeEventListener = vi.spyOn(window, "removeEventListener");
|
||||
|
||||
try {
|
||||
const { ensurePluginToolbar, isPluginToolbarMounted } = await import(
|
||||
"../src/content/market/plugin-toolbar"
|
||||
);
|
||||
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers());
|
||||
const resizeHandler = addEventListener.mock.calls.find(
|
||||
([eventName]) => eventName === "resize"
|
||||
)?.[1];
|
||||
|
||||
toolbar.root.remove();
|
||||
|
||||
expect(isPluginToolbarMounted(toolbar.root, document)).toBe(false);
|
||||
expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler);
|
||||
} finally {
|
||||
addEventListener.mockRestore();
|
||||
removeEventListener.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("moves the toolbar resize listener when its root is adopted by another window", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const addEventListener = vi.spyOn(window, "addEventListener");
|
||||
const removeEventListener = vi.spyOn(window, "removeEventListener");
|
||||
const alternateAddEventListener = vi.fn();
|
||||
const alternateRemoveEventListener = vi.fn();
|
||||
const alternateWindow = {
|
||||
addEventListener: alternateAddEventListener,
|
||||
removeEventListener: alternateRemoveEventListener
|
||||
} as unknown as Window;
|
||||
const alternateDocument = {
|
||||
createTreeWalker: document.createTreeWalker.bind(document),
|
||||
defaultView: alternateWindow
|
||||
} as unknown as Document;
|
||||
|
||||
try {
|
||||
const { ensurePluginToolbar, isPluginToolbarMounted } = await import(
|
||||
"../src/content/market/plugin-toolbar"
|
||||
);
|
||||
const handlers = createNoopToolbarHandlers();
|
||||
const toolbar = ensurePluginToolbar(document, handlers);
|
||||
const resizeHandler = addEventListener.mock.calls.find(
|
||||
([eventName]) => eventName === "resize"
|
||||
)?.[1];
|
||||
const originalOwnerDocument = toolbar.root.ownerDocument;
|
||||
|
||||
Object.defineProperty(toolbar.root, "ownerDocument", {
|
||||
configurable: true,
|
||||
value: alternateDocument
|
||||
});
|
||||
const reusedToolbar = ensurePluginToolbar(document, handlers);
|
||||
|
||||
expect(reusedToolbar.root).toBe(toolbar.root);
|
||||
expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler);
|
||||
expect(alternateAddEventListener).toHaveBeenCalledWith("resize", resizeHandler);
|
||||
|
||||
Object.defineProperty(toolbar.root, "ownerDocument", {
|
||||
configurable: true,
|
||||
value: originalOwnerDocument
|
||||
});
|
||||
toolbar.root.remove();
|
||||
isPluginToolbarMounted(toolbar.root, document);
|
||||
} finally {
|
||||
addEventListener.mockRestore();
|
||||
removeEventListener.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("removes the toolbar resize listener when its root has no owner window", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const addEventListener = vi.spyOn(window, "addEventListener");
|
||||
const removeEventListener = vi.spyOn(window, "removeEventListener");
|
||||
let root: HTMLElement | null = null;
|
||||
let originalOwnerDocument: Document | null = null;
|
||||
let isPluginToolbarMounted:
|
||||
| ((toolbarRoot: HTMLElement, toolbarDocument: Document) => boolean)
|
||||
| undefined;
|
||||
|
||||
try {
|
||||
const toolbarModule = await import("../src/content/market/plugin-toolbar");
|
||||
isPluginToolbarMounted = toolbarModule.isPluginToolbarMounted;
|
||||
const handlers = createNoopToolbarHandlers();
|
||||
const toolbar = toolbarModule.ensurePluginToolbar(document, handlers);
|
||||
root = toolbar.root;
|
||||
originalOwnerDocument = root.ownerDocument;
|
||||
const resizeHandler = addEventListener.mock.calls.find(
|
||||
([eventName]) => eventName === "resize"
|
||||
)?.[1];
|
||||
|
||||
Object.defineProperty(root, "ownerDocument", {
|
||||
configurable: true,
|
||||
value: {
|
||||
createTreeWalker: document.createTreeWalker.bind(document),
|
||||
defaultView: null
|
||||
} as unknown as Document
|
||||
});
|
||||
toolbarModule.ensurePluginToolbar(document, handlers);
|
||||
|
||||
expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler);
|
||||
expect(addEventListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
Object.defineProperty(root, "ownerDocument", {
|
||||
configurable: true,
|
||||
value: originalOwnerDocument
|
||||
});
|
||||
toolbarModule.ensurePluginToolbar(document, handlers);
|
||||
const resizeCalls = addEventListener.mock.calls.filter(
|
||||
([eventName]) => eventName === "resize"
|
||||
);
|
||||
expect(resizeCalls).toHaveLength(2);
|
||||
expect(resizeCalls[1]).toEqual(["resize", expect.any(Function)]);
|
||||
|
||||
toolbarModule.ensurePluginToolbar(document, handlers);
|
||||
expect(
|
||||
addEventListener.mock.calls.filter(
|
||||
([eventName]) => eventName === "resize"
|
||||
)
|
||||
).toHaveLength(2);
|
||||
} finally {
|
||||
if (root && originalOwnerDocument) {
|
||||
Object.defineProperty(root, "ownerDocument", {
|
||||
configurable: true,
|
||||
value: originalOwnerDocument
|
||||
});
|
||||
root.remove();
|
||||
isPluginToolbarMounted?.(root, document);
|
||||
}
|
||||
addEventListener.mockRestore();
|
||||
removeEventListener.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("catalog groups numeric fields, supports search, and adds one field rule", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
|
||||
window
|
||||
}));
|
||||
await controller.ready;
|
||||
|
||||
const field = "内容数据-个人视频-近30天-完播率";
|
||||
const catalogPanel = document.querySelector(
|
||||
'[data-plugin-metric-catalog-panel="root"]'
|
||||
) as HTMLElement | null;
|
||||
expect(catalogPanel?.hidden).toBe(true);
|
||||
expect(catalogPanel?.style.zIndex).toBe("100");
|
||||
expect(document.querySelector('[data-plugin-metric-selected-count="text"]')?.textContent).toBe("已选 0 项");
|
||||
|
||||
click('[data-plugin-metric-catalog-trigger="button"]');
|
||||
const groupLabels = Array.from(
|
||||
document.querySelectorAll('[data-plugin-metric-catalog-group] > strong')
|
||||
).map((element) => element.textContent);
|
||||
expect(groupLabels).toEqual(expect.arrayContaining([
|
||||
"列表字段", "看后搜率", "秒思api数据", "内容数据", "效果预估", "观众画像", "粉丝画像", "铁粉画像"
|
||||
]));
|
||||
const search = document.querySelector(
|
||||
'[data-plugin-metric-catalog-search="input"]'
|
||||
) as HTMLInputElement | null;
|
||||
expect(search).not.toBeNull();
|
||||
search!.value = "个人视频-近30天-完播率";
|
||||
search!.dispatchEvent(new Event("input"));
|
||||
expect(findMetricCatalogItem(field)?.hidden).toBe(false);
|
||||
expect(findMetricCatalogItem("商单视频看后搜率")?.hidden).toBe(true);
|
||||
|
||||
clickMetricCatalogAction(field);
|
||||
expect(catalogPanel?.hidden).toBe(true);
|
||||
expect(findMetricRule(field)).not.toBeNull();
|
||||
expect(document.querySelectorAll("[data-plugin-spread-filter]")).toHaveLength(0);
|
||||
expect(findMetricCatalogAction(field)?.disabled).toBe(true);
|
||||
expect(findMetricCatalogAction(field)?.textContent).toBe("已添加");
|
||||
|
||||
clickMetricRuleRemove(field);
|
||||
expect(findMetricRule(field)).toBeNull();
|
||||
expect(findMetricCatalogAction(field)?.disabled).toBe(false);
|
||||
expect(findMetricCatalogAction(field)?.textContent).toBe("添加");
|
||||
});
|
||||
|
||||
test("reads field rules with selectable greater-than-or-equal and less-than-or-equal operators", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const { ensurePluginToolbar, readToolbarSpreadFilter } = await import(
|
||||
"../src/content/market/plugin-toolbar"
|
||||
);
|
||||
const field = "内容数据-个人视频-近30天-完播率";
|
||||
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers(), [
|
||||
{ field, group: "内容数据" }
|
||||
]);
|
||||
|
||||
click('[data-plugin-metric-catalog-trigger="button"]');
|
||||
clickMetricCatalogAction(field);
|
||||
expect(readToolbarSpreadFilter(toolbar)).toEqual({
|
||||
error: `请输入有效的${field}筛选数值`
|
||||
});
|
||||
|
||||
setInputValueForMetric(field, "30");
|
||||
setMetricOperator(field, "lte");
|
||||
expect(readToolbarSpreadFilter(toolbar)).toEqual({
|
||||
filter: {
|
||||
rules: [{ field, operator: "lte", threshold: 30 }]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("busy state disables metric search, catalog actions, and added rule controls", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const { ensurePluginToolbar, setToolbarBusyState } = await import(
|
||||
"../src/content/market/plugin-toolbar"
|
||||
);
|
||||
const field = "内容数据-个人视频-近30天-互动率";
|
||||
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers(), [
|
||||
{ field, group: "内容数据" }
|
||||
]);
|
||||
click('[data-plugin-metric-catalog-trigger="button"]');
|
||||
clickMetricCatalogAction(field);
|
||||
setToolbarBusyState(toolbar, true);
|
||||
|
||||
expect(
|
||||
(document.querySelector('[data-plugin-metric-catalog-trigger="button"]') as HTMLButtonElement | null)?.disabled
|
||||
).toBe(true);
|
||||
expect(
|
||||
(document.querySelector('[data-plugin-metric-catalog-search="input"]') as HTMLInputElement | null)?.disabled
|
||||
).toBe(true);
|
||||
expect(findMetricRuleRemove(field)?.disabled).toBe(true);
|
||||
expect(findMetricRuleOperator(field)?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("audience profile export requires selected creators outside of the all range", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||
@@ -2288,6 +2011,18 @@ describe("market-content-entry", () => {
|
||||
await controller.ready;
|
||||
click('[data-plugin-audience-profile-fields="button"]');
|
||||
|
||||
const searchInput = document.querySelector(
|
||||
'[data-audience-profile-field-dialog-search="input"]'
|
||||
) as HTMLInputElement | null;
|
||||
expect(searchInput).not.toBeNull();
|
||||
searchInput!.value = "看后搜数";
|
||||
searchInput!.dispatchEvent(new Event("input"));
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-audience-profile-field-dialog-result-count="text"]'
|
||||
)?.textContent
|
||||
).toBe("找到 1 个");
|
||||
|
||||
const afterSearchCountInput = document.querySelector(
|
||||
'input[data-audience-profile-field-dialog-field="checkbox"][value="秒思api-看后搜数"]'
|
||||
) as HTMLInputElement | null;
|
||||
@@ -2333,8 +2068,6 @@ describe("market-content-entry", () => {
|
||||
await controller.ready;
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
|
||||
removeDefaultSpreadMetricFilter();
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
await waitForMockCall(submitBatch, 40, 50);
|
||||
|
||||
@@ -2348,76 +2081,6 @@ describe("market-content-entry", () => {
|
||||
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
|
||||
});
|
||||
|
||||
test("batch submit applies selected field rules using their fixed content-data headers", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
||||
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
||||
]);
|
||||
attachMarketListState([
|
||||
{
|
||||
attribute_datas: {
|
||||
id: "spread-a",
|
||||
nickname: "Alpha"
|
||||
},
|
||||
star_id: "a"
|
||||
},
|
||||
{
|
||||
attribute_datas: {
|
||||
id: "spread-b",
|
||||
nickname: "Beta"
|
||||
},
|
||||
star_id: "b"
|
||||
}
|
||||
]);
|
||||
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||
const loadSpreadMetrics = vi.fn(async (spreadAuthorId: string) => {
|
||||
return {
|
||||
"内容数据-个人视频-近30天-互动率":
|
||||
spreadAuthorId === "spread-a" ? "6%" : "4%",
|
||||
"内容数据-个人视频-近30天-完播率":
|
||||
spreadAuthorId === "spread-a" ? "35%" : "20%"
|
||||
};
|
||||
});
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
getAuthState: async () => ({
|
||||
isAuthenticated: true,
|
||||
resource: "https://talent-search.intelligrow.cn",
|
||||
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
|
||||
}),
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
loadSpreadMetrics,
|
||||
promptBatchName: () => "筛选批次",
|
||||
submitBatch,
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
const finishField = "内容数据-个人视频-近30天-完播率";
|
||||
const interactionField = "内容数据-个人视频-近30天-互动率";
|
||||
click('[data-plugin-metric-catalog-trigger="button"]');
|
||||
clickMetricCatalogAction(finishField);
|
||||
click('[data-plugin-metric-catalog-trigger="button"]');
|
||||
clickMetricCatalogAction(interactionField);
|
||||
setInputValueForMetric(finishField, "30");
|
||||
setInputValueForMetric(interactionField, "5");
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
await waitForMockCall(submitBatch, 80, 50);
|
||||
|
||||
expect(submitBatch.mock.calls[0]?.[0].authors).toEqual([
|
||||
expect.objectContaining({
|
||||
authorId: "a"
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
test("opens a custom batch name dialog before submitting", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||
@@ -2439,7 +2102,6 @@ describe("market-content-entry", () => {
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
removeDefaultSpreadMetricFilter();
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
|
||||
expect(submitBatch).not.toHaveBeenCalled();
|
||||
@@ -2483,7 +2145,6 @@ describe("market-content-entry", () => {
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
removeDefaultSpreadMetricFilter();
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
click('[data-plugin-batch-name-confirm="button"]');
|
||||
await flush();
|
||||
@@ -2518,7 +2179,6 @@ describe("market-content-entry", () => {
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
removeDefaultSpreadMetricFilter();
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
click('[data-plugin-batch-name-cancel="button"]');
|
||||
await flush();
|
||||
@@ -2559,8 +2219,6 @@ describe("market-content-entry", () => {
|
||||
clickSelectionCheckboxForAuthor("222");
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
|
||||
removeDefaultSpreadMetricFilter();
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
await waitForMockCall(submitBatch, 40, 50);
|
||||
|
||||
@@ -2646,8 +2304,6 @@ describe("market-content-entry", () => {
|
||||
|
||||
await controller.ready;
|
||||
clickSelectionCheckboxForAuthor("111");
|
||||
|
||||
removeDefaultSpreadMetricFilter();
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
if (
|
||||
@@ -2766,8 +2422,6 @@ describe("market-content-entry", () => {
|
||||
|
||||
const rowSelectionCheckbox = readSelectionCheckboxForAuthor("111");
|
||||
rowSelectionCheckbox.checked = true;
|
||||
|
||||
removeDefaultSpreadMetricFilter();
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
if (
|
||||
@@ -2848,8 +2502,6 @@ describe("market-content-entry", () => {
|
||||
await flushWithTimers();
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
|
||||
removeDefaultSpreadMetricFilter();
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
await waitForMockCall(submitBatch, 40, 50);
|
||||
|
||||
@@ -2906,7 +2558,6 @@ describe("market-content-entry", () => {
|
||||
clickSelectionCheckboxForAuthor("111");
|
||||
setSelectValue('[data-plugin-export-range="select"]', "all");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
removeDefaultSpreadMetricFilter();
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
|
||||
await waitForCondition(() =>
|
||||
@@ -2994,8 +2645,6 @@ describe("market-content-entry", () => {
|
||||
|
||||
setSelectValue('[data-plugin-export-range="select"]', "all");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
|
||||
removeDefaultSpreadMetricFilter();
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
if (
|
||||
@@ -3054,7 +2703,6 @@ describe("market-content-entry", () => {
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
removeDefaultSpreadMetricFilter();
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
await flush();
|
||||
|
||||
@@ -3087,7 +2735,6 @@ describe("market-content-entry", () => {
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
removeDefaultSpreadMetricFilter();
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
await flush();
|
||||
|
||||
@@ -4903,68 +4550,6 @@ function click(selector: string) {
|
||||
element.click();
|
||||
}
|
||||
|
||||
function findMetricCatalogAction(field: string): HTMLButtonElement | null {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-plugin-metric-catalog-action]")
|
||||
).find((element) => element.dataset.pluginMetricCatalogAction === field) ?? null;
|
||||
}
|
||||
|
||||
function findMetricCatalogItem(field: string): HTMLElement | null {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[data-plugin-metric-catalog-item]")
|
||||
).find((element) => element.dataset.pluginMetricCatalogItem === field) ?? null;
|
||||
}
|
||||
|
||||
function findMetricRule(field: string): HTMLElement | null {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[data-plugin-metric-filter-rule]")
|
||||
).find((element) => element.dataset.pluginMetricFilterRule === field) ?? null;
|
||||
}
|
||||
|
||||
function findMetricRuleRemove(field: string): HTMLButtonElement | null {
|
||||
return findMetricRule(field)?.querySelector(
|
||||
"[data-plugin-metric-filter-remove]"
|
||||
) as HTMLButtonElement | null;
|
||||
}
|
||||
|
||||
function findMetricRuleOperator(field: string): HTMLSelectElement | null {
|
||||
return findMetricRule(field)?.querySelector(
|
||||
"[data-plugin-metric-filter-operator]"
|
||||
) as HTMLSelectElement | null;
|
||||
}
|
||||
|
||||
function clickMetricCatalogAction(field: string) {
|
||||
const action = findMetricCatalogAction(field);
|
||||
if (!action) throw new Error(`Missing metric catalog action: ${field}`);
|
||||
action.click();
|
||||
}
|
||||
|
||||
function clickMetricRuleRemove(field: string) {
|
||||
const removeButton = findMetricRuleRemove(field);
|
||||
if (!removeButton) throw new Error(`Missing metric rule remove button: ${field}`);
|
||||
removeButton.click();
|
||||
}
|
||||
|
||||
function setInputValueForMetric(field: string, value: string) {
|
||||
const input = findMetricRule(field)?.querySelector(
|
||||
"[data-plugin-metric-filter-threshold]"
|
||||
) as HTMLInputElement | null;
|
||||
if (!input) throw new Error(`Missing metric threshold input: ${field}`);
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input"));
|
||||
}
|
||||
|
||||
function setMetricOperator(field: string, value: "gte" | "lte") {
|
||||
const select = findMetricRuleOperator(field);
|
||||
if (!select) throw new Error(`Missing metric operator: ${field}`);
|
||||
select.value = value;
|
||||
select.dispatchEvent(new Event("change"));
|
||||
}
|
||||
|
||||
function removeDefaultSpreadMetricFilter() {
|
||||
// Metric filters now start empty, so batch-submit tests need no cleanup.
|
||||
}
|
||||
|
||||
function createNoopToolbarHandlers() {
|
||||
return {
|
||||
onConfigureAudienceProfileFields: vi.fn(),
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
listNumericMetricFilterGroups,
|
||||
matchesMetricFilterRule,
|
||||
parseDisplayNumber
|
||||
} from "../src/content/market/metric-filter";
|
||||
|
||||
describe("metric-filter", () => {
|
||||
test("keeps only numeric list fields while retaining numeric API and profile groups", () => {
|
||||
const groups = listNumericMetricFilterGroups([
|
||||
"达人信息",
|
||||
"粉丝数",
|
||||
"互动率",
|
||||
"完播率",
|
||||
"内容主题",
|
||||
"21-60s报价"
|
||||
]);
|
||||
const listFields = groups.find((group) => group.label === "列表字段");
|
||||
|
||||
expect(listFields?.headers).toEqual([
|
||||
"粉丝数",
|
||||
"互动率",
|
||||
"完播率",
|
||||
"21-60s报价"
|
||||
]);
|
||||
expect(groups.map((group) => group.label)).toEqual(expect.arrayContaining([
|
||||
"内容数据",
|
||||
"效果预估",
|
||||
"观众画像",
|
||||
"粉丝画像",
|
||||
"铁粉画像"
|
||||
]));
|
||||
});
|
||||
|
||||
test("compares displayed percentages, prices, and wan-unit values with both operators", () => {
|
||||
expect(parseDisplayNumber("28.5%")).toBe(28.5);
|
||||
expect(parseDisplayNumber("¥12,000")).toBe(12000);
|
||||
expect(parseDisplayNumber("6.2w")).toBe(62000);
|
||||
expect(parseDisplayNumber("缺失")).toBeNull();
|
||||
|
||||
expect(matchesMetricFilterRule(
|
||||
{ 完播率: "28.5%" },
|
||||
{ field: "完播率", operator: "gte", threshold: 28 }
|
||||
)).toBe(true);
|
||||
expect(matchesMetricFilterRule(
|
||||
{ 完播率: "28.5%" },
|
||||
{ field: "完播率", operator: "lte", threshold: 28 }
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
+24
-110
@@ -1,14 +1,11 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildSpreadInfoConfigKey,
|
||||
buildSpreadInfoColumns,
|
||||
buildSpreadInfoUrl,
|
||||
createSpreadInfoClient,
|
||||
DEFAULT_SPREAD_INFO_CONFIGS,
|
||||
filterUnloadedSpreadInfoConfigs,
|
||||
matchesSpreadMetricRule,
|
||||
normalizeSpreadInfoConfig,
|
||||
mapSpreadInfoResponse,
|
||||
selectSpreadInfoConfigsForHeaders
|
||||
} from "../src/content/market/spread-info";
|
||||
@@ -246,12 +243,14 @@ describe("spread-info", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.loadAuthorSpreadMetricSnapshot("7361012802036695050", {
|
||||
flowType: 0,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
})
|
||||
client.loadAuthorSpreadMetrics("7361012802036695050", [
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
}
|
||||
])
|
||||
).rejects.toThrow("HTTP 429: Too Many Requests");
|
||||
});
|
||||
|
||||
@@ -269,12 +268,14 @@ describe("spread-info", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.loadAuthorSpreadMetricSnapshot("7361012802036695050", {
|
||||
flowType: 0,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
})
|
||||
client.loadAuthorSpreadMetrics("7361012802036695050", [
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
}
|
||||
])
|
||||
).rejects.toThrow(
|
||||
"星图传播数据接口被限流 (status_code=31157): 您访问过于频繁,请24小时后重试"
|
||||
);
|
||||
@@ -288,101 +289,14 @@ describe("spread-info", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.loadAuthorSpreadMetricSnapshot("7361012802036695050", {
|
||||
flowType: 0,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
})
|
||||
client.loadAuthorSpreadMetrics("7361012802036695050", [
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
}
|
||||
])
|
||||
).rejects.toThrow("network unavailable");
|
||||
});
|
||||
|
||||
test("normalizes fixed personal-video parameters before grouping", () => {
|
||||
expect(
|
||||
normalizeSpreadInfoConfig({
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 3,
|
||||
type: 1
|
||||
})
|
||||
).toEqual({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
});
|
||||
});
|
||||
|
||||
test("uses all normalized video dimensions in the config key", () => {
|
||||
expect(
|
||||
buildSpreadInfoConfigKey({
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 3,
|
||||
type: 1
|
||||
})
|
||||
).toBe(
|
||||
buildSpreadInfoConfigKey({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
buildSpreadInfoConfigKey({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 2
|
||||
})
|
||||
).not.toBe(
|
||||
buildSpreadInfoConfigKey({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 2
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("matches only the metric named by one filter rule", () => {
|
||||
expect(
|
||||
matchesSpreadMetricRule(
|
||||
{
|
||||
finishRate: "28.24%",
|
||||
interactionRate: "4.02%"
|
||||
},
|
||||
{
|
||||
config: {
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
},
|
||||
metric: "finishRate",
|
||||
threshold: 28
|
||||
}
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
matchesSpreadMetricRule(
|
||||
{
|
||||
finishRate: "28.24%"
|
||||
},
|
||||
{
|
||||
config: {
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
},
|
||||
metric: "interactionRate",
|
||||
threshold: 1
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user