fix: 收紧拼图草稿恢复判定
This commit is contained in:
@@ -571,6 +571,10 @@ import {
|
||||
resolvePlatformPublicWorkStartIntent,
|
||||
resolveVisiblePuzzleDetailCoverCount,
|
||||
} from './platformPublicWorkDetailFlow';
|
||||
import {
|
||||
hasRecoverableGeneratedPuzzleDraft,
|
||||
normalizeRecoveredPuzzleDraftSession,
|
||||
} from './platformPuzzleDraftRecoveryModel';
|
||||
import {
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
@@ -1045,77 +1049,6 @@ function openPuzzleRuntimeStage(
|
||||
writePuzzleRuntimeUrlState(state);
|
||||
}
|
||||
|
||||
function normalizeRecoveredPuzzleDraftSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const primaryLevel = draft.levels?.[0];
|
||||
const selectedCandidate =
|
||||
primaryLevel?.candidates.find((candidate) => candidate.selected) ??
|
||||
primaryLevel?.candidates[0] ??
|
||||
draft.candidates.find((candidate) => candidate.selected) ??
|
||||
draft.candidates[0] ??
|
||||
null;
|
||||
const coverImageSrc =
|
||||
draft.coverImageSrc?.trim() ||
|
||||
primaryLevel?.coverImageSrc?.trim() ||
|
||||
selectedCandidate?.imageSrc.trim() ||
|
||||
null;
|
||||
const coverAssetId =
|
||||
draft.coverAssetId?.trim() ||
|
||||
primaryLevel?.coverAssetId?.trim() ||
|
||||
selectedCandidate?.assetId.trim() ||
|
||||
null;
|
||||
const selectedCandidateId =
|
||||
draft.selectedCandidateId ??
|
||||
primaryLevel?.selectedCandidateId ??
|
||||
selectedCandidate?.candidateId ??
|
||||
null;
|
||||
|
||||
return {
|
||||
...session,
|
||||
draft: {
|
||||
...draft,
|
||||
coverImageSrc,
|
||||
coverAssetId,
|
||||
selectedCandidateId,
|
||||
generationStatus: 'ready',
|
||||
levels: draft.levels?.map((level, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...level,
|
||||
coverImageSrc: level.coverImageSrc ?? coverImageSrc,
|
||||
coverAssetId: level.coverAssetId ?? coverAssetId,
|
||||
selectedCandidateId:
|
||||
level.selectedCandidateId ?? selectedCandidateId,
|
||||
generationStatus: 'ready',
|
||||
}
|
||||
: level,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hasRecoverableGeneratedPuzzleDraft(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstLevel = draft.levels?.[0];
|
||||
return Boolean(
|
||||
draft.coverImageSrc?.trim() ||
|
||||
firstLevel?.coverImageSrc?.trim() ||
|
||||
firstLevel?.candidates.some((candidate) => candidate.imageSrc.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveProfileWalletBalance(
|
||||
dashboard: { walletBalance?: number | null } | null | undefined,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
PuzzleAnchorPack,
|
||||
PuzzleDraftLevel,
|
||||
PuzzleGeneratedImageCandidate,
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import {
|
||||
hasRecoverableGeneratedPuzzleDraft,
|
||||
normalizeRecoveredPuzzleDraftSession,
|
||||
} from './platformPuzzleDraftRecoveryModel';
|
||||
|
||||
function buildAnchorPack(): PuzzleAnchorPack {
|
||||
const item = {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed' as const,
|
||||
};
|
||||
return {
|
||||
themePromise: item,
|
||||
visualSubject: item,
|
||||
visualMood: item,
|
||||
compositionHooks: item,
|
||||
tagsAndForbidden: item,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCandidate(
|
||||
overrides: Partial<PuzzleGeneratedImageCandidate> = {},
|
||||
): PuzzleGeneratedImageCandidate {
|
||||
return {
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/candidate-cover.png',
|
||||
assetId: 'asset-candidate-cover',
|
||||
prompt: '星桥机关',
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLevel(overrides: Partial<PuzzleDraftLevel> = {}): PuzzleDraftLevel {
|
||||
return {
|
||||
levelId: 'level-1',
|
||||
levelName: '星桥机关',
|
||||
pictureDescription: '星桥机关画面',
|
||||
candidates: [buildCandidate()],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDraft(overrides: Partial<PuzzleResultDraft> = {}): PuzzleResultDraft {
|
||||
const anchorPack = buildAnchorPack();
|
||||
return {
|
||||
workTitle: '星桥拼图',
|
||||
workDescription: '修复星桥机关。',
|
||||
levelName: '星桥机关',
|
||||
summary: '把碎片拼回原位。',
|
||||
themeTags: ['星桥', '机关', '修复'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating',
|
||||
levels: [buildLevel()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const anchorPack = buildAnchorPack();
|
||||
return {
|
||||
sessionId: 'puzzle-session-1',
|
||||
seedText: '星桥',
|
||||
currentTurn: 1,
|
||||
progressPercent: 100,
|
||||
stage: 'draft_ready',
|
||||
anchorPack,
|
||||
draft: buildDraft(),
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-06-01T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function withCompleteLevelAssets(
|
||||
overrides: Partial<PuzzleDraftLevel> = {},
|
||||
): PuzzleDraftLevel {
|
||||
return buildLevel({
|
||||
levelSceneImageSrc: '/level-scene.png',
|
||||
uiSpritesheetImageSrc: '/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc: '/level-background.png',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('platformPuzzleDraftRecoveryModel', () => {
|
||||
test('normalizes and marks recovered puzzle draft ready when asset pack is complete', () => {
|
||||
const normalized = normalizeRecoveredPuzzleDraftSession(
|
||||
buildSession({
|
||||
draft: buildDraft({
|
||||
levels: [withCompleteLevelAssets()],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hasRecoverableGeneratedPuzzleDraft(normalized)).toBe(true);
|
||||
expect(normalized.draft).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
coverAssetId: 'asset-candidate-cover',
|
||||
selectedCandidateId: 'candidate-1',
|
||||
generationStatus: 'ready',
|
||||
});
|
||||
expect(normalized.draft?.levels?.[0]).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
coverAssetId: 'asset-candidate-cover',
|
||||
selectedCandidateId: 'candidate-1',
|
||||
generationStatus: 'ready',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps half-finished draft generating when only cover candidate exists', () => {
|
||||
const normalized = normalizeRecoveredPuzzleDraftSession(buildSession());
|
||||
|
||||
expect(hasRecoverableGeneratedPuzzleDraft(normalized)).toBe(false);
|
||||
expect(normalized.draft).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
generationStatus: 'generating',
|
||||
});
|
||||
expect(normalized.draft?.levels?.[0]).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
generationStatus: 'generating',
|
||||
});
|
||||
});
|
||||
|
||||
test('requires level scene, ui spritesheet and level background assets together', () => {
|
||||
expect(
|
||||
hasRecoverableGeneratedPuzzleDraft(
|
||||
buildSession({
|
||||
draft: buildDraft({
|
||||
coverImageSrc: '/draft-cover.png',
|
||||
levels: [
|
||||
withCompleteLevelAssets({
|
||||
uiSpritesheetImageSrc: null,
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('accepts object keys as recovered asset references', () => {
|
||||
expect(
|
||||
hasRecoverableGeneratedPuzzleDraft(
|
||||
buildSession({
|
||||
draft: buildDraft({
|
||||
coverImageSrc: '/draft-cover.png',
|
||||
levels: [
|
||||
buildLevel({
|
||||
levelSceneImageObjectKey: 'level-scene.png',
|
||||
uiSpritesheetImageObjectKey: 'ui-spritesheet.png',
|
||||
levelBackgroundImageObjectKey: 'level-background.png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('leaves sessions without draft unchanged and unrecoverable', () => {
|
||||
const session = buildSession({ draft: null });
|
||||
|
||||
expect(normalizeRecoveredPuzzleDraftSession(session)).toBe(session);
|
||||
expect(hasRecoverableGeneratedPuzzleDraft(session)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
|
||||
function normalizeRecoveryText(value: string | null | undefined) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function hasPuzzleAssetReference(
|
||||
imageSrc: string | null | undefined,
|
||||
objectKey: string | null | undefined,
|
||||
) {
|
||||
return Boolean(normalizeRecoveryText(imageSrc) || normalizeRecoveryText(objectKey));
|
||||
}
|
||||
|
||||
function resolvePrimaryPuzzleLevel(session: PuzzleAgentSessionSnapshot) {
|
||||
return session.draft?.levels?.[0] ?? null;
|
||||
}
|
||||
|
||||
function resolvePuzzleRecoveryCandidate(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
primaryLevel: PuzzleDraftLevel | null,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
primaryLevel?.candidates.find((candidate) => candidate.selected) ??
|
||||
primaryLevel?.candidates[0] ??
|
||||
draft.candidates.find((candidate) => candidate.selected) ??
|
||||
draft.candidates[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePuzzleRecoveryCoverFields(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
const primaryLevel = resolvePrimaryPuzzleLevel(session);
|
||||
const selectedCandidate = resolvePuzzleRecoveryCandidate(
|
||||
session,
|
||||
primaryLevel,
|
||||
);
|
||||
|
||||
return {
|
||||
coverImageSrc:
|
||||
normalizeRecoveryText(draft?.coverImageSrc) ??
|
||||
normalizeRecoveryText(primaryLevel?.coverImageSrc) ??
|
||||
normalizeRecoveryText(selectedCandidate?.imageSrc),
|
||||
coverAssetId:
|
||||
normalizeRecoveryText(draft?.coverAssetId) ??
|
||||
normalizeRecoveryText(primaryLevel?.coverAssetId) ??
|
||||
normalizeRecoveryText(selectedCandidate?.assetId),
|
||||
selectedCandidateId:
|
||||
draft?.selectedCandidateId ??
|
||||
primaryLevel?.selectedCandidateId ??
|
||||
selectedCandidate?.candidateId ??
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasCompleteGeneratedPuzzleLevelAssets(
|
||||
level: PuzzleDraftLevel | null,
|
||||
coverImageSrc: string | null,
|
||||
) {
|
||||
return Boolean(
|
||||
normalizeRecoveryText(coverImageSrc) &&
|
||||
hasPuzzleAssetReference(
|
||||
level?.levelSceneImageSrc,
|
||||
level?.levelSceneImageObjectKey,
|
||||
) &&
|
||||
hasPuzzleAssetReference(
|
||||
level?.uiSpritesheetImageSrc,
|
||||
level?.uiSpritesheetImageObjectKey,
|
||||
) &&
|
||||
hasPuzzleAssetReference(
|
||||
level?.levelBackgroundImageSrc,
|
||||
level?.levelBackgroundImageObjectKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasRecoverableGeneratedPuzzleDraft(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const primaryLevel = resolvePrimaryPuzzleLevel(session);
|
||||
const { coverImageSrc } = resolvePuzzleRecoveryCoverFields(session);
|
||||
return hasCompleteGeneratedPuzzleLevelAssets(primaryLevel, coverImageSrc);
|
||||
}
|
||||
|
||||
export function normalizeRecoveredPuzzleDraftSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const { coverImageSrc, coverAssetId, selectedCandidateId } =
|
||||
resolvePuzzleRecoveryCoverFields(session);
|
||||
const nextLevels = draft.levels?.map((level, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...level,
|
||||
coverImageSrc: normalizeRecoveryText(level.coverImageSrc)
|
||||
? level.coverImageSrc
|
||||
: coverImageSrc,
|
||||
coverAssetId: normalizeRecoveryText(level.coverAssetId)
|
||||
? level.coverAssetId
|
||||
: coverAssetId,
|
||||
selectedCandidateId:
|
||||
level.selectedCandidateId ?? selectedCandidateId,
|
||||
}
|
||||
: level,
|
||||
);
|
||||
const nextSession = {
|
||||
...session,
|
||||
draft: {
|
||||
...draft,
|
||||
coverImageSrc,
|
||||
coverAssetId,
|
||||
selectedCandidateId,
|
||||
levels: nextLevels,
|
||||
},
|
||||
} satisfies PuzzleAgentSessionSnapshot;
|
||||
const isRecoverable = hasRecoverableGeneratedPuzzleDraft(nextSession);
|
||||
|
||||
if (!isRecoverable) {
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextSession,
|
||||
draft: {
|
||||
...nextSession.draft,
|
||||
generationStatus: 'ready',
|
||||
levels: nextSession.draft.levels?.map((level, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...level,
|
||||
generationStatus: 'ready',
|
||||
}
|
||||
: level,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user