补充 C4 引用页签与 C8 润色提醒的定向测试
- 新增 chatPromptPolish.test.tsx:覆盖提醒判据、草稿指纹、本机偏好读写与润色命令调用 - 覆盖主动润色回填、可重复润色覆盖结果、恢复原文回到最初原文 - 覆盖润色失败保留原文并在提醒面板内给出重试提示 - 覆盖长文本提交被提醒面板拦下、使用原文提交、关闭取消发送、AI 润色后发送 - 覆盖不再提醒偏好落本机 localStorage 后不再拦截,以及挂载时读取该偏好 - 覆盖短文本与命令不触发提醒 - resourceReferenceInput.test.tsx 新增:两个页签的独立搜索与筛选状态 - 新增:activeVersionId、版本回退、悬空绑定过滤与空态 - 新增:资源改名后 chip 与候选列表显示名刷新 - 新增:跨会话恢复草稿后光标落在文本末尾,引用追加在文本之后
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH,
|
||||
CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY,
|
||||
chatPromptDraftKey,
|
||||
readChatPromptPolishReminderDisabled,
|
||||
requestChatPromptPolish,
|
||||
shouldRemindChatPromptPolish,
|
||||
writeChatPromptPolishReminderDisabled,
|
||||
} from '../src/features/project-workspace/chatPromptPolish';
|
||||
import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput';
|
||||
import {
|
||||
type ChatComposerDraft,
|
||||
type ChatReference,
|
||||
resourceReferenceFromAsset,
|
||||
} from '../src/features/project-workspace/resourceReferences';
|
||||
|
||||
type TauriInvoke = (
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
|
||||
const LONG_PROMPT =
|
||||
'做一个像素风横版动作小游戏,包含三段跳跃关卡、三个 Boss 和可以收集的金币与道具。';
|
||||
|
||||
function installTauriInvoke(invoke: TauriInvoke) {
|
||||
const mock = vi.fn(invoke);
|
||||
(
|
||||
window as unknown as {
|
||||
__TAURI__?: { core?: { invoke?: typeof mock } };
|
||||
}
|
||||
).__TAURI__ = { core: { invoke: mock } };
|
||||
return mock;
|
||||
}
|
||||
|
||||
function installPolishingInvoke(...results: string[]) {
|
||||
const queue = [...results];
|
||||
return installTauriInvoke(async (command) => {
|
||||
if (command !== 'polish_local_project_prompt') return undefined;
|
||||
const next = queue.shift();
|
||||
if (next === undefined) {
|
||||
throw new Error('fixture has no more polish results');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Lexical 的编辑器状态提交排在微任务里,读取输入区文本前先让 React 追平编辑器内容。
|
||||
async function settleComposer() {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
async function composerText() {
|
||||
await settleComposer();
|
||||
return screen.getByLabelText('创作想法').textContent ?? '';
|
||||
}
|
||||
|
||||
function ControlledChatComposer({
|
||||
initialText,
|
||||
initialReferences = [],
|
||||
onSubmitDraft,
|
||||
}: {
|
||||
initialText: string;
|
||||
initialReferences?: ChatReference[];
|
||||
onSubmitDraft: (draft: ChatComposerDraft) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<ChatComposerDraft>({
|
||||
text: initialText,
|
||||
references: initialReferences,
|
||||
});
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmitDraft(draft);
|
||||
}}
|
||||
>
|
||||
<ResourceReferenceInput
|
||||
value={draft.text}
|
||||
references={draft.references}
|
||||
onChange={setDraft}
|
||||
assets={[]}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="创作想法"
|
||||
/>
|
||||
<button type="submit">发送</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function renderComposer({
|
||||
initialText,
|
||||
initialReferences,
|
||||
onSubmitDraft = vi.fn(),
|
||||
}: {
|
||||
initialText: string;
|
||||
initialReferences?: ChatReference[];
|
||||
onSubmitDraft?: (draft: ChatComposerDraft) => void;
|
||||
}) {
|
||||
render(
|
||||
<ControlledChatComposer
|
||||
initialText={initialText}
|
||||
initialReferences={initialReferences}
|
||||
onSubmitDraft={onSubmitDraft}
|
||||
/>,
|
||||
);
|
||||
return onSubmitDraft;
|
||||
}
|
||||
|
||||
function sendButton() {
|
||||
return screen.getByRole('button', { name: '发送' });
|
||||
}
|
||||
|
||||
function reminderPanel() {
|
||||
return screen.getByRole('dialog', { name: '发送前提醒' });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete (
|
||||
window as unknown as { __TAURI__?: { core?: { invoke?: TauriInvoke } } }
|
||||
).__TAURI__;
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('发送前提醒判据', () => {
|
||||
test('only reminds for long plain prompts that were not acknowledged this round', () => {
|
||||
const draft: ChatComposerDraft = {
|
||||
text: '字'.repeat(CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH),
|
||||
references: [],
|
||||
};
|
||||
expect(
|
||||
shouldRemindChatPromptPolish({
|
||||
draft,
|
||||
acknowledgedDraftKey: null,
|
||||
reminderDisabled: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRemindChatPromptPolish({
|
||||
draft: { text: '短需求', references: [] },
|
||||
acknowledgedDraftKey: null,
|
||||
reminderDisabled: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRemindChatPromptPolish({
|
||||
draft,
|
||||
acknowledgedDraftKey: null,
|
||||
reminderDisabled: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRemindChatPromptPolish({
|
||||
draft,
|
||||
acknowledgedDraftKey: chatPromptDraftKey(draft),
|
||||
reminderDisabled: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRemindChatPromptPolish({
|
||||
draft: { text: `/${'长'.repeat(60)}`, references: [] },
|
||||
acknowledgedDraftKey: null,
|
||||
reminderDisabled: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('changes the draft key when the references change', () => {
|
||||
const reference = resourceReferenceFromAsset(
|
||||
{
|
||||
id: 'hero',
|
||||
kind: 'character',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/hero.png',
|
||||
source: { kind: 'uploaded' },
|
||||
},
|
||||
'asset-picker',
|
||||
);
|
||||
expect(
|
||||
chatPromptDraftKey({ text: '需求', references: [] }),
|
||||
).not.toBe(chatPromptDraftKey({ text: '需求', references: [reference] }));
|
||||
});
|
||||
|
||||
test('persists the 不再提醒 preference on this machine only', () => {
|
||||
expect(readChatPromptPolishReminderDisabled()).toBe(false);
|
||||
writeChatPromptPolishReminderDisabled(true);
|
||||
expect(window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY))
|
||||
.toBe('true');
|
||||
expect(readChatPromptPolishReminderDisabled()).toBe(true);
|
||||
writeChatPromptPolishReminderDisabled(false);
|
||||
expect(window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY))
|
||||
.toBeNull();
|
||||
expect(readChatPromptPolishReminderDisabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestChatPromptPolish', () => {
|
||||
test('goes through the short-text Tauri command with trimmed input', async () => {
|
||||
const invoke = installPolishingInvoke(' 润色后的需求 ');
|
||||
await expect(
|
||||
requestChatPromptPolish(' 原始需求 ', ' 项目上下文 '),
|
||||
).resolves.toBe('润色后的需求');
|
||||
expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', {
|
||||
prompt: '原始需求',
|
||||
context: '项目上下文',
|
||||
});
|
||||
});
|
||||
|
||||
test('omits blank context and keeps failures recoverable', async () => {
|
||||
const invoke = installPolishingInvoke('润色结果');
|
||||
await requestChatPromptPolish('原始需求', ' ');
|
||||
expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', {
|
||||
prompt: '原始需求',
|
||||
context: null,
|
||||
});
|
||||
|
||||
const failing = vi.fn(async () => {
|
||||
throw new Error('platform llm unavailable');
|
||||
});
|
||||
installTauriInvoke(failing);
|
||||
await expect(requestChatPromptPolish('原始需求')).resolves.toBeNull();
|
||||
await expect(requestChatPromptPolish(' ')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('treats an empty model reply as a failure', async () => {
|
||||
installPolishingInvoke(' ');
|
||||
await expect(requestChatPromptPolish('原始需求')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when the native bridge is unavailable', async () => {
|
||||
delete (
|
||||
window as unknown as { __TAURI__?: { core?: { invoke?: TauriInvoke } } }
|
||||
).__TAURI__;
|
||||
await expect(requestChatPromptPolish('原始需求')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('聊天输入区 AI 润色与发送前提醒', () => {
|
||||
test('fills back the polished result, can polish again, and always restores the first original', async () => {
|
||||
const user = userEvent.setup();
|
||||
const invoke = installPolishingInvoke('第一版润色结果', '第二版润色结果');
|
||||
renderComposer({ initialText: '原本的需求' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI 润色' }));
|
||||
expect(await composerText()).toBe('第一版润色结果');
|
||||
expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', {
|
||||
prompt: '原本的需求',
|
||||
context: 'C:/project',
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI 润色' }));
|
||||
expect(await composerText()).toBe('第二版润色结果');
|
||||
expect(invoke.mock.calls.at(-1)?.[1]).toEqual({
|
||||
prompt: '第一版润色结果',
|
||||
context: 'C:/project',
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '恢复原文' }));
|
||||
expect(await composerText()).toBe('原本的需求');
|
||||
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the original text and shows a retry hint when polishing fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
installTauriInvoke(async (command) => {
|
||||
if (command !== 'polish_local_project_prompt') return undefined;
|
||||
throw new Error('platform llm timeout');
|
||||
});
|
||||
renderComposer({ initialText: '原本的需求' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI 润色' }));
|
||||
expect(await composerText()).toBe('原本的需求');
|
||||
expect(await screen.findByText('AI 润色失败,可重试')).not.toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
|
||||
});
|
||||
|
||||
test('holds a long prompt behind the reminder panel and sends the original on demand', async () => {
|
||||
const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT });
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
expect(reminderPanel()).not.toBeNull();
|
||||
expect(onSubmitDraft).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(
|
||||
within(reminderPanel()).getByRole('button', { name: '使用原文提交' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(onSubmitDraft).toHaveBeenCalledWith({
|
||||
text: LONG_PROMPT,
|
||||
references: [],
|
||||
});
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull();
|
||||
});
|
||||
|
||||
test('closing the reminder cancels the send and keeps the reminder armed', async () => {
|
||||
const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT });
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
fireEvent.click(
|
||||
within(reminderPanel()).getByRole('button', { name: '关闭' }),
|
||||
);
|
||||
expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull();
|
||||
expect(onSubmitDraft).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
expect(reminderPanel()).not.toBeNull();
|
||||
expect(onSubmitDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('polishes first and then submits the polished prompt', async () => {
|
||||
installPolishingInvoke('润色后的长需求');
|
||||
const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT });
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
fireEvent.click(
|
||||
within(reminderPanel()).getByRole('button', { name: 'AI 润色' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(onSubmitDraft).toHaveBeenCalledWith({
|
||||
text: '润色后的长需求',
|
||||
references: [],
|
||||
});
|
||||
});
|
||||
expect(await composerText()).toBe('润色后的长需求');
|
||||
});
|
||||
|
||||
test('keeps the reminder open with the original text when the in-panel polish fails', async () => {
|
||||
installTauriInvoke(async () => {
|
||||
throw new Error('platform llm timeout');
|
||||
});
|
||||
const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT });
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
fireEvent.click(
|
||||
within(reminderPanel()).getByRole('button', { name: 'AI 润色' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('AI 润色失败,可重试或使用原文提交'),
|
||||
).not.toBeNull();
|
||||
expect(onSubmitDraft).not.toHaveBeenCalled();
|
||||
expect(await composerText()).toBe(LONG_PROMPT);
|
||||
|
||||
fireEvent.click(
|
||||
within(reminderPanel()).getByRole('button', { name: '使用原文提交' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(onSubmitDraft).toHaveBeenCalledWith({
|
||||
text: LONG_PROMPT,
|
||||
references: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('persists 不再提醒 locally and stops holding later sends', async () => {
|
||||
const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT });
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
fireEvent.click(
|
||||
within(reminderPanel()).getByRole('checkbox', { name: '不再提醒' }),
|
||||
);
|
||||
expect(window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY))
|
||||
.toBe('true');
|
||||
|
||||
fireEvent.click(
|
||||
within(reminderPanel()).getByRole('button', { name: '关闭' }),
|
||||
);
|
||||
fireEvent.click(sendButton());
|
||||
await waitFor(() => {
|
||||
expect(onSubmitDraft).toHaveBeenCalledWith({
|
||||
text: LONG_PROMPT,
|
||||
references: [],
|
||||
});
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull();
|
||||
});
|
||||
|
||||
test('reads the stored 不再提醒 preference when the composer mounts', () => {
|
||||
window.localStorage.setItem(
|
||||
CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY,
|
||||
'true',
|
||||
);
|
||||
const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT });
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull();
|
||||
expect(onSubmitDraft).toHaveBeenCalledWith({
|
||||
text: LONG_PROMPT,
|
||||
references: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('does not hold short prompts or slash commands', () => {
|
||||
const shortSubmit = renderComposer({ initialText: '做个跳跃游戏' });
|
||||
fireEvent.click(sendButton());
|
||||
expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull();
|
||||
expect(shortSubmit).toHaveBeenCalledWith({
|
||||
text: '做个跳跃游戏',
|
||||
references: [],
|
||||
});
|
||||
|
||||
cleanup();
|
||||
const commandSubmit = renderComposer({
|
||||
initialText: `/${'命令'.repeat(40)}`,
|
||||
});
|
||||
fireEvent.click(sendButton());
|
||||
expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull();
|
||||
expect(commandSubmit).toHaveBeenCalledWith({
|
||||
text: `/${'命令'.repeat(40)}`,
|
||||
references: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,10 @@ import userEvent from '@testing-library/user-event';
|
||||
import { StrictMode } from 'react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type {
|
||||
GameCreationAppAssetManifestEntry,
|
||||
GameIterationVersion,
|
||||
} from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
LOCAL_GAME_PREVIEW_INSPECT_MESSAGE,
|
||||
parseLocalGamePreviewInspectMessage,
|
||||
@@ -13,13 +16,16 @@ import { ResourceReferenceInput } from '../src/features/project-workspace/Resour
|
||||
import {
|
||||
type ChatComposerDraft,
|
||||
type ChatReference,
|
||||
currentIterationVersionAssets,
|
||||
dispatchResourceReferenceInsert,
|
||||
RESOURCE_REFERENCE_FILTERS,
|
||||
RESOURCE_REFERENCE_INSERT_EVENT,
|
||||
RESOURCE_REFERENCE_SCOPES,
|
||||
resourceReferenceCategory,
|
||||
resourceReferenceFromAsset,
|
||||
resourceReferenceMatchesCategoryFilter,
|
||||
resourceReferenceMatchesQuery,
|
||||
resolveActiveIterationVersion,
|
||||
} from '../src/features/project-workspace/resourceReferences';
|
||||
|
||||
function asset(
|
||||
@@ -37,6 +43,30 @@ function asset(
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
const assets = [
|
||||
asset('hero', 'character', 'image/png', 'assets/hero.png'),
|
||||
asset('enemy', 'character', 'image/png', 'assets/enemy.png'),
|
||||
@@ -273,4 +303,207 @@ describe('ResourceReferenceInput', () => {
|
||||
expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user