Files
Genarrative/apps/ai-game-creator-shell/tests/launcherNoticeShellWiring.test.tsx
T
kdletters 1d6cbd4b67
Project CI / AI game creator shell Rust crates (push) Successful in 1m29s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / Backend tests (push) Successful in 3m57s
Project CI / Frontend tests (push) Successful in 2m0s
Project CI / Native shell tests (push) Successful in 6m5s
Project CI / Repository checks (push) Failing after 42s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m49s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m54s
Project CI / AI game creator shell web tests (push) Successful in 1m40s
外壳状态栏退役,状态改由浮层提示承载
- 首页输入框下方与项目组页面头部那行状态栏退役:status 不再由页面渲染,统一由外壳浮层承载(WorkspaceLauncher 合成粘性状态与一次性提示,渲染 notice ?? statusNotice)
- 进行中进度与失败结论走粘性浮层(sticky: true,不自动收起,切页回来仍可见);成功与取消回显走一次性浮层(成功/取消 2.6 秒、失败 6 秒收起),一次性提示优先显示,收起后粘性状态继续显示
- 粘性状态只挂首页与项目组两个页面,进项目工作台、模板库、帮助页不再飘在界面上;运行/预览提示与取消回显不受该限制
- RunNoticeToast/RunNotice 更名为 LauncherNoticeToast/LauncherNotice,新增 sticky 与 neutral 色调,挂点改为 data-launcher-notice-toast,长文案容器限宽 min(680px, 100vw - 2rem) 并换行
- 收起回调只从 ref 读,不再写进 effect 依赖:此前外壳每次重渲染都会换一个内联箭头函数,把收起计时重新起算,长驻页面上的提示可能永远收不回去
- useHomeProjectCreation 三处取消(非空文件夹确认弹窗、打开项目选目录、新建项目选目录)改为清空状态并弹一次性浮层回显「已取消」,不再往页面上那行写文字
- 删除 HomeView 的 status prop 与状态行节点、ProjectsPage 的 status prop 与 .launcher-project-page-status 节点及对应 CSS 规则
- launcherNoticeToast 用例补粘性不收起、父组件重渲染不重新计时、取消回显短时收起;launcherNoticeShellWiring 用例补进行中状态与取消回显都只走浮层、首页不留那一行、粘性状态不进项目工作台
- appSurface/home.suite 三处取消断言改断浮层挂点;homeWebPreflight 与 designProjectRestore 的 hook 夹具补 showTransientNotice
- 技术方案与 decision-log 记录该口径与边界:首页带按钮的「已创建的工作区」提示仍留在页面上
2026-09-29 19:35:49 +08:00

232 lines
7.4 KiB
TypeScript

