1163 lines
37 KiB
TypeScript
1163 lines
37 KiB
TypeScript
import { createElement } from 'react';
|
||
import { renderToStaticMarkup } from 'react-dom/server';
|
||
import { expect, test, vi } from 'vitest';
|
||
|
||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||
import {
|
||
buildCreationWorkShelfItems,
|
||
getCreationWorkShelfItemTime,
|
||
hasBarkBattleRequiredImages,
|
||
isPersistedBarkBattleDraftGenerating,
|
||
type CreationWorkShelfItem,
|
||
} from './creationWorkShelf';
|
||
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
|
||
|
||
test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [],
|
||
visualNovelItems: [
|
||
{
|
||
runtimeKind: 'visual-novel',
|
||
profileId: 'vn-profile-demo-12345678',
|
||
ownerUserId: 'user-1',
|
||
title: '雨夜终章',
|
||
description: '失踪列车上的选择。',
|
||
coverImageSrc: '/vn-cover.png',
|
||
tags: ['悬疑', '列车'],
|
||
publishStatus: 'published',
|
||
publishReady: true,
|
||
playCount: 12,
|
||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||
publishedAt: '2026-05-07T00:00:00.000Z',
|
||
},
|
||
{
|
||
runtimeKind: 'visual-novel',
|
||
profileId: 'vn-profile-draft-00000001',
|
||
ownerUserId: 'user-1',
|
||
title: '',
|
||
description: '',
|
||
coverImageSrc: null,
|
||
tags: [],
|
||
publishStatus: 'draft',
|
||
publishReady: false,
|
||
playCount: 0,
|
||
updatedAt: '2026-05-06T00:00:00.000Z',
|
||
publishedAt: null,
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(items[0]?.kind).toBe('visual-novel');
|
||
expect(items[0]?.publicWorkCode).toBe('VN-12345678');
|
||
expect(items[0]?.sharePath).toContain('/works/detail?work=VN-12345678');
|
||
expect(items[1]?.status).toBe('draft');
|
||
expect(items[1]?.publicWorkCode).toBeNull();
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems maps wooden fish items with WF public code', () => {
|
||
const onOpenWoodenFishDetail = vi.fn();
|
||
const woodenFishWork = {
|
||
runtimeKind: 'wooden-fish' as const,
|
||
workId: 'wooden-fish-work-1',
|
||
profileId: 'wooden-fish-profile-12345678',
|
||
ownerUserId: 'user-1',
|
||
sourceSessionId: 'wooden-fish-session-1',
|
||
workTitle: '苹果敲木鱼',
|
||
workDescription: '苹果主题木鱼。',
|
||
themeTags: ['苹果', '休闲'],
|
||
coverImageSrc: '/wooden-fish/apple-cover.png',
|
||
publicationStatus: 'published',
|
||
playCount: 9,
|
||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||
publishReady: true,
|
||
generationStatus: 'ready' as const,
|
||
};
|
||
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [],
|
||
woodenFishItems: [woodenFishWork],
|
||
onOpenWoodenFishDetail,
|
||
});
|
||
|
||
items[0]?.actions.open();
|
||
|
||
expect(items).toHaveLength(1);
|
||
expect(items[0]?.kind).toBe('wooden-fish');
|
||
expect(items[0]?.status).toBe('published');
|
||
expect(items[0]?.publicWorkCode).toBe('WF-12345678');
|
||
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(onOpenWoodenFishDetail).toHaveBeenCalledWith(woodenFishWork);
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems keeps published bark battle over duplicate draft', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [],
|
||
barkBattleItems: [
|
||
{
|
||
workId: 'bark-battle-work-1',
|
||
draftId: 'bark-battle-draft-1',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '草稿作者',
|
||
title: '汪汪测试杯',
|
||
summary: '',
|
||
themeDescription: '阳光草坪声浪竞技场',
|
||
playerImageDescription: '戴红色围巾的柯基选手',
|
||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||
difficultyPreset: 'normal',
|
||
status: 'draft',
|
||
generationStatus: 'ready',
|
||
publishReady: true,
|
||
playCount: 0,
|
||
updatedAt: '2026-05-14T10:01:00.000Z',
|
||
publishedAt: null,
|
||
},
|
||
{
|
||
workId: 'bark-battle-work-1',
|
||
draftId: 'bark-battle-draft-1',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '测试玩家',
|
||
title: '汪汪测试杯',
|
||
summary: '',
|
||
themeDescription: '阳光草坪声浪竞技场',
|
||
playerImageDescription: '戴红色围巾的柯基选手',
|
||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||
difficultyPreset: 'normal',
|
||
status: 'published',
|
||
generationStatus: 'ready',
|
||
publishReady: true,
|
||
playCount: 0,
|
||
updatedAt: '2026-05-14T10:02:00.000Z',
|
||
publishedAt: '2026-05-14T10:02:00.000Z',
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(items).toHaveLength(1);
|
||
expect(items[0]?.kind).toBe('bark-battle');
|
||
expect(items[0]?.status).toBe('published');
|
||
expect(items[0]?.publicWorkCode).toBe('BB-TLEWORK1');
|
||
expect(items[0]?.authorDisplayName).toBe('测试玩家');
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems keeps separate bark battle draft and published works visible', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [],
|
||
barkBattleItems: [
|
||
{
|
||
workId: 'BB-DRAFT001',
|
||
draftId: 'bark-battle-draft-visible',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '草稿作者',
|
||
title: '草稿声浪赛',
|
||
summary: '',
|
||
themeDescription: '草地声浪挑战',
|
||
playerImageDescription: '柯基选手',
|
||
opponentImageDescription: '哈士奇对手',
|
||
playerCharacterImageSrc: '/draft-player.png',
|
||
opponentCharacterImageSrc: '/draft-opponent.png',
|
||
uiBackgroundImageSrc: '/draft-background.png',
|
||
difficultyPreset: 'easy',
|
||
status: 'draft',
|
||
generationStatus: 'ready',
|
||
publishReady: false,
|
||
playCount: 0,
|
||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||
publishedAt: null,
|
||
},
|
||
{
|
||
workId: 'BB-PUB00001',
|
||
draftId: 'bark-battle-draft-published',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '发布作者',
|
||
title: '已发布声浪赛',
|
||
summary: '',
|
||
themeDescription: '霓虹声浪挑战',
|
||
playerImageDescription: '柴犬选手',
|
||
opponentImageDescription: '机器人对手',
|
||
playerCharacterImageSrc: '/published-player.png',
|
||
opponentCharacterImageSrc: '/published-opponent.png',
|
||
uiBackgroundImageSrc: '/published-background.png',
|
||
difficultyPreset: 'normal',
|
||
status: 'published',
|
||
generationStatus: 'ready',
|
||
publishReady: true,
|
||
playCount: 3,
|
||
updatedAt: '2026-05-21T00:00:00.000Z',
|
||
publishedAt: '2026-05-21T00:00:00.000Z',
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(items).toHaveLength(2);
|
||
expect(items.find((item) => item.status === 'draft')?.id).toBe('BB-DRAFT001');
|
||
expect(items.find((item) => item.status === 'published')?.id).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', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [
|
||
{
|
||
workId: 'rpg-work-published',
|
||
sourceType: 'published_profile',
|
||
status: 'published',
|
||
title: '潮雾列岛已发布版',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '已经发布的群岛世界作品。',
|
||
coverImageSrc: null,
|
||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||
stage: 'published',
|
||
stageLabel: '已发布',
|
||
playableNpcCount: 3,
|
||
landmarkCount: 4,
|
||
sessionId: null,
|
||
profileId: 'world-public-1',
|
||
canResume: false,
|
||
canEnterWorld: true,
|
||
},
|
||
],
|
||
bigFishItems: [],
|
||
puzzleItems: [],
|
||
});
|
||
|
||
expect(items).toHaveLength(1);
|
||
expect(items[0]?.publicWorkCode).toBe('CW-00000001');
|
||
expect(items[0]?.sharePath).toContain('/works/detail?work=CW-00000001');
|
||
expect(items[0]?.canShare).toBe(true);
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems gives bark battle draft cover from character or reference fallback', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [],
|
||
barkBattleItems: [
|
||
{
|
||
workId: 'BB-COVER001',
|
||
draftId: 'bark-battle-draft-cover',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '草稿作者',
|
||
title: '角色封面声浪赛',
|
||
summary: '',
|
||
themeDescription: '草地声浪挑战',
|
||
playerImageDescription: '柯基选手',
|
||
opponentImageDescription: '哈士奇对手',
|
||
playerCharacterImageSrc: '/draft-player-cover.png',
|
||
opponentCharacterImageSrc: '/draft-opponent-cover.png',
|
||
uiBackgroundImageSrc: null,
|
||
difficultyPreset: 'easy',
|
||
status: 'draft',
|
||
generationStatus: 'partial_failed',
|
||
publishReady: false,
|
||
playCount: 0,
|
||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||
publishedAt: null,
|
||
},
|
||
{
|
||
workId: 'BB-COVER002',
|
||
draftId: 'bark-battle-draft-cover-fallback',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '草稿作者',
|
||
title: '默认封面声浪赛',
|
||
summary: '',
|
||
themeDescription: '夜市声浪挑战',
|
||
playerImageDescription: '柴犬选手',
|
||
opponentImageDescription: '机器人对手',
|
||
playerCharacterImageSrc: null,
|
||
opponentCharacterImageSrc: null,
|
||
uiBackgroundImageSrc: null,
|
||
difficultyPreset: 'normal',
|
||
status: 'draft',
|
||
generationStatus: 'pending_assets',
|
||
publishReady: false,
|
||
playCount: 0,
|
||
updatedAt: '2026-05-19T00:00:00.000Z',
|
||
publishedAt: null,
|
||
},
|
||
],
|
||
});
|
||
|
||
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-COVER002')?.coverImageSrc).toBe(
|
||
'/creation-type-references/bark-battle.webp',
|
||
);
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems keeps bark battle draft author display name', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [],
|
||
barkBattleItems: [
|
||
{
|
||
workId: 'bark-battle-work-draft-author',
|
||
draftId: 'bark-battle-draft-author',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '草稿作者',
|
||
title: '草稿声浪赛',
|
||
summary: '',
|
||
themeDescription: '草地声浪挑战',
|
||
playerImageDescription: '柯基选手',
|
||
opponentImageDescription: '哈士奇对手',
|
||
playerCharacterImageSrc: '/player.png',
|
||
opponentCharacterImageSrc: '/opponent.png',
|
||
uiBackgroundImageSrc: '/background.png',
|
||
difficultyPreset: 'easy',
|
||
status: 'draft',
|
||
generationStatus: 'ready',
|
||
publishReady: false,
|
||
playCount: 0,
|
||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||
publishedAt: null,
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(items[0]?.kind).toBe('bark-battle');
|
||
expect(items[0]?.status).toBe('draft');
|
||
expect(items[0]?.authorDisplayName).toBe('草稿作者');
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems falls back unknown authors to player label', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [],
|
||
match3dItems: [
|
||
{
|
||
workId: 'match3d-work-author-fallback',
|
||
profileId: 'match3d-profile-author-fallback',
|
||
ownerUserId: 'user-1',
|
||
gameName: '水果抓大鹅',
|
||
themeText: '水果',
|
||
summary: '把水果从透明罐里抓出来。',
|
||
tags: [],
|
||
coverImageSrc: null,
|
||
clearCount: 0,
|
||
difficulty: 1,
|
||
publicationStatus: 'published',
|
||
playCount: 0,
|
||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||
publishReady: true,
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(items[0]?.kind).toBe('match3d');
|
||
expect(items[0]?.authorDisplayName).toBe('玩家');
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems attaches open and delete actions through shelf adapters', () => {
|
||
const onOpenPuzzleDetail = vi.fn();
|
||
const onDeletePuzzle = vi.fn();
|
||
const puzzleWork = {
|
||
workId: 'puzzle:work-action',
|
||
profileId: 'puzzle-profile-action',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '测试作者',
|
||
levelName: '动作拼图',
|
||
summary: '验证作品架动作 Adapter。',
|
||
themeTags: [],
|
||
coverImageSrc: null,
|
||
publicationStatus: 'draft' as const,
|
||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||
publishedAt: null,
|
||
playCount: 0,
|
||
remixCount: 0,
|
||
likeCount: 0,
|
||
publishReady: false,
|
||
};
|
||
|
||
const [item] = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [puzzleWork],
|
||
onOpenPuzzleDetail,
|
||
onDeletePuzzle,
|
||
});
|
||
|
||
item?.actions.open();
|
||
item?.actions.delete?.();
|
||
|
||
expect(onOpenPuzzleDetail).toHaveBeenCalledWith(puzzleWork);
|
||
expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork);
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems restores persisted generation state for puzzle and match3d drafts', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [
|
||
{
|
||
workId: 'puzzle:generating',
|
||
profileId: 'puzzle-profile-generating',
|
||
ownerUserId: 'user-1',
|
||
sourceSessionId: 'puzzle-session-generating',
|
||
authorDisplayName: '测试作者',
|
||
levelName: '生成中拼图',
|
||
summary: '退出产品后仍应显示生成中。',
|
||
themeTags: [],
|
||
coverImageSrc: null,
|
||
publicationStatus: 'draft',
|
||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||
publishedAt: null,
|
||
publishReady: false,
|
||
generationStatus: 'generating',
|
||
},
|
||
],
|
||
match3dItems: [
|
||
{
|
||
workId: 'match3d:generating',
|
||
profileId: 'match3d-profile-generating',
|
||
ownerUserId: 'user-1',
|
||
sourceSessionId: 'match3d-session-generating',
|
||
gameName: '生成中抓鹅',
|
||
themeText: '糖果厨房',
|
||
summary: '退出产品后仍应显示生成中。',
|
||
tags: [],
|
||
coverImageSrc: null,
|
||
clearCount: 18,
|
||
difficulty: 1,
|
||
publicationStatus: 'draft',
|
||
playCount: 0,
|
||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||
publishReady: false,
|
||
generationStatus: 'generating',
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe(
|
||
true,
|
||
);
|
||
expect(items.find((item) => item.kind === 'match3d')?.isGenerating).toBe(
|
||
true,
|
||
);
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
|
||
const onOpenBabyObjectMatchDetail = vi.fn();
|
||
const onDeleteBabyObjectMatch = vi.fn();
|
||
const baseDraft: BabyObjectMatchDraft = {
|
||
draftId: 'baby-object-draft-1',
|
||
profileId: 'baby-object-profile-12345678',
|
||
templateId: 'baby-object-match',
|
||
templateName: '宝贝识物',
|
||
workTitle: '宝贝识物',
|
||
workDescription: '苹果和香蕉识物分类',
|
||
itemNames: ['苹果', '香蕉'],
|
||
itemAssets: [
|
||
{
|
||
itemId: 'baby-object-item-1',
|
||
itemName: '苹果',
|
||
imageSrc: '/apple.png',
|
||
assetObjectId: null,
|
||
generationProvider: 'placeholder',
|
||
prompt: '苹果',
|
||
},
|
||
{
|
||
itemId: 'baby-object-item-2',
|
||
itemName: '香蕉',
|
||
imageSrc: '/banana.png',
|
||
assetObjectId: null,
|
||
generationProvider: 'placeholder',
|
||
prompt: '香蕉',
|
||
},
|
||
],
|
||
visualPackage: null,
|
||
themeTags: ['寓教于乐'],
|
||
publicationStatus: 'draft',
|
||
createdAt: '2026-05-11T00:00:00.000Z',
|
||
updatedAt: '2026-05-11T00:00:00.000Z',
|
||
publishedAt: null,
|
||
};
|
||
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [],
|
||
babyObjectMatchItems: [
|
||
baseDraft,
|
||
{
|
||
...baseDraft,
|
||
draftId: 'baby-object-draft-2',
|
||
profileId: 'baby-object-profile-87654321',
|
||
publicationStatus: 'published',
|
||
publishedAt: '2026-05-11T01:00:00.000Z',
|
||
updatedAt: '2026-05-11T01:00:00.000Z',
|
||
},
|
||
],
|
||
canDeleteBabyObjectMatch: true,
|
||
onOpenBabyObjectMatchDetail,
|
||
onDeleteBabyObjectMatch,
|
||
});
|
||
|
||
items[1]?.actions.open();
|
||
items[1]?.actions.delete?.();
|
||
|
||
expect(items[0]?.kind).toBe('baby-object-match');
|
||
expect(items[0]?.status).toBe('published');
|
||
expect(items[0]?.publicWorkCode).toBe('BO-87654321');
|
||
expect(items[0]?.sharePath).toContain('/works/detail?work=BO-87654321');
|
||
expect(items[1]?.status).toBe('draft');
|
||
expect(items[1]?.publicWorkCode).toBeNull();
|
||
expect(items[1]?.canDelete).toBe(true);
|
||
expect(onOpenBabyObjectMatchDetail).toHaveBeenCalledWith(baseDraft);
|
||
expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(baseDraft);
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems sorts works by latest updatedAt across timestamp formats', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [
|
||
{
|
||
workId: 'puzzle:older',
|
||
profileId: 'puzzle-profile-older',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '测试作者',
|
||
levelName: '旧草稿',
|
||
summary: '较早修改。',
|
||
themeTags: [],
|
||
coverImageSrc: null,
|
||
publicationStatus: 'draft',
|
||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||
publishedAt: null,
|
||
publishReady: false,
|
||
},
|
||
{
|
||
workId: 'puzzle:newer',
|
||
profileId: 'puzzle-profile-newer',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '测试作者',
|
||
levelName: '新草稿',
|
||
summary: '较晚修改。',
|
||
themeTags: [],
|
||
coverImageSrc: null,
|
||
publicationStatus: 'draft',
|
||
updatedAt: '1778457601.234567Z',
|
||
publishedAt: null,
|
||
publishReady: false,
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(items.map((item) => item.id)).toEqual([
|
||
'puzzle:newer',
|
||
'puzzle:older',
|
||
]);
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems falls back to available gameplay images as covers', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [
|
||
{
|
||
workId: 'puzzle:level-cover',
|
||
profileId: 'puzzle-profile-level-cover',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '测试作者',
|
||
levelName: '关卡封面拼图',
|
||
summary: '作品自身封面为空时使用关卡正式图。',
|
||
themeTags: [],
|
||
coverImageSrc: null,
|
||
publicationStatus: 'draft',
|
||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||
publishedAt: null,
|
||
publishReady: false,
|
||
levels: [
|
||
{
|
||
levelId: 'level-1',
|
||
levelName: '第一关',
|
||
pictureDescription: '港口雨夜。',
|
||
candidates: [
|
||
{
|
||
candidateId: 'candidate-1',
|
||
imageSrc: '/puzzle-candidate.png',
|
||
assetId: 'asset-1',
|
||
prompt: '港口雨夜',
|
||
sourceType: 'generated',
|
||
selected: true,
|
||
},
|
||
],
|
||
selectedCandidateId: 'candidate-1',
|
||
coverImageSrc: null,
|
||
coverAssetId: null,
|
||
generationStatus: 'ready',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
match3dItems: [
|
||
{
|
||
workId: 'match3d:asset-cover',
|
||
profileId: 'match3d-profile-asset-cover',
|
||
ownerUserId: 'user-1',
|
||
gameName: '素材封面抓鹅',
|
||
themeText: '糖果厨房',
|
||
summary: '作品自身封面为空时使用素材图。',
|
||
tags: [],
|
||
coverImageSrc: null,
|
||
clearCount: 18,
|
||
difficulty: 1,
|
||
publicationStatus: 'draft',
|
||
playCount: 0,
|
||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||
publishReady: false,
|
||
generatedItemAssets: [
|
||
{
|
||
itemId: 'item-1',
|
||
itemName: '糖果',
|
||
imageSrc: '/match3d-item.png',
|
||
status: 'image_ready',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
squareHoleItems: [
|
||
{
|
||
workId: 'square-hole:background-cover',
|
||
profileId: 'square-hole-profile-background-cover',
|
||
ownerUserId: 'user-1',
|
||
gameName: '背景封面方洞',
|
||
themeText: '星空玩具箱',
|
||
twistRule: '旋转洞口',
|
||
summary: '作品自身封面为空时使用背景图。',
|
||
tags: [],
|
||
coverImageSrc: null,
|
||
backgroundPrompt: '星空玩具箱',
|
||
backgroundImageSrc: '/square-hole-background.png',
|
||
shapeOptions: [],
|
||
holeOptions: [],
|
||
shapeCount: 3,
|
||
difficulty: 1,
|
||
publicationStatus: 'draft',
|
||
playCount: 0,
|
||
updatedAt: '2026-05-06T00:00:00.000Z',
|
||
publishReady: false,
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(items.find((item) => item.kind === 'puzzle')?.coverImageSrc).toBe(
|
||
'/puzzle-candidate.png',
|
||
);
|
||
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
|
||
'/match3d-item.png',
|
||
);
|
||
expect(items.find((item) => item.kind === 'square-hole')?.coverImageSrc).toBe(
|
||
'/square-hole-background.png',
|
||
);
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems uses generated object keys as cover sources', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [
|
||
{
|
||
workId: 'puzzle:level-object-key',
|
||
profileId: 'puzzle-profile-level-object-key',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '测试作者',
|
||
levelName: '关卡对象拼图',
|
||
summary: '作品摘要带关卡图对象路径时用关卡图做卡片背景。',
|
||
themeTags: [],
|
||
coverImageSrc: null,
|
||
publicationStatus: 'draft',
|
||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||
publishedAt: null,
|
||
publishReady: false,
|
||
levels: [
|
||
{
|
||
levelId: 'level-1',
|
||
levelName: '第一关',
|
||
pictureDescription: '港口雨夜。',
|
||
candidates: [
|
||
{
|
||
candidateId: 'candidate-1',
|
||
imageSrc: '',
|
||
assetId: 'asset-1',
|
||
prompt: '港口雨夜',
|
||
sourceType: 'generated',
|
||
selected: true,
|
||
},
|
||
],
|
||
selectedCandidateId: 'candidate-1',
|
||
coverImageSrc:
|
||
'generated-puzzle-assets/session/profile/level-cover.png',
|
||
coverAssetId: null,
|
||
generationStatus: 'ready',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
match3dItems: [
|
||
{
|
||
workId: 'match3d:object-key-cover',
|
||
profileId: 'match3d-profile-object-key-cover',
|
||
ownerUserId: 'user-1',
|
||
gameName: '对象路径抓鹅',
|
||
themeText: '糖果厨房',
|
||
summary: '背景图或物品图只有 object key 时也应展示。',
|
||
tags: [],
|
||
coverImageSrc: null,
|
||
clearCount: 18,
|
||
difficulty: 1,
|
||
publicationStatus: 'draft',
|
||
playCount: 0,
|
||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||
publishReady: false,
|
||
generatedBackgroundAsset: {
|
||
prompt: '糖果厨房背景',
|
||
imageObjectKey:
|
||
'generated-match3d-assets/session/profile/background/image.png',
|
||
containerImageObjectKey:
|
||
'generated-match3d-assets/session/profile/background/container.png',
|
||
status: 'ready',
|
||
},
|
||
generatedItemAssets: [
|
||
{
|
||
itemId: 'item-1',
|
||
itemName: '糖果',
|
||
imageObjectKey:
|
||
'generated-match3d-assets/session/profile/items/item-1/image.png',
|
||
imageViews: [
|
||
{
|
||
viewId: 'view-1',
|
||
viewIndex: 1,
|
||
imageObjectKey:
|
||
'generated-match3d-assets/session/profile/items/item-1/views/view-1.png',
|
||
},
|
||
],
|
||
status: 'image_ready',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(items.find((item) => item.kind === 'puzzle')?.coverImageSrc).toBe(
|
||
'generated-puzzle-assets/session/profile/level-cover.png',
|
||
);
|
||
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
|
||
'generated-match3d-assets/session/profile/background/container.png',
|
||
);
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems falls back to match3d item object key without background', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [],
|
||
match3dItems: [
|
||
{
|
||
workId: 'match3d:item-object-key-cover',
|
||
profileId: 'match3d-profile-item-object-key-cover',
|
||
ownerUserId: 'user-1',
|
||
gameName: '物品对象路径抓鹅',
|
||
themeText: '糖果厨房',
|
||
summary: '背景图缺失时用物品视角图对象路径。',
|
||
tags: [],
|
||
coverImageSrc: null,
|
||
clearCount: 18,
|
||
difficulty: 1,
|
||
publicationStatus: 'draft',
|
||
playCount: 0,
|
||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||
publishReady: false,
|
||
generatedItemAssets: [
|
||
{
|
||
itemId: 'item-1',
|
||
itemName: '糖果',
|
||
imageObjectKey:
|
||
'generated-match3d-assets/session/profile/items/item-1/image.png',
|
||
imageViews: [
|
||
{
|
||
viewId: 'view-1',
|
||
viewIndex: 1,
|
||
imageObjectKey:
|
||
'generated-match3d-assets/session/profile/items/item-1/views/view-1.png',
|
||
},
|
||
],
|
||
status: 'image_ready',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
|
||
'generated-match3d-assets/session/profile/items/item-1/views/view-1.png',
|
||
);
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems ignores puzzle theme reference cover and uses first level image', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [
|
||
{
|
||
workId: 'puzzle:theme-reference-cover',
|
||
profileId: 'puzzle-profile-theme-reference-cover',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '测试作者',
|
||
levelName: '主题兜底拼图',
|
||
summary: '摘要里的封面是玩法参考图时,用第一关画面兜底。',
|
||
themeTags: [],
|
||
coverImageSrc: '/creation-type-references/puzzle.webp',
|
||
publicationStatus: 'draft',
|
||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||
publishedAt: null,
|
||
publishReady: false,
|
||
levels: [
|
||
{
|
||
levelId: 'level-1',
|
||
levelName: '第一关',
|
||
pictureDescription: '第一关画面。',
|
||
candidates: [
|
||
{
|
||
candidateId: 'candidate-1',
|
||
imageSrc: '/puzzle-first-level-candidate.png',
|
||
assetId: 'asset-1',
|
||
prompt: '第一关画面',
|
||
sourceType: 'generated',
|
||
selected: true,
|
||
},
|
||
],
|
||
selectedCandidateId: 'candidate-1',
|
||
coverImageSrc: '/puzzle-first-level-cover.png',
|
||
coverAssetId: 'asset-1',
|
||
generationStatus: 'ready',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(items.find((item) => item.kind === 'puzzle')?.coverImageSrc).toBe(
|
||
'/puzzle-first-level-cover.png',
|
||
);
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems ignores match3d theme reference cover and uses container image', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [],
|
||
match3dItems: [
|
||
{
|
||
workId: 'match3d:theme-reference-cover',
|
||
profileId: 'match3d-profile-theme-reference-cover',
|
||
ownerUserId: 'user-1',
|
||
gameName: '主题兜底抓鹅',
|
||
themeText: '糖果厨房',
|
||
summary: '摘要里的封面是玩法参考图时,用UI背景图兜底。',
|
||
tags: [],
|
||
coverImageSrc: '/creation-type-references/match3d.webp',
|
||
clearCount: 18,
|
||
difficulty: 1,
|
||
publicationStatus: 'draft',
|
||
playCount: 0,
|
||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||
publishReady: false,
|
||
generatedBackgroundAsset: {
|
||
prompt: '糖果厨房竖屏UI背景',
|
||
imageSrc: '/match3d-ui-background.png',
|
||
containerImageSrc: '/match3d-container.png',
|
||
status: 'image_ready',
|
||
},
|
||
generatedItemAssets: [
|
||
{
|
||
itemId: 'item-1',
|
||
itemName: '糖果',
|
||
imageSrc: '/match3d-item.png',
|
||
status: 'image_ready',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
|
||
'/match3d-container.png',
|
||
);
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems uses match3d container asset before background and item image', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [],
|
||
match3dItems: [
|
||
{
|
||
workId: 'match3d:item-background-asset-cover',
|
||
profileId: 'match3d-profile-item-background-asset-cover',
|
||
ownerUserId: 'user-1',
|
||
gameName: '背景资产抓鹅',
|
||
themeText: '糖果厨房',
|
||
summary: '顶层背景缺失时,从素材携带的UI背景兜底。',
|
||
tags: [],
|
||
coverImageSrc: '/creation-type-references/match3d.webp',
|
||
clearCount: 18,
|
||
difficulty: 1,
|
||
publicationStatus: 'draft',
|
||
playCount: 0,
|
||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||
publishReady: false,
|
||
generatedItemAssets: [
|
||
{
|
||
itemId: 'item-1',
|
||
itemName: '糖果',
|
||
imageSrc: '/match3d-item.png',
|
||
backgroundAsset: {
|
||
prompt: '糖果厨房竖屏UI背景',
|
||
imageObjectKey:
|
||
'generated-match3d-assets/session/profile/background/image.png',
|
||
containerImageObjectKey:
|
||
'generated-match3d-assets/session/profile/ui-container/container.png',
|
||
status: 'image_ready',
|
||
},
|
||
status: 'image_ready',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
|
||
'generated-match3d-assets/session/profile/ui-container/container.png',
|
||
);
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems uses match3d transparent container reference as last fallback', () => {
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [],
|
||
match3dItems: [
|
||
{
|
||
workId: 'match3d:container-reference-fallback',
|
||
profileId: 'match3d-profile-container-reference-fallback',
|
||
ownerUserId: 'user-1',
|
||
sourceSessionId: 'session-1',
|
||
gameName: '水果抓大鹅',
|
||
themeText: '水果',
|
||
summary: '',
|
||
tags: [],
|
||
coverImageSrc: null,
|
||
referenceImageSrc: null,
|
||
backgroundPrompt: '',
|
||
backgroundImageSrc: null,
|
||
backgroundImageObjectKey: null,
|
||
generatedBackgroundAsset: null,
|
||
generatedItemAssets: [],
|
||
clearCount: 3,
|
||
difficulty: 2,
|
||
publicationStatus: 'draft',
|
||
publishReady: false,
|
||
playCount: 0,
|
||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||
publishedAt: null,
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
|
||
'/match3d-background-references/pot-fused-reference.png',
|
||
);
|
||
});
|
||
|
||
test('buildCreationWorkShelfItems maps bark battle works with scene role cover and BB code', () => {
|
||
const onOpenBarkBattleDetail = vi.fn();
|
||
const items = buildCreationWorkShelfItems({
|
||
rpgItems: [],
|
||
bigFishItems: [],
|
||
puzzleItems: [],
|
||
barkBattleItems: [
|
||
{
|
||
workId: 'bark-battle-work-12345678',
|
||
draftId: 'bark-battle-draft-1',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '玩家',
|
||
title: '公园声浪赛',
|
||
summary: '柯基和哈士奇比拼声浪。',
|
||
themeDescription: '傍晚公园擂台',
|
||
playerImageDescription: '红围巾柯基',
|
||
opponentImageDescription: '蓝头带哈士奇',
|
||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||
difficultyPreset: 'normal',
|
||
status: 'published',
|
||
generationStatus: 'ready',
|
||
publishReady: true,
|
||
playCount: 6,
|
||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||
},
|
||
],
|
||
onOpenBarkBattleDetail,
|
||
});
|
||
|
||
const item = items[0];
|
||
item?.actions.open();
|
||
|
||
expect(item?.kind).toBe('bark-battle');
|
||
expect(item?.publicWorkCode).toBe('BB-12345678');
|
||
expect(item?.sharePath).toContain('/works/detail?work=BB-12345678');
|
||
expect(item?.coverImageSrc).toBe('/generated-bark-battle/background.png');
|
||
expect(item?.coverRenderMode).toBe('scene_with_roles');
|
||
expect(item?.coverCharacterImageSrcs).toEqual([
|
||
'/generated-bark-battle/player.png',
|
||
'/generated-bark-battle/opponent.png',
|
||
]);
|
||
expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(
|
||
expect.objectContaining({ workId: 'bark-battle-work-12345678' }),
|
||
);
|
||
});
|
||
|
||
test('bark battle draft generating state follows pending assets or missing three images', () => {
|
||
const draft = {
|
||
workId: 'bark-battle-work-draft',
|
||
draftId: 'bark-battle-draft-1',
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName: '玩家',
|
||
title: '草稿声浪赛',
|
||
summary: '',
|
||
themeDescription: '草地',
|
||
playerImageDescription: '柯基',
|
||
opponentImageDescription: '哈士奇',
|
||
playerCharacterImageSrc: '/player.png',
|
||
opponentCharacterImageSrc: null,
|
||
uiBackgroundImageSrc: '/background.png',
|
||
difficultyPreset: 'easy' as const,
|
||
status: 'draft' as const,
|
||
generationStatus: 'pending_assets',
|
||
publishReady: false,
|
||
playCount: 0,
|
||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||
publishedAt: null,
|
||
};
|
||
|
||
expect(hasBarkBattleRequiredImages(draft)).toBe(false);
|
||
expect(isPersistedBarkBattleDraftGenerating(draft)).toBe(true);
|
||
expect(
|
||
isPersistedBarkBattleDraftGenerating({
|
||
...draft,
|
||
opponentCharacterImageSrc: '/opponent.png',
|
||
generationStatus: 'ready',
|
||
}),
|
||
).toBe(false);
|
||
});
|
||
|
||
|
||
test('CustomWorldWorkCard hides author on shelf draft and published cards', () => {
|
||
const buildItem = (
|
||
status: CreationWorkShelfItem['status'],
|
||
authorDisplayName: string,
|
||
): CreationWorkShelfItem => ({
|
||
id: `card-${status}`,
|
||
kind: 'bark-battle',
|
||
status,
|
||
authorDisplayName,
|
||
title: status === 'draft' ? '草稿声浪赛' : '发布声浪赛',
|
||
summary: '一场轻快的汪汪声浪对决。',
|
||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||
coverImageSrc: null,
|
||
coverRenderMode: 'image',
|
||
coverCharacterImageSrcs: [],
|
||
publicWorkCode: null,
|
||
sharePath: null,
|
||
openActionLabel: status === 'draft' ? '继续创作' : '查看详情',
|
||
canDelete: false,
|
||
canShare: false,
|
||
badges: [
|
||
{ id: 'status', label: status === 'draft' ? '草稿' : '已发布', tone: 'neutral' },
|
||
{ id: 'type', label: '汪汪', tone: 'neutral' },
|
||
],
|
||
metrics: [],
|
||
actions: { open: () => {} },
|
||
source: {
|
||
kind: 'bark-battle',
|
||
item: {
|
||
workId: `bark-battle-${status}`,
|
||
draftId: `draft-${status}`,
|
||
ownerUserId: 'user-1',
|
||
authorDisplayName,
|
||
title: status === 'draft' ? '草稿声浪赛' : '发布声浪赛',
|
||
summary: '一场轻快的汪汪声浪对决。',
|
||
themeDescription: '公园舞台',
|
||
playerImageDescription: '柯基选手',
|
||
opponentImageDescription: '哈士奇对手',
|
||
playerCharacterImageSrc: null,
|
||
opponentCharacterImageSrc: null,
|
||
uiBackgroundImageSrc: null,
|
||
difficultyPreset: 'normal',
|
||
status,
|
||
generationStatus: 'ready',
|
||
publishReady: status === 'published',
|
||
playCount: 0,
|
||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||
publishedAt: status === 'published' ? '2026-05-20T00:00:00.000Z' : null,
|
||
},
|
||
},
|
||
});
|
||
|
||
const draftHtml = renderToStaticMarkup(
|
||
createElement(CustomWorldWorkCard, {
|
||
item: buildItem('draft', '草稿作者'),
|
||
onOpen: () => {},
|
||
}),
|
||
);
|
||
const publishedHtml = renderToStaticMarkup(
|
||
createElement(CustomWorldWorkCard, {
|
||
item: buildItem('published', '发布作者'),
|
||
onOpen: () => {},
|
||
}),
|
||
);
|
||
|
||
expect(draftHtml).not.toContain('作者:草稿作者');
|
||
expect(publishedHtml).not.toContain('作者:发布作者');
|
||
});
|
||
|
||
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
|
||
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
|
||
1778457601234.567,
|
||
);
|
||
expect(getCreationWorkShelfItemTime('2026-05-07T00:00:00.000Z')).toBe(
|
||
new Date('2026-05-07T00:00:00.000Z').getTime(),
|
||
);
|
||
});
|