Refine play type integration flow and docs

This commit is contained in:
2026-06-03 00:57:24 +08:00
parent dbe4c902b4
commit 67ba40c678
35 changed files with 2226 additions and 619 deletions

View File

@@ -560,7 +560,7 @@ test('creation hub shows RPG public work code from published library entry', ()
expect(screen.queryByText('CW-00000001')).toBeNull();
});
test('creation hub exposes persisted draft delete action directly on the card', () => {
test('creation hub keeps persisted draft delete action off the card header', () => {
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
@@ -579,7 +579,7 @@ test('creation hub exposes persisted draft delete action directly on the card',
expect(
container.querySelector('.creation-work-card__swipe-underlay'),
).toBeTruthy();
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
});
test('creation hub reveals persisted draft delete action from left swipe', () => {
@@ -641,6 +641,75 @@ test('creation hub reveals persisted draft delete action from keyboard', async (
expect(screen.queryByRole('button', { name: '分享' })).toBeNull();
});
test('creation hub reveals persisted draft delete action from long press menu', () => {
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onDeletePublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
const card = screen.getByRole('button', { name: //u });
fireEvent.contextMenu(card);
expect(
container.querySelector('.creation-work-card-shell--actions-visible'),
).toBeTruthy();
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub gives every deletable work card a side delete action', () => {
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
babyObjectMatchItems={[babyObjectMatchDraftItem]}
puzzleItems={[
{
workId: 'puzzle:side-delete',
profileId: 'puzzle-profile-side-delete',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '侧边删除拼图',
summary: '不同来源都应有侧边删除。',
themeTags: ['灯塔'],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: new Date('2026-05-02T12:00:00.000Z').toISOString(),
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onDeletePublished={() => {}}
onDeleteBabyObjectMatch={() => {}}
onDeletePuzzle={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
expect(
container.querySelectorAll('.creation-work-card__swipe-underlay'),
).toHaveLength(3);
});
test('creation hub shows delete action for baby object match drafts', async () => {
const user = userEvent.setup();
const onDeleteBabyObjectMatch = vi.fn();
@@ -719,7 +788,7 @@ test('creation hub works-only tab filters bark battle draft and published works'
expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(barkBattlePublishedItem);
});
test('creation hub published work delete action is directly visible', async () => {
test('creation hub published work delete action stays in revealed side actions', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
@@ -759,9 +828,11 @@ test('creation hub published work delete action is directly visible', async () =
/>,
);
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
await user.click(screen.getByRole('button', { name: '删除' }));
expect(onDeletePuzzle).toHaveBeenCalledWith(
@@ -770,7 +841,7 @@ test('creation hub published work delete action is directly visible', async () =
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
});
test('creation hub exposes work delete action directly on card', async () => {
test('creation hub reveals draft work delete action from keyboard', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
@@ -810,6 +881,10 @@ test('creation hub exposes work delete action directly on card', async () => {
/>,
);
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
await user.click(screen.getByRole('button', { name: '删除' }));
expect(onDeletePuzzle).toHaveBeenCalledWith(
@@ -858,7 +933,9 @@ test('creation hub keeps swipe delete action available', async () => {
/>,
);
const card = screen.getByRole('button', { name: //u });
const card = screen.getByRole('button', {
name: //u,
});
fireEvent.touchStart(card, {
touches: [{ clientX: 180, clientY: 20 }],
});

View File

@@ -22,6 +22,7 @@ import {
buildCreationWorkShelfItems,
type CreationWorkShelfItem,
type CreationWorkShelfMetricId,
type CreationWorkShelfRuntimeState,
} from './creationWorkShelf';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
@@ -66,7 +67,9 @@ type CustomWorldCreationHubProps = {
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
woodenFishItems?: WoodenFishWorkSummaryResponse[];
onOpenWoodenFishDetail?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
onOpenWoodenFishDetail?:
| ((item: WoodenFishWorkSummaryResponse) => void)
| null;
onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
@@ -84,7 +87,7 @@ type CustomWorldCreationHubProps = {
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
getWorkState?: (
item: CreationWorkShelfItem,
) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null;
) => CreationWorkShelfRuntimeState | null;
onOpenShelfItem?: (item: CreationWorkShelfItem) => void;
mode?: 'full' | 'start-only' | 'works-only';
};

View File

@@ -1,5 +1,6 @@
import {
BadgeCheck,
CircleAlert,
Clock3,
Loader2,
Share2,
@@ -439,11 +440,8 @@ export function CustomWorldWorkCard({
return;
}
updateSwipeOffset(
gesture,
event.clientX,
event.clientY,
() => event.preventDefault(),
updateSwipeOffset(gesture, event.clientX, event.clientY, () =>
event.preventDefault(),
);
};
@@ -473,9 +471,7 @@ export function CustomWorldWorkCard({
}
};
const beginTouchSwipeGesture = (
event: ReactTouchEvent<HTMLDivElement>,
) => {
const beginTouchSwipeGesture = (event: ReactTouchEvent<HTMLDivElement>) => {
if (swipeRevealWidth <= 0) {
return;
}
@@ -494,20 +490,15 @@ export function CustomWorldWorkCard({
};
};
const updateTouchSwipeGesture = (
event: ReactTouchEvent<HTMLDivElement>,
) => {
const updateTouchSwipeGesture = (event: ReactTouchEvent<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
const touch = event.touches[0];
if (!gesture || gesture.pointerId !== -1 || !touch) {
return;
}
updateSwipeOffset(
gesture,
touch.clientX,
touch.clientY,
() => event.preventDefault(),
updateSwipeOffset(gesture, touch.clientX, touch.clientY, () =>
event.preventDefault(),
);
};
@@ -676,8 +667,8 @@ export function CustomWorldWorkCard({
{displayTitle}
</span>
</div>
<div className="creation-work-card__quick-actions">
{canUseShareAction ? (
{canUseShareAction ? (
<div className="creation-work-card__quick-actions">
<button
type="button"
onClick={(event) => {
@@ -713,38 +704,8 @@ export function CustomWorldWorkCard({
>
<Share2 aria-hidden="true" className="h-4 w-4" />
</button>
) : null}
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
closeSwipeActions();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
onPointerDown={(event) => {
event.stopPropagation();
}}
onTouchStart={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
title={deleteBusy ? '删除中' : '删除作品'}
aria-label={deleteBusy ? '删除中' : '删除'}
className="creation-work-card__quick-action-button creation-work-card__quick-action-button--danger"
>
{deleteBusy ? (
<span className="text-xs leading-none">...</span>
) : (
<Trash2 aria-hidden="true" className="h-4 w-4" />
)}
</button>
) : null}
</div>
</div>
) : null}
</div>
<div className="creation-work-card__meta platform-category-game-item__meta">
@@ -762,6 +723,16 @@ export function CustomWorldWorkCard({
{item.summary}
</div>
{item.hasGenerationFailure ? (
<div
aria-label={item.generationFailureSummary ?? '生成失败'}
className="creation-work-card__failure-status"
>
<CircleAlert aria-hidden="true" className="h-3.5 w-3.5" />
<span>{item.generationFailureSummary ?? '生成失败'}</span>
</div>
) : null}
{isPublished ? (
<div className="creation-work-card__published-info">
{item.pointIncentive ? (

View File

@@ -93,7 +93,9 @@ test('buildCreationWorkShelfItems maps wooden fish items with WF public code', (
expect(items[0]?.sharePath).toContain('/works/detail?work=WF-12345678');
expect(items[0]?.openActionLabel).toBe('查看详情');
expect(items[0]?.badges.some((badge) => badge.label === '敲木鱼')).toBe(true);
expect(items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value).toBe(9);
expect(
items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value,
).toBe(9);
expect(onOpenWoodenFishDetail).toHaveBeenCalledWith(woodenFishWork);
});
@@ -211,9 +213,9 @@ test('buildCreationWorkShelfItems keeps separate bark battle draft and published
expect(items.find((item) => item.status === 'published')?.id).toBe(
'BB-PUB00001',
);
expect(items.find((item) => item.status === 'published')?.publicWorkCode).toBe(
'BB-PUB00001',
);
expect(
items.find((item) => item.status === 'published')?.publicWorkCode,
).toBe('BB-PUB00001');
});
test('buildCreationWorkShelfItems falls back to deterministic RPG public work code when library entry is missing', () => {
@@ -303,10 +305,9 @@ test('buildCreationWorkShelfItems gives bark battle draft cover from character o
expect(items.find((item) => item.id === 'BB-COVER001')?.coverImageSrc).toBe(
'/draft-player-cover.png',
);
expect(items.find((item) => item.id === 'BB-COVER001')?.coverCharacterImageSrcs).toEqual([
'/draft-player-cover.png',
'/draft-opponent-cover.png',
]);
expect(
items.find((item) => item.id === 'BB-COVER001')?.coverCharacterImageSrcs,
).toEqual(['/draft-player-cover.png', '/draft-opponent-cover.png']);
expect(items.find((item) => item.id === 'BB-COVER002')?.coverImageSrc).toBe(
'/creation-type-references/bark-battle.webp',
);
@@ -457,14 +458,76 @@ test('buildCreationWorkShelfItems restores persisted generation state for puzzle
],
});
expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe(
true,
);
expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe(true);
expect(items.find((item) => item.kind === 'match3d')?.isGenerating).toBe(
true,
);
});
test('buildCreationWorkShelfItems lets failure notice override persisted generating copy', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:failed-generating',
profileId: 'puzzle-profile-failed-generating',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-failed-generating',
authorDisplayName: '测试作者',
levelName: '失败拼图',
summary: '正在生成拼图草稿。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
},
],
getItemState: (item) =>
item.kind === 'puzzle'
? {
isGenerating: false,
suppressPersistedGenerating: true,
summaryOverride: '拼图草稿生成失败,可重新打开处理。',
}
: null,
});
expect(items[0]?.isGenerating).toBe(false);
expect(items[0]?.summary).toBe('拼图草稿生成失败,可重新打开处理。');
});
test('persisted failed puzzle draft is not treated as generating', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:failed',
profileId: 'puzzle-profile-failed',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-failed',
authorDisplayName: '测试作者',
levelName: '失败拼图',
summary: '服务端已回写失败。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'failed',
},
],
});
expect(items[0]?.isGenerating).toBeFalsy();
expect(items[0]?.summary).toBe('服务端已回写失败。');
});
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
const onOpenBabyObjectMatchDetail = vi.fn();
const onDeleteBabyObjectMatch = vi.fn();
@@ -1088,7 +1151,6 @@ test('bark battle draft generating state only follows pending assets', () => {
).toBe(false);
});
test('CustomWorldWorkCard hides author on shelf draft and published cards', () => {
const buildItem = (
status: CreationWorkShelfItem['status'],
@@ -1110,7 +1172,11 @@ test('CustomWorldWorkCard hides author on shelf draft and published cards', () =
canDelete: false,
canShare: false,
badges: [
{ id: 'status', label: status === 'draft' ? '草稿' : '已发布', tone: 'neutral' },
{
id: 'status',
label: status === 'draft' ? '草稿' : '已发布',
tone: 'neutral',
},
{ id: 'type', label: '汪汪', tone: 'neutral' },
],
metrics: [],

View File

@@ -125,6 +125,8 @@ export type CreationWorkShelfItem = {
kind: CreationWorkShelfKind;
status: CreationWorkShelfStatus;
isGenerating?: boolean;
hasGenerationFailure?: boolean;
generationFailureSummary?: string;
hasUnreadUpdate?: boolean;
title: string;
summary: string;
@@ -145,6 +147,16 @@ export type CreationWorkShelfItem = {
source: CreationWorkShelfSource;
};
export type CreationWorkShelfRuntimeState = {
isGenerating?: boolean;
hasGenerationFailure?: boolean;
generationFailureSummary?: string;
hasUnreadUpdate?: boolean;
suppressPersistedGenerating?: boolean;
titleOverride?: string;
summaryOverride?: string;
};
export function buildCreationWorkShelfItems(params: {
rpgItems: CustomWorldWorkSummary[];
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
@@ -191,7 +203,7 @@ export function buildCreationWorkShelfItems(params: {
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
getItemState?: (
item: CreationWorkShelfItem,
) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null;
) => CreationWorkShelfRuntimeState | null;
}) {
const {
rpgItems,
@@ -307,18 +319,24 @@ export function buildCreationWorkShelfItems(params: {
.map((item) => {
const state = getItemState?.(item);
const persistedIsGenerating = isPersistedCreationWorkGenerating(item);
return state
const isGenerating = Boolean(
state?.isGenerating ||
(!state?.suppressPersistedGenerating && persistedIsGenerating),
);
return state || isGenerating
? {
...item,
isGenerating: Boolean(state.isGenerating || persistedIsGenerating),
hasUnreadUpdate: state.hasUnreadUpdate,
title: state?.titleOverride ?? item.title,
summary: state?.summaryOverride ?? item.summary,
isGenerating,
hasGenerationFailure:
state?.hasGenerationFailure ?? item.hasGenerationFailure,
generationFailureSummary:
state?.generationFailureSummary ??
item.generationFailureSummary,
hasUnreadUpdate: state?.hasUnreadUpdate,
}
: persistedIsGenerating
? {
...item,
isGenerating: true,
}
: item;
: item;
})
.sort(
(left, right) =>
@@ -327,7 +345,6 @@ export function buildCreationWorkShelfItems(params: {
);
}
function mergeBarkBattleShelfSourceItems(
items: readonly BarkBattleWorkSummary[],
): BarkBattleWorkSummary[] {
@@ -376,8 +393,8 @@ function mapRpgWorkToShelfItem(
: null;
const publicWorkCode =
item.status === 'published'
? (libraryEntry?.publicWorkCode?.trim() ||
(item.profileId ? buildCustomWorldPublicWorkCode(item.profileId) : null))
? libraryEntry?.publicWorkCode?.trim() ||
(item.profileId ? buildCustomWorldPublicWorkCode(item.profileId) : null)
: null;
const badges: CreationWorkShelfBadge[] = [
buildStatusBadge(item.status),
@@ -843,7 +860,9 @@ function mapWoodenFishWorkToShelfItem(
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
status === 'published' ? buildWoodenFishPublicWorkCode(item.profileId) : null;
status === 'published'
? buildWoodenFishPublicWorkCode(item.profileId)
: null;
const title = item.workTitle.trim() || '敲木鱼';
const summary =
item.workDescription.trim() || (status === 'draft' ? '未填写作品描述' : '');
@@ -884,10 +903,7 @@ function mapWoodenFishWorkToShelfItem(
};
}
function resolveAuthorDisplayName(
...sources: Array<unknown>
) {
function resolveAuthorDisplayName(...sources: Array<unknown>) {
for (const source of sources) {
const authorDisplayName =
source &&
@@ -961,7 +977,8 @@ export function resolvePuzzleLevelCoverImageSrc(
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
level.candidates[level.candidates.length - 1]?.imageSrc,
);
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
const candidateImageSrc =
selectedCandidateImageSrc || fallbackCandidateImageSrc;
if (
candidateImageSrc &&
@@ -984,7 +1001,9 @@ function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) {
const topLevelContainerImageSrc =
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageObjectKey);
normalizeCoverImageSrc(
item.generatedBackgroundAsset?.containerImageObjectKey,
);
if (topLevelContainerImageSrc) {
return topLevelContainerImageSrc;
}

File diff suppressed because it is too large Load Diff

View File

@@ -88,9 +88,7 @@ import {
} from '../../services/edutainment-baby-object';
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
import { match3dCreationClient } from '../../services/match3d-creation';
import {
createServerMatch3DRuntimeAdapter,
} from '../../services/match3d-runtime';
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
import {
deleteMatch3DWork,
getMatch3DWorkDetail,
@@ -172,6 +170,7 @@ import {
} from '../../services/square-hole-works';
import { listVisualNovelGallery } from '../../services/visual-novel-runtime';
import { listVisualNovelWorks } from '../../services/visual-novel-works';
import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient';
import { type CustomWorldProfile, WorldType } from '../../types';
import {
AuthUiContext,
@@ -759,6 +758,22 @@ vi.mock('../../services/visual-novel-works', () => ({
updateVisualNovelWork: vi.fn(),
}));
vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
woodenFishClient: {
checkpointRun: vi.fn(),
createSession: vi.fn(),
executeAction: vi.fn(),
finishRun: vi.fn(),
getGalleryDetail: vi.fn(),
getSession: vi.fn(),
getWorkDetail: vi.fn(),
listGallery: vi.fn(),
listWorks: vi.fn(),
publishWork: vi.fn(),
startRun: vi.fn(),
},
}));
vi.mock('../../services/visual-novel-creation', () => ({
compileVisualNovelWorkProfile: vi.fn(),
createVisualNovelSession: vi.fn(),
@@ -2672,6 +2687,12 @@ beforeEach(() => {
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
vi.mocked(listVisualNovelGallery).mockResolvedValue({ works: [] });
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
vi.mocked(woodenFishClient.listGallery).mockResolvedValue({
items: [],
hasMore: false,
nextCursor: null,
});
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({ items: [] });
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]);
vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]);
vi.mocked(jumpHopClient.listGallery).mockResolvedValue({
@@ -3825,9 +3846,9 @@ test('bark battle form checks mud points before creating image assets', async ()
).toBeTruthy();
expect(screen.getByText('汪汪声浪配置表单')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect((screen.getByLabelText('汪汪作品标题') as HTMLInputElement).value).toBe(
'自定义声浪杯',
);
expect(
(screen.getByLabelText('汪汪作品标题') as HTMLInputElement).value,
).toBe('自定义声浪杯');
expect(createBarkBattleDraft).not.toHaveBeenCalled();
expect(generateAllBarkBattleImageAssets).not.toHaveBeenCalled();
});
@@ -3975,6 +3996,106 @@ test('running match3d form generation can return to draft tab and reopen progres
});
});
test('background match3d draft failure notifies and reopens failed retry page', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-background-failed-session',
draft: null,
stage: 'collecting_config',
});
const persistedFailedWork: Match3DWorkSummary = {
workId: 'match3d-background-failed-work',
profileId: 'match3d-background-failed-profile',
ownerUserId: 'user-1',
sourceSessionId: runningSession.sessionId,
gameName: '失败中的抓鹅',
themeText: '泥塑水果摊',
summary: '正在生成玩法素材。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-18T12:05:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
generatedItemAssets: [],
};
let rejectCompile!: (reason?: unknown) => void;
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: runningSession,
});
vi.mocked(match3dCreationClient.executeAction).mockReturnValue(
new Promise((_, reject) => {
rejectCompile = reject;
}),
);
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: buildMockMatch3DAgentSession({
sessionId: runningSession.sessionId,
stage: 'collecting_config',
draft: null,
updatedAt: '2026-05-18T12:05:00.000Z',
}),
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [persistedFailedWork],
});
await act(async () => {
rejectCompile(new Error('抓大鹅素材服务失败'));
await Promise.resolve();
});
const failureDialog = await screen.findByRole('dialog', {
name: '发生错误',
});
expect(within(failureDialog).getByText(/抓大鹅素材服务失败/u)).toBeTruthy();
await user.click(within(failureDialog).getByRole('button', { name: '关闭' }));
const draftPanel = getPlatformTabPanel('saves');
const reopenButton = await within(draftPanel).findByRole('button', {
name: /继续创作《(?:失败中的抓鹅|抓大鹅草稿)》/u,
});
expect(within(draftPanel).getByText('赛博水果摊')).toBeTruthy();
await user.click(reopenButton);
expect(await screen.findByText(/生成失败/u)).toBeTruthy();
const reopenedFailureDialog = await screen.findByRole('dialog', {
name: '发生错误',
});
await user.click(
within(reopenedFailureDialog).getByRole('button', { name: '关闭' }),
);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '发生错误' })).toBeNull();
});
expect(
await screen.findByRole('button', { name: '重新生成草稿' }),
).toBeTruthy();
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(1);
});
test('running match3d persisted draft reopens progress instead of unfinished result', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
@@ -4065,9 +4186,6 @@ test('running match3d persisted draft reopens progress instead of unfinished res
}),
).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
expect(match3dCreationClient.getSession).toHaveBeenCalledWith(
'match3d-running-persisted-session',
);
});
test('persisted generating match3d draft opens generation progress after refresh', async () => {
@@ -4135,12 +4253,14 @@ test('persisted generating match3d draft opens generation progress after refresh
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
expect(
const restoredProgressValue = Number(
screen
.getByRole('progressbar', { name: '抓大鹅草稿生成进度' })
.getAttribute('aria-valuenow'),
).toBe('0');
expect(screen.getByText('0%')).toBeTruthy();
);
expect(restoredProgressValue).toBeGreaterThan(0);
expect(restoredProgressValue).toBeLessThan(100);
expect(screen.getByText(`${restoredProgressValue}%`)).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-profile-generating',
@@ -4432,9 +4552,7 @@ test('running puzzle form generation creates a new puzzle draft on same template
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(
await screen.findByRole('progressbar', {
name: '拼图图片生成进度',
@@ -4458,7 +4576,9 @@ test('running puzzle form generation creates a new puzzle draft on same template
expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false);
await user.click(secondGenerateButton);
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(2);
});
expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2);
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
1,
@@ -4467,7 +4587,7 @@ test('running puzzle form generation creates a new puzzle draft on same template
);
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
2,
'puzzle-session-1',
'puzzle-parallel-session-2',
expect.objectContaining({ action: 'compile_puzzle_draft' }),
);
@@ -4479,7 +4599,11 @@ test('running puzzle form generation creates a new puzzle draft on same template
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await waitFor(() => {
expect(screen.getAllByText('拼图草稿').length).toBeGreaterThanOrEqual(2);
expect(
within(getPlatformTabPanel('saves')).getAllByRole('button', {
name: /继续创作《[^》]+》,生成中/u,
}).length,
).toBeGreaterThanOrEqual(2);
});
await expectDraftHubGeneratingBadgeCountAtLeast(2);
@@ -4513,6 +4637,158 @@ test('running puzzle form generation creates a new puzzle draft on same template
});
});
test('failed parallel puzzle generations stay as separate non-generating drafts', async () => {
const user = userEvent.setup();
const firstSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-parallel-failed-session-1',
});
const secondSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-parallel-failed-session-2',
});
let rejectFirstCompile!: (reason?: unknown) => void;
let rejectSecondCompile!: (reason?: unknown) => void;
vi.mocked(createPuzzleAgentSession)
.mockResolvedValueOnce({
session: firstSession,
})
.mockResolvedValueOnce({
session: secondSession,
});
vi.mocked(executePuzzleAgentAction)
.mockReturnValueOnce(
new Promise((_, reject) => {
rejectFirstCompile = reject;
}),
)
.mockReturnValueOnce(
new Promise((_, reject) => {
rejectSecondCompile = reject;
}),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(
await screen.findByRole('progressbar', {
name: '拼图图片生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
expect(await screen.findByText('18泥点')).toBeTruthy();
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await waitFor(() => {
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(2);
});
expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2);
await clickFirstButtonByName(user, '返回');
expect(await screen.findByText('16泥点')).toBeTruthy();
await openDraftHub(user);
const draftPanel = getPlatformTabPanel('saves');
await waitFor(() => {
expect(
within(draftPanel).getAllByRole('button', {
name: /继续创作《[^》]+》,生成中/u,
}).length,
).toBeGreaterThanOrEqual(2);
});
await expectDraftHubGeneratingBadgeCountAtLeast(2);
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [
{
workId: `puzzle-work-${firstSession.sessionId}`,
profileId: `puzzle-profile-${firstSession.sessionId}`,
ownerUserId: 'user-1',
sourceSessionId: firstSession.sessionId,
authorDisplayName: '测试玩家',
workTitle: '',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '第1关',
summary: '一套雨夜猫街主题拼图。',
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-05-18T12:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
generationStatus: 'failed',
levels: [],
},
{
workId: `puzzle-work-${secondSession.sessionId}`,
profileId: `puzzle-profile-${secondSession.sessionId}`,
ownerUserId: 'user-1',
sourceSessionId: secondSession.sessionId,
authorDisplayName: '测试玩家',
workTitle: '',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '第1关',
summary: '一套雨夜猫街主题拼图。',
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-05-18T12:00:01.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
generationStatus: 'failed',
levels: [],
},
],
});
await act(async () => {
rejectFirstCompile(
new Error(
'拼图 VectorEngine 图片编辑失败创建图片编辑任务失败error sending request for url (https://api.vectorengine.cn/v1/images/edits)',
),
);
rejectSecondCompile(
new Error(
'拼图 VectorEngine 图片编辑失败创建图片编辑任务失败error sending request for url (https://api.vectorengine.cn/v1/images/edits)',
),
);
await Promise.resolve();
});
await waitFor(() => {
expect(
within(draftPanel).getAllByRole('button', {
name: /继续创作《[^》]+》/u,
}).length,
).toBeGreaterThanOrEqual(2);
expect(within(draftPanel).queryAllByLabelText('生成中')).toHaveLength(0);
});
expect(await screen.findByText('20泥点')).toBeTruthy();
expect(within(draftPanel).queryByText('第1关')).toBeNull();
expect(
within(draftPanel).getAllByText('拼图草稿生成失败,可重新打开处理。')
.length,
).toBeGreaterThanOrEqual(2);
expect(
within(draftPanel).getAllByText('一套雨夜猫街主题拼图。').length,
).toBeGreaterThanOrEqual(2);
const failureDialog = await screen.findByRole('dialog', {
name: '发生错误',
});
expect(within(failureDialog).getByText(/拼图 VectorEngine 图片编辑失败/u))
.toBeTruthy();
});
test('running puzzle draft opens generation progress from draft tab', async () => {
const user = userEvent.setup();
const runningSession = buildMockPuzzleAgentSession({
@@ -4548,9 +4824,7 @@ test('running puzzle draft opens generation progress from draft tab', async () =
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(
await screen.findByRole('progressbar', {
name: '拼图图片生成进度',
@@ -4562,7 +4836,7 @@ test('running puzzle draft opens generation progress from draft tab', async () =
await expectDraftHubGeneratingBadgeCountAtLeast(1);
await user.click(
screen.getByRole('button', { name: /继续创作《拼图草稿》/u }),
screen.getByRole('button', { name: /继续创作《[^》]+》,生成中/u }),
);
expect(
@@ -4602,9 +4876,7 @@ test('puzzle form checks mud points before creating a draft', async () => {
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
@@ -4894,7 +5166,9 @@ test('match3d result back returns to draft hub when opened from shelf', async ()
within(draftPanel).getByRole('tablist', { name: '作品筛选' }),
).toBeTruthy();
expect(within(draftPanel).getByText('自动试玩抓大鹅')).toBeTruthy();
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true');
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe(
'true',
);
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
});
@@ -5221,13 +5495,9 @@ test('completed match3d draft notice first opens trial then reopens result', asy
name: '生成完成',
});
expect(
within(completionDialog).getByText(
/抓大鹅草稿 match3d-notice-session-1/u,
),
).toBeTruthy();
expect(
within(completionDialog).getByText(/生成任务已完成/u),
within(completionDialog).getByText(/抓大鹅草稿 match3d-notice-session-1/u),
).toBeTruthy();
expect(within(completionDialog).getByText(/生成任务已完成/u)).toBeTruthy();
expect(
within(completionDialog).getByRole('button', { name: '复制内容' }),
).toBeTruthy();
@@ -5445,9 +5715,7 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await waitFor(() => {
expect(updatePuzzleWork).toHaveBeenCalledWith(
@@ -5534,9 +5802,7 @@ test('embedded puzzle form recovers when compile request times out after backend
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await waitFor(() => {
expect(getPuzzleAgentSession).toHaveBeenCalledWith(
@@ -6685,7 +6951,10 @@ test('home recommendation puzzle next level switches to similar work detail', as
nextLevelId: 'puzzle-level-2',
recommendedNextWorks: [],
};
const startedRun = buildMockPuzzleRun(entryWork.profileId, entryWork.levelName);
const startedRun = buildMockPuzzleRun(
entryWork.profileId,
entryWork.levelName,
);
const similarRun = {
...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName),
runId: clearedRun.runId,
@@ -6719,7 +6988,9 @@ test('home recommendation puzzle next level switches to similar work detail', as
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
run: clearedRunWithSameWorkNext,
});
let resolveAdvancePuzzleNextLevel!: (value: { run: PuzzleRunSnapshot }) => void;
let resolveAdvancePuzzleNextLevel!: (value: {
run: PuzzleRunSnapshot;
}) => void;
vi.mocked(advancePuzzleNextLevel).mockReturnValue(
new Promise((resolve) => {
resolveAdvancePuzzleNextLevel = resolve;
@@ -6753,10 +7024,9 @@ test('home recommendation puzzle next level switches to similar work detail', as
await user.click(within(dialog).getByRole('button', { name: '下一关' }));
await waitFor(() => {
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedRun.runId,
{ preferSimilarWork: true },
);
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(clearedRun.runId, {
preferSimilarWork: true,
});
});
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
expect(screen.queryByText('加载中...')).toBeNull();
@@ -7457,8 +7727,8 @@ test('embedded puzzle form maps raw bearer token errors to user-facing auth copy
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
expect(createCreativeAgentSession).not.toHaveBeenCalled();
expect(
await screen.findByText('当前登录状态已失效,请重新登录后继续。'),
).toBeTruthy();
await screen.findAllByText('当前登录状态已失效,请重新登录后继续。'),
).not.toHaveLength(0);
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
});
@@ -7572,7 +7842,9 @@ test('puzzle draft result back button returns to draft hub when opened from shel
within(draftPanel).getByRole('tablist', { name: '作品筛选' }),
).toBeTruthy();
expect(within(draftPanel).getByText('雨夜猫塔')).toBeTruthy();
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true');
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe(
'true',
);
expect(screen.queryByText('拼图工作区missing-session')).toBeNull();
expect(
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
@@ -7635,14 +7907,14 @@ test('persisted generating puzzle draft opens generation progress after refresh'
name: '拼图图片生成进度',
}),
).toBeTruthy();
expect(
Number(
screen
.getByRole('progressbar', { name: '拼图图片生成进度' })
.getAttribute('aria-valuenow'),
),
).toBe(0);
expect(screen.getByText('0%')).toBeTruthy();
const restoredProgressValue = Number(
screen
.getByRole('progressbar', { name: '拼图图片生成进度' })
.getAttribute('aria-valuenow'),
);
expect(restoredProgressValue).toBeGreaterThan(0);
expect(restoredProgressValue).toBeLessThan(100);
expect(screen.getByText(`${restoredProgressValue}%`)).toBeTruthy();
expect(screen.queryByText('拼图结果页')).toBeNull();
});
@@ -7736,7 +8008,9 @@ test('puzzle compile timeout shows failure dialog when reread session is still g
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
const dialog = await screen.findByRole('dialog', { name: '发生错误' });
expect(within(dialog).getByText('拼图草稿 puzzle-session-timeout')).toBeTruthy();
expect(
within(dialog).getByText('拼图草稿 puzzle-session-timeout'),
).toBeTruthy();
expect(
within(dialog).getByText(
'拼图共创操作超时,请确认运行时后端已启动后重试。',
@@ -9818,8 +10092,12 @@ test('agent draft result back button returns to draft hub without syncing result
await waitFor(() => {
expect(draftPanel.getAttribute('aria-hidden')).toBe('false');
});
expect(within(draftPanel).getByRole('tablist', { name: '作品筛选' })).toBeTruthy();
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true');
expect(
within(draftPanel).getByRole('tablist', { name: '作品筛选' }),
).toBeTruthy();
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe(
'true',
);
expect(
vi
@@ -10915,8 +11193,9 @@ test('creation hub published work card reveals delete action after card action r
publishedCard.focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '删除' }));
const deleteButtons = screen.getAllByRole('button', { name: '删除' });
expect(deleteButtons.length).toBeGreaterThan(0);
await user.click(deleteButtons[0]!);
const dialog = await screen.findByRole('dialog', { name: '删除作品' });
expect(dialog.parentElement?.className).toContain('platform-theme--light');

View File

@@ -7059,7 +7059,8 @@ export function RpgEntryHomeView({
<Settings className="h-5 w-5" />
</button>
</div>
) : isAuthenticated && activeTab === 'create' ? (
) : isAuthenticated &&
(activeTab === 'create' || activeTab === 'saves') ? (
<button
type="button"
onClick={openUserSurface}
@@ -7224,7 +7225,8 @@ export function RpgEntryHomeView({
</div>
<div className="flex items-center gap-3">
{isAuthenticated && activeTab === 'create' ? (
{isAuthenticated &&
(activeTab === 'create' || activeTab === 'saves') ? (
<button
type="button"
onClick={openUserSurface}

View File

@@ -131,7 +131,9 @@ test('match3d workspace can defer visible chrome to the unified creation page',
expect(workspace?.className).not.toContain('overflow-hidden');
expect(workspace?.className).not.toContain('platform-remap-surface');
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull();
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
const themeInput = screen.getByLabelText('想做一个什么题材的抓大鹅?');
expect(themeInput).toBeTruthy();
expect(themeInput.className).not.toContain('h-full');
});
test('match3d workspace omits legacy asset style fields from entry payload', () => {

View File

@@ -239,7 +239,7 @@ export function Match3DCreationWorkspace({
: 'min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] lg:grid-rows-1'
} ${isBusy ? 'opacity-55' : ''}`}
>
<label className="block min-h-0">
<label className={unifiedChrome ? 'block' : 'block min-h-0'}>
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
@@ -254,7 +254,11 @@ export function Match3DCreationWorkspace({
themeText: event.target.value,
}))
}
className="h-full min-h-[7.75rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)] sm:min-h-[9rem] lg:min-h-[14rem]"
className={`w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)] ${
unifiedChrome
? 'min-h-[11rem] sm:min-h-[12rem]'
: 'h-full min-h-[7.75rem] sm:min-h-[9rem] lg:min-h-[14rem]'
}`}
aria-label="想做一个什么题材的抓大鹅?"
/>
</label>

View File

@@ -202,6 +202,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '一只猫在雨夜灯牌下回头。',
workDescription: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
@@ -367,6 +368,7 @@ test('puzzle workspace selects a history image from the upload card', async () =
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留历史图里的主体,改成晴天花园。',
workDescription: '保留历史图里的主体,改成晴天花园。',
pictureDescription: '保留历史图里的主体,改成晴天花园。',
referenceImageSrc: null,
referenceImageSrcs: [],
@@ -425,6 +427,7 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
expect(onCreateFromForm).not.toHaveBeenCalled();
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'compile_puzzle_draft',
workDescription: '潮雾中的灯塔与断桥',
pictureDescription: '潮雾中的灯塔与断桥',
promptText: '潮雾中的灯塔与断桥',
referenceImageSrc: null,
@@ -528,6 +531,7 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
expect(onAutoSaveForm).toHaveBeenCalledWith({
seedText: '旧街灯牌下的猫和发光雨伞。',
workDescription: '旧街灯牌下的猫和发光雨伞。',
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
referenceImageSrc: null,
referenceImageSrcs: [],
@@ -576,6 +580,7 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: 'first-level.png',
workDescription: 'first-level.png',
pictureDescription: 'first-level.png',
referenceImageSrc: 'data:image/png;base64,uploaded-square',
referenceImageSrcs: [],
@@ -632,6 +637,7 @@ test('puzzle workspace submits history image when AI redraw is off', async () =>
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '历史素材 · image.png',
workDescription: '历史素材 · image.png',
pictureDescription: '历史素材 · image.png',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
referenceImageSrcs: [],
@@ -674,6 +680,7 @@ test('puzzle workspace submits uploaded reference image as data URL when AI redr
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
workDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
referenceImageSrc: 'data:image/png;base64,uploaded-square',
referenceImageSrcs: [],
@@ -764,6 +771,7 @@ test('puzzle workspace uploads prompt references as asset object ids', async ()
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '一只猫在雨夜灯牌下回头。',
workDescription: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [
@@ -852,6 +860,7 @@ test('puzzle workspace uploads prompt reference images from the description box'
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '一只猫在雨夜灯牌下回头。',
workDescription: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [

View File

@@ -321,6 +321,7 @@ export function PuzzleCreationWorkspace({
const autosavePayload = useMemo(
() => ({
seedText: pictureDescription,
workDescription: pictureDescription,
pictureDescription,
referenceImageSrc: mainReferenceImageSrcForPayload,
referenceImageSrcs: promptReferenceImageSrcsForPayload,
@@ -551,6 +552,7 @@ export function PuzzleCreationWorkspace({
: pictureDescription || formState.referenceImageLabel || '上传拼图图片';
const payload = {
seedText: payloadPictureDescription,
workDescription: payloadPictureDescription,
pictureDescription: payloadPictureDescription,
referenceImageSrc: mainReferenceImageSrcForPayload,
referenceImageSrcs: promptReferenceImageSrcsForPayload,
@@ -571,6 +573,7 @@ export function PuzzleCreationWorkspace({
onExecuteAction({
action: 'compile_puzzle_draft',
promptText: payloadPictureDescription,
workDescription: payloadPictureDescription,
pictureDescription: payloadPictureDescription,
referenceImageSrc: mainReferenceImageSrcForPayload,
referenceImageSrcs: promptReferenceImageSrcsForPayload,