sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
@@ -0,0 +1,820 @@
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>');
});
});