import { getIronCalcMock, markWorkbookDirtyForTest, resetSheetEditorTestState } from './SheetEditor.testUtils' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { ComponentProps } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { SheetEditor } from './SheetEditor' const ironCalcMock = getIronCalcMock() type SheetHarnessProps = Omit, 'content' | 'onContentChange' | 'path'> interface SheetHarnessOptions { path?: string props?: SheetHarnessProps } interface ExpectedSheetSave extends SheetHarnessOptions { content: string editWorkbook: () => void expectedContent: string } function renderSheetHarness(content: string, options: SheetHarnessOptions = {}) { const onContentChange = vi.fn() const path = options.path ?? '/vault/budget.md' return { onContentChange, path, ...render( , ), } } function sheetWithA1Metadata(metadataLines: string[], body = 'Metric,January'): string { return [ '---', 'type: Sheet', '_sheet:', ' cells:', ' A1:', ...metadataLines.map((line) => ` ${line}`), '---', body, ].join('\n') } async function renderLoadedSheet(content: string, options: SheetHarnessOptions = {}) { const rendered = renderSheetHarness(content, options) await screen.findByTestId('ironcalc-workbook') return rendered } async function expectNoSaveOnUnmount(content: string, options: SheetHarnessOptions = {}) { const { onContentChange, unmount } = await renderLoadedSheet(content, options) unmount() expect(onContentChange).not.toHaveBeenCalled() } async function expectSaveAfterDirtyEdit({ content, editWorkbook, expectedContent, path, props, }: ExpectedSheetSave) { const rendered = await renderLoadedSheet(content, { path, props }) editWorkbook() markWorkbookDirtyForTest() rendered.unmount() await waitFor(() => { expect(rendered.onContentChange).toHaveBeenCalledWith(rendered.path, expectedContent) }) } describe('SheetEditor serialization', () => { afterEach(() => { resetSheetEditorTestState() }) it('flushes the current workbook content when unmounted before debounce runs', async () => { await expectSaveAfterDirtyEdit({ content: '---\ntype: Sheet\n---\nMetric,January', editWorkbook: () => { ironCalcMock.state.lastModel?.setUserInput(0, 1, 1, 'Updated Metric') }, expectedContent: '---\ntype: Sheet\n---\nUpdated Metric,January', }) }) it('does not pad ragged rows when an unchanged sheet is flushed', async () => { await expectNoSaveOnUnmount( '---\n_display: sheet\n---\nName,Value,Notes\nIntro\n,Only second column\nTotal,42', { path: '/vault/ragged-sheet.md' }, ) }) it('does not rewrite unchanged sheets with explicit trailing empty cells', async () => { await expectNoSaveOnUnmount('---\n_display: sheet\n---\nMetric,January,,\nRevenue,1200,,') }) it('keeps a replaced workbook model alive until the replacement can commit', async () => { const rendered = await renderLoadedSheet('---\n_display: sheet\n---\nMetric,January') const firstModel = ironCalcMock.state.lastModel expect(firstModel).not.toBeNull() vi.useFakeTimers() rendered.rerender( , ) await act(async () => { await Promise.resolve() await Promise.resolve() }) expect(ironCalcMock.state.modelConstructs).toBe(2) expect(firstModel?.getSelectedSheet()).toBe(0) expect(ironCalcMock.state.freedModels.has(firstModel!)).toBe(false) act(() => { vi.runOnlyPendingTimers() }) expect(ironCalcMock.state.freedModels.has(firstModel!)).toBe(true) expect(() => firstModel?.getSelectedSheet()).toThrow('null pointer passed to rust') }) it('preserves trailing empty cells when saving an edited row', async () => { await expectSaveAfterDirtyEdit({ content: '---\n_display: sheet\n---\nMetric,January,,\nRevenue,1200,,', editWorkbook: () => { ironCalcMock.state.lastModel?.setUserInput(0, 1, 1, 'Updated Metric') }, expectedContent: '---\n_display: sheet\n---\nUpdated Metric,January,,\nRevenue,1200,,', }) }) it('preserves the CSV body exactly for formatting-only saves', async () => { const { onContentChange, unmount } = await renderLoadedSheet( '---\n_display: sheet\n---\nMetric,"January",,\nRevenue,1200,,', ) const workbook = await screen.findByTestId('ironcalc-workbook') fireEvent.contextMenu(workbook, { button: 2, clientX: 16, clientY: 16 }) fireEvent.click(await screen.findByRole('menuitem', { name: 'Bold' })) unmount() await waitFor(() => { expect(onContentChange).toHaveBeenCalledWith( '/vault/budget.md', [ '---', '_display: sheet', '_sheet:', ' cells:', ' A1:', ' bold: true', '---', 'Metric,"January",,', 'Revenue,1200,,', ].join('\n'), ) }) }) it('registers a path-scoped sheet flush for note switch boundaries', async () => { const flushContentRef = { current: null as ((path: string) => void) | null } const { onContentChange, unmount } = await renderLoadedSheet( '---\ntype: Sheet\n---\nMetric,January', { props: { flushContentRef } }, ) ironCalcMock.state.lastModel?.setUserInput(0, 1, 1, 'Updated Metric') markWorkbookDirtyForTest() act(() => { flushContentRef.current?.('/vault/other.md') }) expect(onContentChange).not.toHaveBeenCalled() act(() => { flushContentRef.current?.('/vault/budget.md') }) expect(onContentChange).toHaveBeenCalledWith( '/vault/budget.md', '---\ntype: Sheet\n---\nUpdated Metric,January', ) unmount() }) it('does not serialize the workbook for pure pointer selection', async () => { const { onContentChange } = await renderLoadedSheet('---\ntype: Sheet\n---\nMetric,January') const workbook = screen.getByTestId('ironcalc-workbook') ironCalcMock.state.selectedView = { column: 1, left_column: 1, range: [1, 1, 50000, 200], row: 1, sheet: 0, top_row: 1, } vi.useFakeTimers() fireEvent.pointerDown(workbook) fireEvent.pointerUp(workbook) act(() => vi.advanceTimersByTime(1000)) vi.useRealTimers() expect(onContentChange).not.toHaveBeenCalled() expect(ironCalcMock.state.columnsWithDataCalls).toBe(0) expect(ironCalcMock.state.rowsWithDataCalls).toBe(0) }) it('normalizes markdown wrappers into plain-text metadata on save', async () => { await expectSaveAfterDirtyEdit({ content: '---\ntype: Sheet\n---\nMetric,January', editWorkbook: () => { ironCalcMock.state.lastModel?.setUserInput(0, 1, 1, '**Updated Metric**') }, expectedContent: [ '---', 'type: Sheet', '_sheet:', ' cells:', ' A1:', ' bold: true', '---', 'Updated Metric,January', ].join('\n'), }) }) it('preserves workbook-level sheet settings in plain-text metadata', async () => { await expectSaveAfterDirtyEdit({ content: [ '---', 'type: Sheet', '_sheet:', ' show_grid_lines: false', ' frozen_rows: 1', ' frozen_columns: 2', ' cells:', ' A1:', ' border_top: "thin #ff0000"', '---', 'Metric,January', ].join('\n'), editWorkbook: () => { ironCalcMock.state.lastModel?.setUserInput(0, 1, 1, 'Updated Metric') }, expectedContent: [ '---', 'type: Sheet', '_sheet:', ' show_grid_lines: false', ' frozen_rows: 1', ' frozen_columns: 2', ' cells:', ' A1:', ' border_top: "thin #ff0000"', '---', 'Updated Metric,January', ].join('\n'), }) }) it('normalizes violet sheet metadata colors before applying them to IronCalc', async () => { await expectSaveAfterDirtyEdit({ content: sheetWithA1Metadata([ 'font_color: violet', 'fill_color: violet', 'border_top: "thin violet"', ]), editWorkbook: () => { ironCalcMock.state.lastModel?.setUserInput(0, 1, 1, 'Updated Metric') }, expectedContent: sheetWithA1Metadata([ 'font_color: "#ee82ee"', 'fill_color: "#ee82ee"', 'border_top: "thin #ee82ee"', ], 'Updated Metric,January'), }) }) it('preserves existing rows beyond the default serialization scan window', async () => { const rows = Array.from({ length: 1005 }, (_, index) => `Row ${index + 1}`) await expectSaveAfterDirtyEdit({ content: `---\ntype: Sheet\n---\n${rows.join('\n')}`, editWorkbook: () => { ironCalcMock.state.lastModel?.setUserInput(0, 1, 1, 'Updated Row 1') }, expectedContent: `---\ntype: Sheet\n---\nUpdated Row 1\n${rows.slice(1).join('\n')}`, path: '/vault/large-budget.md', }) }) it('serializes borders created in the workbook into cell metadata', async () => { await expectSaveAfterDirtyEdit({ content: '---\ntype: Sheet\n---\nMetric,January', editWorkbook: () => { ironCalcMock.state.lastModel?.setAreaWithBorder( { column: 1, row: 1 }, { item: { color: '#ff0000', style: 'thin' }, type: 'Top' }, ) }, expectedContent: [ '---', 'type: Sheet', '_sheet:', ' cells:', ' A1:', ' border_top: "thin #ff0000"', '---', 'Metric,January', ].join('\n'), }) }) })