Update spacetime-client bindings and frontend

Large update across server and web clients: regenerated/added many spacetime-client module bindings and input types (including new delete/work_delete input types and numerous procedure/reducer files), updates to server-rs API modules (bark_battle, jump_hop, wooden_fish, auth, module-runtime and shared contracts), and fixes in module-runtime behavior and domain logic. Frontend changes include new/updated components and tests (creative audio helpers, bark-battle/jump-hop/wooden-fish clients and views, unified generation pages, RPG entry views, and runtime shells), plus CSS and service updates. Documentation and operational notes updated (.hermes pitfalls and multiple PRD/docs) to cover daily-task refresh, banner asset fallback, recommend-key bug, and other platform behaviors. Tests and verification steps added/updated alongside these changes.
This commit is contained in:
2026-06-04 22:44:19 +08:00
parent 2678954627
commit 27b30f974b
326 changed files with 4374 additions and 2539 deletions

View File

@@ -32,8 +32,10 @@ import {
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
type PlatformEdutainmentGalleryCard,
type PlatformJumpHopGalleryCard,
type PlatformPublicGalleryCard,
type PlatformPuzzleGalleryCard,
type PlatformWoodenFishGalleryCard,
} from './rpgEntryWorldPresentation';
const {
@@ -321,6 +323,7 @@ const {
const {
mockGetPublicAuthUserByCode,
mockGetPublicAuthUserById,
mockRefreshStoredAccessToken,
mockUpdateAuthProfile,
} = vi.hoisted(() => ({
mockGetPublicAuthUserByCode: vi.fn(
@@ -341,9 +344,14 @@ const {
avatarUrl: null,
}),
),
mockRefreshStoredAccessToken: vi.fn(async () => 'jwt-refreshed-token'),
mockUpdateAuthProfile: vi.fn(),
}));
vi.mock('../../services/apiClient', () => ({
refreshStoredAccessToken: mockRefreshStoredAccessToken,
}));
vi.mock('../../services/authService', () => ({
getPublicAuthUserByCode: mockGetPublicAuthUserByCode,
getPublicAuthUserById: mockGetPublicAuthUserById,
@@ -413,11 +421,6 @@ const originalUserAgent = navigator.userAgent;
const originalMaxTouchPoints = navigator.maxTouchPoints;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const DEFAULT_PROFILE_CREATED_AT = '2026-04-01T00:00:00.000Z';
function buildFreshProfileCreatedAt() {
return new Date().toISOString();
}
function dispatchPointerEvent(
target: HTMLElement,
@@ -481,6 +484,53 @@ const puzzlePublicEntry = {
updatedAt: '2026-04-25T10:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
const jumpHopPublicEntry = {
sourceType: 'jump-hop',
workId: 'jump-hop-work-public-1',
profileId: 'jump-hop-profile-public-1',
sourceSessionId: 'jump-hop-session-public-1',
publicWorkCode: 'JH-EPUBLIC1',
ownerUserId: 'jump-hop-user-1',
authorDisplayName: '跳台作者',
worldName: '星桥跳台',
subtitle: '标准路线',
summaryText: '一条用于公开分享的跳一跳路线。',
coverImageSrc: null,
themeTags: ['跳一跳'],
playCount: 8,
remixCount: 1,
likeCount: 3,
recentPlayCount7d: 2,
visibility: 'published',
publishedAt: '2026-05-20T10:00:00.000Z',
updatedAt: '2026-05-20T10:00:00.000Z',
difficulty: 'standard',
stylePreset: 'storybook',
} satisfies PlatformJumpHopGalleryCard;
const woodenFishPublicEntry = {
sourceType: 'wooden-fish',
workId: 'wooden-fish-work-public-1',
profileId: 'wooden-fish-profile-public-1',
sourceSessionId: 'wooden-fish-session-public-1',
publicWorkCode: 'WF-EPUBLIC1',
ownerUserId: 'wooden-fish-user-1',
authorUsername: null,
authorDisplayName: '木鱼作者',
worldName: '莲台木鱼',
subtitle: '敲木鱼',
summaryText: '一件用于公开分享的敲木鱼作品。',
coverImageSrc: null,
themeTags: ['敲木鱼'],
playCount: 9,
remixCount: 2,
likeCount: 4,
recentPlayCount7d: 3,
visibility: 'published',
publishedAt: '2026-05-21T10:00:00.000Z',
updatedAt: '2026-05-21T10:00:00.000Z',
} satisfies PlatformWoodenFishGalleryCard;
const remixRankEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-remix-rank',
@@ -1081,6 +1131,7 @@ afterEach(() => {
mockBuildReferralCenter(),
);
mockGetRpgProfileTasks.mockResolvedValue(mockBuildTaskCenter());
mockRefreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token');
mockClaimRpgProfileTaskReward.mockResolvedValue({
taskId: 'daily_login',
dayKey: 20260503,
@@ -2447,7 +2498,7 @@ test('profile daily task shortcut reflects task progress and claim updates', asy
await waitFor(() => {
expect(within(dailyTask).getByText('1 / 1')).toBeTruthy();
});
expect(within(dailyTask).getByText('领取')).toBeTruthy();
expect(dailyTask.querySelector('.platform-profile-daily-task-card__action')).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
@@ -2465,7 +2516,80 @@ test('profile daily task shortcut reflects task progress and claim updates', asy
expect(await screen.findByText('已领取 10 泥点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '已领取' })).toBeNull();
expect(screen.getByText('暂无任务')).toBeTruthy();
expect(within(dailyTask).getByText('已完成')).toBeTruthy();
expect(within(dailyTask).queryByText('已完成')).toBeNull();
});
test('profile daily task refreshes at Beijing midnight reset', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-03T15:59:58.000Z'));
mockGetRpgProfileTasks
.mockResolvedValueOnce(
mockBuildTaskCenter({
walletBalance: 10,
tasks: [
{
taskId: 'daily_login',
title: '每日登录',
description: '',
eventKey: 'profile.login.daily',
cycle: 'daily',
threshold: 1,
progressCount: 1,
rewardPoints: 10,
status: 'claimed',
dayKey: 20260503,
claimedAt: '2026-05-03T15:59:00Z',
updatedAt: '2026-05-03T15:59:00Z',
},
],
updatedAt: '2026-05-03T15:59:00Z',
}),
)
.mockResolvedValueOnce(
mockBuildTaskCenter({
walletBalance: 10,
tasks: [
{
taskId: 'daily_login',
title: '每日登录',
description: '',
eventKey: 'profile.login.daily',
cycle: 'daily',
threshold: 1,
progressCount: 1,
rewardPoints: 10,
status: 'claimable',
dayKey: 20260504,
claimedAt: null,
updatedAt: '2026-05-03T16:00:00Z',
},
],
updatedAt: '2026-05-03T16:00:00Z',
}),
);
renderProfileView();
await act(async () => {
await Promise.resolve();
});
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(2000);
await Promise.resolve();
await Promise.resolve();
});
expect(mockRefreshStoredAccessToken).toHaveBeenCalledWith({
clearOnFailure: false,
});
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('button', { name: '领取' })).toBeTruthy();
});
test('profile task center keeps only the highest priority actionable task', async () => {
@@ -2534,7 +2658,7 @@ test('profile total play time card always uses hours', async () => {
});
const playTimeCard = screen.getByRole('button', {
name: //u,
name: //u,
});
expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
@@ -2548,10 +2672,11 @@ test('profile played works card shows count unit', async () => {
});
const playedCard = screen.getByRole('button', {
name: /\s*1/u,
name: /\s*1/u,
});
expect(within(playedCard).getByText('1个')).toBeTruthy();
expect(within(playedCard).queryByText('已玩游戏数量')).toBeNull();
await screen.findByText('1 / 1');
});
@@ -2563,8 +2688,8 @@ test('profile stats cards are centered without update timestamp', async () => {
const walletCard = screen.getByRole('button', {
name: /\s*0/u,
});
const playTimeCard = screen.getByRole('button', { name: /|/u });
const playedCard = screen.getByRole('button', { name: /\s*0/u });
const playTimeCard = screen.getByRole('button', { name: /\s*0/u });
const playedCard = screen.getByRole('button', { name: /\s*0/u });
for (const card of [walletCard, playTimeCard, playedCard]) {
expect(card.className).toContain('platform-profile-stat-card');
@@ -2616,8 +2741,8 @@ test('mobile profile page matches the reference layout sections', async () => {
expect(statPanel.className).toContain('platform-profile-stats-panel');
expect(statPanel.querySelector('.platform-profile-stats-grid')).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*70/u })).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*0/u })).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*0/u })).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*0/u })).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*0/u })).toBeTruthy();
expect(
within(statPanel).getByRole('button', { name: /\s*70/u }).className,
).toContain('platform-profile-stat-card');
@@ -2628,6 +2753,8 @@ test('mobile profile page matches the reference layout sections', async () => {
expect(dailyTask.querySelector('.platform-profile-daily-task-card__title')).toBeTruthy();
expect(dailyTask.querySelector('.platform-profile-daily-task-card__desc')).toBeTruthy();
expect(dailyTask.querySelector('.platform-profile-daily-task-card__progress')).toBeTruthy();
expect(dailyTask.querySelector('.platform-profile-daily-task-card__action')).toBeNull();
expect(dailyTask.textContent).not.toContain('去完成');
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
expect(await within(dailyTask).findByText('1 / 1')).toBeTruthy();
@@ -2668,13 +2795,22 @@ test('mobile profile page matches the reference layout sections', async () => {
within(shortcutRegion).getByRole('button', { name: new RegExp(label, 'u') }),
).toBeTruthy();
}
expect(
within(
within(shortcutRegion).getByRole('button', { name: //u }),
).getByText('帮我们优化产品'),
).toBeTruthy();
expect(
within(
within(shortcutRegion).getByRole('button', { name: //u }),
).queryByText('帮助我们做得更好'),
).toBeNull();
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
for (const label of ['主题设置', '账号与安全', '通用设置']) {
expect(
within(settingsRegion).getByRole('button', { name: new RegExp(label, 'u') }),
).toBeTruthy();
}
expect(within(settingsRegion).getByRole('button', { name: //u })).toBeTruthy();
expect(within(settingsRegion).queryByRole('button', { name: //u })).toBeNull();
expect(within(settingsRegion).queryByRole('button', { name: //u })).toBeNull();
expect(settingsRegion.querySelectorAll('.platform-profile-settings-row')).toHaveLength(1);
expect(
within(settingsRegion).queryByRole('button', { name: //u }),
).toBeNull();
@@ -2805,7 +2941,8 @@ test('profile community shortcut shows reward subtitle and invited users', async
expect(screen.queryByRole('button', { name: //u })).toBeNull();
const communityButton = screen.getByRole('button', { name: //u });
expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy();
expect(within(communityButton).getByText('交流心得')).toBeTruthy();
expect(within(communityButton).queryByText('交流心得 领取福利')).toBeNull();
await user.click(communityButton);
@@ -2982,8 +3119,12 @@ test('profile page shows legal entries and hides archive shortcuts', async () =>
const dailyTask = screen.getByRole('button', { name: //u });
expect(dailyTask).toBeTruthy();
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
expect(dailyTask.querySelector('.platform-profile-daily-task-card__action')).toBeNull();
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
expect(within(settingsRegion).getByRole('button', { name: //u })).toBeTruthy();
expect(within(settingsRegion).queryByRole('button', { name: //u })).toBeNull();
expect(within(settingsRegion).queryByRole('button', { name: //u })).toBeNull();
expect(
within(settingsRegion).queryByRole('button', { name: //u }),
).toBeNull();
@@ -3590,6 +3731,53 @@ test('logged out recommend page can enter runtime without login gate', () => {
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
test('mobile recommend meta matches active jump hop runtime entry', () => {
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry, jumpHopPublicEntry],
activeRecommendEntryKey: 'jump-hop:jump-hop-user-1:jump-hop-profile-public-1',
recommendRuntimeContent: (
<div data-testid="recommend-runtime"></div>
),
});
expect(screen.getByTestId('recommend-runtime').textContent).toContain(
'跳一跳运行内容',
);
const meta = document.querySelector(
'.platform-recommend-work-meta[data-active="true"]',
) as HTMLElement | null;
expect(meta?.getAttribute('aria-label')).toBe('星桥跳台 作品信息');
if (!meta) {
throw new Error('缺少当前推荐作品信息');
}
expect(within(meta).getByText('跳台作者')).toBeTruthy();
expect(within(meta).getByText('星桥跳台')).toBeTruthy();
});
test('mobile recommend meta matches active wooden fish runtime entry', () => {
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry, woodenFishPublicEntry],
activeRecommendEntryKey:
'wooden-fish:wooden-fish-user-1:wooden-fish-profile-public-1',
recommendRuntimeContent: (
<div data-testid="recommend-runtime"></div>
),
});
expect(screen.getByTestId('recommend-runtime').textContent).toContain(
'敲木鱼运行内容',
);
const meta = document.querySelector(
'.platform-recommend-work-meta[data-active="true"]',
) as HTMLElement | null;
expect(meta?.getAttribute('aria-label')).toBe('莲台木鱼 作品信息');
if (!meta) {
throw new Error('缺少当前推荐作品信息');
}
expect(within(meta).getByText('木鱼作者')).toBeTruthy();
expect(within(meta).getByText('莲台木鱼')).toBeTruthy();
});
test('logged out desktop recommend rail enters runtime without login modal', async () => {
mockDesktopLayout();
const user = userEvent.setup();