运行视图没有画面时自动补一次载入(进入项目/切项目的运行页空态)

- index.tsx:新增 autoRunPreviewProjectRef 记账,站在运行视图却没有可嵌入预览且可运行时自动补发一次 onPlay,同一个项目只补一次
- index.tsx:showRunView 先记账再发播放,避免点页签时被自动补发重复触发一次
- 用例:新增 runAutoLoadOnEnter(切项目自动补发一次、同项目不重复、点页签只发一次、有活体预览时不补发)
- 文档:PRD 顶栏条款、验收用例 S17 判据、decision-log 同步自动载入口径
This commit is contained in:
2026-10-04 15:56:41 +08:00
parent 0e75d132a7
commit 9eb537c6ff
5 changed files with 214 additions and 4 deletions
@@ -2816,6 +2816,29 @@ export default function ProjectDevelopmentView({
setRuntimeInspectMode(false);
}
}, [embeddedPreviewUrl, mode]);
/**
* 站在运行视图上却没有画面可看时,自动按一次「运行」。
*
* 「运行」页签就是播放入口,但**进入项目**这条路上用户可能一进来就站在运行页:从别的项目
* 切过来时工作台不重挂(`mode` 是工作台自己的 state),上一条项目的运行视图会原样留下,
* 画面却已经没了;此时页面上只有一句「点击顶部「运行」页签」,看起来就是坏的。这里把缺的那
* 一下自动补上——只在**没有画面**时发,画面还在就不重复启动。
*
* 记账用 ref 而不是 state:`showRunView`(用户点页签)会先记上再自己发一次播放,这里就不会
* 跟着重复发;同一个项目只自动发一次,失败后不打转,手动重跑仍走页签或画面上的刷新按钮。
*/
const autoRunPreviewProjectRef = useRef<string | null>(null);
useEffect(() => {
if (mode !== 'run' || embeddedPreviewUrl || !runAvailable || !onPlay) {
return;
}
if (autoRunPreviewProjectRef.current === projectPath) {
return;
}
autoRunPreviewProjectRef.current = projectPath;
onPlay();
}, [embeddedPreviewUrl, mode, onPlay, projectPath, runAvailable]);
const handleRuntimeInspectSelection = useCallback(
(selection: LocalGamePreviewInspectSelection) => {
const sourceName = selection.sourcePath?.split('/').pop()?.toLowerCase();
@@ -7369,6 +7392,9 @@ export default function ProjectDevelopmentView({
* (切视图 + 启动本地预览)。页签因此在不可运行时不置灰、只给提示(见 `run-unavailable-hint`),
* 点了不切视图也不发播放请求。
*
* 这里记一次账(`autoRunPreviewProjectRef`)再发播放:下一条 effect 会在「运行视图 + 没有
* 画面」时自动补发,不记账就会跟着重复发一次。
*
* 自动切运行那两条路径(会话内已确认的预览、播放请求)不走这里,它们只是**呈现**运行结果,
* 不该再发起一次播放。
*/
@@ -7385,6 +7411,7 @@ export default function ProjectDevelopmentView({
captureResourceSectionScrollPositions();
stopActiveCardMedia();
setMode('run');
autoRunPreviewProjectRef.current = projectPath;
onPlay?.();
}
@@ -0,0 +1,182 @@
/** @vitest-environment jsdom */
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createGameCreationAppManifest,
type GameCreationAppPreviewState,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import ProjectDevelopmentView from '../src/view/project-development';
import { installTauriRuntime } from './tauriRuntimeFake';
/**
* 「进入项目时已经站在运行页,却没有画面」要自动载入一次。
*
* 现场:从另一个项目切过来时工作台不重挂(`mode` 是工作台自己的 state),上一条项目留下的运行
* 视图会原样保留,画面却已经没了;用户看到的就是「客户端运行画面尚未载入 + 点击顶部「运行」
* 页签」,像坏了一样。
*/
const OFFLINE_PREVIEW: GameCreationAppPreviewState = {
status: 'running',
url: 'https://example.com/game',
port: 443,
};
/** 有完成的代码原型才算「可运行」——运行页签才会真的发播放请求。 */
function buildManifest(id: string, name: string) {
const manifest = createGameCreationAppManifest(id, name);
manifest.tasks = manifest.tasks.map((task) =>
task.id === 'code-prototype' ? { ...task, status: 'completed' } : task,
);
return manifest;
}
function installInvoke() {
installTauriRuntime({
core: {
invoke: vi.fn(async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: [],
referenceEdges: [],
taskFlows: [],
categories: [],
diagnostics: [],
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
throw new Error(`unexpected invoke ${command}`);
}),
},
});
}
function renderWorkbench({
projectPath,
name,
preview = OFFLINE_PREVIEW,
onPlay,
}: {
projectPath: string;
name: string;
preview?: GameCreationAppPreviewState;
onPlay: () => void;
}) {
installInvoke();
return render(
<ProjectDevelopmentView
projectName={name}
projectPath={projectPath}
manifest={buildManifest('run-auto-load', name)}
attachments={[]}
recentRunStatus="completed"
recentRunStopReason={null}
preview={preview}
chat={null}
onHomeOpen={vi.fn()}
onProjectsOpen={vi.fn()}
onNotice={vi.fn()}
onPlay={onPlay}
/>,
);
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe('运行视图没有画面时的自动载入', () => {
it('切到另一个项目时自动补一次播放,同一个项目不重复发', async () => {
const onPlay = vi.fn();
const view = renderWorkbench({
projectPath: '/tmp/project-a',
name: '项目 A',
onPlay,
});
// 先在「项目 A」上把视图切到运行(没有可嵌入的预览:切过去只有空态)。
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
await screen.findByText('客户端运行画面尚未载入');
expect(onPlay).toHaveBeenCalledTimes(1);
// 工作台不重挂,只换项目:运行视图原样留下,画面是空的 → 自动补一次。
view.rerender(
<ProjectDevelopmentView
projectName="项目 B"
projectPath="/tmp/project-b"
manifest={buildManifest('run-auto-load-b', '项目 B')}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
preview={OFFLINE_PREVIEW}
chat={null}
onHomeOpen={vi.fn()}
onProjectsOpen={vi.fn()}
onNotice={vi.fn()}
onPlay={onPlay}
/>,
);
expect(onPlay).toHaveBeenCalledTimes(2);
// 同一个项目再渲染一次(manifest 刷新、对话更新之类)不该反复发播放请求。
view.rerender(
<ProjectDevelopmentView
projectName="项目 B"
projectPath="/tmp/project-b"
manifest={buildManifest('run-auto-load-b', '项目 B 更新')}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
preview={OFFLINE_PREVIEW}
chat={null}
onHomeOpen={vi.fn()}
onProjectsOpen={vi.fn()}
onNotice={vi.fn()}
onPlay={onPlay}
/>,
);
expect(onPlay).toHaveBeenCalledTimes(2);
});
it('点「运行」页签本身只发一次播放,不被自动载入重复触发', async () => {
const onPlay = vi.fn();
renderWorkbench({
projectPath: '/tmp/project-a',
name: '项目 A',
onPlay,
});
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
expect(onPlay).toHaveBeenCalledTimes(1);
});
it('有活体预览时不补发:画面已经在,没必要再启动一次', async () => {
const onPlay = vi.fn();
renderWorkbench({
projectPath: '/tmp/project-a',
name: '项目 A',
preview: {
status: 'running',
url: 'http://127.0.0.1:4173/',
port: 4173,
},
onPlay,
});
// 会话内确认过的预览会自动切到运行视图;此时画面在,谁都不该发播放请求。
await screen.findByTitle('项目 A 游戏运行画面');
expect(onPlay).not.toHaveBeenCalled();
});
});