feat: add independent spread metric filters

This commit is contained in:
wxs
2026-07-10 17:48:02 +08:00
parent b48e244a99
commit 9326d676b7
5 changed files with 767 additions and 356 deletions
+348 -49
View File
@@ -4,6 +4,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createMarketResultStore } from "../src/content/market/result-store";
import type { SpreadInfoConfig } from "../src/content/market/types";
const disposers: Array<() => void> = [];
@@ -415,11 +416,11 @@ describe("market-content-entry", () => {
const dataGroup = document.querySelector(
'[data-plugin-toolbar-group="data"]'
) as HTMLElement | null;
const videoGroup = document.querySelector(
'[data-plugin-toolbar-group="video"]'
const metricSelector = document.querySelector(
'[data-plugin-spread-metrics="root"]'
) as HTMLElement | null;
const thresholdGroup = document.querySelector(
'[data-plugin-toolbar-group="thresholds"]'
const rulesGroup = document.querySelector(
'[data-plugin-spread-rules="root"]'
) as HTMLElement | null;
const statusText = document.querySelector(
'[data-plugin-export-status="text"]'
@@ -433,12 +434,12 @@ describe("market-content-entry", () => {
const operators = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold-operator]")
).map((element) => element.textContent);
const conjunctions = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold-conjunction]")
).map((element) => element.textContent);
const thresholdControls = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold-control]")
);
const metricInputs = Array.from(
document.querySelectorAll("[data-plugin-spread-metric]")
) as HTMLInputElement[];
const ruleRows = Array.from(
document.querySelectorAll("[data-plugin-spread-rule]")
) as HTMLElement[];
const thresholdInputs = Array.from(
document.querySelectorAll("[data-plugin-spread-threshold]")
) as HTMLInputElement[];
@@ -450,22 +451,19 @@ describe("market-content-entry", () => {
expect(thresholdRow?.style.flexWrap).toBe("nowrap");
expect(thresholdRow?.style.alignItems).toBe("center");
expect(dataGroup?.parentElement).toBe(primaryRow);
expect(videoGroup?.parentElement).toBe(primaryRow);
expect(statusText?.parentElement).toBe(primaryRow);
expect(thresholdGroup?.parentElement).toBe(thresholdRow);
expect(thresholdGroup?.style.flexWrap).toBe("nowrap");
expect(thresholdGroup?.style.overflowX).toBe("auto");
expect(metricSelector?.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(titles[1]?.style.background).toBe("rgb(238, 245, 255)");
expect(document.querySelector("[data-plugin-spread-threshold-rule]")).toBeNull();
expect(operators).toEqual(["≥", "≥"]);
expect(conjunctions).toEqual(["且"]);
expect(metricInputs.map((input) => input.checked)).toEqual([false, false]);
expect(ruleRows.map((row) => row.hidden)).toEqual([true, true]);
expect(thresholdInputs.map((input) => input.placeholder)).toEqual([
"",
""
@@ -474,10 +472,6 @@ describe("market-content-entry", () => {
"0.1",
"0.1"
]);
expect(thresholdControls.map((control) => control.textContent)).toEqual([
"完播率≥%",
"互动率≥%"
]);
expect(buttons.map((button) => button.textContent)).toEqual([
"导出CSV",
"导出选中达人数据",
@@ -1282,7 +1276,7 @@ describe("market-content-entry", () => {
expect(customPagesInput?.hidden).toBe(false);
});
test("toolbar exposes spread threshold filters and disables fixed personal-video controls", async () => {
test("toolbar exposes independent metric rules and applies personal-video constraints per rule", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
@@ -1297,31 +1291,148 @@ describe("market-content-entry", () => {
await controller.ready;
const videoTypeSelect = document.querySelector(
'[data-plugin-spread-filter="type"]'
) as HTMLSelectElement | null;
const assignSelect = document.querySelector(
'[data-plugin-spread-filter="onlyAssign"]'
) as HTMLSelectElement | null;
const flowTypeSelect = document.querySelector(
'[data-plugin-spread-filter="flowType"]'
) as HTMLSelectElement | null;
const finishToggle = document.querySelector(
'[data-plugin-spread-metric="finishRate"]'
) as HTMLInputElement | null;
const interactionToggle = document.querySelector(
'[data-plugin-spread-metric="interactionRate"]'
) as HTMLInputElement | 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(videoTypeSelect?.value).toBe("1");
expect(assignSelect?.value).toBe("false");
expect(assignSelect?.disabled).toBe(true);
expect(flowTypeSelect?.value).toBe("0");
expect(flowTypeSelect?.disabled).toBe(true);
expect(finishToggle?.checked).toBe(false);
expect(interactionToggle?.checked).toBe(false);
expect(finishRule?.hidden).toBe(true);
expect(interactionRule?.hidden).toBe(true);
expect(finishRateInput?.placeholder).toBe("");
setSelectValue('[data-plugin-spread-filter="type"]', "2");
dispatchChange('[data-plugin-spread-filter="type"]');
enableSpreadMetric("finishRate");
enableSpreadMetric("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");
expect(assignSelect?.disabled).toBe(false);
expect(flowTypeSelect?.disabled).toBe(false);
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(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");
if (!finishToggle) {
throw new Error("Missing finish-rate metric toggle");
}
finishToggle.checked = false;
dispatchChange('[data-plugin-spread-metric="finishRate"]');
finishToggle.checked = true;
dispatchChange('[data-plugin-spread-metric="finishRate"]');
expect(finishRateInput?.value).toBe("");
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="type"]',
"1"
);
expectSelectValue(
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="range"]',
"2"
);
});
test("reads selected spread metrics as independent validated rules", 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());
expect(readToolbarSpreadFilter(toolbar)).toEqual({
filter: { rules: [] }
});
enableSpreadMetric("finishRate");
expect(readToolbarSpreadFilter(toolbar)).toEqual({
error: "请输入有效的完播率筛选阈值"
});
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
enableSpreadMetric("interactionRate");
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
setSpreadRuleSelect("finishRate", "type", "2");
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
setSpreadRuleSelect("finishRate", "flowType", "1");
setSpreadRuleSelect("interactionRate", "range", "3");
expect(readToolbarSpreadFilter(toolbar)).toEqual({
filter: {
rules: [
{
config: {
flowType: 1,
onlyAssign: true,
range: 2,
type: 2
},
metric: "finishRate",
threshold: 30
},
{
config: {
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
},
metric: "interactionRate",
threshold: 5
}
]
}
});
});
test("export uses the current page ordering without triggering a full scan", async () => {
@@ -1437,7 +1548,7 @@ describe("market-content-entry", () => {
]);
});
test("export keeps only records that match spread threshold filters", async () => {
test("export requires independent spread metric configs to all match", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
@@ -1459,9 +1570,79 @@ describe("market-content-entry", () => {
}
]);
const buildCsv = vi.fn(() => "csv-output");
const loadSpreadFilterMetrics = vi.fn(async (spreadAuthorId: string) => ({
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%",
interactionRate: "5%"
const loadSpreadFilterMetrics = vi.fn(async (
spreadAuthorId: string,
config: SpreadInfoConfig
) => {
if (config.type === 2) {
return { finishRate: "35%" };
}
return {
interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%"
};
});
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildCsv,
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
loadSpreadFilterMetrics,
loadSpreadMetrics: async () => ({}),
onCsvReady: vi.fn(),
window
}));
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("finishRate", "onlyAssign", "true");
setSpreadRuleSelect("interactionRate", "range", "3");
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
flowType: 0,
onlyAssign: true,
range: 2,
type: 2
});
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
});
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
"a"
]);
});
test("export reuses one spread snapshot when metric configs match", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" }
]);
attachMarketListState([
{
attribute_datas: {
id: "spread-a",
nickname: "Alpha"
},
star_id: "a"
}
]);
const buildCsv = vi.fn(() => "csv-output");
const loadSpreadFilterMetrics = vi.fn(async () => ({
finishRate: "35%",
interactionRate: "6%"
}));
const { createMarketController } = await import("../src/content/market/index");
@@ -1473,6 +1654,7 @@ describe("market-content-entry", () => {
reason: "request-failed"
}),
loadSpreadFilterMetrics,
loadSpreadMetrics: async () => ({}),
onCsvReady: vi.fn(),
window
}));
@@ -1480,10 +1662,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");
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1);
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
flowType: 0,
onlyAssign: false,
@@ -1495,6 +1681,58 @@ describe("market-content-entry", () => {
]);
});
test("export excludes missing-id and failed spread metric records without aborting", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
]);
attachMarketListState([
{ star_id: "a" },
{
attribute_datas: {
id: "spread-b",
nickname: "Beta"
},
star_id: "b"
}
]);
const buildCsv = vi.fn(() => "csv-output");
const loadSpreadFilterMetrics = vi.fn(async () => {
throw new Error("request failed");
});
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildCsv,
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
loadSpreadFilterMetrics,
loadSpreadMetrics: async () => ({}),
onCsvReady: vi.fn(),
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
enableSpreadMetric("finishRate");
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1);
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-b", {
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
});
expect(buildCsv.mock.calls[0][0]).toEqual([]);
});
test(
"default export captures the first 5 pages and keeps non-empty fields when merging duplicates",
async () => {
@@ -2433,7 +2671,7 @@ describe("market-content-entry", () => {
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
});
test("batch submit keeps only records that match spread threshold filters", async () => {
test("batch submit applies all independent spread metric rules", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
@@ -2455,9 +2693,19 @@ describe("market-content-entry", () => {
}
]);
const submitBatch = vi.fn(async () => ({ ok: true }));
const loadSpreadFilterMetrics = vi.fn(async (spreadAuthorId: string) => ({
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%"
}));
const loadSpreadFilterMetrics = vi.fn(async (
spreadAuthorId: string,
config: SpreadInfoConfig
) => {
if (config.type === 2) {
return {
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%"
};
}
return {
interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%"
};
});
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
@@ -2480,7 +2728,12 @@ 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", "range", "3");
click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 80, 50);
@@ -5089,6 +5342,52 @@ function click(selector: string) {
element.click();
}
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 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 setSpreadRuleSelect(
metric: "finishRate" | "interactionRate",
field: "type" | "onlyAssign" | "flowType" | "range",
value: string
) {
const select = readSpreadRuleSelect(metric, field);
select.value = value;
select.dispatchEvent(new Event("change"));
}
function createNoopToolbarHandlers() {
return {
onConfigureAudienceProfileFields: vi.fn(),
onExport: vi.fn(),
onExportAudienceProfile: vi.fn(),
onExportAudienceProfileByIds: vi.fn(),
onSubmitBatch: vi.fn()
};
}
function clickSelectionCheckboxForAuthor(authorId: string) {
readSelectionCheckboxForAuthor(authorId).click();
}