import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; import type { CSSProperties } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { Simulate } from 'react-dom/test-utils'; import { JSDOM } from 'jsdom'; import { ManualEditPanel, emptyManualEditDraft, manualEditPatchSummary, normalizeManualEditStyles, type ManualEditDraft } from '../../src/components/ManualEditPanel'; import { emptyManualEditStyles, type ManualEditPatch, type ManualEditStyles, type ManualEditTarget } from '../../src/edit-mode/types'; const target: ManualEditTarget = { id: 'hero-title', kind: 'text', label: 'Hero Title', tagName: 'h1', className: 'hero', text: 'Original', rect: { x: 0, y: 0, width: 120, height: 40 }, fields: { text: 'Original' }, attributes: { 'data-od-id': 'hero-title' }, styles: emptyManualEditStyles(), isLayoutContainer: false, outerHtml: '

Original

', }; type OnDraftChange = (draft: ManualEditDraft) => void; type OnStyleChange = (id: string, styles: Partial, label: string) => void; type OnInvalidStyle = (id: string, keys: Array) => void; type OnApplyPatch = (patch: ManualEditPatch, label: string) => void; type OnError = (message: string) => void; type OnClearSelection = () => void; type OnSaveDraft = () => void; type OnCancelDraft = () => void; type OnResetDraft = () => void; describe('ManualEditPanel', () => { let dom: JSDOM; let host: HTMLDivElement; let root: Root; beforeEach(() => { dom = new JSDOM('
'); globalThis.window = dom.window as unknown as Window & typeof globalThis; globalThis.document = dom.window.document; globalThis.HTMLElement = dom.window.HTMLElement; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; host = dom.window.document.querySelector('#root') as HTMLDivElement; root = createRoot(host); }); afterEach(() => { act(() => root.unmount()); dom.window.close(); Reflect.deleteProperty(globalThis, 'window'); Reflect.deleteProperty(globalThis, 'document'); Reflect.deleteProperty(globalThis, 'HTMLElement'); Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); }); it('renders the style inspector without the advanced editor entry', () => { renderPanel(); expect(host.textContent).toContain('TYPOGRAPHY'); expect(host.textContent).not.toContain('Advanced'); }); it('shows a readable selected element name in the titlebar', () => { renderPanel({ selectedTarget: { ...target, id: 'path-0-0', kind: 'container', label: 'div.container.hero-split', className: 'container hero-split', text: 'Turn a brand brief into an editorial collage system.', attributes: { 'data-od-source-path': 'path-0-0' }, }, }); expect(host.querySelector('.manual-edit-titlebar')?.textContent).toContain('Hero split'); expect(host.querySelector('.manual-edit-titlebar')?.textContent).not.toContain('div.container'); }); it('shows a drag handle for floating edit panels', () => { renderPanel({ floatingStyle: { left: 20, top: 24, width: 320, height: 380 } }); expect(host.querySelector('.manual-edit-drag-handle')).not.toBeNull(); expect(host.querySelector('.manual-edit-drag-handle')?.getAttribute('aria-label')).toBe('Move edit panel'); }); it('does not show page-level controls inside an element inspector', () => { const onClearSelection = vi.fn(); renderPanel({ onClearSelection }); expect(host.querySelector('button[aria-label="Show page inspector"]')).toBeNull(); expect(host.textContent).not.toContain('PAGE'); expect(onClearSelection).not.toHaveBeenCalled(); }); it('keeps inspector controls scrollable separately from footer actions', () => { renderPanel(); const scrollRegion = host.querySelector('.manual-edit-scroll'); const footer = host.querySelector('.manual-edit-footer'); const deleteButton = host.querySelector('button[aria-label="Delete element"]'); expect(scrollRegion?.textContent).toContain('TYPOGRAPHY'); expect(scrollRegion?.contains(deleteButton)).toBe(false); expect(footer?.contains(deleteButton)).toBe(true); expect(deleteButton?.textContent).toBe(''); expect(footer?.textContent).toContain('Cancel'); expect(footer?.textContent).toContain('Save'); }); it('routes delete as a direct icon-only action', () => { const onApplyPatch = vi.fn(); renderPanel({ onApplyPatch }); const footer = host.querySelector('.manual-edit-footer'); const deleteButton = host.querySelector('button[aria-label="Delete element"]') as HTMLButtonElement | null; if (!deleteButton) throw new Error('Delete button not found'); act(() => { deleteButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); expect(footer?.contains(deleteButton)).toBe(true); expect(deleteButton.textContent).toBe(''); expect(deleteButton.className).toContain('manual-edit-delete-btn'); expect(onApplyPatch).toHaveBeenCalledWith( { id: 'hero-title', kind: 'remove-element' }, 'Delete element', ); }); it('routes footer reset, cancel, and save actions', () => { const onResetDraft = vi.fn(); const onCancelDraft = vi.fn(); const onSaveDraft = vi.fn(); renderPanel({ resetAvailable: true, onResetDraft, onCancelDraft, onSaveDraft }); const footerButtons = Array.from(host.querySelectorAll('.manual-edit-footer button')); const reset = footerButtons.find((button) => button.textContent === 'Reset') as HTMLButtonElement | undefined; const cancel = footerButtons.find((button) => button.textContent === 'Cancel') as HTMLButtonElement | undefined; const save = footerButtons.find((button) => button.textContent === 'Save') as HTMLButtonElement | undefined; if (!reset || !cancel || !save) throw new Error('Footer action buttons not found'); act(() => { reset.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); cancel.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); save.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); expect(onResetDraft).toHaveBeenCalledTimes(1); expect(onCancelDraft).toHaveBeenCalledTimes(1); expect(onSaveDraft).toHaveBeenCalledTimes(1); }); it('edits selected text content from the panel', () => { const onDraftChange = vi.fn(); renderPanel({ onDraftChange }); const textArea = sectionByTitle('CONTENT').querySelector('textarea') as HTMLTextAreaElement | null; if (!textArea) throw new Error('Content textarea not found'); act(() => { textArea.value = 'Panel edited copy'; Simulate.change(textArea); }); expect(onDraftChange).toHaveBeenCalledWith(expect.objectContaining({ text: 'Panel edited copy' })); }); it('normalizes font stacks and writes a usable font-family value', () => { const onDraftChange = vi.fn(); const onStyleChange = vi.fn(); renderPanel({ onDraftChange, onStyleChange, styles: { ...emptyManualEditStyles(), fontFamily: '"Roboto", sans-serif', fontSize: '32px', color: '#111111', paddingTop: '8px', }, }); const fontSelect = host.querySelector('select') as HTMLSelectElement | null; if (!fontSelect) throw new Error('Font select not found'); expect(fontSelect.value).toBe('Roboto, Arial, sans-serif'); act(() => { fontSelect.value = 'Georgia, serif'; fontSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true })); }); expect(onDraftChange).toHaveBeenCalledWith(expect.objectContaining({ styles: expect.objectContaining({ fontFamily: 'Georgia, serif' }), })); expect(onStyleChange).toHaveBeenCalledWith('hero-title', { fontFamily: 'Georgia, serif' }, 'Style: Hero Title'); expect(onStyleChange).not.toHaveBeenCalledWith( 'hero-title', expect.objectContaining({ fontSize: '32px', color: '#111111', paddingTop: '8px' }), 'Style: Hero Title', ); }); it('shows px-backed values without px in numeric inputs', () => { renderPanel({ styles: { ...emptyManualEditStyles(), fontSize: '32px', }, }); const sizeRow = Array.from(host.querySelectorAll('.cc-row')) .find((row) => row.textContent?.includes('Size')); const sizeInput = sizeRow?.querySelector('input') as HTMLInputElement | null; if (!sizeInput) throw new Error('Size input not found'); expect(sizeInput.value).toBe('32'); }); it('increments text typography rows with normalized values', () => { const onStyleChange = vi.fn(); renderPanel({ onStyleChange, styles: { ...emptyManualEditStyles(), fontSize: '32px', lineHeight: '1.4', letterSpacing: '1px', }, }); const sizeIncrease = host.querySelector('button[aria-label="Size increase"]') as HTMLButtonElement | null; const lineIncrease = host.querySelector('button[aria-label="Line increase"]') as HTMLButtonElement | null; const trackingDecrease = host.querySelector('button[aria-label="Tracking decrease"]') as HTMLButtonElement | null; if (!sizeIncrease || !lineIncrease || !trackingDecrease) throw new Error('Stepper button not found'); act(() => { sizeIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); lineIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); trackingDecrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); expect(onStyleChange).toHaveBeenCalledWith('hero-title', { fontSize: '33px' }, 'Style: Hero Title'); expect(onStyleChange).toHaveBeenCalledWith('hero-title', { lineHeight: '1.5' }, 'Style: Hero Title'); expect(onStyleChange).toHaveBeenCalledWith('hero-title', { letterSpacing: '0px' }, 'Style: Hero Title'); expect(host.textContent).not.toContain('Opacity'); expect(host.textContent).not.toContain('Padding'); }); it('does not persist an unchanged target style when the inspector opens', () => { vi.useFakeTimers(); try { const onApplyPatch = vi.fn(); renderPanel({ onApplyPatch }); act(() => { vi.advanceTimersByTime(1600); }); expect(onApplyPatch).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); it('normalizes valid style values before host preview/persistence', () => { expect(normalizeManualEditStyles({ fontSize: '48', color: '#f00', opacity: '2', lineHeight: '1.4', }, { layoutEnabled: true })).toEqual({ ok: true, styles: { fontSize: '48px', color: '#ff0000', opacity: '1', lineHeight: '1.4', }, }); expect(normalizeManualEditStyles({ lineHeight: '49px' }, { layoutEnabled: true })).toEqual({ ok: true, styles: { lineHeight: '49px' }, }); }); it('rejects invalid style values before host preview/persistence', () => { expect(normalizeManualEditStyles({ color: 'tomato' }, { layoutEnabled: true })).toEqual({ ok: false, error: 'color must be a hex color.', }); expect(normalizeManualEditStyles({ lineHeight: '-1px' }, { layoutEnabled: true })).toEqual({ ok: false, error: 'Line height must be a positive number or px value.', }); }); it('treats empty values as inline style clears', () => { expect(normalizeManualEditStyles({ fontSize: '', color: '' }, { layoutEnabled: true })).toEqual({ ok: true, styles: { fontSize: '', color: '' }, }); }); it('does not validate unchanged computed line-height values on blur', () => { const onError = vi.fn(); const onStyleChange = vi.fn(); renderPanel({ onError, onStyleChange, styles: { ...emptyManualEditStyles(), lineHeight: '48.96px', }, }); const lineInput = Array.from(host.querySelectorAll('.cc-row')) .find((row) => row.textContent?.includes('Line')) ?.querySelector('input') as HTMLInputElement | null; if (!lineInput) throw new Error('Line input not found'); act(() => { lineInput.dispatchEvent(new dom.window.FocusEvent('blur', { bubbles: true })); }); expect(onError).not.toHaveBeenCalled(); expect(onStyleChange).not.toHaveBeenCalled(); }); it('accepts edited computed pixel line-height values', () => { const onError = vi.fn(); const onStyleChange = vi.fn(); renderPanel({ onError, onStyleChange, styles: { ...emptyManualEditStyles(), lineHeight: '48.96px', }, }); const lineInput = Array.from(host.querySelectorAll('.cc-row')) .find((row) => row.textContent?.includes('Line')) ?.querySelector('input') as HTMLInputElement | null; if (!lineInput) throw new Error('Line input not found'); act(() => { lineInput.value = '49px'; Simulate.change(lineInput); }); expect(onError).toHaveBeenCalledWith(''); expect(onStyleChange).toHaveBeenCalledWith('hero-title', { lineHeight: '49px' }, 'Style: Hero Title'); }); it('does not persist unchanged page styles when no target is selected', () => { vi.useFakeTimers(); try { const onApplyPatch = vi.fn(); renderPanel({ onApplyPatch, selectedTarget: null }); act(() => { vi.advanceTimersByTime(1600); }); expect(onApplyPatch).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); it('emits only the changed page style field', () => { const onStyleChange = vi.fn(); renderPanel({ onStyleChange, selectedTarget: null }); const bgSwatch = host.querySelector('button[aria-label="Pick Background"]') as HTMLButtonElement | null; if (!bgSwatch) throw new Error('Background swatch not found'); act(() => { bgSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); const colorTile = host.querySelector('button[aria-label="#3b82f6"]') as HTMLButtonElement | null; if (!colorTile) throw new Error('Background color tile not found'); act(() => { colorTile.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); expect(onStyleChange).toHaveBeenCalledWith('__body__', { backgroundColor: '#3b82f6' }, 'Page styles'); expect(onStyleChange).not.toHaveBeenCalledWith( '__body__', expect.objectContaining({ fontFamily: expect.any(String) }), 'Page styles', ); expect(onStyleChange).not.toHaveBeenCalledWith( '__body__', expect.objectContaining({ fontSize: expect.any(String) }), 'Page styles', ); }); it('does not emit untouched page fields when changing the page font', () => { const onStyleChange = vi.fn(); renderPanel({ onStyleChange, selectedTarget: null }); const fontSelect = host.querySelector('.cc-row select') as HTMLSelectElement | null; if (!fontSelect) throw new Error('Font select not found'); act(() => { fontSelect.value = 'Georgia, serif'; fontSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true })); }); expect(onStyleChange).toHaveBeenCalledWith('__body__', { fontFamily: 'Georgia, serif' }, 'Page styles'); expect(onStyleChange).not.toHaveBeenCalledWith( '__body__', expect.objectContaining({ backgroundColor: expect.any(String) }), 'Page styles', ); expect(onStyleChange).not.toHaveBeenCalledWith( '__body__', expect.objectContaining({ fontSize: expect.any(String) }), 'Page styles', ); }); it('shows an inactive Page inspector for fragment HTML sources', () => { const onStyleChange = vi.fn(); renderPanel({ onStyleChange, selectedTarget: null, pageStylesEnabled: false }); expect(host.textContent).toContain('Page styles are available only for full HTML documents.'); expect(host.textContent).not.toContain('Background'); expect(host.querySelector('input')).toBeNull(); expect(host.querySelector('select')).toBeNull(); expect(onStyleChange).not.toHaveBeenCalled(); }); it('keeps explicit empty page values as field-specific clears', () => { const onStyleChange = vi.fn(); renderPanel({ onStyleChange, selectedTarget: null }); const fontSelect = host.querySelector('.cc-row select') as HTMLSelectElement | null; if (!fontSelect) throw new Error('Font select not found'); act(() => { fontSelect.value = ''; fontSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true })); }); expect(onStyleChange).toHaveBeenCalledWith('__body__', { fontFamily: '' }, 'Page styles'); expect(onStyleChange).not.toHaveBeenCalledWith( '__body__', expect.objectContaining({ backgroundColor: expect.any(String), fontFamily: expect.any(String) }), 'Page styles', ); }); it('hides layout controls for non-layout single targets', () => { const onStyleChange = vi.fn(); renderPanel({ onStyleChange, styles: { ...emptyManualEditStyles(), gap: 'normal', flexDirection: 'row', }, }); const layoutSection = Array.from(host.querySelectorAll('.cc-section')).find((section) => ( section.textContent?.includes('LAYOUT') )); expect(layoutSection).toBeUndefined(); expect(normalizeManualEditStyles({ gap: '12', flexDirection: 'column' }, { layoutEnabled: false })).toEqual({ ok: true, styles: {}, }); }); it('enables layout controls for flex or grid containers', () => { const onStyleChange = vi.fn(); renderPanel({ onStyleChange, selectedTarget: { ...target, isLayoutContainer: true }, styles: { ...emptyManualEditStyles(), gap: '8px', flexDirection: 'row', }, }); const layoutSection = sectionByTitle('LAYOUT'); expect(layoutSection.classList.contains('cc-section-inactive')).toBe(false); expect(layoutSection.textContent).not.toContain('Select a container or group to edit layout.'); const gapInput = layoutSection.querySelector('input') as HTMLInputElement | null; const directionSelect = layoutSection.querySelector('select') as HTMLSelectElement | null; const gapIncrease = layoutSection.querySelector('button[aria-label="Gap increase"]') as HTMLButtonElement | null; if (!gapInput || !directionSelect) throw new Error('Layout controls not found'); expect(gapInput.disabled).toBe(false); expect(directionSelect.disabled).toBe(false); if (!gapIncrease) throw new Error('Gap increase control not found'); act(() => { gapIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); directionSelect.value = 'column'; directionSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true })); }); expect(onStyleChange).toHaveBeenCalledWith('hero-title', { gap: '9px' }, 'Style: Hero Title'); expect(onStyleChange).toHaveBeenCalledWith('hero-title', { flexDirection: 'column' }, 'Style: Hero Title'); }); it('summarizes full-source history entries without rendering the full file', () => { const source = '' + 'x'.repeat(10_000) + ''; expect(manualEditPatchSummary({ kind: 'set-full-source', source })).toBe( JSON.stringify({ kind: 'set-full-source', bytes: source.length }), ); expect(manualEditPatchSummary({ kind: 'set-full-source', source })).not.toContain('x'.repeat(100)); }); function sectionByTitle(title: string): HTMLElement { const section = Array.from(host.querySelectorAll('.cc-section')) .find((candidate) => candidate.querySelector('.cc-section-head')?.textContent === title) as HTMLElement | undefined; if (!section) throw new Error(`${title} section not found`); return section; } function renderPanel({ onDraftChange = vi.fn(), onApplyPatch = vi.fn(), onError = vi.fn(), onStyleChange = vi.fn(), onInvalidStyle = vi.fn(), onClearSelection = vi.fn(), onCancelDraft = vi.fn(), onSaveDraft = vi.fn(), onResetDraft = vi.fn(), attributesText = '{}', selectedTarget = target, styles = emptyManualEditStyles(), resetAvailable = false, pageStylesEnabled = true, floatingStyle, onFloatingPositionChange, }: { onDraftChange?: OnDraftChange; onApplyPatch?: OnApplyPatch; onError?: OnError; onStyleChange?: OnStyleChange; onInvalidStyle?: OnInvalidStyle; onClearSelection?: OnClearSelection; onCancelDraft?: OnCancelDraft; onSaveDraft?: OnSaveDraft; onResetDraft?: OnResetDraft; attributesText?: string; selectedTarget?: ManualEditTarget | null; styles?: ReturnType; resetAvailable?: boolean; pageStylesEnabled?: boolean; floatingStyle?: CSSProperties; onFloatingPositionChange?: (position: { left: number; top: number }) => void; } = {}) { const draft = { ...emptyManualEditDraft(''), text: 'Updated copy', attributesText, styles, outerHtml: target.outerHtml, }; act(() => { root.render( void>()} onDraftChange={onDraftChange} onStyleChange={onStyleChange} onInvalidStyle={onInvalidStyle} onApplyPatch={onApplyPatch} onError={onError} onClearSelection={onClearSelection} onCancelDraft={onCancelDraft} onSaveDraft={onSaveDraft} onResetDraft={onResetDraft} onUndo={vi.fn<() => void>()} onRedo={vi.fn<() => void>()} floatingStyle={floatingStyle} onFloatingPositionChange={onFloatingPositionChange} />, ); }); } });