refactor: 迁移拼图与跳跃 runtime 请求骨架

This commit is contained in:
2026-06-03 16:42:18 +08:00
parent ab49c32e33
commit 3783f0d2af
7 changed files with 284 additions and 152 deletions

View File

@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const apiClientMocks = vi.hoisted(() => ({
requestJson: vi.fn(),
}));
vi.mock('../apiClient', async () => {
const actual =
await vi.importActual<typeof import('../apiClient')>('../apiClient');
return {
...actual,
requestJson: apiClientMocks.requestJson,
};
});
import {
restartJumpHopRuntimeRun,
startJumpHopRuntimeRun,
submitJumpHopJump,
} from './jumpHopClient';
describe('jumpHopClient runtime requests', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(Date, 'now').mockReturnValue(1780000000000);
apiClientMocks.requestJson.mockResolvedValue({ runId: 'run-1' });
});
afterEach(() => {
vi.restoreAllMocks();
});
it('starts runs through the shared runtime request skeleton', async () => {
await startJumpHopRuntimeRun('profile/1', {
runtimeGuestToken: 'runtime-guest-token',
});
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/runtime/jump-hop/runs',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer runtime-guest-token',
},
body: JSON.stringify({ profileId: 'profile/1' }),
}),
'启动跳一跳运行态失败',
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
});
it('submits jump input with a generated client event id', async () => {
await submitJumpHopJump(
'run/1',
{ chargeMs: 320 },
{ runtimeGuestToken: 'runtime-guest-token' },
);
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/runtime/jump-hop/runs/run%2F1/jump',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer runtime-guest-token',
},
body: JSON.stringify({
chargeMs: 320,
clientEventId: 'jump-run/1-1780000000000',
}),
}),
'提交跳一跳起跳失败',
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
});
it('restarts runs with the same guest auth request skeleton', async () => {
await restartJumpHopRuntimeRun('run/1', {
runtimeGuestToken: 'runtime-guest-token',
});
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/runtime/jump-hop/runs/run%2F1/restart',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer runtime-guest-token',
},
body: JSON.stringify({
clientActionId: 'restart-run/1-1780000000000',
}),
}),
'重新开始跳一跳失败',
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
});
});

View File

