@ansonlai/docx-redline-js 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +36 -10
- package/README.md +83 -6
- package/adapters/xml-adapter.js +73 -10
- package/core/list-targeting.js +3 -0
- package/core/paragraph-targeting.js +33 -7
- package/core/redline-validation.js +22 -0
- package/core/types.js +122 -27
- package/core/xml-query.js +3 -1
- package/dist/docx-redline-js.esm.js +1148 -572
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +79 -78
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +687 -0
- package/docs/VALIDATION.md +81 -2
- package/docs/WORD-MANUAL-REVIEW.md +138 -0
- package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
- package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
- package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
- package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
- package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
- package/docs/test-comparison-dashboard.html +95 -0
- package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
- package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
- package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
- package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
- package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
- package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
- package/engine/format-extraction.js +1 -1
- package/engine/formatting-removal.js +95 -104
- package/engine/oxml-engine.js +176 -83
- package/engine/reconstruction-mapper.js +276 -79
- package/engine/reconstruction-mode.js +20 -6
- package/engine/reconstruction-writer.js +117 -72
- package/engine/run-builders.js +17 -13
- package/engine/surgical-diff-application.js +7 -21
- package/engine/surgical-mode.js +3 -2
- package/engine/table-mode.js +27 -16
- package/index.d.ts +95 -3
- package/index.js +14 -13
- package/orchestration/list-structural-fallback.js +16 -39
- package/package.json +23 -5
- package/pipeline/diff-engine.js +174 -55
- package/pipeline/ingestion-export.js +39 -24
- package/pipeline/ingestion-paragraph.js +7 -5
- package/pipeline/list-generation.js +27 -18
- package/pipeline/patching.js +2 -3
- package/pipeline/pipeline.js +65 -36
- package/pipeline/serialization.js +13 -5
- package/scripts/build-test-dashboard.mjs +43 -0
- package/scripts/check-types.mjs +16 -24
- package/scripts/export-validation-fixtures.mjs +191 -45
- package/scripts/fetch-superdoc-corpus.mjs +61 -0
- package/scripts/generate-test-dashboard.mjs +199 -0
- package/scripts/inspect-visual-evidence.mjs +271 -0
- package/scripts/lib/minimal-zip.mjs +199 -18
- package/scripts/lib/word-coverage-catalogue.mjs +207 -0
- package/scripts/lib/word-coverage-metadata.mjs +93 -0
- package/scripts/lib/zip-reader.mjs +64 -0
- package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
- package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
- package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
- package/scripts/prepare-word-review.mjs +77 -0
- package/scripts/prepare-word-visual-review.mjs +90 -0
- package/scripts/render-agenda-multilevel.mjs +70 -0
- package/scripts/render-case22.mjs +73 -0
- package/scripts/render-case40.ps1 +35 -0
- package/scripts/render-multilevel-bullet-images.py +58 -0
- package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
- package/scripts/render-multilevel-cases.mjs +80 -0
- package/scripts/report-coverage-gaps.mjs +103 -0
- package/scripts/report-word-coverage.mjs +71 -0
- package/scripts/sample-multimodal-visual-check.mjs +221 -0
- package/scripts/test-multilevel-bullet-visual.mjs +187 -0
- package/scripts/word-com-corpus-suite.ps1 +43 -0
- package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
- package/scripts/word-com-differential.ps1 +158 -16
- package/scripts/word-com-suite.ps1 +19 -0
- package/scripts/word-com-visual-suite.ps1 +132 -0
- package/services/comment-engine.js +51 -46
- package/services/comment-locator.js +0 -1
- package/services/comment-package.js +11 -10
- package/services/numbering-service.js +1 -1
- package/services/revision-comment-management.js +31 -10
- package/services/standalone-docx-plumbing.js +45 -34
- package/services/standalone-operation-runner.js +315 -75
- package/services/table-reconciliation.js +23 -11
|
@@ -1,71 +1,158 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
1
2
|
import { mkdirSync, writeFileSync } from 'fs';
|
|
2
|
-
import { join } from 'path';
|
|
3
|
+
import { join, resolve } from 'path';
|
|
3
4
|
|
|
4
5
|
import { configureXmlProvider } from '../adapters/xml-adapter.js';
|
|
5
6
|
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
6
7
|
import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
|
|
7
|
-
import {
|
|
8
|
-
|
|
8
|
+
import {
|
|
9
|
+
applyOperationToDocumentXml,
|
|
10
|
+
applyOperationsToDocumentXml
|
|
11
|
+
} from '../services/standalone-operation-runner.js';
|
|
12
|
+
import { buildMinimalDocx, buildMinimalDocxEntries } from './lib/minimal-zip.mjs';
|
|
13
|
+
import { unzipEntries } from './lib/zip-reader.mjs';
|
|
14
|
+
import {
|
|
15
|
+
acceptTrackedChangesInOoxml,
|
|
16
|
+
rejectTrackedChangesInOoxml
|
|
17
|
+
} from '../services/revision-comment-management.js';
|
|
18
|
+
import { WORD_TASK_CASES } from '../tests/fixtures/word-task-cases.mjs';
|
|
9
19
|
|
|
10
20
|
const { DOMParser, XMLSerializer } = await import('@xmldom/xmldom');
|
|
11
21
|
configureXmlProvider({ DOMParser, XMLSerializer });
|
|
12
22
|
|
|
13
23
|
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
14
|
-
const
|
|
24
|
+
const outputArgIndex = process.argv.indexOf('--output-dir');
|
|
25
|
+
const requestedOutputDir = outputArgIndex >= 0 ? process.argv[outputArgIndex + 1] : null;
|
|
26
|
+
if (outputArgIndex >= 0 && !requestedOutputDir) throw new Error('--output-dir requires a path');
|
|
27
|
+
const outputDir = requestedOutputDir
|
|
28
|
+
? resolve(process.cwd(), requestedOutputDir)
|
|
29
|
+
: join(process.cwd(), 'tmp', 'validation-docx');
|
|
15
30
|
mkdirSync(outputDir, { recursive: true });
|
|
16
31
|
|
|
32
|
+
const escapeXmlText = text => String(text)
|
|
33
|
+
.replace(/&/g, '&')
|
|
34
|
+
.replace(/</g, '<')
|
|
35
|
+
.replace(/>/g, '>');
|
|
36
|
+
|
|
17
37
|
const baseDocument = text => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
18
38
|
<w:document xmlns:w="${NS_W}">
|
|
19
39
|
<w:body>
|
|
20
|
-
|
|
40
|
+
${String(text).split(/\r?\n/).map(paragraphText =>
|
|
41
|
+
`<w:p><w:r><w:t xml:space="preserve">${escapeXmlText(paragraphText)}</w:t></w:r></w:p>`
|
|
42
|
+
).join('\n ')}
|
|
21
43
|
<w:sectPr/>
|
|
22
44
|
</w:body>
|
|
23
45
|
</w:document>`;
|
|
24
46
|
|
|
25
|
-
const cases =
|
|
26
|
-
{
|
|
27
|
-
name: 'simple-redline',
|
|
28
|
-
original: 'The old sentence.',
|
|
29
|
-
modified: 'The new sentence.'
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
name: 'paragraph-insert',
|
|
33
|
-
original: 'one',
|
|
34
|
-
modified: 'one\ntwo'
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
name: 'format-only',
|
|
38
|
-
original: 'Make word bold',
|
|
39
|
-
modified: 'Make **word** bold'
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
name: 'whitespace-heavy',
|
|
43
|
-
original: 'Alpha beta gamma delta.',
|
|
44
|
-
modified: 'Alpha beta REPLACED delta.'
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
name: 'unicode-replace',
|
|
48
|
-
original: 'Term 条款 applies to café.',
|
|
49
|
-
modified: 'Term 合同 applies to café 🚀.'
|
|
50
|
-
}
|
|
51
|
-
];
|
|
47
|
+
const cases = WORD_TASK_CASES;
|
|
52
48
|
|
|
53
49
|
let failures = 0;
|
|
54
50
|
|
|
55
51
|
for (const testCase of cases) {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
52
|
+
const sourceDocumentXml = testCase.sourceDocumentXml || baseDocument(testCase.sourceText || testCase.original);
|
|
53
|
+
const operationOptions = { generateRedlines: true, ...(testCase.operationOptions || {}) };
|
|
54
|
+
const result = Array.isArray(testCase.batchOperations)
|
|
55
|
+
? await applyOperationsToDocumentXml(
|
|
56
|
+
sourceDocumentXml,
|
|
57
|
+
testCase.batchOperations,
|
|
58
|
+
'Validation',
|
|
59
|
+
null,
|
|
60
|
+
operationOptions
|
|
61
|
+
)
|
|
62
|
+
: await applyOperationToDocumentXml(
|
|
63
|
+
sourceDocumentXml,
|
|
64
|
+
testCase.operation || { type: 'redline', target: testCase.original, modified: testCase.modified },
|
|
65
|
+
'Validation',
|
|
66
|
+
null,
|
|
67
|
+
operationOptions
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (testCase.expectAtomicRollback) {
|
|
71
|
+
if (
|
|
72
|
+
result?.hasChanges ||
|
|
73
|
+
result?.rolledBack !== true ||
|
|
74
|
+
result?.error?.code !== 'BATCH_OPERATION_FAILED' ||
|
|
75
|
+
result?.documentXml !== sourceDocumentXml
|
|
76
|
+
) {
|
|
77
|
+
console.error(`FAIL ${testCase.name}: expected an atomic batch rollback`);
|
|
78
|
+
failures++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
} else if ((!result?.hasChanges && !testCase.expectNoOp) || result?.status === 'error') {
|
|
65
82
|
console.error(`FAIL ${testCase.name}: redline did not apply (status=${result?.status}, error=${result?.error?.message})`);
|
|
66
83
|
failures++;
|
|
67
84
|
continue;
|
|
68
85
|
}
|
|
86
|
+
if (testCase.expectNoOp && (result?.hasChanges || result?.status !== 'no-op' || result?.documentXml !== sourceDocumentXml)) {
|
|
87
|
+
console.error(`FAIL ${testCase.name}: expected a byte-identical no-op preserving prior revisions`);
|
|
88
|
+
failures++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Number.isInteger(testCase.maxRevisionId)) {
|
|
93
|
+
const resultDoc = new DOMParser().parseFromString(result.documentXml, 'application/xml');
|
|
94
|
+
const revisionNames = ['ins', 'del', 'moveFrom', 'moveTo', 'rPrChange', 'pPrChange', 'cellIns', 'cellDel'];
|
|
95
|
+
const revisionIds = revisionNames
|
|
96
|
+
.flatMap(name => Array.from(resultDoc.getElementsByTagNameNS(NS_W, name)))
|
|
97
|
+
.map(node => Number.parseInt(node.getAttribute('w:id') || node.getAttribute('id') || '', 10))
|
|
98
|
+
.filter(Number.isFinite);
|
|
99
|
+
if (revisionIds.length === 0 || revisionIds.some(id => id > testCase.maxRevisionId)) {
|
|
100
|
+
console.error(`FAIL ${testCase.name}: revision IDs exceeded ${testCase.maxRevisionId}: ${revisionIds.join(', ')}`);
|
|
101
|
+
failures++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (testCase.requiredElements) {
|
|
107
|
+
const resultDoc = new DOMParser().parseFromString(result.documentXml, 'application/xml');
|
|
108
|
+
let missingRequiredElement = false;
|
|
109
|
+
for (const [localName, minimumCount] of Object.entries(testCase.requiredElements)) {
|
|
110
|
+
const actualCount = resultDoc.getElementsByTagNameNS(NS_W, localName).length;
|
|
111
|
+
if (actualCount < minimumCount) {
|
|
112
|
+
console.error(`FAIL ${testCase.name}: expected at least ${minimumCount} w:${localName} element(s), found ${actualCount}`);
|
|
113
|
+
failures++;
|
|
114
|
+
missingRequiredElement = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (missingRequiredElement) continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (testCase.requiredNumberingFormats) {
|
|
121
|
+
const numberingXml = testCase.packageParts?.numberingXml || result.numberingXml || '';
|
|
122
|
+
for (const format of testCase.requiredNumberingFormats) {
|
|
123
|
+
if (!numberingXml.includes(`<w:numFmt w:val="${format}"`)) {
|
|
124
|
+
console.error(`FAIL ${testCase.name}: required numbering format ${format} is missing`);
|
|
125
|
+
failures++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (testCase.requiredElementParents || testCase.requiredElementText) {
|
|
132
|
+
const resultDoc = new DOMParser().parseFromString(result.documentXml, 'application/xml');
|
|
133
|
+
let structuralRequirementFailed = false;
|
|
134
|
+
|
|
135
|
+
for (const [localName, parentLocalName] of Object.entries(testCase.requiredElementParents || {})) {
|
|
136
|
+
const nodes = Array.from(resultDoc.getElementsByTagNameNS(NS_W, localName));
|
|
137
|
+
const invalidNodes = nodes.filter(node => node.parentNode?.namespaceURI !== NS_W || node.parentNode?.localName !== parentLocalName);
|
|
138
|
+
if (invalidNodes.length > 0) {
|
|
139
|
+
console.error(`FAIL ${testCase.name}: ${invalidNodes.length} w:${localName} element(s) were not direct children of w:${parentLocalName}`);
|
|
140
|
+
failures++;
|
|
141
|
+
structuralRequirementFailed = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const [localName, expectedTexts] of Object.entries(testCase.requiredElementText || {})) {
|
|
146
|
+
const actualTexts = Array.from(resultDoc.getElementsByTagNameNS(NS_W, localName), node => node.textContent || '');
|
|
147
|
+
if (JSON.stringify(actualTexts) !== JSON.stringify(expectedTexts)) {
|
|
148
|
+
console.error(`FAIL ${testCase.name}: w:${localName} text mismatch; expected ${JSON.stringify(expectedTexts)}, found ${JSON.stringify(actualTexts)}`);
|
|
149
|
+
failures++;
|
|
150
|
+
structuralRequirementFailed = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (structuralRequirementFailed) continue;
|
|
155
|
+
}
|
|
69
156
|
|
|
70
157
|
const validation = validateRedlineOoxml(result.documentXml);
|
|
71
158
|
const validationErrors = validation.issues.filter(issue => issue.severity === 'error');
|
|
@@ -80,22 +167,79 @@ for (const testCase of cases) {
|
|
|
80
167
|
writeFileSync(join(outputDir, `${testCase.name}.numbering.xml`), result.numberingXml, 'utf8');
|
|
81
168
|
}
|
|
82
169
|
|
|
83
|
-
const
|
|
170
|
+
const packageParts = {
|
|
171
|
+
numberingXml: result.numberingXml || null,
|
|
172
|
+
...(testCase.packageParts || {})
|
|
173
|
+
};
|
|
174
|
+
let packageEntries;
|
|
175
|
+
let docx;
|
|
176
|
+
try {
|
|
177
|
+
packageEntries = buildMinimalDocxEntries(result.documentXml, packageParts);
|
|
178
|
+
docx = buildMinimalDocx(result.documentXml, packageParts);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error(`FAIL ${testCase.name}: package validation failed: ${error.message}`);
|
|
181
|
+
failures++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const unpacked = unzipEntries(docx);
|
|
186
|
+
const untouchedPartSha256 = {};
|
|
187
|
+
for (const entry of packageEntries.filter(item => /^word\/(?:comments|footnotes|endnotes|header[0-9]+|footer[0-9]+)\.xml$/.test(item.name))) {
|
|
188
|
+
const expectedBytes = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, 'utf8');
|
|
189
|
+
const actualBytes = unpacked.get(entry.name);
|
|
190
|
+
if (!actualBytes?.equals(expectedBytes)) {
|
|
191
|
+
console.error(`FAIL ${testCase.name}: packaged ${entry.name} was not byte-identical to the configured source part`);
|
|
192
|
+
failures++;
|
|
193
|
+
docx = null;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
untouchedPartSha256[entry.name] = createHash('sha256').update(actualBytes).digest('hex');
|
|
197
|
+
}
|
|
198
|
+
if (!docx) continue;
|
|
84
199
|
writeFileSync(join(outputDir, `${testCase.name}.docx`), docx);
|
|
85
200
|
|
|
201
|
+
const sourceDocx = buildMinimalDocx(sourceDocumentXml, testCase.packageParts || {});
|
|
202
|
+
const acceptedXml = acceptTrackedChangesInOoxml(result.documentXml, { allAuthors: true }).oxml;
|
|
203
|
+
const rejectedXml = rejectTrackedChangesInOoxml(result.documentXml, { allAuthors: true }).oxml;
|
|
204
|
+
const acceptedDocx = buildMinimalDocx(acceptedXml, packageParts);
|
|
205
|
+
const rejectedDocx = buildMinimalDocx(rejectedXml, packageParts);
|
|
206
|
+
writeFileSync(join(outputDir, `${testCase.name}.source.docx`), sourceDocx);
|
|
207
|
+
writeFileSync(join(outputDir, `${testCase.name}.accepted.docx`), acceptedDocx);
|
|
208
|
+
writeFileSync(join(outputDir, `${testCase.name}.rejected.docx`), rejectedDocx);
|
|
209
|
+
|
|
86
210
|
// Expected text is derived from edit *intent*, not from this library's
|
|
87
211
|
// accept/reject transforms, so external consumers (Word COM, LibreOffice)
|
|
88
212
|
// act as independent oracles.
|
|
89
213
|
const expected = {
|
|
90
214
|
name: testCase.name,
|
|
91
|
-
|
|
92
|
-
|
|
215
|
+
category: testCase.category,
|
|
216
|
+
task: testCase.task,
|
|
217
|
+
coverageMetadata: testCase.coverageMetadata,
|
|
218
|
+
textFidelity: testCase.textFidelity || 'exact',
|
|
219
|
+
assertionMode: testCase.assertionMode || 'exact',
|
|
220
|
+
expectedAcceptedText: testCase.expectedAcceptedText ?? preprocessMarkdown(testCase.modified).cleanText,
|
|
221
|
+
expectedRejectedText: testCase.expectedRejectedText ?? testCase.original,
|
|
222
|
+
...(testCase.assertionMode === 'contains' ? {
|
|
223
|
+
expectedAcceptedContains: testCase.expectedAcceptedContains || [],
|
|
224
|
+
expectedAcceptedAbsent: testCase.expectedAcceptedAbsent || [],
|
|
225
|
+
expectedRejectedContains: testCase.expectedRejectedContains || [],
|
|
226
|
+
expectedRejectedAbsent: testCase.expectedRejectedAbsent || []
|
|
227
|
+
} : {}),
|
|
228
|
+
sourceText: testCase.sourceText || testCase.original,
|
|
229
|
+
modifiedText: preprocessMarkdown(testCase.modified).cleanText,
|
|
230
|
+
requiredNumberingFormats: testCase.requiredNumberingFormats || [],
|
|
231
|
+
untouchedPartSha256
|
|
93
232
|
};
|
|
94
233
|
writeFileSync(join(outputDir, `${testCase.name}.expected.json`), `${JSON.stringify(expected, null, 2)}\n`, 'utf8');
|
|
95
234
|
|
|
96
|
-
console.log(`wrote ${testCase.name}:
|
|
235
|
+
console.log(`wrote ${testCase.name}: source, tracked, accepted, rejected, XML, and expectations`);
|
|
97
236
|
}
|
|
98
237
|
|
|
238
|
+
writeFileSync(join(outputDir, 'suite.json'), `${JSON.stringify({
|
|
239
|
+
name: 'English legal and administrative Word differential suite',
|
|
240
|
+
cases: cases.map(testCase => testCase.name)
|
|
241
|
+
}, null, 2)}\n`, 'utf8');
|
|
242
|
+
|
|
99
243
|
writeFileSync(join(outputDir, 'README.md'), `# Validation Fixtures
|
|
100
244
|
|
|
101
245
|
Generated by \`node scripts/export-validation-fixtures.mjs\`.
|
|
@@ -106,6 +250,8 @@ Each case produces:
|
|
|
106
250
|
XSD validation and manual inspection).
|
|
107
251
|
- \`<name>.docx\` — a minimal package assembled by release tooling only (the
|
|
108
252
|
published library still has no zip dependency).
|
|
253
|
+
- \`<name>.source.docx\`, \`<name>.accepted.docx\`, and
|
|
254
|
+
\`<name>.rejected.docx\` — comparison states for the local HTML dashboard.
|
|
109
255
|
- \`<name>.expected.json\` — the accept-all / reject-all plain-text outcomes
|
|
110
256
|
derived from edit intent, used by external-consumer differential checks.
|
|
111
257
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const repoRoot = dirname(scriptDir);
|
|
8
|
+
const manifestPath = join(repoRoot, 'tests', 'corpus', 'superdoc-english-legal-administrative.json');
|
|
9
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
10
|
+
const requestedIds = [];
|
|
11
|
+
|
|
12
|
+
for (let index = 2; index < process.argv.length; index++) {
|
|
13
|
+
if (process.argv[index] === '--id' && process.argv[index + 1]) {
|
|
14
|
+
requestedIds.push(process.argv[++index]);
|
|
15
|
+
} else {
|
|
16
|
+
throw new Error(`Unknown argument: ${process.argv[index]}. Use --id <pinned-sha256>.`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (requestedIds.length === 0) {
|
|
21
|
+
console.error('No document selected. Fetch one or more pinned references with:');
|
|
22
|
+
console.error(' npm run corpus:fetch:superdoc -- --id <sha256> [--id <sha256>]');
|
|
23
|
+
console.error('Pinned ids:');
|
|
24
|
+
for (const document of manifest.documents) console.error(` ${document.id} ${document.type} ${document.filename}`);
|
|
25
|
+
process.exit(2);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const byId = new Map(manifest.documents.map(document => [document.id, document]));
|
|
29
|
+
const outputDir = join(repoRoot, 'tmp', 'superdoc-corpus');
|
|
30
|
+
mkdirSync(outputDir, { recursive: true });
|
|
31
|
+
|
|
32
|
+
for (const id of requestedIds) {
|
|
33
|
+
const document = byId.get(id);
|
|
34
|
+
if (!document) throw new Error(`Document ${id} is not in the pinned SuperDoc reference manifest.`);
|
|
35
|
+
|
|
36
|
+
const outputPath = join(outputDir, `${id}.docx`);
|
|
37
|
+
let bytes = existsSync(outputPath) ? readFileSync(outputPath) : null;
|
|
38
|
+
let fetched = false;
|
|
39
|
+
const expectedDigest = document.downloadSha256 || id;
|
|
40
|
+
let digest = bytes ? createHash('sha256').update(bytes).digest('hex') : null;
|
|
41
|
+
if (digest !== expectedDigest) {
|
|
42
|
+
const response = await fetch(document.downloadUrl);
|
|
43
|
+
if (!response.ok) throw new Error(`Failed to fetch ${id}: HTTP ${response.status}`);
|
|
44
|
+
bytes = Buffer.from(await response.arrayBuffer());
|
|
45
|
+
digest = createHash('sha256').update(bytes).digest('hex');
|
|
46
|
+
fetched = true;
|
|
47
|
+
}
|
|
48
|
+
if (digest !== expectedDigest) {
|
|
49
|
+
throw new Error(`Hash mismatch for ${id}: expected ${expectedDigest}, received ${digest}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
writeFileSync(outputPath, bytes);
|
|
53
|
+
writeFileSync(join(outputDir, `${id}.source.json`), `${JSON.stringify({
|
|
54
|
+
...document,
|
|
55
|
+
dataset: manifest.name,
|
|
56
|
+
attribution: manifest.attribution,
|
|
57
|
+
datasetLicense: manifest.datasetLicense,
|
|
58
|
+
licenseUrl: manifest.licenseUrl
|
|
59
|
+
}, null, 2)}\n`, 'utf8');
|
|
60
|
+
console.log(`${fetched ? 'Fetched' : 'Verified cached'} ${id}.docx (${bytes.length} bytes)`);
|
|
61
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, join, resolve } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
COVERAGE_ORACLES,
|
|
7
|
+
COVERAGE_STRUCTURES,
|
|
8
|
+
COVERAGE_TASKS
|
|
9
|
+
} from './lib/word-coverage-metadata.mjs';
|
|
10
|
+
import { loadCoverageCatalogue } from './lib/word-coverage-catalogue.mjs';
|
|
11
|
+
import { selectVisualReviewCases } from './prepare-word-visual-review.mjs';
|
|
12
|
+
|
|
13
|
+
function parseArgs(argv) {
|
|
14
|
+
const outputIndex = argv.indexOf('--output');
|
|
15
|
+
if (outputIndex >= 0 && !argv[outputIndex + 1]) throw new Error('--output requires a path');
|
|
16
|
+
const fixturesIndex = argv.indexOf('--fixtures-dir');
|
|
17
|
+
if (fixturesIndex >= 0 && !argv[fixturesIndex + 1]) throw new Error('--fixtures-dir requires a path');
|
|
18
|
+
const corpusIndex = argv.indexOf('--corpus-fixtures-dir');
|
|
19
|
+
if (corpusIndex >= 0 && !argv[corpusIndex + 1]) throw new Error('--corpus-fixtures-dir requires a path');
|
|
20
|
+
return {
|
|
21
|
+
outputPath: outputIndex >= 0
|
|
22
|
+
? resolve(process.cwd(), argv[outputIndex + 1])
|
|
23
|
+
: join(process.cwd(), 'docs', 'test-comparison-dashboard.html'),
|
|
24
|
+
fixturesDir: fixturesIndex >= 0
|
|
25
|
+
? resolve(process.cwd(), argv[fixturesIndex + 1])
|
|
26
|
+
: join(process.cwd(), 'tmp', 'dashboard-docx'),
|
|
27
|
+
corpusFixturesDir: corpusIndex >= 0
|
|
28
|
+
? resolve(process.cwd(), argv[corpusIndex + 1])
|
|
29
|
+
: null
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildDashboardData(fixturesDir = null, corpusFixturesDir = null) {
|
|
34
|
+
const { cases, priorities } = loadCoverageCatalogue();
|
|
35
|
+
const corpusSuitePath = corpusFixturesDir ? join(corpusFixturesDir, 'suite.json') : null;
|
|
36
|
+
const corpusSuite = corpusSuitePath && existsSync(corpusSuitePath)
|
|
37
|
+
? JSON.parse(readFileSync(corpusSuitePath, 'utf8'))
|
|
38
|
+
: { cases: [] };
|
|
39
|
+
const corpusNames = new Map(corpusSuite.cases.map(item => [item.scenarioKey || item.sourceId, item.name]));
|
|
40
|
+
const visualEligible = new Set(
|
|
41
|
+
selectVisualReviewCases().map(testCase => `synthetic:${testCase.name}`)
|
|
42
|
+
);
|
|
43
|
+
return {
|
|
44
|
+
generatedAt: new Date().toISOString(),
|
|
45
|
+
tasks: COVERAGE_TASKS,
|
|
46
|
+
structures: COVERAGE_STRUCTURES,
|
|
47
|
+
oracles: COVERAGE_ORACLES,
|
|
48
|
+
priorities,
|
|
49
|
+
cases: cases.map(item => {
|
|
50
|
+
const syntheticName = item.identity.startsWith('synthetic:')
|
|
51
|
+
? item.identity.slice('synthetic:'.length)
|
|
52
|
+
: null;
|
|
53
|
+
const sourceId = item.identity.startsWith('superdoc:')
|
|
54
|
+
? item.identity.slice('superdoc:'.length)
|
|
55
|
+
: null;
|
|
56
|
+
const name = syntheticName || corpusNames.get(sourceId) || null;
|
|
57
|
+
const variantDir = syntheticName ? fixturesDir : corpusFixturesDir;
|
|
58
|
+
const readBase64 = suffix => {
|
|
59
|
+
const path = name && variantDir ? join(variantDir, `${name}${suffix}.docx`) : null;
|
|
60
|
+
return path && existsSync(path) ? readFileSync(path).toString('base64') : null;
|
|
61
|
+
};
|
|
62
|
+
const expectedPath = name && variantDir ? join(variantDir, `${name}.expected.json`) : null;
|
|
63
|
+
const expected = expectedPath && existsSync(expectedPath)
|
|
64
|
+
? JSON.parse(readFileSync(expectedPath, 'utf8'))
|
|
65
|
+
: null;
|
|
66
|
+
const documentXmlPath = name && variantDir ? join(variantDir, `${name}.document.xml`) : null;
|
|
67
|
+
const documentXml = documentXmlPath && existsSync(documentXmlPath)
|
|
68
|
+
? readFileSync(documentXmlPath, 'utf8')
|
|
69
|
+
: '';
|
|
70
|
+
return {
|
|
71
|
+
identity: item.identity,
|
|
72
|
+
displayName: sourceId
|
|
73
|
+
? `${name} — ${expected?.originalTarget || item.detail}`
|
|
74
|
+
: name,
|
|
75
|
+
lane: item.lane,
|
|
76
|
+
category: item.category,
|
|
77
|
+
task: item.metadata.task,
|
|
78
|
+
structures: item.metadata.structures,
|
|
79
|
+
oracles: item.metadata.oracles,
|
|
80
|
+
manualReview: item.metadata.manualReview,
|
|
81
|
+
visualEligible: visualEligible.has(item.identity) || Boolean(sourceId),
|
|
82
|
+
docxVariants: name ? {
|
|
83
|
+
source: readBase64('.source'),
|
|
84
|
+
tracked: readBase64(''),
|
|
85
|
+
accepted: readBase64('.accepted'),
|
|
86
|
+
rejected: readBase64('.rejected')
|
|
87
|
+
} : null,
|
|
88
|
+
expectations: expected ? {
|
|
89
|
+
source: expected.sourceText || expected.originalTarget,
|
|
90
|
+
accepted: expected.expectedAcceptedText || expected.modifiedTarget,
|
|
91
|
+
rejected: expected.expectedRejectedText || expected.originalTarget
|
|
92
|
+
} : null,
|
|
93
|
+
revisions: {
|
|
94
|
+
insertions: (documentXml.match(/<w:ins\b/g) || []).length,
|
|
95
|
+
deletions: (documentXml.match(/<w:del\b/g) || []).length,
|
|
96
|
+
formatting: (documentXml.match(/<w:(?:rPrChange|pPrChange)\b/g) || []).length
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
})
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function escapeJsonForHtml(value) {
|
|
104
|
+
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function renderDashboardHtml(data, libraries = {}) {
|
|
108
|
+
const encoded = escapeJsonForHtml(data);
|
|
109
|
+
const jszipSource = String(libraries.jszipSource || '').replace(/<\/script/gi, '<\\/script');
|
|
110
|
+
const docxPreviewSource = String(libraries.docxPreviewSource || '').replace(/<\/script/gi, '<\\/script');
|
|
111
|
+
return `<!doctype html>
|
|
112
|
+
<html lang="en">
|
|
113
|
+
<head>
|
|
114
|
+
<meta charset="utf-8">
|
|
115
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
116
|
+
<title>DOCX Redline Test Comparison Dashboard</title>
|
|
117
|
+
<style>
|
|
118
|
+
:root{color-scheme:light dark;--bg:#f4f7fb;--surface:#fff;--surface2:#eef3f9;--text:#172033;--muted:#68758a;--line:#d9e1ec;--blue:#2563eb;--blue2:#dbeafe;--green:#138a5b;--green2:#d9f5e9;--amber:#b66a00;--amber2:#fff1cf;--red:#be3b45;--red2:#ffe2e5;--purple:#7656c8;--purple2:#eee8ff;--shadow:0 12px 30px rgba(38,55,80,.09)}
|
|
119
|
+
@media(prefers-color-scheme:dark){:root{--bg:#10141d;--surface:#181e29;--surface2:#222a37;--text:#edf2fb;--muted:#a8b3c5;--line:#323c4d;--blue:#78a7ff;--blue2:#223d68;--green:#58d2a0;--green2:#183f33;--amber:#ffc66d;--amber2:#533d1e;--red:#ff929a;--red2:#55282d;--purple:#b9a1ff;--purple2:#382f59;--shadow:none}}
|
|
120
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}button,select,input{font:inherit;color:inherit}.shell{max-width:1480px;margin:auto;padding:28px;transition:max-width .18s ease}.top{display:flex;justify-content:space-between;gap:24px;align-items:end;margin-bottom:22px}.eyebrow{text-transform:uppercase;letter-spacing:.12em;color:var(--blue);font-weight:700;font-size:11px}h1{margin:4px 0 2px;font-size:clamp(25px,4vw,40px);line-height:1.12}h2{font-size:18px;margin:0 0 14px}.stamp{color:var(--muted);text-align:right}.controls{display:flex;flex-wrap:wrap;gap:10px;margin:0 0 18px}.controls label{display:flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 11px}.controls select,.controls input{border:0;background:transparent;outline:none}.stats{display:grid;grid-template-columns:repeat(6,minmax(120px,1fr));gap:12px;margin-bottom:18px}.stat,.panel{background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.stat{padding:16px}.stat strong{display:block;font-size:25px;line-height:1.1}.stat span{color:var(--muted);font-size:12px}.layout{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(300px,.7fr);gap:18px}.sidebar-hidden .shell{max-width:none}.sidebar-hidden .layout{grid-template-columns:minmax(0,1fr)}.sidebar-hidden #dashboard-sidebar{display:none}.panel{padding:18px;margin-bottom:18px}.panel-head{display:flex;align-items:start;justify-content:space-between;gap:12px}.sub{color:var(--muted);font-size:12px;margin-top:-8px;margin-bottom:14px}.matrix-wrap{overflow:auto}.matrix{display:grid;min-width:1040px;gap:4px;align-items:stretch}.matrix .label{font-size:11px;color:var(--muted);padding:8px 5px;display:flex;align-items:end}.matrix .row-label{justify-content:flex-end;text-align:right;align-items:center}.cell{border:0;border-radius:7px;min-height:42px;padding:4px;cursor:pointer;background:var(--surface2);display:flex;align-items:center;justify-content:center;font-weight:700}.cell:hover,.cell:focus{outline:2px solid var(--blue);outline-offset:1px}.cell.tested{background:var(--green2);color:var(--green)}.cell.planned{background:var(--amber2);color:var(--amber)}.cell.missing{background:var(--red2);color:var(--red)}.cell.empty{color:var(--muted);font-weight:400}.cell.selected{box-shadow:inset 0 0 0 3px currentColor}.legend{display:flex;flex-wrap:wrap;gap:15px;margin-top:12px;color:var(--muted);font-size:12px}.legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px}.bars{display:grid;gap:9px}.bar-row{display:grid;grid-template-columns:130px minmax(0,1fr) 34px;gap:8px;align-items:center}.track{height:12px;background:var(--surface2);border-radius:99px;overflow:hidden;display:flex}.seg-syn{background:var(--blue)}.seg-real{background:var(--purple)}.bar-value{text-align:right;font-variant-numeric:tabular-nums}.detail{min-height:180px}.detail h3{font-size:16px;margin:0 0 8px}.detail ul{margin:8px 0 0;padding-left:18px;max-height:310px;overflow:auto}.detail code{font-size:11px;overflow-wrap:anywhere}.badge{display:inline-block;border-radius:99px;padding:3px 8px;margin:3px 4px 3px 0;background:var(--surface2);font-size:11px}.badge.syn{background:var(--blue2);color:var(--blue)}.badge.real{background:var(--purple2);color:var(--purple)}.gap{padding:11px 0;border-top:1px solid var(--line)}.gap:first-of-type{border-top:0}.gap strong{display:block}.gap p{margin:4px 0;color:var(--muted);font-size:12px}.oracle{display:grid;grid-template-columns:1fr 44px;gap:8px;align-items:center;margin:9px 0}.oracle .track{height:8px}.search-results{margin-top:8px}.case-row{display:grid;grid-template-columns:minmax(220px,1.4fr) 100px 130px;gap:12px;padding:9px 0;border-top:1px solid var(--line);align-items:center}.case-row:first-child{border-top:0}.case-row code{overflow-wrap:anywhere;font-size:11px}.empty-state{color:var(--muted);padding:18px 0}.docx-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px}.docx-toolbar select{min-width:300px;max-width:100%;border:1px solid var(--line);background:var(--surface2);border-radius:8px;padding:7px 9px}.docx-status{color:var(--muted);font-size:12px}.docx-compare{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.docx-pane{min-width:0}.docx-pane h3{font-size:13px;margin:0 0 7px}.docx-view{background:var(--surface2);border:1px solid var(--line);border-radius:10px;min-height:330px;max-height:620px;overflow:auto}.docx-view .docx-wrapper{background:var(--surface2)!important;padding:12px!important}.docx-view .docx-wrapper>section.docx{background:#fff!important;color:#111!important;width:100%!important;min-height:380px!important;padding:44px!important;margin:0!important;box-shadow:none!important}.docx-view ins{background:#dcfce7;color:#166534;text-decoration:none}.docx-view del{background:#fee2e2;color:#991b1b}.screen-reader{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:980px){.stats{grid-template-columns:repeat(3,1fr)}.layout{grid-template-columns:1fr}.stamp{text-align:left}.top{align-items:start;flex-direction:column}}@media(max-width:760px){.docx-compare{grid-template-columns:1fr}}@media(max-width:620px){.shell{padding:16px}.stats{grid-template-columns:repeat(2,1fr)}.bar-row{grid-template-columns:100px minmax(0,1fr) 30px}.case-row{grid-template-columns:1fr}.controls label{width:100%;justify-content:space-between}.docx-toolbar select{min-width:0;width:100%}.docx-view .docx-wrapper>section.docx{padding:24px!important}}
|
|
121
|
+
.action{border:1px solid var(--line);background:var(--surface2);border-radius:8px;padding:7px 10px;cursor:pointer}.action:hover,.action:focus{border-color:var(--blue)}.action.primary{background:var(--blue);border-color:var(--blue);color:#fff}.preset-row{display:flex;gap:7px;flex-wrap:wrap;margin:0 0 14px}.docx-meta{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 14px}.pane-head{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:7px}.pane-head h3{margin:0}.pane-controls{display:flex;gap:7px}.pane-controls select{border:1px solid var(--line);background:var(--surface2);border-radius:7px;padding:5px}.expectations{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:14px}.expectation{background:var(--surface2);border-radius:9px;padding:11px;white-space:pre-wrap;overflow-wrap:anywhere}.expectation strong{display:block;margin-bottom:5px}.sync-control{display:inline-flex;align-items:center;gap:5px}.case-row{grid-template-columns:minmax(220px,1.4fr) 100px 120px auto}@media(max-width:760px){.expectations{grid-template-columns:1fr}}@media(max-width:620px){.case-row{grid-template-columns:1fr}.pane-head{align-items:flex-start;flex-direction:column}.pane-controls{width:100%}.pane-controls select{flex:1}}
|
|
122
|
+
</style>
|
|
123
|
+
</head>
|
|
124
|
+
<body>
|
|
125
|
+
<main class="shell">
|
|
126
|
+
<header class="top"><div><div class="eyebrow">Reliability coverage</div><h1>What is actually being tested?</h1><div class="sub">Task × structure × oracle comparison</div></div><div class="stamp" id="stamp"></div></header>
|
|
127
|
+
<div class="controls" aria-label="Dashboard filters">
|
|
128
|
+
<label>Lane <select id="lane"><option value="all">All cases</option><option value="synthetic">Synthetic Word</option><option value="superdoc">Real documents</option></select></label>
|
|
129
|
+
<label>Category <select id="category"><option value="all">All categories</option><option value="legal">Legal</option><option value="administrative">Administrative</option></select></label>
|
|
130
|
+
<label>Find case <input id="search" type="search" placeholder="Name or structure"></label>
|
|
131
|
+
<button class="action" id="sidebar-toggle" type="button" aria-controls="dashboard-sidebar" aria-expanded="true">Hide sidebar</button>
|
|
132
|
+
</div>
|
|
133
|
+
<section class="stats" id="stats" aria-label="Coverage summary"></section>
|
|
134
|
+
<div class="layout">
|
|
135
|
+
<div>
|
|
136
|
+
<section class="panel"><div class="panel-head"><div><h2>Task × structure matrix</h2><div class="sub">Select a cell to inspect its cases. Planned cells are high-priority gaps with recorded dependencies.</div></div></div><div class="matrix-wrap"><div class="matrix" id="matrix"></div></div><div class="legend"><span><i style="background:var(--green2)"></i>Tested</span><span><i style="background:var(--amber2)"></i>Planned high priority</span><span><i style="background:var(--red2)"></i>Unplanned high priority</span><span><i style="background:var(--surface2)"></i>Not prioritized</span></div></section>
|
|
137
|
+
<section class="panel" id="docx-comparison"><h2>DOCX comparison workbench</h2><div class="sub">Compare source, tracked, accepted, and rejected states from synthetic fixtures and reviewed real legal/administrative documents. Tracked markup uses docxjs experimental revision rendering.</div><div class="docx-toolbar"><label for="docx-case">Document</label><select id="docx-case"></select><label class="sync-control"><input id="sync-scroll" type="checkbox" checked> Sync scroll</label><span class="docx-status" id="docx-status"></span></div><div class="preset-row"><button class="action primary" type="button" data-preset="source,tracked">Source ↔ tracked</button><button class="action" type="button" data-preset="source,accepted">Source ↔ accepted</button><button class="action" type="button" data-preset="accepted,rejected">Accepted ↔ rejected</button></div><div class="docx-meta" id="docx-meta"></div><div class="docx-compare"><div class="docx-pane"><div class="pane-head"><h3>Left document</h3><div class="pane-controls"><select id="left-view" aria-label="Left document state"></select><button class="action" id="left-download" type="button">Download</button></div></div><div class="docx-view" id="docx-left"></div></div><div class="docx-pane"><div class="pane-head"><h3>Right document</h3><div class="pane-controls"><select id="right-view" aria-label="Right document state"></select><button class="action" id="right-download" type="button">Download</button></div></div><div class="docx-view" id="docx-right"></div></div></div><div class="expectations"><div class="expectation"><strong>Expected rejected/source text</strong><span id="expected-before"></span></div><div class="expectation"><strong>Expected accepted text</strong><span id="expected-after"></span></div></div></section>
|
|
138
|
+
<section class="panel"><h2>Coverage by structure</h2><div class="sub">Unique case count, split between synthetic packages and reviewed real documents.</div><div class="bars" id="structure-bars"></div></section>
|
|
139
|
+
<section class="panel"><h2>Matching cases</h2><div class="sub" id="case-caption"></div><div class="search-results" id="case-list"></div></section>
|
|
140
|
+
</div>
|
|
141
|
+
<aside id="dashboard-sidebar">
|
|
142
|
+
<section class="panel detail" id="detail"><h2>Cell detail</h2><div class="empty-state">Select a matrix cell to see exact scenarios and gap rationale.</div></section>
|
|
143
|
+
<section class="panel"><h2>Oracle comparison</h2><div class="sub">How many filtered cases are checked by each independent oracle.</div><div id="oracles"></div></section>
|
|
144
|
+
<section class="panel"><h2>Planned high-priority gaps</h2><div id="gaps"></div></section>
|
|
145
|
+
</aside>
|
|
146
|
+
</div>
|
|
147
|
+
</main>
|
|
148
|
+
<script id="dashboard-data" type="application/json">${encoded}</script>
|
|
149
|
+
<script>${jszipSource}</script>
|
|
150
|
+
<script>${docxPreviewSource}</script>
|
|
151
|
+
<script>
|
|
152
|
+
const DATA=JSON.parse(document.getElementById('dashboard-data').textContent);
|
|
153
|
+
const $=id=>document.getElementById(id);let selected=null;
|
|
154
|
+
const label=s=>s.replaceAll('-',' ').replace(/\\b\\w/g,c=>c.toUpperCase());
|
|
155
|
+
const dispositionMap=new Map(DATA.priorities.emptyCellDispositions.map(d=>[d.task+'|'+d.structure,d]));
|
|
156
|
+
const prioritySet=new Set(DATA.priorities.highPriorityCells.map(d=>d.task+'|'+d.structure));
|
|
157
|
+
function filtered(){const lane=$('lane').value,cat=$('category').value,q=$('search').value.trim().toLowerCase();return DATA.cases.filter(c=>(lane==='all'||c.lane===lane)&&(cat==='all'||c.category===cat)&&(!q||c.identity.toLowerCase().includes(q)||c.task.includes(q)||c.structures.some(s=>s.includes(q))||c.oracles.some(o=>o.includes(q))))}
|
|
158
|
+
function countUnique(cases,key){return new Set(cases.flatMap(c=>c[key])).size}
|
|
159
|
+
function renderStats(cases){const high=DATA.priorities.highPriorityCells.length;const coveredHigh=DATA.priorities.highPriorityCells.filter(p=>cases.some(c=>c.task===p.task&&c.structures.includes(p.structure))).length;const values=[['Cases',cases.length],['Synthetic',cases.filter(c=>c.lane==='synthetic').length],['Real documents',cases.filter(c=>c.lane==='superdoc').length],['Structures',countUnique(cases,'structures')],['High-priority cells',coveredHigh+' / '+high],['Visual-render eligible',cases.filter(c=>c.visualEligible).length]];$('stats').innerHTML=values.map(([k,v])=>'<div class="stat"><strong>'+v+'</strong><span>'+k+'</span></div>').join('')}
|
|
160
|
+
function cellCases(cases,t,s){return cases.filter(c=>c.task===t&&c.structures.includes(s))}
|
|
161
|
+
function renderMatrix(cases){const grid=$('matrix');grid.style.gridTemplateColumns='150px repeat('+DATA.structures.length+',minmax(50px,1fr))';let html='<div></div>'+DATA.structures.map(s=>'<div class="label">'+label(s)+'</div>').join('');for(const t of DATA.tasks){html+='<div class="label row-label">'+label(t)+'</div>';for(const s of DATA.structures){const matches=cellCases(cases,t,s),key=t+'|'+s,priority=prioritySet.has(key),plan=dispositionMap.get(key);let cls=matches.length?'tested':priority?(plan?'planned':'missing'):'empty';html+='<button class="cell '+cls+(selected===key?' selected':'')+'" data-task="'+t+'" data-structure="'+s+'" aria-label="'+label(t)+' with '+label(s)+': '+(matches.length?matches.length+' cases':plan?'planned':'not covered')+'">'+(matches.length|| (plan?'P':priority?'!':'·'))+'</button>'}}grid.innerHTML=html;grid.querySelectorAll('button').forEach(b=>b.addEventListener('click',()=>{selected=b.dataset.task+'|'+b.dataset.structure;renderMatrix(cases);renderDetail(cases,b.dataset.task,b.dataset.structure);const preview=cellCases(cases,b.dataset.task,b.dataset.structure).find(c=>c.docxVariants?.tracked);if(preview)selectDocx(preview.identity)}))}
|
|
162
|
+
function renderDetail(cases,t,s){const matches=cellCases(cases,t,s),plan=dispositionMap.get(t+'|'+s);let html='<h2>Cell detail</h2><h3>'+label(t)+' × '+label(s)+'</h3>';if(matches.length){html+='<span class="badge">'+matches.length+' case'+(matches.length===1?'':'s')+'</span><ul>'+matches.map(c=>'<li><code>'+c.identity+'</code> <span class="badge '+(c.lane==='synthetic'?'syn':'real')+'">'+(c.lane==='synthetic'?'synthetic':'real doc')+'</span></li>').join('')+'</ul>'}else if(plan){html+='<span class="badge">Planned</span><p>'+plan.reason+'</p><p><strong>Dependency:</strong> '+plan.dependency+'</p>'}else{html+='<div class="empty-state">No case is declared for this combination'+(prioritySet.has(t+'|'+s)?', and it is a high-priority gap.':'.')+'</div>'}$('detail').innerHTML=html}
|
|
163
|
+
function renderBars(cases){const counts=DATA.structures.map(s=>{const set=cases.filter(c=>c.structures.includes(s));return{s,syn:set.filter(c=>c.lane==='synthetic').length,real:set.filter(c=>c.lane==='superdoc').length,total:set.length}});const max=Math.max(1,...counts.map(x=>x.total));$('structure-bars').innerHTML=counts.map(x=>'<div class="bar-row"><span>'+label(x.s)+'</span><div class="track" aria-label="'+x.total+' cases"><span class="seg-syn" style="width:'+(x.syn/max*100)+'%"></span><span class="seg-real" style="width:'+(x.real/max*100)+'%"></span></div><span class="bar-value">'+x.total+'</span></div>').join('')}
|
|
164
|
+
function renderOracles(cases){const max=Math.max(1,cases.length);$('oracles').innerHTML=DATA.oracles.map(o=>{const n=cases.filter(c=>c.oracles.includes(o)).length;return'<div class="oracle"><div><div>'+label(o)+'</div><div class="track"><span class="seg-syn" style="width:'+(n/max*100)+'%"></span></div></div><strong>'+n+'</strong></div>'}).join('')}
|
|
165
|
+
function renderGaps(){const gaps=DATA.priorities.emptyCellDispositions;$('gaps').innerHTML=gaps.map(g=>'<div class="gap"><strong>'+label(g.task)+' × '+label(g.structure)+'</strong><p>'+g.reason+'</p><span class="badge">'+g.dependency+'</span></div>').join('')}
|
|
166
|
+
function renderCases(cases){$('case-caption').textContent=cases.length+' cases match the current filters.';$('case-list').innerHTML=cases.length?cases.map(c=>'<div class="case-row"><code>'+c.identity+'</code><span class="badge '+(c.lane==='synthetic'?'syn':'real')+'">'+(c.lane==='synthetic'?'synthetic':'real doc')+'</span><span>'+label(c.task)+'</span>'+(c.docxVariants?.tracked?'<button class="action view-case" type="button" data-identity="'+c.identity+'">Compare</button>':'')+'</div>').join(''):'<div class="empty-state">No cases match.</div>';$('case-list').querySelectorAll('.view-case').forEach(button=>button.addEventListener('click',()=>{selectDocx(button.dataset.identity);$('docx-comparison').scrollIntoView({behavior:'smooth',block:'start'})}))}
|
|
167
|
+
const VIEW_LABELS={source:'Source',tracked:'Tracked changes',accepted:'Accepted',rejected:'Rejected'};
|
|
168
|
+
const escapeHtml=value=>String(value??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
169
|
+
function decodeDocx(value){const raw=atob(value),bytes=new Uint8Array(raw.length);for(let i=0;i<raw.length;i++)bytes[i]=raw.charCodeAt(i);return bytes}
|
|
170
|
+
function activeDocx(){return DATA.cases.find(c=>c.identity===$('docx-case').value)}
|
|
171
|
+
function viewOptions(selected){return Object.entries(VIEW_LABELS).map(([value,text])=>'<option value="'+value+'"'+(value===selected?' selected':'')+'>'+text+'</option>').join('')}
|
|
172
|
+
function renderDocxMeta(item){const r=item.revisions;$('docx-meta').innerHTML='<span class="badge '+(item.lane==='superdoc'?'real':'syn')+'">'+(item.lane==='superdoc'?'Reviewed real document':'Synthetic fixture')+'</span><span class="badge">'+label(item.category)+'</span><span class="badge">'+label(item.task)+'</span>'+item.structures.map(s=>'<span class="badge">'+label(s)+'</span>').join('')+'<span class="badge">'+r.insertions+' insertion revision'+(r.insertions===1?'':'s')+'</span><span class="badge">'+r.deletions+' deletion revision'+(r.deletions===1?'':'s')+'</span>'+(r.formatting?'<span class="badge">'+r.formatting+' formatting revision'+(r.formatting===1?'':'s')+'</span>':'');$('expected-before').textContent=item.expectations?.rejected||item.expectations?.source||'';$('expected-after').textContent=item.expectations?.accepted||''}
|
|
173
|
+
async function renderPane(side,item){const state=$(side+'-view').value,target=$('docx-'+side),payload=item.docxVariants?.[state];target.innerHTML='';if(!payload){target.innerHTML='<div class="empty-state">This document state is unavailable.</div>';return}const base={experimental:true,renderHeaders:true,renderFooters:true,renderFootnotes:true,renderEndnotes:true,renderComments:true,ignoreWidth:true,ignoreHeight:true,breakPages:false,useBase64URL:true,renderChanges:state==='tracked'};await window.docx.renderAsync(decodeDocx(payload),target,null,base)}
|
|
174
|
+
async function renderDocxComparison(){const item=activeDocx();if(!item?.docxVariants?.tracked){$('docx-status').textContent='No embedded DOCX is available.';return}if(!window.docx?.renderAsync){$('docx-status').textContent='docxjs failed to load.';return}$('docx-status').textContent='Rendering both documents…';renderDocxMeta(item);try{await Promise.all([renderPane('left',item),renderPane('right',item)]);$('docx-status').textContent='Ready · docx-preview 0.4.0';}catch(error){$('docx-status').textContent='Render error: '+error.message}}
|
|
175
|
+
function setComparison(left,right){$('left-view').value=left;$('right-view').value=right;renderDocxComparison()}
|
|
176
|
+
function selectDocx(identity){if(!$('docx-case').querySelector('option[value="'+identity+'"]'))return;$('docx-case').value=identity;renderDocxComparison()}
|
|
177
|
+
function downloadView(side){const item=activeDocx(),state=$(side+'-view').value,payload=item?.docxVariants?.[state];if(!payload)return;const blob=new Blob([decodeDocx(payload)],{type:'application/vnd.openxmlformats-officedocument.wordprocessingml.document'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=item.identity.replace(/^(synthetic|superdoc):/,'')+'.'+state+'.docx';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
|
178
|
+
function initDocx(){const available=DATA.cases.filter(c=>c.docxVariants?.tracked),options=lane=>available.filter(c=>c.lane===lane).map(c=>'<option value="'+c.identity+'">'+escapeHtml(c.displayName||c.identity)+'</option>').join('');$('docx-case').innerHTML='<optgroup label="Reviewed real documents">'+options('superdoc')+'</optgroup><optgroup label="Synthetic fixtures">'+options('synthetic')+'</optgroup>';$('left-view').innerHTML=viewOptions('source');$('right-view').innerHTML=viewOptions('tracked');const preferred=available.find(c=>c.lane==='superdoc')||available.find(c=>c.identity==='synthetic:administrative-tab-aligned-status')||available[0];if(preferred)$('docx-case').value=preferred.identity;$('docx-case').addEventListener('change',renderDocxComparison);$('left-view').addEventListener('change',renderDocxComparison);$('right-view').addEventListener('change',renderDocxComparison);document.querySelectorAll('[data-preset]').forEach(button=>button.addEventListener('click',()=>setComparison(...button.dataset.preset.split(','))));$('left-download').addEventListener('click',()=>downloadView('left'));$('right-download').addEventListener('click',()=>downloadView('right'));let syncing=false;for(const [from,to] of [[$('docx-left'),$('docx-right')],[$('docx-right'),$('docx-left')]])from.addEventListener('scroll',()=>{if(!$('sync-scroll').checked||syncing)return;syncing=true;const maxFrom=from.scrollHeight-from.clientHeight,maxTo=to.scrollHeight-to.clientHeight;to.scrollTop=maxFrom>0?from.scrollTop/maxFrom*maxTo:0;to.scrollLeft=from.scrollLeft;requestAnimationFrame(()=>{syncing=false})});renderDocxComparison()}
|
|
179
|
+
function render(){const cases=filtered();renderStats(cases);renderMatrix(cases);renderBars(cases);renderOracles(cases);renderCases(cases)}
|
|
180
|
+
function setSidebarHidden(hidden){document.body.classList.toggle('sidebar-hidden',hidden);const toggle=$('sidebar-toggle');toggle.textContent=hidden?'Show sidebar':'Hide sidebar';toggle.setAttribute('aria-expanded',String(!hidden));try{localStorage.setItem('docx-dashboard-sidebar-hidden',hidden?'1':'0')}catch{}}
|
|
181
|
+
function initSidebar(){let hidden=false;try{hidden=localStorage.getItem('docx-dashboard-sidebar-hidden')==='1'}catch{}setSidebarHidden(hidden);$('sidebar-toggle').addEventListener('click',()=>setSidebarHidden(!document.body.classList.contains('sidebar-hidden')))}
|
|
182
|
+
$('lane').addEventListener('change',render);$('category').addEventListener('change',render);$('search').addEventListener('input',render);$('stamp').textContent='Generated '+new Date(DATA.generatedAt).toLocaleString();renderGaps();render();initSidebar();initDocx();
|
|
183
|
+
</script>
|
|
184
|
+
</body>
|
|
185
|
+
</html>\n`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const isCli = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
189
|
+
if (isCli) {
|
|
190
|
+
const { outputPath, fixturesDir, corpusFixturesDir } = parseArgs(process.argv.slice(2));
|
|
191
|
+
const libraries = {
|
|
192
|
+
jszipSource: readFileSync(join(process.cwd(), 'node_modules', 'jszip', 'dist', 'jszip.min.js'), 'utf8'),
|
|
193
|
+
docxPreviewSource: readFileSync(join(process.cwd(), 'node_modules', 'docx-preview', 'dist', 'docx-preview.min.js'), 'utf8')
|
|
194
|
+
};
|
|
195
|
+
const html = renderDashboardHtml(buildDashboardData(fixturesDir, corpusFixturesDir), libraries);
|
|
196
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
197
|
+
writeFileSync(outputPath, html, 'utf8');
|
|
198
|
+
console.log(`Wrote test comparison dashboard: ${outputPath}`);
|
|
199
|
+
}
|