/* @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( {}} 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( {}} onSubmitText={() => {}} onPrimaryAction={() => {}} />, ); const progressbar = screen.getByRole('progressbar'); 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( (progressbar.firstElementChild as HTMLElement | null)?.style.width, ).toBe('0%'); }); test('creation agent workspace renders operation banner with shared status message', () => { ensureScrollApis(); render( {}} 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( {}} 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('创作进度').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( {}} 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 renders selected reference image with shared preview row', async () => { ensureScrollApis(); const onClearReferenceImage = vi.fn(); render( {}} 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( {}} 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(); }); });