d04fe83f10
- App.tsx:`conversation.write` 策略确认后的重跑改为整体复用首轮入参(`directCodexPolicyRetryInput`),不再手写字段,修掉漏传 `references` 导致 @ 引用被静默丢弃的缺陷 - resourceReferences.ts:运行画面引用的判别指纹补上 `resourceIds`(排序集合口径)/`versionId`/`elementRole`/`width`/`height`,避免不同选点撞 key 被 `dedupeChatReferences` 去重丢掉 - ResourceReferenceInput.tsx:程序化重建编辑器内容改为把 chip 内联嵌在文本里的同名 token 位置(不再另起一段堆在末尾),并把 `lastEmittedDraftRef` 记为「编辑器实际读回来的草稿」而不是 props,消除每轮多一段 `@显示名` 的渲染循环 - ResourceReferenceInput.tsx:候选浮层(候选菜单 / 素材选择器)打开时 Enter 不再提交表单,把按键让给 `LexicalTypeaheadMenuPlugin` - ResourceReferenceInput.tsx:`assets` / `currentVersionAssetReferences` 的 memo 依赖改用内容签名;预览请求在换素材 / 卸载时主动作废上一个 scope;空态文案的嵌套三元抽成 `pickerEmptyMessage` - usePromptPolish.ts:`polish()` 补 `catch`,注入式 `requestPolish` 拒绝时给出可见失败提示而不是未处理拒绝;`reset()` 递增请求代次作废在飞请求 - ChatPromptPolishReminder.tsx:章节级 Esc 分支补 `busy` 判据,与关闭按钮 / 遮罩同一口径(在飞不许关面板) - LocalGamePreviewFrame.tsx:资源 id 改用专用净化(允许 `:` 与可打印非 ASCII),修掉 `local-asset:<id>`、`persisted-角色草图.png` 被整条过滤导致引用丢素材关联 - sessionPreview.ts:`get_local_game_preview_status` 读失败不再当作「确认没有在跑」去发停止命令,避免停掉真在跑的活体预览 - useHomeProjectCreation.ts:进项目流程加代次令牌,慢请求后到不再覆盖新项目 - WorkspaceLauncher.tsx:拒收恢复的阶段回写按 `projectId`/`heldRevision`/`snapshotRevision` 关联,不再把恢复结果盖到后续新拒收的提示上 - 用例:`directCodexPolicyRetryInput` 透传、引用指纹区分度、重建不循环、浮层打开时 Enter 不提交、润色拒绝 / reset 作废、预览读失败不停预览
211 lines
7.0 KiB
TypeScript
211 lines
7.0 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { GameCreationAppPreviewState } from '../../../packages/shared/src/contracts/gameCreationApp';
|
|
import { resolveSessionPreviewOnProjectOpen } from '../src/features/app-shell/sessionPreview';
|
|
|
|
const projectPath = '/tmp/session-preview-project';
|
|
|
|
function createInvoke(
|
|
responses: Record<string, unknown>,
|
|
calls: Array<[string, Record<string, unknown> | undefined]> = [],
|
|
) {
|
|
return vi.fn(async (command: string, args?: Record<string, unknown>) => {
|
|
calls.push([command, args]);
|
|
if (!(command in responses)) {
|
|
throw new Error(`unexpected invoke ${command}`);
|
|
}
|
|
return responses[command];
|
|
});
|
|
}
|
|
|
|
describe('进入项目时的预览活体核验', () => {
|
|
it('registry 里没有活体时不给会话预览,并把陈旧的 running 记录对齐成 stopped', async () => {
|
|
const calls: Array<[string, Record<string, unknown> | undefined]> = [];
|
|
const invoke = createInvoke(
|
|
{
|
|
get_local_game_preview_status: {
|
|
status: 'stopped',
|
|
url: null,
|
|
port: null,
|
|
root: null,
|
|
},
|
|
stop_local_game_preview: {
|
|
status: 'stopped',
|
|
url: null,
|
|
port: null,
|
|
root: null,
|
|
},
|
|
},
|
|
calls,
|
|
);
|
|
|
|
const resolution = await resolveSessionPreviewOnProjectOpen({
|
|
invoke,
|
|
projectPath,
|
|
recordedPreview: {
|
|
status: 'running',
|
|
url: 'http://127.0.0.1:4173/',
|
|
port: 4173,
|
|
},
|
|
});
|
|
|
|
// 没有活体预览:本次会话不带预览(工作台因此不会自动进运行视图)。
|
|
expect(resolution.sessionPreview).toBeNull();
|
|
// 落盘记录被判陈旧,会话 manifest 立刻按真相投影。
|
|
expect(resolution.manifestPreviewPatch).toEqual({ status: 'stopped' });
|
|
expect(calls).toEqual([
|
|
['get_local_game_preview_status', { projectPath }],
|
|
['stop_local_game_preview', { projectPath }],
|
|
]);
|
|
});
|
|
|
|
it('registry 确实在跑时只认活体,且不写落盘记录', async () => {
|
|
const calls: Array<[string, Record<string, unknown> | undefined]> = [];
|
|
const invoke = createInvoke(
|
|
{
|
|
get_local_game_preview_status: {
|
|
status: 'running',
|
|
url: 'http://127.0.0.1:5210/',
|
|
port: 5210,
|
|
root: projectPath,
|
|
},
|
|
},
|
|
calls,
|
|
);
|
|
|
|
const resolution = await resolveSessionPreviewOnProjectOpen({
|
|
invoke,
|
|
projectPath,
|
|
// 落盘记录落后于真相(例如同项目重启后记录没跟上)也不影响:活体说了算。
|
|
recordedPreview: { status: 'stopped' },
|
|
});
|
|
|
|
expect(resolution.sessionPreview).toEqual({
|
|
status: 'running',
|
|
url: 'http://127.0.0.1:5210/',
|
|
port: 5210,
|
|
});
|
|
expect(resolution.manifestPreviewPatch).toBeNull();
|
|
expect(calls).toEqual([['get_local_game_preview_status', { projectPath }]]);
|
|
});
|
|
|
|
it('registry 不可读时不顺手停预览:读失败不等于确认没有在跑', async () => {
|
|
const calls: Array<[string, Record<string, unknown> | undefined]> = [];
|
|
const invoke = vi.fn(
|
|
async (command: string, args?: Record<string, unknown>) => {
|
|
calls.push([command, args]);
|
|
if (command === 'get_local_game_preview_status') {
|
|
throw new Error('需要在陶泥儿客户端内运行');
|
|
}
|
|
return { status: 'stopped', url: null, port: null, root: null };
|
|
},
|
|
);
|
|
|
|
const resolution = await resolveSessionPreviewOnProjectOpen({
|
|
invoke,
|
|
projectPath,
|
|
recordedPreview: {
|
|
status: 'running',
|
|
url: 'http://127.0.0.1:4173/',
|
|
port: 4173,
|
|
},
|
|
});
|
|
|
|
// 本次会话不带预览:落盘记录有可能确实是陈旧的,但读失败证明不了这一点。
|
|
expect(resolution).toEqual({
|
|
sessionPreview: null,
|
|
manifestPreviewPatch: null,
|
|
});
|
|
// 关键:不许发 stop_local_game_preview —— 它按项目停的是活体预览,
|
|
// 读失败时发出去会把真正在跑的预览一起停掉。
|
|
expect(calls).toEqual([['get_local_game_preview_status', { projectPath }]]);
|
|
});
|
|
|
|
it('记录本来就不是 running 时只读活体,不发多余的停止命令', async () => {
|
|
const calls: Array<[string, Record<string, unknown> | undefined]> = [];
|
|
const invoke = createInvoke(
|
|
{
|
|
get_local_game_preview_status: {
|
|
status: 'stopped',
|
|
url: null,
|
|
port: null,
|
|
root: null,
|
|
},
|
|
},
|
|
calls,
|
|
);
|
|
|
|
const stoppedRecord: GameCreationAppPreviewState = { status: 'stopped' };
|
|
const resolution = await resolveSessionPreviewOnProjectOpen({
|
|
invoke,
|
|
projectPath,
|
|
recordedPreview: stoppedRecord,
|
|
});
|
|
|
|
expect(resolution).toEqual({
|
|
sessionPreview: null,
|
|
manifestPreviewPatch: null,
|
|
});
|
|
expect(calls).toEqual([['get_local_game_preview_status', { projectPath }]]);
|
|
});
|
|
|
|
it('没有 invoke(浏览器里跑)时不做任何请求', async () => {
|
|
const resolution = await resolveSessionPreviewOnProjectOpen({
|
|
invoke: null,
|
|
projectPath,
|
|
recordedPreview: {
|
|
status: 'running',
|
|
url: 'http://127.0.0.1:4173/',
|
|
port: 4173,
|
|
},
|
|
});
|
|
|
|
expect(resolution).toEqual({
|
|
sessionPreview: null,
|
|
manifestPreviewPatch: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('本地预览记录的生命周期(Rust 侧结构性守卫)', () => {
|
|
const mainSource = readFileSync(
|
|
resolve(process.cwd(), 'apps/ai-game-creator-shell/src-tauri/src/main.rs'),
|
|
'utf8',
|
|
);
|
|
const previewSource = readFileSync(
|
|
resolve(
|
|
process.cwd(),
|
|
'apps/ai-game-creator-shell/src-tauri/src/preview.rs',
|
|
),
|
|
'utf8',
|
|
);
|
|
|
|
it('客户端退出的 RunEvent::Exit 分支必须接上预览收尾', () => {
|
|
// 退出路径是"记录与真相不再分叉"的主要保证:进程内监听线程随进程消失,而落盘记录
|
|
// 会留在 running 上。这条接线没有别的行为测试覆盖(注册全局 registry 的用例会互相
|
|
// 打架),所以按本仓既有做法直接钉源码结构。
|
|
const exitBranch =
|
|
/matches!\(event, tauri::RunEvent::Exit\)\s*\{([\s\S]*?)\n {8}\}/u.exec(
|
|
mainSource,
|
|
)?.[1] ?? '';
|
|
expect(exitBranch).toContain('preview::stop_local_game_preview_on_exit');
|
|
});
|
|
|
|
it('同一个项目重启预览时,旧预览身份不得收尾新写的 running 记录', () => {
|
|
// record_replaced_preview_stop 只允许在旧预览属于别的项目时调用:同项目重启时它会把
|
|
// 刚写进去的 running 覆盖成 stopped。
|
|
const replaceBranch =
|
|
/let \(preview, previous_preview\) = registry\.set_running\(preview, stop\);([\s\S]*?)\n {4}\}/u.exec(
|
|
previewSource,
|
|
)?.[1] ?? '';
|
|
expect(replaceBranch).toContain('ensure_preview_belongs_to_project');
|
|
expect(replaceBranch).toContain('record_replaced_preview_stop');
|
|
expect(
|
|
replaceBranch.indexOf('ensure_preview_belongs_to_project'),
|
|
).toBeLessThan(replaceBranch.indexOf('record_replaced_preview_stop'));
|
|
});
|
|
});
|