运行视图没有画面时自动补一次载入(进入项目/切项目的运行页空态)
- index.tsx:新增 autoRunPreviewProjectRef 记账,站在运行视图却没有可嵌入预览且可运行时自动补发一次 onPlay,同一个项目只补一次 - index.tsx:showRunView 先记账再发播放,避免点页签时被自动补发重复触发一次 - 用例:新增 runAutoLoadOnEnter(切项目自动补发一次、同项目不重复、点页签只发一次、有活体预览时不补发) - 文档:PRD 顶栏条款、验收用例 S17 判据、decision-log 同步自动载入口径
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user