/** @vitest-environment jsdom */
/**
* 浮层提示的外壳级接线:`ProjectChat` 发一条运行提示,工作台壳真的把它渲染成浮层;
* 原来那一行状态栏(进行中进度、失败结论、取消回显)也走同一条通道,页面上不留那一行。
*
* 单测(`launcherNoticeToast`)只证明组件本身,`previewActivation` 只证明 App 会调这条通道;
* 从「通道被调用」到「用户看到 toast」之间还差一层壳的接线(prop 名、handler、portal 挂点、
* tone 传递)。这里用替身聊天把这一层单独钉住:以后有人把 `<LauncherNoticeToast/>` 挪进条件
* 分支、或在传 prop 时写错名字,这条会红。
*/
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { ProjectChatComponentProps } from '../src/features/app-shell/model';
import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher';
import {
createProjectChatRuntimeHarness,
pickProjectFromLauncher,
testAuthUser,
} from './appSurface/harness';
const PROJECT_PATH = '/tmp/run-notice-shell-project';
function StubProjectChat() {
return null;
}
function StubRunNoticeChat({ onRunNotice }: ProjectChatComponentProps) {
return (
<div>
<button
type="button"
onClick={() =>
onRunNotice?.({ message: '运行通过,已载入客户端运行视图' })
}
>
触发成功提示
</button>
<button
type="button"
onClick={() =>
onRunNotice?.({
tone: 'error',
message: '运行游戏失败:预览端口被占用',
})
}
>
触发失败提示
</button>
</div>
);
}
/**
* 打开工作台所需的最小命令集照抄 `home.suite` 的活动清单用例:项目目录探测 + 清单 +
* 资源图 / 布局读回,其余命令沿用聊天 harness。
*/
function renderWorkbench() {
const manifest = createGameCreationAppManifest(
'run-notice-shell-project',
'运行提示接线项目',
);
const runtimeHarness = createProjectChatRuntimeHarness({
projectPath: PROJECT_PATH,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_design_agent_runtime_mode') return null;
if (command === 'inspect_local_project_directory') {
return {
projectPath: PROJECT_PATH,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: manifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: [],
referenceEdges: [],
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
return runtimeHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke: invoke as never },
event: { listen: runtimeHarness.listen as never },
};
render(
<WorkspaceLauncherShell
currentUser={testAuthUser}
initialView="projects"
onLogout={vi.fn()}
ProjectChat={StubRunNoticeChat}
/>,
);
pickProjectFromLauncher(PROJECT_PATH);
}
function toastElement() {
return document.querySelector('[data-launcher-notice-toast="true"]');
}
describe('浮层提示的外壳接线', () => {
it('成功提示渲染成浮层,且不落在对话容器里', async () => {
renderWorkbench();
await screen.findByLabelText('项目开发工作台');
fireEvent.click(
await screen.findByRole('button', { name: '触发成功提示' }),
);
await waitFor(() => expect(toastElement()).not.toBeNull());
expect(toastElement()?.textContent ?? '').toContain(
'运行通过,已载入客户端运行视图',
);
// 「对话区里没有这条提示」由 `previewActivation` 用真实聊天容器断言;这里用的替身
// 聊天没有消息列表,重复断言只会自证。
});
it('失败提示按 alert 渲染', async () => {
renderWorkbench();
await screen.findByLabelText('项目开发工作台');
fireEvent.click(
await screen.findByRole('button', { name: '触发失败提示' }),
);
await waitFor(() =>
expect(toastElement()?.querySelector('[role="alert"]')).not.toBeNull(),
);
expect(toastElement()?.textContent ?? '').toContain(
'运行游戏失败:预览端口被占用',
);
});
it('状态栏退役:进行中状态与取消回显都只走浮层,首页不留那一行', async () => {
let finishDirectoryPick: ((path: string | null) => void) | null = null;
const directoryPick = new Promise<string | null>((resolve) => {
finishDirectoryPick = resolve;
});
const runtimeHarness = createProjectChatRuntimeHarness({});
const invoke = vi.fn(async (command: string) => {
if (command === 'pick_local_project_directory') {
return directoryPick;
}
return runtimeHarness.invoke(command);
});
window.__TAURI__ = {
core: { invoke: invoke as never },
event: { listen: runtimeHarness.listen as never },
};
render(
<WorkspaceLauncherShell
currentUser={testAuthUser}
initialView="home"
onLogout={vi.fn()}
ProjectChat={StubProjectChat}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: '打开项目' }));
// 进行中状态:粘性浮层,页面上(以前那一行的位置)不留文字。
await waitFor(() =>
expect(toastElement()?.textContent ?? '').toContain('正在选择项目'),
);
expect(screen.getByLabelText('首页输入').textContent ?? '').not.toContain(
'正在选择项目',
);
// 取消:粘性状态被一次性回显替换掉,同样只在浮层里。
await act(async () => {
finishDirectoryPick?.(null);
await directoryPick;
});
await waitFor(() =>
expect(toastElement()?.textContent ?? '').toContain('已取消'),
);
expect(screen.getByLabelText('首页输入').textContent ?? '').not.toContain(
'已取消',
);
});
it('粘性状态只长在首页 / 项目组,进项目工作台不再飘在界面上', async () => {
renderWorkbench();
await screen.findByLabelText('项目开发工作台');
// 「打开项目」把状态写成「已打开项目」后立刻切到工作台:那一行原来只长在列表页上,
// 换成浮层也不能飘到工作台上来。
expect(toastElement()).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '首页' }));
await waitFor(() =>
expect(toastElement()?.textContent ?? '').toContain('已打开项目'),
);
});
});