feat: make metric filters field-driven
This commit is contained in:
@@ -127,6 +127,20 @@ export function listAudienceProfileCsvHeaders(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildAudienceProfileFieldValues(
|
||||||
|
row: AudienceProfileExportRow
|
||||||
|
): Record<string, string> {
|
||||||
|
const columns = deduplicateCsvColumns([
|
||||||
|
...buildMarketCsvColumns([row.record]).map(toMarketColumn),
|
||||||
|
...buildBusinessEstimateColumns(),
|
||||||
|
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
columns.map((column) => [column.header, column.readValue(row)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function listAudienceProfileSelectableFieldGroups(
|
export function listAudienceProfileSelectableFieldGroups(
|
||||||
marketListHeaders: string[] = []
|
marketListHeaders: string[] = []
|
||||||
): AudienceProfileCsvFieldGroup[] {
|
): AudienceProfileCsvFieldGroup[] {
|
||||||
|
|||||||
+64
-63
@@ -4,6 +4,7 @@ import {
|
|||||||
listRateCsvHeaders
|
listRateCsvHeaders
|
||||||
} from "./csv-exporter";
|
} from "./csv-exporter";
|
||||||
import {
|
import {
|
||||||
|
buildAudienceProfileFieldValues,
|
||||||
buildAudienceProfileCsv,
|
buildAudienceProfileCsv,
|
||||||
listAudienceProfileSelectableFieldGroups,
|
listAudienceProfileSelectableFieldGroups,
|
||||||
type AudienceProfileCsvOptions
|
type AudienceProfileCsvOptions
|
||||||
@@ -52,16 +53,16 @@ import { applyFilterAndSort } from "./filter-sort-controller";
|
|||||||
import { createMarketApiClient } from "./api-client";
|
import { createMarketApiClient } from "./api-client";
|
||||||
import { createExportRangeController } from "./export-range-controller";
|
import { createExportRangeController } from "./export-range-controller";
|
||||||
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
|
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
|
||||||
|
import {
|
||||||
|
listNumericMetricFilterDefinitions,
|
||||||
|
matchesMetricFilterRule
|
||||||
|
} from "./metric-filter";
|
||||||
import { createSilentExportController } from "./silent-export-controller";
|
import { createSilentExportController } from "./silent-export-controller";
|
||||||
import {
|
import {
|
||||||
buildSpreadInfoConfigKey,
|
|
||||||
createSpreadInfoClient,
|
createSpreadInfoClient,
|
||||||
DEFAULT_SPREAD_INFO_CONFIGS,
|
DEFAULT_SPREAD_INFO_CONFIGS,
|
||||||
filterUnloadedSpreadInfoConfigs,
|
filterUnloadedSpreadInfoConfigs,
|
||||||
matchesSpreadMetricRule,
|
selectSpreadInfoConfigsForHeaders
|
||||||
normalizeSpreadInfoConfig,
|
|
||||||
selectSpreadInfoConfigsForHeaders,
|
|
||||||
type MappedSpreadInfoResponse
|
|
||||||
} from "./spread-info";
|
} from "./spread-info";
|
||||||
import {
|
import {
|
||||||
readToolbarExportTarget,
|
readToolbarExportTarget,
|
||||||
@@ -88,9 +89,8 @@ import type {
|
|||||||
MarketRecord,
|
MarketRecord,
|
||||||
MarketRowSnapshot,
|
MarketRowSnapshot,
|
||||||
MarketSortState,
|
MarketSortState,
|
||||||
|
MetricThresholdFilter,
|
||||||
SpreadInfoConfig,
|
SpreadInfoConfig,
|
||||||
SpreadMetricFilterRule,
|
|
||||||
SpreadThresholdFilter
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
interface MutationObserverLike {
|
interface MutationObserverLike {
|
||||||
@@ -127,10 +127,6 @@ export interface CreateMarketControllerOptions {
|
|||||||
target: AudienceProfileRequestTarget
|
target: AudienceProfileRequestTarget
|
||||||
) => Promise<AudienceProfileResult>;
|
) => Promise<AudienceProfileResult>;
|
||||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||||
loadSpreadFilterMetrics?: (
|
|
||||||
spreadAuthorId: string,
|
|
||||||
config: SpreadInfoConfig
|
|
||||||
) => Promise<MappedSpreadInfoResponse>;
|
|
||||||
loadSpreadMetrics?: (
|
loadSpreadMetrics?: (
|
||||||
spreadAuthorId: string,
|
spreadAuthorId: string,
|
||||||
configs?: SpreadInfoConfig[]
|
configs?: SpreadInfoConfig[]
|
||||||
@@ -169,9 +165,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const resultStore = options.resultStore ?? createMarketResultStore();
|
const resultStore = options.resultStore ?? createMarketResultStore();
|
||||||
const loadAuthorMetrics =
|
const loadAuthorMetrics =
|
||||||
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
|
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
|
||||||
const loadSpreadFilterMetrics =
|
|
||||||
options.loadSpreadFilterMetrics ??
|
|
||||||
spreadInfoClient.loadAuthorSpreadMetricSnapshot;
|
|
||||||
const loadSpreadMetrics =
|
const loadSpreadMetrics =
|
||||||
options.loadSpreadMetrics ?? spreadInfoClient.loadAuthorSpreadMetrics;
|
options.loadSpreadMetrics ?? spreadInfoClient.loadAuthorSpreadMetrics;
|
||||||
const searchBackendMetrics =
|
const searchBackendMetrics =
|
||||||
@@ -520,7 +513,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
setToolbarBusyState(toolbar, true);
|
setToolbarBusyState(toolbar, true);
|
||||||
try {
|
try {
|
||||||
const hasSelectedAuthors = selectedAuthorIds.size > 0;
|
const hasSelectedAuthors = selectedAuthorIds.size > 0;
|
||||||
const resolvedRecords = await applySpreadThresholdFilter(
|
const resolvedRecords = await applyMetricThresholdFilter(
|
||||||
await exportRecords(
|
await exportRecords(
|
||||||
exportTarget.target,
|
exportTarget.target,
|
||||||
hasSelectedAuthors ? "提交已选达人中" : "提交中",
|
hasSelectedAuthors ? "提交已选达人中" : "提交中",
|
||||||
@@ -566,7 +559,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
toolbar = ensurePluginToolbar(
|
||||||
|
options.document,
|
||||||
|
toolbarHandlers,
|
||||||
|
readMetricFilterDefinitions()
|
||||||
|
);
|
||||||
|
|
||||||
const ready = (async () => {
|
const ready = (async () => {
|
||||||
await runSyncCycle();
|
await runSyncCycle();
|
||||||
@@ -739,7 +736,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
|
|
||||||
function applyCurrentView(): void {
|
function applyCurrentView(): void {
|
||||||
runWithoutMutationSync(() => {
|
runWithoutMutationSync(() => {
|
||||||
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
toolbar = ensurePluginToolbar(
|
||||||
|
options.document,
|
||||||
|
toolbarHandlers,
|
||||||
|
readMetricFilterDefinitions()
|
||||||
|
);
|
||||||
const table = syncMarketTable(options.document);
|
const table = syncMarketTable(options.document);
|
||||||
if (!table) {
|
if (!table) {
|
||||||
return;
|
return;
|
||||||
@@ -1125,60 +1126,49 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return selectedRecords.length > 0 ? selectedRecords : records;
|
return selectedRecords.length > 0 ? selectedRecords : records;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applySpreadThresholdFilter(
|
async function applyMetricThresholdFilter(
|
||||||
records: MarketRecord[],
|
records: MarketRecord[],
|
||||||
filter: SpreadThresholdFilter | undefined
|
filter: MetricThresholdFilter | undefined
|
||||||
): Promise<MarketRecord[]> {
|
): Promise<MarketRecord[]> {
|
||||||
if (!filter || filter.rules.length === 0) {
|
if (!filter || filter.rules.length === 0) {
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedRules = filter.rules.map((rule) => ({
|
const filterSelection = resolveAudienceProfileExportSelection(
|
||||||
...rule,
|
filter.rules.map((rule) => rule.field)
|
||||||
config: normalizeSpreadInfoConfig(rule.config)
|
);
|
||||||
}));
|
const hydratedRecords = await hydrateExportRecords(records, {
|
||||||
const configsByKey = new Map<string, SpreadInfoConfig>();
|
includeBackendMetrics: filterSelection.includeBackendMetrics,
|
||||||
normalizedRules.forEach((rule) => {
|
includeRates: filterSelection.includeRates,
|
||||||
configsByKey.set(buildSpreadInfoConfigKey(rule.config), rule.config);
|
spreadInfoConfigs: filterSelection.spreadInfoConfigs
|
||||||
});
|
});
|
||||||
|
const matchedRecords: MarketRecord[] = [];
|
||||||
|
|
||||||
const matchedAuthorIds = new Set<string>();
|
for (let index = 0; index < hydratedRecords.length; index += 1) {
|
||||||
await Promise.all(
|
const record = hydratedRecords[index];
|
||||||
records.map(async (record) => {
|
setToolbarExportStatus(
|
||||||
const spreadAuthorId = record.spreadAuthorId;
|
toolbar,
|
||||||
if (!spreadAuthorId) {
|
`指标筛选 ${index + 1}/${hydratedRecords.length}...`
|
||||||
return;
|
);
|
||||||
}
|
const [profiles, businessAbility] = await Promise.all([
|
||||||
|
loadAudienceProfileSet(record, filterSelection),
|
||||||
|
filterSelection.businessAbility
|
||||||
|
? loadBusinessAbilitySafe(record)
|
||||||
|
: Promise.resolve(undefined)
|
||||||
|
]);
|
||||||
|
const values = buildAudienceProfileFieldValues(
|
||||||
|
finalizeAudienceProfileExportRow({
|
||||||
|
businessAbility,
|
||||||
|
profiles,
|
||||||
|
record
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (filter.rules.every((rule) => matchesMetricFilterRule(values, rule))) {
|
||||||
|
matchedRecords.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const snapshots = new Map<string, MappedSpreadInfoResponse>();
|
return matchedRecords;
|
||||||
await Promise.all(
|
|
||||||
Array.from(configsByKey.entries()).map(async ([key, config]) => {
|
|
||||||
snapshots.set(
|
|
||||||
key,
|
|
||||||
await loadSpreadFilterMetrics(spreadAuthorId, config)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchesAllSpreadMetricRules(normalizedRules, snapshots)) {
|
|
||||||
matchedAuthorIds.add(record.authorId);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return records.filter((record) => matchedAuthorIds.has(record.authorId));
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesAllSpreadMetricRules(
|
|
||||||
rules: SpreadMetricFilterRule[],
|
|
||||||
snapshots: Map<string, MappedSpreadInfoResponse>
|
|
||||||
): boolean {
|
|
||||||
return rules.every((rule) =>
|
|
||||||
matchesSpreadMetricRule(
|
|
||||||
snapshots.get(buildSpreadInfoConfigKey(rule.config)) ?? {},
|
|
||||||
rule
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
|
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
|
||||||
@@ -1461,6 +1451,13 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readMetricFilterDefinitions() {
|
||||||
|
const marketListHeaders = buildBaseColumns(
|
||||||
|
readCurrentPageRecords(syncMarketTable(options.document))
|
||||||
|
).map((column) => column.header);
|
||||||
|
return listNumericMetricFilterDefinitions(marketListHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
function readAudienceProfileSelectedHeaders(): string[] | undefined {
|
function readAudienceProfileSelectedHeaders(): string[] | undefined {
|
||||||
try {
|
try {
|
||||||
const rawValue = options.window.localStorage?.getItem(
|
const rawValue = options.window.localStorage?.getItem(
|
||||||
@@ -2049,7 +2046,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runSingleSyncCycle(): Promise<void> {
|
async function runSingleSyncCycle(): Promise<void> {
|
||||||
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
toolbar = ensurePluginToolbar(
|
||||||
|
options.document,
|
||||||
|
toolbarHandlers,
|
||||||
|
readMetricFilterDefinitions()
|
||||||
|
);
|
||||||
if (isPluginToolbarMounted(toolbar.root, options.document)) {
|
if (isPluginToolbarMounted(toolbar.root, options.document)) {
|
||||||
toolbarRemountScheduled = false;
|
toolbarRemountScheduled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
listAudienceProfileSelectableFieldGroups,
|
||||||
|
type AudienceProfileCsvFieldGroup
|
||||||
|
} from "./audience-profile-csv";
|
||||||
|
import type { MetricFilterRule } from "./types";
|
||||||
|
|
||||||
|
export interface MetricFilterDefinition {
|
||||||
|
field: string;
|
||||||
|
group: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NUMERIC_MARKET_LIST_FIELDS = new Set([
|
||||||
|
"连接用户数",
|
||||||
|
"粉丝数",
|
||||||
|
"预期CPM",
|
||||||
|
"预期播放量",
|
||||||
|
"互动率",
|
||||||
|
"完播率",
|
||||||
|
"爆文率",
|
||||||
|
"21-60s报价"
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function listNumericMetricFilterGroups(
|
||||||
|
marketListHeaders: string[] = []
|
||||||
|
): AudienceProfileCsvFieldGroup[] {
|
||||||
|
return listAudienceProfileSelectableFieldGroups(marketListHeaders)
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
headers: group.headers.filter((header) =>
|
||||||
|
group.label === "列表字段"
|
||||||
|
? NUMERIC_MARKET_LIST_FIELDS.has(header)
|
||||||
|
: true
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
.filter((group) => group.headers.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listNumericMetricFilterDefinitions(
|
||||||
|
marketListHeaders: string[] = []
|
||||||
|
): MetricFilterDefinition[] {
|
||||||
|
return listNumericMetricFilterGroups(marketListHeaders).flatMap((group) =>
|
||||||
|
group.headers.map((field) => ({ field, group: group.label }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesMetricFilterRule(
|
||||||
|
values: Record<string, string>,
|
||||||
|
rule: MetricFilterRule
|
||||||
|
): boolean {
|
||||||
|
const value = parseDisplayNumber(values[rule.field]);
|
||||||
|
if (value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rule.operator === "gte"
|
||||||
|
? value >= rule.threshold
|
||||||
|
: value <= rule.threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDisplayNumber(value: string | undefined): number | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim().replace(/[\s,¥¥]/g, "");
|
||||||
|
if (!normalized || normalized === "缺失") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = normalized.endsWith("w") || normalized.endsWith("万")
|
||||||
|
? 10_000
|
||||||
|
: normalized.endsWith("亿")
|
||||||
|
? 100_000_000
|
||||||
|
: 1;
|
||||||
|
const numericText = normalized
|
||||||
|
.replace(/[%w万亿]/g, "")
|
||||||
|
.replace(/^\+/, "");
|
||||||
|
const numericValue = Number(numericText);
|
||||||
|
return Number.isFinite(numericValue) ? numericValue * suffix : null;
|
||||||
|
}
|
||||||
+419
-1185
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,18 @@ export interface SpreadThresholdFilter {
|
|||||||
rules: SpreadMetricFilterRule[];
|
rules: SpreadMetricFilterRule[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MetricFilterOperator = "gte" | "lte";
|
||||||
|
|
||||||
|
export interface MetricFilterRule {
|
||||||
|
field: string;
|
||||||
|
operator: MetricFilterOperator;
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricThresholdFilter {
|
||||||
|
rules: MetricFilterRule[];
|
||||||
|
}
|
||||||
|
|
||||||
export type MarketSortField =
|
export type MarketSortField =
|
||||||
| keyof Required<AfterSearchRates>
|
| keyof Required<AfterSearchRates>
|
||||||
| keyof Required<BackendMetrics>;
|
| keyof Required<BackendMetrics>;
|
||||||
|
|||||||
+143
-328
@@ -442,10 +442,10 @@ describe("market-content-entry", () => {
|
|||||||
'[data-plugin-toolbar-action-group="batch-submit"]'
|
'[data-plugin-toolbar-action-group="batch-submit"]'
|
||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
const metricCatalog = document.querySelector(
|
const metricCatalog = document.querySelector(
|
||||||
'[data-plugin-spread-metric-catalog="root"]'
|
'[data-plugin-metric-catalog="root"]'
|
||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
const rulesGroup = document.querySelector(
|
const rulesGroup = document.querySelector(
|
||||||
'[data-plugin-spread-rules="root"]'
|
'[data-plugin-metric-rules="root"]'
|
||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
const statusText = document.querySelector(
|
const statusText = document.querySelector(
|
||||||
'[data-plugin-export-status="text"]'
|
'[data-plugin-export-status="text"]'
|
||||||
@@ -472,13 +472,13 @@ describe("market-content-entry", () => {
|
|||||||
'[data-plugin-batch-submit="button"]'
|
'[data-plugin-batch-submit="button"]'
|
||||||
) as HTMLButtonElement | null;
|
) as HTMLButtonElement | null;
|
||||||
const operators = Array.from(
|
const operators = Array.from(
|
||||||
document.querySelectorAll("[data-plugin-spread-threshold-operator]")
|
document.querySelectorAll("[data-plugin-metric-filter-operator]")
|
||||||
).map((element) => element.textContent);
|
).map((element) => element.textContent);
|
||||||
const ruleRows = Array.from(
|
const ruleRows = Array.from(
|
||||||
document.querySelectorAll("[data-plugin-spread-rule]")
|
document.querySelectorAll("[data-plugin-metric-filter-rule]")
|
||||||
) as HTMLElement[];
|
) as HTMLElement[];
|
||||||
const thresholdInputs = Array.from(
|
const thresholdInputs = Array.from(
|
||||||
document.querySelectorAll("[data-plugin-spread-threshold]")
|
document.querySelectorAll("[data-plugin-metric-filter-threshold]")
|
||||||
) as HTMLInputElement[];
|
) as HTMLInputElement[];
|
||||||
|
|
||||||
expect(toolbar?.style.flexWrap).toBe("nowrap");
|
expect(toolbar?.style.flexWrap).toBe("nowrap");
|
||||||
@@ -515,32 +515,25 @@ describe("market-content-entry", () => {
|
|||||||
expect(primaryRow?.style.justifyContent).toBe("flex-start");
|
expect(primaryRow?.style.justifyContent).toBe("flex-start");
|
||||||
expect(thresholdRow?.style.justifyContent).toBe("flex-start");
|
expect(thresholdRow?.style.justifyContent).toBe("flex-start");
|
||||||
expect(titles.map((element) => element.textContent)).toEqual([
|
expect(titles.map((element) => element.textContent)).toEqual([
|
||||||
"传播指标筛选"
|
"指标筛选"
|
||||||
]);
|
]);
|
||||||
expect(titles[0]?.style.background).toBe("rgb(238, 245, 255)");
|
expect(titles[0]?.style.background).toBe("rgb(238, 245, 255)");
|
||||||
expect(operators).toEqual(["≥", "≥"]);
|
expect(operators).toEqual([]);
|
||||||
expect(
|
expect(
|
||||||
document.querySelector('[data-plugin-spread-metric-catalog-trigger="button"]')
|
document.querySelector('[data-plugin-metric-catalog-trigger="button"]')
|
||||||
?.textContent
|
?.textContent
|
||||||
).toBe("添加筛选指标");
|
).toBe("添加筛选指标");
|
||||||
expect(
|
expect(
|
||||||
document.querySelector('[data-plugin-spread-metric-selected-count="text"]')
|
document.querySelector('[data-plugin-metric-selected-count="text"]')
|
||||||
?.textContent
|
?.textContent
|
||||||
).toBe("已选 1 项");
|
).toBe("已选 0 项");
|
||||||
expect(
|
expect(
|
||||||
(document.querySelector(
|
(document.querySelector(
|
||||||
'[data-plugin-spread-metric-catalog-panel="root"]'
|
'[data-plugin-metric-catalog-panel="root"]'
|
||||||
) as HTMLElement | null)?.hidden
|
) as HTMLElement | null)?.hidden
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(ruleRows.map((row) => row.hidden)).toEqual([false, true]);
|
expect(ruleRows).toEqual([]);
|
||||||
expect(thresholdInputs.map((input) => input.placeholder)).toEqual([
|
expect(thresholdInputs).toEqual([]);
|
||||||
"",
|
|
||||||
""
|
|
||||||
]);
|
|
||||||
expect(thresholdInputs.map((input) => input.step)).toEqual([
|
|
||||||
"0.1",
|
|
||||||
"0.1"
|
|
||||||
]);
|
|
||||||
expect([
|
expect([
|
||||||
audienceProfileExportButton?.textContent,
|
audienceProfileExportButton?.textContent,
|
||||||
audienceProfileByIdExportButton?.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();
|
document.body.innerHTML = buildMarketFixture();
|
||||||
|
|
||||||
const { createMarketController } = await import("../src/content/market/index");
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
const controller = trackController(createMarketController({
|
const controller = trackController(createMarketController({
|
||||||
document,
|
document,
|
||||||
loadAuthorMetrics: async () => ({
|
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
|
||||||
success: false,
|
|
||||||
reason: "request-failed"
|
|
||||||
}),
|
|
||||||
window
|
window
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await controller.ready;
|
await controller.ready;
|
||||||
|
|
||||||
const catalogTrigger = document.querySelector(
|
const field = "内容数据-个人视频-近30天-完播率";
|
||||||
'[data-plugin-spread-metric-catalog-trigger="button"]'
|
|
||||||
) as HTMLButtonElement | null;
|
|
||||||
const catalogPanel = document.querySelector(
|
const catalogPanel = document.querySelector(
|
||||||
'[data-plugin-spread-metric-catalog-panel="root"]'
|
'[data-plugin-metric-catalog-panel="root"]'
|
||||||
) as HTMLElement | null;
|
) 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?.hidden).toBe(true);
|
||||||
expect(catalogPanel?.style.zIndex).toBe("100");
|
expect(catalogPanel?.style.zIndex).toBe("100");
|
||||||
expect(finishRule?.hidden).toBe(false);
|
expect(document.querySelector('[data-plugin-metric-selected-count="text"]')?.textContent).toBe("已选 0 项");
|
||||||
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");
|
|
||||||
|
|
||||||
catalogTrigger?.click();
|
click('[data-plugin-metric-catalog-trigger="button"]');
|
||||||
expect(catalogPanel?.hidden).toBe(false);
|
const groupLabels = Array.from(
|
||||||
click('[data-plugin-spread-metric-catalog-action="interactionRate"]');
|
document.querySelectorAll('[data-plugin-metric-catalog-group] > strong')
|
||||||
setSpreadRuleSelect("finishRate", "type", "2");
|
).map((element) => element.textContent);
|
||||||
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
|
expect(groupLabels).toEqual(expect.arrayContaining([
|
||||||
setSpreadRuleSelect("finishRate", "flowType", "1");
|
"列表字段", "看后搜率", "秒思api数据", "内容数据", "效果预估", "观众画像", "粉丝画像", "铁粉画像"
|
||||||
setSpreadRuleSelect("interactionRate", "type", "2");
|
]));
|
||||||
setSpreadRuleSelect("interactionRate", "onlyAssign", "true");
|
const search = document.querySelector(
|
||||||
setSpreadRuleSelect("interactionRate", "flowType", "1");
|
'[data-plugin-metric-catalog-search="input"]'
|
||||||
setSpreadRuleSelect("interactionRate", "type", "1");
|
) 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(
|
clickMetricCatalogAction(field);
|
||||||
"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"]');
|
|
||||||
expect(catalogPanel?.hidden).toBe(true);
|
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", {
|
clickMetricRuleRemove(field);
|
||||||
configurable: true,
|
expect(findMetricRule(field)).toBeNull();
|
||||||
value: originalInnerWidth
|
expect(findMetricCatalogAction(field)?.disabled).toBe(false);
|
||||||
});
|
expect(findMetricCatalogAction(field)?.textContent).toBe("添加");
|
||||||
window.dispatchEvent(new Event("resize"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
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(
|
const { ensurePluginToolbar, readToolbarSpreadFilter } = await import(
|
||||||
"../src/content/market/plugin-toolbar"
|
"../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({
|
expect(readToolbarSpreadFilter(toolbar)).toEqual({
|
||||||
error: "请输入有效的完播率筛选阈值"
|
error: `请输入有效的${field}筛选数值`
|
||||||
});
|
});
|
||||||
|
|
||||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
setInputValueForMetric(field, "30");
|
||||||
click('[data-plugin-spread-metric-catalog-trigger="button"]');
|
setMetricOperator(field, "lte");
|
||||||
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");
|
|
||||||
|
|
||||||
expect(readToolbarSpreadFilter(toolbar)).toEqual({
|
expect(readToolbarSpreadFilter(toolbar)).toEqual({
|
||||||
filter: {
|
filter: {
|
||||||
rules: [
|
rules: [{ field, operator: "lte", threshold: 30 }]
|
||||||
{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
test("audience profile export requires selected creators outside of the all range", async () => {
|
||||||
document.body.innerHTML = buildRealMarketFixture([
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
@@ -2551,7 +2348,7 @@ describe("market-content-entry", () => {
|
|||||||
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
|
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([
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
||||||
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
||||||
@@ -2573,17 +2370,12 @@ describe("market-content-entry", () => {
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
const submitBatch = vi.fn(async () => ({ ok: true }));
|
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||||
const loadSpreadFilterMetrics = vi.fn(async (
|
const loadSpreadMetrics = vi.fn(async (spreadAuthorId: string) => {
|
||||||
spreadAuthorId: string,
|
|
||||||
config: SpreadInfoConfig
|
|
||||||
) => {
|
|
||||||
if (config.type === 2) {
|
|
||||||
return {
|
|
||||||
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
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,
|
success: false,
|
||||||
reason: "request-failed"
|
reason: "request-failed"
|
||||||
}),
|
}),
|
||||||
loadSpreadFilterMetrics,
|
loadSpreadMetrics,
|
||||||
promptBatchName: () => "筛选批次",
|
promptBatchName: () => "筛选批次",
|
||||||
submitBatch,
|
submitBatch,
|
||||||
window
|
window
|
||||||
@@ -2608,13 +2400,14 @@ describe("market-content-entry", () => {
|
|||||||
await controller.ready;
|
await controller.ready;
|
||||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||||
dispatchChange('[data-plugin-export-range="select"]');
|
dispatchChange('[data-plugin-export-range="select"]');
|
||||||
enableSpreadMetric("finishRate");
|
const finishField = "内容数据-个人视频-近30天-完播率";
|
||||||
enableSpreadMetric("interactionRate");
|
const interactionField = "内容数据-个人视频-近30天-互动率";
|
||||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
click('[data-plugin-metric-catalog-trigger="button"]');
|
||||||
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
|
clickMetricCatalogAction(finishField);
|
||||||
setSpreadRuleSelect("finishRate", "type", "2");
|
click('[data-plugin-metric-catalog-trigger="button"]');
|
||||||
setSpreadRuleSelect("interactionRate", "type", "1");
|
clickMetricCatalogAction(interactionField);
|
||||||
setSpreadRuleSelect("interactionRate", "range", "3");
|
setInputValueForMetric(finishField, "30");
|
||||||
|
setInputValueForMetric(interactionField, "5");
|
||||||
click('[data-plugin-batch-submit="button"]');
|
click('[data-plugin-batch-submit="button"]');
|
||||||
await waitForMockCall(submitBatch, 80, 50);
|
await waitForMockCall(submitBatch, 80, 50);
|
||||||
|
|
||||||
@@ -5110,46 +4903,68 @@ function click(selector: string) {
|
|||||||
element.click();
|
element.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDefaultSpreadMetricFilter() {
|
function findMetricCatalogAction(field: string): HTMLButtonElement | null {
|
||||||
click('[data-plugin-spread-rule-remove="finishRate"]');
|
return Array.from(
|
||||||
|
document.querySelectorAll<HTMLButtonElement>("[data-plugin-metric-catalog-action]")
|
||||||
|
).find((element) => element.dataset.pluginMetricCatalogAction === field) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function enableSpreadMetric(metric: "finishRate" | "interactionRate") {
|
function findMetricCatalogItem(field: string): HTMLElement | null {
|
||||||
const selector = `[data-plugin-spread-metric="${metric}"]`;
|
return Array.from(
|
||||||
const input = document.querySelector(selector) as HTMLInputElement | null;
|
document.querySelectorAll<HTMLElement>("[data-plugin-metric-catalog-item]")
|
||||||
if (!input) {
|
).find((element) => element.dataset.pluginMetricCatalogItem === field) ?? null;
|
||||||
throw new Error(`Missing spread metric toggle: ${metric}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
input.checked = true;
|
|
||||||
dispatchChange(selector);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSpreadRuleSelect(
|
function findMetricRule(field: string): HTMLElement | null {
|
||||||
metric: "finishRate" | "interactionRate",
|
return Array.from(
|
||||||
field: "type" | "onlyAssign" | "flowType" | "range"
|
document.querySelectorAll<HTMLElement>("[data-plugin-metric-filter-rule]")
|
||||||
): HTMLSelectElement {
|
).find((element) => element.dataset.pluginMetricFilterRule === field) ?? null;
|
||||||
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(
|
function findMetricRuleRemove(field: string): HTMLButtonElement | null {
|
||||||
metric: "finishRate" | "interactionRate",
|
return findMetricRule(field)?.querySelector(
|
||||||
field: "type" | "onlyAssign" | "flowType" | "range",
|
"[data-plugin-metric-filter-remove]"
|
||||||
value: string
|
) as HTMLButtonElement | null;
|
||||||
) {
|
}
|
||||||
const select = readSpreadRuleSelect(metric, field);
|
|
||||||
|
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.value = value;
|
||||||
select.dispatchEvent(new Event("change"));
|
select.dispatchEvent(new Event("change"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeDefaultSpreadMetricFilter() {
|
||||||
|
// Metric filters now start empty, so batch-submit tests need no cleanup.
|
||||||
|
}
|
||||||
|
|
||||||
function createNoopToolbarHandlers() {
|
function createNoopToolbarHandlers() {
|
||||||
return {
|
return {
|
||||||
onConfigureAudienceProfileFields: vi.fn(),
|
onConfigureAudienceProfileFields: vi.fn(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user