@@ -12,8 +12,8 @@ import type {
JumpHopWorkDetailResponse,
JumpHopWorkMutationResponse,
JumpHopWorkProfileResponse,
JumpHopWorksResponse,
JumpHopWorkspaceCreateRequest,
JumpHopWorksResponse,
JumpHopWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import {
@@ -21,11 +21,8 @@ import {
requestJson,
} from '../apiClient';
import { createCreationAgentClient } from '../creation-agent';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
const JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions';
const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works';
@@ -51,8 +48,8 @@ export type {
JumpHopWorkDetailResponse,
JumpHopWorkMutationResponse,
JumpHopWorkProfileResponse,
JumpHopWorksResponse,
JumpHopWorkspaceCreateRequest,
JumpHopWorksResponse,
};
export type CreateJumpHopSessionRequest = {
themeText: string;
@@ -234,22 +231,13 @@ export async function startJumpHopRuntimeRun(
profileId: string,
options: JumpHopRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<JumpHopRunResponse>(
`${JUMP_HOP_RUNTIME_API_BASE}/runs`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify({ profileId }),
},
'启动跳一跳运行态失败',
{
...requestOptions,
},
);
return requestRuntimeJson<JumpHopRunResponse>({
url: buildRuntimeApiPath(JUMP_HOP_RUNTIME_API_BASE, 'runs'),
method: 'POST',
jsonBody: { profileId },
fallbackMessage: '启动跳一跳运行态失败',
requestOptions: options,
});
}
export async function submitJumpHopJump(
@@ -257,47 +245,38 @@ export async function submitJumpHopJump(
payload: { chargeMs: number },
options: JumpHopRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
const requestPayload = {
chargeMs: payload.chargeMs,
clientEventId: `jump-${runId}-${Date.now()}`,
};
return requestJson<JumpHopRunResponse>(
`${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/jump`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify(requestPayload),
},
'提交跳一跳起跳失败',
requestOptions,
);
return requestRuntimeJson<JumpHopRunResponse>({
url: buildRuntimeApiPath(JUMP_HOP_RUNTIME_API_BASE, 'runs', runId, 'jump'),
method: 'POST',
jsonBody: requestPayload,
fallbackMessage: '提交跳一跳起跳失败',
requestOptions: options,
});
}
export async function restartJumpHopRuntimeRun(
runId: string,
options: JumpHopRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<JumpHopRunResponse>(
`${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/restart`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify({
clientActionId: `restart-${runId}-${Date.now()}`,
}),
return requestRuntimeJson<JumpHopRunResponse>({
url: buildRuntimeApiPath(
JUMP_HOP_RUNTIME_API_BASE,
'runs',
runId,
'restart',
),
method: 'POST',
jsonBody: {
clientActionId: `restart-${runId}-${Date.now()}`,
},
'重新开始跳一跳失败',
requestOptions,
);
fallbackMessage: '重新开始跳一跳失败',
requestOptions: options,
});
}
export const jumpHopClient = {

View File

@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const apiClientMocks = vi.hoisted(() => ({
requestJson: vi.fn(),
}));
vi.mock('../apiClient', async () => {
const actual =
await vi.importActual<typeof import('../apiClient')>('../apiClient');
return {
...actual,
requestJson: apiClientMocks.requestJson,
};
});
import {
getPuzzleRun,
swapPuzzlePieces,
updatePuzzleRunPause,
} from './puzzleRuntimeClient';
describe('puzzleRuntimeClient', () => {
beforeEach(() => {
vi.clearAllMocks();
apiClientMocks.requestJson.mockResolvedValue({ runId: 'run-1' });
});
it('reads runs through the shared encoded runtime path', async () => {
await getPuzzleRun('run/1');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/runtime/puzzle/runs/run%2F1',
{ method: 'GET' },
'读取拼图运行快照失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1 }),
}),
);
});
it('submits puzzle swaps through the shared json request skeleton', async () => {
await swapPuzzlePieces('run/1', {
firstPieceId: 'piece-a',
secondPieceId: 'piece-b',
});
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/runtime/puzzle/runs/run%2F1/swap',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstPieceId: 'piece-a',
secondPieceId: 'piece-b',
}),
}),
'交换拼图块失败',
expect.objectContaining({
retry: expect.objectContaining({ retryUnsafeMethods: true }),
}),
);
});
it('keeps pause requests on account auth options instead of guest auth', async () => {
await updatePuzzleRunPause(
'run/1',
{ paused: true },
{
authImpact: 'local',
runtimeGuestToken: 'runtime-guest-token',
skipRefresh: true,
},
);
const [, init, , options] = apiClientMocks.requestJson.mock.calls[0];
expect(init).toEqual(
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paused: true }),
}),
);
expect(init.headers).not.toHaveProperty('Authorization');
expect(options).toEqual(
expect.objectContaining({
authImpact: 'local',
skipRefresh: true,
}),
);
expect(options).not.toMatchObject({ skipAuth: true });
});
});

View File

