Files
Genarrative/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx
T
suzmii d04fe83f10 修复 PR #316 review(前端 TS/TSX):@ 引用与提示词润色主链路
- App.tsx:`conversation.write` 策略确认后的重跑改为整体复用首轮入参(`directCodexPolicyRetryInput`),不再手写字段,修掉漏传 `references` 导致 @ 引用被静默丢弃的缺陷
- resourceReferences.ts:运行画面引用的判别指纹补上 `resourceIds`(排序集合口径)/`versionId`/`elementRole`/`width`/`height`,避免不同选点撞 key 被 `dedupeChatReferences` 去重丢掉
- ResourceReferenceInput.tsx:程序化重建编辑器内容改为把 chip 内联嵌在文本里的同名 token 位置(不再另起一段堆在末尾),并把 `lastEmittedDraftRef` 记为「编辑器实际读回来的草稿」而不是 props,消除每轮多一段 `@显示名` 的渲染循环
- ResourceReferenceInput.tsx:候选浮层(候选菜单 / 素材选择器)打开时 Enter 不再提交表单,把按键让给 `LexicalTypeaheadMenuPlugin`
- ResourceReferenceInput.tsx:`assets` / `currentVersionAssetReferences` 的 memo 依赖改用内容签名;预览请求在换素材 / 卸载时主动作废上一个 scope;空态文案的嵌套三元抽成 `pickerEmptyMessage`
- usePromptPolish.ts:`polish()` 补 `catch`,注入式 `requestPolish` 拒绝时给出可见失败提示而不是未处理拒绝;`reset()` 递增请求代次作废在飞请求
- ChatPromptPolishReminder.tsx:章节级 Esc 分支补 `busy` 判据,与关闭按钮 / 遮罩同一口径(在飞不许关面板)
- LocalGamePreviewFrame.tsx:资源 id 改用专用净化(允许 `:` 与可打印非 ASCII),修掉 `local-asset:<id>`、`persisted-角色草图.png` 被整条过滤导致引用丢素材关联
- sessionPreview.ts:`get_local_game_preview_status` 读失败不再当作「确认没有在跑」去发停止命令,避免停掉真在跑的活体预览
- useHomeProjectCreation.ts:进项目流程加代次令牌,慢请求后到不再覆盖新项目
- WorkspaceLauncher.tsx:拒收恢复的阶段回写按 `projectId`/`heldRevision`/`snapshotRevision` 关联,不再把恢复结果盖到后续新拒收的提示上
- 用例:`directCodexPolicyRetryInput` 透传、引用指纹区分度、重建不循环、浮层打开时 Enter 不提交、润色拒绝 / reset 作废、预览读失败不停预览
2026-09-12 20:48:13 +08:00

