import { rmSync } from 'node:fs'; import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import ExcelJS from 'exceljs'; import { afterEach, describe, expect, it } from 'vitest'; import { EXCEL_LIMITS } from './excel-types.js'; import { MAX_ROW_HEIGHT } from '../../office/cell-edit-input.js'; import { applyExcelPlan, findSharedFormulaMasterWrites, materializeSharedFormulaBlocks, normalizePatchOperations, parseExcelPatchInput, type ExcelPatchRequest, type NormalizedExcelPlan, type WorkbookStructureSnapshot, } from './excel-patch.js'; const tempDirs: string[] = []; function rawRequest(operations: unknown[]): Record { return { output_path: 'output/book.xlsx', operations, }; } function parsedRequest(operations: unknown[]): ExcelPatchRequest { const result = parseExcelPatchInput(rawRequest(operations)); if (!result.ok) throw new Error(result.violations.map((item) => item.message).join('; ')); return result.value; } function snapshot( merges: string[] = [], extraSheets: string[] = [], ): WorkbookStructureSnapshot { return { sheets: [ { name: 'Sheet1', merges }, ...extraSheets.map((name) => ({ name, merges: [] })), ], formulaCells: 0, }; } afterEach(() => { for (const path of tempDirs.splice(0)) rmSync(path, { recursive: true, force: true }); }); describe('parseExcelPatchInput limits and schema', () => { it('accepts 500 operations and rejects 501', () => { const operation = { op: 'freeze_panes', sheet: 'Sheet1', rows: 1, columns: 0 }; expect(parseExcelPatchInput(rawRequest(Array.from({ length: EXCEL_LIMITS.operations }, () => operation))).ok) .toBe(true); const rejected = parseExcelPatchInput(rawRequest( Array.from({ length: EXCEL_LIMITS.operations + 1 }, () => operation), )); expect(rejected).toMatchObject({ ok: false }); }); it('enforces changed-cell and formula-cell expansion limits', () => { expect(parseExcelPatchInput(rawRequest([ { op: 'style_range', sheet: 'Sheet1', range: 'A1:A100000', style: { fill: '#FF0000' } }, ])).ok).toBe(true); expect(parseExcelPatchInput(rawRequest([ { op: 'style_range', sheet: 'Sheet1', range: 'A1:A100001', style: { fill: '#FF0000' } }, ]))).toMatchObject({ ok: false }); expect(parseExcelPatchInput(rawRequest([ { op: 'fill_formula', sheet: 'Sheet1', range: 'A1:A10000', formula: 'ROW()' }, ])).ok).toBe(true); expect(parseExcelPatchInput(rawRequest([ { op: 'fill_formula', sheet: 'Sheet1', range: 'A1:A10001', formula: 'ROW()' }, ]))).toMatchObject({ ok: false }); }); it('accepts XFD1048576 and rejects coordinates beyond it', () => { expect(parseExcelPatchInput(rawRequest([ { op: 'set_formula', sheet: 'Sheet1', cell: 'XFD1048576', formula: '1+1' }, ])).ok).toBe(true); expect(parseExcelPatchInput(rawRequest([ { op: 'set_formula', sheet: 'Sheet1', cell: 'XFE1048576', formula: '1+1' }, ]))).toMatchObject({ ok: false }); expect(parseExcelPatchInput(rawRequest([ { op: 'set_formula', sheet: 'Sheet1', cell: 'XFD1048577', formula: '1+1' }, ]))).toMatchObject({ ok: false }); }); it('rejects a values matrix that does not match its range', () => { expect(parseExcelPatchInput(rawRequest([ { op: 'set_values', sheet: 'Sheet1', range: 'A1:B2', values: [[1, 2]] }, ]))).toMatchObject({ ok: false }); }); it('rejects forbidden and case-only duplicate sheet names', () => { expect(parseExcelPatchInput(rawRequest([ { op: 'add_sheet', name: 'Bad/Name' }, ]))).toMatchObject({ ok: false }); expect(parseExcelPatchInput(rawRequest([ { op: 'add_sheet', name: 'Plan' }, { op: 'add_sheet', name: 'plan' }, ]))).toMatchObject({ ok: false }); }); }); describe('normalizePatchOperations conflicts and phases', () => { it('rejects contradictory writes to the same cell', () => { const result = normalizePatchOperations(parsedRequest([ { op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }, { op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }, ]), snapshot()); expect(result).toMatchObject({ ok: false, violations: [expect.objectContaining({ code: 'OPERATION_CONFLICT', cell: 'A1' })], }); }); it('rejects intersecting new and existing merges', () => { expect(normalizePatchOperations(parsedRequest([ { op: 'merge', sheet: 'Sheet1', range: 'A1:B2' }, { op: 'merge', sheet: 'Sheet1', range: 'B2:C3' }, ]), snapshot())).toMatchObject({ ok: false }); expect(normalizePatchOperations(parsedRequest([ { op: 'merge', sheet: 'Sheet1', range: 'B2:C3' }, ]), snapshot(['A1:B2']))).toMatchObject({ ok: false }); }); it('allows replacing an existing merge after an explicit unmerge', () => { const result = normalizePatchOperations(parsedRequest([ { op: 'merge', sheet: 'Sheet1', range: 'A1:C1' }, { op: 'unmerge', sheet: 'Sheet1', range: 'A1:B1' }, ]), snapshot(['A1:B1'])); expect(result.ok).toBe(true); }); it('rejects writes to a merged non-anchor cell', () => { expect(normalizePatchOperations(parsedRequest([ { op: 'set_values', sheet: 'Sheet1', range: 'B1', values: [['lost']] }, { op: 'merge', sheet: 'Sheet1', range: 'A1:B1' }, ]), snapshot())).toMatchObject({ ok: false }); }); it('rejects operations for a missing sheet', () => { expect(normalizePatchOperations(parsedRequest([ { op: 'style_range', sheet: 'Missing', range: 'A1', style: { fill: '#FF0000' } }, ]), snapshot())).toMatchObject({ ok: false }); }); it('normalizes phases independent of input order and expands fill_formula', () => { const result = normalizePatchOperations(parsedRequest([ { op: 'freeze_panes', sheet: 'New', rows: 1, columns: 1 }, { op: 'style_range', sheet: 'New', range: 'A1:B2', style: { fill: '#112233' } }, { op: 'merge', sheet: 'New', range: 'A3:B3' }, { op: 'fill_formula', sheet: 'New', range: 'A1:B2', formula: 'A1+$C$1' }, { op: 'unmerge', sheet: 'Sheet1', range: 'D1:E1' }, { op: 'add_sheet', name: 'New' }, ]), snapshot(['D1:E1'])); if (!result.ok) throw new Error(result.violations.map((item) => item.message).join('; ')); expect(result.value.operations.map((operation) => operation.phase)).toEqual([ 'add_sheet', 'unmerge', 'values_formulas', 'values_formulas', 'values_formulas', 'values_formulas', 'merge', 'styles', 'layout', ]); expect(result.value.operations.filter((operation) => operation.op === 'set_cell_formula')) .toEqual(expect.arrayContaining([ expect.objectContaining({ cell: 'A1', formula: 'A1+$C$1' }), expect.objectContaining({ cell: 'B2', formula: 'B2+$C$1' }), ])); }); }); /** * 行の高さの上限は、同じ道具の中で 2 通りあってはならない(#1322 PR レビュー)。 * 「セルだけを直す方法」が 409.5 を通すのに詳細編集が 409 で断ると、利用者には * 「さっき通った高さが通らない」と見える。409.5 は Excel で作れる最大。 */ describe('set_row_height の上限', () => { const heightOf = (height: unknown) => parseExcelPatchInput(rawRequest([{ op: 'set_row_height', sheet: 'Sheet1', rows: '1:2', height }])); it('セルだけを直す方法と同じ 409.5 まで通す', () => { expect(MAX_ROW_HEIGHT).toBe(409.5); expect(heightOf(409.5).ok).toBe(true); expect(heightOf(409).ok).toBe(true); }); it('409.5 を超えたら断る', () => { expect(heightOf(409.6).ok).toBe(false); expect(heightOf(0).ok).toBe(false); }); }); /** * コピーされた式のおおもとを見分ける判定(#1351 段 1)。 * * 見ているのは 2 つ——`shareType` が `shared` であることと、範囲(`ref`)を * 持つこと。2 つは重なって効いているので、片方を落としても素通りする素材しか * 無いと、あとから「整理」した人が黙って穴を開けられる。**片方ずつが効く形**を * 置いて釘付けする。 */ describe('findSharedFormulaMasterWrites', () => { function planFor(operations: unknown[]): NormalizedExcelPlan { const normalized = normalizePatchOperations(parsedRequest(operations), snapshot()); if (!normalized.ok) throw new Error(normalized.violations.map((item) => item.message).join('; ')); return normalized.value; } it('範囲を持つおおもとに書く指示を拾う', () => { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('Sheet1'); sheet.getCell('C2').value = { formula: 'A2*B2', shareType: 'shared', ref: 'C2:C4', result: 40 }; const found = findSharedFormulaMasterWrites(workbook, planFor([ { op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }, ])); expect(found).toEqual([{ sheet: 'Sheet1', cell: 'C2', range: 'C2:C4' }]); }); /** * 配列数式も範囲を持つ。範囲があるというだけで拾うと、かたまりとは別の * しくみのこちらまで書けなくなる(本体が消える件はこの段では扱わない)。 */ it('配列数式のおおもとは拾わない(範囲は持っている)', () => { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('Sheet1'); sheet.getCell('D1').value = { formula: 'SUM(A1:A3)', shareType: 'array', ref: 'D1:D3', result: 6 }; const found = findSharedFormulaMasterWrites(workbook, planFor([ { op: 'set_values', sheet: 'Sheet1', range: 'D1', values: }, ])); expect(found).toEqual([]); }); /** * 範囲を持たない側は追随側で、書き換えてもおおもとの定義は壊れない。拾うと * 直しすぎになる。 */ it('範囲を持たないセルは拾わない', () => { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('Sheet1'); sheet.getCell('C3').value = { formula: 'A3*B3', shareType: 'shared', result: 90 }; const found = findSharedFormulaMasterWrites(workbook, planFor([ { op: 'set_values', sheet: 'Sheet1', range: 'C3', values: }, ])); expect(found).toEqual([]); }); }); /** * おおもとに書く前の実体化(#1351 段 2)。`ExcelPatch` 側の経路。 * * 画面の保存側は XML を直に書き換えるので別の検査がある(`xlsx-shared-formula.test.ts`)。 * こちらは exceljs の値の形で同じことをしている。**計算済みの値を持ち越すところ**は、 * まとまり全体を通す検査だと計算し直しの段で上書きされて見えなくなるので、ここで直に釘付けする。 * * **持ち主が決まるかどうかの判定はここには無い**(#1378)。ファイルを開いた時点で * 関所が全シートを見るようになったので、判定は `findSharedFormulaSheetDoubts` の側 * (`xlsx-shared-formula.test.ts`)に寄せた。ここに同じ検査を戻さないこと。 */ describe('materializeSharedFormulaBlocks', () => { function planFor(operations: unknown[]): NormalizedExcelPlan { const normalized = normalizePatchOperations(parsedRequest(operations), snapshot()); if (!normalized.ok) throw new Error(normalized.violations.map((item) => item.message).join('; ')); return normalized.value; } function sharedBook(formula: string): ExcelJS.Workbook { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('Sheet1'); sheet.getCell('C2').value = { formula, shareType: 'shared', ref: 'C2:C4', result: 40 }; sheet.getCell('C3').value = { sharedFormula: 'C2', result: 90 } as ExcelJS.CellValue; sheet.getCell('C4').value = { sharedFormula: 'C2', result: 160 } as ExcelJS.CellValue; return workbook; } it('追随側に訳した式が入り、計算済みの値はそのまま残る', () => { const workbook = sharedBook('A2*B2'); const failures = materializeSharedFormulaBlocks(workbook, planFor([ { op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }, ])); expect(failures).toEqual([]); const sheet = workbook.getWorksheet('Sheet1')!; expect(sheet.getCell('C3').value).toEqual({ formula: 'A3*B3', result: 90 }); expect(sheet.getCell('C4').value).toEqual({ formula: 'A4*B4', result: 160 }); }); it('おおもとからは、かたまりの宣言(範囲と種別)が外れる', () => { const workbook = sharedBook('A2*B2'); materializeSharedFormulaBlocks(workbook, planFor([ { op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }, ])); expect(workbook.getWorksheet('Sheet1')!.getCell('C2').value).toEqual({ formula: 'A2*B2', result: 40 }); }); /** 訳せないかたまりが 1 つでもあれば、そのブックには 1 文字も書かない。 */ it('訳せないかたまりは理由を返し、ブックには手を付けない', () => { const workbook = sharedBook('SUM(A:A)'); const failures = materializeSharedFormulaBlocks(workbook, planFor([ { op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }, ])); expect(failures).toHaveLength(1); expect(failures[0]!.sheet).toBe('Sheet1'); expect(failures[0]!.cell).toBe('C2'); expect(failures[0]!.range).toBe('C2:C4'); const sheet = workbook.getWorksheet('Sheet1')!; expect(sheet.getCell('C3').value).toEqual({ sharedFormula: 'C2', result: 90 }); expect(sheet.getCell('C2').value).toEqual({ formula: 'SUM(A:A)', shareType: 'shared', ref: 'C2:C4', result: 40 }); }); }); describe('applyExcelPlan', () => { it('applies all operation phases, styles empty cells and survives round-trip', async () => { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('Sheet1'); sheet.mergeCells('F1:G1'); sheet.getCell('I1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF00FF00' } }; const request = parsedRequest([ { op: 'set_values', sheet: 'Sheet1', range: 'A1:B2', values: [[1, 2], [3, 4]] }, { op: 'set_formula', sheet: 'Sheet1', cell: 'C1', formula: '=SUM(A1:B1)' }, { op: 'fill_formula', sheet: 'Sheet1', range: 'C2:C3', formula: 'A1+B1' }, { op: 'unmerge', sheet: 'Sheet1', range: 'F1:G1' }, { op: 'merge', sheet: 'Sheet1', range: 'D1:E1' }, { op: 'style_range', sheet: 'Sheet1', range: 'H1:H2', style: { fill: '#112233', bold: true } }, { op: 'set_column_width', sheet: 'Sheet1', columns: 'A:C', width: 14 }, { op: 'set_row_height', sheet: 'Sheet1', rows: '1:2', height: 24 }, { op: 'freeze_panes', sheet: 'Sheet1', rows: 1, columns: 1 }, { op: 'add_sheet', name: 'Summary' }, ]); const normalized = normalizePatchOperations(request, snapshot(['F1:G1'])); if (!normalized.ok) throw new Error(normalized.violations.map((item) => item.message).join('; ')); const result = await applyExcelPlan(workbook, normalized.value); expect(result.formulaCells).toBe(3); expect(result.changedCells).toBeGreaterThan(0); expect(sheet.getCell('H1').style).not.toBe(sheet.getCell('H2').style); expect(sheet.getCell('H2').fill).toMatchObject({ fgColor: { argb: 'FF112233' } }); expect(sheet.getCell('I1').fill).toMatchObject({ fgColor: { argb: 'FF00FF00' } }); expect(workbook.calcProperties.fullCalcOnLoad).toBe(true); const dir = await mkdtemp(join(tmpdir(), 'excel-patch-')); tempDirs.push(dir); const path = join(dir, 'book.xlsx'); await workbook.xlsx.writeFile(path); const reopened = new ExcelJS.Workbook(); await reopened.xlsx.readFile(path); const reopenedSheet = reopened.getWorksheet('Sheet1')!; expect(reopened.worksheets.map((item) => item.name)).toContain('Summary'); expect(reopenedSheet.getCell('C1').formula).toBe('SUM(A1:B1)'); expect(reopenedSheet.getCell('C3').formula).toBe('A2+B2'); expect(reopenedSheet.getCell('H2').value).toBeNull(); expect(reopenedSheet.getCell('H2').fill).toMatchObject({ fgColor: { argb: 'FF112233' } }); expect(reopenedSheet.getColumn(1).width).toBe(14); expect(reopenedSheet.getRow(1).height).toBe(24); expect(reopenedSheet.views[0]).toMatchObject({ state: 'frozen', xSplit: 1, ySplit: 1 }); }); });