Files
Genarrative/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx
T
suzmii 35d3a92afd 快速编辑提示词支持 @ 引用素材,@ 面板补标签筛选
- 快速编辑面板提示词换成与聊天同一个 `ResourceReferenceInput`,`@` 候选、插入动作与引用模型全部复用聊天那一套
- 共享 composer / 快速编辑面板新增 `promptInputSlot` 注入位,宿主控件替换默认 textarea,不传时 DOM 与行为逐字不变
- `ResourceReferenceInput` 新增 `showPolishAction`:快速编辑的润色仍由 ResourcePromptPolishSlot 承担,面板里不再出现第二个润色入口
- `ResourceReferenceInput` 的多行输入区把 Enter 留给换行,只有单行输入区才用 Enter 提交
- `@` 面板的搜索与筛选改用共享 `PlatformResourceFilterBar`,搜索 + 功能分类 + 标签三者在面板内叠加生效
- 新增 `resourceReferenceMatchesTagSelection` / `resourceReferenceTagLibrary`,标签口径(多标签 AND、计数与排序)落在共享标签库,两个页签各持一份
- 宿主新增 `quickEditReferences` 与 `applyResourceQuickEditDraft`,派生请求的 prompt 就是输入区回填的那份文本(`@显示名`),不引入第二种引用格式
- `useImageCanvasFloatingOptionDismiss` 新增 `isInsideExtraOverlay`:宿主 portal 到 body 的 `@` 选择器与候选菜单不再被判成点外部而收起快速编辑面板
- 资源画布快速编辑提示词输入区补样式(铺满 composer 一整行、高度上限),`@` 面板去掉手写搜索行样式
- 用例:新增标签库口径、标签筛选(多标签叠加 + 关键字 + 功能分类)、两页签独立标签、聊天与快速编辑草稿一致性、快速编辑内 `@` 出选择器且派生 payload 断言;扩展 AGC 资源工作台 V3 端到端验收用例
2026-09-12 16:44:55 +08:00

851 lines
30 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,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { $getRoot } from 'lexical';
import { StrictMode } 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,
currentIterationVersionAssets,
dispatchResourceReferenceInsert,
resolveActiveIterationVersion,
RESOURCE_REFERENCE_FILTERS,
RESOURCE_REFERENCE_INSERT_EVENT,
RESOURCE_REFERENCE_SCOPES,
resourceReferenceCategory,
resourceReferenceFromAsset,
resourceReferenceMatchesCategoryFilter,
resourceReferenceMatchesQuery,
resourceReferenceMatchesTagSelection,
resourceReferenceTagLibrary,
} 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('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,
resourceIds: ['hero', 'bad id', '../secret'],
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'],
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();
});
});