Files
maestro/src/engine/tools/excel-edit-pipeline.test.ts
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

821 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import AdmZip from 'adm-zip';
import ExcelJS from 'exceljs';
import { afterEach, describe, expect, it } from 'vitest';
import type { ToolContext } from './core.js';
import { ExcelFileSafetyError } from './excel-file-safety.js';
import {
runExcelEditPipeline,
type ExcelEditPipelineDependencies,
} from './excel-edit-pipeline.js';
import {
readSheetDataXml,
writeXlsxWithCleanSheetAndCollidingSecondSheet,
writeXlsxWithCollidingIdsAndCleanSheet,
writeXlsxWithCollidingSharedFormulaIds,
writeXlsxWithEmptySharedFormulaMaster,
writeXlsxWithOrphanSharedFormulaFollowers,
writeXlsxWithSharedFormulaMasterAfterFollowers,
writeXlsxWithSharedFormulaBlock,
writeXlsxWithSharedFormulaBlocksOnTwoSheets,
writeXlsxWithSharedFormulaOf,
} from '../../office/__fixtures__/forge-zip.js';
const workspaces: string[] = [];
async function workspace(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'excel-edit-pipeline-'));
workspaces.push(path);
return path;
}
async function writeSource(path: string): Promise<void> {
mkdirSync(dirname(path), { recursive: true });
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet('Sheet1');
await workbook.xlsx.writeFile(path);
}
const context = (workspacePath: string): ToolContext => ({ workspacePath, editAllowed: true });
/** 断ったときに元のファイルが 1 バイトも動いていないことを見るための控え。 */
function digestOf(path: string): string {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}
function recalculation(
formulaErrorCodes: Record<string, number> = {},
): ExcelEditPipelineDependencies['recalculate'] {
return async input => {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(input.inputPath);
for (const sheet of workbook.worksheets) {
sheet.eachRow(row => row.eachCell(cell => {
if (!cell.formula) return;
const result = cell.formula === 'SUM(A1:B1)' ? 3 : 2;
cell.value = { formula: cell.formula, result };
}));
}
await workbook.xlsx.writeFile(input.outputPath);
return { ok: true, canaryValue: 2, formulaErrorCodes };
};
}
afterEach(() => {
for (const path of workspaces.splice(0)) rmSync(path, { recursive: true, force: true });
});
describe('runExcelEditPipeline', () => {
it('applies merge, color, formula and validates the calculated expectation before commit', async () => {
const workspacePath = await workspace();
await writeSource(join(workspacePath, 'input/source.xlsx'));
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
operations: [
{ op: 'set_values', sheet: 'Sheet1', range: 'A1:B1', values: [[1, 2]] },
{ op: 'set_formula', sheet: 'Sheet1', cell: 'C1', formula: 'SUM(A1:B1)', expect: 3 },
{ op: 'merge', sheet: 'Sheet1', range: 'A3:B3' },
{ op: 'style_range', sheet: 'Sheet1', range: 'A1:C1', style: { fill: '#FFF2CC', bold: true } },
],
}, context(workspacePath), { recalculate: recalculation() });
expect(result).toMatchObject({
ok: true,
phase: 'commit',
candidate_path: 'output/report.xlsx',
summary: { checked_formula_cells: 1 },
});
const output = join(workspacePath, 'output/report.xlsx');
expect(existsSync(output)).toBe(true);
expect(existsSync(join(workspacePath, '.conflict'))).toBe(false);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(output);
const sheet = workbook.getWorksheet('Sheet1')!;
expect(sheet.getCell('C1').formula).toBe('SUM(A1:B1)');
expect(sheet.getCell('A1').fill).toMatchObject({ fgColor: { argb: 'FFFFF2CC' } });
expect((sheet.model as { merges?: string[] }).merges).toContain('A3:B3');
});
it('does not publish a candidate when recalculation reports a formula error', async () => {
const workspacePath = await workspace();
await writeSource(join(workspacePath, 'input/source.xlsx'));
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
operations: [
{ op: 'set_formula', sheet: 'Sheet1', cell: 'C1', formula: '"text"+1' },
],
}, context(workspacePath), {
recalculate: recalculation({ 'Sheet1!C1': 519 }),
});
expect(result).toMatchObject({
ok: false,
phase: 'calculation',
candidate_path: null,
violations: [expect.objectContaining({ code: 'FORMULA_ERROR', sheet: 'Sheet1', cell: 'C1' })],
});
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
});
/**
* 実行時例外を「ファイルが壊れている」と診断しない(#1067 診断 1 件目)。
*
* ここへ来る時点で元ファイルは package 段階の検査を通っている。外部プロセスや
* ライブラリが落ちただけの例外を PACKAGE_INVALID にすると、正常なブックが
* 壊れていると報告される。
*/
it('reports an unexpected runtime failure as INTERNAL_ERROR, not a broken workbook', async () => {
const workspacePath = await workspace();
await writeSource(join(workspacePath, 'input/source.xlsx'));
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
operations: [
{ op: 'set_formula', sheet: 'Sheet1', cell: 'C1', formula: '1+1', expect: 2 },
],
}, context(workspacePath), {
recalculate: async () => { throw new Error('spawn soffice ENOMEM'); },
});
expect(result.ok).toBe(false);
expect(result.violations).toHaveLength(1);
const [violation] = result.violations;
expect(violation.code).toBe('INTERNAL_ERROR');
expect(violation.code).not.toBe('PACKAGE_INVALID');
// 生の例外文言だけを返さない(何が起きたのかを添えて返す)。
expect(violation.message).toContain('内部エラー');
expect(violation.message).toContain('spawn soffice ENOMEM');
expect(violation.message).not.toBe('spawn soffice ENOMEM');
// 元のブックは無事で、候補も残らない。
expect(existsSync(join(workspacePath, 'input/source.xlsx'))).toBe(true);
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
});
/**
* 反対側も固定する。理由の分かっている失敗(ExcelFileSafetyError)は、
* INTERNAL_ERROR に丸めず、その診断のまま返す(#1067 レビュー P2-3)。
*/
it('keeps a known file-safety failure with its own diagnosis', async () => {
const workspacePath = await workspace();
await writeSource(join(workspacePath, 'input/source.xlsx'));
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
operations: [
{ op: 'set_formula', sheet: 'Sheet1', cell: 'C1', formula: '1+1', expect: 2 },
],
}, context(workspacePath), {
recalculate: async () => {
throw new ExcelFileSafetyError('PACKAGE_INVALID', '壊れたZIP/OOXMLパッケージです');
},
});
expect(result.ok).toBe(false);
expect(result.violations).toHaveLength(1);
const [violation] = result.violations;
expect(violation.code).toBe('PACKAGE_INVALID');
expect(violation.code).not.toBe('INTERNAL_ERROR');
expect(violation.message).toBe('壊れたZIP/OOXMLパッケージです');
expect(violation.message).not.toContain('内部エラー');
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
});
/**
* `operations` の経路は共通の関所を通らない(手前で分岐して返る)。展開後
* サイズの上限が設定キーを見ずに 200MB 固定だったのを、同じキーへ繋いだ
* (#1067 レビュー P2-4)。同じファイルで、設定を絞ると断り、既定なら通る。
*/
it('honours officePptxMaxUncompressedMb on the operations path', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/source.xlsx');
await writeSource(source);
const zip = new AdmZip(source);
zip.addFile('docProps/thumbnail.bin', Buffer.alloc(2 * 1024 * 1024, 0));
zip.writeZip(source);
const request = {
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1:B1', values: [[1, 2]] }],
};
const rejected = await runExcelEditPipeline(
request,
{ workspacePath, editAllowed: true, toolsConfig: { officePptxMaxUncompressedMb: 1 } },
{ recalculate: recalculation() },
);
expect(rejected.ok).toBe(false);
expect(rejected.violations[0]?.code).toBe('PACKAGE_INVALID');
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
// 同じファイルでも、既定(200MB)なら通る=断ったのは設定キーの効果。
const accepted = await runExcelEditPipeline(
request,
context(workspacePath),
{ recalculate: recalculation() },
);
expect(accepted.ok).toBe(true);
});
/**
* 項目数の上限も同じ設定キーで動く。共通の関所とこちらで値が食い違うと、
* 同じファイルが経路によって通ったり断られたりする(#1067 レビュー P2-2)。
*/
it('honours officeMaxEntries on the operations path', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/source.xlsx');
await writeSource(source);
const request = {
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1:B1', values: [[1, 2]] }],
};
const rejected = await runExcelEditPipeline(
request,
{ workspacePath, editAllowed: true, toolsConfig: { officeMaxEntries: 1 } },
{ recalculate: recalculation() },
);
expect(rejected.ok).toBe(false);
expect(rejected.violations[0]?.code).toBe('PACKAGE_INVALID');
expect(rejected.violations[0]?.message).toContain('部品数');
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
// 同じファイルでも、既定(10000 件)なら通る=断ったのは設定キーの効果。
const accepted = await runExcelEditPipeline(
request,
context(workspacePath),
{ recalculate: recalculation() },
);
expect(accepted.ok).toBe(true);
});
it('fully validates dry_run but leaves no output file', async () => {
const workspacePath = await workspace();
await writeSource(join(workspacePath, 'input/source.xlsx'));
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
dry_run: true,
operations: [
{ op: 'set_formula', sheet: 'Sheet1', cell: 'A1', formula: '1+1', expect: 2 },
],
}, context(workspacePath), { recalculate: recalculation() });
expect(result).toMatchObject({ ok: true, phase: 'commit', candidate_path: null });
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
});
it('compiles gantt holiday and progress colors after applying data operations', async () => {
const workspacePath = await workspace();
await writeSource(join(workspacePath, 'input/source.xlsx'));
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/gantt.xlsx',
operations: [
{ op: 'set_values', sheet: 'Sheet1', range: 'D1:H1', values: [['2026-08-07', '2026-08-08', '2026-08-09', '2026-08-10', '2026-08-11']] },
{ op: 'set_values', sheet: 'Sheet1', range: 'A2:C2', values: [['2026-08-07', '2026-08-11', 0.5]] },
],
gantt: {
sheet: 'Sheet1',
timeline_header: 'D1:H1',
task_rows: '2',
start_column: 'A',
end_column: 'B',
progress_column: 'C',
progress_scale: 'fraction',
holidays: ['2026-08-10'],
roles: {
weekend: { fill: '#EEEEEE' },
holiday: { fill: '#FFCCCC' },
planned: { fill: '#99CCFF' },
completed: { fill: '#66CC66' },
},
},
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok, JSON.stringify(result)).toBe(true);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(join(workspacePath, 'output/gantt.xlsx'));
const sheet = workbook.getWorksheet('Sheet1')!;
expect(sheet.getCell('G1').fill).toMatchObject({ fgColor: { argb: 'FFFFCCCC' } });
expect(sheet.getCell('D2').fill).toMatchObject({ fgColor: { argb: 'FF66CC66' } });
expect(sheet.getCell('H2').fill).toMatchObject({ fgColor: { argb: 'FF99CCFF' } });
});
it('counts user and gantt operations against one combined budget', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/source.xlsx');
await writeSource(source);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(source);
const sheet = workbook.getWorksheet('Sheet1')!;
sheet.getCell('D1').value = '2026-08-08';
sheet.getCell('A2').value = '2026-08-08';
sheet.getCell('B2').value = '2026-08-08';
sheet.getCell('C2').value = 0.5;
await workbook.xlsx.writeFile(source);
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/too-many.xlsx',
operations: Array.from({ length: 500 }, () => ({
op: 'freeze_panes', sheet: 'Sheet1', rows: 1, columns: 1,
})),
gantt: {
sheet: 'Sheet1',
timeline_header: 'D1:D1',
task_rows: '2',
start_column: 'A',
end_column: 'B',
progress_column: 'C',
progress_scale: 'fraction',
holidays: [],
roles: {
weekend: { fill: '#EEEEEE' },
holiday: { fill: '#FFCCCC' },
planned: { fill: '#99CCFF' },
completed: { fill: '#66CC66' },
},
},
}, context(workspacePath), { recalculate: recalculation() });
expect(result).toMatchObject({
ok: false,
phase: 'normalize',
violations: [expect.objectContaining({ code: 'INPUT_INVALID' })],
});
expect(existsSync(join(workspacePath, 'output/too-many.xlsx'))).toBe(false);
});
/**
* コピーした式のおおもと(#1351 段 2)。
*
* ExcelPatch は読んで書き直す作りなので、**もとのファイルにかたまりがあれば
* そのまま残る**。段 1 まではおおもとへの書き込みを断っていた。段 2 では、
* 追随するセルへ式を配ってから編集を通す。**画面の保存側だけ通せるように
* すると、こちらが断ったままの抜け道になる。**
*/
describe('コピーした式のおおもと', () => {
it('おおもとに式を書く指示は通り、追随するセルは元の式を全文で持つ', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/shared.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithSharedFormulaBlock(source);
const result = await runExcelEditPipeline({
path: 'input/shared.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_formula', sheet: 'Sheet1', cell: 'C2', formula: 'A2+B2' }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(true);
const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx'));
// 頼んだのは C2 だけ。残りは「おおもとと同じ式」ではなく自分の式になる。
expect(sheetXml).toContain('<f>A2+B2</f>');
expect(sheetXml).toContain('<f>A3*B3</f>');
expect(sheetXml).toContain('<f>A4*B4</f>');
expect(sheetXml).not.toContain('t="shared"');
});
it('おおもとに値を書く指示も通り、追随するセルの式は残る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/shared.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithSharedFormulaBlock(source);
const result = await runExcelEditPipeline({
path: 'input/shared.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(true);
const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx'));
expect(sheetXml).toContain('<f>A3*B3</f>');
expect(sheetXml).toContain('<f>A4*B4</f>');
});
/** 訳せない形(列をまるごと指す参照)は、段 1 と同じく理由を添えて断る。 */
it('訳せない式のおおもとは、理由の分かる形で断る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/shared.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithSharedFormulaOf(source, 'SUM(A:A)*B2');
const result = await runExcelEditPipeline({
path: 'input/shared.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result).toMatchObject({
ok: false,
violations: [expect.objectContaining({
code: 'SHARED_FORMULA_MASTER',
sheet: 'Sheet1',
cell: 'C2',
})],
});
expect(result.violations[0]!.message).toContain('C2:C4');
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
});
/**
* 同じ番号(`si`)を名乗るかたまりが 2 つある書類(#1378)。
*
* この経路はブックを exceljs で丸ごと読み込んで丸ごと書き戻す。exceljs はこの
* 並びを「あとに見たおおもとが勝つ」で読み、Excel / LibreOffice は「先に見た
* おおもとが勝つ」で読む。だから**書き込み先がどこであっても**、触っていない
* C のかたまりに E のかたまりの式が流れ込む(LibreOffice で計算し直すと
* C3 が 33 から 90、E3 が 40 から 630 に変わる)。
*
* **関係の無いマスへの書き込みでも断ること。**#1378 の穴はここだった。
* おおもとを書くときだけ断っていたので、`A1` を書く指示は素通りしていた。
*/
it('番号がぶつかった書類では、無関係なマス(A1)への書き込みも断る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/collide.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithCollidingSharedFormulaIds(source);
const before = digestOf(source);
const result = await runExcelEditPipeline({
path: 'input/collide.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(false);
expect(result.violations.map(item => [item.code, item.sheet, item.cell]))
.toEqual([
['SHARED_FORMULA_MASTER', 'Sheet1', 'C2'],
['SHARED_FORMULA_MASTER', 'Sheet1', 'E2'],
]);
// 「おおもとに書こうとしたから断った」と読める文面にしない。書いたのは A1 で、
// 利用者はどのかたまりにも触っていない。
expect(result.violations[0]!.message).toContain('どのマスを書き換えても');
// この並びは `edits` なら通る(`xlsx-shared-formula.test.ts` で裏取り済み)。
// 案内文もそう言い切っている。通らない並びと同じ文面にしてはいけない。
expect(result.violations[0]!.message)
.toContain('edits(値・数式の差し替え)で頼めば、この書き方のままでも通ります');
expect(digestOf(source)).toBe(before);
});
/** 断ったら、候補のファイルも残さない。書きかけを拾わせない。 */
it('番号がぶつかった書類を断ったとき、出力ファイルは作られない', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/collide.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithCollidingSharedFormulaIds(source);
const result = await runExcelEditPipeline({
path: 'input/collide.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(false);
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
});
/**
* **書き換えるシートと壊れるシートは別**(#1378)。
*
* 丸ごと書き戻す経路なので、`Sheet2` を書く指示でも `Sheet1` のかたまりが
* 化ける。書き込み先のシートだけを見る関所では捕まらない。
*
* `Sheet2` も `si="0"` を使っている。シートをまたいだ番号を「ぶつかっている」と
* 読む関所なら、この材料で断りすぎが出る。
*/
it('別のシートを編集する指示でも、番号のぶつかったシートを理由に断る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/collide.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithCollidingIdsAndCleanSheet(source);
const before = digestOf(source);
const result = await runExcelEditPipeline({
path: 'input/collide.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet2', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(false);
// 断る理由は Sheet1 のかたまりだけ。Sheet2 は普通のかたまりなので挙げない。
expect(result.violations.map(item => [item.sheet, item.cell]))
.toEqual([['Sheet1', 'C2'], ['Sheet1', 'E2']]);
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
expect(digestOf(source)).toBe(before);
});
/**
* **壊れているのは 2 枚目のシートだけ**(#1378)。
*
* 1 枚目は `si="0"` のかたまりが 1 つあるだけの普通のシートで、いくら見ても
* 何も出ない。関所が 1 枚目で歩みを止めていると、この書類は素通りする。
* 上の「別のシートを編集する指示でも〜」は壊れている側が 1 枚目なので、
* 1 枚目しか見ない関所でも断れてしまい、この穴を塞げない。
*
* 書き込み先は普通のほうの `Sheet1`。**利用者が触っているシートには何の問題も
* 無い**のに、丸ごと書き戻す経路では `Sheet2` のかたまりが化ける。
*/
it('2 枚目のシートにだけ番号のぶつかったかたまりがある書類も断る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/second-sheet.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithCleanSheetAndCollidingSecondSheet(source);
const before = digestOf(source);
const result = await runExcelEditPipeline({
path: 'input/second-sheet.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(false);
// 断る理由は Sheet2 のかたまりだけ。Sheet1 は普通のかたまりなので挙げない。
expect(result.violations.map(item => [item.code, item.sheet, item.cell]))
.toEqual([
['SHARED_FORMULA_MASTER', 'Sheet2', 'C2'],
['SHARED_FORMULA_MASTER', 'Sheet2', 'E2'],
]);
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
expect(digestOf(source)).toBe(before);
});
/**
* 文面の無いおおもとを持つ書類(#1376)。
*
* 以前は exceljs が式を平行移動しようとして `TypeError` を投げ、`normalize` 段の
* `INTERNAL_ERROR` になっていた。符号の意味(こちら側の実行時例外)と実態
* (ファイル側が原因)が食い違うので、利用者は原因にたどり着けない。
* **入口で理由を添えて断る。**
*/
it('文面の無いおおもとを持つ書類は、内部エラーではなく理由を添えて断る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/empty-master.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithEmptySharedFormulaMaster(source);
const before = digestOf(source);
const result = await runExcelEditPipeline({
path: 'input/empty-master.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result).toMatchObject({
ok: false,
violations: [expect.objectContaining({
code: 'SHARED_FORMULA_MASTER',
sheet: 'Sheet1',
cell: 'C2',
})],
});
expect(result.violations.some(item => item.code === 'INTERNAL_ERROR')).toBe(false);
expect(result.violations[0]!.message).toContain('配るはずの式の文面がありません');
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
expect(digestOf(source)).toBe(before);
});
/**
* おおもとがただの値に置き換わり、追随側だけが残った書類(#1378 の 3 つ目の形)。
*
* これも成功として返っていた。書き出されるのは中身の無い式(`<f/>`)で、
* LibreOffice で開くと `Err:520` になる(実測)。
*/
it('おおもとの消えた追随マスが残っている書類も断る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/orphan.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithOrphanSharedFormulaFollowers(source);
const before = digestOf(source);
const result = await runExcelEditPipeline({
path: 'input/orphan.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(false);
expect(result.violations.map(item => [item.code, item.cell]))
.toEqual([['SHARED_FORMULA_MASTER', 'C3'], ['SHARED_FORMULA_MASTER', 'C4']]);
expect(result.violations[0]!.message).toContain('Err:520');
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
expect(digestOf(source)).toBe(before);
});
/**
* 追随マスが**おおもとより前**に並んでいる書類(#1378 レビュー P1-1)。
*
* 範囲(`ref`)は追随マスを含んでいるので、範囲だけを見る判定はこの並びを
* 通してしまう。ところが exceljs は読みながら採番表を作るので、まだ読んで
* いない番号を引いた C2・C3 は持ち主が `undefined` になり、書き出しで
* 中身の無い式(`<f/>`)になる。**成功として返ったうえで値が消えていた。**
*/
it('おおもとが追随マスより後ろに並んだ書類も断る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/master-after.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithSharedFormulaMasterAfterFollowers(source);
const before = digestOf(source);
const result = await runExcelEditPipeline({
path: 'input/master-after.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(false);
expect(result.violations.map(item => [item.code, item.sheet, item.cell]))
.toEqual([
['SHARED_FORMULA_MASTER', 'Sheet1', 'C2'],
['SHARED_FORMULA_MASTER', 'Sheet1', 'C3'],
]);
// 「おおもとがどこにも無い」書類とは症状が違う。文面も分ける。
expect(result.violations[0]!.message).toContain('C4');
expect(result.violations[0]!.message).toContain('後ろ');
expect(result.violations[0]!.message).toContain('Err:520');
// この並びは `edits` なら通る(`xlsx-shared-formula.test.ts` で裏取り済み)。
expect(result.violations[0]!.message)
.toContain('edits(値・数式の差し替え)で頼めば、この書き方のままでも通ります');
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
expect(digestOf(source)).toBe(before);
});
/**
* **普通の複数シートのブックは通す**(#1378)。
*
* 共有式の番号はシートごとに閉じているので、普通のブックは素で「どのシートも
* `si="0"`」になる。入口の関所が 1 冊まとめて見ていると、この形が全部断られる。
* 断りすぎに倒したときに気づくための検査。
*/
it('どのシートも si=0 を使う普通のブックは通る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/two-sheets.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithSharedFormulaBlocksOnTwoSheets(source);
const result = await runExcelEditPipeline({
path: 'input/two-sheets.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet2', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result).toMatchObject({ ok: true, phase: 'commit' });
// かたまりはどちらのシートでも無傷のまま残る。番地はシートごとに違う
// (同じ番地だと、2 枚を 1 枚として見る誤りが潰れて見えなくなる)。
const out = join(workspacePath, 'output/report.xlsx');
expect(readSheetDataXml(out, 'xl/worksheets/sheet1.xml'))
.toContain('<f t="shared" ref="C2:C4" si="0">A2*B2</f>');
expect(readSheetDataXml(out, 'xl/worksheets/sheet2.xml'))
.toContain('<f t="shared" ref="E2:E4" si="0">A2*B2</f>');
});
it('追随側のセルと、本体への書式だけの指示は通す', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/shared.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithSharedFormulaBlock(source);
const result = await runExcelEditPipeline({
path: 'input/shared.xlsx',
output_path: 'output/report.xlsx',
operations: [
{ op: 'set_values', sheet: 'Sheet1', range: 'C3', values: },
{ op: 'style_range', sheet: 'Sheet1', range: 'C2', style: { bold: true } },
],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(true);
const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx'));
expect(sheetXml).toContain('<f t="shared" ref="C2:C4" si="0">A2*B2</f>');
});
});
});
describe('配列数式の保護', () => {
it('本体の上書きを具体的な理由で断り、元ファイルを維持する', async () => {
const root = await workspace();
const path = join(root, 'array.xlsx');
const book = new ExcelJS.Workbook();
const sheet = book.addWorksheet('Sheet1');
sheet.getCell('A1').value = { formula: 'B1:B3*2', shareType: 'array', ref: 'A1:A3', result: 2 };
sheet.getCell('A2').value = 4;
sheet.getCell('A3').value = 6;
await book.xlsx.writeFile(path);
const before = digestOf(path);
const result = await runExcelEditPipeline({
path: 'array.xlsx', output_path: 'output/array.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }],
}, context(root));
expect(result.violations).toEqual(expect.arrayContaining([expect.objectContaining({
code: 'UNSUPPORTED_WORKBOOK_FEATURE',
message: expect.stringContaining('array formula'),
})]));
expect(digestOf(path)).toBe(before);
expect(existsSync(join(root, 'output/array.xlsx'))).toBe(false);
});
});
describe('数式の計算済みゼロ値 (#1373)', () => {
it('再計算した通常数式の数値ゼロを期待値として検証し、他の結果型も維持する', async () => {
const workspacePath = await workspace();
await writeSource(join(workspacePath, 'input/source.xlsx'));
const expected = [0, 'zero', true, 7] as const;
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
operations: expected.map((value, index) => ({
op: 'set_formula',
sheet: 'Sheet1',
cell: `A${index + 1}`,
formula: `${index + 1}`,
expect: value,
})),
}, context(workspacePath), {
recalculate: async input => {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(input.inputPath);
for (const sheet of workbook.worksheets) {
sheet.eachRow(row => row.eachCell(cell => {
if (!cell.formula) return;
const rowIndex = Number.parseInt(cell.address.slice(1), 10) - 1;
const cached = sheet.name === 'Sheet1' ? expected[rowIndex] : 2;
cell.value = { formula: cell.formula, result: cached };
}));
}
await workbook.xlsx.writeFile(input.outputPath);
return { ok: true, canaryValue: 2, formulaErrorCodes: {} };
},
});
expect(result.ok, JSON.stringify(result)).toBe(true);
});
it('共有数式を実体化しても追随セルの数値ゼロを残す', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/shared.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithSharedFormulaOf(source, 'A2*B2');
const result = await runExcelEditPipeline({
path: 'input/shared.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok, JSON.stringify(result)).toBe(true);
const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx'));
expect(sheetXml).toContain('<c r="C3"><f>A3*B3</f><v>0</v></c>');
expect(sheetXml).toContain('<c r="C4"><f>A4*B4</f><v>0</v></c>');
});
});
describe('共有数式の偽値キャッシュ (#1373)', () => {
it('実体化した追随セルの false を真偽値のまま残す', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/shared-false.xlsx');
mkdirSync(dirname(source), { recursive: true });
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Sheet1');
for (let row = 2; row <= 4; row += 1) {
sheet.getCell(`A${row}`).value = row;
sheet.getCell(`B${row}`).value = row * 10;
}
sheet.getCell('C2').value = {
formula: 'A2*B2', shareType: 'shared', ref: 'C2:C4', result: 40,
};
sheet.getCell('C3').value = { sharedFormula: 'C2', result: false };
sheet.getCell('C4').value = { sharedFormula: 'C2', result: 160 };
await workbook.xlsx.writeFile(source);
const result = await runExcelEditPipeline({
path: 'input/shared-false.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok, JSON.stringify(result)).toBe(true);
const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx'));
expect(sheetXml).toContain('<c r="C3" t="b"><f>A3*B3</f><v>0</v></c>');
expect(sheetXml).toContain('<c r="C4"><f>A4*B4</f><v>160</v></c>');
});
});