进项目时按内存 registry 核对预览活体,陈旧记录不再冒充可运行预览
- 新增 features/app-shell/sessionPreview.ts 的 resolveSessionPreviewOnProjectOpen:进入项目时问一次 get_local_game_preview_status(内存 registry 是活体的唯一真相),确实 running 才把它作为本次会话的预览;记录写着 running 而 registry 没有活体时判为陈旧,复用现役 stop_local_game_preview 把落盘记录对齐成 stopped,并同时把会话 manifest 按真相投影成 stopped(否则运行入口会继续拿着打不开的 URL 渲染运行画面)。 - useHomeProjectCreation.ts:enterProjectDevelopment 改为 async,先做这次核对再进工作台,并按核对结果决定会话预览与 manifest 投影;三个调用点(新建项目、创建项目、打开项目)都改为 await。 - preview.rs:同一个项目重启预览时,旧 registry 身份的收尾不再覆盖刚落盘的 running——只有当旧预览属于**另一个项目**时才替它收尾。此前同项目重启会把刚写进去的 running 覆盖成 stopped,下次进项目便丢掉"预览在跑"这条事实(记录反方向失真)。 - preview.rs:stop_local_game_preview_for_root 在没有活体可停、但落盘记录仍是 running 时也把记录收尾成 stopped(stop 的语义变成"真的停干净,包括记录"),因此陈旧记录既可由进入项目触发对齐,也可由用户显式停止对齐。 - tests/project.rs:新增三条 Rust 定向用例——同项目重启后记录仍为 running 且端口是新预览的;换项目启动时旧项目记录写 stopped、新项目写 running;registry 无活体而记录写着 running 时 stop 会把记录对齐成 stopped。 - tests/sessionPreview.test.ts(新增):五条用例覆盖活体优先、陈旧记录对齐并投影、registry 不可读按没有在跑处理、记录本来不是 running 时不发多余停止命令、无 invoke 时零请求。变异验证:把活体核对去掉(直接信落盘记录)→「registry 里没有活体时不给会话预览」与「registry 不可读」两条立即红灯(已实测)。 - project-development.suite.ts:把「打开项目不查预览状态」那条断言改为「恰好核对一次预览活体」,并保留邻近的 start_local_game_preview 必须为 0 的断言——被新行为取代的是"从不核验","开项目不偷偷启动预览"这条原意一字未改。 - PRD §4.1:状态机按当前实现改写(进入项目一律落到 resource-overview;run 的自动进入只认内存 registry 的活体确认;落盘 preview 是记录而非活体,陈旧记录不触发切换也不参与渲染),并把退出收尾一并写进该节说明。
This commit is contained in:
@@ -758,7 +758,14 @@ pub(crate) fn start_local_game_preview_at_revision(
|
||||
}
|
||||
let (preview, previous_preview) = registry.set_running(preview, stop);
|
||||
if let Some(previous_preview) = previous_preview.as_ref() {
|
||||
record_replaced_preview_stop(previous_preview);
|
||||
// 同一个项目重启预览(换监听线程、换端口)时,旧的 registry 身份与新预览共享
|
||||
// 同一份落盘记录:上面刚写进去的是 running,这里若再用旧预览收尾,就会把它覆盖
|
||||
// 成 stopped,下次进项目便丢掉"预览在跑"这条事实。只有旧预览属于**另一个项目**
|
||||
// 时才替它收尾(那才是真的换项目)。
|
||||
let previous_status = local_preview_status_from_result(previous_preview);
|
||||
if ensure_preview_belongs_to_project(&previous_status, root).is_err() {
|
||||
record_replaced_preview_stop(previous_preview);
|
||||
}
|
||||
}
|
||||
if let Err(error) = append_preview_start_trace_step(root, &preview) {
|
||||
let _ = registry.stop();
|
||||
@@ -792,11 +799,23 @@ pub(crate) fn stop_local_game_preview_for_root(
|
||||
registry: &PreviewRegistry,
|
||||
) -> Result<LocalPreviewStatus, String> {
|
||||
let (status, stopped) = registry.stop_for_project(root);
|
||||
if let Some(root) = root.filter(|_| stopped) {
|
||||
record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?;
|
||||
append_preview_log(root, "stopped", None)?;
|
||||
append_preview_stop_trace_step(root)?;
|
||||
let Some(root) = root else {
|
||||
return Ok(status);
|
||||
};
|
||||
// 没有活体预览可停时,落盘记录仍可能是陈旧的 running:预览服务器是进程内线程、
|
||||
// 非 Linux 端口还是随机临时端口,客户端退出后记录会一直写着 running。这时把记录
|
||||
// 对齐成 stopped 才算真的"停"——否则下次进项目会照着陈旧记录渲染打不开的运行
|
||||
// 界面(进入项目时的活体核对是第二道保险,记录本身也该在自己的生命周期里结束)。
|
||||
let stale_record = !stopped
|
||||
&& read_manifest_for_project(root)?
|
||||
.preview
|
||||
.is_some_and(|preview| preview.status == GameCreationAppPreviewStatus::Running);
|
||||
if !stopped && !stale_record {
|
||||
return Ok(status);
|
||||
}
|
||||
record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?;
|
||||
append_preview_log(root, "stopped", None)?;
|
||||
append_preview_stop_trace_step(root)?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
|
||||
@@ -4735,6 +4735,87 @@ fn stale_preview_cleanup_cannot_be_blocked_by_project_stop_policy() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restarting_preview_for_the_same_project_keeps_the_running_record() {
|
||||
let root = unique_project_path();
|
||||
init_existing_html_project_at(&root, "project-1", "同项目重启预览记录测试")
|
||||
.expect("project init");
|
||||
let registry = PreviewRegistry::default();
|
||||
|
||||
let first = start_local_game_preview_at(&root, ®istry).expect("first preview start");
|
||||
let second = start_local_game_preview_at(&root, ®istry).expect("second preview start");
|
||||
assert_ne!(first.url, second.url, "重启必须换一个监听端口");
|
||||
|
||||
let manifest: Value =
|
||||
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
|
||||
.expect("manifest json");
|
||||
assert_eq!(
|
||||
manifest["preview"]["status"], "running",
|
||||
"同一个项目重启预览不能让旧预览的收尾把刚写进去的 running 覆盖成 stopped"
|
||||
);
|
||||
assert_eq!(manifest["preview"]["port"], second.port);
|
||||
|
||||
let _ = registry.stop();
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_preview_for_another_project_closes_the_previous_project_record() {
|
||||
let first_root = unique_project_path();
|
||||
let second_root = unique_project_path();
|
||||
init_existing_html_project_at(&first_root, "project-1", "旧项目预览记录测试")
|
||||
.expect("first project init");
|
||||
init_existing_html_project_at(&second_root, "project-2", "新项目预览记录测试")
|
||||
.expect("second project init");
|
||||
let registry = PreviewRegistry::default();
|
||||
|
||||
let _first = start_local_game_preview_at(&first_root, ®istry).expect("first preview");
|
||||
let second = start_local_game_preview_at(&second_root, ®istry).expect("second preview");
|
||||
|
||||
let previous_manifest: Value =
|
||||
serde_json::from_str(&fs::read_to_string(first_root.join(".agent/manifest.json")).unwrap())
|
||||
.expect("previous manifest json");
|
||||
assert_eq!(
|
||||
previous_manifest["preview"]["status"], "stopped",
|
||||
"换项目时旧项目的落盘记录必须收尾"
|
||||
);
|
||||
let next_manifest: Value = serde_json::from_str(
|
||||
&fs::read_to_string(second_root.join(".agent/manifest.json")).unwrap(),
|
||||
)
|
||||
.expect("next manifest json");
|
||||
assert_eq!(next_manifest["preview"]["status"], "running");
|
||||
assert_eq!(next_manifest["preview"]["port"], second.port);
|
||||
|
||||
let _ = registry.stop();
|
||||
fs::remove_dir_all(first_root).ok();
|
||||
fs::remove_dir_all(second_root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_without_a_live_preview_still_reconciles_a_stale_running_record() {
|
||||
let root = unique_project_path();
|
||||
init_existing_html_project_at(&root, "project-1", "陈旧记录对齐测试").expect("project init");
|
||||
// 模拟"上次留下的 running 记录":直接写记录,registry 里没有任何活体预览。
|
||||
record_preview_state(
|
||||
&root,
|
||||
GameCreationAppPreviewStatus::Running,
|
||||
Some("http://127.0.0.1:3210/".to_string()),
|
||||
Some(3210),
|
||||
)
|
||||
.expect("stale record");
|
||||
let registry = PreviewRegistry::default();
|
||||
|
||||
let status = stop_local_game_preview_for_root(Some(&root), ®istry).expect("reconcile");
|
||||
|
||||
assert_eq!(status, stopped_preview_status());
|
||||
let manifest: Value =
|
||||
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
|
||||
.expect("manifest json");
|
||||
assert_eq!(manifest["preview"]["status"], "stopped");
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_preview_serves_generated_playable_game() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { GameCreationAppPreviewState } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
|
||||
/**
|
||||
* 进入项目时的预览活体核验。
|
||||
*
|
||||
* 为什么不能直接信项目 manifest 里的 `preview`:那是"上次留下的一条记录",不是"现在
|
||||
* 有没有在跑"。本地预览服务器是**进程内线程**(`src-tauri/src/preview.rs` 的
|
||||
* `thread::spawn` + loopback listener),非 Linux 平台端口还是**随机临时端口**
|
||||
* (`bind_loopback_listener_with_linux_fallback` 里的 `TcpListener::bind(..., 0)`),
|
||||
* 客户端一退 URL 就永久失效;而落盘记录会一直写着 `running`。照它渲染,用户就会被直接
|
||||
* 丢进一个打不开的运行界面——这正是"有时候进项目看到的是运行界面"的成因。
|
||||
*
|
||||
* 所以进项目时问一次内存 registry(`get_local_game_preview_status`):
|
||||
* - 确实在跑 → 作为**本次会话的预览**交给工作台(工作台据此自动进运行视图);
|
||||
* - 没在跑但记录写着 running → 记录陈旧:把落盘记录对齐成 stopped(复用现役
|
||||
* `stop_local_game_preview`,它现在会把陈旧记录一并收尾),本次会话不带预览。
|
||||
*/
|
||||
export type SessionPreviewResolution = {
|
||||
/** 本次会话可用的预览:只有内存 registry 里真的在跑才有值。 */
|
||||
sessionPreview: GameCreationAppPreviewState | null;
|
||||
/**
|
||||
* 会话 manifest 里该投影成的预览状态;`null` 表示落盘记录与真相一致,不用改。
|
||||
*
|
||||
* 判定陈旧的同一刻就按真相投影(而不是等落盘写成功):否则一份写不进去的陈旧记录
|
||||
* (例如项目策略把 `preview.stop` 设成需确认)会让运行入口继续拿着一个打不开的 URL
|
||||
* 渲染运行画面。落盘那份由这次写入、退出收尾或下次进入时的核对负责收敛。
|
||||
*/
|
||||
manifestPreviewPatch: GameCreationAppPreviewState | null;
|
||||
};
|
||||
|
||||
/** 预览命令的最小 invoke 形状(与 App 里 `window.__TAURI__.core.invoke` 一致)。 */
|
||||
export type PreviewCommandInvoke = <T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<T>;
|
||||
|
||||
type LocalPreviewStatusLike = {
|
||||
status?: string | null;
|
||||
url?: string | null;
|
||||
port?: number | null;
|
||||
};
|
||||
|
||||
function livePreviewFromStatus(
|
||||
status: LocalPreviewStatusLike | null,
|
||||
): GameCreationAppPreviewState | null {
|
||||
const url = typeof status?.url === 'string' ? status.url.trim() : '';
|
||||
if (status?.status !== 'running' || !url) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
status: 'running',
|
||||
url,
|
||||
port: typeof status.port === 'number' ? status.port : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveSessionPreviewOnProjectOpen({
|
||||
invoke,
|
||||
projectPath,
|
||||
recordedPreview,
|
||||
}: {
|
||||
invoke: PreviewCommandInvoke | null | undefined;
|
||||
projectPath: string;
|
||||
recordedPreview: GameCreationAppPreviewState | null;
|
||||
}): Promise<SessionPreviewResolution> {
|
||||
const trimmedProjectPath = projectPath.trim();
|
||||
if (!invoke || !trimmedProjectPath) {
|
||||
return { sessionPreview: null, manifestPreviewPatch: null };
|
||||
}
|
||||
|
||||
let status: LocalPreviewStatusLike | null = null;
|
||||
try {
|
||||
status = await invoke<LocalPreviewStatusLike>(
|
||||
'get_local_game_preview_status',
|
||||
{ projectPath: trimmedProjectPath },
|
||||
);
|
||||
} catch {
|
||||
// 读不到 registry 就按"没有在跑"处理:宁可让用户点一下「运行」,也不要进一个
|
||||
// 打不开的运行界面。
|
||||
status = null;
|
||||
}
|
||||
const sessionPreview = livePreviewFromStatus(status);
|
||||
if (sessionPreview) {
|
||||
return { sessionPreview, manifestPreviewPatch: null };
|
||||
}
|
||||
|
||||
if (recordedPreview?.status !== 'running') {
|
||||
return { sessionPreview: null, manifestPreviewPatch: null };
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('stop_local_game_preview', {
|
||||
projectPath: trimmedProjectPath,
|
||||
});
|
||||
} catch {
|
||||
// 对齐落盘记录失败不改判:本次会话照样不把陈旧记录当活体用。
|
||||
}
|
||||
return {
|
||||
sessionPreview: null,
|
||||
manifestPreviewPatch: { status: 'stopped' },
|
||||
};
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
isAbsoluteProjectPath,
|
||||
projectPathHasControlCharacter,
|
||||
} from '../project-summary/projectSummary';
|
||||
import { resolveSessionPreviewOnProjectOpen } from './sessionPreview';
|
||||
|
||||
type UseHomeProjectCreationOptions = {
|
||||
setStatus: Dispatch<SetStateAction<string>>;
|
||||
@@ -175,15 +176,30 @@ export function useHomeProjectCreation({
|
||||
}
|
||||
}
|
||||
|
||||
function enterProjectDevelopment(context: LauncherProjectContext) {
|
||||
setCurrentProjectContext(context);
|
||||
async function enterProjectDevelopment(context: LauncherProjectContext) {
|
||||
/**
|
||||
* 进项目时**不**把 manifest 里的 `preview` 当成本次会话的预览:那是"上次留下的一条
|
||||
* 落盘记录",预览服务器是进程内线程、非 Linux 端口还是随机临时端口,客户端退出后
|
||||
* 记录仍会写着 running。会话预览只由活体确认(下一步的 registry 核对)或本次会话里
|
||||
* 的 preview.start / preview.status 写入,见 `activeProjectPreview` 的其它赋值点。
|
||||
* 会话预览只认"内存 registry 里真的还在跑"的那一个(见
|
||||
* `resolveSessionPreviewOnProjectOpen`)。项目 manifest 里的 `preview` 是**落盘记录**、
|
||||
* 不是活体:预览服务器是进程内线程、非 Linux 端口还是随机临时端口,客户端退出后记录
|
||||
* 仍会写着 running,照它自动进运行视图就是给用户一个打不开的运行界面。
|
||||
*/
|
||||
setActiveProjectPreview(null);
|
||||
const session = await resolveSessionPreviewOnProjectOpen({
|
||||
invoke: resolveTauriInvoke(),
|
||||
projectPath: context.projectPath,
|
||||
recordedPreview: context.manifest.preview ?? null,
|
||||
});
|
||||
setCurrentProjectContext(
|
||||
session.manifestPreviewPatch
|
||||
? {
|
||||
...context,
|
||||
manifest: {
|
||||
...context.manifest,
|
||||
preview: session.manifestPreviewPatch,
|
||||
},
|
||||
}
|
||||
: context,
|
||||
);
|
||||
setActiveProjectPreview(session.sessionPreview);
|
||||
setAgentRuntimeSummaries([]);
|
||||
setAgentResults([]);
|
||||
setProjectPath(context.projectPath);
|
||||
@@ -263,7 +279,7 @@ export function useHomeProjectCreation({
|
||||
result.projectPath,
|
||||
attachments,
|
||||
);
|
||||
enterProjectDevelopment({
|
||||
await enterProjectDevelopment({
|
||||
projectPath: result.projectPath,
|
||||
projectName:
|
||||
result.manifest.name || projectNameFromPath(result.projectPath),
|
||||
@@ -399,7 +415,7 @@ export function useHomeProjectCreation({
|
||||
},
|
||||
);
|
||||
setStatus('已创建项目');
|
||||
enterProjectDevelopment({
|
||||
await enterProjectDevelopment({
|
||||
projectPath: result.projectPath,
|
||||
projectName:
|
||||
result.manifest.name || projectNameFromPath(result.projectPath),
|
||||
@@ -484,7 +500,7 @@ export function useHomeProjectCreation({
|
||||
return;
|
||||
}
|
||||
setStatus('已打开项目');
|
||||
enterProjectDevelopment({
|
||||
await enterProjectDevelopment({
|
||||
projectPath: trimmedProjectPath,
|
||||
projectName:
|
||||
directoryStatus.projectName ||
|
||||
|
||||
@@ -6993,11 +6993,13 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
([command]) => command === 'chat_with_game_creator_direct_codex',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
// 进入项目时按内存 registry 核对一次预览活体(有活体才作为会话预览)。
|
||||
// 这里只核验、不启动:开始预览仍然只能由用户动作或 Agent 触发。
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'get_local_game_preview_status',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'start_local_game_preview',
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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 invoke = vi.fn(async (command: string) => {
|
||||
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.sessionPreview).toBeNull();
|
||||
expect(resolution.manifestPreviewPatch).toEqual({ status: 'stopped' });
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -143,11 +143,11 @@
|
||||
### 4.1 主视窗
|
||||
|
||||
```text
|
||||
resource-overview
|
||||
resource-overview(进入项目的初始态)
|
||||
-> resource-editor.derive(静态图片、SVG、视频、音频、文档/代码、Agent 回执“编辑资源”)
|
||||
-> resource-editor.version-branch(项目版本“编辑资源”)
|
||||
-> ui-editor(UI 原型与 UI 资源“编辑资源”)
|
||||
-> run(存在 runnableVersion 且 loopback preview 可启动)
|
||||
-> run(用户点运行入口/播放,或进入项目时内存 registry 确认预览仍在运行)
|
||||
|
||||
resource-editor.* | ui-editor
|
||||
-> resource-overview(取消或保存投影完成)
|
||||
@@ -162,7 +162,7 @@ run.paused
|
||||
-> resource-overview
|
||||
```
|
||||
|
||||
运行入口不可用时仍允许点击,显示“当前无可运行版本”,但不切换状态。素材创作的完整 opening/editing/generating/saving/cancelling/failed/recovering 状态、草稿身份和迟到结果门禁以 [`【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`](../technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md) 为准。
|
||||
进入项目时一律先落到 `resource-overview`。`run` 的自动进入只认内存 registry 的活体确认:只有本地预览服务器确实还在跑(`get_local_game_preview_status` 返回 `running`)才直接切过去。项目 manifest 里的 `preview` 是**落盘记录**而不是活体(预览服务器是进程内线程,非 Linux 平台端口还是随机临时端口),客户端退出后它会停留在 `running`;陈旧记录既不触发自动切换,也不允许被当成可运行预览渲染——进入项目时核对到记录陈旧就把它对齐成 `stopped`,客户端退出时也会统一停掉预览并收尾记录。运行入口不可用时仍允许点击,显示“当前无可运行版本”,但不切换状态。素材创作的完整 opening/editing/generating/saving/cancelling/failed/recovering 状态、草稿身份和迟到结果门禁以 [`【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`](../technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md) 为准。
|
||||
|
||||
### 4.2 测试切片
|
||||
|
||||
|
||||
Reference in New Issue
Block a user