@@ -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, []);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user