Files
Genarrative/src/components/creation-agent/CreationAgentWorkspace.test.tsx
T
kdletters bda650e598 接入创作参考图原生导入
Creation Agent参考图上传优先复用file.importImage宿主能力

补充原生导入取消和浏览器回退测试

更新宿主壳协议、方案文档和共享记忆
2026-06-19 09:57:56 +08:00

1215 lines
34 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 * as hostBridgeServices from '../../services/host-bridge/hostBridge';
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');
const emptyState = screen.getByText('暂无消息');
const composer = screen.getByPlaceholderText('输入消息');
expect(emptyState.className).toContain('platform-empty-state');
expect(emptyState.className).toContain('h-full');
expect(composer.className).toContain('platform-text-field');
expect(composer.className).toContain('min-h-[3rem]');
expect(composer.className).toContain('flex-1');
expect(
(progressbar.firstElementChild as HTMLElement | null)?.style.width,
).toBe('0%');
});
test('creation agent workspace keeps composer keyboard submit semantics', () => {
ensureScrollApis();
const onSubmitText = vi.fn();
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: null,
currentTurn: 0,
progressPercent: 0,
anchors: [],
messages: [],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={onSubmitText}
onPrimaryAction={() => {}}
/>,
);
const composer = screen.getByPlaceholderText('输入消息') as HTMLTextAreaElement;
fireEvent.change(composer, {
target: { value: ' 继续推进设定 ' },
});
fireEvent.keyDown(composer, { key: 'Enter', shiftKey: true });
expect(onSubmitText).not.toHaveBeenCalled();
fireEvent.keyDown(composer, { key: 'Enter' });
expect(onSubmitText).toHaveBeenCalledWith('继续推进设定');
expect(composer.value).toBe('');
});
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 imports DOCX through native HostBridge document flow', async () => {
ensureScrollApis();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation(
(capability) => capability === 'file.importDocument',
);
const importTextSpy = vi
.spyOn(hostBridgeServices, 'importHostTextFile')
.mockResolvedValue(false);
vi.spyOn(hostBridgeServices, 'importHostDocumentFile').mockResolvedValue({
action: 'selected',
fileName: '世界设定.docx',
base64Data: 'UEsDBGRvY3g=',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
});
const parseSpy = vi
.spyOn(creationAgentServices, 'parseCreationAgentDocumentInput')
.mockResolvedValue({
document: {
fileName: '世界设定.docx',
contentType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
sizeBytes: 8,
text: '第一章:雾中的钟楼',
},
});
try {
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.click(screen.getByRole('button', { name: '上传文档' }));
await waitFor(() => {
expect(
(screen.getByPlaceholderText('输入消息') as HTMLTextAreaElement).value,
).toBe('第一章:雾中的钟楼');
});
expect(parseSpy).toHaveBeenCalledWith(expect.any(File));
const parsedFile = parseSpy.mock.calls[0]?.[0] as File;
expect(parsedFile.name).toBe('世界设定.docx');
expect(parsedFile.type).toBe(
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
);
expect(parsedFile.size).toBe(8);
expect(importTextSpy).not.toHaveBeenCalled();
expect(inputClickSpy).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
test('creation agent workspace falls back to native text import for older shells', async () => {
ensureScrollApis();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation(
(capability) => capability === 'file.importText',
);
vi.spyOn(hostBridgeServices, 'importHostTextFile').mockResolvedValue({
action: 'selected',
fileName: '世界设定.md',
content: '原生壳导入的世界设定',
mimeType: 'text/markdown',
bytes: 30,
});
const parseSpy = vi
.spyOn(creationAgentServices, 'parseCreationAgentDocumentInput')
.mockResolvedValue({
document: {
fileName: '世界设定.md',
contentType: 'text/markdown',
sizeBytes: 30,
text: '第一章:雾中的钟楼',
},
});
try {
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.click(screen.getByRole('button', { name: '上传文档' }));
await waitFor(() => {
expect(
(screen.getByPlaceholderText('输入消息') as HTMLTextAreaElement).value,
).toBe('第一章:雾中的钟楼');
});
expect(parseSpy).toHaveBeenCalledWith(expect.any(File));
const parsedFile = parseSpy.mock.calls[0]?.[0] as File;
expect(parsedFile.name).toBe('世界设定.md');
expect(parsedFile.type).toBe('text/markdown');
expect(inputClickSpy).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
test('creation agent workspace exports session markdown through native HostBridge', async () => {
ensureScrollApis();
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation(
(capability) => capability === 'file.exportText',
);
const exportSpy = vi
.spyOn(hostBridgeServices, 'exportHostTextFile')
.mockResolvedValue({
action: 'saved',
fileName: '创作记录-潮声工坊.md',
bytes: 120,
});
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '潮声工坊',
assistantSummary: '围绕港口工坊推进设定。',
currentTurn: 2,
progressPercent: 64,
anchors: [
{
key: 'tone',
label: '基调',
value: '温暖悬疑',
status: 'confirmed',
},
],
messages: [
{
id: 'message-1',
role: 'user',
kind: 'chat',
text: '主角想找回失踪的师傅。',
},
{
id: 'message-2',
role: 'assistant',
kind: 'chat',
text: '可以把工坊设成潮汐驱动。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
fireEvent.change(screen.getByPlaceholderText('输入消息'), {
target: {
value: '补充当前草稿',
},
});
fireEvent.click(screen.getByRole('button', { name: '导出会话' }));
await waitFor(() => {
expect(exportSpy).toHaveBeenCalledTimes(1);
});
const payload = exportSpy.mock.calls[0]?.[0];
expect(payload?.fileName).toBe('创作记录-潮声工坊.md');
expect(payload?.mimeType).toBe('text/markdown');
expect(payload?.content).toContain('# 潮声工坊');
expect(payload?.content).toContain('创作进度: 64%');
expect(payload?.content).toContain('- 基调: 温暖悬疑 (confirmed)');
expect(payload?.content).toContain('### 用户');
expect(payload?.content).toContain('主角想找回失踪的师傅。');
expect(payload?.content).toContain('## 当前输入');
expect(payload?.content).toContain('补充当前草稿');
});
test('creation agent workspace hides native export when capability or content is missing', () => {
ensureScrollApis();
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockReturnValue(
false,
);
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={() => {}}
/>,
);
expect(screen.queryByRole('button', { name: '导出会话' })).toBeNull();
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockReturnValue(
true,
);
rerender(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-2',
title: null,
assistantSummary: null,
currentTurn: 0,
progressPercent: 0,
anchors: [],
messages: [],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(screen.queryByRole('button', { name: '导出会话' })).toBeNull();
});
test('creation agent workspace shows native export errors near composer', async () => {
ensureScrollApis();
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation(
(capability) => capability === 'file.exportText',
);
vi.spyOn(hostBridgeServices, 'exportHostTextFile').mockRejectedValue(
new Error('系统保存面板暂时不可用。'),
);
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={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '导出会话' }));
await waitFor(() => {
const errorMessage = screen.getByText('系统保存面板暂时不可用。');
expect(errorMessage).toBeTruthy();
expect(errorMessage.className).toContain('platform-status-message');
});
expect(
screen.getByRole('button', { name: '导出会话' }).getAttribute('disabled'),
).toBeNull();
});
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 imports reference image through native HostBridge', async () => {
ensureScrollApis();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation(
(capability) => capability === 'file.importImage',
);
const importImageSpy = vi
.spyOn(hostBridgeServices, 'importHostImageFile')
.mockResolvedValue({
action: 'selected',
fileName: '设定参考图.png',
base64Data: 'aW1hZ2U=',
mimeType: 'image/png',
bytes: 5,
});
const onReferenceImageChange = vi.fn<[File], Promise<void>>(
async () => undefined,
);
try {
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={onReferenceImageChange}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '上传参考图' }));
await waitFor(() => {
expect(onReferenceImageChange).toHaveBeenCalledTimes(1);
});
const importedFile = onReferenceImageChange.mock.calls[0]?.[0] as File;
expect(importImageSpy).toHaveBeenCalledTimes(1);
expect(importedFile.name).toBe('设定参考图.png');
expect(importedFile.type).toBe('image/png');
expect(importedFile.size).toBe(5);
expect(inputClickSpy).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
test('creation agent workspace keeps native image cancellation inside shell flow', async () => {
ensureScrollApis();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation(
(capability) => capability === 'file.importImage',
);
vi.spyOn(hostBridgeServices, 'importHostImageFile').mockResolvedValue(false);
const onReferenceImageChange = vi.fn();
try {
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={onReferenceImageChange}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '上传参考图' }));
await waitFor(() => {
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
});
expect(onReferenceImageChange).not.toHaveBeenCalled();
expect(inputClickSpy).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
test('creation agent workspace falls back to browser image picker without native image capability', () => {
ensureScrollApis();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockReturnValue(
false,
);
try {
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={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '上传参考图' }));
expect(inputClickSpy).toHaveBeenCalledTimes(1);
} finally {
inputClickSpy.mockRestore();
}
});
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(() => {
const errorMessage = screen.getByText(
'暂时只支持 txt、md、csv、json 文本文档。',
);
expect(errorMessage).toBeTruthy();
expect(errorMessage.className).toContain('platform-status-message');
expect(errorMessage.className).toContain(
'bg-[var(--platform-button-danger-fill)]',
);
});
});