hyapp-admin-platform/databi/src/DatabiApp.test.jsx
2026-07-16 14:21:33 +08:00

988 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { act, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { DatabiApp } from "./DatabiApp.jsx";
import {
fetchFilterOptions,
fetchSelfGameStatisticsOverview,
fetchSocialBiFunnel,
fetchSocialBiKpi,
fetchSocialBiMaster,
fetchSocialBiOverview,
fetchSocialBiRequirements,
fetchStatisticsOverview
} from "./api.js";
vi.mock("./api.js", () => ({
fetchFilterOptions: vi.fn(),
fetchSocialBiFilterOptions: vi.fn(),
fetchSocialBiFunnel: vi.fn(),
fetchSocialBiKpi: vi.fn(),
fetchSocialBiMaster: vi.fn(),
fetchSocialBiOverview: vi.fn(),
fetchSocialBiRequirements: vi.fn(),
fetchSelfGameStatisticsOverview: vi.fn(),
fetchStatisticsOverview: vi.fn(),
getCurrentAppCode: vi.fn(() => "lalu"),
getDefaultAppOptions: vi.fn(() => [
{ code: "", label: "全部", value: "" },
{ code: "lalu", label: "Lalu", value: "lalu" },
{ code: "aslan", label: "Aslan", value: "aslan" },
{ code: "yumi", label: "Yumi", value: "yumi" }
]),
setCurrentAppCode: vi.fn((value) => String(value || "lalu").toLowerCase())
}));
vi.mock("./charts/EChart.jsx", () => ({
EChart: ({ option }) => (
<div
data-series={JSON.stringify((option?.series || []).map(({ data, name }) => ({ data, name })))}
data-testid="databi-chart"
/>
)
}));
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-06T08:00:00Z"));
fetchFilterOptions.mockResolvedValue({
appOptions: [
{ code: "", label: "全部", value: "" },
{ code: "lalu", label: "Lalu", value: "lalu" },
{ code: "aslan", label: "Aslan", value: "aslan" },
{ code: "yumi", label: "Yumi", value: "yumi" }
],
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
});
fetchSelfGameStatisticsOverview.mockResolvedValue({});
fetchSocialBiMaster.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [],
operators: [],
permissions: {},
regions: []
});
fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
fetchSocialBiFunnel.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
fetchSocialBiKpi.mockResolvedValue({ items: [], period_month: "2026-06", summary: {} });
fetchSocialBiRequirements.mockResolvedValue({ columns: [], daily_rows: [], section: "new_users", total: null });
});
afterEach(() => {
window.history.pushState(null, "", "/");
vi.useRealTimers();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
test("keeps current data visible during scheduled refresh", async () => {
const overview = { active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 };
fetchStatisticsOverview
.mockResolvedValueOnce(overview)
.mockResolvedValueOnce(overview)
.mockImplementationOnce(() => new Promise(() => {}));
render(<DatabiApp />);
await flushEffects();
expect(screen.getAllByText("$1").length).toBeGreaterThan(0);
expect(screen.getByLabelText("数据报表")).toBeTruthy();
expect(document.querySelector(".report-metric-card.is-loading")).toBeNull();
await act(async () => {
vi.advanceTimersByTime(60_000);
});
expect(fetchStatisticsOverview).toHaveBeenCalledTimes(3);
expect(screen.getAllByText("$1").length).toBeGreaterThan(0);
expect(document.querySelector(".report-metric-card.is-loading")).toBeNull();
});
test("queries China natural day statistics when display timezone is Beijing", async () => {
fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 });
render(<DatabiApp />);
await flushEffects();
expect(fetchStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({
endMs: Date.UTC(2026, 5, 6, 15, 59, 59),
seriesEndMs: Date.UTC(2026, 5, 6, 15, 59, 59),
seriesStartMs: Date.UTC(2026, 4, 30, 16, 0, 0),
startMs: Date.UTC(2026, 5, 5, 16, 0, 0),
statTz: "Asia/Shanghai"
}));
});
test("renders legacy app metric columns in report view", async () => {
fetchStatisticsOverview.mockResolvedValue({
coin_seller_stock_coin: 400_000,
coin_seller_transfer_coin: 160_000,
salary_transfer_coin: 88_000,
country_breakdown: [
{
coin_seller_stock_coin: 400_000,
coin_seller_transfer_coin: 160_000,
country: "巴西",
country_id: 86,
salary_transfer_coin: 88_000
}
],
updated_at_ms: 1
});
render(<DatabiApp />);
await flushEffects();
expect(screen.getByRole("columnheader", { name: "币商充值金币" })).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "币商出货金币" })).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "工资兑换金币" })).toBeTruthy();
expect(screen.getAllByText("400,000").length).toBeGreaterThan(0);
expect(screen.getAllByText("160,000").length).toBeGreaterThan(0);
expect(screen.getAllByText("88,000").length).toBeGreaterThan(0);
});
test("switches report sidebar sections and stops data report polling on empty sections", async () => {
fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 });
render(<DatabiApp />);
await flushEffects();
const reportTypeTabs = screen.getByRole("tablist", { name: "报表类型" });
expect(within(reportTypeTabs).getByRole("tab", { name: "数据报表" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("region", { name: "数据报表" })).toBeTruthy();
await act(async () => {
within(reportTypeTabs).getByRole("tab", { name: "财务报表" }).click();
});
expect(within(reportTypeTabs).getByRole("tab", { name: "财务报表" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("region", { name: "财务报表" })).toBeTruthy();
expect(screen.queryByRole("region", { name: "数据报表" })).toBeNull();
const requestCountAfterFinanceSwitch = fetchStatisticsOverview.mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(60_000);
});
expect(fetchStatisticsOverview).toHaveBeenCalledTimes(requestCountAfterFinanceSwitch);
await act(async () => {
within(reportTypeTabs).getByRole("tab", { name: "运营报表" }).click();
});
expect(within(reportTypeTabs).getByRole("tab", { name: "运营报表" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("region", { name: "运营报表" })).toBeTruthy();
await act(async () => {
within(reportTypeTabs).getByRole("tab", { name: "数据报表" }).click();
});
await flushEffects();
expect(screen.getByRole("region", { name: "数据报表" })).toBeTruthy();
expect(fetchStatisticsOverview.mock.calls.length).toBeGreaterThan(requestCountAfterFinanceSwitch);
});
test("loads self game statistics from the big screen switch", async () => {
fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 });
fetchSelfGameStatisticsOverview.mockResolvedValue({
events: [{ event_name: "page_open", event_count: 8, user_count: 5 }],
match_totals: { created_matches: 3, matched_matches: 2, settled_matches: 1 },
retention: { cohort_users: 5, day1_rate: 0.2, day7_rate: 0.1 }
});
render(<DatabiApp />);
await flushEffects();
await act(async () => {
screen.getByRole("button", { name: "自研游戏" }).click();
});
await flushEffects();
expect(fetchSelfGameStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({ appCode: "lalu", gameId: "all", statTz: "Asia/Shanghai" }));
expect(screen.getByText("Dice / Rock H5 聚合统计")).toBeTruthy();
expect(screen.getByText("创建局")).toBeTruthy();
});
test("renders real-room robot gift cards in a separate row", async () => {
fetchStatisticsOverview.mockResolvedValue({
real_room_robot_avg_room_gift_coin: 300,
real_room_robot_gift_room_count: 2,
real_room_robot_lucky_gift_coin: 200,
real_room_robot_normal_gift_coin: 100,
real_room_robot_super_gift_coin: 300,
updated_at_ms: 1
});
render(<DatabiApp />);
await flushEffects();
await act(async () => {
screen.getByRole("tab", { name: "大屏 BI" }).click();
});
const businessSection = screen.getByLabelText("业务指标");
const robotGiftSection = screen.getByLabelText("真人房机器人送礼指标");
expect(within(businessSection).queryByText("真人房机器人普通礼物")).toBeNull();
expect(within(robotGiftSection).getByText("真人房机器人普通礼物")).toBeTruthy();
expect(within(robotGiftSection).getByText("真人房机器人幸运礼物")).toBeTruthy();
expect(within(robotGiftSection).getByText("真人房机器人超级幸运礼物")).toBeTruthy();
expect(within(robotGiftSection).getByText("真人房机器人房均礼物")).toBeTruthy();
});
test("routes databi social page to Social BI and loads real overview rows", async () => {
window.history.pushState(null, "", "/databi/social/");
const laluTotal = {
active_users: 20,
arpu_usd_minor: 410,
arppu_usd_minor: 615,
avg_mic_online_ms: 120_000,
consumed_coin: 900_000,
consume_output_delta: 450_000,
consume_output_ratio: 0.5,
d1_retention_rate: 0.25,
game_profit_rate: 0.17,
game_turnover: 300_000,
gift_coin_spent: 123_456,
google_recharge_usd_minor: 700,
mifapay_recharge_usd_minor: 800,
new_users: 3,
output_coin: 450_000,
paid_conversion_rate: 0.2,
paid_users: 4,
recharge_usd_minor: 12_300,
recharge_users: 4
};
const aslanRegionRow = {
active_users: 10,
new_users: 1,
paid_users: 2,
recharge_usd_minor: 5000,
recharge_users: 2,
region_code: "MENA",
region_id: 11,
region_name: "中东大区"
};
fetchSocialBiMaster.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
{ app_code: "lalu", app_name: "Lalu", kind: "hyapp", logo_url: "https://media.example.com/lalu.png" },
{ app_code: "aslan", app_name: "Aslan", kind: "legacy", logo_url: "https://media.example.com/aslan.png" }
],
operators: [
{ account: "omar", name: "Omar", scopes: [{ app_code: "aslan", region_id: 11 }], team: "中东运营部", team_id: 2, user_id: 9 }
],
permissions: {},
regions: [{ app_code: "aslan", countries: ["SA"], region_code: "MENA", region_id: 11, region_name: "中东大区" }]
});
fetchSocialBiOverview.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
{
app_code: "lalu",
app_name: "Lalu",
country_breakdown: [],
daily_country_breakdown: [],
daily_region_breakdown: [],
daily_series: [],
kind: "hyapp",
region_breakdown: [],
total: laluTotal
},
{
app_code: "aslan",
app_name: "Aslan",
country_breakdown: [],
daily_country_breakdown: [],
daily_region_breakdown: [],
daily_series: [],
kind: "legacy",
region_breakdown: [aslanRegionRow],
total: { active_users: 10, new_users: 1, paid_users: 2, recharge_usd_minor: 5000, recharge_users: 2 }
}
]
});
fetchSocialBiKpi.mockResolvedValue({
items: [
{
app_code: "aslan",
app_name: "Aslan",
month_recharge_usd_minor: 90_000,
operator_account: "omar",
operator_name: "Omar",
operator_user_id: 9,
range_new_users: 1,
range_recharge_usd_minor: 5000,
region_id: 11,
region_name: "中东大区",
team: "中东运营部",
team_id: 2
}
],
period_month: "2026-06",
summary: { month_recharge_usd_minor: 90_000, operator_count: 1, range_recharge_usd_minor: 5000 }
});
render(<DatabiApp />);
await flushEffects();
// v2 Shell主视图容器 + 侧栏视图 tab默认选中“经营概览”。
expect(screen.getByLabelText("Social BI 数据中心")).toBeTruthy();
const viewTabs = screen.getByRole("tablist", { name: "数据视图" });
["经营概览", "地区洞察", "运营中心", "留存质量", "数据明细", "埋点漏斗"].forEach((name) => {
expect(within(viewTabs).getByRole("tab", { name })).toBeTruthy();
});
expect(within(viewTabs).getByRole("tab", { name: "经营概览" })).toHaveAttribute("aria-selected", "true");
expect(fetchSocialBiMaster).toHaveBeenCalledTimes(1);
expect(fetchSocialBiOverview).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
expect(fetchSocialBiFunnel).not.toHaveBeenCalled();
expect(fetchSocialBiKpi).not.toHaveBeenCalled();
// 经营概览App 名、Hero 汇总充值12,300 + 5,000 USD 分 = $173
// App 对比里 Lalu 的单 App 充值($123、区域 Top5 里的中东大区。
expect(screen.getAllByText("Lalu").length).toBeGreaterThan(0);
expect(document.querySelector('img[src="https://media.example.com/lalu.png"]')).toBeTruthy();
expect(document.querySelector('img[src="https://media.example.com/aslan.png"]')).toBeTruthy();
expect(screen.getAllByText("充值 ($)").length).toBeGreaterThan(0);
expect(screen.getByText("付费用户")).toBeTruthy();
expect(screen.getAllByText("$173").length).toBeGreaterThan(0);
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
expect(screen.getAllByText("中东大区").length).toBeGreaterThan(0);
// 侧栏切换:地区洞察 —— 区域下钻表出现Aslan 中东大区行充值 $50。
await act(async () => {
within(viewTabs).getByRole("tab", { name: "地区洞察" }).click();
});
expect(within(viewTabs).getByRole("tab", { name: "地区洞察" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("区域下钻")).toBeTruthy();
expect(screen.getAllByText("中东大区").length).toBeGreaterThan(0);
expect(screen.getAllByText("$50").length).toBeGreaterThan(0);
// 侧栏切换:运营中心 —— 运营人员 Omar 上榜,只展示负责范围内的充值数据。
await act(async () => {
within(viewTabs).getByRole("tab", { name: "运营中心" }).click();
});
await flushEffects();
expect(within(viewTabs).getByRole("tab", { name: "运营中心" })).toHaveAttribute("aria-selected", "true");
expect(fetchSocialBiKpi).toHaveBeenCalledTimes(1);
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
expect(screen.getAllByText("Omar").length).toBeGreaterThan(0);
expect(screen.getAllByText("$900").length).toBeGreaterThan(0);
});
test("filters operation KPI by person and shows totals with App and region amounts", async () => {
window.history.pushState(null, "", "/databi/social/?view=team&operator=9");
fetchSocialBiMaster.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
{ app_code: "aslan", app_name: "Aslan", kind: "legacy" },
{ app_code: "yumi", app_name: "Yumi", kind: "legacy" }
],
operators: [
{
account: "omar",
name: "Omar",
scopes: [{ app_code: "aslan", region_id: 11 }, { app_code: "yumi", region_id: 20 }],
team: "中东运营部",
team_id: 2,
user_id: 9
}
],
permissions: {},
regions: [
{ app_code: "aslan", region_id: 11, region_name: "中东大区" },
{ app_code: "yumi", region_id: 20, region_name: "南亚大区" }
]
});
fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
fetchSocialBiKpi.mockResolvedValue({
items: [
{
app_code: "aslan",
app_name: "Aslan",
month_recharge_usd_minor: 90_000,
operator_account: "omar",
operator_name: "Omar",
operator_user_id: 9,
range_recharge_usd_minor: 5000,
region_id: 11,
region_name: "中东大区",
team: "中东运营部"
},
{
app_code: "yumi",
app_name: "Yumi",
month_recharge_usd_minor: 50_000,
operator_account: "omar",
operator_name: "Omar",
operator_user_id: 9,
range_recharge_usd_minor: 3000,
region_id: 20,
region_name: "南亚大区",
team: "中东运营部"
}
],
operator_app_rows: [
{
app_code: "aslan",
app_name: "Aslan",
month_recharge_usd_minor: 90_000,
operator_account: "omar",
operator_name: "Omar",
operator_user_id: 9,
range_recharge_usd_minor: 5000,
region_count: 1,
region_names: "中东大区",
team: "中东运营部"
},
{
app_code: "yumi",
app_name: "Yumi",
month_recharge_usd_minor: 50_000,
operator_account: "omar",
operator_name: "Omar",
operator_user_id: 9,
range_recharge_usd_minor: 3000,
region_count: 1,
region_names: "南亚大区",
team: "中东运营部"
}
],
period_month: "2026-06",
summary: { month_recharge_usd_minor: 140_000, operator_count: 1, range_recharge_usd_minor: 8000 }
});
render(<DatabiApp />);
await flushEffects();
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ operatorUserId: 9 }));
expect(screen.getByRole("combobox", { name: "运营人员筛选" })).toHaveValue("Omar");
expect(window.location.search).toContain("operator=9");
const summary = screen.getByLabelText("充值金额汇总");
expect(within(summary).getByText("Omar")).toBeTruthy();
expect(within(summary).getByText("$80")).toBeTruthy();
expect(within(summary).getByText("$1,400")).toBeTruthy();
expect(screen.getByRole("cell", { name: "中东大区" })).toBeTruthy();
expect(screen.getByRole("cell", { name: "南亚大区" })).toBeTruthy();
await act(async () => {
screen.getByRole("radio", { name: "按人员×App" }).click();
});
expect(screen.getByRole("cell", { name: "中东大区" })).toBeTruthy();
expect(screen.getByRole("cell", { name: "南亚大区" })).toBeTruthy();
expect(screen.getAllByText("$50").length).toBeGreaterThan(0);
expect(screen.getAllByText("$30").length).toBeGreaterThan(0);
});
test("defaults retention quality to canonical active cohort and can switch to new-user cohort", async () => {
window.history.pushState(null, "", "/databi/social/?view=retention&apps=lalu");
fetchSocialBiMaster.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [{ app_code: "lalu", app_name: "Lalu", kind: "hyapp" }],
operators: [],
permissions: {},
regions: []
});
fetchSocialBiOverview.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
{
app_code: "lalu",
app_name: "Lalu",
country_breakdown: [],
daily_region_breakdown: [],
daily_series: [
{
active_users: 20,
d1_retention_base_users: 4,
d1_retention_rate: 0.25,
d1_retention_users: 1,
d30_retention_base_users: 2,
d30_retention_rate: 0.5,
d30_retention_users: 1,
d7_retention_base_users: 3,
d7_retention_rate: 0.3333,
d7_retention_users: 1,
new_users: 4,
stat_day: "2026-06-05"
}
],
kind: "hyapp",
region_breakdown: [],
total: {
active_users: 20,
d1_retention_base_users: 4,
d1_retention_rate: 0.25,
d1_retention_users: 1,
d30_retention_base_users: 2,
d30_retention_rate: 0.5,
d30_retention_users: 1,
d7_retention_base_users: 3,
d7_retention_rate: 0.3333,
d7_retention_users: 1,
new_users: 4
}
}
]
});
fetchSocialBiRequirements.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
{
app_code: "lalu",
app_name: "Lalu",
kind: "hyapp",
sections: [
{
daily_series: [
{
metrics: {
active_d1_active_base_users: 12,
active_d1_active_rate: 0.5,
active_d1_active_users: 6,
active_d30_active_base_users: 3,
active_d30_active_rate: 0.3333,
active_d30_active_users: 1,
active_d7_active_base_users: 6,
active_d7_active_rate: 0.5,
active_d7_active_users: 3
},
stat_day: "2026-06-04"
},
{
metrics: {
active_d1_active_base_users: 8,
active_d1_active_rate: 0.5,
active_d1_active_users: 4,
active_d30_active_base_users: 2,
active_d30_active_rate: 0,
active_d30_active_users: 0,
active_d7_active_base_users: 4,
active_d7_active_rate: 0.25,
active_d7_active_users: 1
},
stat_day: "2026-06-05"
},
{
metrics: {
active_d1_active_base_users: null,
active_d1_active_rate: null,
active_d1_active_users: null,
active_d30_active_base_users: null,
active_d30_active_rate: null,
active_d30_active_users: null,
active_d7_active_base_users: null,
active_d7_active_rate: null,
active_d7_active_users: null
},
stat_day: "2026-06-06"
}
],
key: "retention",
total: {
metrics: {
active_d1_active_base_users: 20,
active_d1_active_rate: 0.5,
active_d1_active_users: 10,
active_d30_active_base_users: 5,
active_d30_active_rate: 0.2,
active_d30_active_users: 1,
active_d7_active_base_users: 10,
active_d7_active_rate: 0.4,
active_d7_active_users: 4
}
}
}
]
}
]
});
render(<DatabiApp />);
await flushEffects();
expect(fetchSocialBiRequirements).toHaveBeenCalledWith(expect.objectContaining({
newUserType: "all",
payerType: "all",
section: "retention",
userRole: "all"
}));
// Overview 的 access 结果落地不能改变 requirements callback 身份,否则同一轮 D30 查询会重复执行。
expect(fetchSocialBiRequirements).toHaveBeenCalledTimes(1);
const modeSwitch = screen.getByRole("radiogroup", { name: "留存口径" });
expect(within(modeSwitch).getByRole("radio", { name: "整体次留" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByText("活跃基数与次日留存")).toBeTruthy();
expect(screen.getByText("整体用户留存趋势")).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "活跃用户基数" })).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "次留人数" })).toBeTruthy();
expect(screen.getAllByText("50.0%").length).toBeGreaterThan(0);
expect(screen.getAllByText(/活跃基数 20 人/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/已接入 Social App 汇总/).length).toBeGreaterThan(0);
const overallCharts = screen.getAllByTestId("databi-chart");
const comboSeries = JSON.parse(overallCharts[0].dataset.series);
const trendSeries = JSON.parse(overallCharts[1].dataset.series);
expect(comboSeries.find((series) => series.name === "整体次日留存").data).toEqual([50, 50, null]);
expect(trendSeries.find((series) => series.name === "整体次日留存").data).toEqual([50, 50, null]);
await act(async () => {
within(modeSwitch).getByRole("radio", { name: "新增次留" }).click();
});
expect(within(modeSwitch).getByRole("radio", { name: "新增次留" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByText("新增与次日留存")).toBeTruthy();
expect(screen.getByText("新增用户留存趋势")).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "新增" })).toBeTruthy();
expect(screen.getAllByText("25.0%").length).toBeGreaterThan(0);
expect(screen.getAllByText(/注册基数 4 人/).length).toBeGreaterThan(0);
});
test("marks overall retention as a partial subset when one app fails", async () => {
window.history.pushState(null, "", "/databi/social/?view=retention");
fetchSocialBiMaster.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
{ app_code: "lalu", app_name: "Lalu", kind: "hyapp" },
{ app_code: "huwaa", app_name: "Huwaa", kind: "hyapp" }
],
operators: [],
permissions: {},
regions: []
});
fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
fetchSocialBiRequirements.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
{
app_code: "lalu",
app_name: "Lalu",
kind: "hyapp",
sections: [{
daily_series: [{
active_d1_active_base_users: 10,
active_d1_active_rate: 0.5,
active_d1_active_users: 5,
stat_day: "2026-06-05"
}],
key: "retention",
total: {
active_d1_active_base_users: 10,
active_d1_active_rate: 0.5,
active_d1_active_users: 5
}
}]
},
{ app_code: "huwaa", app_name: "Huwaa", error: "statistics timeout", kind: "hyapp" }
]
});
render(<DatabiApp />);
await flushEffects();
expect(screen.getByRole("alert")).toHaveTextContent("部分 App 整体留存加载失败");
expect(screen.getByRole("alert")).toHaveTextContent("Huwaa: statistics timeout");
expect(screen.getAllByText(/成功返回的 Social App 子集/).length).toBeGreaterThan(0);
expect(screen.queryByText(/已接入 Social App 汇总/)).toBeNull();
});
test("renders social BI data requirements table with section filters and CSV export", async () => {
window.history.pushState(null, "", "/databi/social/?view=table&regions=lalu:9,lalu:11");
fetchSocialBiMaster.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [{ app_code: "lalu", app_name: "Lalu", kind: "hyapp" }],
operators: [],
permissions: {},
regions: []
});
fetchSocialBiOverview.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [{ app_code: "lalu", app_name: "Lalu", daily_series: [], kind: "hyapp", total: {} }]
});
fetchSocialBiRequirements.mockImplementation(async ({ section }) => {
if (section === "revenue") {
return {
access: { all: true, scopes: [] },
apps: [{
app_code: "lalu",
app_name: "Lalu",
kind: "hyapp",
sections: [{
columns: [
{ key: "recharge_users", label: "充值用户", type: "count" },
{ key: "recharge_usd_minor", label: "充值金额", type: "money_minor" }
],
daily_series: [{ metrics: { recharge_users: 2, recharge_usd_minor: 12300 }, stat_day: "2026-06-05" }],
key: section,
total: { label: "汇总", metrics: { recharge_users: 2, recharge_usd_minor: 12300 }, stat_day: "total" }
}]
}]
};
}
return {
access: { all: true, scopes: [] },
apps: [{
app_code: "lalu",
app_name: "Lalu",
kind: "hyapp",
sections: [{
columns: [
{ key: "app_download_users", label: "下载APP人数", type: "count" },
{ key: "registration_success_rate", label: "注册成功率", type: "ratio" }
],
daily_series: [{ metrics: { app_download_users: 12, registration_success_rate: 0.6666 }, stat_day: "2026-06-05" }],
key: section,
total: { label: "汇总", metrics: { app_download_users: 12, registration_success_rate: 0.6666 }, stat_day: "total" }
}]
}]
};
});
render(<DatabiApp />);
await flushEffects();
expect(fetchSocialBiOverview).toHaveBeenCalledWith(
expect.objectContaining({ appCodes: ["lalu"], regionIds: [9, 11], statTz: "Asia/Shanghai" })
);
const viewTabs = screen.getByRole("tablist", { name: "数据视图" });
expect(within(viewTabs).getByRole("tab", { name: "数据明细" })).toHaveAttribute("aria-selected", "true");
await act(async () => {
screen.getByRole("radio", { name: "数据需求" }).click();
});
await flushEffects();
expect(fetchSocialBiRequirements).toHaveBeenCalledWith(expect.objectContaining({
payerType: "all",
regionIds: [9, 11],
section: "revenue",
userRole: "all"
}));
const requirementTabs = screen.getByRole("tablist", { name: "数据需求分组" });
expect(within(requirementTabs).getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
"充值",
"新增",
"日活",
"礼物",
"游戏",
"留存"
]);
expect(screen.getByRole("tab", { name: "充值" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy();
expect(screen.getByRole("radiogroup", { name: "付费身份" })).toBeTruthy();
expect(screen.getByText("充值用户")).toBeTruthy();
expect(screen.getByText("充值金额")).toBeTruthy();
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
await act(async () => {
screen.getByRole("tab", { name: "新增" }).click();
});
await flushEffects();
expect(fetchSocialBiRequirements).toHaveBeenLastCalledWith(expect.objectContaining({ section: "new_users" }));
expect(screen.getByRole("tab", { name: "新增" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("下载APP人数")).toBeTruthy();
expect(screen.getByText("注册成功率")).toBeTruthy();
expect(screen.getAllByText("12").length).toBeGreaterThan(0);
expect(screen.getAllByText("66.66%").length).toBeGreaterThan(0);
expect(screen.queryByRole("radiogroup", { name: "用户身份" })).toBeNull();
expect(screen.queryByRole("radiogroup", { name: "付费身份" })).toBeNull();
await act(async () => {
screen.getByRole("tab", { name: "充值" }).click();
});
await flushEffects();
expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy();
expect(screen.getByRole("radiogroup", { name: "付费身份" })).toBeTruthy();
await act(async () => {
screen.getByRole("radio", { name: "主播" }).click();
});
await flushEffects();
await act(async () => {
screen.getByRole("radio", { name: "已付费" }).click();
});
await flushEffects();
expect(fetchSocialBiRequirements).toHaveBeenLastCalledWith(expect.objectContaining({
payerType: "paid",
section: "revenue",
userRole: "host"
}));
expect(screen.getByText("充值用户")).toBeTruthy();
expect(screen.getByText("充值金额")).toBeTruthy();
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
const urlMock = { createObjectURL: vi.fn(() => "blob:social-bi-requirements"), revokeObjectURL: vi.fn() };
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
vi.stubGlobal("URL", urlMock);
await act(async () => {
screen.getByRole("button", { name: "导出 CSV" }).click();
});
expect(urlMock.createObjectURL).toHaveBeenCalledTimes(1);
expect(anchorClick).toHaveBeenCalledTimes(1);
anchorClick.mockRestore();
});
test("groups social BI wide-table daily rows by app instead of interleaving by date", async () => {
window.history.pushState(null, "", "/databi/social/?view=table");
fetchSocialBiMaster.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
// 带 logoAppIdentity 渲染图片而不是字母头像,单元格 textContent 恰好等于 App 名。
{ app_code: "lalu", app_name: "Lalu", kind: "hyapp", logo_url: "https://media.example.com/lalu.png" },
{ app_code: "aslan", app_name: "Aslan", kind: "legacy", logo_url: "https://media.example.com/aslan.png" }
],
operators: [],
permissions: {},
regions: []
});
const dailyRow = (statDay, rechargeUsdMinor) => ({ recharge_usd_minor: rechargeUsdMinor, recharge_users: 1, stat_day: statDay });
fetchSocialBiOverview.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
{
app_code: "lalu",
app_name: "Lalu",
country_breakdown: [],
daily_region_breakdown: [],
daily_series: [dailyRow("2026-06-04", 100), dailyRow("2026-06-05", 200)],
kind: "hyapp",
region_breakdown: [],
total: { recharge_usd_minor: 300, recharge_users: 2 }
},
{
// Aslan 各日充值都高于 Lalu旧的“按日倒序 + 日内充值倒序”排序会把两个 App 一天一行穿插,
// 新排序必须仍按 master 顺序把 Lalu 块排在前面。
app_code: "aslan",
app_name: "Aslan",
country_breakdown: [],
daily_region_breakdown: [],
daily_series: [dailyRow("2026-06-04", 900), dailyRow("2026-06-05", 800)],
kind: "legacy",
region_breakdown: [],
total: { recharge_usd_minor: 1700, recharge_users: 2 }
}
]
});
render(<DatabiApp />);
await flushEffects();
const table = document.querySelector(".sbi-dt-table");
expect(table).toBeTruthy();
const headerCells = [...table.querySelectorAll("thead tr:first-child th")].map((cell) => cell.textContent.trim());
expect(headerCells.slice(0, 2)).toEqual(["App", "日期"]);
// 行序:汇总/平均值之后,每个 App 的日行连成块(块内日期倒序),不按日期把 App 穿插。
const bodyRows = [...table.querySelectorAll("tbody tr")];
const dimCells = bodyRows.map((row) => {
const cells = row.querySelectorAll("td");
return [cells[0].textContent.trim(), cells[1].textContent.trim()];
});
expect(dimCells).toEqual([
["全部", "汇总"],
["平均值", "--"],
["Lalu", "2026-06-05"],
["Lalu", "2026-06-04"],
["Aslan", "2026-06-05"],
["Aslan", "2026-06-04"]
]);
// 分块分隔线只标在后一个块的首行。
expect(bodyRows[4].classList.contains("sbi-dt-group-start")).toBe(true);
expect(bodyRows[3].classList.contains("sbi-dt-group-start")).toBe(false);
});
test("renders social BI funnel view and limits funnel apps to supported apps", async () => {
window.history.pushState(null, "", "/databi/social/?view=funnel");
fetchSocialBiMaster.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
{ app_code: "lalu", app_name: "Lalu", kind: "hyapp" },
{ app_code: "huwaa", app_name: "Huwaa", kind: "hyapp" },
{ app_code: "fami", app_name: "Fami", kind: "hyapp" },
{ app_code: "aslan", app_name: "Aslan", kind: "legacy" },
{ app_code: "yumi", app_name: "Yumi", kind: "legacy" }
],
operators: [],
permissions: {},
regions: []
});
fetchSocialBiFunnel.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
{
app_code: "lalu",
app_name: "Lalu",
cohorts: [
{
base_users: 1,
d1_retention_rate: 1,
d1_retention_users: 1,
dimension: "country",
label: "US",
steps: [
{ event_name: "login_success", user_count: 1 },
{ event_name: "profile_complete", user_count: 1 },
{ event_name: "room_join_success", user_count: 1 },
{ event_name: "stay_3m", user_count: 1 },
{ event_name: "send_message", user_count: 1 }
],
value: "US"
}
],
kind: "hyapp",
steps: [
{ event_name: "login_start", event_count: 2, label: "登录开始", user_count: 2 },
{ event_name: "login_success", event_count: 1, label: "登录成功", user_count: 1 },
{ event_name: "room_join_success", event_count: 1, label: "进房成功", user_count: 1 },
{ event_name: "room_join_fail", event_count: 1, is_failure: true, label: "进房失败", user_count: 1 },
{ event_name: "stay_3m", event_count: 1, label: "停留 3 分钟", user_count: 1 },
{ event_name: "send_message", event_count: 1, label: "发消息", user_count: 1 }
],
totals: {
d1_retention_base_users: 1,
d1_retention_rate: 1,
d1_retention_users: 1,
login_start_users: 2,
login_success_users: 1,
room_join_fail_users: 1,
room_join_success_users: 1
}
}
]
});
render(<DatabiApp />);
await flushEffects();
expect(screen.getByRole("tab", { name: "埋点漏斗" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("button", { name: "全部漏斗 App" })).toBeTruthy();
expect(screen.getByRole("button", { name: /Lalu/ })).toBeTruthy();
expect(screen.getByRole("button", { name: /Huwaa/ })).toBeTruthy();
expect(screen.getByRole("button", { name: /Fami/ })).toBeTruthy();
expect(screen.queryByRole("button", { name: /Aslan/ })).toBeNull();
expect(screen.queryByRole("button", { name: /Yumi/ })).toBeNull();
expect(fetchSocialBiFunnel).toHaveBeenCalledWith(expect.objectContaining({ appCodes: ["lalu", "huwaa", "fami"] }));
expect(screen.getByText("主路径转化")).toBeTruthy();
expect(screen.getByText("事件明细")).toBeTruthy();
expect(screen.getByText("D1 Cohort")).toBeTruthy();
expect(screen.getByText("login_start")).toBeTruthy();
expect(screen.getByText("room_join_fail")).toBeTruthy();
expect(screen.getByText("US")).toBeTruthy();
expect(screen.getAllByText("100.0%").length).toBeGreaterThan(0);
expect(screen.getByTestId("databi-chart")).toBeTruthy();
});
async function flushEffects() {
await act(async () => {
for (let index = 0; index < 10; index += 1) {
await Promise.resolve();
}
});
}