feat: add selected audience profile csv export
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createAudienceProfileClient,
|
||||
mapAudienceProfileResponse
|
||||
} from "../src/content/market/audience-profile-client";
|
||||
|
||||
describe("audience-profile-client", () => {
|
||||
test("loads connection user audience distributions from Xingtu", async () => {
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
json: async () => buildAudiencePayload(),
|
||||
ok: true
|
||||
}));
|
||||
const client = createAudienceProfileClient({
|
||||
baseUrl: "https://www.xingtu.cn",
|
||||
fetchImpl,
|
||||
timeoutMs: 1000
|
||||
});
|
||||
|
||||
const result = await client.loadAudienceProfile({
|
||||
authorId: "7294473194298146854",
|
||||
authorName: "奇奇de海洋",
|
||||
status: "success"
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://www.xingtu.cn/gw/api/data_sp/author_audience_distribution?o_author_id=7294473194298146854&platform_source=1&platform_channel=1&link_type=1",
|
||||
expect.objectContaining({
|
||||
credentials: "include",
|
||||
method: "GET"
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "success",
|
||||
gender: [
|
||||
{ label: "男性", value: "71.7%" },
|
||||
{ label: "女性", value: "28.3%" }
|
||||
],
|
||||
cityTop: expect.arrayContaining([{ label: "广州", value: "30.4%" }])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("maps Xingtu audience distribution payload into named profile sections", () => {
|
||||
const result = mapAudienceProfileResponse(buildAudiencePayload());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "success",
|
||||
age: [
|
||||
{ label: "18-23", value: "20%" },
|
||||
{ label: "24-30", value: "30%" },
|
||||
{ label: "31-40", value: "50%" }
|
||||
],
|
||||
province: [
|
||||
{ label: "广东", value: "60%" },
|
||||
{ label: "浙江", value: "40%" }
|
||||
],
|
||||
cityTier: [{ label: "一线城市", value: "100%" }],
|
||||
interest: [{ label: "随拍", value: "100%" }],
|
||||
crowd: [{ label: "都市蓝领", value: "100%" }]
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function buildAudiencePayload() {
|
||||
return {
|
||||
base_resp: {
|
||||
status_code: 0,
|
||||
status_message: ""
|
||||
},
|
||||
distributions: [
|
||||
{
|
||||
distribution_list: [
|
||||
{ distribution_key: "male", distribution_value: 717 },
|
||||
{ distribution_key: "female", distribution_value: 283 }
|
||||
],
|
||||
type_display: "性别分布"
|
||||
},
|
||||
{
|
||||
distribution_list: [
|
||||
{ distribution_key: "31-40", distribution_value: 50 },
|
||||
{ distribution_key: "18-23", distribution_value: 20 },
|
||||
{ distribution_key: "24-30", distribution_value: 30 }
|
||||
],
|
||||
type_display: "年龄分布"
|
||||
},
|
||||
{
|
||||
distribution_list: [
|
||||
{ distribution_key: "浙江", distribution_value: 40 },
|
||||
{ distribution_key: "广东", distribution_value: 60 }
|
||||
],
|
||||
type_display: "省份分布"
|
||||
},
|
||||
{
|
||||
distribution_list: [
|
||||
{ distribution_key: "广州", distribution_value: 304 },
|
||||
{ distribution_key: "北京", distribution_value: 291 },
|
||||
{ distribution_key: "上海", distribution_value: 405 }
|
||||
],
|
||||
type_display: "城市分布"
|
||||
},
|
||||
{
|
||||
distribution_list: [{ distribution_key: "一线", distribution_value: 1 }],
|
||||
type_display: "城市等级分布"
|
||||
},
|
||||
{
|
||||
distribution_list: [{ distribution_key: "随拍", distribution_value: 1 }],
|
||||
type_display: "兴趣分布"
|
||||
},
|
||||
{
|
||||
distribution_list: [{ distribution_key: "都市蓝领", distribution_value: 1 }],
|
||||
type_display: "八大人群分布"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildAudienceProfileCsv } from "../src/content/market/audience-profile-csv";
|
||||
import type { AudienceProfileExportRow } from "../src/content/market/audience-profile-types";
|
||||
|
||||
describe("audience-profile-csv", () => {
|
||||
test("appends structured audience profile columns after the market export columns", () => {
|
||||
const csv = buildAudienceProfileCsv([
|
||||
{
|
||||
profile: {
|
||||
age: [{ label: "31-40", value: "50%" }],
|
||||
cityTier: [{ label: "一线城市", value: "100%" }],
|
||||
cityTop: [{ label: "广州", value: "30.4%" }],
|
||||
crowd: [{ label: "都市蓝领", value: "100%" }],
|
||||
gender: [
|
||||
{ label: "男性", value: "71.7%" },
|
||||
{ label: "女性", value: "28.3%" }
|
||||
],
|
||||
interest: [{ label: "随拍", value: "100%" }],
|
||||
province: [{ label: "广东", value: "60%" }],
|
||||
status: "success"
|
||||
},
|
||||
record: {
|
||||
authorId: "123",
|
||||
authorName: "达人 A",
|
||||
exportFields: {
|
||||
达人信息: "达人 A",
|
||||
连接用户数: "300w"
|
||||
},
|
||||
status: "success"
|
||||
}
|
||||
}
|
||||
] satisfies AudienceProfileExportRow[]);
|
||||
|
||||
const [headerLine, rowLine] = csv.split("\n");
|
||||
|
||||
expect(headerLine).toContain("达人信息,连接用户数");
|
||||
expect(headerLine).toContain("画像抓取状态");
|
||||
expect(headerLine).toContain("连接用户-男性占比");
|
||||
expect(headerLine).toContain("连接用户-31-40占比");
|
||||
expect(headerLine).toContain("省份-广东占比");
|
||||
expect(headerLine).toContain("地域TOP1名称,地域TOP1占比");
|
||||
expect(headerLine).toContain("城市等级-一线城市占比");
|
||||
expect(headerLine).toContain("兴趣TOP1名称,兴趣TOP1占比");
|
||||
expect(headerLine).toContain("八大人群-都市蓝领占比");
|
||||
expect(rowLine).toContain("成功");
|
||||
expect(rowLine).toContain("71.7%");
|
||||
expect(rowLine).toContain("广州,30.4%");
|
||||
expect(rowLine).toContain("随拍,100%");
|
||||
});
|
||||
|
||||
test("keeps failed profile rows and marks their failure reason", () => {
|
||||
const csv = buildAudienceProfileCsv([
|
||||
{
|
||||
profile: {
|
||||
failureReason: "request-failed",
|
||||
status: "failed"
|
||||
},
|
||||
record: {
|
||||
authorId: "123",
|
||||
authorName: "达人 A",
|
||||
status: "success"
|
||||
}
|
||||
}
|
||||
] satisfies AudienceProfileExportRow[]);
|
||||
|
||||
const [, rowLine] = csv.split("\n");
|
||||
|
||||
expect(rowLine).toContain("失败");
|
||||
expect(rowLine).toContain("request-failed");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,27 @@ describe("background-auth-controller", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("returns unauthenticated state when the access token cannot be read", async () => {
|
||||
const controller = createAuthController({
|
||||
authClient: {
|
||||
getAccessToken: vi.fn(async () => {
|
||||
throw new Error("token expired");
|
||||
}),
|
||||
getIdTokenClaims: vi.fn(),
|
||||
isAuthenticated: vi.fn(async () => true),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}
|
||||
});
|
||||
|
||||
await expect(controller.getAuthState()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
isAuthenticated: false,
|
||||
lastError: "token expired"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("delegates sign in to the auth client", async () => {
|
||||
const signIn = vi.fn(async () => undefined);
|
||||
const controller = createAuthController({
|
||||
|
||||
@@ -24,4 +24,24 @@ describe("market-auth-gating", () => {
|
||||
expect(createMarketController).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("请先登录插件");
|
||||
});
|
||||
|
||||
test("shows an expired login message when auth state reports a token error", async () => {
|
||||
document.body.innerHTML = "<div></div>";
|
||||
|
||||
await bootContentScript({
|
||||
createMarketController: vi.fn(),
|
||||
document,
|
||||
sendAuthMessage: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: "auth:state",
|
||||
value: {
|
||||
isAuthenticated: false,
|
||||
lastError: "token expired"
|
||||
}
|
||||
})),
|
||||
window
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("登录已过期,请重新登录");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user