import { renderHook, act } from '@testing-library/react' import { useEmailEditorState } from '../useEmailEditorState' import { createSection, createRow, createBlock, Section } from '../../types' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Build a minimal single-section state with one row, one column, * and an optional list of blocks already in column 0. */ function makeSections(blockTypes: Array<'text' | 'divider' | 'cta' | 'spacer'> = []): Section[] { const section = createSection() // createSection already gives one row with layout '1' section.rows[0].columns[0] = blockTypes.map((t) => createBlock(t)) return [section] } /** * Renders the hook with a self-updating onChange wrapper so that every * commitChange call feeds back into the hook as new initialSections. * Returns the renderHook result plus a getter for the current sections. */ function setup(initial: Section[] = makeSections()) { // We keep a mutable ref outside so we can close over it in onChange let currentSections = initial const onChange = jest.fn((next: Section[]) => { currentSections = next rerender({ initialSections: currentSections, onChange }) }) const { result, rerender } = renderHook( ({ initialSections, onChange: cb }: { initialSections: Section[]; onChange: (s: Section[]) => void }) => useEmailEditorState({ initialSections, onChange: cb }), { initialProps: { initialSections: initial, onChange } }, ) return { result, onChange, getSections: () => currentSections } } // --------------------------------------------------------------------------- // Block operations // --------------------------------------------------------------------------- describe('useEmailEditorState — block operations', () => { it('addBlock appends a block to a column', () => { const { result, onChange } = setup(makeSections()) act(() => { result.current.addBlock(0, 0, 0, 'text') }) expect(onChange).toHaveBeenCalledTimes(1) const [committed] = onChange.mock.calls[0] expect(committed[0].rows[0].columns[0]).toHaveLength(1) expect(committed[0].rows[0].columns[0][0].type).toBe('text') }) it('addBlock inserts after a given block index', () => { const initial = makeSections(['text', 'divider']) const { result, onChange } = setup(initial) // Insert a spacer after the first block (index 0) act(() => { result.current.addBlock(0, 0, 0, 'spacer', 0) }) const [committed] = onChange.mock.calls[0] const col = committed[0].rows[0].columns[0] expect(col).toHaveLength(3) expect(col[0].type).toBe('text') expect(col[1].type).toBe('spacer') expect(col[2].type).toBe('divider') }) it('addBlock selects the newly inserted block', () => { const { result } = setup(makeSections(['text'])) act(() => { result.current.addBlock(0, 0, 0, 'cta', 0) }) expect(result.current.selection).toEqual({ sectionIndex: 0, rowIndex: 0, columnIndex: 0, blockIndex: 1, }) }) it('removeBlock removes the block at the given index', () => { const initial = makeSections(['text', 'divider', 'spacer']) const { result, onChange } = setup(initial) act(() => { result.current.removeBlock(0, 0, 0, 1) // remove divider }) const [committed] = onChange.mock.calls[0] const col = committed[0].rows[0].columns[0] expect(col).toHaveLength(2) expect(col[0].type).toBe('text') expect(col[1].type).toBe('spacer') }) it('removeBlock clears selection', () => { const { result } = setup(makeSections(['text'])) act(() => { result.current.setSelection({ sectionIndex: 0, rowIndex: 0, columnIndex: 0, blockIndex: 0 }) }) act(() => { result.current.removeBlock(0, 0, 0, 0) }) expect(result.current.selection).toBeNull() }) it('updateBlock merges updates into an existing block', () => { const initial = makeSections(['text']) const { result, onChange } = setup(initial) act(() => { result.current.updateBlock(0, 0, 0, 0, { content: 'Hello world' }) }) const [committed] = onChange.mock.calls[0] const block = committed[0].rows[0].columns[0][0] expect(block.type).toBe('text') // @ts-expect-error content only exists on TextBlock, but we know the type expect(block.content).toBe('Hello world') }) it('duplicateBlock inserts an identical block right after the original', () => { const initial = makeSections(['text', 'divider']) const { result, onChange } = setup(initial) act(() => { result.current.duplicateBlock(0, 0, 0, 0) }) const [committed] = onChange.mock.calls[0] const col = committed[0].rows[0].columns[0] expect(col).toHaveLength(3) expect(col[1].type).toBe('text') // duplicate must have a distinct id expect(col[1].id).not.toBe(col[0].id) }) it('duplicateBlock selects the duplicated block', () => { const initial = makeSections(['text']) const { result } = setup(initial) act(() => { result.current.duplicateBlock(0, 0, 0, 0) }) expect(result.current.selection).toEqual({ sectionIndex: 0, rowIndex: 0, columnIndex: 0, blockIndex: 1, }) }) }) // --------------------------------------------------------------------------- // Section operations // --------------------------------------------------------------------------- describe('useEmailEditorState — section operations', () => { it('addSection appends a new section after the last one by default', () => { const { result, onChange } = setup(makeSections()) act(() => { result.current.addSection() }) const [committed] = onChange.mock.calls[0] expect(committed).toHaveLength(2) }) it('addSection inserts after a specific index', () => { const initial = [createSection(), createSection(), createSection()] const { result, onChange } = setup(initial) act(() => { result.current.addSection(0) // after index 0 }) const [committed] = onChange.mock.calls[0] expect(committed).toHaveLength(4) // New section should be at index 1 // The original 2nd and 3rd sections shift to 2 and 3 expect(committed[0].id).toBe(initial[0].id) expect(committed[2].id).toBe(initial[1].id) }) it('removeSection removes the section at a given index', () => { const initial = [createSection(), createSection()] const firstId = initial[0].id const { result, onChange } = setup(initial) act(() => { result.current.removeSection(1) }) const [committed] = onChange.mock.calls[0] expect(committed).toHaveLength(1) expect(committed[0].id).toBe(firstId) }) it('removeSection does nothing when only one section remains', () => { const { result, onChange } = setup(makeSections()) act(() => { result.current.removeSection(0) }) expect(onChange).not.toHaveBeenCalled() }) it('removeSection clears selection', () => { const initial = [createSection(), createSection()] const { result } = setup(initial) act(() => { result.current.setSelection({ sectionIndex: 1, rowIndex: 0, columnIndex: 0, blockIndex: 0 }) }) act(() => { result.current.removeSection(1) }) expect(result.current.selection).toBeNull() }) }) // --------------------------------------------------------------------------- // Row operations // --------------------------------------------------------------------------- describe('useEmailEditorState — row operations', () => { it('addRow appends a new row to the given section', () => { const { result, onChange } = setup(makeSections()) act(() => { result.current.addRow(0) }) const [committed] = onChange.mock.calls[0] expect(committed[0].rows).toHaveLength(2) }) it('addRow with a specific layout creates the correct number of columns', () => { const { result, onChange } = setup(makeSections()) act(() => { result.current.addRow(0, '3') }) const [committed] = onChange.mock.calls[0] expect(committed[0].rows[1].layout).toBe('3') expect(committed[0].rows[1].columns).toHaveLength(3) }) it('removeRow removes a row from the section', () => { const section = createSection() section.rows.push(createRow('1')) const { result, onChange } = setup([section]) act(() => { result.current.removeRow(0, 1) }) const [committed] = onChange.mock.calls[0] expect(committed[0].rows).toHaveLength(1) }) it('removeRow does nothing when only one row remains in the section', () => { const { result, onChange } = setup(makeSections()) act(() => { result.current.removeRow(0, 0) }) expect(onChange).not.toHaveBeenCalled() }) it('changeRowLayout to 2 columns creates two columns', () => { const { result, onChange } = setup(makeSections()) act(() => { result.current.changeRowLayout(0, 0, '2') }) const [committed] = onChange.mock.calls[0] expect(committed[0].rows[0].layout).toBe('2') expect(committed[0].rows[0].columns).toHaveLength(2) }) it('changeRowLayout merges excess blocks when reducing column count', () => { // Start with a 3-column row that has blocks in each column const section = createSection() const row = createRow('3') row.columns[0] = [createBlock('text')] row.columns[1] = [createBlock('divider')] row.columns[2] = [createBlock('spacer')] section.rows[0] = row const { result, onChange } = setup([section]) act(() => { result.current.changeRowLayout(0, 0, '1') }) const [committed] = onChange.mock.calls[0] expect(committed[0].rows[0].columns).toHaveLength(1) // All three blocks should be merged into the single column expect(committed[0].rows[0].columns[0]).toHaveLength(3) }) }) // --------------------------------------------------------------------------- // Selection state // --------------------------------------------------------------------------- describe('useEmailEditorState — selection', () => { it('setSelection updates the selection', () => { const { result } = setup(makeSections()) act(() => { result.current.setSelection({ sectionIndex: 0, rowIndex: 0, columnIndex: 0, blockIndex: 0 }) }) expect(result.current.selection).toEqual({ sectionIndex: 0, rowIndex: 0, columnIndex: 0, blockIndex: 0, }) }) it('setSelection to null clears selection', () => { const { result } = setup(makeSections()) act(() => { result.current.setSelection({ sectionIndex: 0, rowIndex: 0, columnIndex: 0, blockIndex: 0 }) }) act(() => { result.current.setSelection(null) }) expect(result.current.selection).toBeNull() }) }) // --------------------------------------------------------------------------- // onChange callback // --------------------------------------------------------------------------- describe('useEmailEditorState — onChange callback', () => { it('onChange is called with updated sections after addBlock', () => { const onChange = jest.fn() const initial = makeSections() const { result } = renderHook(() => useEmailEditorState({ initialSections: initial, onChange }), ) act(() => { result.current.addBlock(0, 0, 0, 'text') }) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0][0][0].rows[0].columns[0]).toHaveLength(1) }) it('onChange is not called when removeSection is a no-op', () => { const onChange = jest.fn() const { result } = renderHook(() => useEmailEditorState({ initialSections: makeSections(), onChange }), ) act(() => { result.current.removeSection(0) // only one section }) expect(onChange).not.toHaveBeenCalled() }) it('onChange fires when commitChange is called directly', () => { const onChange = jest.fn() const initial = makeSections() const { result } = renderHook(() => useEmailEditorState({ initialSections: initial, onChange }), ) const newSections = [createSection()] act(() => { result.current.commitChange(newSections) }) expect(onChange).toHaveBeenCalledWith(newSections) }) }) // --------------------------------------------------------------------------- // Undo / redo // --------------------------------------------------------------------------- describe('useEmailEditorState — undo/redo', () => { it('canUndo is false initially', () => { const { result } = setup(makeSections()) expect(result.current.canUndo).toBe(false) }) it('canRedo is false initially', () => { const { result } = setup(makeSections()) expect(result.current.canRedo).toBe(false) }) it('commitChange records history so canUndo becomes true', () => { const { result } = setup(makeSections()) act(() => { // addBlock triggers commitChange + onChange → rerender via setup() wrapper result.current.addBlock(0, 0, 0, 'text') }) expect(result.current.canUndo).toBe(true) }) it('undo calls onChange with the previous state', () => { const onChange = jest.fn() const initial = makeSections(['text']) const { result } = renderHook(() => useEmailEditorState({ initialSections: initial, onChange }), ) const afterChange = makeSections(['text', 'divider']) act(() => { result.current.commitChange(afterChange) }) onChange.mockClear() act(() => { result.current.undo() }) expect(onChange).toHaveBeenCalledTimes(1) // onChange receives the previous state (no divider) const undoneArg = onChange.mock.calls[0][0] expect(undoneArg[0].rows[0].columns[0]).toHaveLength(1) expect(undoneArg[0].rows[0].columns[0][0].type).toBe('text') }) it('redo restores the undone state and calls onChange', () => { const onChange = jest.fn() const initial = makeSections(['text']) const { result } = renderHook(() => useEmailEditorState({ initialSections: initial, onChange }), ) const afterChange = makeSections(['text', 'divider']) act(() => { result.current.commitChange(afterChange) }) act(() => { result.current.undo() }) onChange.mockClear() act(() => { result.current.redo() }) expect(onChange).toHaveBeenCalledTimes(1) const redoneArg = onChange.mock.calls[0][0] expect(redoneArg[0].rows[0].columns[0]).toHaveLength(2) }) it('canRedo becomes true after undo', () => { const { result } = setup(makeSections()) // addBlock triggers commitChange + onChange → rerender act(() => { result.current.addBlock(0, 0, 0, 'text') }) act(() => { result.current.undo() }) expect(result.current.canRedo).toBe(true) }) it('undo with no history calls onChange with unchanged present', () => { const onChange = jest.fn() const initial = makeSections() const { result } = renderHook(() => useEmailEditorState({ initialSections: initial, onChange }), ) act(() => { result.current.undo() }) // The hook always fires onChange even when there's nothing to undo; // the value should be the current present (no blocks). expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0][0][0].rows[0].columns[0]).toHaveLength(0) }) it('redo with no future calls onChange with unchanged present', () => { const onChange = jest.fn() const initial = makeSections() const { result } = renderHook(() => useEmailEditorState({ initialSections: initial, onChange }), ) act(() => { result.current.redo() }) expect(onChange).toHaveBeenCalledTimes(1) expect(onChange.mock.calls[0][0][0].rows[0].columns[0]).toHaveLength(0) }) it('multiple commits then undo steps back through history in order', () => { const onChange = jest.fn() const s0 = makeSections([]) const s1 = makeSections(['text']) const s2 = makeSections(['text', 'divider']) const { result } = renderHook(() => useEmailEditorState({ initialSections: s0, onChange }), ) act(() => { result.current.commitChange(s1) }) act(() => { result.current.commitChange(s2) }) onChange.mockClear() act(() => { result.current.undo() }) expect(onChange.mock.calls[0][0][0].rows[0].columns[0]).toHaveLength(1) // s1 onChange.mockClear() act(() => { result.current.undo() }) expect(onChange.mock.calls[0][0][0].rows[0].columns[0]).toHaveLength(0) // s0 }) })