// @vitest-environment jsdom import '../../test/dom-setup'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../../test/render-helpers'; import { ChatPane } from './ChatPane'; import { __resetDraftPruneForTest } from '../../hooks/useDraft'; import type { LocalTask, LocalTaskComment } from '../../api'; const DRAFT_KEY = 'maestro:draft:v1:chat:42'; // useJobStream が EventSource を張るため jsdom にスタブを入れる class FakeEventSource { onmessage: ((e: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; addEventListener() {} removeEventListener() {} close() {} } const task = { id: 42, title: 'テストタスク', pieceName: 'chat', latestJob: undefined, } as unknown as LocalTask; beforeEach(() => { localStorage.clear(); __resetDraftPruneForTest(); vi.stubGlobal('EventSource', FakeEventSource); vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 }))); // ChatPetOverlay (rendered unconditionally by ChatPane) reads matchMedia for // prefers-reduced-motion; jsdom doesn't implement it. Minimal stub only — // not part of the brief, needed to get past mount. vi.stubGlobal('matchMedia', (query: string) => ({ matches: false, media: query, addEventListener: () => {}, removeEventListener: () => {}, })); }); describe('ChatPane drafts', () => { it('入力途中の内容がアンマウント後も復元される', async () => { const user = userEvent.setup(); const first = renderWithProviders( {})} />, ); await user.type(screen.getByRole('textbox'), '書きかけの返信'); first.unmount(); expect(JSON.parse(localStorage.getItem(DRAFT_KEY)!).text).toBe('書きかけの返信'); renderWithProviders( {})} />, ); expect(screen.getByRole('textbox')).toHaveValue('書きかけの返信'); }); it('送信成功で下書きが消える', async () => { localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '送る内容', updatedAt: Date.now() })); const user = userEvent.setup(); const onSubmit = vi.fn(async () => {}); renderWithProviders(); const textbox = screen.getByRole('textbox'); expect(textbox).toHaveValue('送る内容'); // Ctrl+Enter は textarea にフォーカスがないと発火しないため、まずフォーカスする。 await user.click(textbox); await user.keyboard('{Control>}{Enter}{/Control}'); await waitFor(() => expect(onSubmit).toHaveBeenCalled()); expect(localStorage.getItem(DRAFT_KEY)).toBeNull(); }); it('queued のままでもコメントが反映されたら送信ロックを外す', async () => { const user = userEvent.setup(); const onSubmit = vi.fn(async () => {}); const queuedTask = { ...task, latestJob: { status: 'queued' }, } as LocalTask; const rendered = renderWithProviders(); const textbox = screen.getByRole('textbox'); await user.click(textbox); await user.type(textbox, '追記'); const queuedButton = screen.getByRole('button', { name: /Add to task|追加/ }); await user.click(queuedButton); await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('追記', undefined)); expect(queuedButton).toBeDisabled(); rendered.rerender( , ); const textboxAfter = screen.getByRole('textbox'); await user.click(textboxAfter); await user.type(textboxAfter, '続き'); await waitFor(() => expect(screen.getByRole('button', { name: /Add to task|追加/ })).toBeEnabled()); }); it('入力中のプロンプトコーチを常設ボタンから開閉できる', async () => { const user = userEvent.setup(); renderWithProviders( {})} />); await user.type(screen.getByRole('textbox'), '評価してほしい依頼'); const coachButton = screen.getByRole('button', { name: /Evaluate prompt|プロンプトを評価/i }); await user.click(coachButton); expect(screen.getByTestId('chat-prompt-coach')).toBeInTheDocument(); await user.click(coachButton); expect(screen.queryByTestId('chat-prompt-coach')).not.toBeInTheDocument(); }); });