45ae29461c
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m7s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m38s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m42s
Project CI / Frontend tests (pull_request) Successful in 3m37s
Project CI / Backend tests (pull_request) Successful in 8m12s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m54s
Project CI / Repository checks (pull_request) Successful in 6m58s
Project CI / Native shell tests (pull_request) Successful in 9m32s
- 面板形态:快照读取成功但没有在跑回合时不再渲染「未能读取正在运行的项目」,补回 !readFailed 守卫 - 发布门控:currentProjectPath / currentProjectName 只在 launcherView === 'project-development' 时发布,离开工作台按口径回落到最近启动的在跑项目 - 菜单头部只报运行中数量,读取失败结论由 role=status 一行独占,避免同一句连排两遍 - 标签提示拆成「作用域 + 运行状态」:回落态记为「最近运行的项目:X」、状态位「共 N 个项目在运行」,不再重复「正在运行」 - 数量徽标补 title 说明数字含义 - 用例:新增面板空态回归与两条视图门控回归,更新两处标签断言;三条定向文件 25 passed - 文档:决策日志补 2026-10-05 评审后续条目并修正实施计划交叉引用,里程碑补 2026-10-05 修订,实施计划与主规范同步口径
235 lines
7.4 KiB
TypeScript
235 lines
7.4 KiB
TypeScript
/** @vitest-environment jsdom */
|
|
import type { ReactNode } from 'react';
|
|
import { useCallback, useRef } from 'react';
|
|
import { vi } from 'vitest';
|
|
|
|
import type { LauncherProjectContext } from '../src/app/types';
|
|
import { WindowChrome } from '../src/components/WindowChrome';
|
|
import {
|
|
useWindowChrome,
|
|
type WindowChromeActiveProjectRuns,
|
|
WindowChromeContext,
|
|
} from '../src/components/windowChromeContext';
|
|
import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher';
|
|
import {
|
|
act,
|
|
createGameCreationAppManifest,
|
|
expect,
|
|
it,
|
|
React,
|
|
render,
|
|
testAuthUser,
|
|
} from './appSurface/harness';
|
|
import { resetTauriRuntime } from './tauriRuntimeFake';
|
|
|
|
const homeProjectOverride = vi.hoisted(() => ({
|
|
openProject: null as
|
|
| null
|
|
| ((path: string, mode: 'open' | 'create') => Promise<void>),
|
|
projectContext: null as LauncherProjectContext | null,
|
|
}));
|
|
|
|
vi.mock('../src/features/app-shell/useHomeProjectCreation', async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import('../src/features/app-shell/useHomeProjectCreation')
|
|
>('../src/features/app-shell/useHomeProjectCreation');
|
|
return {
|
|
...actual,
|
|
useHomeProjectCreation(
|
|
...args: Parameters<typeof actual.useHomeProjectCreation>
|
|
) {
|
|
const result = actual.useHomeProjectCreation(...args);
|
|
const withContext = homeProjectOverride.projectContext
|
|
? {
|
|
...result,
|
|
currentProjectContext: homeProjectOverride.projectContext,
|
|
setCurrentProjectContext: (() => undefined) as never,
|
|
}
|
|
: result;
|
|
return homeProjectOverride.openProject
|
|
? { ...withContext, openProject: homeProjectOverride.openProject }
|
|
: withContext;
|
|
},
|
|
};
|
|
});
|
|
|
|
it('真实窗口与工作台状态同步收敛,回调读取最新处理器且卸载才清理', async () => {
|
|
const publications: WindowChromeActiveProjectRuns[] = [];
|
|
let cleanups = 0;
|
|
// 仍经过真实 WindowChrome 的 setState/Context;上限只防止回归时测试无限循环。
|
|
function BoundedWindowBridge({ children }: { children: ReactNode }) {
|
|
const chrome = useWindowChrome();
|
|
const { setActiveProjectRuns } = chrome;
|
|
const publish = useCallback(
|
|
(next: WindowChromeActiveProjectRuns | null) => {
|
|
if (next) publications.push(next);
|
|
else cleanups += 1;
|
|
if (publications.length < 12) setActiveProjectRuns(next);
|
|
},
|
|
[setActiveProjectRuns],
|
|
);
|
|
return (
|
|
<WindowChromeContext.Provider
|
|
value={{ ...chrome, setActiveProjectRuns: publish }}
|
|
>
|
|
{children}
|
|
</WindowChromeContext.Provider>
|
|
);
|
|
}
|
|
const chat = () => null;
|
|
const view = (displayName = '测试用户') => (
|
|
<WindowChrome>
|
|
<BoundedWindowBridge>
|
|
<WorkspaceLauncherShell
|
|
currentUser={{ ...testAuthUser, displayName }}
|
|
onLogout={() => undefined}
|
|
initialView="projects"
|
|
ProjectChat={chat}
|
|
/>
|
|
</BoundedWindowBridge>
|
|
</WindowChrome>
|
|
);
|
|
resetTauriRuntime();
|
|
const rendered = render(view());
|
|
try {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
// 无原生 invoke 时空快照引用不变,只发布一次。
|
|
expect(publications).toHaveLength(1);
|
|
expect(cleanups).toBe(0);
|
|
expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe(
|
|
1,
|
|
);
|
|
const openProject = publications[0]!.onOpenProject!;
|
|
const latestOpen = vi.fn(async () => undefined);
|
|
homeProjectOverride.openProject = latestOpen;
|
|
rendered.rerender(view('更改显示名'));
|
|
// 非依赖变化的重渲染不能让窗口面板重新发布。
|
|
expect(publications).toHaveLength(1);
|
|
act(() => openProject('/tmp/window-latest-project'));
|
|
expect(latestOpen).toHaveBeenCalledWith(
|
|
'/tmp/window-latest-project',
|
|
'open',
|
|
);
|
|
// 回调执行只换 ref 里的实现,发布次数与清理次数都不能变。
|
|
expect(publications).toHaveLength(1);
|
|
expect(cleanups).toBe(0);
|
|
rendered.unmount();
|
|
expect(cleanups).toBe(1);
|
|
} finally {
|
|
homeProjectOverride.openProject = null;
|
|
rendered.unmount();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 只记录工作台发布给窗口标题栏的「当前项目」名;转发超过 12 次就不再往下传,
|
|
* 仅用于防止回归时测试进入无限 setState。
|
|
*/
|
|
function ProjectScopeBridge({
|
|
children,
|
|
record,
|
|
}: {
|
|
children: ReactNode;
|
|
record: (projectName: string | null) => void;
|
|
}) {
|
|
const chrome = useWindowChrome();
|
|
const { setActiveProjectRuns } = chrome;
|
|
const forwardCount = useRef(0);
|
|
const publish = useCallback(
|
|
(next: WindowChromeActiveProjectRuns | null) => {
|
|
if (next) record(next.currentProjectName ?? null);
|
|
if (forwardCount.current >= 12) return;
|
|
forwardCount.current += 1;
|
|
setActiveProjectRuns(next);
|
|
},
|
|
[record, setActiveProjectRuns],
|
|
);
|
|
return (
|
|
<WindowChromeContext.Provider
|
|
value={{ ...chrome, setActiveProjectRuns: publish }}
|
|
>
|
|
{children}
|
|
</WindowChromeContext.Provider>
|
|
);
|
|
}
|
|
|
|
/** 会话里已打开过项目:`currentProjectContext` 在离开工作台后不会被清空。 */
|
|
function openedProjectContext(): LauncherProjectContext {
|
|
return {
|
|
projectPath: '/tmp/window-scope-project',
|
|
projectName: '飞鸟 v2',
|
|
projectKind: 'web',
|
|
manifest: createGameCreationAppManifest('window-scope-project', '飞鸟 v2'),
|
|
projectRevision: null,
|
|
creationType: null,
|
|
startMode: null,
|
|
initialPrompt: '',
|
|
attachments: [],
|
|
recentRunStatus: null,
|
|
recentRunStopReason: null,
|
|
createdAt: 1,
|
|
};
|
|
}
|
|
|
|
function renderScopeRecordedAt(
|
|
initialView: 'projects' | 'project-development',
|
|
record: (projectName: string | null) => void,
|
|
) {
|
|
return render(
|
|
<WindowChrome>
|
|
<ProjectScopeBridge record={record}>
|
|
<WorkspaceLauncherShell
|
|
currentUser={testAuthUser}
|
|
onLogout={() => undefined}
|
|
initialView={initialView}
|
|
ProjectChat={() => null}
|
|
/>
|
|
</ProjectScopeBridge>
|
|
</WindowChrome>,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 回归:`currentProjectContext` 是会话级的(离开工作台不清空),发布时若照搬它,
|
|
* 首页 / 项目组 / 模板库上标签会一直写「当前项目:<上次打开的项目>」,徽标数字却属于
|
|
* 别的项目。只有工作台视图才认这个身份。
|
|
*/
|
|
it('不在工作台视图时不发布会话残留的项目身份', async () => {
|
|
const publishedNames: Array<string | null> = [];
|
|
homeProjectOverride.projectContext = openedProjectContext();
|
|
resetTauriRuntime();
|
|
const rendered = renderScopeRecordedAt('projects', (name) => {
|
|
publishedNames.push(name);
|
|
});
|
|
try {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(publishedNames.length).toBeGreaterThan(0);
|
|
expect(publishedNames.every((name) => name === null)).toBe(true);
|
|
} finally {
|
|
homeProjectOverride.projectContext = null;
|
|
rendered.unmount();
|
|
}
|
|
});
|
|
|
|
it('工作台视图里照常发布当前项目名', async () => {
|
|
const publishedNames: Array<string | null> = [];
|
|
homeProjectOverride.projectContext = openedProjectContext();
|
|
resetTauriRuntime();
|
|
const rendered = renderScopeRecordedAt('project-development', (name) => {
|
|
publishedNames.push(name);
|
|
});
|
|
try {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(publishedNames).toContain('飞鸟 v2');
|
|
} finally {
|
|
homeProjectOverride.projectContext = null;
|
|
rendered.unmount();
|
|
}
|
|
});
|