feat: wire market plugin controls
This commit is contained in:
parent
f6b2bdc862
commit
9b3c6756bb
@ -1 +1,34 @@
|
|||||||
export {};
|
import {
|
||||||
|
createMarketController,
|
||||||
|
type CreateMarketControllerOptions
|
||||||
|
} from "./market/index";
|
||||||
|
|
||||||
|
interface BootContentScriptOptions {
|
||||||
|
createMarketController?: (
|
||||||
|
options: CreateMarketControllerOptions
|
||||||
|
) => { ready: Promise<void> };
|
||||||
|
document?: Document;
|
||||||
|
window?: Window;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootContentScript(
|
||||||
|
options: BootContentScriptOptions = {}
|
||||||
|
): Promise<{ ready: Promise<void> } | null> {
|
||||||
|
const currentWindow = options.window ?? window;
|
||||||
|
const currentDocument = options.document ?? document;
|
||||||
|
const controllerFactory =
|
||||||
|
options.createMarketController ?? createMarketController;
|
||||||
|
|
||||||
|
if (!isMarketPage(currentWindow.location.href)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return controllerFactory({
|
||||||
|
document: currentDocument,
|
||||||
|
window: currentWindow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarketPage(url: string): boolean {
|
||||||
|
return url.startsWith("https://xingtu.cn/ad/creator/market");
|
||||||
|
}
|
||||||
|
|||||||
176
src/content/market/index.ts
Normal file
176
src/content/market/index.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { buildMarketCsv } from "./csv-exporter";
|
||||||
|
import {
|
||||||
|
applyRowOrder,
|
||||||
|
applyRowVisibility,
|
||||||
|
renderMarketRowState,
|
||||||
|
syncMarketTable
|
||||||
|
} from "./dom-sync";
|
||||||
|
import { applyFilterAndSort } from "./filter-sort-controller";
|
||||||
|
import { createFullScanController } from "./full-scan-controller";
|
||||||
|
import { createMarketApiClient } from "./api-client";
|
||||||
|
import { ensurePluginToolbar } from "./plugin-toolbar";
|
||||||
|
import { createMarketResultStore } from "./result-store";
|
||||||
|
import type {
|
||||||
|
MarketApiResult,
|
||||||
|
MarketFilterState,
|
||||||
|
MarketRecord,
|
||||||
|
MarketRowSnapshot,
|
||||||
|
MarketSortState
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface FullScanControllerLike {
|
||||||
|
ensureScanForExport(): Promise<void>;
|
||||||
|
ensureScanForFilter(): Promise<void>;
|
||||||
|
ensureScanForSort(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMarketControllerOptions {
|
||||||
|
buildCsv?: (records: MarketRecord[]) => string;
|
||||||
|
document: Document;
|
||||||
|
fullScanController?: FullScanControllerLike;
|
||||||
|
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||||
|
onCsvReady?: (csv: string) => void;
|
||||||
|
resultStore?: ReturnType<typeof createMarketResultStore>;
|
||||||
|
window: Window;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMarketController(options: CreateMarketControllerOptions) {
|
||||||
|
const table = syncMarketTable(options.document);
|
||||||
|
const resultStore = options.resultStore ?? createMarketResultStore();
|
||||||
|
const loadAuthorMetrics =
|
||||||
|
options.loadAuthorMetrics ?? createMarketApiClient().loadAuthorAseInfo;
|
||||||
|
const buildCsv = options.buildCsv ?? buildMarketCsv;
|
||||||
|
let activeFilters: MarketFilterState = {};
|
||||||
|
let activeSort: MarketSortState | undefined;
|
||||||
|
|
||||||
|
const fullScanController =
|
||||||
|
options.fullScanController ??
|
||||||
|
createFullScanController({
|
||||||
|
goToNextPage: async () => false,
|
||||||
|
hasNextPage: () => false,
|
||||||
|
loadAuthorMetrics,
|
||||||
|
readCurrentPageRows: () =>
|
||||||
|
table ? table.rows.map((rowDom) => readRowSnapshot(rowDom.row)) : [],
|
||||||
|
resultStore
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolbar = ensurePluginToolbar(options.document, {
|
||||||
|
onApplyFilter: async () => {
|
||||||
|
activeFilters = {
|
||||||
|
personalVideoAfterSearchRateMin: parseNumberValue(
|
||||||
|
toolbar.personalFilterInput.value
|
||||||
|
),
|
||||||
|
singleVideoAfterSearchRateMin: parseNumberValue(
|
||||||
|
toolbar.singleFilterInput.value
|
||||||
|
)
|
||||||
|
};
|
||||||
|
await fullScanController.ensureScanForFilter();
|
||||||
|
applyCurrentView();
|
||||||
|
},
|
||||||
|
onApplySort: async () => {
|
||||||
|
activeSort = readSortState(toolbar.sortFieldSelect, toolbar.sortDirectionSelect);
|
||||||
|
await fullScanController.ensureScanForSort();
|
||||||
|
applyCurrentView();
|
||||||
|
},
|
||||||
|
onExport: async () => {
|
||||||
|
await fullScanController.ensureScanForExport();
|
||||||
|
const records = getVisibleOrderedRecords();
|
||||||
|
options.onCsvReady?.(buildCsv(records));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ready = hydrateCurrentPage().then(() => {
|
||||||
|
applyCurrentView();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready
|
||||||
|
};
|
||||||
|
|
||||||
|
async function hydrateCurrentPage(): Promise<void> {
|
||||||
|
if (!table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rowDom of table.rows) {
|
||||||
|
const rowSnapshot = readRowSnapshot(rowDom.row);
|
||||||
|
resultStore.upsertMarketRow(rowSnapshot);
|
||||||
|
resultStore.setAuthorLoading(rowSnapshot.authorId);
|
||||||
|
renderMarketRowState(rowDom, {
|
||||||
|
...rowSnapshot,
|
||||||
|
status: "loading"
|
||||||
|
});
|
||||||
|
|
||||||
|
const metricsResult = await loadAuthorMetrics(rowSnapshot.authorId);
|
||||||
|
if (metricsResult.success) {
|
||||||
|
resultStore.setAuthorSuccess(rowSnapshot.authorId, metricsResult.rates);
|
||||||
|
renderMarketRowState(rowDom, {
|
||||||
|
...rowSnapshot,
|
||||||
|
status: "success",
|
||||||
|
rates: metricsResult.rates
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultStore.setAuthorFailed(rowSnapshot.authorId, metricsResult.reason);
|
||||||
|
renderMarketRowState(rowDom, {
|
||||||
|
...rowSnapshot,
|
||||||
|
failureReason: metricsResult.reason,
|
||||||
|
status: "failed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCurrentView(): void {
|
||||||
|
if (!table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = getVisibleOrderedRecords();
|
||||||
|
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
|
||||||
|
applyRowOrder(table, records.map((record) => record.authorId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleOrderedRecords(): MarketRecord[] {
|
||||||
|
return applyFilterAndSort(resultStore.listRecords(), {
|
||||||
|
filters: activeFilters,
|
||||||
|
sort: activeSort
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRowSnapshot(row: HTMLElement): MarketRowSnapshot {
|
||||||
|
return {
|
||||||
|
authorId: row.dataset.authorId ?? "",
|
||||||
|
authorName:
|
||||||
|
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
|
||||||
|
"",
|
||||||
|
price21To60s:
|
||||||
|
row
|
||||||
|
.querySelector('[data-market-field="price21To60s"]')
|
||||||
|
?.textContent?.trim() ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumberValue(value: string): number | undefined {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = Number(value);
|
||||||
|
return Number.isFinite(parsedValue) ? parsedValue : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSortState(
|
||||||
|
fieldSelect: HTMLSelectElement,
|
||||||
|
directionSelect: HTMLSelectElement
|
||||||
|
): MarketSortState | undefined {
|
||||||
|
if (!fieldSelect.value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
direction: directionSelect.value === "asc" ? "asc" : "desc",
|
||||||
|
field: fieldSelect.value as MarketSortState["field"]
|
||||||
|
};
|
||||||
|
}
|
||||||
137
src/content/market/plugin-toolbar.ts
Normal file
137
src/content/market/plugin-toolbar.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
export interface PluginToolbarHandlers {
|
||||||
|
onApplyFilter(): Promise<void> | void;
|
||||||
|
onApplySort(): Promise<void> | void;
|
||||||
|
onExport(): Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginToolbarDom {
|
||||||
|
exportButton: HTMLButtonElement;
|
||||||
|
filterApplyButton: HTMLButtonElement;
|
||||||
|
personalFilterInput: HTMLInputElement;
|
||||||
|
root: HTMLElement;
|
||||||
|
singleFilterInput: HTMLInputElement;
|
||||||
|
sortApplyButton: HTMLButtonElement;
|
||||||
|
sortDirectionSelect: HTMLSelectElement;
|
||||||
|
sortFieldSelect: HTMLSelectElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensurePluginToolbar(
|
||||||
|
document: Document,
|
||||||
|
handlers: PluginToolbarHandlers
|
||||||
|
): PluginToolbarDom {
|
||||||
|
const existingRoot = document.querySelector(
|
||||||
|
"[data-plugin-toolbar='root']"
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (existingRoot) {
|
||||||
|
return readToolbarDom(existingRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.createElement("section");
|
||||||
|
root.dataset.pluginToolbar = "root";
|
||||||
|
|
||||||
|
const singleFilterInput = document.createElement("input");
|
||||||
|
singleFilterInput.type = "number";
|
||||||
|
singleFilterInput.step = "0.01";
|
||||||
|
singleFilterInput.dataset.pluginFilterSingle = "input";
|
||||||
|
|
||||||
|
const personalFilterInput = document.createElement("input");
|
||||||
|
personalFilterInput.type = "number";
|
||||||
|
personalFilterInput.step = "0.01";
|
||||||
|
personalFilterInput.dataset.pluginFilterPersonal = "input";
|
||||||
|
|
||||||
|
const filterApplyButton = document.createElement("button");
|
||||||
|
filterApplyButton.type = "button";
|
||||||
|
filterApplyButton.dataset.pluginFilterApply = "button";
|
||||||
|
filterApplyButton.textContent = "应用筛选";
|
||||||
|
|
||||||
|
const sortFieldSelect = document.createElement("select");
|
||||||
|
sortFieldSelect.dataset.pluginSortField = "select";
|
||||||
|
appendOption(sortFieldSelect, "", "不排序");
|
||||||
|
appendOption(sortFieldSelect, "singleVideoAfterSearchRate", "单视频看后搜率");
|
||||||
|
appendOption(sortFieldSelect, "personalVideoAfterSearchRate", "个人视频看后搜率");
|
||||||
|
|
||||||
|
const sortDirectionSelect = document.createElement("select");
|
||||||
|
sortDirectionSelect.dataset.pluginSortDirection = "select";
|
||||||
|
appendOption(sortDirectionSelect, "desc", "降序");
|
||||||
|
appendOption(sortDirectionSelect, "asc", "升序");
|
||||||
|
|
||||||
|
const sortApplyButton = document.createElement("button");
|
||||||
|
sortApplyButton.type = "button";
|
||||||
|
sortApplyButton.dataset.pluginSortApply = "button";
|
||||||
|
sortApplyButton.textContent = "应用排序";
|
||||||
|
|
||||||
|
const exportButton = document.createElement("button");
|
||||||
|
exportButton.type = "button";
|
||||||
|
exportButton.dataset.pluginExport = "button";
|
||||||
|
exportButton.textContent = "导出CSV";
|
||||||
|
|
||||||
|
root.append(
|
||||||
|
singleFilterInput,
|
||||||
|
personalFilterInput,
|
||||||
|
filterApplyButton,
|
||||||
|
sortFieldSelect,
|
||||||
|
sortDirectionSelect,
|
||||||
|
sortApplyButton,
|
||||||
|
exportButton
|
||||||
|
);
|
||||||
|
document.body.prepend(root);
|
||||||
|
|
||||||
|
filterApplyButton.addEventListener("click", () => {
|
||||||
|
void handlers.onApplyFilter();
|
||||||
|
});
|
||||||
|
sortApplyButton.addEventListener("click", () => {
|
||||||
|
void handlers.onApplySort();
|
||||||
|
});
|
||||||
|
exportButton.addEventListener("click", () => {
|
||||||
|
void handlers.onExport();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
exportButton,
|
||||||
|
filterApplyButton,
|
||||||
|
personalFilterInput,
|
||||||
|
root,
|
||||||
|
singleFilterInput,
|
||||||
|
sortApplyButton,
|
||||||
|
sortDirectionSelect,
|
||||||
|
sortFieldSelect
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendOption(
|
||||||
|
select: HTMLSelectElement,
|
||||||
|
value: string,
|
||||||
|
label: string
|
||||||
|
): void {
|
||||||
|
const option = select.ownerDocument.createElement("option");
|
||||||
|
option.value = value;
|
||||||
|
option.textContent = label;
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||||
|
return {
|
||||||
|
exportButton: root.querySelector(
|
||||||
|
'[data-plugin-export="button"]'
|
||||||
|
) as HTMLButtonElement,
|
||||||
|
filterApplyButton: root.querySelector(
|
||||||
|
'[data-plugin-filter-apply="button"]'
|
||||||
|
) as HTMLButtonElement,
|
||||||
|
personalFilterInput: root.querySelector(
|
||||||
|
'[data-plugin-filter-personal="input"]'
|
||||||
|
) as HTMLInputElement,
|
||||||
|
root,
|
||||||
|
singleFilterInput: root.querySelector(
|
||||||
|
'[data-plugin-filter-single="input"]'
|
||||||
|
) as HTMLInputElement,
|
||||||
|
sortApplyButton: root.querySelector(
|
||||||
|
'[data-plugin-sort-apply="button"]'
|
||||||
|
) as HTMLButtonElement,
|
||||||
|
sortDirectionSelect: root.querySelector(
|
||||||
|
'[data-plugin-sort-direction="select"]'
|
||||||
|
) as HTMLSelectElement,
|
||||||
|
sortFieldSelect: root.querySelector(
|
||||||
|
'[data-plugin-sort-field="select"]'
|
||||||
|
) as HTMLSelectElement
|
||||||
|
};
|
||||||
|
}
|
||||||
261
tests/market-content-entry.test.ts
Normal file
261
tests/market-content-entry.test.ts
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
// @vitest-environment-options {"url":"https://xingtu.cn/"}
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createMarketResultStore } from "../src/content/market/result-store";
|
||||||
|
|
||||||
|
describe("market-content-entry", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("boots the market controller on the Xingtu market URL", async () => {
|
||||||
|
const createMarketController = vi.fn(() => ({
|
||||||
|
ready: Promise.resolve()
|
||||||
|
}));
|
||||||
|
|
||||||
|
window.history.replaceState({}, "", "/ad/creator/market");
|
||||||
|
|
||||||
|
const { bootContentScript } = await import("../src/content/index");
|
||||||
|
await bootContentScript({
|
||||||
|
createMarketController
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createMarketController).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hydrates current page rows on start", async () => {
|
||||||
|
document.body.innerHTML = buildMarketFixture();
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = createMarketController({
|
||||||
|
document,
|
||||||
|
loadAuthorMetrics: async (authorId) => ({
|
||||||
|
success: true,
|
||||||
|
rates:
|
||||||
|
authorId === "a"
|
||||||
|
? {
|
||||||
|
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
||||||
|
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
singleVideoAfterSearchRate: "0.5%-1%",
|
||||||
|
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
window
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-market-row-cell="singleVideoAfterSearchRate"]')
|
||||||
|
?.textContent
|
||||||
|
).toBe("0.02% - 0.1%");
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-market-row-cell="personalVideoAfterSearchRate"]')
|
||||||
|
?.textContent
|
||||||
|
).toBe("0.03% - 0.2%");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applying plugin filters triggers full scan and hides non-matching rows", async () => {
|
||||||
|
document.body.innerHTML = buildMarketFixture();
|
||||||
|
const resultStore = createMarketResultStore();
|
||||||
|
const ensureScanForFilter = vi.fn(async () => {
|
||||||
|
resultStore.setAuthorSuccess("a", {
|
||||||
|
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
||||||
|
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
||||||
|
});
|
||||||
|
resultStore.setAuthorSuccess("b", {
|
||||||
|
singleVideoAfterSearchRate: "0.5%-1%",
|
||||||
|
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = createMarketController({
|
||||||
|
document,
|
||||||
|
fullScanController: {
|
||||||
|
ensureScanForExport: vi.fn(async () => {}),
|
||||||
|
ensureScanForFilter,
|
||||||
|
ensureScanForSort: vi.fn(async () => {})
|
||||||
|
},
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
resultStore,
|
||||||
|
window
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
setInputValue('[data-plugin-filter-single="input"]', "0.1");
|
||||||
|
click('[data-plugin-filter-apply="button"]');
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(ensureScanForFilter).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-market-row="a"]')?.hasAttribute("hidden")
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-market-row="b"]')?.hasAttribute("hidden")
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applying plugin sorting triggers full scan and reorders rows", async () => {
|
||||||
|
document.body.innerHTML = buildMarketFixture();
|
||||||
|
const resultStore = createMarketResultStore();
|
||||||
|
const ensureScanForSort = vi.fn(async () => {
|
||||||
|
resultStore.setAuthorSuccess("a", {
|
||||||
|
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
||||||
|
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
||||||
|
});
|
||||||
|
resultStore.setAuthorSuccess("b", {
|
||||||
|
singleVideoAfterSearchRate: "0.5%-1%",
|
||||||
|
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = createMarketController({
|
||||||
|
document,
|
||||||
|
fullScanController: {
|
||||||
|
ensureScanForExport: vi.fn(async () => {}),
|
||||||
|
ensureScanForFilter: vi.fn(async () => {}),
|
||||||
|
ensureScanForSort
|
||||||
|
},
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
resultStore,
|
||||||
|
window
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate");
|
||||||
|
setSelectValue('[data-plugin-sort-direction="select"]', "desc");
|
||||||
|
click('[data-plugin-sort-apply="button"]');
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(ensureScanForSort).toHaveBeenCalledTimes(1);
|
||||||
|
expect(readRowOrder()).toEqual(["b", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("export triggers full scan and hands ordered visible records to the csv exporter", async () => {
|
||||||
|
document.body.innerHTML = buildMarketFixture();
|
||||||
|
const resultStore = createMarketResultStore();
|
||||||
|
const buildCsv = vi.fn(() => "csv-output");
|
||||||
|
const onCsvReady = vi.fn();
|
||||||
|
const ensureScanForExport = vi.fn(async () => {
|
||||||
|
resultStore.setAuthorSuccess("a", {
|
||||||
|
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
||||||
|
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
||||||
|
});
|
||||||
|
resultStore.setAuthorSuccess("b", {
|
||||||
|
singleVideoAfterSearchRate: "0.5%-1%",
|
||||||
|
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = createMarketController({
|
||||||
|
buildCsv,
|
||||||
|
document,
|
||||||
|
fullScanController: {
|
||||||
|
ensureScanForExport,
|
||||||
|
ensureScanForFilter: vi.fn(async () => {}),
|
||||||
|
ensureScanForSort: vi.fn(async () => {})
|
||||||
|
},
|
||||||
|
loadAuthorMetrics: async () => ({
|
||||||
|
success: false,
|
||||||
|
reason: "request-failed"
|
||||||
|
}),
|
||||||
|
onCsvReady,
|
||||||
|
resultStore,
|
||||||
|
window
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate");
|
||||||
|
setSelectValue('[data-plugin-sort-direction="select"]', "desc");
|
||||||
|
click('[data-plugin-sort-apply="button"]');
|
||||||
|
await flush();
|
||||||
|
click('[data-plugin-export="button"]');
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(ensureScanForExport).toHaveBeenCalledTimes(1);
|
||||||
|
expect(buildCsv).toHaveBeenCalledWith(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ authorId: "a" }),
|
||||||
|
expect.objectContaining({ authorId: "b" })
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||||||
|
"b",
|
||||||
|
"a"
|
||||||
|
]);
|
||||||
|
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildMarketFixture() {
|
||||||
|
return `
|
||||||
|
<div data-market-table>
|
||||||
|
<div data-market-header>
|
||||||
|
<div data-market-header-cell="authorName">达人</div>
|
||||||
|
<div data-market-header-cell="price21To60s">21-60s报价</div>
|
||||||
|
</div>
|
||||||
|
<div data-market-body>
|
||||||
|
<div data-market-row="a" data-author-id="a">
|
||||||
|
<span data-market-field="authorName">Alpha</span>
|
||||||
|
<span data-market-field="price21To60s">450000</span>
|
||||||
|
</div>
|
||||||
|
<div data-market-row="b" data-author-id="b">
|
||||||
|
<span data-market-field="authorName">Beta</span>
|
||||||
|
<span data-market-field="price21To60s">70000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(selector: string) {
|
||||||
|
const element = document.querySelector(selector) as HTMLButtonElement | null;
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(`Missing element: ${selector}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInputValue(selector: string, value: string) {
|
||||||
|
const element = document.querySelector(selector) as HTMLInputElement | null;
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(`Missing input: ${selector}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectValue(selector: string, value: string) {
|
||||||
|
const element = document.querySelector(selector) as HTMLSelectElement | null;
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(`Missing select: ${selector}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRowOrder() {
|
||||||
|
return Array.from(document.querySelectorAll("[data-market-row]")).map(
|
||||||
|
(row) => row.getAttribute("data-author-id")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user