Merge branch 'codex/backend-rewrite-spacetimedb' of http://82.157.175.59:3000/GenarrativeAI/Genarrative into codex/backend-rewrite-spacetimedb
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# API 错误 details.message 展示修复
|
||||
|
||||
## 背景
|
||||
|
||||
`POST /api/runtime/big-fish/agent/sessions/{sessionId}/actions` 在执行 `big_fish_publish_game` 时,Rust `api-server` 会把 SpacetimeDB 发布校验失败映射为统一 API envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "请求参数不合法",
|
||||
"details": {
|
||||
"message": "big_fish 发布校验未通过:还缺少 16 个基础动作",
|
||||
"provider": "spacetimedb"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其中 `error.message` 是通用错误分类文案,`error.details.message` 才是当前业务动作的可定位失败原因。前端通用请求解析此前只读取 `error.message`,导致界面只显示“请求参数不合法”。
|
||||
|
||||
## 落地口径
|
||||
|
||||
1. 所有通过 `parseApiErrorMessage(...)` 解析的 API 错误,优先展示 `error.details.message`。
|
||||
2. 当 `error.details.message` 不存在或为空时,再回退到 `error.message`。
|
||||
3. 当 envelope 外层也不存在有效文案时,继续沿用原有的顶层 `message`、错误码和原始响应兜底逻辑。
|
||||
4. `unwrapApiResponse(...)` 处理 `ok: false` envelope 时也复用同一优先级,避免成功响应解析路径和 HTTP 非 2xx 路径展示不一致。
|
||||
5. Big Fish 结果页发布失败属于阻断性动作错误,展示为独立模态窗口,不再挤在结果页内容流里,关闭后只清掉当前错误状态,不改变草稿与资源数据。
|
||||
|
||||
## 验收
|
||||
|
||||
1. `big_fish_publish_game` 返回发布校验失败时,界面应显示 `big_fish 发布校验未通过:还缺少 16 个基础动作`。
|
||||
2. 没有 `details.message` 的旧错误响应仍显示原 `error.message`。
|
||||
3. 非 JSON 错误响应仍显示原始响应文本。
|
||||
4. Big Fish 结果页错误以居中模态窗口展示,并可通过关闭按钮回到结果页继续补资源。
|
||||
@@ -241,6 +241,7 @@
|
||||
1. 展示会话、草稿、资产槽位、运行快照。
|
||||
2. 发送聊天、action 和摇杆输入。
|
||||
3. 根据后端 snapshot 渲染实体。
|
||||
4. 当后端 snapshot 返回 `won` 或 `failed` 时,必须在玩法舞台中央显示清晰结算浮层;通关与失败都不能只依赖顶部状态标签或事件日志。
|
||||
|
||||
前端禁止:
|
||||
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
2. 壳层文件内不再直接包含自动保存防抖实现。
|
||||
3. 壳层文件内不再直接包含 session -> result profile 编译细节。
|
||||
4. 壳层文件内不再直接处理 works/library/history/save 的多路请求编排。
|
||||
5. Agent 聊天工作区进入时必须如实展示后端 session 的 `progressPercent`。新会话后端初始值为 `0` 时,前端数字与进度条填充都必须保持 `0%`;只允许对大于 0 的值使用视觉最小宽度,避免用户误判聊天流程已经推进。
|
||||
|
||||
## 5.2 custom world 专属 client 拆分方案
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
2. 交换拼图块、拖动拼图块、关卡是否拼完,全部由前端本地计算。
|
||||
3. 本地运行态不调用 `/api/runtime/puzzle/runs/*` 写回当前过程状态。
|
||||
4. 关闭玩法后,这次运行态直接失效,不做断点续玩,不做跨端同步。
|
||||
5. 后端仍然负责:
|
||||
5. 通关后的第一版接续只保证单次游玩闭环:本地生成一个临时 `recommendedNextProfileId`,点击“下一关”后沿用当前作品图片、作者和标签,重建下一关棋盘;正式的广场推荐池仍留给后端运行态版本恢复。
|
||||
6. 后端仍然负责:
|
||||
- Agent 会话
|
||||
- 结果页草稿编译
|
||||
- 正式候选图生成
|
||||
@@ -47,6 +48,8 @@
|
||||
|
||||
不能继续写到仓库本地 `public/generated-puzzle-covers/*`。
|
||||
|
||||
这些路径只是前后端 DTO 里的兼容标识,不是浏览器可以直接裸读的公开资源地址。实际图片对象存放在私有 OSS 中,前端渲染前必须先通过 `/api/assets/read-url?legacyPublicPath=...` 换取签名读 URL;签名 URL 未返回或换签失败时,图片组件不能把 `/generated-puzzle-assets/*` 直接写入 `<img src>`,避免浏览器发起无签名、无鉴权请求。
|
||||
|
||||
### 4.2 运行态边界
|
||||
|
||||
第一版单机运行态保留现有 DTO 结构,目的是不重做界面层。
|
||||
@@ -55,7 +58,9 @@
|
||||
|
||||
1. 进入玩法时从作品详情构造本地 `run`
|
||||
2. 交换 / 拖动 / 通关时由前端工具函数返回新的 `run`
|
||||
3. 当前不依赖后端 `start/swap/drag/next-level` 接口完成主链
|
||||
3. 通关时本地写入临时下一关 id,用于显示“下一关”按钮
|
||||
4. 点击下一关时重置棋盘、推进关卡序号,并按已通关数量切换 `3x3 / 4x4`
|
||||
5. 当前不依赖后端 `start/swap/drag/next-level` 接口完成主链
|
||||
|
||||
## 5. 当前实现判断标准
|
||||
|
||||
@@ -65,4 +70,5 @@
|
||||
2. 返回路径切到 `/generated-puzzle-assets/*`。
|
||||
3. 未配置 DashScope 或 OSS 时,接口明确返回 provider 级错误,而不是静默回退占位图。
|
||||
4. 玩家进入拼图玩法后,即使后端运行态接口不可用,也能在本地完成交换与拖动。
|
||||
5. 关闭玩法后不保留当前 run 进度。
|
||||
5. 玩家完成整张图后能看到通关态与“下一关”入口,点击后进入新棋盘。
|
||||
6. 关闭玩法后不保留当前 run 进度。
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
## 文档列表
|
||||
|
||||
- [API_ERROR_DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md](./API_ERROR_DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md):记录统一 API envelope 错误展示优先读取 `error.details.message` 的修复口径,避免 Big Fish 发布校验等业务错误只显示通用“请求参数不合法”。
|
||||
- [BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md](./BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md):记录大鱼吃小鱼从固定摇杆改为屏幕首触点方向控制,并要求本地直达局在未操作时保持对象运动。
|
||||
- [RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md](./RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md):记录 `server-rs` 无参数 `cargo build` 链接 `spacetime-module` 失败的根因,并冻结默认只构建原生 `api-server`、模块产物继续走 `spacetime build` 的命令边界。
|
||||
- [BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md](./BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md):记录 `/big-fish` 大鱼吃小鱼玩法直达入口,明确复用现有 `BigFishRuntimeShell` 和本地占位运行态的调试边界。
|
||||
|
||||
@@ -143,7 +143,20 @@ export function unwrapApiResponse<T>(value: ApiResponse<T> | T): T {
|
||||
return value.data;
|
||||
}
|
||||
|
||||
throw new Error(value.error.message || '请求失败');
|
||||
throw new Error(getApiErrorDisplayMessage(value.error) || '请求失败');
|
||||
}
|
||||
|
||||
function readTrimmedMessage(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function getApiErrorDisplayMessage(error: ApiErrorPayload) {
|
||||
// 后端通用 message 常用于错误分类,details.message 才是给用户定位问题的业务原因。
|
||||
const detailMessage = isRecord(error.details)
|
||||
? readTrimmedMessage(error.details.message)
|
||||
: '';
|
||||
|
||||
return detailMessage || readTrimmedMessage(error.message);
|
||||
}
|
||||
|
||||
export function parseApiErrorMessage(rawText: string, fallbackMessage: string) {
|
||||
@@ -158,11 +171,20 @@ export function parseApiErrorMessage(rawText: string, fallbackMessage: string) {
|
||||
error?: {
|
||||
message?: string;
|
||||
code?: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
};
|
||||
message?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
const detailMessage = isRecord(parsed.error?.details)
|
||||
? readTrimmedMessage(parsed.error.details.message)
|
||||
: '';
|
||||
|
||||
if (detailMessage) {
|
||||
return detailMessage;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed.error?.message === 'string' &&
|
||||
parsed.error.message.trim()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
|
||||
@@ -146,4 +146,28 @@ describe('BigFishResultView', () => {
|
||||
expect(screen.getByAltText('荧潮幼体')).toBeTruthy();
|
||||
expect(screen.getByAltText('深海谜境 场地背景')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('shows publish failures in a dismissible modal', () => {
|
||||
const onDismissError = vi.fn();
|
||||
|
||||
render(
|
||||
<BigFishResultView
|
||||
session={createSession()}
|
||||
error="big_fish 发布校验未通过:还缺少 16 个基础动作"
|
||||
onBack={() => {}}
|
||||
onDismissError={onDismissError}
|
||||
onExecuteAction={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
expect(screen.getByText('发布失败')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('big_fish 发布校验未通过:还缺少 16 个基础动作'),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '知道了' }));
|
||||
expect(onDismissError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ type BigFishResultViewProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onDismissError?: () => void;
|
||||
onExecuteAction: (payload: ExecuteBigFishActionRequest) => void;
|
||||
onStartTestRun: () => void;
|
||||
};
|
||||
@@ -330,6 +331,7 @@ export function BigFishResultView({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onDismissError,
|
||||
onExecuteAction,
|
||||
onStartTestRun,
|
||||
}: BigFishResultViewProps) {
|
||||
@@ -417,12 +419,6 @@ export function BigFishResultView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[1.15rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<div className="min-h-0 overflow-y-auto pr-1">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@@ -520,6 +516,58 @@ export function BigFishResultView({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<BigFishResultErrorModal
|
||||
message={error}
|
||||
onClose={() => {
|
||||
onDismissError?.();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BigFishResultErrorModal({
|
||||
message,
|
||||
onClose,
|
||||
}: {
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[160] flex items-center justify-center bg-slate-950/58 px-4 py-6 backdrop-blur-sm">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="big-fish-result-error-title"
|
||||
className="w-full max-w-sm rounded-[1.6rem] border border-red-100/80 bg-white p-5 text-slate-950 shadow-2xl"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
|
||||
<Waves className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
id="big-fish-result-error-title"
|
||||
className="text-base font-black text-slate-950"
|
||||
>
|
||||
发布失败
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-5 inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,6 +108,24 @@ function resolveRuntimeEntityAsset(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSettlementCopy(run: BigFishRuntimeSnapshotResponse) {
|
||||
if (run.status === 'won') {
|
||||
return {
|
||||
title: '通关完成',
|
||||
message: `已成长到 Lv.${run.playerLevel},本轮生态征服完成。`,
|
||||
tone: 'from-emerald-300/28 via-cyan-300/18 to-white/10',
|
||||
};
|
||||
}
|
||||
if (run.status === 'failed') {
|
||||
return {
|
||||
title: '本轮失败',
|
||||
message: '己方鱼群已经耗尽,重新调整路线再来一次。',
|
||||
tone: 'from-rose-300/30 via-orange-300/16 to-white/10',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function BigFishEntityDot({
|
||||
entity,
|
||||
run,
|
||||
@@ -241,6 +259,7 @@ export function BigFishRuntimeShell({
|
||||
|
||||
const statusLabel =
|
||||
run.status === 'won' ? '通关' : run.status === 'failed' ? '失败' : '进行中';
|
||||
const settlementCopy = resolveSettlementCopy(run);
|
||||
const backgroundAsset =
|
||||
findBigFishAssetSlot(assetSlots, 'stage_background')?.assetUrl?.trim() || null;
|
||||
|
||||
@@ -298,6 +317,21 @@ export function BigFishRuntimeShell({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{settlementCopy ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center px-5">
|
||||
<div
|
||||
className={`w-full max-w-[20rem] rounded-[2rem] border border-white/24 bg-gradient-to-br ${settlementCopy.tone} p-6 text-center shadow-2xl shadow-slate-950/45 backdrop-blur-xl`}
|
||||
>
|
||||
<div className="text-3xl font-black tracking-[0.22em] text-white [text-shadow:0_2px_12px_rgba(2,6,23,0.6)]">
|
||||
{settlementCopy.title}
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-semibold leading-6 text-white/82">
|
||||
{settlementCopy.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="pointer-events-none absolute bottom-6 right-4 z-30 max-w-[13rem] space-y-2 text-right text-xs text-white/72">
|
||||
{isBusy ? <div>同步中...</div> : null}
|
||||
{error ? <div className="text-rose-200">{error}</div> : null}
|
||||
|
||||
@@ -32,6 +32,37 @@ function ensureScrollApis() {
|
||||
}
|
||||
}
|
||||
|
||||
test('creation agent workspace keeps initial chat progress at zero percent', () => {
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: null,
|
||||
currentTurn: 0,
|
||||
progressPercent: 0,
|
||||
anchors: [],
|
||||
messages: [],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const progressbar = screen.getByRole('progressbar');
|
||||
|
||||
expect(progressbar.getAttribute('aria-valuenow')).toBe('0');
|
||||
expect((progressbar.firstElementChild as HTMLElement | null)?.style.width).toBe(
|
||||
'0%',
|
||||
);
|
||||
});
|
||||
|
||||
test('creation agent workspace filters duplicate recommended replies', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
|
||||
@@ -311,6 +311,7 @@ export function CreationAgentWorkspace({
|
||||
}
|
||||
|
||||
const progress = normalizeCreationAgentProgress(session.progressPercent);
|
||||
const progressFillWidth = progress <= 0 ? '0%' : `${Math.max(6, progress)}%`;
|
||||
const hasHeroCopy = Boolean(session.title || session.assistantSummary);
|
||||
const canShowPrimaryAction = progress >= 100;
|
||||
const visibleQuickActions = quickActions.filter((action) =>
|
||||
@@ -414,10 +415,16 @@ export function CreationAgentWorkspace({
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-white/12">
|
||||
<div
|
||||
className="h-2 overflow-hidden rounded-full bg-white/12"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progress}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${theme.accentBgClass}`}
|
||||
style={{ width: `${Math.max(6, progress)}%` }}
|
||||
style={{ width: progressFillWidth }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-white/64">
|
||||
|
||||
@@ -1820,6 +1820,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
onBack={() => {
|
||||
setSelectionStage('big-fish-agent-workspace');
|
||||
}}
|
||||
onDismissError={() => {
|
||||
setBigFishError(null);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void executeBigFishAction(payload);
|
||||
}}
|
||||
|
||||
@@ -3,6 +3,29 @@ import {describe, expect, it} from 'vitest';
|
||||
import {parseApiErrorMessage} from './jsonClient';
|
||||
|
||||
describe('parseApiErrorMessage', () => {
|
||||
it('prefers api error detail messages for business failures', () => {
|
||||
expect(
|
||||
parseApiErrorMessage(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
data: null,
|
||||
error: {
|
||||
code: 'BAD_REQUEST',
|
||||
message: '请求参数不合法',
|
||||
details: {
|
||||
message: 'big_fish 发布校验未通过:还缺少 16 个基础动作',
|
||||
provider: 'spacetimedb',
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
'Fallback failure',
|
||||
),
|
||||
).toBe('big_fish 发布校验未通过:还缺少 16 个基础动作');
|
||||
});
|
||||
|
||||
it('prefers nested api error messages', () => {
|
||||
expect(
|
||||
parseApiErrorMessage(
|
||||
|
||||
108
src/hooks/useResolvedAssetReadUrl.test.tsx
Normal file
108
src/hooks/useResolvedAssetReadUrl.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { ResolvedAssetImage } from '../components/ResolvedAssetImage';
|
||||
import { clearStoredAccessToken, setStoredAccessToken } from '../services/apiClient';
|
||||
import { clearSignedAssetReadUrlCache } from '../services/assetReadUrlService';
|
||||
|
||||
describe('useResolvedAssetReadUrl', () => {
|
||||
beforeEach(() => {
|
||||
clearSignedAssetReadUrlCache();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
});
|
||||
|
||||
test('generated 私有资源签名完成前不会把裸路径写入 img', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey:
|
||||
'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
|
||||
signedUrl: 'https://signed.example.com/puzzle.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<ResolvedAssetImage
|
||||
src="/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
|
||||
alt="候选图"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('img', { name: '候选图' })).toBeNull();
|
||||
|
||||
const image = await screen.findByRole('img', { name: '候选图' });
|
||||
expect(image.getAttribute('src')).toBe('https://signed.example.com/puzzle.png');
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fpuzzle-session-1%2Fcandidate-1%2Fasset-1%2Fimage.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('generated 私有资源签名失败时保持空图像而不是回退裸路径', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
data: null,
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: '对象不存在',
|
||||
},
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<ResolvedAssetImage
|
||||
src="/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
|
||||
alt="候选图"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(screen.queryByRole('img', { name: '候选图' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,9 @@ export function useResolvedAssetReadUrl(
|
||||
const normalizedSource = source?.trim() ?? '';
|
||||
const shouldResolve =
|
||||
enabled && Boolean(normalizedSource) && isGeneratedLegacyPath(normalizedSource);
|
||||
const [resolvedUrl, setResolvedUrl] = useState(normalizedSource);
|
||||
const [resolvedUrl, setResolvedUrl] = useState(
|
||||
shouldResolve ? '' : normalizedSource,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedSource) {
|
||||
@@ -32,8 +34,8 @@ export function useResolvedAssetReadUrl(
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
// 生成资源的签名 URL 还没回来前,先保留原始路径占位,避免结果页/运行时首屏出现空白图块。
|
||||
setResolvedUrl(normalizedSource);
|
||||
// 生成资源通常是 OSS 私有对象;签名 URL 未就绪前不能把裸 generated 路径交给 img 触发无鉴权 GET。
|
||||
setResolvedUrl('');
|
||||
|
||||
void resolveAssetReadUrl(normalizedSource, {
|
||||
expireSeconds: options.expireSeconds,
|
||||
@@ -45,8 +47,8 @@ export function useResolvedAssetReadUrl(
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
// 读取签名失败时回退原始路径,至少保持现有 UI 可见错误表象。
|
||||
setResolvedUrl(normalizedSource);
|
||||
// 签名失败时保持空 src,避免继续请求无签名的私有对象兼容路径。
|
||||
setResolvedUrl('');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
69
src/services/puzzle-runtime/puzzleLocalRuntime.test.ts
Normal file
69
src/services/puzzle-runtime/puzzleLocalRuntime.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import {
|
||||
advanceLocalPuzzleLevel,
|
||||
dragLocalPuzzlePiece,
|
||||
startLocalPuzzleRun,
|
||||
} from './puzzleLocalRuntime';
|
||||
|
||||
const baseWork: PuzzleWorkSummary = {
|
||||
workId: 'work-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: null,
|
||||
authorDisplayName: '测试作者',
|
||||
levelName: '测试拼图',
|
||||
summary: '服务层测试用拼图。',
|
||||
themeTags: ['测试', '拼图'],
|
||||
coverImageSrc: '/generated-puzzle-assets/test.png',
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T00:00:00.000Z',
|
||||
publishedAt: '2026-04-25T00:00:00.000Z',
|
||||
playCount: 0,
|
||||
publishReady: true,
|
||||
};
|
||||
|
||||
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||
let nextRun = run;
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const currentLevel = nextRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status === 'cleared') {
|
||||
return nextRun;
|
||||
}
|
||||
|
||||
const misplacedPiece = currentLevel.board.pieces.find(
|
||||
(piece) =>
|
||||
piece.currentRow !== piece.correctRow ||
|
||||
piece.currentCol !== piece.correctCol,
|
||||
);
|
||||
if (!misplacedPiece) {
|
||||
return nextRun;
|
||||
}
|
||||
|
||||
nextRun = dragLocalPuzzlePiece(nextRun, {
|
||||
pieceId: misplacedPiece.pieceId,
|
||||
targetRow: misplacedPiece.correctRow,
|
||||
targetCol: misplacedPiece.correctCol,
|
||||
});
|
||||
}
|
||||
return nextRun;
|
||||
}
|
||||
|
||||
describe('puzzleLocalRuntime', () => {
|
||||
test('通关后提供下一关入口并能推进到新棋盘', () => {
|
||||
const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
|
||||
|
||||
expect(clearedRun.currentLevel?.status).toBe('cleared');
|
||||
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
|
||||
|
||||
const nextRun = advanceLocalPuzzleLevel(clearedRun);
|
||||
|
||||
expect(nextRun.currentLevelIndex).toBe(2);
|
||||
expect(nextRun.currentLevel?.status).toBe('playing');
|
||||
expect(nextRun.currentLevel?.levelName).toBe('测试拼图 · 第 2 关');
|
||||
expect(nextRun.currentLevel?.board.allTilesResolved).toBe(false);
|
||||
expect(nextRun.recommendedNextProfileId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -89,17 +89,66 @@ function applyNextBoard(
|
||||
return run;
|
||||
}
|
||||
const status = nextBoard.allTilesResolved ? 'cleared' : 'playing';
|
||||
const nextClearedLevelCount =
|
||||
status === 'cleared' && run.currentLevel.status !== 'cleared'
|
||||
? run.clearedLevelCount + 1
|
||||
: run.clearedLevelCount;
|
||||
return {
|
||||
...run,
|
||||
clearedLevelCount:
|
||||
status === 'cleared' && run.currentLevel.status !== 'cleared'
|
||||
? run.clearedLevelCount + 1
|
||||
: run.clearedLevelCount,
|
||||
clearedLevelCount: nextClearedLevelCount,
|
||||
currentLevel: {
|
||||
...run.currentLevel,
|
||||
board: nextBoard,
|
||||
status,
|
||||
},
|
||||
recommendedNextProfileId:
|
||||
status === 'cleared'
|
||||
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
|
||||
: run.recommendedNextProfileId,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLocalNextProfileId(entryProfileId: string, levelIndex: number) {
|
||||
return `${entryProfileId}::local-level-${levelIndex}`;
|
||||
}
|
||||
|
||||
// 第一版单机玩法没有后端推荐池,本地沿用当前作品图片并生成可推进的临时关卡名。
|
||||
function buildLocalLevelName(previousLevelName: string, levelIndex: number) {
|
||||
return `${previousLevelName.replace(/ · 第 \d+ 关$/, '')} · 第 ${levelIndex} 关`;
|
||||
}
|
||||
|
||||
// 本地运行态只保证单次游玩闭环:通关后立即重建下一关棋盘,不写回后端。
|
||||
function buildNextLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'cleared') {
|
||||
return run;
|
||||
}
|
||||
|
||||
const nextLevelIndex = run.currentLevelIndex + 1;
|
||||
const gridSize = resolvePuzzleGridSize(run.clearedLevelCount);
|
||||
const nextProfileId =
|
||||
run.recommendedNextProfileId ??
|
||||
buildLocalNextProfileId(run.entryProfileId, nextLevelIndex);
|
||||
|
||||
return {
|
||||
...run,
|
||||
currentLevelIndex: nextLevelIndex,
|
||||
currentGridSize: gridSize,
|
||||
playedProfileIds: run.playedProfileIds.includes(nextProfileId)
|
||||
? run.playedProfileIds
|
||||
: [...run.playedProfileIds, nextProfileId],
|
||||
previousLevelTags: currentLevel.themeTags,
|
||||
currentLevel: {
|
||||
...currentLevel,
|
||||
runId: run.runId,
|
||||
levelIndex: nextLevelIndex,
|
||||
gridSize,
|
||||
profileId: nextProfileId,
|
||||
levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex),
|
||||
board: buildInitialBoard(gridSize),
|
||||
status: 'playing',
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,5 +240,5 @@ export function dragLocalPuzzlePiece(
|
||||
}
|
||||
|
||||
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
return run;
|
||||
return buildNextLocalLevel(run);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user