1
This commit is contained in:
@@ -97,7 +97,10 @@ import {
|
||||
dragLocalPuzzlePiece,
|
||||
swapLocalPuzzlePieces,
|
||||
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
||||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import {
|
||||
listPuzzleWorks,
|
||||
updatePuzzleWork,
|
||||
} from '../../services/puzzle-works';
|
||||
import {
|
||||
createRpgCreationSession,
|
||||
executeRpgCreationAction,
|
||||
@@ -376,6 +379,7 @@ vi.mock('../../services/creationEntryConfigService', () => ({
|
||||
|
||||
vi.mock('../../services/puzzle-works', () => ({
|
||||
listPuzzleWorks: vi.fn(),
|
||||
updatePuzzleWork: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-gallery', () => ({
|
||||
@@ -437,6 +441,10 @@ vi.mock('../../services/match3d-works', () => ({
|
||||
updateMatch3DGeneratedItemAssets: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3dGeneratedModelCache', () => ({
|
||||
preloadMatch3DGeneratedModelAssets: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-runtime', () => ({
|
||||
clickMatch3DItem: vi.fn(),
|
||||
finishMatch3DTimeUp: vi.fn(),
|
||||
@@ -555,6 +563,7 @@ vi.mock('../puzzle-result/PuzzleResultView', () => ({
|
||||
PuzzleResultView: ({
|
||||
isBusy,
|
||||
onExecuteAction,
|
||||
onStartTestRun,
|
||||
session,
|
||||
onBack,
|
||||
}: {
|
||||
@@ -564,7 +573,8 @@ vi.mock('../puzzle-result/PuzzleResultView', () => ({
|
||||
levelId?: string;
|
||||
promptText?: string;
|
||||
}) => void;
|
||||
session: { draft?: { levelName: string } | null };
|
||||
onStartTestRun?: (draft: PuzzleResultDraft) => void;
|
||||
session: { draft?: PuzzleResultDraft | null };
|
||||
onBack: () => void;
|
||||
}) => (
|
||||
<div className="puzzle-result-view-mock">
|
||||
@@ -585,6 +595,17 @@ vi.mock('../puzzle-result/PuzzleResultView', () => ({
|
||||
>
|
||||
重新生成画面
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!session.draft}
|
||||
onClick={() => {
|
||||
if (session.draft) {
|
||||
onStartTestRun?.(session.draft);
|
||||
}
|
||||
}}
|
||||
>
|
||||
试玩
|
||||
</button>
|
||||
<button type="button" disabled={isBusy}>
|
||||
新增关卡
|
||||
</button>
|
||||
@@ -660,6 +681,31 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../match3d-result/Match3DResultView', () => ({
|
||||
Match3DResultView: ({
|
||||
draft,
|
||||
onBack,
|
||||
onStartTestRun,
|
||||
profile,
|
||||
}: {
|
||||
draft?: { gameName?: string | null } | null;
|
||||
onBack: () => void;
|
||||
onStartTestRun: (profile: Match3DWorkSummary) => void;
|
||||
profile: Match3DWorkSummary;
|
||||
}) => (
|
||||
<div className="match3d-result-view-mock">
|
||||
<div>抓大鹅结果页</div>
|
||||
<div>{draft?.gameName ?? profile.gameName}</div>
|
||||
<button type="button" onClick={() => onStartTestRun(profile)}>
|
||||
试玩
|
||||
</button>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
|
||||
Match3DAgentWorkspace: ({
|
||||
session,
|
||||
@@ -672,6 +718,7 @@ vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
|
||||
referenceImageSrc: string | null;
|
||||
clearCount: number;
|
||||
difficulty: number;
|
||||
generateClickSound?: boolean;
|
||||
}) => void;
|
||||
}) => (
|
||||
<div className="match3d-agent-workspace-mock">
|
||||
@@ -2246,6 +2293,31 @@ beforeEach(() => {
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(updatePuzzleWork).mockImplementation(async (profileId, payload) => ({
|
||||
item: {
|
||||
workId: `puzzle-work-${profileId}`,
|
||||
profileId,
|
||||
ownerUserId: mockAuthUser.id,
|
||||
sourceSessionId: null,
|
||||
authorDisplayName: mockAuthUser.displayName,
|
||||
workTitle: payload.workTitle ?? payload.levelName,
|
||||
workDescription: payload.workDescription ?? payload.summary,
|
||||
levelName: payload.levelName,
|
||||
summary: payload.summary,
|
||||
themeTags: payload.themeTags,
|
||||
coverImageSrc: payload.coverImageSrc ?? null,
|
||||
coverAssetId: payload.coverAssetId ?? null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-05-12T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
levels: payload.levels,
|
||||
anchorPack: buildPuzzleAnchorPack(),
|
||||
},
|
||||
}));
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
@@ -2566,7 +2638,9 @@ test('running match3d form generation can return to draft tab and reopen progres
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
|
||||
await user.click(screen.getByRole('button', { name: '生成抓大鹅草稿' }));
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
@@ -2585,6 +2659,280 @@ test('running match3d form generation can return to draft tab and reopen progres
|
||||
});
|
||||
});
|
||||
|
||||
test('match3d result trial passes generated models into first runtime mount', async () => {
|
||||
const user = userEvent.setup();
|
||||
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: 'task-strawberry',
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
const match3dDraftWork: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-draft-1',
|
||||
profileId: 'match3d-profile-draft-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-draft-1',
|
||||
gameName: '水果抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '',
|
||||
tags: ['水果', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-01T10:30:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generatedItemAssets,
|
||||
};
|
||||
vi.mocked(listMatch3DWorks).mockResolvedValue({
|
||||
items: [match3dDraftWork],
|
||||
});
|
||||
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
|
||||
session: buildMockMatch3DAgentSession({
|
||||
sessionId: 'match3d-session-draft-1',
|
||||
draft: {
|
||||
profileId: 'match3d-profile-draft-1',
|
||||
gameName: '水果抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '',
|
||||
tags: ['水果', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
generatedItemAssets,
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: match3dDraftWork,
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dDraftWork.profileId),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openDraftHub(user);
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: /继续创作《水果抓大鹅》/u }),
|
||||
);
|
||||
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-draft-1');
|
||||
});
|
||||
expect(
|
||||
await screen.findByTestId('match3d-runtime-generated-model-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
|
||||
test('match3d draft generation auto starts trial and runtime back opens draft result', async () => {
|
||||
const user = userEvent.setup();
|
||||
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: 'task-strawberry',
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
const generatedSession = buildMockMatch3DAgentSession({
|
||||
stage: 'draft_ready',
|
||||
draft: {
|
||||
profileId: 'match3d-profile-auto-1',
|
||||
gameName: '自动试玩抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '',
|
||||
tags: ['水果', '抓大鹅', '试玩'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
generatedItemAssets,
|
||||
},
|
||||
});
|
||||
const generatedProfile: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-auto-1',
|
||||
profileId: 'match3d-profile-auto-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: generatedSession.sessionId,
|
||||
gameName: '自动试玩抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '',
|
||||
tags: ['水果', '抓大鹅', '试玩'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-12T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generatedItemAssets,
|
||||
};
|
||||
|
||||
vi.mocked(match3dCreationClient.executeAction).mockResolvedValueOnce({
|
||||
session: generatedSession,
|
||||
});
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: generatedProfile,
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
run: buildMockMatch3DRun(generatedProfile.profileId),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy();
|
||||
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-auto-1');
|
||||
expect(
|
||||
await screen.findByTestId('match3d-runtime-generated-model-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
|
||||
expect(screen.getByText('自动试玩抓大鹅')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('puzzle draft generation auto starts trial and runtime back opens draft result', async () => {
|
||||
const user = userEvent.setup();
|
||||
const generatedDraft: PuzzleResultDraft = {
|
||||
workTitle: '自动试玩拼图',
|
||||
workDescription: '生成完成后直接试玩。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
themeTags: ['猫咪', '雨夜', '拼图'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack: buildPuzzleAnchorPack(),
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/auto-candidate.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫街',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/auto-candidate.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'ready',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫街',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
pictureReference: null,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/auto-candidate.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫街',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/auto-candidate.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
};
|
||||
const generatedSession: PuzzleAgentSessionSnapshot = {
|
||||
sessionId: 'puzzle-session-auto-1',
|
||||
seedText: '屋檐下的猫与暖灯街角。',
|
||||
currentTurn: 1,
|
||||
progressPercent: 100,
|
||||
stage: 'ready_to_publish',
|
||||
anchorPack: buildPuzzleAnchorPack(),
|
||||
draft: generatedDraft,
|
||||
messages: [],
|
||||
lastAssistantReply: '拼图草稿已经生成。',
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: {
|
||||
draft: generatedDraft,
|
||||
publishReady: true,
|
||||
blockers: [],
|
||||
qualityFindings: [],
|
||||
},
|
||||
updatedAt: '2026-05-12T10:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
|
||||
operation: {
|
||||
operationId: 'compile-puzzle-auto-1',
|
||||
type: 'compile_puzzle_draft',
|
||||
status: 'completed',
|
||||
phaseLabel: '已完成',
|
||||
phaseDetail: '草稿已生成',
|
||||
progress: 1,
|
||||
},
|
||||
session: generatedSession,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('button', { name: '生成草稿' }));
|
||||
|
||||
expect(await screen.findByText('雨夜猫街')).toBeTruthy();
|
||||
expect(updatePuzzleWork).toHaveBeenCalledWith(
|
||||
'puzzle-profile-auto-1',
|
||||
expect.objectContaining({
|
||||
levelName: '雨夜猫街',
|
||||
coverImageSrc: '/puzzle/auto-candidate.png',
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText('拼图结果页')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回上一页' }));
|
||||
|
||||
expect(await screen.findByText('拼图结果页')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('雨夜猫街')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('embedded puzzle form routes through requireAuth while logged out', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
@@ -3545,6 +3893,82 @@ test('home recommendation Match3D runtime keeps profile generated models when ca
|
||||
});
|
||||
});
|
||||
|
||||
test('home recommendation Match3D runtime refetches detail when stale card only has image assets', async () => {
|
||||
const match3dCard: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-card-image-only',
|
||||
profileId: 'match3d-profile-card-image-only',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'match3d-session-card-image-only',
|
||||
gameName: '水果抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '消除水果模型。',
|
||||
tags: ['水果', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 3,
|
||||
difficulty: 5,
|
||||
publicationStatus: 'published',
|
||||
playCount: 3,
|
||||
updatedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishReady: true,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
const match3dDetail: Match3DWorkSummary = {
|
||||
...match3dCard,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
...match3dCard.generatedItemAssets![0]!,
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: 'task-strawberry',
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(listMatch3DGallery).mockResolvedValue({
|
||||
items: [match3dCard],
|
||||
});
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: match3dDetail,
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dCard.profileId),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getMatch3DWorkDetail).toHaveBeenCalledWith(
|
||||
'match3d-profile-card-image-only',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-generated-model-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
});
|
||||
|
||||
test('home recommendation surfaces start failure instead of staying in loading state', async () => {
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
@@ -3983,8 +4407,7 @@ test('published puzzle work card restores its source session for editing', async
|
||||
expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('first launch puzzle onboarding can be skipped from top right', async () => {
|
||||
const user = userEvent.setup();
|
||||
test('first launch hides puzzle onboarding by default', async () => {
|
||||
window.localStorage.removeItem(
|
||||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||||
);
|
||||
@@ -4000,61 +4423,16 @@ test('first launch puzzle onboarding can be skipped from top right', async () =>
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('待定待定待定')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '跳过' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('待定待定待定')).toBeNull();
|
||||
});
|
||||
expect(screen.queryByPlaceholderText('把你的梦讲给我听吧')).toBeNull();
|
||||
expect(
|
||||
window.localStorage.getItem('genarrative.puzzle-onboarding.first-visit.v1'),
|
||||
).toBe('1');
|
||||
).toBeNull();
|
||||
expect(generatePuzzleOnboardingWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('first launch puzzle onboarding falls back to local run when generate route is missing', async () => {
|
||||
const user = userEvent.setup();
|
||||
window.localStorage.removeItem(
|
||||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||||
);
|
||||
vi.mocked(generatePuzzleOnboardingWork).mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '资源不存在',
|
||||
status: 404,
|
||||
code: 'NOT_FOUND',
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: () => {},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(
|
||||
await screen.findByPlaceholderText('把你的梦讲给我听吧'),
|
||||
'我想飞上天',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('puzzle-board', undefined, { timeout: 3000 }),
|
||||
).toBeTruthy();
|
||||
expect(generatePuzzleOnboardingWork).toHaveBeenCalledWith({
|
||||
promptText: '我想飞上天',
|
||||
});
|
||||
expect(screen.queryByText('资源不存在')).toBeNull();
|
||||
expect(startPuzzleRun).not.toHaveBeenCalled();
|
||||
expect(
|
||||
window.localStorage.getItem('genarrative.puzzle-onboarding.first-visit.v1'),
|
||||
).toBe('1');
|
||||
});
|
||||
|
||||
test('formal puzzle runtime uses frontend move merge logic and backend leaderboard next level', async () => {
|
||||
const user = userEvent.setup();
|
||||
const clearedFirstLevel = buildClearedPuzzleRun({
|
||||
|
||||
@@ -21,6 +21,10 @@ import type {
|
||||
ProfileTaskCenterResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import {
|
||||
ICP_RECORD_NUMBER,
|
||||
ICP_RECORD_URL,
|
||||
} from '../common/legalDocuments';
|
||||
import {
|
||||
RpgEntryHomeView,
|
||||
type RpgEntryHomeViewProps,
|
||||
@@ -1089,11 +1093,48 @@ test('opens reward code modal from profile action on mobile', async () => {
|
||||
expect(screen.getByLabelText('关闭兑换码')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('shows a reachable login entry in logged out mobile shell', async () => {
|
||||
test('profile page shows legal entries and ICP record link', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderProfileView();
|
||||
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'),
|
||||
).toBe(true);
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /每日任务/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /邀请好友/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /玩家社区/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /反馈/u }))
|
||||
.toBeTruthy();
|
||||
|
||||
const legalRegion = screen.getByRole('region', { name: '法律信息' });
|
||||
expect(within(legalRegion).getByRole('button', { name: /用户协议/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(legalRegion).getByRole('button', { name: /隐私政策/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(legalRegion).getByRole('button', { name: /免责声明/u }))
|
||||
.toBeTruthy();
|
||||
|
||||
const recordLink = within(legalRegion).getByRole('link', {
|
||||
name: ICP_RECORD_NUMBER,
|
||||
});
|
||||
expect(recordLink.getAttribute('href')).toBe(ICP_RECORD_URL);
|
||||
expect(recordLink.getAttribute('target')).toBe('_blank');
|
||||
expect(recordLink.getAttribute('rel')).toBe('noreferrer');
|
||||
|
||||
await user.click(within(legalRegion).getByRole('button', { name: /隐私政策/u }));
|
||||
expect(await screen.findByRole('dialog', { name: '隐私政策' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('shows a reachable login entry outside mobile recommend tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openLoginModal = vi.fn();
|
||||
|
||||
renderLoggedOutHomeView(openLoginModal);
|
||||
renderLoggedOutHomeView(openLoginModal, {}, 'category');
|
||||
await user.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
@@ -1360,6 +1401,10 @@ test('logged out mobile shell defaults to discover tab', () => {
|
||||
expect(
|
||||
screen.getByPlaceholderText('搜索作品号、名称、作者、描述'),
|
||||
).toBeTruthy();
|
||||
expect(container.querySelector('.platform-mobile-topbar')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('.platform-mobile-entry-shell--recommend'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('logged out recommend tab opens login modal and shows cover only', async () => {
|
||||
@@ -1381,6 +1426,10 @@ test('logged out recommend tab opens login modal and shows cover only', async ()
|
||||
expect(
|
||||
container.querySelector('.platform-recommend-cover-only'),
|
||||
).toBeTruthy();
|
||||
expect(container.querySelector('.platform-mobile-topbar')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('.platform-mobile-entry-shell--recommend'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
|
||||
expect(screen.queryByLabelText('奇幻拼图 作品信息')).toBeNull();
|
||||
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
||||
@@ -1647,6 +1696,7 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
});
|
||||
|
||||
const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement;
|
||||
expect(meta.closest('[data-recommend-swipe-zone="true"]')).toBeTruthy();
|
||||
const activeRecommendCard = within(meta);
|
||||
const likeButton = activeRecommendCard.getByRole('button', {
|
||||
name: '点赞 12',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
Coins,
|
||||
Compass,
|
||||
Copy,
|
||||
FileText,
|
||||
Gamepad2,
|
||||
GitFork,
|
||||
Heart,
|
||||
@@ -75,6 +75,14 @@ import {
|
||||
} from '../../services/rpg-entry/rpgProfileClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { LegalDocumentModal } from '../common/LegalDocumentModal';
|
||||
import {
|
||||
getLegalDocument,
|
||||
ICP_RECORD_NUMBER,
|
||||
ICP_RECORD_URL,
|
||||
LEGAL_DOCUMENTS,
|
||||
type LegalDocumentId,
|
||||
} from '../common/legalDocuments';
|
||||
import {
|
||||
canExposePublicWork,
|
||||
EDUTAINMENT_WORK_TAG,
|
||||
@@ -825,7 +833,10 @@ function RecommendSwipeCard({
|
||||
data-active={isActive ? 'true' : 'false'}
|
||||
>
|
||||
<div className="platform-recommend-swipe-card__visual">{visual}</div>
|
||||
<div className="platform-recommend-swipe-card__meta">
|
||||
<div
|
||||
className="platform-recommend-swipe-card__meta"
|
||||
data-recommend-swipe-zone={isActive ? 'true' : 'false'}
|
||||
>
|
||||
<RecommendRuntimeMeta
|
||||
entry={entry}
|
||||
authorAvatarUrl={authorAvatarUrl}
|
||||
@@ -2103,6 +2114,53 @@ function ProfileShortcutButton({
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileLegalSection({
|
||||
onOpenDocument,
|
||||
}: {
|
||||
onOpenDocument: (documentId: LegalDocumentId) => void;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}
|
||||
aria-label="法律信息"
|
||||
>
|
||||
<div className="mb-3 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
法律信息
|
||||
</div>
|
||||
<div className="platform-subpanel overflow-hidden rounded-[1.25rem]">
|
||||
{LEGAL_DOCUMENTS.map((document, index) => (
|
||||
<button
|
||||
key={document.id}
|
||||
type="button"
|
||||
onClick={() => onOpenDocument(document.id)}
|
||||
className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--platform-button-secondary-fill)] ${
|
||||
index > 0 ? 'border-t border-[var(--platform-subpanel-border)]' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="platform-profile-chip flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
|
||||
<FileText className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{document.title}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href={ICP_RECORD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-3 block text-center text-xs font-semibold text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)]"
|
||||
>
|
||||
{ICP_RECORD_NUMBER}
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileReferralUserAvatar({
|
||||
name,
|
||||
avatarUrl,
|
||||
@@ -3176,6 +3234,8 @@ export function RpgEntryHomeView({
|
||||
const [profileCopyState, setProfileCopyState] = useState<
|
||||
'idle' | 'copied' | 'failed'
|
||||
>('idle');
|
||||
const [activeLegalDocumentId, setActiveLegalDocumentId] =
|
||||
useState<LegalDocumentId | null>(null);
|
||||
const profileCopyResetTimerRef = useRef<number | null>(null);
|
||||
const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false);
|
||||
@@ -3296,6 +3356,9 @@ export function RpgEntryHomeView({
|
||||
const publicUserCode = buildPublicUserCode(authUi?.user);
|
||||
const avatarLabel = getUserAvatarLabel(authUi?.user);
|
||||
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
|
||||
const activeLegalDocument = activeLegalDocumentId
|
||||
? getLegalDocument(activeLegalDocumentId)
|
||||
: null;
|
||||
const avatarCropSize = avatarImageSize
|
||||
? Math.min(avatarImageSize.width, avatarImageSize.height) / avatarScale
|
||||
: 0;
|
||||
@@ -4931,7 +4994,7 @@ export function RpgEntryHomeView({
|
||||
className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}
|
||||
aria-label="常用功能"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ProfileShortcutButton
|
||||
label="每日任务"
|
||||
subLabel={
|
||||
@@ -4999,6 +5062,8 @@ export function RpgEntryHomeView({
|
||||
<ChevronRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
|
||||
</>
|
||||
) : (
|
||||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||||
@@ -5385,36 +5450,33 @@ export function RpgEntryHomeView({
|
||||
) : null;
|
||||
|
||||
if (!isDesktopLayout) {
|
||||
const isMobileRecommendTab = activeTab === 'home';
|
||||
|
||||
return (
|
||||
<div className="platform-mobile-entry-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
|
||||
<RpgEntryBrandLogo />
|
||||
{!isAuthenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-button platform-button--primary shrink-0 px-3 py-2 text-xs"
|
||||
>
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
登录
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-icon-button platform-mobile-topbar__action shrink-0"
|
||||
aria-label="通知与账户"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`platform-mobile-entry-shell ${isMobileRecommendTab ? 'platform-mobile-entry-shell--recommend' : ''} flex h-full min-h-0 min-w-0 flex-col overflow-hidden`}
|
||||
>
|
||||
{!isMobileRecommendTab ? (
|
||||
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
|
||||
<RpgEntryBrandLogo />
|
||||
{!isAuthenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-button platform-button--primary shrink-0 px-3 py-2 text-xs"
|
||||
>
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
登录
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="platform-tab-panel-stack min-w-0 flex-1">
|
||||
{tabPanels}
|
||||
</div>
|
||||
|
||||
<div className="platform-mobile-bottom-dock mt-3 min-w-0 shrink-0">
|
||||
<div className="platform-mobile-bottom-dock min-w-0 shrink-0">
|
||||
<div
|
||||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
@@ -5504,6 +5566,12 @@ export function RpgEntryHomeView({
|
||||
onRetry={loadWalletLedger}
|
||||
/>
|
||||
) : null}
|
||||
<LegalDocumentModal
|
||||
document={activeLegalDocument}
|
||||
open={Boolean(activeLegalDocument)}
|
||||
platformTheme={authUi?.platformTheme}
|
||||
onClose={() => setActiveLegalDocumentId(null)}
|
||||
/>
|
||||
{profileEditModals}
|
||||
</div>
|
||||
);
|
||||
@@ -5528,14 +5596,6 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-icon-button"
|
||||
aria-label="通知与账户"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
@@ -5651,6 +5711,12 @@ export function RpgEntryHomeView({
|
||||
onRetry={loadWalletLedger}
|
||||
/>
|
||||
) : null}
|
||||
<LegalDocumentModal
|
||||
document={activeLegalDocument}
|
||||
open={Boolean(activeLegalDocument)}
|
||||
platformTheme={authUi?.platformTheme}
|
||||
onClose={() => setActiveLegalDocumentId(null)}
|
||||
/>
|
||||
{profileEditModals}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -108,6 +108,9 @@ export type PlatformMatch3DGalleryCard = {
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
backgroundPrompt?: string | null;
|
||||
backgroundImageSrc?: string | null;
|
||||
backgroundImageObjectKey?: string | null;
|
||||
generatedItemAssets?: Match3DGeneratedItemAsset[];
|
||||
};
|
||||
|
||||
@@ -255,6 +258,9 @@ export function mapMatch3DWorkToPlatformGalleryCard(
|
||||
visibility: 'published',
|
||||
publishedAt: work.publishedAt ?? null,
|
||||
updatedAt: work.updatedAt,
|
||||
backgroundPrompt: work.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: work.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: work.backgroundImageObjectKey ?? null,
|
||||
generatedItemAssets: work.generatedItemAssets ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user