/* @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(); }); 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( {}} onSubmitText={() => {}} onPrimaryAction={() => {}} />, ); const progressbar = screen.getByRole('progressbar'); expect(progressbar.getAttribute('aria-valuenow')).toBe('0'); expect( (progressbar.firstElementChild as HTMLElement | null)?.style.width, ).toBe('0%'); }); test('creation agent workspace filters duplicate recommended replies', () => { const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => undefined); ensureScrollApis(); render( {}} 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( {}} onSubmitText={() => {}} onPrimaryAction={() => {}} />, ); expect(screen.getByText(/那我先顺着这个方向收一下/u)).toBeTruthy(); }); test('creation agent workspace renders waiting dots before first streamed token', () => { ensureScrollApis(); render( {}} onSubmitText={() => {}} onPrimaryAction={() => {}} />, ); expect(screen.getByTestId('creation-agent-waiting-dots')).toBeTruthy(); }); test('creation agent workspace appends streaming assistant message after stable message list', () => { ensureScrollApis(); render( {}} 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( {}} 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( {}} 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( {}} onSubmitText={() => {}} onPrimaryAction={() => {}} />, ); expect(screen.queryByText('统一共创')).toBeNull(); expect(screen.getByText('创作进度')).toBeTruthy(); }); 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( {}} 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( {}} 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( {}} onSubmitText={() => {}} onPrimaryAction={() => {}} />, ); fireEvent.change(screen.getByPlaceholderText('输入消息'), { target: { value: '已有方向', }, }); const input = document.querySelector('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 shows document parse error near composer', async () => { ensureScrollApis(); vi.spyOn( creationAgentServices, 'parseCreationAgentDocumentInput', ).mockRejectedValue(new Error('暂时只支持 txt、md、csv、json 文本文档。')); render( {}} onSubmitText={() => {}} onPrimaryAction={() => {}} />, ); const input = document.querySelector('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(); }); });