11
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-16 21:47:20 +08:00
parent 2456c10c63
commit 09d4c0c31b
79 changed files with 11873 additions and 2341 deletions

View File

@@ -277,7 +277,9 @@ async function waitForCustomWorldAgentOperation(params: {
assert.equal(operationResponse.status, 200);
operationText = await operationResponse.text();
if (new RegExp(`"status":"${params.expectedStatus}"`, 'u').test(operationText)) {
if (
new RegExp(`"status":"${params.expectedStatus}"`, 'u').test(operationText)
) {
return operationText;
}
@@ -1827,6 +1829,164 @@ test('runtime persistence is isolated by user', async () => {
});
});
test('profile dashboard aggregates wallet, play time and played works at the account level', async () => {
await withTestServer('profile-dashboard', async ({ baseUrl }) => {
const user = await authEntry(baseUrl, 'dashboard_user', 'secret123');
const firstSaveResponse = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
withBearer(user.token, {
method: 'PUT',
body: JSON.stringify({
savedAt: '2026-04-16T08:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'CUSTOM',
playerCurrency: 120,
runtimeStats: {
playTimeMs: 5400000,
},
customWorldProfile: {
id: 'world-aurora',
name: '裂潮边城',
summary: '潮声与城线之间的冷铁边疆。',
},
},
}),
}),
);
assert.equal(firstSaveResponse.status, 200);
const secondSaveResponse = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
withBearer(user.token, {
method: 'PUT',
body: JSON.stringify({
savedAt: '2026-04-16T09:30:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'CUSTOM',
playerCurrency: 86,
runtimeStats: {
playTimeMs: 7200000,
},
customWorldProfile: {
id: 'world-aurora',
name: '裂潮边城',
summary: '潮声与城线之间的冷铁边疆。',
},
},
}),
}),
);
assert.equal(secondSaveResponse.status, 200);
const thirdSaveResponse = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
withBearer(user.token, {
method: 'PUT',
body: JSON.stringify({
savedAt: '2026-04-16T10:15:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'WUXIA',
playerCurrency: 86,
runtimeStats: {
playTimeMs: 900000,
},
currentScenePreset: {
name: '江湖新章',
},
},
}),
}),
);
assert.equal(thirdSaveResponse.status, 200);
const dashboardResponse = await httpRequest(
`${baseUrl}/api/runtime/profile/dashboard`,
withBearer(user.token),
);
const dashboardPayload = (await dashboardResponse.json()) as {
walletBalance: number;
totalPlayTimeMs: number;
playedWorldCount: number;
updatedAt: string | null;
};
assert.equal(dashboardResponse.status, 200);
assert.equal(dashboardPayload.walletBalance, 86);
assert.equal(dashboardPayload.totalPlayTimeMs, 8100000);
assert.equal(dashboardPayload.playedWorldCount, 2);
assert.equal(dashboardPayload.updatedAt, '2026-04-16T10:15:00.000Z');
const legacyDashboardResponse = await httpRequest(
`${baseUrl}/api/profile/dashboard`,
withBearer(user.token),
);
const legacyDashboardPayload = (await legacyDashboardResponse.json()) as {
walletBalance: number;
totalPlayTimeMs: number;
playedWorldCount: number;
updatedAt: string | null;
};
assert.equal(legacyDashboardResponse.status, 200);
assert.deepEqual(legacyDashboardPayload, dashboardPayload);
const walletLedgerResponse = await httpRequest(
`${baseUrl}/api/runtime/profile/wallet-ledger`,
withBearer(user.token),
);
const walletLedgerPayload = (await walletLedgerResponse.json()) as {
entries: Array<{
amountDelta: number;
balanceAfter: number;
sourceType: string;
}>;
};
assert.equal(walletLedgerResponse.status, 200);
assert.equal(walletLedgerPayload.entries.length, 2);
assert.equal(walletLedgerPayload.entries[0]?.amountDelta, -34);
assert.equal(walletLedgerPayload.entries[0]?.balanceAfter, 86);
assert.equal(walletLedgerPayload.entries[0]?.sourceType, 'snapshot_sync');
assert.equal(walletLedgerPayload.entries[1]?.amountDelta, 120);
const playStatsResponse = await httpRequest(
`${baseUrl}/api/runtime/profile/play-stats`,
withBearer(user.token),
);
const playStatsPayload = (await playStatsResponse.json()) as {
totalPlayTimeMs: number;
playedWorks: Array<{
worldKey: string;
worldTitle: string;
lastObservedPlayTimeMs: number;
}>;
updatedAt: string | null;
};
assert.equal(playStatsResponse.status, 200);
assert.equal(playStatsPayload.totalPlayTimeMs, 8100000);
assert.equal(playStatsPayload.updatedAt, '2026-04-16T10:15:00.000Z');
assert.equal(playStatsPayload.playedWorks.length, 2);
assert.equal(playStatsPayload.playedWorks[0]?.worldKey, 'builtin:WUXIA');
assert.equal(playStatsPayload.playedWorks[0]?.worldTitle, '江湖新章');
assert.equal(
playStatsPayload.playedWorks[1]?.worldKey,
'custom:world-aurora',
);
assert.equal(
playStatsPayload.playedWorks[1]?.lastObservedPlayTimeMs,
7200000,
);
});
});
test('custom worlds stay private until published and then appear in the public gallery', async () => {
await withTestServer('custom-world-gallery', async ({ baseUrl }) => {
const owner = await authEntry(baseUrl, 'gallery_owner', 'secret123');
@@ -1929,7 +2089,10 @@ test('custom worlds stay private until published and then appear in the public g
assert.equal(galleryAfterPublish.status, 200);
assert.equal(galleryAfterPayload.entries.length, 1);
assert.equal(galleryAfterPayload.entries[0]?.worldName, '裂桥前线');
assert.equal(galleryAfterPayload.entries[0]?.authorDisplayName, 'gallery_owner');
assert.equal(
galleryAfterPayload.entries[0]?.authorDisplayName,
'gallery_owner',
);
const galleryDetail = await httpRequest(
`${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryAfterPayload.entries[0]?.ownerUserId || '')}/${encodeURIComponent(galleryAfterPayload.entries[0]?.profileId || '')}`,
@@ -1975,9 +2138,10 @@ test('custom worlds stay private until published and then appear in the public g
},
},
);
const galleryAfterUnpublishPayload = (await galleryAfterUnpublish.json()) as {
entries: unknown[];
};
const galleryAfterUnpublishPayload =
(await galleryAfterUnpublish.json()) as {
entries: unknown[];
};
assert.deepEqual(galleryAfterUnpublishPayload.entries, []);
});
});
@@ -2413,12 +2577,18 @@ test('custom world agent update_draft_card action updates draft profile and card
await withTestServer(
'custom-world-agent-phase4-update-http',
async ({ baseUrl, context }) => {
const entry = await authEntry(baseUrl, 'cw_agent_phase4_update', 'secret123');
const entry = await authEntry(
baseUrl,
'cw_agent_phase4_update',
'secret123',
);
const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl,
token: entry.token,
});
const worldCard = session.draftCards.find((card) => card.kind === 'world');
const worldCard = session.draftCards.find(
(card) => card.kind === 'world',
);
assert.ok(worldCard);
@@ -2526,7 +2696,8 @@ test('custom world agent update_draft_card action updates draft profile and card
assert.equal(cardDetailResponse.status, 200);
assert.ok(
cardDetailPayload.card.sections.some(
(section) => section.label === '标题' && section.value === '潮雾列岛·回潮版',
(section) =>
section.label === '标题' && section.value === '潮雾列岛·回潮版',
),
);
@@ -2547,11 +2718,7 @@ test('custom world agent generate_characters action appends character cards over
await withTestServer(
'custom-world-agent-phase4-generate-characters-http',
async ({ baseUrl, context }) => {
const entry = await authEntry(
baseUrl,
'cw_agent_p4_ch',
'secret123',
);
const entry = await authEntry(baseUrl, 'cw_agent_p4_ch', 'secret123');
const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl,
token: entry.token,
@@ -2621,7 +2788,8 @@ test('custom world agent generate_characters action appends character cards over
assert.equal(sessionResponse.status, 200);
assert.ok((sessionPayload.draftProfile?.storyNpcs?.length ?? 0) >= 2);
assert.ok(
sessionPayload.draftCards.filter((card) => card.kind === 'character').length >=
sessionPayload.draftCards.filter((card) => card.kind === 'character')
.length >=
baselineCharacterCount + 2,
);
assert.ok(sessionPayload.focusCardId);
@@ -2649,11 +2817,7 @@ test('custom world agent generate_landmarks action appends landmark cards over h
await withTestServer(
'custom-world-agent-phase4-generate-landmarks-http',
async ({ baseUrl, context }) => {
const entry = await authEntry(
baseUrl,
'cw_agent_p4_lm',
'secret123',
);
const entry = await authEntry(baseUrl, 'cw_agent_p4_lm', 'secret123');
const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl,
token: entry.token,
@@ -2723,7 +2887,8 @@ test('custom world agent generate_landmarks action appends landmark cards over h
assert.equal(sessionResponse.status, 200);
assert.ok((sessionPayload.draftProfile?.landmarks?.length ?? 0) >= 6);
assert.ok(
sessionPayload.draftCards.filter((card) => card.kind === 'landmark').length >=
sessionPayload.draftCards.filter((card) => card.kind === 'landmark')
.length >=
baselineLandmarkCount + 2,
);
assert.ok(sessionPayload.focusCardId);
@@ -3053,3 +3218,175 @@ test('runtime snapshot persistence returns hydrated snapshots for frontend resto
assert.equal(loadPayload.gameState.playerMaxHp, 170);
});
});
test('profile browse history supports batch sync, dedupe ordering, isolation and clear', async () => {
await withTestServer('profile-browse-history', async ({ baseUrl }) => {
const viewer = await authEntry(baseUrl, 'browse_viewer', 'secret123');
const author = await authEntry(baseUrl, 'browse_author', 'secret123');
const browseHistoryUrl = `${baseUrl}/api/runtime/profile/browse-history`;
const createResponse = await httpRequest(
browseHistoryUrl,
withBearer(viewer.token, {
method: 'POST',
body: JSON.stringify({
ownerUserId: author.user.id,
profileId: 'world-1',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '第一次浏览记录',
coverImageSrc: '/covers/world-1.png',
themeMode: 'tide',
authorDisplayName: '潮汐作者',
visitedAt: '2026-04-16T10:00:00.000Z',
}),
}),
);
const createPayload = (await createResponse.json()) as {
entries: Array<{
profileId: string;
}>;
};
assert.equal(createResponse.status, 200);
assert.deepEqual(
createPayload.entries.map((entry) => entry.profileId),
['world-1'],
);
const batchResponse = await httpRequest(
browseHistoryUrl,
withBearer(viewer.token, {
method: 'POST',
body: JSON.stringify({
entries: [
{
ownerUserId: author.user.id,
profileId: 'world-2',
worldName: '灰潮港',
subtitle: '海雾中的残灯码头',
summaryText: '第二条浏览记录',
coverImageSrc: '/covers/world-2.png',
themeMode: 'mythic',
authorDisplayName: '潮汐作者',
visitedAt: '2026-04-16T11:00:00.000Z',
},
{
ownerUserId: author.user.id,
profileId: 'world-1',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '第二次浏览后更新',
coverImageSrc: '/covers/world-1-updated.png',
themeMode: 'tide',
authorDisplayName: '潮汐作者',
visitedAt: '2026-04-16T12:00:00.000Z',
},
],
}),
}),
);
const batchPayload = (await batchResponse.json()) as {
entries: Array<{
profileId: string;
summaryText: string;
visitedAt: string;
}>;
};
assert.equal(batchResponse.status, 200);
assert.deepEqual(
batchPayload.entries.map((entry) => entry.profileId),
['world-1', 'world-2'],
);
assert.equal(batchPayload.entries[0]?.summaryText, '第二次浏览后更新');
assert.equal(
batchPayload.entries[0]?.visitedAt,
'2026-04-16T12:00:00.000Z',
);
const viewerHistoryResponse = await httpRequest(
browseHistoryUrl,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
const viewerHistoryPayload = (await viewerHistoryResponse.json()) as {
entries: Array<{
profileId: string;
}>;
};
assert.equal(viewerHistoryResponse.status, 200);
assert.deepEqual(
viewerHistoryPayload.entries.map((entry) => entry.profileId),
['world-1', 'world-2'],
);
const legacyViewerHistoryResponse = await httpRequest(
`${baseUrl}/api/profile/browse-history`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
const legacyViewerHistoryPayload =
(await legacyViewerHistoryResponse.json()) as {
entries: Array<{
profileId: string;
}>;
};
assert.equal(legacyViewerHistoryResponse.status, 200);
assert.deepEqual(
legacyViewerHistoryPayload.entries.map((entry) => entry.profileId),
['world-1', 'world-2'],
);
const authorHistoryResponse = await httpRequest(
browseHistoryUrl,
{
headers: {
Authorization: `Bearer ${author.token}`,
},
},
);
const authorHistoryPayload = (await authorHistoryResponse.json()) as {
entries: Array<unknown>;
};
assert.equal(authorHistoryResponse.status, 200);
assert.deepEqual(authorHistoryPayload.entries, []);
const clearResponse = await httpRequest(
browseHistoryUrl,
withBearer(viewer.token, {
method: 'DELETE',
}),
);
const clearPayload = (await clearResponse.json()) as {
entries: Array<unknown>;
};
assert.equal(clearResponse.status, 200);
assert.deepEqual(clearPayload.entries, []);
const clearedHistoryResponse = await httpRequest(
browseHistoryUrl,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
const clearedHistoryPayload = (await clearedHistoryResponse.json()) as {
entries: Array<unknown>;
};
assert.equal(clearedHistoryResponse.status, 200);
assert.deepEqual(clearedHistoryPayload.entries, []);
});
});