821 lines
37 KiB
TypeScript
821 lines
37 KiB
TypeScript
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>');
|
||
});
|
||
});
|