Merge remote-tracking branch 'origin/master'
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-01 22:14:49 +08:00
151 changed files with 3952 additions and 1299 deletions

View File

@@ -56,12 +56,16 @@ import {
import {
getPuzzleGalleryDetail,
listPuzzleGallery,
remixPuzzleGalleryWork,
} from '../../services/puzzle-gallery';
import {
advanceLocalPuzzleNextLevel,
advancePuzzleNextLevel,
getPuzzleRun,
startPuzzleRun,
submitPuzzleLeaderboard,
updatePuzzleRunPause,
usePuzzleRuntimeProp,
} from '../../services/puzzle-runtime';
import {
dragLocalPuzzlePiece,
@@ -96,6 +100,7 @@ import {
recordRpgEntryWorldGalleryPlay,
remixRpgEntryWorldGallery,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import { type CustomWorldProfile, WorldType } from '../../types';
import {
AuthUiContext,
type PlatformSettingsSection,
@@ -210,6 +215,7 @@ vi.mock('../../services/puzzle-works', () => ({
vi.mock('../../services/puzzle-gallery', () => ({
getPuzzleGalleryDetail: vi.fn(),
listPuzzleGallery: vi.fn(),
remixPuzzleGalleryWork: vi.fn(),
}));
vi.mock('../../services/puzzle-runtime', () => ({
@@ -553,6 +559,7 @@ const mockAuthUser: AuthUser = {
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
};
function buildMockPuzzleRun(
@@ -732,22 +739,38 @@ function buildMockMatch3DAgentSession(
function buildMockRpgGalleryDetail(
entry: CustomWorldGalleryCard,
): CustomWorldLibraryEntry {
): CustomWorldLibraryEntry<CustomWorldProfile> {
return {
...entry,
profile: {
id: entry.profileId,
settingText: entry.summaryText,
name: entry.worldName,
subtitle: entry.subtitle,
summary: entry.summaryText,
tone: '压抑、潮湿、悬疑',
playerGoal: '查清旧案。',
templateWorldType: WorldType.WUXIA,
attributeSchema: {
id: `${entry.profileId}-attribute-schema`,
worldId: entry.profileId,
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: entry.worldName,
settingSummary: entry.summaryText,
tone: '压抑、潮湿、悬疑',
conflictCore: '雾潮正在逼近港口',
},
slots: [],
},
majorFactions: ['守灯会'],
coreConflicts: ['雾潮正在逼近港口'],
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
} as never,
},
};
}
@@ -1616,6 +1639,7 @@ beforeEach(() => {
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [],
});
vi.mocked(recordBigFishPlay).mockResolvedValue(undefined);
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: null,
});
@@ -1682,9 +1706,21 @@ beforeEach(() => {
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [],
});
vi.mocked(remixPuzzleGalleryWork).mockRejectedValue(
new Error('未启用拼图 remix'),
);
vi.mocked(advancePuzzleNextLevel).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-next-profile`, '后端推荐下一关'),
}));
vi.mocked(getPuzzleRun).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
}));
vi.mocked(updatePuzzleRunPause).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
}));
vi.mocked(usePuzzleRuntimeProp).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
}));
vi.mocked(submitPuzzleLeaderboard).mockImplementation(
async (runId, payload) => ({
run: {
@@ -2100,6 +2136,114 @@ test('clicking a public work while logged out opens public detail without starti
expect(recordRpgEntryWorldGalleryPlay).not.toHaveBeenCalled();
});
test('logged out public detail gates puzzle start and remix before real actions', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
const publishedPuzzleWork = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: null,
authorDisplayName: '拼图作者',
levelName: '星桥机关',
summary: '旋转碎片并接通星桥机关。',
themeTags: ['机关', '星桥'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
remixCount: 0,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [publishedPuzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth,
})}
/>,
);
await waitFor(() => {
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
});
const workCards = screen.getAllByRole('button', { name: //u });
await user.click(workCards[0]!);
expect(await screen.findByText('详情')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(startPuzzleRun).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '作品改造' }));
expect(requireAuth).toHaveBeenCalledTimes(2);
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
});
test('logged out public detail gates big fish start before local runtime', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
const bigFishWork: BigFishWorkSummary = {
workId: 'big-fish-work-public-1',
sourceSessionId: 'big-fish-session-public-1',
ownerUserId: 'user-2',
authorDisplayName: '大鱼作者',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化',
summary: '从微光孢子一路吞并成长到深海巨鲲。',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
};
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [bigFishWork],
});
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth,
})}
/>,
);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'BF-NPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(await screen.findByText('详情')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(startLocalBigFishRuntimeRun).not.toHaveBeenCalled();
expect(recordBigFishPlay).not.toHaveBeenCalled();
});
test('creation hub clears all private work shelves immediately after logout state', async () => {
const user = userEvent.setup();
const loggedInAuth = createAuthValue();
@@ -2793,7 +2937,7 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / M3 / PZ 编号',
'搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -2801,8 +2945,8 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
await user.click(await screen.findByRole('button', { name: '启动' }));
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith({
profileId: 'puzzle-profile-public-1',
levelId: null,
profileId: 'puzzle-profile-public-1',
});
});
@@ -2866,7 +3010,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / M3 / PZ 编号',
'搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -2909,7 +3053,7 @@ test('public code search opens a published big fish work by BF code', async () =
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / M3 / PZ 编号',
'搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'BF-NPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -2963,7 +3107,7 @@ test('public code search opens a published Match3D work by M3 code and starts ru
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / M3 / PZ 编号',
'搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'M3-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -4082,6 +4226,31 @@ test('authenticated users with save archives default into the saves tab', async
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
expect(screen.queryByText('ARCHIVE')).toBeNull();
expect(screen.queryByText('最近存档')).toBeNull();
});
test('puzzle save archive highlights work title and level subtitle', async () => {
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
worldKey: 'puzzle:puzzle-profile-1',
ownerUserId: 'user-2',
profileId: 'puzzle-profile-1',
worldType: 'PUZZLE',
worldName: '雨夜猫塔',
subtitle: '第 2 关 · 星桥机关',
summaryText: '拼图进行中',
coverImageSrc: '/generated-puzzle-assets/puzzle-1/level-2.png',
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
]);
render(<TestWrapper withAuth />);
expect((await screen.findAllByText('雨夜猫塔')).length).toBeGreaterThan(0);
expect(screen.getAllByText('第 2 关 · 星桥机关').length).toBeGreaterThan(0);
expect(screen.queryByText('ARCHIVE')).toBeNull();
expect(screen.queryByText('最近存档')).toBeNull();
});
test('manual tab switch is preserved after platform bootstrap requests finish', async () => {