This commit is contained in:
2026-05-01 00:33:39 +08:00
parent 61969c5116
commit fe02603ba1
68 changed files with 4586 additions and 748 deletions

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { act, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
@@ -118,10 +118,24 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
ResolvedAssetImage: ({
src,
alt,
className,
...rest
}: {
src?: string | null;
alt?: string;
className?: string;
}) =>
src ? (
<img src={src} alt={alt ?? ''} className={className} {...rest} />
) : null,
}));
const originalMatchMedia = window.matchMedia;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const puzzlePublicEntry = {
sourceType: 'puzzle',
workId: 'puzzle-work-public-1',
@@ -156,6 +170,33 @@ const remixRankEntry = {
updatedAt: '2026-04-25T11:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
function buildCarouselPuzzleEntry(
id: string,
worldName: string,
coverPrefix: string,
) {
return {
...puzzlePublicEntry,
workId: `puzzle-work-${id}`,
profileId: `puzzle-profile-${id}`,
publicWorkCode: `PZ-${id.toUpperCase()}`,
worldName,
coverImageSrc: `${coverPrefix}-fallback.png`,
coverSlides: [
{
id: `${id}-cover-1`,
imageSrc: `${coverPrefix}-1.png`,
label: `${worldName} 1`,
},
{
id: `${id}-cover-2`,
imageSrc: `${coverPrefix}-2.png`,
label: `${worldName} 2`,
},
],
} satisfies PlatformPublicGalleryCard;
}
const hotRankEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-hot-rank',
@@ -414,12 +455,23 @@ function renderStatefulLoggedOutHomeView(
}
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: originalMatchMedia,
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
writable: true,
value: originalRequestAnimationFrame,
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
writable: true,
value: originalCancelAnimationFrame,
});
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
@@ -430,7 +482,7 @@ test('opens wallet ledger modal from narrative coin card', async () => {
const user = userEvent.setup();
renderProfileView();
await user.click(screen.getByText('剩余陶泥币'));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('陶泥币账单')).toBeTruthy();
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
@@ -446,7 +498,7 @@ test('profile total play time card always uses hours', () => {
});
const playTimeCard = screen.getByRole('button', {
name: //u,
name: //u,
});
expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
@@ -470,12 +522,12 @@ test('wallet ledger modal shows empty and error states', async () => {
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
renderProfileView();
await user.click(screen.getByText('剩余陶泥币'));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
await user.click(screen.getByLabelText('关闭陶泥币账单'));
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
await user.click(screen.getByText('剩余陶泥币'));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('加载失败')).toBeTruthy();
expect(screen.getByText('重新加载')).toBeTruthy();
@@ -622,6 +674,126 @@ test('mobile public work cards render cover, author, kind and cover stats', () =
).toBe('推荐');
});
test('mobile home feed only rotates the card closest to screen center', () => {
vi.useFakeTimers();
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
writable: true,
value: (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(0), 0),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
writable: true,
value: (handle: number) => window.clearTimeout(handle),
});
const firstEntry = buildCarouselPuzzleEntry('center1', '中心拼图一', 'center-one');
const secondEntry = buildCarouselPuzzleEntry(
'center2',
'中心拼图二',
'center-two',
);
const cardRects = new Map<string, DOMRect>();
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [firstEntry, secondEntry],
});
const tabPanel = document.querySelector('.platform-tab-panel--active');
const firstCard = screen.getByRole('button', { name: //u });
const secondCard = screen.getByRole('button', { name: //u });
if (!tabPanel) {
throw new Error('缺少移动端首页滚动面板');
}
tabPanel.getBoundingClientRect = vi.fn(
() =>
({
top: 0,
bottom: 600,
height: 600,
left: 0,
right: 360,
width: 360,
}) as DOMRect,
);
firstCard.getBoundingClientRect = vi.fn(() => cardRects.get('first')!);
secondCard.getBoundingClientRect = vi.fn(() => cardRects.get('second')!);
cardRects.set('first', {
top: 170,
bottom: 370,
height: 200,
left: 0,
right: 320,
width: 320,
} as DOMRect);
cardRects.set('second', {
top: 420,
bottom: 620,
height: 200,
left: 0,
right: 320,
width: 320,
} as DOMRect);
act(() => {
vi.runOnlyPendingTimers();
});
expect(within(firstCard).getByRole('img').getAttribute('src')).toBe(
'center-one-1.png',
);
expect(within(secondCard).getByRole('img').getAttribute('src')).toBe(
'center-two-1.png',
);
act(() => {
vi.advanceTimersByTime(4200);
});
expect(within(firstCard).getByRole('img').getAttribute('src')).toBe(
'center-one-2.png',
);
expect(within(secondCard).getByRole('img').getAttribute('src')).toBe(
'center-two-1.png',
);
cardRects.set('first', {
top: -120,
bottom: 80,
height: 200,
left: 0,
right: 320,
width: 320,
} as DOMRect);
cardRects.set('second', {
top: 200,
bottom: 400,
height: 200,
left: 0,
right: 320,
width: 320,
} as DOMRect);
act(() => {
tabPanel.dispatchEvent(new Event('scroll'));
vi.runOnlyPendingTimers();
});
expect(within(firstCard).getByRole('img').getAttribute('src')).toBe(
'center-one-1.png',
);
act(() => {
vi.advanceTimersByTime(4200);
});
expect(within(secondCard).getByRole('img').getAttribute('src')).toBe(
'center-two-2.png',
);
});
test('mobile today channel only shows newly published works from today', async () => {
const user = userEvent.setup();
const now = new Date();
@@ -676,13 +848,31 @@ test('mobile today channel only shows newly published works from today', async (
expect(screen.queryByText('今日更新旧作')).toBeNull();
});
test('desktop trending list shows kind instead of work code or timestamp text', () => {
test('desktop home syncs mobile home modules without square or latest labels', () => {
mockDesktopLayout();
const todayPublishedAt = new Date().toISOString();
const todayEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-desktop-today',
profileId: 'puzzle-profile-desktop-today',
publicWorkCode: 'PZ-DTODAY',
worldName: '桌面今日新游',
publishedAt: todayPublishedAt,
updatedAt: todayPublishedAt,
} satisfies PlatformPublicGalleryCard;
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
latestEntries: [puzzlePublicEntry, todayEntry],
});
expect(screen.getByText('今日游戏')).toBeTruthy();
expect(screen.getByText('推荐')).toBeTruthy();
expect(screen.getByText('作品分类')).toBeTruthy();
expect(screen.getAllByText('桌面今日新游').length).toBeGreaterThan(0);
expect(screen.queryByText('趋势关注')).toBeNull();
expect(screen.queryByText('最新发布')).toBeNull();
expect(screen.queryByText('作品广场')).toBeNull();
expect(screen.queryByText('公开作品')).toBeNull();
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.queryByText('1777110165.990127Z')).toBeNull();