import * as React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; // MarkdownRenderer pulls react-markdown (ESM, not transformed from node_modules // by this package's jest config). The editor's own concern is wiring the preview, // not markdown rendering, so isolate it with a lightweight stand-in. jest.mock('../../blog/MarkdownRenderer', () => ({ MarkdownRenderer: ({ content }: { content: string }) => require('react').createElement('div', { 'data-testid': 'md-preview' }, content), })); import { DocumentEditor } from '../DocumentEditor'; import type { DocSection } from '../types'; function baseSections(): DocSection[] { return [ { key: 'blog', label: 'Blog post', kind: 'markdown', value: 'hello world' }, { key: 'linkedin', label: 'LinkedIn', kind: 'text', value: 'a post' }, { key: 'seo', label: 'SEO', kind: 'structured', value: { meta_description: 'short', primary_keyword: 'kw' } }, { key: 'sources', label: 'Sources', kind: 'list', value: ['https://a.com'] }, ]; } /** Controlled harness that mirrors how a consuming app wires the editor. */ function Harness({ onSave, autosaveMs, readOnly, }: { onSave?: (s: DocSection[]) => void | Promise; autosaveMs?: number; readOnly?: boolean; }) { const [sections, setSections] = React.useState(baseSections()); return ( My draft} onChange={(key, value) => setSections((prev) => prev.map((s) => (s.key === key ? { ...s, value } : s))) } /> ); } describe('DocumentEditor', () => { it('renders a header slot and every section label', () => { render(); expect(screen.getByText('My draft')).toBeInTheDocument(); for (const label of ['Blog post', 'LinkedIn', 'SEO', 'Sources']) { expect(screen.getByRole('heading', { name: label })).toBeInTheDocument(); } }); it('markdown section shows a word count and a live preview alongside the editor', () => { render(); expect(screen.getByText('2 words')).toBeInTheDocument(); // split view: an editable textarea AND the rendered preview text expect(screen.getByLabelText('Blog post markdown')).toBeInTheDocument(); const previews = screen.getAllByText('hello world'); expect(previews.length).toBeGreaterThanOrEqual(1); }); it('emits per-section edits through onChange (controlled)', () => { render(); const textarea = screen.getByLabelText('LinkedIn') as HTMLTextAreaElement; fireEvent.change(textarea, { target: { value: 'edited post' } }); expect((screen.getByLabelText('LinkedIn') as HTMLTextAreaElement).value).toBe('edited post'); }); it('structured section renders a field input and an SEO char meter on meta_description', () => { render(); const metaInput = screen.getByLabelText('meta description') as HTMLInputElement; expect(metaInput.value).toBe('short'); // the meta meter surfaces the aim band expect(screen.getByText(/aim 150/)).toBeInTheDocument(); fireEvent.change(metaInput, { target: { value: 'new description' } }); expect((screen.getByLabelText('meta description') as HTMLInputElement).value).toBe('new description'); }); it('list section adds, edits and removes items', () => { render(); expect(screen.getByLabelText('Sources item 1')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Add' })); const item2 = screen.getByLabelText('Sources item 2') as HTMLInputElement; fireEvent.change(item2, { target: { value: 'https://b.com' } }); expect((screen.getByLabelText('Sources item 2') as HTMLInputElement).value).toBe('https://b.com'); fireEvent.click(screen.getByRole('button', { name: 'Remove item 1' })); expect(screen.queryByLabelText('Sources item 2')).not.toBeInTheDocument(); expect((screen.getByLabelText('Sources item 1') as HTMLInputElement).value).toBe('https://b.com'); }); it('debounces onSave after an edit and shows a Saved status', async () => { const onSave = jest.fn().mockResolvedValue(undefined); render(); // no autosave before any edit expect(onSave).not.toHaveBeenCalled(); fireEvent.change(screen.getByLabelText('LinkedIn'), { target: { value: 'changed' } }); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); const saved = onSave.mock.calls[0][0] as DocSection[]; expect(saved.find((s) => s.key === 'linkedin')!.value).toBe('changed'); expect(await screen.findByText('Saved')).toBeInTheDocument(); }); it('surfaces a Save failed status when onSave rejects', async () => { const onSave = jest.fn().mockRejectedValue(new Error('nope')); render(); fireEvent.change(screen.getByLabelText('LinkedIn'), { target: { value: 'boom' } }); expect(await screen.findByText('Save failed')).toBeInTheDocument(); }); it('readOnly renders previews only (no editable inputs) and skips autosave', async () => { const onSave = jest.fn(); render(); expect(screen.queryByLabelText('Blog post markdown')).not.toBeInTheDocument(); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); // markdown still renders as a preview expect(screen.getAllByText('hello world').length).toBeGreaterThanOrEqual(1); await new Promise((r) => setTimeout(r, 40)); expect(onSave).not.toHaveBeenCalled(); }); });