feat: add selected audience profile csv export

This commit is contained in:
2026-05-18 16:59:05 +08:00
parent 03c2fe0cc7
commit 66bc49d498
17 changed files with 1458 additions and 16 deletions
+135
View File
@@ -209,6 +209,46 @@ describe("market-content-entry", () => {
expect(revokeObjectURL).toHaveBeenCalledWith("blob:test-url");
});
test("booted export callback can download a custom csv filename", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
let clickedDownload: { download: string; href: string } | null = null;
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement
) {
clickedDownload = {
download: this.download,
href: this.href
};
});
window.history.replaceState({}, "", "/ad/creator/market");
Object.defineProperty(window.URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:test-url")
});
Object.defineProperty(window.URL, "revokeObjectURL", {
configurable: true,
value: vi.fn()
});
const { bootContentScript } = await import("../src/content/index");
await bootContentScript({
createMarketController,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}))
});
const controllerOptions = createMarketController.mock.calls[0]?.[0];
controllerOptions.onCsvReady("列1,列2\n值1,值2", "达人连接用户画像_20260518_1530.csv");
expect(clickedDownload?.download).toBe("达人连接用户画像_20260518_1530.csv");
});
test("booted export callback sends the csv to extension runtime when available", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
@@ -245,6 +285,7 @@ describe("market-content-entry", () => {
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
csv: "列1,列2\n值1,值2",
filename: expect.stringMatching(/^star-chart-search-enhancer-/),
type: "download-market-csv"
})
);
@@ -284,6 +325,9 @@ describe("market-content-entry", () => {
expect(document.body.firstElementChild).not.toBe(toolbar);
expect(document.querySelector('[data-plugin-export-range="select"]')).not.toBeNull();
expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull();
expect(
document.querySelector('[data-plugin-export-audience-profile="button"]')
).not.toBeNull();
expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull();
expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull();
@@ -293,10 +337,15 @@ describe("market-content-entry", () => {
const batchSubmitButton = document.querySelector(
'[data-plugin-batch-submit="button"]'
) as HTMLButtonElement | null;
const audienceProfileExportButton = document.querySelector(
'[data-plugin-export-audience-profile="button"]'
) as HTMLButtonElement | null;
expect(exportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(audienceProfileExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(exportButton?.style.color).toBe("rgb(255, 255, 255)");
expect(batchSubmitButton?.style.color).toBe("rgb(255, 255, 255)");
expect(audienceProfileExportButton?.style.color).toBe("rgb(255, 255, 255)");
});
test("remounts the plugin action bar when the native market action row appears later", async () => {
@@ -1535,6 +1584,92 @@ describe("market-content-entry", () => {
]);
});
test("audience profile export requires selected creators", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadAudienceProfile = vi.fn();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadAudienceProfile,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady: vi.fn(),
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await flush();
expect(loadAudienceProfile).not.toHaveBeenCalled();
expect(buildAudienceProfileCsv).not.toHaveBeenCalled();
expect(
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toContain("请先勾选需要导出画像的达人");
});
test("audience profile export loads profiles only for selected creators", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
]);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadAudienceProfile = vi.fn(async () => ({
gender: [{ label: "男性", value: "60%" }],
status: "success" as const
}));
const onCsvReady = vi.fn();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadAudienceProfile,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady,
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("222");
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(loadAudienceProfile).toHaveBeenCalledTimes(1);
expect(loadAudienceProfile).toHaveBeenCalledWith(
expect.objectContaining({ authorId: "222" })
);
expect(buildAudienceProfileCsv).toHaveBeenCalledWith([
{
profile: {
gender: [{ label: "男性", value: "60%" }],
status: "success"
},
record: expect.objectContaining({ authorId: "222" })
}
]);
expect(onCsvReady).toHaveBeenCalledWith(
"profile-csv",
expect.stringMatching(/^达人连接用户画像_\d{8}_\d{4}\.csv$/)
);
});
test(
"selected export keeps a generic loading status while exporting the default paged range",
async () => {