docs: plan independent spread metric filters
This commit is contained in:
@@ -0,0 +1,665 @@
|
||||
# Independent Spread Metric Filter Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the single shared spread-video configuration with independently configurable completion-rate and interaction-rate rules that are combined with AND semantics.
|
||||
|
||||
**Architecture:** Represent each enabled metric as a `SpreadMetricFilterRule` containing its metric, threshold, and normalized `SpreadInfoConfig`. The toolbar owns metric selection and per-rule controls; the market controller groups rules by normalized config, loads one Xingtu snapshot per unique config and author, and evaluates every rule before export or batch submission.
|
||||
|
||||
**Tech Stack:** TypeScript 6, Chrome MV3 content scripts, DOM APIs, Vitest 4 with JSDOM, tsup build scripts.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `src/content/market/types.ts`: own shared spread config and filter rule types.
|
||||
- Modify `src/content/market/spread-info.ts`: normalize configs, build stable config keys, and compare one metric rule against a mapped snapshot.
|
||||
- Modify `src/content/market/plugin-toolbar.ts`: render metric checkboxes and per-metric dynamic rule controls, enforce personal-video constraints, and read validated rule arrays.
|
||||
- Modify `src/content/market/index.ts`: group rules by config, reuse snapshots, and apply AND filtering for export and batch submission.
|
||||
- Modify `tests/spread-info.test.ts`: cover config normalization, grouping keys, and single-rule comparison.
|
||||
- Modify `tests/market-content-entry.test.ts`: cover toolbar interaction, validation, request grouping, AND behavior, export, and batch submission.
|
||||
- Modify `docs/项目流程说明文档.md`: replace the obsolete seven-threshold/global-config description with the implemented two-metric independent-rule flow.
|
||||
|
||||
### Task 1: Shared Rule Model and Pure Spread Filter Logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/types.ts:15-35`
|
||||
- Modify: `src/content/market/spread-info.ts:1-25, 210-223`
|
||||
- Test: `tests/spread-info.test.ts:1-214`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for normalized configs, stable keys, and metric-specific matching**
|
||||
|
||||
Replace the old `matchesSpreadThresholds` test with focused tests and imports:
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildSpreadInfoConfigKey,
|
||||
matchesSpreadMetricRule,
|
||||
normalizeSpreadInfoConfig
|
||||
} from "../src/content/market/spread-info";
|
||||
|
||||
test("normalizes fixed personal-video parameters before grouping", () => {
|
||||
expect(normalizeSpreadInfoConfig({
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 3,
|
||||
type: 1
|
||||
})).toEqual({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
});
|
||||
});
|
||||
|
||||
test("uses all normalized video dimensions in the config key", () => {
|
||||
expect(buildSpreadInfoConfigKey({
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 3,
|
||||
type: 1
|
||||
})).toBe(buildSpreadInfoConfigKey({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
}));
|
||||
expect(buildSpreadInfoConfigKey({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 2
|
||||
})).not.toBe(buildSpreadInfoConfigKey({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 2
|
||||
}));
|
||||
});
|
||||
|
||||
test("matches only the metric named by one filter rule", () => {
|
||||
expect(matchesSpreadMetricRule(
|
||||
{ finishRate: "28.24%", interactionRate: "4.02%" },
|
||||
{
|
||||
config: { flowType: 0, onlyAssign: false, range: 2, type: 1 },
|
||||
metric: "finishRate",
|
||||
threshold: 28
|
||||
}
|
||||
)).toBe(true);
|
||||
expect(matchesSpreadMetricRule(
|
||||
{ finishRate: "28.24%" },
|
||||
{
|
||||
config: { flowType: 0, onlyAssign: false, range: 2, type: 1 },
|
||||
metric: "interactionRate",
|
||||
threshold: 1
|
||||
}
|
||||
)).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the new API is missing**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts`
|
||||
|
||||
Expected: FAIL because `normalizeSpreadInfoConfig`, `buildSpreadInfoConfigKey`, and `matchesSpreadMetricRule` are not exported.
|
||||
|
||||
- [ ] **Step 3: Replace the global threshold type with shared rule types**
|
||||
|
||||
In `types.ts`, remove `SpreadMetricThresholds` and define:
|
||||
|
||||
```ts
|
||||
export interface SpreadInfoConfig {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
}
|
||||
|
||||
export type SpreadFilterMetric = "finishRate" | "interactionRate";
|
||||
|
||||
export interface SpreadMetricFilterRule {
|
||||
config: SpreadInfoConfig;
|
||||
metric: SpreadFilterMetric;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export interface SpreadThresholdFilter {
|
||||
rules: SpreadMetricFilterRule[];
|
||||
}
|
||||
```
|
||||
|
||||
Move `SpreadInfoConfig` ownership out of `spread-info.ts` and import it there from `types.ts`.
|
||||
|
||||
- [ ] **Step 4: Implement pure normalization, keying, and comparison helpers**
|
||||
|
||||
In `spread-info.ts`, replace `matchesSpreadThresholds` with:
|
||||
|
||||
```ts
|
||||
export function normalizeSpreadInfoConfig(
|
||||
config: SpreadInfoConfig
|
||||
): SpreadInfoConfig {
|
||||
return config.type === 1
|
||||
? { ...config, flowType: 0, onlyAssign: false }
|
||||
: { ...config };
|
||||
}
|
||||
|
||||
export function buildSpreadInfoConfigKey(config: SpreadInfoConfig): string {
|
||||
const normalized = normalizeSpreadInfoConfig(config);
|
||||
return [
|
||||
normalized.type,
|
||||
normalized.onlyAssign ? 1 : 0,
|
||||
normalized.flowType,
|
||||
normalized.range
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function matchesSpreadMetricRule(
|
||||
metrics: MappedSpreadInfoResponse,
|
||||
rule: SpreadMetricFilterRule
|
||||
): boolean {
|
||||
const numericValue = readDisplayNumber(metrics[rule.metric]);
|
||||
return numericValue !== null && numericValue >= rule.threshold;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the focused tests**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit the shared rule model**
|
||||
|
||||
```bash
|
||||
git add src/content/market/types.ts src/content/market/spread-info.ts tests/spread-info.test.ts
|
||||
git commit -m "refactor: model independent spread metric rules"
|
||||
```
|
||||
|
||||
### Task 2: Dynamic Per-Metric Toolbar Rules
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/plugin-toolbar.ts:1-590, 810-1035`
|
||||
- Test: `tests/market-content-entry.test.ts:390-480, 1285-1340`
|
||||
|
||||
- [ ] **Step 1: Write failing DOM tests for the default and enabled states**
|
||||
|
||||
Update the toolbar assertions to use metric checkboxes and scoped rule rows:
|
||||
|
||||
```ts
|
||||
const finishToggle = document.querySelector(
|
||||
'[data-plugin-spread-metric="finishRate"]'
|
||||
) as HTMLInputElement;
|
||||
const interactionToggle = document.querySelector(
|
||||
'[data-plugin-spread-metric="interactionRate"]'
|
||||
) as HTMLInputElement;
|
||||
const finishRule = document.querySelector(
|
||||
'[data-plugin-spread-rule="finishRate"]'
|
||||
) as HTMLElement;
|
||||
|
||||
expect(finishToggle.checked).toBe(false);
|
||||
expect(interactionToggle.checked).toBe(false);
|
||||
expect(finishRule.hidden).toBe(true);
|
||||
|
||||
finishToggle.checked = true;
|
||||
finishToggle.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(finishRule.hidden).toBe(false);
|
||||
expect(finishRule.querySelector('[data-plugin-spread-filter="type"]')).not.toBeNull();
|
||||
expect(finishRule.querySelector('[data-plugin-spread-threshold="finishRate"]')).not.toBeNull();
|
||||
```
|
||||
|
||||
Add reusable test helpers:
|
||||
|
||||
```ts
|
||||
function enableSpreadMetric(metric: "finishRate" | "interactionRate") {
|
||||
const selector = `[data-plugin-spread-metric="${metric}"]`;
|
||||
const input = document.querySelector(selector) as HTMLInputElement;
|
||||
input.checked = true;
|
||||
dispatchChange(selector);
|
||||
}
|
||||
|
||||
function setSpreadRuleSelect(
|
||||
metric: "finishRate" | "interactionRate",
|
||||
field: "type" | "onlyAssign" | "flowType" | "range",
|
||||
value: string
|
||||
) {
|
||||
const selector =
|
||||
`[data-plugin-spread-rule="${metric}"] [data-plugin-spread-filter="${field}"]`;
|
||||
setSelectValue(selector, value);
|
||||
dispatchChange(selector);
|
||||
}
|
||||
```
|
||||
|
||||
Then verify per-rule isolation:
|
||||
|
||||
```ts
|
||||
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");
|
||||
|
||||
const finishOnlyAssign = document.querySelector(
|
||||
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement;
|
||||
const interactionOnlyAssign = document.querySelector(
|
||||
'[data-plugin-spread-rule="interactionRate"] [data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement;
|
||||
|
||||
expect(finishOnlyAssign.value).toBe("true");
|
||||
expect(finishOnlyAssign.disabled).toBe(false);
|
||||
expect(interactionOnlyAssign.value).toBe("false");
|
||||
expect(interactionOnlyAssign.disabled).toBe(true);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write failing validation tests for rule-array parsing**
|
||||
|
||||
Exercise `readToolbarSpreadFilter` through export button integration. The validation path is synchronous before any export work starts:
|
||||
|
||||
```ts
|
||||
enableSpreadMetric("finishRate");
|
||||
click('[data-plugin-export="button"]');
|
||||
expect(
|
||||
document.querySelector('[data-plugin-export-status="text"]')?.textContent
|
||||
).toContain("完播率");
|
||||
expect(buildCsv).not.toHaveBeenCalled();
|
||||
|
||||
setInputValue(
|
||||
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-threshold="finishRate"]',
|
||||
"30"
|
||||
);
|
||||
```
|
||||
|
||||
Import `ensurePluginToolbar` and `readToolbarSpreadFilter` in a focused test, then assert the empty and reset states directly:
|
||||
|
||||
```ts
|
||||
const { ensurePluginToolbar, readToolbarSpreadFilter } = await import(
|
||||
"../src/content/market/plugin-toolbar"
|
||||
);
|
||||
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers());
|
||||
expect(readToolbarSpreadFilter(toolbar)).toEqual({ filter: { rules: [] } });
|
||||
|
||||
enableSpreadMetric("finishRate");
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
setSpreadRuleSelect("finishRate", "type", "2");
|
||||
const toggle = document.querySelector(
|
||||
'[data-plugin-spread-metric="finishRate"]'
|
||||
) as HTMLInputElement;
|
||||
toggle.checked = false;
|
||||
dispatchChange('[data-plugin-spread-metric="finishRate"]');
|
||||
toggle.checked = true;
|
||||
dispatchChange('[data-plugin-spread-metric="finishRate"]');
|
||||
|
||||
expect(
|
||||
(document.querySelector('[data-plugin-spread-threshold="finishRate"]') as HTMLInputElement).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"
|
||||
);
|
||||
```
|
||||
|
||||
Define the no-op handlers next to the existing DOM test helpers:
|
||||
|
||||
```ts
|
||||
function createNoopToolbarHandlers() {
|
||||
return {
|
||||
onConfigureAudienceProfileFields: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
onExportAudienceProfile: vi.fn(),
|
||||
onExportAudienceProfileByIds: vi.fn(),
|
||||
onSubmitBatch: vi.fn()
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the toolbar-focused test file and verify failure**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts -t "spread"`
|
||||
|
||||
Expected: FAIL because metric checkboxes and per-rule controls do not exist.
|
||||
|
||||
- [ ] **Step 4: Replace the global toolbar DOM contract with per-rule DOM objects**
|
||||
|
||||
Define this internal shape in `plugin-toolbar.ts`:
|
||||
|
||||
```ts
|
||||
interface SpreadMetricRuleDom {
|
||||
enabledInput: HTMLInputElement;
|
||||
flowTypeSelect: HTMLSelectElement;
|
||||
onlyAssignSelect: HTMLSelectElement;
|
||||
rangeSelect: HTMLSelectElement;
|
||||
root: HTMLElement;
|
||||
thresholdInput: HTMLInputElement;
|
||||
typeSelect: HTMLSelectElement;
|
||||
}
|
||||
|
||||
type SpreadMetricRuleDomMap = Record<SpreadFilterMetric, SpreadMetricRuleDom>;
|
||||
```
|
||||
|
||||
Replace the four global config selects and two loose threshold inputs in `PluginToolbarDom` with `spreadMetricRules: SpreadMetricRuleDomMap`.
|
||||
|
||||
- [ ] **Step 5: Build metric selectors and hidden rule rows**
|
||||
|
||||
Create one checkbox and one rule row for each fixed definition:
|
||||
|
||||
```ts
|
||||
const SPREAD_FILTER_DEFINITIONS = [
|
||||
{ label: "完播率", metric: "finishRate" },
|
||||
{ label: "互动率", metric: "interactionRate" }
|
||||
] as const;
|
||||
```
|
||||
|
||||
Use these stable selectors:
|
||||
|
||||
```text
|
||||
data-plugin-spread-metric="finishRate|interactionRate"
|
||||
data-plugin-spread-rule="finishRate|interactionRate"
|
||||
data-plugin-spread-filter="type|onlyAssign|flowType|range"
|
||||
data-plugin-spread-threshold="finishRate|interactionRate"
|
||||
```
|
||||
|
||||
Keep both rule rows in the DOM but set `hidden=true` until their checkboxes are enabled. Layout the selection controls before the rule container and remove the old global “视频口径” group.
|
||||
|
||||
- [ ] **Step 6: Implement independent state synchronization and reset behavior**
|
||||
|
||||
On each metric checkbox change:
|
||||
|
||||
```ts
|
||||
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);
|
||||
}
|
||||
syncSpreadMetricVideoConstraints(rule);
|
||||
}
|
||||
```
|
||||
|
||||
`syncSpreadMetricVideoConstraints` must only touch the changed rule. For personal video it fixes and disables `onlyAssign` and `flowType`; for Xingtu video it enables them.
|
||||
|
||||
- [ ] **Step 7: Parse selected controls into validated rule arrays**
|
||||
|
||||
Rework `readToolbarSpreadFilter` to iterate in finish-rate then interaction-rate order:
|
||||
|
||||
```ts
|
||||
const rules: SpreadMetricFilterRule[] = [];
|
||||
for (const metric of ["finishRate", "interactionRate"] as const) {
|
||||
const ruleDom = toolbar.spreadMetricRules[metric];
|
||||
clearSpreadMetricRuleValidation(ruleDom);
|
||||
if (!ruleDom.enabledInput.checked) continue;
|
||||
|
||||
const threshold = Number(ruleDom.thresholdInput.value.trim());
|
||||
if (!ruleDom.thresholdInput.value.trim() || !Number.isFinite(threshold) || threshold < 0) {
|
||||
markSpreadMetricRuleInvalid(ruleDom);
|
||||
return { error: `请输入有效的${metric === "finishRate" ? "完播率" : "互动率"}筛选阈值` };
|
||||
}
|
||||
|
||||
rules.push({ metric, threshold, config: readSpreadMetricConfig(ruleDom) });
|
||||
}
|
||||
return { filter: { rules } };
|
||||
```
|
||||
|
||||
Update busy-state handling and native-control styling to walk every checkbox, threshold input, and select in `spreadMetricRules`.
|
||||
|
||||
- [ ] **Step 8: Run toolbar-focused tests**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts -t "toolbar|spread"`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit the dynamic toolbar**
|
||||
|
||||
```bash
|
||||
git add src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: add independent spread metric controls"
|
||||
```
|
||||
|
||||
### Task 3: Config-Deduplicated AND Filtering for Export and Batch Submission
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts:30-95, 801-828`
|
||||
- Test: `tests/market-content-entry.test.ts:1440-1496, 2436-2492`
|
||||
|
||||
- [ ] **Step 1: Write a failing export test for different configs and AND semantics**
|
||||
|
||||
Configure completion rate as Xingtu/assigned/30-day and interaction rate as personal/90-day. Mock snapshots by config:
|
||||
|
||||
```ts
|
||||
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%" };
|
||||
});
|
||||
```
|
||||
|
||||
Configure the controls and assert the exact result and calls:
|
||||
|
||||
```ts
|
||||
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(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual(["a"]);
|
||||
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
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write a failing export test for same-config request reuse**
|
||||
|
||||
Configure both rules as personal/30-day, return both metrics in one snapshot, and assert:
|
||||
|
||||
```ts
|
||||
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,
|
||||
range: 2,
|
||||
type: 1
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update the batch-submit integration test to enable and apply a rule**
|
||||
|
||||
Enable both rules before submitting and preserve the exact payload assertion:
|
||||
|
||||
```ts
|
||||
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);
|
||||
|
||||
expect(submitBatch.mock.calls[0]?.[0].authors).toEqual([
|
||||
expect.objectContaining({ authorId: "a" })
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the new integration tests and verify failure**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts -t "spread threshold|independent spread|reuses"`
|
||||
|
||||
Expected: FAIL because the controller still reads one global config and threshold object.
|
||||
|
||||
- [ ] **Step 5: Update the controller dependency type**
|
||||
|
||||
Change `loadSpreadFilterMetrics` to accept `SpreadInfoConfig` and return `MappedSpreadInfoResponse`:
|
||||
|
||||
```ts
|
||||
loadSpreadFilterMetrics?: (
|
||||
spreadAuthorId: string,
|
||||
config: SpreadInfoConfig
|
||||
) => Promise<MappedSpreadInfoResponse>;
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Group normalized rules and reuse each snapshot**
|
||||
|
||||
Replace the one-request filter body with config groups:
|
||||
|
||||
```ts
|
||||
const normalizedRules = filter.rules.map((rule) => ({
|
||||
...rule,
|
||||
config: normalizeSpreadInfoConfig(rule.config)
|
||||
}));
|
||||
const groups = new Map<string, {
|
||||
config: SpreadInfoConfig;
|
||||
rules: SpreadMetricFilterRule[];
|
||||
}>();
|
||||
|
||||
for (const rule of normalizedRules) {
|
||||
const key = buildSpreadInfoConfigKey(rule.config);
|
||||
const group = groups.get(key) ?? { config: rule.config, rules: [] };
|
||||
group.rules.push(rule);
|
||||
groups.set(key, group);
|
||||
}
|
||||
```
|
||||
|
||||
For each record, load all unique groups, catch a group failure as an empty snapshot, and preserve the existing per-author isolation:
|
||||
|
||||
```ts
|
||||
const snapshots = new Map<string, MappedSpreadInfoResponse>();
|
||||
await Promise.all(Array.from(groups.entries()).map(async ([key, group]) => {
|
||||
try {
|
||||
snapshots.set(key, await loadSpreadFilterMetrics(spreadAuthorId, group.config));
|
||||
} catch {
|
||||
snapshots.set(key, {});
|
||||
}
|
||||
}));
|
||||
|
||||
const matchesAll = normalizedRules.every((rule) =>
|
||||
matchesSpreadMetricRule(
|
||||
snapshots.get(buildSpreadInfoConfigKey(rule.config)) ?? {},
|
||||
rule
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
Return records unchanged when `filter` is missing or `filter.rules.length === 0`.
|
||||
|
||||
- [ ] **Step 7: Run focused integration tests**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts -t "spread threshold|independent spread|reuses"`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Run all market content-entry tests**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit config-deduplicated filtering**
|
||||
|
||||
```bash
|
||||
git add src/content/market/index.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: filter spread metrics by independent configs"
|
||||
```
|
||||
|
||||
### Task 4: Process Documentation and Full Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/项目流程说明文档.md:251-269, 323, 363, 426, 490, 509, 593`
|
||||
|
||||
- [ ] **Step 1: Update the maintained process description**
|
||||
|
||||
Rewrite section `3.8 传播指标阈值筛选` to state:
|
||||
|
||||
```markdown
|
||||
- 输入内容:可选的完播率、互动率规则;每条已选规则包含非负阈值和独立的视频类别、是否只看指派、是否排除营销流量、时间范围。
|
||||
- 处理规则:
|
||||
- 默认不选择任何指标,不启用二次筛选;
|
||||
- 每个已选指标必须填写阈值,并使用自己的视频口径请求传播指标;
|
||||
- 同时选择完播率和互动率时,两条规则必须全部满足;
|
||||
- 两条规则口径相同时,同一达人复用一次接口响应;口径不同时分别请求;
|
||||
- 个人视频固定为不限指派、不排除营销流量;
|
||||
- 请求失败、缺少 ID 或缺少已选指标的达人视为不满足筛选。
|
||||
```
|
||||
|
||||
Update the interface table's concurrency note to mention unique-config reuse, and change the configuration table row from one generic “传播指标阈值” to “传播指标规则”,defaulting to no selected metric.
|
||||
|
||||
- [ ] **Step 2: Run documentation consistency checks**
|
||||
|
||||
Run: `rg -n "七个指标阈值|没有填写任何阈值|平均时长按秒|播放量、评论、点赞、转发" docs/项目流程说明文档.md`
|
||||
|
||||
Expected: no output.
|
||||
|
||||
Run: `rg -n "每个已选指标|口径相同|传播指标规则" docs/项目流程说明文档.md`
|
||||
|
||||
Expected: matches in section 3.8, the `get_author_spread_info` interface row, and the configuration table.
|
||||
|
||||
- [ ] **Step 3: Run focused feature tests**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts tests/market-content-entry.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run the full test suite**
|
||||
|
||||
Run: `npm test`
|
||||
|
||||
Expected: PASS with no failed test files.
|
||||
|
||||
- [ ] **Step 5: Build the extension**
|
||||
|
||||
Run: `npm run build`
|
||||
|
||||
Expected: exit code `0` and refreshed development bundle in `dist/`.
|
||||
|
||||
- [ ] **Step 6: Review the final diff and workspace scope**
|
||||
|
||||
Run: `git diff --check`
|
||||
|
||||
Expected: no whitespace errors.
|
||||
|
||||
Run: `git status --short`
|
||||
|
||||
Expected: only the planned source, test, and process-document files are modified; `.superpowers/` remains untracked and must not be staged.
|
||||
|
||||
- [ ] **Step 7: Commit documentation and verification-complete state**
|
||||
|
||||
```bash
|
||||
git add docs/项目流程说明文档.md
|
||||
git commit -m "docs: update independent spread filter flow"
|
||||
```
|
||||
Reference in New Issue
Block a user