997 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { $getRoot } from 'lexical';
import { StrictMode, useState } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type {
GameCreationAppAssetManifestEntry,
GameIterationVersion,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import {
LOCAL_GAME_PREVIEW_INSPECT_MESSAGE,
parseLocalGamePreviewInspectMessage,
} from '../src/features/project-workspace/LocalGamePreviewFrame';
import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput';
import {
type ChatComposerDraft,
type ChatReference,
chatReferenceListKey,
currentIterationVersionAssets,
dedupeChatReferences,
dispatchResourceReferenceInsert,
resolveActiveIterationVersion,
RESOURCE_REFERENCE_FILTERS,
RESOURCE_REFERENCE_INSERT_EVENT,
RESOURCE_REFERENCE_SCOPES,
resourceReferenceCategory,
resourceReferenceFromAsset,
resourceReferenceMatchesCategoryFilter,
resourceReferenceMatchesQuery,
resourceReferenceMatchesTagSelection,
resourceReferenceTagLibrary,
type RuntimeRegionReference,
} from '../src/features/project-workspace/resourceReferences';
function asset(
id: string,
kind: string,
mediaType: string,
localPath: string,
): GameCreationAppAssetManifestEntry {
return {
id,
kind,
mediaType,
localPath,
source: { kind: 'uploaded' },
};
}
function iterationVersion(
versionId: string,
resourceIds: string[],
parentVersionId: string | null = null,
): GameIterationVersion {
return {
versionId,
parentVersionId,
projectRevision: 1,
resourceBindings: resourceIds.map((resourceId, index) => ({
slotId: `slot-${index}`,
resourceId,
})),
createdReason: 'initial',
createdAt: 1,
};
}
async function settleComposer() {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
/**
* 编辑器文本模型里的字符数。断言「一个引用 = 一个字符」用它而不是 DOM 文本:
* DOM 里 chip 仍要显示 `@显示名` 给用户看,两者本来就不该相等。
*/
function composerEditor(): {
getEditorState: () => { read: <T>(fn: () => T) => T };
} {
const element = screen.getByLabelText('聊天') as HTMLElement & {
__lexicalEditor?: {
getEditorState: () => { read: <T>(fn: () => T) => T };
};
};
const editor = element.__lexicalEditor;
if (!editor) throw new Error('lexical editor instance not found');
return editor;
}
function editorTextSize() {
return composerEditor()
.getEditorState()
.read(() => $getRoot().getTextContentSize());
}
const assets = [
asset('hero', 'character', 'image/png', 'assets/hero.png'),
asset('enemy', 'character', 'image/png', 'assets/enemy.png'),
asset('theme', 'background-music', 'audio/mpeg', 'assets/theme.mp3'),
];
/** 带 manifest 标签的同一批素材:标签筛选用例只换 `tags`,其余口径与 `assets` 一致。 */
const taggedAssets: GameCreationAppAssetManifestEntry[] = [
{ ...assets[0]!, tags: ['角色', '主角'] },
{ ...assets[1]!, tags: ['角色'] },
{ ...assets[2]!, tags: ['背景乐'] },
];
/**
* 面板里的候选筛选条由共享组件渲染,标签行是它内部带 `素材筛选标签` 名字的分组。
* 用这一层 scope 取标签 chip,避免和同名的功能分类按钮(例如「角色与对象」)串。
*/
function pickerTagGroup() {
return within(screen.getByRole('group', { name: '素材筛选标签' }));
}
/** 取某个 aria-label 的引用输入区,把它内部(含 `@` 按钮)的操作局限定在自己身上。 */
function referenceInputByLabel(ariaLabel: string) {
const editor = screen.getByLabelText(ariaLabel);
const root = editor.closest('.resource-reference-input');
if (!root)
throw new Error(`resource reference input not found: ${ariaLabel}`);
return within(root as HTMLElement);
}
async function insertAssetThroughPicker(ariaLabel: string, optionName: RegExp) {
await userEvent.click(
referenceInputByLabel(ariaLabel).getByRole('button', {
name: '插入素材引用',
}),
);
await userEvent.click(
within(screen.getByRole('dialog', { name: '选择素材' })).getByRole(
'option',
{ name: optionName },
),
);
await userEvent.click(screen.getByRole('button', { name: '插入引用' }));
await settleComposer();
}
afterEach(cleanup);
describe('ResourceReferenceInput', () => {
test('运行画面引用的判别指纹带上了绑定素材、版本、元素角色与尺寸', () => {
const base: RuntimeRegionReference = {
type: 'runtime-region',
label: 'Play',
runId: 'run-1',
versionId: 'v1',
elementTag: 'button',
elementRole: 'button',
text: '开始',
width: 120,
height: 40,
resourceIds: ['hero', 'enemy'],
source: 'runtime-picker',
};
// 同一个 run 里两个同文案的元素、或重新选点后绑定变了的同一块区域,都是不同引用:
// 指纹漏字段会让 `dedupeChatReferences` 静默丢掉其中一条。
const distinctVariants: RuntimeRegionReference[] = [
{ ...base, resourceIds: ['hero'] },
{ ...base, versionId: 'v2' },
{ ...base, elementRole: 'link' },
{ ...base, width: 200 },
{ ...base, height: 80 },
];
for (const variant of distinctVariants) {
expect(chatReferenceListKey([variant])).not.toBe(
chatReferenceListKey([base]),
);
expect(dedupeChatReferences([base, variant])).toHaveLength(2);
}
// 绑定素材顺序不影响身份:同一组素材换个顺序还是同一条引用。
expect(
dedupeChatReferences([base, { ...base, resourceIds: ['enemy', 'hero'] }]),
).toHaveLength(1);
});
test('AI 润色只换文本时不会把引用一轮轮追加成重复 @显示名', async () => {
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
function Controlled() {
const [draft, setDraft] = useState<ChatComposerDraft>({
text: '原始需求',
references: [reference],
});
return (
<>
<button
type="button"
onClick={() =>
setDraft((current) => ({ ...current, text: '润色后的需求' }))
}
>
模拟润色
</button>
<ResourceReferenceInput
value={draft.text}
references={draft.references}
onChange={(next) => {
onChange(next);
setDraft(next);
}}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>
</>
);
}
render(<Controlled />);
await settleComposer();
fireEvent.click(screen.getByRole('button', { name: '模拟润色' }));
await settleComposer();
await settleComposer();
// 重建必须收敛,而且不许把「重建后的编辑器内容」当成一次用户编辑回抛给宿主:
// 旧实现用 props 覆写 lastEmittedDraftRef,读回来的文本里多出的 `@显示名`
// 会触发下一轮重建,文本一轮轮变长(渲染循环)。
expect(onChange).not.toHaveBeenCalled();
const editorText =
document.querySelector('.resource-reference-input-editor')?.textContent ??
'';
expect(editorText).toContain('润色后的需求');
expect(editorText.match(/@hero/gu) ?? []).toHaveLength(1);
expect(screen.getByRole('button', { name: '模拟润色' })).not.toBeNull();
});
test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
function Controlled() {
const [draft, setDraft] = useState<ChatComposerDraft>({
text: '要一个',
references: [],
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<ResourceReferenceInput
value={draft.text}
references={draft.references}
onChange={setDraft}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>
</form>
);
}
render(<Controlled />);
// 候选浮层开着时 Enter 属于浮层(候选菜单用它选引用),输入区的 HIGH 优先级提交
// 必须先让位,否则用户按 Enter 选候选会变成把这一轮直接发出去。
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await screen.findByRole('dialog', { name: '选择素材' });
const composer = screen.getByLabelText('聊天');
expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false);
expect(onSubmit).not.toHaveBeenCalled();
// 对照:关掉浮层后 Enter 立刻恢复提交语义,说明上面不是编辑器整体坏掉。
await user.click(screen.getByRole('button', { name: '关闭素材选择' }));
expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
});
test('opens the asset picker, supports multi-select, and inserts stable references', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
expect(screen.getByRole('dialog', { name: '选择素材' })).not.toBeNull();
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('option', { name: /enemy/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
const draft = onChange.mock.calls.at(-1)?.[0];
expect(draft?.text).toBe('@hero @enemy');
expect(draft?.references.map((reference) => reference.resourceId)).toEqual([
'hero',
'enemy',
]);
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
).not.toBeNull();
});
test('loads image thumbnails through the controlled native preview command', async () => {
const user = userEvent.setup();
const invoke = vi.fn().mockResolvedValue({
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
});
(
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof invoke } };
}
).__TAURI__ = { core: { invoke } };
render(
<StrictMode>
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>
</StrictMode>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await waitFor(() => {
expect(
document.querySelector('.resource-reference-picker-list img'),
).not.toBeNull();
});
expect(invoke).toHaveBeenCalledWith(
'read_local_project_image_preview',
expect.objectContaining({
projectPath: 'C:/project',
relativePath: 'assets/hero.png',
}),
);
delete (
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof invoke } };
}
).__TAURI__;
});
test('filters candidate references by name, id, kind, and functional category', () => {
const hero = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
const theme = resourceReferenceFromAsset(assets[2]!, 'asset-picker');
expect(resourceReferenceMatchesQuery(hero, 'her')).toBe(true);
expect(resourceReferenceMatchesQuery(hero, 'character')).toBe(true);
expect(resourceReferenceMatchesQuery(hero, 'missing')).toBe(false);
expect(resourceReferenceCategory(hero)).toBe('character');
expect(resourceReferenceCategory(theme)).toBe('audio');
expect(hero.tags).toEqual([]);
});
test('exposes the six functional categories plus all as the single filter source', () => {
expect(RESOURCE_REFERENCE_FILTERS).toEqual([
{ id: 'all', label: '全部' },
{ id: 'ui-interaction', label: 'UI 交互' },
{ id: 'character', label: '角色与对象' },
{ id: 'scene', label: '场景与环境' },
{ id: 'audio', label: '音频' },
{ id: 'document', label: '文档' },
{ id: 'unclassified', label: '待归类' },
]);
});
test('projects explicit category and manifest tags onto the reference', () => {
const hero = resourceReferenceFromAsset(
{
...assets[0]!,
category: 'scene',
tags: ['主舞台', '日夜'],
},
'asset-picker',
);
expect(hero.category).toBe('scene');
expect(hero.tags).toEqual(['主舞台', '日夜']);
expect(resourceReferenceMatchesCategoryFilter(hero, 'scene')).toBe(true);
expect(resourceReferenceMatchesCategoryFilter(hero, 'character')).toBe(
false,
);
expect(resourceReferenceMatchesCategoryFilter(hero, 'all')).toBe(true);
});
test('falls back to the kind-derived category when the manifest asset omits it', () => {
const hero = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
const unclassified = resourceReferenceFromAsset(
{
...assets[0]!,
kind: 'image',
},
'asset-picker',
);
expect(resourceReferenceMatchesCategoryFilter(hero, 'character')).toBe(
true,
);
expect(resourceReferenceCategory(unclassified)).toBe('unclassified');
});
test('resource-card insertion uses the shared structured reference event', () => {
const reference = resourceReferenceFromAsset(assets[0]!, 'resource-card');
const listener = vi.fn();
window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
dispatchResourceReferenceInsert(reference);
window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
expect(listener).toHaveBeenCalledTimes(1);
expect(
(listener.mock.calls[0]?.[0] as CustomEvent).detail.reference,
).toEqual(reference);
});
test('runtime inspect messages only expose the bounded safe selection shape', () => {
expect(
parseLocalGamePreviewInspectMessage({
type: LOCAL_GAME_PREVIEW_INSPECT_MESSAGE,
action: 'selected',
selection: {
label: '开始游戏',
elementTag: 'button',
elementRole: 'button',
text: '开始游戏',
width: 120.4,
height: 40.2,
// 资源 id 允许 `:` 与非 ASCII(画布规范形态是 `local-asset:<id>`
// 落盘文件名派生的 id 还可能是 `persisted-角色草图.png`);
// 空白与路径分隔符不是资源 id,照旧丢掉。
resourceIds: [
'hero',
'local-asset:hero',
'persisted-角色草图.png',
'bad id',
'../secret',
'a/b',
],
sourcePath: '/assets/hero.png?token=secret',
html: '<button>secret</button>',
},
}),
).toEqual({
action: 'selected',
selection: {
label: '开始游戏',
elementTag: 'button',
elementRole: 'button',
text: '开始游戏',
width: 120.4,
height: 40.2,
resourceIds: ['hero', 'local-asset:hero', 'persisted-角色草图.png'],
sourcePath: '/assets/hero.png',
},
});
expect(
parseLocalGamePreviewInspectMessage({
type: 'unknown',
action: 'selected',
}),
).toBeNull();
});
test('runtime-region references travel through the same structured event', () => {
const reference: ChatReference = {
type: 'runtime-region',
label: '开始按钮',
runId: 'preview-3101',
elementTag: 'button',
text: '开始游戏',
resourceIds: ['hero'],
source: 'runtime-picker',
};
const listener = vi.fn();
window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
dispatchResourceReferenceInsert(reference);
window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener);
expect(
(listener.mock.calls[0]?.[0] as CustomEvent).detail.reference,
).toEqual(reference);
});
test('removing a chip keeps the remaining text editable', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await screen.findByRole('button', { name: '移除引用 hero' });
await user.click(screen.getByRole('button', { name: '移除引用 hero' }));
await waitFor(() => {
expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0);
});
});
test('counts one inserted reference as a single character in the editor text model', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await settleComposer();
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
).not.toBeNull();
// 引用是原子节点:编辑器文本模型里只占 1 个字符,加上插入时补的那个分隔空格
// 一共 2 个。显示名 `hero` 有 4 个字符(`@hero` 是 5),如果引用按显示名算,
// 这里会是 5 或 6,所以这条断言正好把「1 个引用 = 1 个字符」钉住。
// 提交用的结构化引用另走 `references`,不受这个占位字符影响。
expect(editorTextSize()).toBe(2);
});
test('keeps the whole reference after one delete and drops its data in one step', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await settleComposer();
// chip 仍把 `@显示名` 显示给用户看;删除按钮挂在 chip 自己身上
// `移除引用 hero` 是 chip 内部的按钮,不是脱离 chip 的独立按钮)。
const chip = document.querySelector('[data-resource-reference-id="hero"]');
expect(chip).not.toBeNull();
expect(chip?.textContent).toBe('@hero');
const deleteButton = screen.getByRole('button', { name: '移除引用 hero' });
expect(chip?.contains(deleteButton)).toBe(true);
// 提交用的结构化引用完整保留,末尾那个分隔空格提交前会被 trim 掉。
expect(onChange.mock.calls.at(-1)?.[0].text).toBe('@hero');
expect(onChange.mock.calls.at(-1)?.[0].references[0]?.resourceId).toBe(
'hero',
);
// 引用节点是原子的:一次操作删掉整个 chip,不存在"删一半"的中间态。
await user.click(deleteButton);
await settleComposer();
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
).toBeNull();
expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0);
expect(onChange.mock.calls.at(-1)?.[0].text).toBe('');
});
test('exposes the current-version and all-canvas scopes as the only two tabs', () => {
expect(RESOURCE_REFERENCE_SCOPES).toEqual([
{ id: 'current-version', label: '当前版本素材' },
{ id: 'all-canvas', label: '全部画布素材' },
]);
});
test('resolves the current version from activeVersionId or the newest manifest version', () => {
const versions = [
iterationVersion('v1', ['hero']),
iterationVersion('v2', ['theme'], 'v1'),
];
expect(resolveActiveIterationVersion(versions, 'v1')?.versionId).toBe('v1');
expect(resolveActiveIterationVersion(versions, null)?.versionId).toBe('v2');
expect(resolveActiveIterationVersion(versions, undefined)?.versionId).toBe(
'v2',
);
expect(resolveActiveIterationVersion(versions, 'missing')).toBeNull();
expect(resolveActiveIterationVersion([], null)).toBeNull();
expect(resolveActiveIterationVersion(undefined, null)).toBeNull();
});
test('derives current-version assets from bindings and drops dangling bindings', () => {
const versions = [iterationVersion('v1', ['hero', 'deleted-asset'])];
expect(
currentIterationVersionAssets(assets, versions, 'v1').map(
(entry) => entry.id,
),
).toEqual(['hero']);
expect(currentIterationVersionAssets(assets, versions, 'missing')).toEqual(
[],
);
expect(currentIterationVersionAssets(assets, [], null)).toEqual([]);
expect(
currentIterationVersionAssets(assets, [iterationVersion('v1', [])], 'v1'),
).toEqual([]);
});
test('keeps an independent search and filter state per picker scope', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
versions={[
iterationVersion('v1', ['hero', 'enemy']),
iterationVersion('v2', ['theme'], 'v1'),
]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
// 没传 activeVersionId 时回退到 manifest 最新版本 v2,只列出该版本绑定的资源。
expect(
screen
.getByRole('tab', { name: '当前版本素材' })
.getAttribute('aria-selected'),
).toBe('true');
expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /hero/u })).toBeNull();
await user.type(screen.getByLabelText('搜索当前版本素材'), 'the');
expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull();
await user.click(screen.getByRole('tab', { name: '全部画布素材' }));
// 另一个页签有自己的搜索与筛选,不受当前版本页签影响。
const allCanvasSearch = screen.getByLabelText(
'搜索全部画布素材',
) as HTMLInputElement;
expect(allCanvasSearch.value).toBe('');
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
expect(screen.getByRole('option', { name: /enemy/u })).not.toBeNull();
await user.type(allCanvasSearch, 'hero');
expect(screen.queryByRole('option', { name: /enemy/u })).toBeNull();
await user.click(screen.getByRole('button', { name: '音频' }));
expect(screen.queryByRole('option', { name: /hero/u })).toBeNull();
expect(screen.getByText('没有匹配的素材')).not.toBeNull();
await user.click(screen.getByRole('tab', { name: '当前版本素材' }));
expect(
(screen.getByLabelText('搜索当前版本素材') as HTMLInputElement).value,
).toBe('the');
expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull();
});
test('shows an empty state when the active version has no bound resources', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
activeVersionId="v1"
versions={[iterationVersion('v1', ['deleted-asset'])]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
// 当前版本没有可用素材时默认落到「全部画布素材」,切回当前版本页签是空态。
await user.click(screen.getByRole('tab', { name: '当前版本素材' }));
expect(screen.getByText('当前版本还没有绑定素材')).not.toBeNull();
expect(screen.queryByRole('option')).toBeNull();
await user.click(screen.getByRole('tab', { name: '全部画布素材' }));
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
});
test('shows the current-version empty state when the project has no versions', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
versions={[]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('tab', { name: '当前版本素材' }));
expect(screen.getByText('当前版本还没有绑定素材')).not.toBeNull();
});
test('refreshes chip and candidate display names after a resource rename', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const renamedAssets = [
asset('hero', 'character', 'image/png', 'assets/hero-final.png'),
assets[1]!,
assets[2]!,
];
render(
<ResourceReferenceInput
value="用这个角色"
references={[resourceReferenceFromAsset(assets[0]!, 'asset-picker')]}
onChange={onChange}
assets={renamedAssets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await waitFor(() => {
expect(
document.querySelector('.resource-reference-chip-label')?.textContent,
).toBe('hero-final');
});
await waitFor(() => {
expect(onChange.mock.calls.at(-1)?.[0].references[0]?.label).toBe(
'hero-final',
);
});
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
expect(screen.getByRole('option', { name: /hero-final/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /hero /u })).toBeNull();
});
test('restores a cross-session draft with the caret at the end of the text', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const { rerender } = render(
<ResourceReferenceInput
value="上一个会话的草稿"
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
// 切换 / 重开会话:外部草稿被整体替换。
rerender(
<ResourceReferenceInput
value="恢复出来的草稿"
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
await user.click(screen.getByRole('button', { name: '插入引用' }));
await settleComposer();
expect(onChange.mock.calls.at(-1)?.[0].text).toBe('恢复出来的草稿@hero');
});
test('标签库按 manifest 标签派生:计数只算候选、排序稳定、多标签取交集', () => {
const references = taggedAssets.map((entry) =>
resourceReferenceFromAsset(entry, 'asset-picker'),
);
expect(resourceReferenceTagLibrary(references)).toEqual([
{ tag: '角色', assetCount: 2, assetIds: ['enemy', 'hero'] },
{ tag: '背景乐', assetCount: 1, assetIds: ['theme'] },
{ tag: '主角', assetCount: 1, assetIds: ['hero'] },
]);
const hero = references[0]!;
const enemy = references[1]!;
const theme = references[2]!;
// 空选择不过滤;单标签按「素材带这个标签」判。
expect(resourceReferenceMatchesTagSelection(hero, [])).toBe(true);
expect(resourceReferenceMatchesTagSelection(theme, ['角色'])).toBe(false);
// 多标签是 AND:hero 同时有「角色」「主角」才算命中,enemy 只有「角色」。
expect(resourceReferenceMatchesTagSelection(hero, ['角色', '主角'])).toBe(
true,
);
expect(resourceReferenceMatchesTagSelection(enemy, ['角色', '主角'])).toBe(
false,
);
});
test('标签筛选把不符合的候选筛掉,多标签叠加,再与搜索关键字、功能分类叠加', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={taggedAssets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
// 没传 versions 时默认落在「全部画布素材」,标签行按这一页签的候选派生。
expect(screen.getAllByRole('option')).toHaveLength(3);
expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe(
'角色2',
);
await user.click(pickerTagGroup().getByRole('button', { name: /^角色/u }));
// 只勾一个标签:没带这个标签的素材必须消失,不是"排前面"。
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
expect(screen.getByRole('option', { name: /enemy/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /theme/u })).toBeNull();
await user.click(pickerTagGroup().getByRole('button', { name: /^主角/u }));
// 第二个标签再收一层:两个标签都命中的只剩 hero。
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /enemy/u })).toBeNull();
expect(
pickerTagGroup()
.getByRole('button', { name: /^主角/u })
.getAttribute('aria-pressed'),
).toBe('true');
// 关键字叠在标签之上:hero 不含 'enemy',两个标签 + 关键字一起就空了。
await user.type(screen.getByLabelText('搜索全部画布素材'), 'enemy');
expect(screen.queryByRole('option')).toBeNull();
expect(screen.getByText('没有匹配的素材')).not.toBeNull();
// 功能分类也参与叠加:hero 是 character,切到「音频」后同样为空。
await user.clear(screen.getByLabelText('搜索全部画布素材'));
await user.click(screen.getByRole('button', { name: '音频' }));
expect(screen.queryByRole('option')).toBeNull();
// 取消两个标签,分类仍在生效:只剩音频素材 theme。
await user.click(pickerTagGroup().getByRole('button', { name: /^主角/u }));
await user.click(pickerTagGroup().getByRole('button', { name: /^角色/u }));
expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /hero/u })).toBeNull();
});
test('两个页签各自持有一份独立的标签库与已选标签', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={taggedAssets}
versions={[iterationVersion('v1', ['hero', 'theme'])]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
// 当前版本 v1 只绑定 hero / theme,标签计数也只看这两个。
expect(
screen
.getByRole('tab', { name: '当前版本素材' })
.getAttribute('aria-selected'),
).toBe('true');
expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe(
'角色1',
);
expect(pickerTagGroup().queryByText('主角')).not.toBeNull();
await user.click(pickerTagGroup().getByRole('button', { name: /^角色/u }));
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /theme/u })).toBeNull();
await user.click(screen.getByRole('tab', { name: '全部画布素材' }));
// 另一个页签有自己的已选标签(此时为空)与自己的标签计数。
expect(screen.getAllByRole('option')).toHaveLength(3);
expect(
pickerTagGroup()
.getByRole('button', { name: /^角色/u })
.getAttribute('aria-pressed'),
).toBe('false');
expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe(
'角色2',
);
await user.click(screen.getByRole('tab', { name: '当前版本素材' }));
// 切回来仍是自己那份选择与筛后结果。
expect(
pickerTagGroup()
.getByRole('button', { name: /^角色/u })
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.queryByRole('option', { name: /theme/u })).toBeNull();
});
test('聊天与快速编辑提示词插入同一素材:草稿文本与引用完全一致', async () => {
const chatOnChange = vi.fn<(draft: ChatComposerDraft) => void>();
const quickEditOnChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<>
<ResourceReferenceInput
value=""
references={[]}
onChange={chatOnChange}
assets={taggedAssets}
projectPath="C:/project"
ariaLabel="聊天"
/>
<ResourceReferenceInput
value=""
references={[]}
onChange={quickEditOnChange}
assets={taggedAssets}
projectPath="C:/project"
ariaLabel="快速编辑提示词"
multiline
rows={3}
showPolishAction={false}
/>
</>,
);
await insertAssetThroughPicker('聊天', /hero/u);
await insertAssetThroughPicker('快速编辑提示词', /hero/u);
const chatDraft = chatOnChange.mock.calls.at(-1)?.[0];
const quickEditDraft = quickEditOnChange.mock.calls.at(-1)?.[0];
// 同一个资产在两条入口上插入,回填文本与结构化引用必须逐字相同:
// 「快速编辑」不允许出现第二种引用格式。
expect(chatDraft?.text).toBe('@hero');
expect(quickEditDraft).toEqual(chatDraft);
expect(quickEditDraft?.references).toEqual([
resourceReferenceFromAsset(taggedAssets[0]!, 'asset-picker'),
]);
expect(
document.querySelectorAll('[data-resource-reference-id="hero"]'),
).toHaveLength(2);
});
test('快速编辑提示词输入区不渲染内置润色入口:润色归宿主的 ResourcePromptPolishSlot', async () => {
render(
<ResourceReferenceInput
value="把夜色改成星空"
references={[]}
onChange={vi.fn()}
assets={taggedAssets}
projectPath="C:/project"
ariaLabel="快速编辑提示词"
multiline
rows={3}
showPolishAction={false}
/>,
);
expect(screen.queryByRole('button', { name: 'AI 润色' })).toBeNull();
// `@` 入口照旧在:润色收起来不影响引用。
expect(
referenceInputByLabel('快速编辑提示词').getByRole('button', {
name: '插入素材引用',
}),
).not.toBeNull();
});
});