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.
482 lines
16 KiB
TypeScript
482 lines
16 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
type BarkBattleImageGenerationBatchResult,
|
|
generateAllBarkBattleImageAssets,
|
|
updateBarkBattleDraftConfig,
|
|
} from '../../services/bark-battle-creation';
|
|
import { BarkBattleGeneratingView } from './BarkBattleGeneratingView';
|
|
|
|
vi.mock('../../services/bark-battle-creation', () => ({
|
|
generateAllBarkBattleImageAssets: vi.fn(),
|
|
updateBarkBattleDraftConfig: vi.fn(),
|
|
}));
|
|
|
|
const draft = {
|
|
draftId: 'bark-battle-draft-1',
|
|
workId: 'BB-12345678',
|
|
title: '汪汪冠军杯',
|
|
description: '',
|
|
themeDescription: '霓虹公园擂台',
|
|
playerImageDescription: '红围巾柴犬',
|
|
opponentImageDescription: '蓝头带哈士奇',
|
|
difficultyPreset: 'normal' as const,
|
|
configVersion: 2,
|
|
rulesetVersion: 'bark-battle-ruleset-v1',
|
|
updatedAt: '2026-05-14T10:00:00.000Z',
|
|
};
|
|
|
|
describe('BarkBattleGeneratingView', () => {
|
|
it('renders all generation slots while parallel generation is still running', async () => {
|
|
const onComplete = vi.fn();
|
|
let resolveGeneration: (
|
|
result: BarkBattleImageGenerationBatchResult,
|
|
) => void = () => {};
|
|
vi.mocked(generateAllBarkBattleImageAssets).mockReturnValue(
|
|
new Promise<BarkBattleImageGenerationBatchResult>((resolve) => {
|
|
resolveGeneration = resolve;
|
|
}),
|
|
);
|
|
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue({
|
|
...draft,
|
|
configVersion: 3,
|
|
updatedAt: '2026-05-14T10:01:00.000Z',
|
|
});
|
|
|
|
const { container } = render(
|
|
<BarkBattleGeneratingView
|
|
draft={draft}
|
|
onBack={() => {}}
|
|
onComplete={onComplete}
|
|
onError={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(container.firstChild).toBeTruthy();
|
|
expect((container.firstChild as HTMLElement).className).toContain('z-[1]');
|
|
expect((container.firstChild as HTMLElement).className).toContain(
|
|
'overflow-hidden',
|
|
);
|
|
expect((container.firstChild as HTMLElement).className).not.toContain(
|
|
'overflow-y-auto',
|
|
);
|
|
expect(screen.getByText('总进度')).toBeTruthy();
|
|
expect(screen.getByText('总进度').className).toContain('text-[9px]');
|
|
const pageVideo = screen.getByTestId(
|
|
'generation-page-background-video',
|
|
) as HTMLVideoElement;
|
|
expect(pageVideo.parentElement?.className).toContain('z-0');
|
|
expect(pageVideo.parentElement?.className).toContain('bg-transparent');
|
|
expect(pageVideo.parentElement?.className).not.toContain('bg-[#fff4ea]');
|
|
expect((container.firstChild as HTMLElement).contains(pageVideo)).toBe(
|
|
true,
|
|
);
|
|
expect(pageVideo.autoplay).toBe(true);
|
|
expect(pageVideo.loop).toBe(true);
|
|
expect(pageVideo.muted).toBe(true);
|
|
expect(pageVideo.playsInline).toBe(true);
|
|
expect(pageVideo.getAttribute('preload')).toBe('auto');
|
|
expect(
|
|
document.querySelector(
|
|
'video[data-testid="generation-page-background-video"] source[type="video/mp4"]',
|
|
),
|
|
).toBeTruthy();
|
|
expect(screen.getByRole('button', { name: '返回编辑' }).className).toContain(
|
|
'text-xs',
|
|
);
|
|
expect(screen.getByText('生成中').className).toContain('text-[11px]');
|
|
expect(screen.getByText('当前步骤')).toBeTruthy();
|
|
expect(screen.getByText('当前步骤').className).toContain('text-[10px]');
|
|
expect(screen.getByTestId('generation-hero-wait-card').className).toContain(
|
|
'text-center',
|
|
);
|
|
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
|
|
'text-center',
|
|
);
|
|
expect(screen.getByTestId('generation-hero-wait-card').className).toContain(
|
|
'bg-white/58',
|
|
);
|
|
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
|
|
'bg-white/58',
|
|
);
|
|
expect(
|
|
screen.getByTestId('generation-hero-wait-card').parentElement
|
|
?.className,
|
|
).toContain('mt-3');
|
|
expect(
|
|
screen.getByTestId('generation-hero-wait-card').parentElement
|
|
?.className,
|
|
).toContain('px-0');
|
|
expect(screen.getByText('预计等待').className).toContain('text-[9px]');
|
|
expect(screen.getByText('已耗时').className).toContain('text-[9px]');
|
|
expect(screen.getByText('预计等待').parentElement?.className).toContain(
|
|
'justify-center',
|
|
);
|
|
expect(screen.getByText('已耗时').parentElement?.className).toContain(
|
|
'justify-center',
|
|
);
|
|
expect(screen.getByText('3 分钟')).toBeTruthy();
|
|
expect(screen.getByText('1 秒')).toBeTruthy();
|
|
expect(screen.queryByText('预计还需 3 分钟')).toBeNull();
|
|
expect(screen.queryByText('已耗时 1 秒')).toBeNull();
|
|
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
|
'justify-start',
|
|
);
|
|
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
|
'z-30',
|
|
);
|
|
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
|
'pt-[2%]',
|
|
);
|
|
expect(screen.getByText('玩家形象')).toBeTruthy();
|
|
expect(screen.getByText('进行中 36%')).toBeTruthy();
|
|
expect(screen.getByText('进行中 36%').className).toContain('text-[11px]');
|
|
expect(screen.getByText('总进度').className).toContain('text-[9px]');
|
|
expect(screen.getByText('0%').className).toContain('text-[1.15rem]');
|
|
expect(
|
|
screen
|
|
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
|
.className,
|
|
).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
|
|
expect(
|
|
screen
|
|
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
|
.className,
|
|
).toContain('max-w-full');
|
|
expect(
|
|
screen
|
|
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
|
.className,
|
|
).toContain('aspect-square');
|
|
expect(
|
|
screen
|
|
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
|
.getAttribute('aria-valuenow'),
|
|
).toBe('0');
|
|
expect(
|
|
screen
|
|
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
|
.getAttribute('data-ring-start-degrees'),
|
|
).toBe('135');
|
|
expect(
|
|
screen
|
|
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
|
.getAttribute('data-ring-fill-start-degrees'),
|
|
).toBe('135');
|
|
expect(
|
|
screen
|
|
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
|
.getAttribute('data-ring-sweep-degrees'),
|
|
).toBe('270');
|
|
expect(
|
|
screen
|
|
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
|
.getAttribute('data-ring-gap-degrees'),
|
|
).toBe('90');
|
|
expect(
|
|
screen
|
|
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
|
.getAttribute('data-ring-fill-degrees'),
|
|
).toBe('0');
|
|
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
|
|
'svg',
|
|
);
|
|
expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain(
|
|
'z-0',
|
|
);
|
|
expect(
|
|
screen
|
|
.getByTestId('generation-hero-progress-ring')
|
|
.getAttribute('viewBox'),
|
|
).toBe('0 0 400 400');
|
|
expect(
|
|
screen
|
|
.getByTestId('generation-hero-progress-ring-track')
|
|
.getAttribute('r'),
|
|
).toBe('166');
|
|
expect(
|
|
screen
|
|
.getByTestId('generation-hero-progress-ring-track')
|
|
.getAttribute('stroke-width'),
|
|
).toBe('18');
|
|
expect(
|
|
screen
|
|
.getByTestId('generation-hero-progress-ring-track')
|
|
.getAttribute('transform'),
|
|
).toBe('rotate(135 200 200)');
|
|
expect(
|
|
screen
|
|
.getByTestId('generation-hero-progress-ring-fill')
|
|
.getAttribute('transform'),
|
|
).toBe('rotate(135 200 200)');
|
|
expect(
|
|
screen
|
|
.getByTestId('generation-hero-progress-ring-fill')
|
|
.getAttribute('stroke-dasharray'),
|
|
).toMatch(/^0\.00 1043\.\d{2}$/u);
|
|
expect(
|
|
screen.getByRole('progressbar', { name: '玩家形象 进度' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
screen
|
|
.getByRole('progressbar', { name: '玩家形象 进度' })
|
|
.getAttribute('aria-valuenow'),
|
|
).toBe('36');
|
|
expect(
|
|
screen.getByTestId('generation-current-step-card').className,
|
|
).toContain('bg-white/58');
|
|
expect(
|
|
screen.getByTestId('generation-current-step-card').parentElement
|
|
?.className,
|
|
).toContain('mt-5');
|
|
expect(screen.queryByText('预览信息')).toBeNull();
|
|
expect(screen.queryByText('汪汪声浪预览')).toBeNull();
|
|
expect(screen.queryByText('霓虹公园擂台')).toBeNull();
|
|
expect(screen.queryByText('对手形象')).toBeNull();
|
|
expect(screen.queryByText('竞技背景')).toBeNull();
|
|
expect(onComplete).not.toHaveBeenCalled();
|
|
|
|
resolveGeneration({
|
|
assets: {
|
|
'player-character': {
|
|
imageSrc: '/generated-bark-battle/player.png',
|
|
assetId: 'asset-player',
|
|
model: 'gpt-image-2-all',
|
|
size: '1024*1024',
|
|
taskId: 'task-player',
|
|
prompt: 'player',
|
|
},
|
|
'opponent-character': {
|
|
imageSrc: '/generated-bark-battle/opponent.png',
|
|
assetId: 'asset-opponent',
|
|
model: 'gpt-image-2-all',
|
|
size: '1024*1024',
|
|
taskId: 'task-opponent',
|
|
prompt: 'opponent',
|
|
},
|
|
'ui-background': {
|
|
imageSrc: '/generated-bark-battle/background.png',
|
|
assetId: 'asset-background',
|
|
model: 'gpt-image-2-all',
|
|
size: '1024*1792',
|
|
taskId: 'task-background',
|
|
prompt: 'background',
|
|
},
|
|
},
|
|
failures: {},
|
|
});
|
|
await waitFor(() => expect(onComplete).toHaveBeenCalled());
|
|
});
|
|
|
|
it('persists generated image assets before entering result view', async () => {
|
|
const onComplete = vi.fn();
|
|
const onError = vi.fn();
|
|
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
|
|
assets: {
|
|
'player-character': {
|
|
imageSrc: '/generated-bark-battle/player.png',
|
|
assetId: 'asset-player',
|
|
model: 'gpt-image-2-all',
|
|
size: '1024*1024',
|
|
taskId: 'task-player',
|
|
prompt: 'player',
|
|
},
|
|
'opponent-character': {
|
|
imageSrc: '/generated-bark-battle/opponent.png',
|
|
assetId: 'asset-opponent',
|
|
model: 'gpt-image-2-all',
|
|
size: '1024*1024',
|
|
taskId: 'task-opponent',
|
|
prompt: 'opponent',
|
|
},
|
|
'ui-background': {
|
|
imageSrc: '/generated-bark-battle/background.png',
|
|
assetId: 'asset-background',
|
|
model: 'gpt-image-2-all',
|
|
size: '1024*1792',
|
|
taskId: 'task-background',
|
|
prompt: 'background',
|
|
},
|
|
},
|
|
failures: {},
|
|
});
|
|
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue({
|
|
...draft,
|
|
configVersion: 3,
|
|
updatedAt: '2026-05-14T10:01:00.000Z',
|
|
});
|
|
|
|
render(
|
|
<BarkBattleGeneratingView
|
|
draft={draft}
|
|
onBack={() => {}}
|
|
onComplete={onComplete}
|
|
onError={onError}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
draftId: 'bark-battle-draft-1',
|
|
workId: 'BB-12345678',
|
|
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
|
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
|
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
|
}),
|
|
);
|
|
});
|
|
expect(onComplete).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
configVersion: 3,
|
|
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
|
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
|
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
|
}),
|
|
false,
|
|
);
|
|
expect(onError).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it('enters result view with partial failure when only part of the images are generated', async () => {
|
|
const onComplete = vi.fn();
|
|
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
|
|
assets: {
|
|
'player-character': {
|
|
imageSrc: '/generated-bark-battle/player.png',
|
|
assetId: 'asset-player',
|
|
model: 'gpt-image-2-all',
|
|
size: '1024*1024',
|
|
taskId: 'task-player',
|
|
prompt: 'player',
|
|
},
|
|
},
|
|
failures: {
|
|
'opponent-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
|
|
'ui-background': '场景图片生成失败:上游超时',
|
|
},
|
|
});
|
|
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue({
|
|
...draft,
|
|
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
|
configVersion: 3,
|
|
});
|
|
|
|
render(
|
|
<BarkBattleGeneratingView
|
|
draft={draft}
|
|
onBack={() => {}}
|
|
onComplete={onComplete}
|
|
onError={() => {}}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onComplete).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('still enters result view when generated assets cannot be persisted', async () => {
|
|
const onComplete = vi.fn();
|
|
const onError = vi.fn();
|
|
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
|
|
assets: {
|
|
'player-character': {
|
|
imageSrc: '/generated-bark-battle/player.png',
|
|
assetId: 'asset-player',
|
|
model: 'gpt-image-2-all',
|
|
size: '1024*1024',
|
|
taskId: 'task-player',
|
|
prompt: 'player',
|
|
},
|
|
'opponent-character': {
|
|
imageSrc: '/generated-bark-battle/opponent.png',
|
|
assetId: 'asset-opponent',
|
|
model: 'gpt-image-2-all',
|
|
size: '1024*1024',
|
|
taskId: 'task-opponent',
|
|
prompt: 'opponent',
|
|
},
|
|
'ui-background': {
|
|
imageSrc: '/generated-bark-battle/background.png',
|
|
assetId: 'asset-background',
|
|
model: 'gpt-image-2-all',
|
|
size: '1024*1792',
|
|
taskId: 'task-background',
|
|
prompt: 'background',
|
|
},
|
|
},
|
|
failures: {},
|
|
});
|
|
vi.mocked(updateBarkBattleDraftConfig).mockRejectedValue(
|
|
new Error('保存超时'),
|
|
);
|
|
|
|
render(
|
|
<BarkBattleGeneratingView
|
|
draft={draft}
|
|
onBack={() => {}}
|
|
onComplete={onComplete}
|
|
onError={onError}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onComplete).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
|
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
|
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
expect(onError).toHaveBeenCalledWith('保存超时');
|
|
});
|
|
|
|
it('shows generation failures and enters result view when no image asset is generated', async () => {
|
|
const onComplete = vi.fn();
|
|
const onError = vi.fn();
|
|
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
|
|
assets: {},
|
|
failures: {
|
|
'player-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
|
|
'opponent-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
|
|
'ui-background': '场景图片生成失败:上游超时',
|
|
},
|
|
});
|
|
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue(draft);
|
|
|
|
render(
|
|
<BarkBattleGeneratingView
|
|
draft={draft}
|
|
onBack={() => {}}
|
|
onComplete={onComplete}
|
|
onError={onError}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onError).toHaveBeenCalledWith(
|
|
'泥点不足,本次需要 1 泥点,当前 0 泥点。',
|
|
);
|
|
expect(onComplete).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
draftId: draft.draftId,
|
|
workId: draft.workId,
|
|
title: draft.title,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
});
|