394b8de9d2
- 三条错误表去冗余前缀:DirectTurnError→TurnError、DirectTurnEnqueueError→EnqueueError、DirectTurnFailure→TurnFailure、DirectTurnDeadline→Deadline、DirectCodexFailureStage→FailureStage、DirectCodexNativeKind→NativeKind、DirectModelCallKind→ModelCallKind
- 用户条目去前缀:DirectCodexUserItem→UserItem 等六个类型;agent/direct_turn_error.rs 移到 codex_app_server/turn_error.rs
- thread_manager/wire.rs 拆为 wire/{mod,clock,items,turn,failure,tests}.rs;direct_turn_failure.rs 的失败载荷并入 wire/failure.rs
- 宿主侧回合终态从 struct TurnTerminal 改为 enum TurnCompletion{Completed,Interrupted,Aborted,Failed},从 wire 移到 thread_manager/turn_completion.rs,status 由变体反推
- 过期回合释放兜底改走 TurnCompletion::Aborted.event
- 同步生成绑定、前端调用点与测试;修正终态单测里过时的 TransportClosed 断言
- 对 src-tauri 全量 cargo fmt,满足 pre-commit 的 rustfmt 门禁
168 lines
5.9 KiB
TypeScript
168 lines
5.9 KiB
TypeScript
// @vitest-environment jsdom
|
||
import {
|
||
cleanup,
|
||
render,
|
||
screen,
|
||
waitFor,
|
||
within,
|
||
} from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||
|
||
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||
import {
|
||
directCodexContentToPromptText,
|
||
resourceLabelResolver,
|
||
} from '../src/features/project-workspace/resourceReferences';
|
||
import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
|
||
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
|
||
import type { UserContentPart } from '../src/view/project-development/chat/generated/UserContentPart';
|
||
import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils';
|
||
|
||
afterEach(cleanup);
|
||
|
||
const imageAction: ResourceCanvasAssetToolAction = {
|
||
id: 'generate-image',
|
||
route: 'asset',
|
||
label: '生成图片',
|
||
assetKind: 'image',
|
||
audioKind: null,
|
||
assetName: 'AI 生成图片',
|
||
promptPlaceholder: '今天想生成什么画面?',
|
||
adjustableDimensions: true,
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
requiresIconSpecReference: false,
|
||
writesIconSpecReference: false,
|
||
};
|
||
|
||
function asset(
|
||
id: string,
|
||
label: string,
|
||
mediaType = 'image/png',
|
||
): GameCreationAppAssetManifestEntry {
|
||
return {
|
||
id,
|
||
kind: 'image',
|
||
mediaType,
|
||
localPath: `assets/${label}.png`,
|
||
source: { kind: 'canvas' },
|
||
};
|
||
}
|
||
|
||
/** 取面板里的引用输入区(含 `@` 触发器按钮),把操作局限在它自己身上。 */
|
||
function referenceInputScope(ariaLabel: string) {
|
||
const root = screen
|
||
.getByLabelText(ariaLabel)
|
||
.closest('.resource-reference-input');
|
||
if (!root) {
|
||
throw new Error(`资源引用输入区不存在:${ariaLabel}`);
|
||
}
|
||
return within(root as HTMLElement);
|
||
}
|
||
|
||
describe('生成浮层里的引用选择(真实组件,不做 props mock)', () => {
|
||
test('浮层不是模态,真实点击候选就能选中并随提交带走引用', async () => {
|
||
const user = userEvent.setup();
|
||
const onSubmit = vi.fn();
|
||
const assets = [asset('asset-a', '素材-a'), asset('asset-b', '素材-b')];
|
||
render(
|
||
<ResourceCanvasAssetGenerationPanelView
|
||
action={imageAction}
|
||
variant="floating"
|
||
assets={assets}
|
||
projectPath="/tmp/project"
|
||
onSubmit={onSubmit}
|
||
onClose={() => undefined}
|
||
/>,
|
||
);
|
||
|
||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||
/*
|
||
浮层形态不是模态:没有全屏遮罩、没有 aria-modal。`ThemedModal` 的焦点陷阱正是 P1 的成因——
|
||
引用选择器 portal 到 body,被陷阱挡在外面时候选项点了不生效(真实浏览器复现过)。
|
||
*/
|
||
expect(panel.getAttribute('aria-modal')).toBeNull();
|
||
expect(document.querySelector('[aria-modal="true"]')).toBeNull();
|
||
expect(document.querySelector('.fixed.inset-0')).toBeNull();
|
||
|
||
await typeGenerationPrompt(panel, '画一只猫');
|
||
await user.click(
|
||
referenceInputScope('生成提示词').getByRole('button', {
|
||
name: '插入素材引用',
|
||
}),
|
||
);
|
||
const picker = await screen.findByRole('dialog', { name: '选择素材' });
|
||
await user.click(within(picker).getByRole('option', { name: /素材-a/ }));
|
||
expect(within(picker).getByText('已选择 1 个')).not.toBeNull();
|
||
const insert = within(picker).getByRole('button', { name: '插入引用' });
|
||
expect((insert as HTMLButtonElement).disabled).toBe(false);
|
||
await user.click(insert);
|
||
|
||
// 选中的引用进了面板:计数可见、提交时随载荷带走。
|
||
await waitFor(() =>
|
||
expect(within(panel).getByText('参考图 1/5')).not.toBeNull(),
|
||
);
|
||
await user.click(within(panel).getByRole('button', { name: '生成图片' }));
|
||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||
const submitted = onSubmit.mock.calls[0]?.[0] as {
|
||
content: UserContentPart[];
|
||
};
|
||
// 提交载荷只有一份 canonical content:正文逐字,引用仍是稳定 resourceId。
|
||
expect(
|
||
directCodexContentToPromptText(
|
||
submitted.content,
|
||
resourceLabelResolver(assets),
|
||
),
|
||
).toContain('画一只猫');
|
||
expect(
|
||
submitted.content.some(
|
||
(part) =>
|
||
part.type === 'agc_resource_reference' &&
|
||
part.resourceId === 'asset-a',
|
||
),
|
||
).toBe(true);
|
||
});
|
||
|
||
test('搜索能收窄候选,键盘也能完成选择', async () => {
|
||
const user = userEvent.setup();
|
||
const onSubmit = vi.fn();
|
||
const assets = [asset('asset-a', '素材-a'), asset('asset-b', '素材-b')];
|
||
render(
|
||
<ResourceCanvasAssetGenerationPanelView
|
||
action={imageAction}
|
||
variant="floating"
|
||
assets={assets}
|
||
projectPath="/tmp/project"
|
||
onSubmit={onSubmit}
|
||
onClose={() => undefined}
|
||
/>,
|
||
);
|
||
|
||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||
await typeGenerationPrompt(panel, '画一只猫');
|
||
await user.click(
|
||
referenceInputScope('生成提示词').getByRole('button', {
|
||
name: '插入素材引用',
|
||
}),
|
||
);
|
||
const picker = await screen.findByRole('dialog', { name: '选择素材' });
|
||
expect(within(picker).getAllByRole('option')).toHaveLength(2);
|
||
|
||
// 搜索收窄:只剩「素材-b」这一条候选。
|
||
await user.clear(screen.getByLabelText('搜索全部画布素材'));
|
||
await user.type(screen.getByLabelText('搜索全部画布素材'), '素材-b');
|
||
await waitFor(() =>
|
||
expect(within(picker).getAllByRole('option')).toHaveLength(1),
|
||
);
|
||
|
||
// 键盘路径:Tab 进候选列表,Enter 选中(不依赖鼠标点击)。
|
||
const option = within(picker).getByRole('option', { name: /素材-b/ });
|
||
option.focus();
|
||
await user.keyboard('{Enter}');
|
||
await waitFor(() =>
|
||
expect(within(picker).getByText('已选择 1 个')).not.toBeNull(),
|
||
);
|
||
});
|
||
});
|