From 9326d676b7034b375622758e178e46a07fff161b Mon Sep 17 00:00:00 2001 From: wxs Date: Fri, 10 Jul 2026 17:48:02 +0800 Subject: [PATCH] feat: add independent spread metric filters --- src/content/market/index.ts | 63 ++- src/content/market/plugin-toolbar.ts | 632 ++++++++++++++++----------- src/content/market/spread-info.ts | 18 +- src/content/market/types.ts | 13 +- tests/market-content-entry.test.ts | 397 ++++++++++++++--- 5 files changed, 767 insertions(+), 356 deletions(-) diff --git a/src/content/market/index.ts b/src/content/market/index.ts index bf3f14a..37c1945 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -31,7 +31,13 @@ import { createMarketApiClient } from "./api-client"; import { createExportRangeController } from "./export-range-controller"; import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar"; import { createSilentExportController } from "./silent-export-controller"; -import { createSpreadInfoClient, matchesSpreadThresholds } from "./spread-info"; +import { + buildSpreadInfoConfigKey, + createSpreadInfoClient, + matchesSpreadMetricRule, + normalizeSpreadInfoConfig, + type MappedSpreadInfoResponse +} from "./spread-info"; import { readToolbarExportTarget, readToolbarSpreadFilter, @@ -57,6 +63,8 @@ import type { MarketRecord, MarketRowSnapshot, MarketSortState, + SpreadInfoConfig, + SpreadMetricFilterRule, SpreadThresholdFilter } from "./types"; @@ -84,8 +92,8 @@ export interface CreateMarketControllerOptions { loadAuthorMetrics?: (authorId: string) => Promise; loadSpreadFilterMetrics?: ( spreadAuthorId: string, - config: SpreadThresholdFilter["config"] - ) => Promise>; + config: SpreadInfoConfig + ) => Promise; loadSpreadMetrics?: (spreadAuthorId: string) => Promise>; searchBackendMetrics?: (starIds: string[]) => Promise< Array @@ -802,10 +810,19 @@ export function createMarketController(options: CreateMarketControllerOptions) { records: MarketRecord[], filter: SpreadThresholdFilter | undefined ): Promise { - if (!filter) { + if (!filter || filter.rules.length === 0) { return records; } + const normalizedRules = filter.rules.map((rule) => ({ + ...rule, + config: normalizeSpreadInfoConfig(rule.config) + })); + const configsByKey = new Map(); + normalizedRules.forEach((rule) => { + configsByKey.set(buildSpreadInfoConfigKey(rule.config), rule.config); + }); + const matchedAuthorIds = new Set(); await Promise.all( records.map(async (record) => { @@ -814,21 +831,41 @@ export function createMarketController(options: CreateMarketControllerOptions) { return; } - try { - const metrics = await loadSpreadFilterMetrics( - spreadAuthorId, - filter.config - ); - if (matchesSpreadThresholds(metrics, filter.thresholds)) { - matchedAuthorIds.add(record.authorId); - } - } catch {} + const snapshots = new Map(); + await Promise.all( + Array.from(configsByKey.entries()).map(async ([key, config]) => { + try { + snapshots.set( + key, + await loadSpreadFilterMetrics(spreadAuthorId, config) + ); + } catch { + snapshots.set(key, {}); + } + }) + ); + + if (matchesAllSpreadMetricRules(normalizedRules, snapshots)) { + matchedAuthorIds.add(record.authorId); + } }) ); return records.filter((record) => matchedAuthorIds.has(record.authorId)); } + function matchesAllSpreadMetricRules( + rules: SpreadMetricFilterRule[], + snapshots: Map + ): boolean { + return rules.every((rule) => + matchesSpreadMetricRule( + snapshots.get(buildSpreadInfoConfigKey(rule.config)) ?? {}, + rule + ) + ); + } + function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] { if (selectedAuthorIds.size === 0) { return []; diff --git a/src/content/market/plugin-toolbar.ts b/src/content/market/plugin-toolbar.ts index 999abb2..7fcda6b 100644 --- a/src/content/market/plugin-toolbar.ts +++ b/src/content/market/plugin-toolbar.ts @@ -1,6 +1,9 @@ import type { MarketExportScope, MarketExportTarget, + SpreadFilterMetric, + SpreadInfoConfig, + SpreadMetricFilterRule, SpreadThresholdFilter } from "./types"; @@ -12,12 +15,25 @@ export interface PluginToolbarHandlers { onSubmitBatch(): Promise | void; } -type VisibleSpreadThresholdKey = Extract< - keyof SpreadThresholdFilter["thresholds"], - "finishRate" | "interactionRate" ->; +interface SpreadMetricRuleDom { + enabledInput: HTMLInputElement; + flowTypeSelect: HTMLSelectElement; + onlyAssignSelect: HTMLSelectElement; + rangeSelect: HTMLSelectElement; + root: HTMLElement; + thresholdInput: HTMLInputElement; + typeSelect: HTMLSelectElement; +} -type SpreadThresholdInputMap = Record; +type SpreadMetricRuleDomMap = Record; + +const SPREAD_FILTER_DEFINITIONS: ReadonlyArray<{ + label: string; + metric: SpreadFilterMetric; +}> = [ + { label: "完播率", metric: "finishRate" }, + { label: "互动率", metric: "interactionRate" } +]; export interface PluginToolbarDom { audienceProfileByIdExportButton: HTMLButtonElement; @@ -28,11 +44,7 @@ export interface PluginToolbarDom { exportCustomPagesInput: HTMLInputElement; exportRangeSelect: HTMLSelectElement; exportStatusText: HTMLElement; - spreadFilterFlowTypeSelect: HTMLSelectElement; - spreadFilterOnlyAssignSelect: HTMLSelectElement; - spreadFilterRangeSelect: HTMLSelectElement; - spreadFilterTypeSelect: HTMLSelectElement; - spreadThresholdInputs: SpreadThresholdInputMap; + spreadMetricRules: SpreadMetricRuleDomMap; root: HTMLElement; } @@ -59,7 +71,8 @@ export function ensurePluginToolbar( if ( existingRoot.querySelector( '[data-plugin-export-audience-profile-by-id="button"]' - ) + ) && + existingRoot.querySelector('[data-plugin-spread-metric="finishRate"]') ) { ensureToolbarMounted(existingRoot, document); return readToolbarDom(existingRoot); @@ -126,31 +139,7 @@ export function ensurePluginToolbar( exportStatusText.dataset.pluginExportStatus = "text"; applyStatusStyles(exportStatusText); - const spreadFilterTypeSelect = document.createElement("select"); - spreadFilterTypeSelect.dataset.pluginSpreadFilter = "type"; - appendOption(spreadFilterTypeSelect, "1", "个人视频"); - appendOption(spreadFilterTypeSelect, "2", "星图视频"); - spreadFilterTypeSelect.value = "1"; - - const spreadFilterOnlyAssignSelect = document.createElement("select"); - spreadFilterOnlyAssignSelect.dataset.pluginSpreadFilter = "onlyAssign"; - appendOption(spreadFilterOnlyAssignSelect, "false", "不限指派"); - appendOption(spreadFilterOnlyAssignSelect, "true", "只看指派"); - spreadFilterOnlyAssignSelect.value = "false"; - - const spreadFilterFlowTypeSelect = document.createElement("select"); - spreadFilterFlowTypeSelect.dataset.pluginSpreadFilter = "flowType"; - appendOption(spreadFilterFlowTypeSelect, "0", "不排除营销"); - appendOption(spreadFilterFlowTypeSelect, "1", "排除营销"); - spreadFilterFlowTypeSelect.value = "0"; - - const spreadFilterRangeSelect = document.createElement("select"); - spreadFilterRangeSelect.dataset.pluginSpreadFilter = "range"; - appendOption(spreadFilterRangeSelect, "2", "近30天"); - appendOption(spreadFilterRangeSelect, "3", "近90天"); - spreadFilterRangeSelect.value = "2"; - - const spreadThresholdInputs = createSpreadThresholdInputs(document); + const spreadMetricRules = createSpreadMetricRuleDoms(document); const panel = document.createElement("div"); panel.dataset.pluginToolbarPanel = "root"; @@ -177,27 +166,20 @@ export function ensurePluginToolbar( batchSubmitButton ); - const videoGroup = document.createElement("div"); - videoGroup.dataset.pluginToolbarGroup = "video"; - applyToolbarGroupStyles(videoGroup); - videoGroup.append( - createToolbarGroupTitle(document, "视频口径"), - spreadFilterTypeSelect, - spreadFilterOnlyAssignSelect, - spreadFilterFlowTypeSelect, - spreadFilterRangeSelect - ); - - const thresholdGroup = document.createElement("div"); - thresholdGroup.dataset.pluginToolbarGroup = "thresholds"; - applyThresholdGroupStyles(thresholdGroup); - thresholdGroup.append( - ...createSpreadThresholdControls(document, spreadThresholdInputs) - ); - const thresholdTitle = createToolbarGroupTitle(document, "传播指标筛选"); - firstRow.append(dataGroup, videoGroup, exportStatusText); - secondRow.append(thresholdTitle, thresholdGroup); + const metricSelector = createSpreadMetricSelector(document, spreadMetricRules); + const rulesGroup = document.createElement("div"); + rulesGroup.dataset.pluginSpreadRules = "root"; + applySpreadRulesGroupStyles(rulesGroup); + rulesGroup.append( + ...SPREAD_FILTER_DEFINITIONS.map( + ({ metric }) => spreadMetricRules[metric].root + ) + ); + const filterNote = createSpreadFilterNote(document); + + firstRow.append(dataGroup, exportStatusText); + secondRow.append(thresholdTitle, metricSelector, rulesGroup, filterNote); panel.append(firstRow, secondRow); root.append(panel); @@ -211,11 +193,7 @@ export function ensurePluginToolbar( exportButton, exportCustomPagesInput, exportRangeSelect, - spreadFilterFlowTypeSelect, - spreadFilterOnlyAssignSelect, - spreadFilterRangeSelect, - spreadFilterTypeSelect, - ...spreadThresholdInputs + spreadMetricRules }); ensureToolbarMounted(root, document); @@ -234,42 +212,6 @@ export function ensurePluginToolbar( batchSubmitButton.addEventListener("click", () => { void handlers.onSubmitBatch(); }); - exportRangeSelect.addEventListener("change", () => { - syncCustomPagesInputVisibility({ - batchSubmitButton, - audienceProfileFieldButton, - audienceProfileByIdExportButton, - audienceProfileExportButton, - exportButton, - exportCustomPagesInput, - exportRangeSelect, - exportStatusText, - root, - spreadFilterFlowTypeSelect, - spreadFilterOnlyAssignSelect, - spreadFilterRangeSelect, - spreadFilterTypeSelect, - spreadThresholdInputs - }); - }); - spreadFilterTypeSelect.addEventListener("change", () => { - syncSpreadFilterControlState({ - audienceProfileByIdExportButton, - audienceProfileExportButton, - audienceProfileFieldButton, - batchSubmitButton, - exportButton, - exportCustomPagesInput, - exportRangeSelect, - exportStatusText, - root, - spreadFilterFlowTypeSelect, - spreadFilterOnlyAssignSelect, - spreadFilterRangeSelect, - spreadFilterTypeSelect, - spreadThresholdInputs - }); - }); const toolbarDom = { audienceProfileExportButton, @@ -280,15 +222,27 @@ export function ensurePluginToolbar( exportCustomPagesInput, exportRangeSelect, exportStatusText, - spreadFilterFlowTypeSelect, - spreadFilterOnlyAssignSelect, - spreadFilterRangeSelect, - spreadFilterTypeSelect, - spreadThresholdInputs, + spreadMetricRules, root } satisfies PluginToolbarDom; + + exportRangeSelect.addEventListener("change", () => { + syncCustomPagesInputVisibility(toolbarDom); + }); + + SPREAD_FILTER_DEFINITIONS.forEach(({ metric }) => { + const rule = spreadMetricRules[metric]; + rule.enabledInput.addEventListener("change", () => { + syncSpreadMetricRuleState(rule); + syncSpreadFilterNote(toolbarDom); + }); + rule.typeSelect.addEventListener("change", () => { + syncSpreadMetricVideoConstraints(rule); + }); + }); + syncCustomPagesInputVisibility(toolbarDom); - syncSpreadFilterControlState(toolbarDom); + syncAllSpreadMetricRules(toolbarDom); return toolbarDom; } @@ -304,94 +258,161 @@ function appendOption( select.appendChild(option); } -function createSpreadThresholdInputs( +function createSpreadMetricRuleDoms( document: Document -): SpreadThresholdInputMap { +): SpreadMetricRuleDomMap { + return Object.fromEntries( + SPREAD_FILTER_DEFINITIONS.map(({ label, metric }) => [ + metric, + createSpreadMetricRuleDom(document, metric, label) + ]) + ) as unknown as SpreadMetricRuleDomMap; +} + +function createSpreadMetricRuleDom( + document: Document, + metric: SpreadFilterMetric, + label: string +): SpreadMetricRuleDom { + const enabledInput = document.createElement("input"); + enabledInput.type = "checkbox"; + enabledInput.dataset.pluginSpreadMetric = metric; + enabledInput.setAttribute("aria-label", `启用${label}筛选`); + + const thresholdInput = document.createElement("input"); + thresholdInput.type = "number"; + thresholdInput.min = "0"; + thresholdInput.step = "0.1"; + thresholdInput.dataset.pluginSpreadThreshold = metric; + thresholdInput.setAttribute("aria-label", `${label}筛选阈值`); + + const typeSelect = document.createElement("select"); + typeSelect.dataset.pluginSpreadFilter = "type"; + appendOption(typeSelect, "1", "个人视频"); + appendOption(typeSelect, "2", "星图视频"); + typeSelect.value = "1"; + + const onlyAssignSelect = document.createElement("select"); + onlyAssignSelect.dataset.pluginSpreadFilter = "onlyAssign"; + appendOption(onlyAssignSelect, "false", "不限指派"); + appendOption(onlyAssignSelect, "true", "只看指派"); + onlyAssignSelect.value = "false"; + + const flowTypeSelect = document.createElement("select"); + flowTypeSelect.dataset.pluginSpreadFilter = "flowType"; + appendOption(flowTypeSelect, "0", "不排除营销"); + appendOption(flowTypeSelect, "1", "排除营销"); + flowTypeSelect.value = "0"; + + const rangeSelect = document.createElement("select"); + rangeSelect.dataset.pluginSpreadFilter = "range"; + appendOption(rangeSelect, "2", "近30天"); + appendOption(rangeSelect, "3", "近90天"); + rangeSelect.value = "2"; + + const root = document.createElement("div"); + root.dataset.pluginSpreadRule = metric; + root.hidden = true; + applySpreadMetricRuleStyles(root); + + const metricLabel = document.createElement("strong"); + metricLabel.textContent = label; + applySpreadMetricRuleLabelStyles(metricLabel); + + const thresholdControl = document.createElement("label"); + thresholdControl.dataset.pluginSpreadThresholdControl = metric; + applySpreadThresholdControlStyles(thresholdControl); + + const operator = document.createElement("b"); + operator.dataset.pluginSpreadThresholdOperator = "gte"; + operator.textContent = "≥"; + + const unitText = document.createElement("span"); + unitText.dataset.pluginSpreadThresholdUnit = metric; + unitText.textContent = "%"; + thresholdControl.append(operator, thresholdInput, unitText); + + root.append( + metricLabel, + thresholdControl, + typeSelect, + onlyAssignSelect, + flowTypeSelect, + rangeSelect + ); + return { - finishRate: createSpreadThresholdInput(document, "finishRate"), - interactionRate: createSpreadThresholdInput(document, "interactionRate") + enabledInput, + flowTypeSelect, + onlyAssignSelect, + rangeSelect, + root, + thresholdInput, + typeSelect }; } -function createSpreadThresholdInput( +function createSpreadMetricSelector( document: Document, - key: VisibleSpreadThresholdKey -): HTMLInputElement { - const input = document.createElement("input"); - input.type = "number"; - input.min = "0"; - input.step = getSpreadThresholdStep(key); - input.dataset.pluginSpreadThreshold = key; - return input; -} - -function getSpreadThresholdStep( - key: VisibleSpreadThresholdKey -): string { - return key === "finishRate" || key === "interactionRate" ? "0.1" : "1"; -} - -function createSpreadThresholdControls( - document: Document, - inputs: SpreadThresholdInputMap -): HTMLElement[] { - const controls: HTMLElement[] = []; - const entries: Array<[string, string, HTMLInputElement]> = [ - ["完播率", "%", inputs.finishRate], - ["互动率", "%", inputs.interactionRate] - ]; - - entries.forEach(([label, unit, input], index) => { - if (index > 0) { - controls.push(createSpreadThresholdConjunction(document)); - } - - const wrapper = document.createElement("label"); - wrapper.dataset.pluginSpreadThresholdControl = input.dataset.pluginSpreadThreshold; - applySpreadThresholdControlStyles(wrapper); + rules: SpreadMetricRuleDomMap +): HTMLElement { + const selector = document.createElement("div"); + selector.dataset.pluginSpreadMetrics = "root"; + applySpreadMetricSelectorStyles(selector); + SPREAD_FILTER_DEFINITIONS.forEach(({ label, metric }) => { + const option = document.createElement("label"); + applySpreadMetricOptionStyles(option); const labelText = document.createElement("span"); labelText.textContent = label; - - const operator = document.createElement("b"); - operator.dataset.pluginSpreadThresholdOperator = "gte"; - operator.textContent = "≥"; - - const unitText = document.createElement("span"); - unitText.dataset.pluginSpreadThresholdUnit = input.dataset.pluginSpreadThreshold; - unitText.textContent = unit; - - wrapper.append(labelText, operator, input, unitText); - controls.push(wrapper); + option.append(rules[metric].enabledInput, labelText); + selector.appendChild(option); }); - return controls; + return selector; } -function createSpreadThresholdConjunction(document: Document): HTMLElement { - const conjunction = document.createElement("span"); - conjunction.dataset.pluginSpreadThresholdConjunction = "and"; - conjunction.textContent = "且"; - applySpreadThresholdConjunctionStyles(conjunction); - return conjunction; +function createSpreadFilterNote(document: Document): HTMLElement { + const note = document.createElement("span"); + note.dataset.pluginSpreadFilterNote = "and"; + note.textContent = "全部规则都达标才保留达人"; + note.hidden = true; + applySpreadFilterNoteStyles(note); + return note; } -function readSpreadThresholdInputs( - root: HTMLElement -): SpreadThresholdInputMap { - return { - finishRate: readSpreadThresholdInput(root, "finishRate"), - interactionRate: readSpreadThresholdInput(root, "interactionRate") - }; -} - -function readSpreadThresholdInput( - root: HTMLElement, - key: VisibleSpreadThresholdKey -): HTMLInputElement { - return root.querySelector( - `[data-plugin-spread-threshold="${key}"]` - ) as HTMLInputElement; +function readSpreadMetricRuleDoms(root: HTMLElement): SpreadMetricRuleDomMap { + return Object.fromEntries( + SPREAD_FILTER_DEFINITIONS.map(({ metric }) => { + const ruleRoot = root.querySelector( + `[data-plugin-spread-rule="${metric}"]` + ) as HTMLElement; + return [ + metric, + { + enabledInput: root.querySelector( + `[data-plugin-spread-metric="${metric}"]` + ) as HTMLInputElement, + flowTypeSelect: ruleRoot.querySelector( + '[data-plugin-spread-filter="flowType"]' + ) as HTMLSelectElement, + onlyAssignSelect: ruleRoot.querySelector( + '[data-plugin-spread-filter="onlyAssign"]' + ) as HTMLSelectElement, + rangeSelect: ruleRoot.querySelector( + '[data-plugin-spread-filter="range"]' + ) as HTMLSelectElement, + root: ruleRoot, + thresholdInput: ruleRoot.querySelector( + `[data-plugin-spread-threshold="${metric}"]` + ) as HTMLInputElement, + typeSelect: ruleRoot.querySelector( + '[data-plugin-spread-filter="type"]' + ) as HTMLSelectElement + } satisfies SpreadMetricRuleDom + ]; + }) + ) as unknown as SpreadMetricRuleDomMap; } function readToolbarDom(root: HTMLElement): PluginToolbarDom { @@ -420,23 +441,11 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom { exportStatusText: root.querySelector( '[data-plugin-export-status="text"]' ) as HTMLElement, - spreadFilterFlowTypeSelect: root.querySelector( - '[data-plugin-spread-filter="flowType"]' - ) as HTMLSelectElement, - spreadFilterOnlyAssignSelect: root.querySelector( - '[data-plugin-spread-filter="onlyAssign"]' - ) as HTMLSelectElement, - spreadFilterRangeSelect: root.querySelector( - '[data-plugin-spread-filter="range"]' - ) as HTMLSelectElement, - spreadFilterTypeSelect: root.querySelector( - '[data-plugin-spread-filter="type"]' - ) as HTMLSelectElement, - spreadThresholdInputs: readSpreadThresholdInputs(root), + spreadMetricRules: readSpreadMetricRuleDoms(root), root } satisfies PluginToolbarDom; syncCustomPagesInputVisibility(toolbarDom); - syncSpreadFilterControlState(toolbarDom); + syncAllSpreadMetricRules(toolbarDom); return toolbarDom; } @@ -498,44 +507,34 @@ export function readToolbarExportTarget( export function readToolbarSpreadFilter( toolbar: PluginToolbarDom ): { error?: string; filter?: SpreadThresholdFilter } { - const thresholds: SpreadThresholdFilter["thresholds"] = {}; + const rules: SpreadMetricFilterRule[] = []; - for (const [key, input] of Object.entries(toolbar.spreadThresholdInputs)) { - const trimmedValue = input.value.trim(); - if (!trimmedValue) { + for (const { label, metric } of SPREAD_FILTER_DEFINITIONS) { + const ruleDom = toolbar.spreadMetricRules[metric]; + clearSpreadMetricRuleValidation(ruleDom); + if (!ruleDom.enabledInput.checked) { continue; } - const numericValue = Number(trimmedValue); - if (!Number.isFinite(numericValue) || numericValue < 0) { + const trimmedValue = ruleDom.thresholdInput.value.trim(); + const threshold = Number(trimmedValue); + if (!trimmedValue || !Number.isFinite(threshold) || threshold < 0) { + markSpreadMetricRuleInvalid(ruleDom); return { - error: "请输入有效筛选阈值" + error: `请输入有效的${label}筛选阈值` }; } - thresholds[key as keyof SpreadThresholdFilter["thresholds"]] = numericValue; + rules.push({ + config: readSpreadMetricConfig(ruleDom), + metric, + threshold + }); } - if (Object.keys(thresholds).length === 0) { - return {}; - } - - const type = Number(toolbar.spreadFilterTypeSelect.value) === 2 ? 2 : 1; return { filter: { - config: { - flowType: - type === 1 - ? 0 - : Number(toolbar.spreadFilterFlowTypeSelect.value) === 1 - ? 1 - : 0, - onlyAssign: - type === 1 ? false : toolbar.spreadFilterOnlyAssignSelect.value === "true", - range: Number(toolbar.spreadFilterRangeSelect.value) === 3 ? 3 : 2, - type - }, - thresholds + rules } }; } @@ -552,16 +551,19 @@ export function setToolbarBusyState( toolbar.exportButton, toolbar.exportRangeSelect, toolbar.exportCustomPagesInput, - toolbar.spreadFilterTypeSelect, - toolbar.spreadFilterOnlyAssignSelect, - toolbar.spreadFilterFlowTypeSelect, - toolbar.spreadFilterRangeSelect, - ...Object.values(toolbar.spreadThresholdInputs) + ...Object.values(toolbar.spreadMetricRules).flatMap((rule) => [ + rule.enabledInput, + rule.thresholdInput, + rule.typeSelect, + rule.onlyAssignSelect, + rule.flowTypeSelect, + rule.rangeSelect + ]) ].forEach((element) => { element.disabled = isBusy; }); if (!isBusy) { - syncSpreadFilterControlState(toolbar); + syncAllSpreadMetricRules(toolbar); } } @@ -577,14 +579,68 @@ function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void { toolbar.exportCustomPagesInput.hidden = toolbar.exportRangeSelect.value !== "custom"; } -function syncSpreadFilterControlState(toolbar: PluginToolbarDom): void { - const isPersonalVideo = toolbar.spreadFilterTypeSelect.value !== "2"; - if (isPersonalVideo) { - toolbar.spreadFilterOnlyAssignSelect.value = "false"; - toolbar.spreadFilterFlowTypeSelect.value = "0"; +function syncAllSpreadMetricRules(toolbar: PluginToolbarDom): void { + Object.values(toolbar.spreadMetricRules).forEach(syncSpreadMetricRuleState); + syncSpreadFilterNote(toolbar); +} + +function syncSpreadMetricRuleState(rule: SpreadMetricRuleDom): void { + rule.root.hidden = !rule.enabledInput.checked; + if (!rule.enabledInput.checked) { + rule.thresholdInput.value = ""; + rule.typeSelect.value = "1"; + rule.onlyAssignSelect.value = "false"; + rule.flowTypeSelect.value = "0"; + rule.rangeSelect.value = "2"; + clearSpreadMetricRuleValidation(rule); } - toolbar.spreadFilterOnlyAssignSelect.disabled = isPersonalVideo; - toolbar.spreadFilterFlowTypeSelect.disabled = isPersonalVideo; + syncSpreadMetricVideoConstraints(rule); +} + +function syncSpreadMetricVideoConstraints(rule: SpreadMetricRuleDom): void { + const isPersonalVideo = rule.typeSelect.value !== "2"; + if (isPersonalVideo) { + rule.onlyAssignSelect.value = "false"; + rule.flowTypeSelect.value = "0"; + } + rule.onlyAssignSelect.disabled = isPersonalVideo; + rule.flowTypeSelect.disabled = isPersonalVideo; +} + +function syncSpreadFilterNote(toolbar: PluginToolbarDom): void { + const note = toolbar.root.querySelector( + '[data-plugin-spread-filter-note="and"]' + ) as HTMLElement | null; + if (!note) { + return; + } + + const enabledCount = Object.values(toolbar.spreadMetricRules).filter( + (rule) => rule.enabledInput.checked + ).length; + note.hidden = enabledCount < 2; +} + +function readSpreadMetricConfig(rule: SpreadMetricRuleDom): SpreadInfoConfig { + const type = rule.typeSelect.value === "2" ? 2 : 1; + return { + flowType: + type === 1 ? 0 : rule.flowTypeSelect.value === "1" ? 1 : 0, + onlyAssign: + type === 1 ? false : rule.onlyAssignSelect.value === "true", + range: rule.rangeSelect.value === "3" ? 3 : 2, + type + }; +} + +function clearSpreadMetricRuleValidation(rule: SpreadMetricRuleDom): void { + delete rule.root.dataset.pluginSpreadRuleInvalid; + rule.thresholdInput.removeAttribute("aria-invalid"); +} + +function markSpreadMetricRuleInvalid(rule: SpreadMetricRuleDom): void { + rule.root.dataset.pluginSpreadRuleInvalid = "true"; + rule.thresholdInput.setAttribute("aria-invalid", "true"); } function ensureToolbarMounted(root: HTMLElement, document: Document): void { @@ -794,17 +850,61 @@ function applyToolbarGroupStyles(group: HTMLElement): void { group.style.flexWrap = "nowrap"; } -function applyThresholdGroupStyles(group: HTMLElement): void { +function applySpreadMetricSelectorStyles(selector: HTMLElement): void { + selector.style.display = "flex"; + selector.style.alignItems = "center"; + selector.style.gap = "12px"; + selector.style.flex = "0 0 auto"; + selector.style.whiteSpace = "nowrap"; +} + +function applySpreadMetricOptionStyles(option: HTMLElement): void { + option.style.display = "inline-flex"; + option.style.alignItems = "center"; + option.style.gap = "5px"; + option.style.height = "32px"; + option.style.color = "#344054"; + option.style.fontSize = "12px"; + option.style.fontWeight = "700"; + option.style.whiteSpace = "nowrap"; +} + +function applySpreadRulesGroupStyles(group: HTMLElement): void { group.style.display = "flex"; - group.style.alignItems = "center"; - group.style.gap = "7px"; + group.style.flexDirection = "column"; + group.style.alignItems = "stretch"; + group.style.gap = "6px"; group.style.minWidth = "0"; group.style.flex = "1 1 auto"; - group.style.flexWrap = "nowrap"; group.style.overflowX = "auto"; group.style.overflowY = "hidden"; } +function applySpreadMetricRuleStyles(rule: HTMLElement): void { + rule.style.display = "flex"; + rule.style.alignItems = "center"; + rule.style.gap = "7px"; + rule.style.minWidth = "max-content"; + rule.style.minHeight = "32px"; + rule.style.whiteSpace = "nowrap"; +} + +function applySpreadMetricRuleLabelStyles(label: HTMLElement): void { + label.style.width = "48px"; + label.style.color = "#344054"; + label.style.fontSize = "12px"; + label.style.fontWeight = "800"; + label.style.flex = "0 0 auto"; +} + +function applySpreadFilterNoteStyles(note: HTMLElement): void { + note.style.color = "#0f8a5f"; + note.style.fontSize = "12px"; + note.style.fontWeight = "800"; + note.style.whiteSpace = "nowrap"; + note.style.flex = "0 0 auto"; +} + function createToolbarGroupTitle(document: Document, label: string): HTMLElement { const title = document.createElement("span"); title.dataset.pluginToolbarTitle = label; @@ -834,14 +934,8 @@ function applyNativeControlStyles( exportButton: HTMLButtonElement; exportCustomPagesInput: HTMLInputElement; exportRangeSelect: HTMLSelectElement; - spreadFilterFlowTypeSelect: HTMLSelectElement; - spreadFilterOnlyAssignSelect: HTMLSelectElement; - spreadFilterRangeSelect: HTMLSelectElement; - spreadFilterTypeSelect: HTMLSelectElement; - } & Record< - VisibleSpreadThresholdKey, - HTMLInputElement - > + spreadMetricRules: SpreadMetricRuleDomMap; + } ): void { const primaryButton = findButtonContainingText(document, "发布任务") ?? @@ -870,13 +964,31 @@ function applyNativeControlStyles( button.style.whiteSpace = "nowrap"; }); - const nativeControls = Array.from(Object.values(controls)).filter( - (element): element is HTMLInputElement | HTMLSelectElement => - element instanceof document.defaultView!.HTMLInputElement || - element instanceof document.defaultView!.HTMLSelectElement + const ruleControls = Object.values(controls.spreadMetricRules).flatMap( + (rule) => [ + rule.enabledInput, + rule.thresholdInput, + rule.typeSelect, + rule.onlyAssignSelect, + rule.flowTypeSelect, + rule.rangeSelect + ] ); + const nativeControls = [ + controls.exportCustomPagesInput, + controls.exportRangeSelect, + ...ruleControls + ]; nativeControls.forEach((element) => { + if (element instanceof document.defaultView!.HTMLInputElement && element.type === "checkbox") { + element.style.width = "16px"; + element.style.height = "16px"; + element.style.padding = "0"; + element.style.accentColor = "#7f1d2d"; + element.style.flex = "0 0 auto"; + return; + } element.style.height = "32px"; element.style.border = "1px solid #d0d7de"; element.style.borderRadius = "6px"; @@ -890,22 +1002,15 @@ function applyNativeControlStyles( controls.exportRangeSelect.style.minWidth = "104px"; controls.exportCustomPagesInput.style.width = "72px"; - [ - controls.spreadFilterTypeSelect, - controls.spreadFilterOnlyAssignSelect, - controls.spreadFilterFlowTypeSelect, - controls.spreadFilterRangeSelect - ].forEach((select) => { - select.style.minWidth = "84px"; - }); - - Object.values(controls).forEach((element) => { + ruleControls.forEach((element) => { + if (element instanceof document.defaultView!.HTMLSelectElement) { + element.style.minWidth = "84px"; + } if ( element instanceof document.defaultView!.HTMLInputElement && element.dataset.pluginSpreadThreshold ) { - element.style.width = - element.dataset.pluginSpreadThreshold === "playMedian" ? "82px" : "58px"; + element.style.width = "58px"; element.style.minWidth = "0"; element.style.height = "26px"; element.style.border = "0"; @@ -945,14 +1050,6 @@ function applySpreadThresholdControlStyles(control: HTMLElement): void { control.style.flex = "0 0 auto"; } -function applySpreadThresholdConjunctionStyles(conjunction: HTMLElement): void { - conjunction.style.color = "#0f8a5f"; - conjunction.style.fontSize = "12px"; - conjunction.style.fontWeight = "900"; - conjunction.style.whiteSpace = "nowrap"; - conjunction.style.flex = "0 0 auto"; -} - function applyStatusStyles(statusText: HTMLElement): void { statusText.style.color = "#64748b"; statusText.style.fontSize = "12px"; @@ -1026,6 +1123,11 @@ function ensurePluginActionButtonTheme(document: Document): void { color: #0f8a5f !important; font-weight: 900 !important; } + + [data-plugin-spread-rule-invalid="true"] [data-plugin-spread-threshold-control] { + border-color: #dc2626 !important; + box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12) !important; + } `; document.head.appendChild(style); } diff --git a/src/content/market/spread-info.ts b/src/content/market/spread-info.ts index 44d351d..17f8ca3 100644 --- a/src/content/market/spread-info.ts +++ b/src/content/market/spread-info.ts @@ -1,8 +1,7 @@ import type { SpreadInfoConfig, SpreadInfoMetrics, - SpreadMetricFilterRule, - SpreadMetricThresholds + SpreadMetricFilterRule } from "./types"; interface FetchResponseLike { @@ -205,21 +204,6 @@ export function mapSpreadInfoResponse( }; } -export function matchesSpreadThresholds( - metrics: MappedSpreadInfoResponse, - thresholds: SpreadMetricThresholds -): boolean { - return Object.entries(thresholds).every(([key, threshold]) => { - if (typeof threshold !== "number" || !Number.isFinite(threshold)) { - return true; - } - - const metricValue = metrics[key as keyof SpreadMetricThresholds]; - const numericValue = readDisplayNumber(metricValue); - return numericValue !== null && numericValue >= threshold; - }); -} - export function normalizeSpreadInfoConfig( config: SpreadInfoConfig ): SpreadInfoConfig { diff --git a/src/content/market/types.ts b/src/content/market/types.ts index e90247f..5648214 100644 --- a/src/content/market/types.ts +++ b/src/content/market/types.ts @@ -29,19 +29,8 @@ export interface SpreadMetricFilterRule { threshold: number; } -export interface SpreadMetricThresholds { - averageCommentCount?: number; - averageDuration?: number; - averageLikeCount?: number; - averageShareCount?: number; - finishRate?: number; - interactionRate?: number; - playMedian?: number; -} - export interface SpreadThresholdFilter { - config: SpreadInfoConfig; - thresholds: SpreadMetricThresholds; + rules: SpreadMetricFilterRule[]; } export type MarketSortField = diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index cafd3e2..a48ee2a 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -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(); }