Files
Genarrative/src/components/platform-entry/platformPublicGalleryFlow.test.ts

573 lines
16 KiB
TypeScript

import { expect, test } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
import {
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
import {
getPlatformPublicGalleryEntryKey,
getPlatformPublicGalleryEntryTime,
getPlatformRecommendRuntimeKind,
isPlatformRecommendRuntimeReadyForEntry,
isSamePlatformPublicGalleryEntry,
mergePlatformPublicGalleryEntries,
type PlatformRecommendRuntimeStartIntentDeps,
type RecommendRuntimeKind,
resolvePlatformRecommendRuntimeStartIntent,
} from './platformPublicGalleryFlow';
import {
mapBarkBattlePublicDetailToWorkSummary,
mapPublicWorkDetailToBigFishWork,
mapPublicWorkDetailToPuzzleWork,
mapPublicWorkDetailToSquareHoleWork,
} from './platformPublicWorkDetailFlow';
type TypedPlatformPublicGalleryCard = Extract<
PlatformPublicGalleryCard,
{ sourceType: string }
>;
type PlatformGallerySourceType = TypedPlatformPublicGalleryCard['sourceType'];
type TypedPlatformPublicGalleryCardOverrides = Partial<
Omit<TypedPlatformPublicGalleryCard, 'sourceType'>
>;
function buildRpgEntry(
overrides: Partial<CustomWorldGalleryCard> = {},
): CustomWorldGalleryCard {
return {
ownerUserId: 'user-1',
profileId: 'rpg-profile',
publicWorkCode: 'CW-RPG',
authorPublicUserCode: null,
visibility: 'published',
publishedAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-01T01:00:00.000Z',
authorDisplayName: '玩家',
worldName: 'RPG 世界',
subtitle: '公开作品',
summaryText: '公开作品摘要',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 1,
landmarkCount: 1,
...overrides,
};
}
function buildTypedEntry(
sourceType: PlatformGallerySourceType,
overrides: TypedPlatformPublicGalleryCardOverrides = {},
): PlatformPublicGalleryCard {
const common = {
workId: `${sourceType}-work`,
profileId: `${sourceType}-profile`,
publicWorkCode: `${sourceType}-code`,
ownerUserId: 'user-1',
authorDisplayName: '玩家',
worldName: `${sourceType} 作品`,
subtitle: '公开作品',
summaryText: '公开作品摘要',
coverImageSrc: null,
themeTags: [sourceType],
visibility: 'published' as const,
publishedAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-01T01:00:00.000Z',
};
switch (sourceType) {
case 'puzzle':
return { ...common, ...overrides, sourceType };
case 'big-fish':
return { ...common, ...overrides, sourceType };
case 'match3d':
return { ...common, ...overrides, sourceType };
case 'square-hole':
return { ...common, ...overrides, sourceType };
case 'visual-novel':
return { ...common, ...overrides, sourceType };
case 'jump-hop':
return { ...common, ...overrides, sourceType };
case 'wooden-fish':
return { ...common, ...overrides, sourceType };
case 'edutainment':
return {
...common,
...overrides,
sourceType,
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
};
case 'bark-battle':
return {
...common,
...overrides,
sourceType,
authorPublicUserCode: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
themeMode: 'martial',
playableNpcCount: 1,
landmarkCount: 1,
};
default: {
const exhaustive: never = sourceType;
return exhaustive;
}
}
}
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work',
profileId: 'puzzle-profile',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session',
authorDisplayName: '玩家',
levelName: '拼图作品',
summary: '拼图摘要',
themeTags: ['拼图'],
coverImageSrc: '/puzzle-cover.png',
publicationStatus: 'published',
updatedAt: '2026-06-01T01:00:00.000Z',
publishedAt: '2026-06-01T00:00:00.000Z',
playCount: 3,
remixCount: 2,
likeCount: 1,
pointIncentiveTotalHalfPoints: 0,
pointIncentiveClaimedPoints: 0,
pointIncentiveTotalPoints: 0,
pointIncentiveClaimablePoints: 0,
publishReady: true,
...overrides,
};
}
function buildMatch3DWork(
overrides: Partial<Match3DWorkSummary> = {},
): Match3DWorkSummary {
return {
workId: 'match3d-work',
profileId: 'match3d-profile',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session',
gameName: '抓大鹅作品',
themeText: '经典消除',
summary: '抓大鹅摘要',
tags: ['抓大鹅'],
coverImageSrc: '/match3d-cover.png',
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'published',
playCount: 10,
updatedAt: '2026-06-01T01:00:00.000Z',
publishedAt: '2026-06-01T00:00:00.000Z',
publishReady: true,
generatedItemAssets: [],
...overrides,
};
}
function buildBarkBattleWork(
overrides: Partial<BarkBattleWorkSummary> = {},
): BarkBattleWorkSummary {
return {
workId: 'bark-battle-work',
draftId: 'bark-battle-draft',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '汪汪声浪作品',
summary: '汪汪摘要',
themeDescription: '森林擂台',
playerImageDescription: '小狗',
opponentImageDescription: '对手',
playerCharacterImageSrc: '/player.png',
opponentCharacterImageSrc: '/opponent.png',
uiBackgroundImageSrc: '/bark-bg.png',
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 9,
recentPlayCount7d: 2,
updatedAt: '2026-06-01T01:00:00.000Z',
publishedAt: '2026-06-01T00:00:00.000Z',
...overrides,
};
}
function buildRecommendRuntimeStartDeps(
overrides: Partial<PlatformRecommendRuntimeStartIntentDeps> = {},
): PlatformRecommendRuntimeStartIntentDeps {
return {
selectedPuzzleDetail: null,
barkBattleGalleryEntries: [],
mapMatch3DWork: () => buildMatch3DWork(),
...overrides,
};
}
test('platform public gallery flow resolves stable key and runtime kind for every play kind', () => {
const cases: Array<
[sourceType: PlatformGallerySourceType, keyKind: string, kind: RecommendRuntimeKind]
> = [
['big-fish', 'big-fish', 'big-fish'],
['puzzle', 'puzzle', 'puzzle'],
['jump-hop', 'jump-hop', 'jump-hop'],
['wooden-fish', 'wooden-fish', 'wooden-fish'],
['match3d', 'match3d', 'match3d'],
['square-hole', 'square-hole', 'square-hole'],
['visual-novel', 'visual-novel', 'visual-novel'],
['bark-battle', 'bark-battle', 'bark-battle'],
[
'edutainment',
`edutainment:${EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID}`,
'edutainment',
],
];
cases.forEach(([sourceType, keyKind, kind]) => {
const entry = buildTypedEntry(sourceType);
expect(getPlatformPublicGalleryEntryKey(entry)).toBe(
`${keyKind}:user-1:${sourceType}-profile`,
);
expect(getPlatformRecommendRuntimeKind(entry)).toBe(kind);
});
const rpgEntry = buildRpgEntry();
expect(getPlatformPublicGalleryEntryKey(rpgEntry)).toBe(
'rpg:user-1:rpg-profile',
);
expect(getPlatformRecommendRuntimeKind(rpgEntry)).toBe('rpg');
});
test('platform public gallery flow compares entries by resolved identity', () => {
const left = buildTypedEntry('puzzle');
const sameIdentity = buildTypedEntry('puzzle', {
workId: 'other-work',
worldName: '新标题',
});
const otherKind = buildTypedEntry('match3d', {
ownerUserId: left.ownerUserId,
profileId: left.profileId,
});
expect(isSamePlatformPublicGalleryEntry(left, sameIdentity)).toBe(true);
expect(isSamePlatformPublicGalleryEntry(left, otherKind)).toBe(false);
});
test('platform public gallery flow resolves recommend runtime start intent', () => {
const bigFishEntry = buildTypedEntry('big-fish');
expect(
resolvePlatformRecommendRuntimeStartIntent(
bigFishEntry,
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-big-fish',
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
returnStage: 'platform',
embedded: true,
});
const selectedPuzzleDetail = buildPuzzleWork({
profileId: 'puzzle-profile',
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('puzzle'),
buildRecommendRuntimeStartDeps({ selectedPuzzleDetail }),
),
).toEqual({
type: 'start-puzzle',
work: selectedPuzzleDetail,
returnStage: 'platform',
embedded: true,
});
const puzzleEntry = buildTypedEntry('puzzle', {
profileId: 'fallback-puzzle-profile',
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
puzzleEntry,
buildRecommendRuntimeStartDeps({
selectedPuzzleDetail: buildPuzzleWork({ profileId: 'stale-profile' }),
}),
),
).toEqual({
type: 'start-puzzle',
work: mapPublicWorkDetailToPuzzleWork(puzzleEntry),
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('jump-hop'),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-jump-hop',
profileId: 'jump-hop-profile',
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('wooden-fish'),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-wooden-fish',
profileId: 'wooden-fish-profile',
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('visual-novel'),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-visual-novel',
profileId: 'visual-novel-profile',
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('edutainment'),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-edutainment',
entry: buildTypedEntry('edutainment'),
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildRpgEntry(),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'mark-ready',
});
});
test('platform public gallery flow resolves recommend runtime mapper-backed start intent', () => {
const match3DEntry = buildTypedEntry('match3d');
const match3DWork = buildMatch3DWork({ workId: 'mapped-match3d-work' });
expect(
resolvePlatformRecommendRuntimeStartIntent(
match3DEntry,
buildRecommendRuntimeStartDeps({
mapMatch3DWork: (entry) =>
entry === match3DEntry ? match3DWork : null,
}),
),
).toEqual({
type: 'start-match3d',
work: match3DWork,
returnStage: 'work-detail',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
match3DEntry,
buildRecommendRuntimeStartDeps({ mapMatch3DWork: () => null }),
),
).toEqual({
type: 'blocked',
errorTarget: 'match3d',
errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。',
});
const squareHoleEntry = buildTypedEntry('square-hole');
expect(
resolvePlatformRecommendRuntimeStartIntent(
squareHoleEntry,
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-square-hole',
work: mapPublicWorkDetailToSquareHoleWork(squareHoleEntry),
returnStage: 'platform',
embedded: true,
});
});
test('platform public gallery flow resolves recommend runtime bark battle priority', () => {
const entry = buildTypedEntry('bark-battle');
const galleryWork = buildBarkBattleWork({
workId: 'bark-battle-work',
title: '推荐缓存',
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
entry,
buildRecommendRuntimeStartDeps({
barkBattleGalleryEntries: [galleryWork],
}),
),
).toEqual({
type: 'start-bark-battle',
work: galleryWork,
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
entry,
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-bark-battle',
work: mapBarkBattlePublicDetailToWorkSummary(entry),
returnStage: 'platform',
embedded: true,
});
});
test('platform public gallery flow resolves recommend runtime readiness', () => {
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('big-fish'), {
activeKind: 'puzzle',
hasBigFishRun: true,
}),
).toBe(false);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('big-fish'), {
activeKind: 'big-fish',
hasBigFishRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('jump-hop'), {
activeKind: 'jump-hop',
hasJumpHopRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('wooden-fish'), {
activeKind: 'wooden-fish',
hasWoodenFishRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('match3d'), {
activeKind: 'match3d',
hasMatch3DRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('square-hole'), {
activeKind: 'square-hole',
hasSquareHoleRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('visual-novel'), {
activeKind: 'visual-novel',
hasVisualNovelRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('bark-battle'), {
activeKind: 'bark-battle',
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildRpgEntry(), {
activeKind: 'rpg',
}),
).toBe(true);
});
test('platform public gallery flow resolves puzzle and edutainment readiness details', () => {
const puzzleEntry = buildTypedEntry('puzzle', {
profileId: 'puzzle-profile',
});
expect(
isPlatformRecommendRuntimeReadyForEntry(puzzleEntry, {
activeKind: 'puzzle',
puzzleRunEntryProfileId: 'other-profile',
puzzleRunCurrentLevelProfileId: 'puzzle-profile',
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(puzzleEntry, {
activeKind: 'puzzle',
puzzleRunEntryProfileId: 'other-profile',
puzzleRunCurrentLevelProfileId: 'another-profile',
}),
).toBe(false);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('edutainment'), {
activeKind: 'edutainment',
hasBabyObjectMatchDraft: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('edutainment'), {
activeKind: 'edutainment',
hasBabyObjectMatchDraft: false,
}),
).toBe(false);
});
test('platform public gallery flow merges duplicate identities and sorts newest first', () => {
const staleRpgEntry = buildRpgEntry({
profileId: 'shared-rpg',
worldName: '旧版 RPG',
publishedAt: '2026-06-01T00:00:00.000Z',
});
const freshRpgEntry = buildRpgEntry({
profileId: 'shared-rpg',
worldName: '新版 RPG',
publishedAt: '2026-06-04T00:00:00.000Z',
});
const middleRpgEntry = buildRpgEntry({
profileId: 'middle-rpg',
worldName: '中间 RPG',
publishedAt: '2026-06-02T00:00:00.000Z',
});
const updatedOnlyEntry = buildTypedEntry('big-fish', {
profileId: 'updated-only',
publishedAt: null,
updatedAt: '2026-06-03T00:00:00.000Z',
});
const invalidTimeEntry = buildTypedEntry('puzzle', {
profileId: 'invalid-time',
publishedAt: 'not-a-date',
updatedAt: 'still-not-a-date',
});
const merged = mergePlatformPublicGalleryEntries(
[staleRpgEntry, middleRpgEntry],
[invalidTimeEntry, updatedOnlyEntry, freshRpgEntry],
);
expect(merged).toHaveLength(4);
expect(merged.map((entry) => entry.profileId)).toEqual([
'shared-rpg',
'updated-only',
'middle-rpg',
'invalid-time',
]);
expect(merged[0]?.worldName).toBe('新版 RPG');
expect(getPlatformPublicGalleryEntryTime(invalidTimeEntry)).toBe(0);
});