creation-agent composer 上传文档和参考图入口改用 PlatformIconButton 工作台测试断言上传入口接入公共图标按钮 补充 PlatformUiKit 收口文档和 Hermes 决策记录
682 lines
19 KiB
TypeScript
682 lines
19 KiB
TypeScript
/* @vitest-environment jsdom */
|
||
|
||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||
import { afterEach, expect, test, vi } from 'vitest';
|
||
|
||
import * as creationAgentServices from '../../services/creation-agent';
|
||
import { createCreationAgentChatQuickActions } from '../../services/creation-agent';
|
||
import {
|
||
type CreationAgentTheme,
|
||
CreationAgentWorkspace,
|
||
} from './CreationAgentWorkspace';
|
||
|
||
const testTheme: CreationAgentTheme = {
|
||
accentTextClass: 'text-emerald-100',
|
||
accentBgClass: 'bg-emerald-300',
|
||
accentButtonClass: 'bg-emerald-200',
|
||
userBubbleClass: 'bg-emerald-600 text-white',
|
||
heroClass: 'border border-emerald-100/20 bg-slate-900',
|
||
};
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
test('creation agent workspace renders missing session notice with shared subpanel chrome', () => {
|
||
render(
|
||
<CreationAgentWorkspace
|
||
session={null}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
const noticePanel = screen
|
||
.getByText('正在准备')
|
||
.closest('.platform-subpanel');
|
||
|
||
expect(noticePanel?.className).toContain('rounded-[1rem]');
|
||
expect(noticePanel?.className).toContain('sm:p-5');
|
||
expect(noticePanel?.className).toContain('text-[var(--platform-text-base)]');
|
||
});
|
||
|
||
function ensureScrollApis() {
|
||
if (!Element.prototype.scrollIntoView) {
|
||
Element.prototype.scrollIntoView = () => {};
|
||
}
|
||
|
||
if (!HTMLElement.prototype.scrollTo) {
|
||
HTMLElement.prototype.scrollTo = () => {};
|
||
}
|
||
}
|
||
|
||
test('creation agent workspace keeps initial chat progress at zero percent', () => {
|
||
ensureScrollApis();
|
||
|
||
render(
|
||
<CreationAgentWorkspace
|
||
session={{
|
||
sessionId: 'creation-agent-session-1',
|
||
title: null,
|
||
currentTurn: 0,
|
||
progressPercent: 0,
|
||
anchors: [],
|
||
messages: [],
|
||
}}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
onReferenceImageChange={() => {}}
|
||
/>,
|
||
);
|
||
|
||
const progressbar = screen.getByRole('progressbar');
|
||
const documentUploadButton = screen.getByRole('button', {
|
||
name: '上传文档',
|
||
});
|
||
const referenceUploadButton = screen.getByRole('button', {
|
||
name: '上传参考图',
|
||
});
|
||
|
||
expect(progressbar.getAttribute('aria-valuenow')).toBe('0');
|
||
expect(progressbar.getAttribute('aria-labelledby')).toBe(
|
||
'creation-agent-progress-label',
|
||
);
|
||
expect(progressbar.className).toContain('platform-progress-track');
|
||
expect(
|
||
screen
|
||
.getByTestId('creation-agent-message-list')
|
||
.closest('.platform-subpanel')?.className,
|
||
).toContain('rounded-[1.5rem]');
|
||
expect(documentUploadButton.className).toContain('platform-icon-button');
|
||
expect(referenceUploadButton.className).toContain('platform-icon-button');
|
||
expect(
|
||
(progressbar.firstElementChild as HTMLElement | null)?.style.width,
|
||
).toBe('0%');
|
||
});
|
||
|
||
test('creation agent workspace renders operation banner with shared status message', () => {
|
||
ensureScrollApis();
|
||
|
||
render(
|
||
<CreationAgentWorkspace
|
||
session={{
|
||
sessionId: 'creation-agent-session-1',
|
||
title: null,
|
||
currentTurn: 0,
|
||
progressPercent: 0,
|
||
anchors: [],
|
||
messages: [],
|
||
}}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
activeOperation={{
|
||
operationId: 'operation-1',
|
||
status: 'running',
|
||
phaseLabel: '正在整理设定',
|
||
phaseDetail: '正在归纳角色和场景',
|
||
progress: 36,
|
||
}}
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
const banner = screen.getByText('正在整理设定').parentElement?.parentElement;
|
||
const progressbar = screen.getAllByRole('progressbar').at(-1);
|
||
|
||
expect(banner?.className).toContain('platform-remap-surface');
|
||
expect(banner?.className).toContain('bg-[var(--platform-cool-bg)]');
|
||
expect(screen.getByText('正在归纳角色和场景')).toBeTruthy();
|
||
expect(progressbar?.className).toContain('platform-progress-track');
|
||
expect(progressbar?.getAttribute('aria-valuenow')).toBe('36');
|
||
});
|
||
|
||
test('creation agent workspace filters duplicate recommended replies', () => {
|
||
const consoleErrorSpy = vi
|
||
.spyOn(console, 'error')
|
||
.mockImplementation(() => undefined);
|
||
|
||
ensureScrollApis();
|
||
|
||
render(
|
||
<CreationAgentWorkspace
|
||
session={{
|
||
sessionId: 'creation-agent-session-1',
|
||
title: '统一共创',
|
||
currentTurn: 2,
|
||
progressPercent: 40,
|
||
anchors: [],
|
||
messages: [
|
||
{
|
||
id: '',
|
||
role: 'assistant',
|
||
kind: 'chat',
|
||
text: '先把方向收一下。',
|
||
},
|
||
],
|
||
recommendedReplies: [
|
||
'',
|
||
'继续补充冲突',
|
||
'继续补充冲突',
|
||
' 先确定玩家身份 ',
|
||
],
|
||
}}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getByRole('button', { name: '继续补充冲突' })).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: '先确定玩家身份' })).toBeTruthy();
|
||
expect(screen.queryByRole('button', { name: /^\s*$/u })).toBeNull();
|
||
|
||
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
|
||
call.some(
|
||
(arg) =>
|
||
typeof arg === 'string' &&
|
||
arg.includes('Encountered two children with the same key'),
|
||
),
|
||
);
|
||
|
||
expect(duplicateKeyCalls).toHaveLength(0);
|
||
});
|
||
|
||
test('creation agent workspace renders streaming assistant text', () => {
|
||
ensureScrollApis();
|
||
|
||
render(
|
||
<CreationAgentWorkspace
|
||
session={{
|
||
sessionId: 'creation-agent-session-1',
|
||
title: '统一共创',
|
||
currentTurn: 1,
|
||
progressPercent: 20,
|
||
anchors: [],
|
||
messages: [
|
||
{
|
||
id: 'message-1',
|
||
role: 'user',
|
||
kind: 'chat',
|
||
text: '我想做一个潮湿压抑的海上世界。',
|
||
},
|
||
],
|
||
}}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
streamingReplyText="那我先顺着这个方向收一下,开场时你更想让玩家撞上什么麻烦"
|
||
isStreamingReply
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getByText(/那我先顺着这个方向收一下/u)).toBeTruthy();
|
||
});
|
||
|
||
test('creation agent workspace renders waiting dots before first streamed token', () => {
|
||
ensureScrollApis();
|
||
|
||
render(
|
||
<CreationAgentWorkspace
|
||
session={{
|
||
sessionId: 'creation-agent-session-1',
|
||
title: '统一共创',
|
||
currentTurn: 1,
|
||
progressPercent: 20,
|
||
anchors: [],
|
||
messages: [
|
||
{
|
||
id: 'message-1',
|
||
role: 'user',
|
||
kind: 'chat',
|
||
text: '我想做一个潮湿压抑的海上世界。',
|
||
},
|
||
],
|
||
}}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
streamingReplyText=""
|
||
isStreamingReply
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getByTestId('creation-agent-waiting-dots')).toBeTruthy();
|
||
});
|
||
|
||
test('creation agent workspace appends streaming assistant message after stable message list', () => {
|
||
ensureScrollApis();
|
||
|
||
render(
|
||
<CreationAgentWorkspace
|
||
session={{
|
||
sessionId: 'creation-agent-session-1',
|
||
title: '统一共创',
|
||
currentTurn: 2,
|
||
progressPercent: 40,
|
||
anchors: [],
|
||
messages: [
|
||
{
|
||
id: 'message-user-1',
|
||
role: 'user',
|
||
kind: 'chat',
|
||
text: '我想做一个潮湿压抑的海上世界。',
|
||
},
|
||
{
|
||
id: 'message-assistant-1',
|
||
role: 'assistant',
|
||
kind: 'chat',
|
||
text: '我先接住这个方向。',
|
||
},
|
||
{
|
||
id: 'message-user-2',
|
||
role: 'user',
|
||
kind: 'chat',
|
||
text: '开场我想先撞上一场假航灯事故。',
|
||
},
|
||
],
|
||
}}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
streamingReplyText="那我就把开场事故往沉船旧案上收。"
|
||
isStreamingReply
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
const bubbles = screen
|
||
.getByTestId('creation-agent-message-list')
|
||
.querySelectorAll('.whitespace-pre-wrap');
|
||
const bubbleTexts = Array.from(bubbles).map((node) =>
|
||
node.textContent?.trim(),
|
||
);
|
||
|
||
expect(bubbleTexts).toEqual([
|
||
'我想做一个潮湿压抑的海上世界。',
|
||
'我先接住这个方向。',
|
||
'开场我想先撞上一场假航灯事故。',
|
||
'那我就把开场事故往沉船旧案上收。',
|
||
]);
|
||
});
|
||
|
||
test('creation agent workspace hides anchors and primary action before completed progress', () => {
|
||
ensureScrollApis();
|
||
|
||
render(
|
||
<CreationAgentWorkspace
|
||
session={{
|
||
sessionId: 'creation-agent-session-1',
|
||
title: '统一共创',
|
||
currentTurn: 2,
|
||
progressPercent: 99,
|
||
anchors: [
|
||
{
|
||
key: 'worldPromise',
|
||
label: '世界承诺',
|
||
value: '一个被潮雾改写航线秩序的群岛世界。',
|
||
status: 'confirmed',
|
||
},
|
||
],
|
||
messages: [
|
||
{
|
||
id: 'message-1',
|
||
role: 'assistant',
|
||
kind: 'chat',
|
||
text: '我们继续把设定收住。',
|
||
},
|
||
],
|
||
}}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.queryByRole('button', { name: '生成结果页' })).toBeNull();
|
||
expect(screen.queryByText('世界承诺')).toBeNull();
|
||
expect(screen.queryByText('一个被潮雾改写航线秩序的群岛世界。')).toBeNull();
|
||
});
|
||
|
||
test('creation agent workspace shows primary and progress actions at completed progress', () => {
|
||
ensureScrollApis();
|
||
|
||
render(
|
||
<CreationAgentWorkspace
|
||
session={{
|
||
sessionId: 'creation-agent-session-1',
|
||
title: '统一共创',
|
||
currentTurn: 2,
|
||
progressPercent: 100,
|
||
anchors: [],
|
||
messages: [
|
||
{
|
||
id: 'message-1',
|
||
role: 'assistant',
|
||
kind: 'chat',
|
||
text: '设定已经可以进入生成。',
|
||
},
|
||
],
|
||
}}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
quickActions={createCreationAgentChatQuickActions()}
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getByRole('button', { name: '生成结果页' })).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: '总结当前设定' })).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: '补充剩余设定' })).toBeTruthy();
|
||
});
|
||
|
||
test('creation agent workspace hides hero copy area when title and summary are absent', () => {
|
||
if (!Element.prototype.scrollIntoView) {
|
||
Element.prototype.scrollIntoView = () => {};
|
||
}
|
||
|
||
render(
|
||
<CreationAgentWorkspace
|
||
session={{
|
||
sessionId: 'creation-agent-session-1',
|
||
title: null,
|
||
assistantSummary: null,
|
||
currentTurn: 2,
|
||
progressPercent: 60,
|
||
anchors: [],
|
||
messages: [
|
||
{
|
||
id: 'message-1',
|
||
role: 'assistant',
|
||
kind: 'chat',
|
||
text: '继续把设定收束到可生成状态。',
|
||
},
|
||
],
|
||
}}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.queryByText('统一共创')).toBeNull();
|
||
expect(screen.getByText('创作进度').className).toContain(
|
||
'creation-agent-hero__progress-label',
|
||
);
|
||
expect(screen.getByText('60%').className).toContain(
|
||
'creation-agent-hero__progress-value',
|
||
);
|
||
expect(screen.getByText(/方向已经成形/u).className).toContain(
|
||
'creation-agent-hero__progress-hint',
|
||
);
|
||
});
|
||
|
||
test('creation agent workspace stops auto-follow when user scrolls away from bottom', () => {
|
||
ensureScrollApis();
|
||
|
||
const scrollToSpy = vi.fn();
|
||
HTMLElement.prototype.scrollTo = scrollToSpy;
|
||
|
||
const { rerender } = render(
|
||
<CreationAgentWorkspace
|
||
session={{
|
||
sessionId: 'creation-agent-session-1',
|
||
title: '统一共创',
|
||
currentTurn: 1,
|
||
progressPercent: 20,
|
||
anchors: [],
|
||
messages: [
|
||
{
|
||
id: 'message-1',
|
||
role: 'assistant',
|
||
kind: 'chat',
|
||
text: '先确定一下世界方向。',
|
||
},
|
||
],
|
||
}}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
const messageList = screen.getByTestId('creation-agent-message-list');
|
||
let scrollTop = 120;
|
||
|
||
Object.defineProperty(messageList, 'scrollHeight', {
|
||
configurable: true,
|
||
value: 640,
|
||
});
|
||
Object.defineProperty(messageList, 'clientHeight', {
|
||
configurable: true,
|
||
value: 240,
|
||
});
|
||
Object.defineProperty(messageList, 'scrollTop', {
|
||
configurable: true,
|
||
get: () => scrollTop,
|
||
set: (value) => {
|
||
scrollTop = Number(value);
|
||
},
|
||
});
|
||
|
||
fireEvent.scroll(messageList);
|
||
scrollToSpy.mockClear();
|
||
|
||
rerender(
|
||
<CreationAgentWorkspace
|
||
session={{
|
||
sessionId: 'creation-agent-session-1',
|
||
title: '统一共创',
|
||
currentTurn: 1,
|
||
progressPercent: 20,
|
||
anchors: [],
|
||
messages: [
|
||
{
|
||
id: 'message-1',
|
||
role: 'assistant',
|
||
kind: 'chat',
|
||
text: '先确定一下世界方向。',
|
||
},
|
||
],
|
||
}}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
streamingReplyText="继续往下收束开场冲突。"
|
||
isStreamingReply
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
expect(scrollToSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('creation agent workspace appends parsed document text into composer', async () => {
|
||
ensureScrollApis();
|
||
|
||
vi.spyOn(
|
||
creationAgentServices,
|
||
'parseCreationAgentDocumentInput',
|
||
).mockResolvedValue({
|
||
document: {
|
||
fileName: '世界设定.md',
|
||
contentType: 'text/markdown',
|
||
sizeBytes: 24,
|
||
text: '第一章:潮湿的港口',
|
||
},
|
||
});
|
||
|
||
render(
|
||
<CreationAgentWorkspace
|
||
session={{
|
||
sessionId: 'creation-agent-session-1',
|
||
title: null,
|
||
currentTurn: 0,
|
||
progressPercent: 0,
|
||
anchors: [],
|
||
messages: [],
|
||
}}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.change(screen.getByPlaceholderText('输入消息'), {
|
||
target: {
|
||
value: '已有方向',
|
||
},
|
||
});
|
||
|
||
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
|
||
expect(input).toBeTruthy();
|
||
|
||
fireEvent.change(input!, {
|
||
target: {
|
||
files: [new File(['unused'], '世界设定.md', { type: 'text/markdown' })],
|
||
},
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
(screen.getByPlaceholderText('输入消息') as HTMLTextAreaElement).value,
|
||
).toBe('已有方向\n\n第一章:潮湿的港口');
|
||
});
|
||
});
|
||
|
||
test('creation agent workspace renders selected reference image with shared preview row', async () => {
|
||
ensureScrollApis();
|
||
|
||
const onClearReferenceImage = vi.fn();
|
||
|
||
render(
|
||
<CreationAgentWorkspace
|
||
session={{
|
||
sessionId: 'creation-agent-session-1',
|
||
title: null,
|
||
currentTurn: 0,
|
||
progressPercent: 0,
|
||
anchors: [],
|
||
messages: [],
|
||
}}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
referenceImagePreviewSrc="data:image/png;base64,reference"
|
||
referenceImageLabel="设定参考图.png"
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
onClearReferenceImage={onClearReferenceImage}
|
||
/>,
|
||
);
|
||
|
||
const image = screen.getByRole('img', { name: '参考图' });
|
||
const row = screen.getByText('设定参考图.png').parentElement;
|
||
const removeButton = screen.getByRole('button', { name: '移除参考图' });
|
||
|
||
expect(image.getAttribute('src')).toBe('data:image/png;base64,reference');
|
||
expect(row?.className).toContain('bg-white/70');
|
||
expect(row?.className).toContain('mx-4');
|
||
|
||
fireEvent.click(removeButton);
|
||
|
||
expect(onClearReferenceImage).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
test('creation agent workspace shows document parse error near composer', async () => {
|
||
ensureScrollApis();
|
||
|
||
vi.spyOn(
|
||
creationAgentServices,
|
||
'parseCreationAgentDocumentInput',
|
||
).mockRejectedValue(new Error('暂时只支持 txt、md、csv、json 文本文档。'));
|
||
|
||
render(
|
||
<CreationAgentWorkspace
|
||
session={{
|
||
sessionId: 'creation-agent-session-1',
|
||
title: null,
|
||
currentTurn: 0,
|
||
progressPercent: 0,
|
||
anchors: [],
|
||
messages: [],
|
||
}}
|
||
theme={testTheme}
|
||
loadingText="正在准备"
|
||
composerPlaceholder="输入消息"
|
||
primaryActionLabel="生成结果页"
|
||
onBack={() => {}}
|
||
onSubmitText={() => {}}
|
||
onPrimaryAction={() => {}}
|
||
/>,
|
||
);
|
||
|
||
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
|
||
expect(input).toBeTruthy();
|
||
|
||
fireEvent.change(input!, {
|
||
target: {
|
||
files: [new File(['unused'], '世界设定.docx')],
|
||
},
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
screen.getByText('暂时只支持 txt、md、csv、json 文本文档。'),
|
||
).toBeTruthy();
|
||
});
|
||
});
|