a11cdf13d7
生成面板 state、重开草稿、重试身份与提交载荷统一为一份 content[] 重试身份改用 directCodexContentKey 内容指纹比对并删除 referenceIdsMatch directCodexContentToLegacyContentDto 只在任务落账的出站边界派生 账本回到草稿的反向翻译收敛为 legacyContentDtoToContent 单一实现 token 扫描从 ResourceReferenceInput 提到 resourceReferences 并两处共用 无法解析的资源引用合成 unknown 占位而不再静默丢弃 出站提示词规范化收敛为 resourceCanvasAssetGenerationPromptText 供两处共用 directCodexContentToPromptText 去掉 trim,空白只在整条 content 上判定 同步决策记录与 canonical content 里程碑落地结果
162 lines
5.9 KiB
TypeScript
162 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 } 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 { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart';
|
||
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: DirectCodexUserContentPart[];
|
||
};
|
||
// 提交载荷只有一份 canonical content:正文逐字,引用仍是稳定 resourceId。
|
||
expect(directCodexContentToPromptText(submitted.content, 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(),
|
||
);
|
||
});
|
||
});
|