@@ -1,6 +1,6 @@
import type {
DragPuzzlePieceRequest,
AdvancePuzzleNextLevelRequest,
DragPuzzlePieceRequest,
PuzzleRunResponse,
StartPuzzleRunRequest,
SubmitPuzzleLeaderboardRequest,
@@ -12,11 +12,8 @@ import {
type ApiRetryOptions,
requestJson,
} from '../apiClient';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs';
const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
@@ -42,38 +39,25 @@ export async function startPuzzleRun(
payload: StartPuzzleRunRequest,
options: PuzzleRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<PuzzleRunResponse>(
PUZZLE_RUNTIME_API_BASE,
{
method: 'POST',
headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify(payload),
},
'启动拼图玩法失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
...requestOptions,
},
);
return requestRuntimeJson<PuzzleRunResponse>({
url: PUZZLE_RUNTIME_API_BASE,
method: 'POST',
jsonBody: payload,
fallbackMessage: '启动拼图玩法失败',
retry: PUZZLE_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
}
/**
* 读取拼图运行态快照。
*/
export async function getPuzzleRun(runId: string) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}`,
{
method: 'GET',
},
'读取拼图运行快照失败',
{
retry: PUZZLE_RUNTIME_READ_RETRY,
},
);
return requestRuntimeJson<PuzzleRunResponse>({
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId),
fallbackMessage: '读取拼图运行快照失败',
retry: PUZZLE_RUNTIME_READ_RETRY,
});
}
/**
@@ -83,18 +67,13 @@ export async function swapPuzzlePieces(
runId: string,
payload: SwapPuzzlePiecesRequest,
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/swap`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'交换拼图块失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
return requestRuntimeJson<PuzzleRunResponse>({
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'swap'),
method: 'POST',
jsonBody: payload,
fallbackMessage: '交换拼图块失败',
retry: PUZZLE_RUNTIME_WRITE_RETRY,
});
}
/**
@@ -104,18 +83,13 @@ export async function dragPuzzlePieceOrGroup(
runId: string,
payload: DragPuzzlePieceRequest,
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/drag`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'拖动拼图块失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
return requestRuntimeJson<PuzzleRunResponse>({
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'drag'),
method: 'POST',
jsonBody: payload,
fallbackMessage: '拖动拼图块失败',
retry: PUZZLE_RUNTIME_WRITE_RETRY,
});
}
/**
@@ -126,7 +100,6 @@ export async function advancePuzzleNextLevel(
payload: AdvancePuzzleNextLevelRequest = {},
options: PuzzleRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
const targetProfileId = payload.targetProfileId?.trim() ?? '';
const preferSimilarWork = payload.preferSimilarWork === true;
const requestPayload = {
@@ -134,27 +107,14 @@ export async function advancePuzzleNextLevel(
...(preferSimilarWork ? { preferSimilarWork: true } : {}),
};
const hasRequestPayload = Object.keys(requestPayload).length > 0;
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`,
{
method: 'POST',
...(hasRequestPayload
? {
headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify(requestPayload),
}
: {
headers: buildRuntimeGuestHeaders(options),
}),
},
'进入下一关失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
...requestOptions,
},
);
return requestRuntimeJson<PuzzleRunResponse>({
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'next-level'),
method: 'POST',
...(hasRequestPayload ? { jsonBody: requestPayload } : {}),
fallbackMessage: '进入下一关失败',
retry: PUZZLE_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
}
/**
@@ -165,22 +125,14 @@ export async function submitPuzzleLeaderboard(
payload: SubmitPuzzleLeaderboardRequest,
options: PuzzleRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/leaderboard`,
{
method: 'POST',
headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify(payload),
},
'提交拼图排行榜失败',
{
retry: PUZZLE_RUNTIME_LEADERBOARD_RETRY,
...requestOptions,
},
);
return requestRuntimeJson<PuzzleRunResponse>({
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'leaderboard'),
method: 'POST',
jsonBody: payload,
fallbackMessage: '提交拼图排行榜失败',
retry: PUZZLE_RUNTIME_LEADERBOARD_RETRY,
requestOptions: options,
});
}
/**
@@ -192,7 +144,7 @@ export async function updatePuzzleRunPause(
options: PuzzleRuntimeRequestOptions = {},
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/pause`,
buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'pause'),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -218,7 +170,7 @@ export async function usePuzzleRuntimeProp(
options: PuzzleRuntimeRequestOptions = {},
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/props`,
buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'props'),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },