329 lines
9.8 KiB
TypeScript
329 lines
9.8 KiB
TypeScript
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<ComponentProps<typeof SheetEditor>, '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(
|
|
<SheetEditor
|
|
content={content}
|
|
path={path}
|
|
onContentChange={onContentChange}
|
|
{...options.props}
|
|
/>,
|
|
),
|
|
}
|
|
}
|
|
|
|
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(
|
|
<SheetEditor
|
|
content={'---\n_display: sheet\n---\nMetric,February'}
|
|
path={rendered.path}
|
|
onContentChange={rendered.onContentChange}
|
|
/>,
|
|
)
|
|
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'),
|
|
})
|
|
})
|
|
})
|