0dbd279dfc
Project CI / AI game creator shell Rust crates (push) Successful in 1m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / Backend tests (push) Successful in 5m16s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m42s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m21s
Project CI / Native shell tests (push) Successful in 6m43s
Project CI / Frontend tests (push) Successful in 2m22s
Project CI / Repository checks (push) Successful in 2m45s
Project CI / AI game creator shell web tests (push) Successful in 2m7s
- consumedMudPoints 改为净额:按北京时间业务日先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日,抵扣不完的丢弃,每日净额非负且区间合计严格等于「消耗 − 退还」 - 退还口径并入 asset_operation_refund 正向流水(生成失败退还、精选审核返还)与 llm_router_consume 正向冲正流水;充值退款追回、余额重置、赠送和 hold 仍不计入 - 契约新增 refundedMudPoints,后端新增「退还泥点」趋势图,后台时段数据并列「退还泥点数」卡,毛消耗可由「消耗 + 退还」核出 - 新增 4 条净额单测(同日对冲 / 回溯抵扣 / 超额归零 / 无退还保原值)与 Dashboard 用例断言 - 同步 Dashboard 运营看板方案指标口径与决策记录 - 验证:cargo test -p api-server --bin api-server admin(136 passed / 0 failed / 1 ignored)、npm run admin-web:typecheck、npx vitest run apps/admin-web/src(218 passed)、cargo fmt --all --check、npm run check:encoding、npm run check:doc-index、git diff --check 全部通过
379 lines
11 KiB
TypeScript
379 lines
11 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|
|
|
import { getAdminDashboard } from '../api/adminApiClient';
|
|
import type { AdminDashboardResponse } from '../api/adminApiTypes';
|
|
import { AdminDashboardPage } from './AdminDashboardPage';
|
|
|
|
vi.mock('../api/adminApiClient', () => ({
|
|
formatAdminApiError: vi.fn((error: unknown) =>
|
|
error instanceof Error ? error.message : '请求失败',
|
|
),
|
|
getAdminDashboard: vi.fn(),
|
|
isAdminApiError: vi.fn(() => false),
|
|
}));
|
|
|
|
const dashboardResponse: AdminDashboardResponse = {
|
|
range: {
|
|
granularity: 'day',
|
|
anchorDate: '2026-06-23',
|
|
periodStartDate: '2026-06-23',
|
|
periodEndDate: '2026-06-23',
|
|
periodLabel: '2026-06-23',
|
|
},
|
|
metrics: {
|
|
generatedAssets: 12,
|
|
consumedMudPoints: 88,
|
|
refundedMudPoints: 24,
|
|
totalRegisteredUsers: 1200,
|
|
newRegisteredUsers: 16,
|
|
newUserPaymentConversion: {
|
|
paidUsers: 5,
|
|
newRegisteredUsers: 16,
|
|
rateBasisPoints: 3125,
|
|
},
|
|
day1Retention: {
|
|
eligibleUsers: 12,
|
|
retainedUsers: 3,
|
|
rateBasisPoints: 2500,
|
|
},
|
|
day7Retention: {
|
|
eligibleUsers: 0,
|
|
retainedUsers: 0,
|
|
rateBasisPoints: 0,
|
|
},
|
|
visitUsers: 34,
|
|
totalVisitUsers: 456,
|
|
visitCount: 98,
|
|
totalVisitCount: 9876,
|
|
currentUsers: 7,
|
|
},
|
|
charts: [
|
|
{
|
|
id: 'generated-assets',
|
|
title: '生产素材',
|
|
unit: '个',
|
|
total: 12,
|
|
buckets: [
|
|
{ key: '2026-06-23', label: '2026-06-23', value: 12 },
|
|
{ key: '2026-06-24', label: '2026-06-24', value: 0 },
|
|
],
|
|
},
|
|
],
|
|
operations: {
|
|
cards: [
|
|
{
|
|
id: 'period-generated-assets',
|
|
label: '生产素材',
|
|
value: 12,
|
|
unit: '个',
|
|
},
|
|
],
|
|
assetKindBreakdown: [
|
|
{ key: 'editor_generated_image', label: '画板图片', value: 9 },
|
|
],
|
|
moduleVisitBreakdown: [{ key: 'auth', label: '认证', value: 30 }],
|
|
},
|
|
warnings: [],
|
|
generatedAt: '2026-06-23T12:00:00Z',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
vi.setSystemTime(new Date('2026-06-27T08:00:00+08:00'));
|
|
vi.mocked(getAdminDashboard).mockResolvedValue(dashboardResponse);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('Dashboard 默认加载今日指标并支持运营汇总页签', async () => {
|
|
const user = setupUser();
|
|
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
|
|
|
expect(await screen.findByText('总计数据')).toBeTruthy();
|
|
expect(screen.getByText('时段数据')).toBeTruthy();
|
|
expect(screen.getByText('本日生产素材数')).toBeTruthy();
|
|
expect(screen.getByText('本日消耗泥点数')).toBeTruthy();
|
|
expect(screen.getByText('本日退还泥点数')).toBeTruthy();
|
|
expect(screen.getByText('总注册用户')).toBeTruthy();
|
|
expect(screen.getByText('本日新增用户数')).toBeTruthy();
|
|
expect(screen.getByText('新增用户转化与留存')).toBeTruthy();
|
|
const paymentRateCard = screen
|
|
.getByText('本日新增用户付费率')
|
|
.closest('article');
|
|
expect(paymentRateCard).toBeTruthy();
|
|
expect(
|
|
within(paymentRateCard as HTMLElement).getByText('31.25%'),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(paymentRateCard as HTMLElement).getByText('付费人数 / 新增人数'),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(paymentRateCard as HTMLElement).getByText('5 / 16 人'),
|
|
).toBeTruthy();
|
|
expect(screen.getByText('次日留存')).toBeTruthy();
|
|
expect(screen.getByText('25%')).toBeTruthy();
|
|
expect(screen.getByText('3 / 12 人')).toBeTruthy();
|
|
const day7Card = screen.getByText('七日留存').closest('article');
|
|
expect(day7Card).toBeTruthy();
|
|
expect(within(day7Card as HTMLElement).getByText('-')).toBeTruthy();
|
|
expect(within(day7Card as HTMLElement).getByText('0 / 0 人')).toBeTruthy();
|
|
expect(screen.getByText('近 5 分钟活跃用户')).toBeTruthy();
|
|
expect(screen.getByText('生产素材')).toBeTruthy();
|
|
expect(
|
|
screen
|
|
.getByTitle('2026-06-24: 0 个')
|
|
.firstElementChild?.getAttribute('style'),
|
|
).toContain('height: 0%');
|
|
expect(screen.queryByRole('button', { name: '本时段' })).toBeNull();
|
|
|
|
await user.click(screen.getByRole('button', { name: '运营汇总' }));
|
|
|
|
expect(screen.getByText('素材类型分布')).toBeTruthy();
|
|
expect(screen.getByText('画板图片')).toBeTruthy();
|
|
expect(screen.getByText('访问模块分布')).toBeTruthy();
|
|
});
|
|
|
|
test('Dashboard 默认日期使用北京时间', async () => {
|
|
vi.setSystemTime(new Date('2026-06-26T17:30:00Z'));
|
|
|
|
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
|
|
|
await waitFor(() => {
|
|
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
|
granularity: 'period',
|
|
anchor: undefined,
|
|
startDate: '2026-06-27',
|
|
endDate: '2026-06-27',
|
|
});
|
|
});
|
|
});
|
|
|
|
test('Dashboard 选择本周时按北京时间今天截断未来日期', async () => {
|
|
const user = setupUser();
|
|
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
|
|
|
await screen.findByText('本日生产素材数');
|
|
fireEvent.change(screen.getByLabelText('终止日期'), {
|
|
target: { value: '2026-07-02' },
|
|
});
|
|
await user.click(screen.getByRole('button', { name: '本周' }));
|
|
|
|
await waitFor(() => {
|
|
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
|
granularity: 'period',
|
|
anchor: undefined,
|
|
startDate: '2026-06-22',
|
|
endDate: '2026-06-27',
|
|
});
|
|
});
|
|
});
|
|
|
|
test('Dashboard 选择本月时按北京时间今天截断未来日期', async () => {
|
|
const user = setupUser();
|
|
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
|
|
|
await screen.findByText('本日生产素材数');
|
|
fireEvent.change(screen.getByLabelText('终止日期'), {
|
|
target: { value: '2026-07-12' },
|
|
});
|
|
await user.click(screen.getByRole('button', { name: '本月' }));
|
|
|
|
await waitFor(() => {
|
|
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
|
granularity: 'period',
|
|
anchor: undefined,
|
|
startDate: '2026-06-01',
|
|
endDate: '2026-06-27',
|
|
});
|
|
});
|
|
});
|
|
|
|
test('Dashboard 从本月切回本日时回到今天', async () => {
|
|
const user = setupUser();
|
|
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
|
|
|
await screen.findByText('本日生产素材数');
|
|
await user.click(screen.getByRole('button', { name: '本月' }));
|
|
await user.click(screen.getByRole('button', { name: '本日' }));
|
|
|
|
await waitFor(() => {
|
|
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
|
granularity: 'period',
|
|
anchor: undefined,
|
|
startDate: '2026-06-27',
|
|
endDate: '2026-06-27',
|
|
});
|
|
});
|
|
});
|
|
|
|
test('Dashboard 手动选择起止日期时使用本时段查询', async () => {
|
|
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
|
|
|
await screen.findByText('本日生产素材数');
|
|
fireEvent.change(screen.getByLabelText('起始日期'), {
|
|
target: { value: '2026-06-01' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('终止日期'), {
|
|
target: { value: '2026-06-12' },
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
|
granularity: 'period',
|
|
anchor: undefined,
|
|
startDate: '2026-06-01',
|
|
endDate: '2026-06-12',
|
|
});
|
|
});
|
|
expect(screen.getByText('本时段新增用户数')).toBeTruthy();
|
|
expect(screen.getByText('本时段新增用户付费率')).toBeTruthy();
|
|
});
|
|
|
|
test('Dashboard 新增用户付费率分母为零时显示横线', async () => {
|
|
vi.mocked(getAdminDashboard).mockResolvedValue({
|
|
...dashboardResponse,
|
|
metrics: {
|
|
...dashboardResponse.metrics,
|
|
newRegisteredUsers: 0,
|
|
newUserPaymentConversion: {
|
|
paidUsers: 0,
|
|
newRegisteredUsers: 0,
|
|
rateBasisPoints: 0,
|
|
},
|
|
},
|
|
});
|
|
|
|
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
|
|
|
const paymentRateCard = (
|
|
await screen.findByText('本日新增用户付费率')
|
|
).closest('article');
|
|
expect(paymentRateCard).toBeTruthy();
|
|
expect(within(paymentRateCard as HTMLElement).getByText('-')).toBeTruthy();
|
|
expect(
|
|
within(paymentRateCard as HTMLElement).getByText('0 / 0 人'),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
test('Dashboard 手动选择日期时不允许查询北京时间今天之后', async () => {
|
|
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
|
|
|
await screen.findByText('本日生产素材数');
|
|
const endDateInput = screen.getByLabelText('终止日期') as HTMLInputElement;
|
|
expect(endDateInput.max).toBe('2026-06-27');
|
|
|
|
fireEvent.change(endDateInput, { target: { value: '2026-07-12' } });
|
|
|
|
await waitFor(() => {
|
|
expect(endDateInput.value).toBe('2026-06-27');
|
|
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
|
granularity: 'period',
|
|
anchor: undefined,
|
|
startDate: '2026-06-27',
|
|
endDate: '2026-06-27',
|
|
});
|
|
});
|
|
});
|
|
|
|
test('Dashboard 四张趋势图共享横向日期窗口', async () => {
|
|
const baseChart = dashboardResponse.charts[0]!;
|
|
vi.mocked(getAdminDashboard).mockResolvedValue({
|
|
...dashboardResponse,
|
|
charts: [
|
|
baseChart,
|
|
{
|
|
...baseChart,
|
|
id: 'consumed-mud-points',
|
|
title: '消耗泥点',
|
|
unit: '泥点',
|
|
},
|
|
],
|
|
});
|
|
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
|
|
|
const chartRegions = await screen.findAllByRole('region', {
|
|
name: /趋势图$/,
|
|
});
|
|
const [sourceChart, targetChart] = chartRegions;
|
|
if (!sourceChart || !targetChart) {
|
|
throw new Error('趋势图未完整渲染');
|
|
}
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
setScrollableDimensions(sourceChart, {
|
|
clientWidth: 100,
|
|
scrollWidth: 500,
|
|
scrollLeft: 200,
|
|
});
|
|
setScrollableDimensions(targetChart, {
|
|
clientWidth: 100,
|
|
scrollWidth: 500,
|
|
scrollLeft: 0,
|
|
});
|
|
|
|
await act(async () => {
|
|
fireEvent.scroll(sourceChart);
|
|
});
|
|
|
|
expect(targetChart.scrollLeft).toBe(200);
|
|
});
|
|
|
|
test('Dashboard 访问人数趋势明确区分每日桶与时段去重总数', async () => {
|
|
const baseChart = dashboardResponse.charts[0]!;
|
|
vi.mocked(getAdminDashboard).mockResolvedValue({
|
|
...dashboardResponse,
|
|
charts: [
|
|
{
|
|
...baseChart,
|
|
id: 'visit-users',
|
|
title: '每日访问人数',
|
|
unit: '人',
|
|
total: 34,
|
|
},
|
|
],
|
|
});
|
|
|
|
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
|
|
|
expect(await screen.findByText('每日访问人数')).toBeTruthy();
|
|
expect(screen.getByText('时段去重 34 人')).toBeTruthy();
|
|
});
|
|
|
|
function setupUser() {
|
|
return userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
}
|
|
|
|
function setScrollableDimensions(
|
|
element: HTMLElement,
|
|
dimensions: {
|
|
clientWidth: number;
|
|
scrollWidth: number;
|
|
scrollLeft: number;
|
|
},
|
|
) {
|
|
Object.defineProperties(element, {
|
|
clientWidth: { configurable: true, value: dimensions.clientWidth },
|
|
scrollWidth: { configurable: true, value: dimensions.scrollWidth },
|
|
scrollLeft: {
|
|
configurable: true,
|
|
value: dimensions.scrollLeft,
|
|
writable: true,
|
|
},
|
|
});
|
|
}
|