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 修订,实施计划与主规范同步口径
447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
import {
|
|
act,
|
|
cleanup,
|
|
fireEvent,
|
|
render,
|
|
renderHook,
|
|
screen,
|
|
waitFor,
|
|
} from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { GameCreatorDirectActiveTurn } from '../src/app/types';
|
|
import {
|
|
DIRECT_ACTIVE_TURNS_CHANGED_EVENT,
|
|
useDirectActiveTurns,
|
|
} from '../src/features/agent-runtime/directActiveTurns';
|
|
import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel';
|
|
import { createTauriEventFake, type TauriEventFake } from './tauriEventFake';
|
|
|
|
let eventFake: TauriEventFake | null = null;
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
eventFake?.restore();
|
|
eventFake = null;
|
|
});
|
|
|
|
const ACTIVE_TURN: GameCreatorDirectActiveTurn = {
|
|
projectPath: 'C:/projects/demo',
|
|
projectName: '示例项目',
|
|
turnId: 'turn-1',
|
|
startedAt: 1,
|
|
status: 'running',
|
|
updatedAt: 2,
|
|
sequence: 1,
|
|
};
|
|
|
|
describe('useDirectActiveTurns', () => {
|
|
it('keeps the snapshot identity when an event-triggered read returns the same content', async () => {
|
|
// 回归点:事件重复或内容未变时不能换掉数组身份,避免窗口标题栏重新发布。
|
|
const invoke = vi.fn(async () => [ACTIVE_TURN]) as never;
|
|
const { result } = renderHook(() =>
|
|
useDirectActiveTurns({
|
|
invoke,
|
|
enabled: true,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeTurns).toHaveLength(1);
|
|
});
|
|
const firstSnapshot = result.current.activeTurns;
|
|
|
|
await act(async () => {
|
|
await result.current.refreshActiveTurns();
|
|
await result.current.refreshActiveTurns();
|
|
});
|
|
expect(result.current.activeTurns).toBe(firstSnapshot);
|
|
});
|
|
|
|
it('subscribes before the first snapshot and does not read while quiet', async () => {
|
|
const invoke = vi.fn<
|
|
[command: string],
|
|
Promise<GameCreatorDirectActiveTurn[]>
|
|
>(async () => [ACTIVE_TURN]);
|
|
eventFake = createTauriEventFake({ invoke });
|
|
eventFake.install();
|
|
const setIntervalSpy = vi.spyOn(window, 'setInterval');
|
|
const { result } = renderHook(() =>
|
|
useDirectActiveTurns({ invoke: invoke as never, enabled: true }),
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
eventFake?.listenInvokeCounts.get(DIRECT_ACTIVE_TURNS_CHANGED_EVENT),
|
|
).toBe(1),
|
|
);
|
|
const listReads = () =>
|
|
invoke.mock.calls.filter(
|
|
([command]) => command === 'list_game_creator_direct_active_turns',
|
|
).length;
|
|
await waitFor(() => expect(result.current.activeTurns).toHaveLength(1));
|
|
expect(listReads()).toBe(1);
|
|
// The native event registration is already in place before the first list read;
|
|
// the fake keeps the callback eval pending until we explicitly flush it.
|
|
expect(eventFake.pendingRegistrationEvals()).toBe(1);
|
|
eventFake.flushRegistrationEvals();
|
|
expect(setIntervalSpy.mock.calls.some(([, delay]) => delay === 5_000)).toBe(
|
|
false,
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(listReads()).toBe(1);
|
|
setIntervalSpy.mockRestore();
|
|
});
|
|
|
|
it('coalesces repeated in-flight notifications into one follow-up read', async () => {
|
|
let resolveFirst!: (turns: GameCreatorDirectActiveTurn[]) => void;
|
|
const first = new Promise<GameCreatorDirectActiveTurn[]>((resolve) => {
|
|
resolveFirst = resolve;
|
|
});
|
|
const invoke = vi
|
|
.fn()
|
|
.mockReturnValueOnce(first)
|
|
.mockResolvedValue([ACTIVE_TURN]);
|
|
eventFake = createTauriEventFake({ invoke });
|
|
eventFake.install();
|
|
renderHook(() =>
|
|
useDirectActiveTurns({ invoke: invoke as never, enabled: true }),
|
|
);
|
|
await waitFor(() =>
|
|
expect(
|
|
eventFake?.listenInvokeCounts.get(DIRECT_ACTIVE_TURNS_CHANGED_EVENT),
|
|
).toBe(1),
|
|
);
|
|
eventFake.flushRegistrationEvals();
|
|
await waitFor(() => expect(invoke).toHaveBeenCalledTimes(1));
|
|
|
|
eventFake.emit(DIRECT_ACTIVE_TURNS_CHANGED_EVENT, { revision: 1 });
|
|
eventFake.emit(DIRECT_ACTIVE_TURNS_CHANGED_EVENT, { revision: 2 });
|
|
resolveFirst([ACTIVE_TURN]);
|
|
await waitFor(() => expect(invoke).toHaveBeenCalledTimes(2));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(invoke).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('keeps the last snapshot and reports observability failure when an event read fails', async () => {
|
|
let fail = false;
|
|
const invoke = vi.fn(async () => {
|
|
if (fail) {
|
|
throw new Error('snapshot unavailable');
|
|
}
|
|
return [ACTIVE_TURN];
|
|
});
|
|
eventFake = createTauriEventFake({ invoke });
|
|
eventFake.install();
|
|
const { result } = renderHook(() =>
|
|
useDirectActiveTurns({ invoke: invoke as never, enabled: true }),
|
|
);
|
|
eventFake.flushRegistrationEvals();
|
|
await waitFor(() => expect(result.current.activeTurns).toHaveLength(1));
|
|
const firstSnapshot = result.current.activeTurns;
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
fail = true;
|
|
act(() => {
|
|
eventFake?.emit(DIRECT_ACTIVE_TURNS_CHANGED_EVENT, { revision: 1 });
|
|
});
|
|
await act(async () => {
|
|
await vi.runAllTimersAsync();
|
|
});
|
|
expect(result.current.activeTurns).toBe(firstSnapshot);
|
|
expect(result.current.snapshotReadFailed).toBe(true);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('clears to a stable empty snapshot when the hook is disabled', async () => {
|
|
let resolveSnapshot!: (turns: GameCreatorDirectActiveTurn[]) => void;
|
|
const snapshot = new Promise<GameCreatorDirectActiveTurn[]>((resolve) => {
|
|
resolveSnapshot = resolve;
|
|
});
|
|
const invoke = vi.fn(() => snapshot) as never;
|
|
const { result, rerender } = renderHook(
|
|
({ enabled }: { enabled: boolean }) =>
|
|
useDirectActiveTurns({ invoke, enabled }),
|
|
{ initialProps: { enabled: true } },
|
|
);
|
|
|
|
const emptySnapshot = result.current.activeTurns;
|
|
await act(async () => {
|
|
resolveSnapshot([]);
|
|
await snapshot;
|
|
});
|
|
expect(result.current.activeTurns).toBe(emptySnapshot);
|
|
rerender({ enabled: false });
|
|
expect(result.current.activeTurns).toBe(emptySnapshot);
|
|
});
|
|
|
|
it('读取失败后的重试定时器会在卸载后清理', async () => {
|
|
vi.useFakeTimers();
|
|
const invoke = vi.fn(async () => {
|
|
throw new Error('temporarily unavailable');
|
|
});
|
|
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout');
|
|
|
|
try {
|
|
const { unmount } = renderHook(() =>
|
|
useDirectActiveTurns({ invoke, enabled: true }),
|
|
);
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(invoke).toHaveBeenCalledTimes(1);
|
|
|
|
unmount();
|
|
expect(clearTimeoutSpy).toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
});
|
|
expect(invoke).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
clearTimeoutSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('停用后晚到的非空快照不能恢复活动回合,手动刷新也不发请求', async () => {
|
|
let complete!: (turns: GameCreatorDirectActiveTurn[]) => void;
|
|
const invoke = vi.fn(
|
|
() =>
|
|
new Promise<GameCreatorDirectActiveTurn[]>((resolve) => {
|
|
complete = resolve;
|
|
}),
|
|
);
|
|
const { result, rerender } = renderHook(
|
|
({ enabled }) =>
|
|
useDirectActiveTurns({ invoke: invoke as never, enabled }),
|
|
{ initialProps: { enabled: true } },
|
|
);
|
|
rerender({ enabled: false });
|
|
const empty = result.current.activeTurns;
|
|
await act(async () => {
|
|
complete([ACTIVE_TURN]);
|
|
await result.current.refreshActiveTurns();
|
|
});
|
|
expect(result.current.activeTurns).toBe(empty);
|
|
expect(result.current.snapshotReadFailed).toBe(false);
|
|
expect(invoke).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('重新启用后读取新快照,旧请求晚到不能覆盖新快照', async () => {
|
|
let completeOld!: (turns: GameCreatorDirectActiveTurn[]) => void;
|
|
const invoke = vi
|
|
.fn()
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise<GameCreatorDirectActiveTurn[]>((resolve) => {
|
|
completeOld = resolve;
|
|
}),
|
|
)
|
|
.mockResolvedValue([{ ...ACTIVE_TURN, turnId: 'new-run' }]);
|
|
const { result, rerender } = renderHook(
|
|
({ enabled }) =>
|
|
useDirectActiveTurns({ invoke: invoke as never, enabled }),
|
|
{ initialProps: { enabled: true } },
|
|
);
|
|
rerender({ enabled: false });
|
|
rerender({ enabled: true });
|
|
await waitFor(() =>
|
|
expect(result.current.activeTurns[0]?.turnId).toBe('new-run'),
|
|
);
|
|
const current = result.current.activeTurns;
|
|
await act(async () => {
|
|
completeOld([ACTIVE_TURN]);
|
|
});
|
|
expect(result.current.activeTurns).toBe(current);
|
|
expect(invoke).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('ActiveProjectRunsPanel', () => {
|
|
it('按开始时间展示正在运行的项目并支持进入项目', () => {
|
|
const onOpenProject = vi.fn();
|
|
render(
|
|
<ActiveProjectRunsPanel
|
|
activeTurns={[
|
|
{
|
|
projectPath: 'C:/projects/later',
|
|
projectName: '后开始',
|
|
turnId: 'turn-later',
|
|
startedAt: 200,
|
|
status: 'streaming',
|
|
updatedAt: 220,
|
|
sequence: 2,
|
|
},
|
|
{
|
|
projectPath: 'C:/projects/first',
|
|
projectName: '先开始',
|
|
turnId: 'turn-first',
|
|
startedAt: 100,
|
|
status: 'running',
|
|
updatedAt: 120,
|
|
sequence: 1,
|
|
},
|
|
]}
|
|
onOpenProject={onOpenProject}
|
|
/>,
|
|
);
|
|
|
|
const items = screen.getAllByRole('button');
|
|
expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([
|
|
true,
|
|
false,
|
|
]);
|
|
fireEvent.click(items[0]!);
|
|
expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
|
|
});
|
|
|
|
it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => {
|
|
render(<ActiveProjectRunsPanel activeTurns={[]} readFailed />);
|
|
|
|
expect(screen.getByRole('status').textContent).toBe(
|
|
'未能读取正在运行的项目',
|
|
);
|
|
});
|
|
|
|
/**
|
|
* 回归:快照读到了、只是没有在跑回合时,面板形态曾把这个健康空态渲染成
|
|
* 「未能读取正在运行的项目」,把"没在跑"说成"没读到"。
|
|
*/
|
|
it('面板形态下快照读取正常但没有在跑回合时整块不渲染', () => {
|
|
const { container } = render(<ActiveProjectRunsPanel activeTurns={[]} />);
|
|
|
|
expect(container.firstChild).toBeNull();
|
|
});
|
|
|
|
it('不在项目内时标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
|
|
const onOpenProject = vi.fn();
|
|
render(
|
|
<ActiveProjectRunsPanel
|
|
placement="titlebar"
|
|
activeTurns={[
|
|
{
|
|
projectPath: 'C:/projects/first',
|
|
projectName: '先开始',
|
|
turnId: 'turn-first',
|
|
startedAt: 100,
|
|
status: 'running',
|
|
updatedAt: 120,
|
|
sequence: 1,
|
|
},
|
|
{
|
|
projectPath: 'C:/projects/later',
|
|
projectName: '后开始',
|
|
turnId: 'turn-later',
|
|
startedAt: 200,
|
|
status: 'streaming',
|
|
updatedAt: 220,
|
|
sequence: 2,
|
|
},
|
|
]}
|
|
onOpenProject={onOpenProject}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByRole('button', { name: /后开始/ })).toBeTruthy();
|
|
expect(screen.queryByRole('menu')).toBeNull();
|
|
fireEvent.click(screen.getByRole('button', { name: /后开始/ }));
|
|
expect(screen.getByRole('menu')).toBeTruthy();
|
|
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ }));
|
|
expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
|
|
});
|
|
|
|
/**
|
|
* 回归:进入的项目没在跑、后台别的项目在跑时,旧实现在标题栏显示的是
|
|
* 最后开始的那个项目(当前项目名整个消失),用户会以为自己开错了项目。
|
|
*/
|
|
it('进入项目后标签显示当前项目名,后台在跑的项目只进徽标与展开菜单', () => {
|
|
render(
|
|
<ActiveProjectRunsPanel
|
|
placement="titlebar"
|
|
activeTurns={[
|
|
{
|
|
projectPath: 'C:/projects/other',
|
|
projectName: 'gameagent-other',
|
|
turnId: 'turn-other',
|
|
startedAt: 100,
|
|
status: 'running',
|
|
updatedAt: 120,
|
|
sequence: 1,
|
|
},
|
|
]}
|
|
currentProjectName="飞鸟 v2"
|
|
currentProjectPath="C:/projects/bird"
|
|
onOpenProject={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
const trigger = screen.getByRole('button', {
|
|
name: '当前项目:飞鸟 v2;共 1 个项目在运行',
|
|
});
|
|
expect(trigger.textContent).toContain('飞鸟 v2');
|
|
expect(trigger.textContent).toContain('1');
|
|
expect(trigger.textContent).not.toContain('gameagent-other');
|
|
|
|
fireEvent.click(trigger);
|
|
expect(
|
|
screen.getByRole('menuitem', { name: /gameagent-other/ }),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('当前项目自己的回合在跑时列表标「当前」并沿用项目名', () => {
|
|
render(
|
|
<ActiveProjectRunsPanel
|
|
placement="titlebar"
|
|
activeTurns={[
|
|
{
|
|
projectPath: 'C:/projects/bird',
|
|
projectName: 'gameagent-bird',
|
|
turnId: 'turn-bird',
|
|
startedAt: 100,
|
|
status: 'streaming',
|
|
updatedAt: 120,
|
|
sequence: 1,
|
|
},
|
|
]}
|
|
currentProjectName="飞鸟 v2"
|
|
currentProjectPath="C:/projects/bird"
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /当前项目:飞鸟 v2/ }));
|
|
const item = screen.getByRole('menuitem');
|
|
expect(item.textContent).toContain('飞鸟 v2');
|
|
expect(item.textContent).not.toContain('gameagent-bird');
|
|
expect(item.textContent).toContain('当前');
|
|
expect(item.getAttribute('aria-current')).toBe('true');
|
|
});
|
|
|
|
it('读取失败时不顶掉当前项目名,读取结论留在展开菜单里', () => {
|
|
render(
|
|
<ActiveProjectRunsPanel
|
|
placement="titlebar"
|
|
activeTurns={[]}
|
|
readFailed
|
|
currentProjectName="飞鸟 v2"
|
|
/>,
|
|
);
|
|
|
|
const trigger = screen.getByRole('button', {
|
|
name: /当前项目:飞鸟 v2;正在运行的项目读取失败/,
|
|
});
|
|
expect(trigger.textContent).toContain('飞鸟 v2');
|
|
|
|
fireEvent.click(trigger);
|
|
expect(screen.getByRole('menu')).toBeTruthy();
|
|
expect(screen.getByRole('status').textContent).toBe(
|
|
'正在运行的项目读取失败',
|
|
);
|
|
});
|
|
});
|