Files
Genarrative/src/services/miniGameDraftGenerationProgress.test.ts
T
JenkenB 50a0d6f982 Refine creation tab UX, generation flow, and bindings
Large changes across frontend, backend and docs to align creation-tab and generation-page behavior with new product UI/UX and Spacetime bindings. Updated hermes decision-log and pitfalls with concrete rules (banner carousel, font sizing, unread-dot tokens, template-card layout, direct card->entry routing, separation of account balance vs prize pools, removal of global page card shell, generation progress milestones and unified circular progress, and background video handling). Added GenerationProgressHero component and media assets, plus generation-related UI/tests updates (CustomWorldGenerationView, BarkBattleGeneratingView, creation hub/cards, platform entry routing, index tests). Backend and contract updates include new category fields in admin API types and admin UI form/list, spacetime-client/module/migration changes and generated bindings script. Misc: many tests adjusted, new docs and plan files added, and several server-rs crate changes to support the updated creation/ generation workflows.
2026-05-25 00:41:30 +08:00

645 lines
20 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import {
buildBabyObjectMatchGenerationAnchorEntries,
buildJumpHopGenerationAnchorEntries,
buildMatch3DGenerationAnchorEntries,
buildMiniGameDraftGenerationProgress,
buildPuzzleGenerationAnchorEntries,
buildWoodenFishGenerationAnchorEntries,
createMiniGameDraftGenerationState,
type MiniGameDraftGenerationState,
} from './miniGameDraftGenerationProgress';
describe('miniGameDraftGenerationProgress', () => {
test('puzzle draft generation follows picture-only creation steps', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 2500);
expect(progress?.steps.map((step) => step.label)).toEqual([
'编译首关草稿',
'生成关卡名称',
'生成拼图首图',
'生成关卡画面',
'生成UI与背景',
'写入正式草稿',
]);
expect(progress?.phaseLabel).toBe('编译首关草稿');
expect(progress?.steps[0]?.detail).toBe(
'建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
);
expect(progress?.steps[2]?.detail).toBe(
'调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
);
expect(progress?.estimatedRemainingMs).toBe(446_500);
expect(progress?.overallProgress).toBe(0);
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
});
test('puzzle draft generation starts total progress from zero', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 1000);
expect(progress?.overallProgress).toBe(0);
expect(progress?.completedWeight).toBe(0);
expect(progress?.estimatedRemainingMs).toBe(448_000);
expect(progress?.steps[0]?.completed).toBe(0);
});
test('puzzle draft generation total progress advances after startup', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 7000);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.overallProgress).toBeLessThan(88);
expect(progress?.phaseId).toBe('compile');
});
test('puzzle draft generation keeps current step until real progress advances it', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const longRunningProgress = buildMiniGameDraftGenerationProgress(
state,
296_000,
);
const progressedState: MiniGameDraftGenerationState = {
...state,
phase: 'puzzle-cover-image',
};
const realProgress = buildMiniGameDraftGenerationProgress(
progressedState,
296_000,
);
expect(longRunningProgress?.phaseId).toBe('compile');
expect(longRunningProgress?.steps[0]?.status).toBe('active');
expect(longRunningProgress?.steps[1]?.status).toBe('pending');
expect(longRunningProgress?.overallProgress).toBeLessThanOrEqual(98);
expect(longRunningProgress?.overallProgress).toBeGreaterThan(40);
expect(realProgress?.phaseId).toBe('puzzle-cover-image');
expect(realProgress?.steps[1]?.status).toBe('completed');
expect(realProgress?.steps[2]?.status).toBe('active');
});
test('puzzle write-back step stays active until the generation action finishes', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'puzzle-select-image',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 448_950);
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);
expect(progress?.estimatedRemainingMs).toBe(50);
expect(progress?.steps[5]?.completed).toBe(0.98);
expect(progress?.steps[5]?.status).toBe('active');
});
test('puzzle direct upload generation skips the first image generation step', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
metadata: {
puzzleAiRedraw: false,
},
};
const progress = buildMiniGameDraftGenerationProgress(state, 20_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 206_000);
expect(progress?.steps.map((step) => step.label)).toEqual([
'编译首关草稿',
'生成关卡名称',
'生成关卡画面',
'生成UI与背景',
'写入正式草稿',
]);
expect(progress?.phaseId).toBe('compile');
expect(progress?.steps[2]?.detail).toContain('直接使用上传图作为参考');
expect(progress?.estimatedRemainingMs).toBe(189_000);
expect(writeBackProgress?.phaseId).toBe('compile');
expect(writeBackProgress?.estimatedRemainingMs).toBe(3_000);
});
test('puzzle draft generation does not advance or claim completion before response', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 630_000);
expect(progress?.phaseId).toBe('compile');
expect(progress?.overallProgress).toBeLessThan(88);
expect(progress?.overallProgress).toBeGreaterThan(80);
expect(progress?.estimatedRemainingMs).toBe(0);
expect(progress?.steps[0]?.status).toBe('active');
expect(progress?.steps[0]?.completed).toBe(0.98);
expect(progress?.steps.slice(1).every((step) => step.status === 'pending')).toBe(
true,
);
});
test('puzzle draft generation advances steps from backend progress percent only', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 88,
} as MiniGameDraftGenerationState['metadata'],
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 120_000);
const uiProgress = buildMiniGameDraftGenerationProgress(
{
...state,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 94,
} as MiniGameDraftGenerationState['metadata'],
},
230_000,
);
const writeProgress = buildMiniGameDraftGenerationProgress(
{
...state,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 96,
} as MiniGameDraftGenerationState['metadata'],
},
260_000,
);
expect(imageProgress?.phaseId).toBe('puzzle-cover-image');
expect(imageProgress?.steps[2]?.status).toBe('active');
expect(imageProgress?.steps[3]?.status).toBe('pending');
expect(uiProgress?.phaseId).toBe('puzzle-ui-assets');
expect(uiProgress?.steps[4]?.status).toBe('active');
expect(uiProgress?.steps[5]?.status).toBe('pending');
expect(writeProgress?.phaseId).toBe('puzzle-select-image');
expect(writeProgress?.steps[5]?.status).toBe('active');
});
test('puzzle backend milestone starts fake progress from the current step entry time', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 88,
puzzleActivePhaseId: 'puzzle-cover-image',
puzzleActiveStepStartedAtMs: 120_000,
} as MiniGameDraftGenerationState['metadata'],
};
const progress = buildMiniGameDraftGenerationProgress(state, 121_000);
expect(progress?.phaseId).toBe('puzzle-cover-image');
expect(progress?.steps[2]?.status).toBe('active');
expect(progress?.steps[2]?.completed).toBeLessThan(0.02);
});
test('puzzle ready copy points to result page work info completion', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'ready',
startedAtMs: 1000,
completedAssetCount: 1,
totalAssetCount: 1,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 2000);
expect(progress?.phaseDetail).toBe(
'首关草稿与正式图已准备完成,可进入结果页补作品信息。',
);
});
test('finished draft generation keeps elapsed time pinned to completion time', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'failed',
startedAtMs: 1_000,
finishedAtMs: 151_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: 'VectorEngine 图片生成请求超时',
};
const progress = buildMiniGameDraftGenerationProgress(state, 500_000);
expect(progress?.elapsedMs).toBe(150_000);
});
test('big fish draft generation exposes multiple draft steps', () => {
const state: MiniGameDraftGenerationState = {
kind: 'big-fish',
phase: 'big-fish-draft',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 1500);
expect(progress).not.toBeNull();
expect(progress?.steps).toHaveLength(3);
expect(progress?.steps.map((step) => step.id)).toEqual([
'big-fish-draft',
'big-fish-levels',
'big-fish-runtime',
]);
expect(progress?.steps[0]?.label).toBe('整理玩法骨架');
});
test('big fish generation progresses to level and runtime phases over time', () => {
const state: MiniGameDraftGenerationState = {
kind: 'big-fish',
phase: 'big-fish-draft',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const levelProgress = buildMiniGameDraftGenerationProgress(state, 3200);
const runtimeProgress = buildMiniGameDraftGenerationProgress(state, 6200);
expect(levelProgress?.phaseId).toBe('big-fish-levels');
expect(levelProgress?.phaseLabel).toBe('编译等级蓝图');
expect(runtimeProgress?.phaseId).toBe('big-fish-runtime');
expect(runtimeProgress?.phaseLabel).toBe('校准场地与参数');
});
test('big fish ready copy directs user to continue generating assets on result page', () => {
const state: MiniGameDraftGenerationState = {
kind: 'big-fish',
phase: 'ready',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 2000);
expect(progress?.phaseDetail).toBe(
'玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。',
);
});
test('match3d draft generation exposes level scene derived asset steps', () => {
const state = createMiniGameDraftGenerationState('match3d');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 30_000,
);
expect(progress?.steps.map((step) => step.id)).toEqual([
'match3d-work-title',
'match3d-item-names',
'match3d-level-scene',
'match3d-derived-assets',
'match3d-parse-spritesheet',
'match3d-write-draft',
]);
expect(progress?.phaseId).toBe('match3d-level-scene');
expect(progress?.phaseLabel).toBe('生成关卡整图');
expect(progress?.estimatedRemainingMs).toBe(430_000);
});
test('match3d draft generation starts from title generation', () => {
const state = createMiniGameDraftGenerationState('match3d');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 1_000,
);
expect(progress?.phaseId).toBe('match3d-work-title');
expect(progress?.phaseLabel).toBe('建立草稿存档');
expect(progress?.steps[0]?.detail).toBe(
'创建可恢复作品草稿,锁定本次题材和难度。',
);
});
test('match3d draft generation starts total progress from zero', () => {
const state = createMiniGameDraftGenerationState('match3d');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs,
);
expect(progress?.overallProgress).toBe(0);
expect(progress?.completedWeight).toBe(0);
expect(progress?.steps[0]?.completed).toBe(0);
});
test('match3d draft generation keeps backend observed asset phase', () => {
const state = {
...createMiniGameDraftGenerationState('match3d'),
phase: 'match3d-generate-views' as const,
completedAssetCount: 1,
totalAssetCount: 3,
};
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 20_000,
);
expect(progress?.phaseId).toBe('match3d-parse-spritesheet');
expect(progress?.steps[4]?.detail).toContain('解析 20 个物品');
expect(progress?.steps[4]?.completed).toBe(1);
expect(progress?.steps[4]?.total).toBe(3);
});
test('match3d draft generation reaches background image and writeback phases', () => {
const state = createMiniGameDraftGenerationState('match3d');
const derivedProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 150_000,
);
const writeProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 460_000,
);
expect(derivedProgress?.phaseId).toBe('match3d-derived-assets');
expect(derivedProgress?.phaseLabel).toBe('生成三张派生图');
expect(writeProgress?.phaseId).toBe('match3d-write-draft');
expect(writeProgress?.phaseLabel).toBe('写入草稿页');
});
test('match3d generation anchors show theme and difficulty item count', () => {
const entries = buildMatch3DGenerationAnchorEntries(null, {
themeText: '水果',
clearCount: 20,
difficulty: 8,
referenceImageSrc: null,
});
expect(entries).toEqual([
{
id: 'match3d-theme',
label: '题材',
value: '水果',
},
{
id: 'match3d-items',
label: '素材数量',
value: '20 种素材',
},
]);
});
test('baby object match generation exposes two item names', () => {
const state = createMiniGameDraftGenerationState('baby-object-match');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 9_000,
);
const entries = buildBabyObjectMatchGenerationAnchorEntries({
itemAName: '苹果',
itemBName: '香蕉',
});
expect(progress?.steps.map((step) => step.id)).toEqual([
'baby-object-draft',
'baby-object-images',
'baby-object-ready',
]);
expect(progress?.phaseId).toBe('baby-object-images');
expect(progress?.estimatedRemainingMs).toBe(351_000);
expect(entries).toEqual([
{
id: 'baby-object-item-1',
label: '物品 1',
value: '苹果',
},
{
id: 'baby-object-item-2',
label: '物品 2',
value: '香蕉',
},
]);
});
test('jump hop draft generation exposes character and tile atlas pipeline', () => {
const state = createMiniGameDraftGenerationState('jump-hop');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 35_000,
);
expect(progress?.steps.map((step) => step.id)).toEqual([
'jump-hop-draft',
'jump-hop-character',
'jump-hop-tile-atlas',
'jump-hop-slice-tiles',
'jump-hop-write-draft',
]);
expect(progress?.phaseId).toBe('jump-hop-character');
expect(progress?.phaseLabel).toBe('生成角色形象');
expect(progress?.estimatedRemainingMs).toBe(265_000);
});
test('jump hop generation anchors expose theme, character and tile style', () => {
const entries = buildJumpHopGenerationAnchorEntries(null, {
themeText: '云端糖果塔',
characterDescription: '披着星星披风的小旅人',
tileStyle: '纸模玩具',
difficulty: '标准',
rhythmPreference: '轻快',
});
expect(entries).toEqual([
{
id: 'jump-hop-theme',
label: '主题',
value: '云端糖果塔',
},
{
id: 'jump-hop-character',
label: '角色',
value: '披着星星披风的小旅人',
},
{
id: 'jump-hop-tile-style',
label: '地块',
value: '纸模玩具',
},
]);
});
test('wooden fish draft generation exposes hit object, background and sound pipeline', () => {
const state = createMiniGameDraftGenerationState('wooden-fish');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 28_000,
);
expect(progress?.steps.map((step) => step.id)).toEqual([
'wooden-fish-draft',
'wooden-fish-hit-object',
'wooden-fish-background',
'wooden-fish-hit-sound',
'wooden-fish-write-draft',
]);
expect(progress?.phaseId).toBe('wooden-fish-hit-object');
expect(progress?.phaseLabel).toBe('生成敲击物图案');
expect(progress?.estimatedRemainingMs).toBe(272_000);
});
test('wooden fish generation anchors expose hit object, sound and words', () => {
const entries = buildWoodenFishGenerationAnchorEntries(null, {
templateId: 'wooden-fish',
workTitle: '每日一敲',
workDescription: '敲一下,好事发生。',
themeTags: ['解压'],
hitObjectPrompt: '金色小木鱼',
hitSoundPrompt: '清脆木鱼声',
floatingWords: ['幸运+1', '功德+1'],
});
expect(entries).toEqual([
{
id: 'wooden-fish-hit-object',
label: '敲击物',
value: '金色小木鱼',
},
{
id: 'wooden-fish-hit-sound',
label: '音效',
value: '清脆木鱼声',
},
{
id: 'wooden-fish-words',
label: '飘字',
value: '幸运+1、功德+1',
},
]);
});
test('puzzle generation anchors expose form payload as the display source', () => {
const entries = buildPuzzleGenerationAnchorEntries({
sessionId: 'puzzle-session-1',
currentTurn: 1,
progressPercent: 0,
stage: 'collecting_anchors',
anchorPack: {
themePromise: {
key: 'themePromise',
label: '题材承诺',
value: '雨夜猫街',
status: 'locked',
},
visualSubject: {
key: 'visualSubject',
label: '画面主体',
value: '一只猫在雨夜灯牌下回头。',
status: 'locked',
},
visualMood: {
key: 'visualMood',
label: '视觉气质',
value: '清晰、适合拼图切块',
status: 'inferred',
},
compositionHooks: {
key: 'compositionHooks',
label: '拼图记忆点',
value: '主体轮廓、色块分区、局部细节',
status: 'inferred',
},
tagsAndForbidden: {
key: 'tagsAndForbidden',
label: '标签与禁忌',
value: '猫咪、雨夜、拼图;禁止标题字',
status: 'inferred',
},
},
draft: null,
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-04-29T00:00:00.000Z',
}, {
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
});
expect(entries).toEqual([
{
id: 'picture-description',
label: '画面描述',
value: '一只猫在雨夜灯牌下回头。',
},
]);
});
});