feat: make metric filters field-driven

This commit is contained in:
wxs
2026-07-27 17:58:10 +08:00
parent e03dedd91e
commit 53db9b0777
7 changed files with 783 additions and 1576 deletions
+143 -328
View File
@@ -442,10 +442,10 @@ describe("market-content-entry", () => {
'[data-plugin-toolbar-action-group="batch-submit"]'
) as HTMLElement | null;
const metricCatalog = document.querySelector(
'[data-plugin-spread-metric-catalog="root"]'
'[data-plugin-metric-catalog="root"]'
) as HTMLElement | null;
const rulesGroup = document.querySelector(
'[data-plugin-spread-rules="root"]'
'[data-plugin-metric-rules="root"]'
) as HTMLElement | null;
const statusText = document.querySelector(
'[data-plugin-export-status="text"]'
@@ -472,13 +472,13 @@ describe("market-content-entry", () => {
'[data-plugin-batch-submit="button"]'
) as HTMLButtonElement | null;
const operators = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold-operator]")
document.querySelectorAll("[data-plugin-metric-filter-operator]")
).map((element) => element.textContent);
const ruleRows = Array.from(
document.querySelectorAll("[data-plugin-spread-rule]")
document.querySelectorAll("[data-plugin-metric-filter-rule]")
) as HTMLElement[];
const thresholdInputs = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold]")
document.querySelectorAll("[data-plugin-metric-filter-threshold]")
) as HTMLInputElement[];
expect(toolbar?.style.flexWrap).toBe("nowrap");
@@ -515,32 +515,25 @@ describe("market-content-entry", () => {
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(operators).toEqual([]);
expect(
document.querySelector('[data-plugin-spread-metric-catalog-trigger="button"]')
document.querySelector('[data-plugin-metric-catalog-trigger="button"]')
?.textContent
).toBe("添加筛选指标");
expect(
document.querySelector('[data-plugin-spread-metric-selected-count="text"]')
document.querySelector('[data-plugin-metric-selected-count="text"]')
?.textContent
).toBe("已选 1 项");
).toBe("已选 0 项");
expect(
(document.querySelector(
'[data-plugin-spread-metric-catalog-panel="root"]'
'[data-plugin-metric-catalog-panel="root"]'
) as HTMLElement | null)?.hidden
).toBe(true);
expect(ruleRows.map((row) => row.hidden)).toEqual([false, true]);
expect(thresholdInputs.map((input) => input.placeholder)).toEqual([
"",
""
]);
expect(thresholdInputs.map((input) => input.step)).toEqual([
"0.1",
"0.1"
]);
expect(ruleRows).toEqual([]);
expect(thresholdInputs).toEqual([]);
expect([
audienceProfileExportButton?.textContent,
audienceProfileByIdExportButton?.textContent,
@@ -1500,297 +1493,101 @@ describe("market-content-entry", () => {
}
});
test("catalog starts with the default finish-rate rule and adds independent metrics", async () => {
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"
}),
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
window
}));
await controller.ready;
const catalogTrigger = document.querySelector(
'[data-plugin-spread-metric-catalog-trigger="button"]'
) as HTMLButtonElement | null;
const field = "内容数据-个人视频-近30天-完播率";
const catalogPanel = document.querySelector(
'[data-plugin-spread-metric-catalog-panel="root"]'
'[data-plugin-metric-catalog-panel="root"]'
) as HTMLElement | null;
const finishRule = document.querySelector(
'[data-plugin-spread-rule="finishRate"]'
) as HTMLElement | null;
const interactionRule = document.querySelector(
'[data-plugin-spread-rule="interactionRate"]'
) as HTMLElement | null;
const finishRateInput = document.querySelector(
'[data-plugin-spread-threshold="finishRate"]'
) as HTMLInputElement | null;
expect(catalogPanel?.hidden).toBe(true);
expect(catalogPanel?.style.zIndex).toBe("100");
expect(finishRule?.hidden).toBe(false);
expect(interactionRule?.hidden).toBe(true);
expect(interactionRule?.style.display).toBe("none");
expect(finishRateInput?.placeholder).toBe("");
expect(
document.querySelector('[data-plugin-spread-metric-selected-count="text"]')
?.textContent
).toBe("已选 1 项");
expect(readSpreadRuleSelect("finishRate", "type").value).toBe("2");
expect(readSpreadRuleSelect("finishRate", "onlyAssign").value).toBe("true");
expect(readSpreadRuleSelect("finishRate", "flowType").value).toBe("0");
expect(readSpreadRuleSelect("finishRate", "range").value).toBe("2");
expect(document.querySelector('[data-plugin-metric-selected-count="text"]')?.textContent).toBe("已选 0 项");
catalogTrigger?.click();
expect(catalogPanel?.hidden).toBe(false);
click('[data-plugin-spread-metric-catalog-action="interactionRate"]');
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
setSpreadRuleSelect("finishRate", "flowType", "1");
setSpreadRuleSelect("interactionRate", "type", "2");
setSpreadRuleSelect("interactionRate", "onlyAssign", "true");
setSpreadRuleSelect("interactionRate", "flowType", "1");
setSpreadRuleSelect("interactionRate", "type", "1");
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);
const finishAssignSelect = readSpreadRuleSelect(
"finishRate",
"onlyAssign"
);
const finishFlowTypeSelect = readSpreadRuleSelect(
"finishRate",
"flowType"
);
const interactionAssignSelect = readSpreadRuleSelect(
"interactionRate",
"onlyAssign"
);
const interactionFlowTypeSelect = readSpreadRuleSelect(
"interactionRate",
"flowType"
);
expect(finishRule?.hidden).toBe(false);
expect(interactionRule?.hidden).toBe(false);
expect(interactionRule?.style.display).toBe("flex");
expect(finishAssignSelect.value).toBe("true");
expect(finishAssignSelect.disabled).toBe(false);
expect(finishFlowTypeSelect.value).toBe("1");
expect(finishFlowTypeSelect.disabled).toBe(false);
expect(interactionAssignSelect.value).toBe("false");
expect(interactionAssignSelect.disabled).toBe(true);
expect(interactionFlowTypeSelect.value).toBe("0");
expect(interactionFlowTypeSelect.disabled).toBe(true);
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
click('[data-plugin-spread-rule-remove="finishRate"]');
expect(finishRule?.hidden).toBe(true);
expect(finishRule?.style.display).toBe("none");
catalogTrigger?.click();
click('[data-plugin-spread-metric-catalog-action="finishRate"]');
expect(finishRateInput?.value).toBe("");
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="type"]',
"2"
);
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="onlyAssign"]',
"true"
);
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="flowType"]',
"0"
);
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="range"]',
"2"
);
});
test("catalog disables duplicate metrics and restores them after deletion", 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 finishAction = document.querySelector(
'[data-plugin-spread-metric-catalog-action="finishRate"]'
) as HTMLButtonElement | null;
expect(finishAction?.disabled).toBe(true);
expect(finishAction?.textContent).toBe("已添加");
click('[data-plugin-spread-rule-remove="finishRate"]');
expect(finishAction?.disabled).toBe(false);
expect(finishAction?.textContent).toBe("添加");
});
test("catalog does not render a metric search input", 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;
click('[data-plugin-spread-metric-catalog-trigger="button"]');
expect(
document.querySelector('[data-plugin-spread-metric-catalog-search="input"]')
).toBeNull();
});
test("busy toolbar disables catalog and selected rule actions", 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 { ensurePluginToolbar, setToolbarBusyState } = await import(
"../src/content/market/plugin-toolbar"
);
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers());
setToolbarBusyState(toolbar, true);
expect(
(document.querySelector(
'[data-plugin-spread-metric-catalog-trigger="button"]'
) as HTMLButtonElement | null)?.disabled
).toBe(true);
expect(
(document.querySelector(
'[data-plugin-spread-metric-catalog-action="interactionRate"]'
) as HTMLButtonElement | null)?.disabled
).toBe(true);
expect(
(document.querySelector(
'[data-plugin-spread-rule-remove="finishRate"]'
) as HTMLButtonElement | null)?.disabled
).toBe(true);
});
test("uses a collapsible catalog drawer and secondary rule controls on narrow screens", async () => {
const originalInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 });
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;
window.dispatchEvent(new Event("resize"));
click('[data-plugin-spread-metric-catalog-trigger="button"]');
const catalogPanel = document.querySelector(
'[data-plugin-spread-metric-catalog-panel="root"]'
) as HTMLElement | null;
expect(catalogPanel?.hidden).toBe(false);
const secondaryControls = document.querySelector(
'[data-plugin-spread-rule-secondary="finishRate"]'
) as HTMLElement | null;
expect(secondaryControls?.hidden).toBe(true);
click('[data-plugin-spread-rule-details="finishRate"]');
expect(secondaryControls?.hidden).toBe(false);
click('[data-plugin-spread-metric-catalog-trigger="button"]');
click('[data-plugin-spread-metric-catalog-close="button"]');
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("已添加");
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: originalInnerWidth
});
window.dispatchEvent(new Event("resize"));
clickMetricRuleRemove(field);
expect(findMetricRule(field)).toBeNull();
expect(findMetricCatalogAction(field)?.disabled).toBe(false);
expect(findMetricCatalogAction(field)?.textContent).toBe("添加");
});
test("reads selected spread metrics as independent validated rules", async () => {
test("reads field rules with selectable greater-than-or-equal and less-than-or-equal operators", 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 { ensurePluginToolbar, readToolbarSpreadFilter } = await import(
"../src/content/market/plugin-toolbar"
);
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers());
const field = "内容数据-个人视频-近30天-完播率";
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers(), [
{ field, group: "内容数据" }
]);
click('[data-plugin-metric-catalog-trigger="button"]');
clickMetricCatalogAction(field);
expect(readToolbarSpreadFilter(toolbar)).toEqual({
error: "请输入有效的完播率筛选阈值"
error: `请输入有效的${field}筛选数值`
});
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
click('[data-plugin-spread-metric-catalog-trigger="button"]');
click('[data-plugin-spread-metric-catalog-action="interactionRate"]');
expect(readSpreadRuleSelect("interactionRate", "type").value).toBe("2");
expect(readSpreadRuleSelect("interactionRate", "onlyAssign").value).toBe("true");
expect(readSpreadRuleSelect("interactionRate", "flowType").value).toBe("0");
expect(readSpreadRuleSelect("interactionRate", "range").value).toBe("2");
expect(
(document.querySelector(
'[data-plugin-spread-threshold="interactionRate"]'
) as HTMLInputElement | null)?.value
).toBe("");
expect(readToolbarSpreadFilter(toolbar)).toEqual({
error: "请输入有效的互动率筛选阈值"
});
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
setInputValueForMetric(field, "30");
setMetricOperator(field, "lte");
expect(readToolbarSpreadFilter(toolbar)).toEqual({
filter: {
rules: [
{
config: {
flowType: 0,
onlyAssign: true,
range: 2,
type: 2
},
metric: "finishRate",
threshold: 30
},
{
config: {
flowType: 0,
onlyAssign: true,
range: 2,
type: 2
},
metric: "interactionRate",
threshold: 5
}
]
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" }
@@ -2551,7 +2348,7 @@ describe("market-content-entry", () => {
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
});
test("batch submit applies all independent spread metric rules", async () => {
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" }
@@ -2573,17 +2370,12 @@ describe("market-content-entry", () => {
}
]);
const submitBatch = vi.fn(async () => ({ ok: true }));
const loadSpreadFilterMetrics = vi.fn(async (
spreadAuthorId: string,
config: SpreadInfoConfig
) => {
if (config.type === 2) {
return {
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%"
};
}
const loadSpreadMetrics = vi.fn(async (spreadAuthorId: string) => {
return {
interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%"
"内容数据-个人视频-近30天-互动率":
spreadAuthorId === "spread-a" ? "6%" : "4%",
"内容数据-个人视频-近30天-完播率":
spreadAuthorId === "spread-a" ? "35%" : "20%"
};
});
@@ -2599,7 +2391,7 @@ describe("market-content-entry", () => {
success: false,
reason: "request-failed"
}),
loadSpreadFilterMetrics,
loadSpreadMetrics,
promptBatchName: () => "筛选批次",
submitBatch,
window
@@ -2608,13 +2400,14 @@ describe("market-content-entry", () => {
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
enableSpreadMetric("finishRate");
enableSpreadMetric("interactionRate");
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("interactionRate", "type", "1");
setSpreadRuleSelect("interactionRate", "range", "3");
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);
@@ -5110,46 +4903,68 @@ function click(selector: string) {
element.click();
}
function removeDefaultSpreadMetricFilter() {
click('[data-plugin-spread-rule-remove="finishRate"]');
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 enableSpreadMetric(metric: "finishRate" | "interactionRate") {
const selector = `[data-plugin-spread-metric="${metric}"]`;
const input = document.querySelector(selector) as HTMLInputElement | null;
if (!input) {
throw new Error(`Missing spread metric toggle: ${metric}`);
}
input.checked = true;
dispatchChange(selector);
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 readSpreadRuleSelect(
metric: "finishRate" | "interactionRate",
field: "type" | "onlyAssign" | "flowType" | "range"
): HTMLSelectElement {
const selector =
`[data-plugin-spread-rule="${metric}"] ` +
`[data-plugin-spread-filter="${field}"]`;
const select = document.querySelector(selector) as HTMLSelectElement | null;
if (!select) {
throw new Error(`Missing spread rule select: ${metric}.${field}`);
}
return select;
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 setSpreadRuleSelect(
metric: "finishRate" | "interactionRate",
field: "type" | "onlyAssign" | "flowType" | "range",
value: string
) {
const select = readSpreadRuleSelect(metric, field);
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(),
+51
View File
@@ -0,0 +1,51 @@
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);
});
});