agmission/server/scripts/validate_advanced_report_template.js

132 lines
5.9 KiB
JavaScript

#!/usr/bin/env node
/**
* Validates a Stimulsoft .mrt template (default: reports/app_advanced.mrt)
* against the rules learned building the Advanced Report
* (docs/ADVANCED_REPORTS_IMPLEMENTATION_PLAN.md — template deliverable D4).
*
* Rules:
* 1. Valid JSON, CalculationMode=Interpretation, ReportUnit=Millimeters.
* 2. No empty {} object anywhere — an empty collection makes report.load()
* silently load 0 pages in the client viewer.
* 3. GlobalizationStrings must contain en-US, pt-PT and es-ES, each with a
* non-empty Items — report.component.ts always calls localizeReport().
* 4. Every GlobalizationStrings PropertyName must target an existing component.
* 5. Component names must be unique.
* 6. Every band DataSourceName / DataRelationName / MasterComponent must exist.
* 7. A DataBand nested inside another DataBand must have a StiPanel between
* them, or the engine hoists it above the master's static content.
* 8. Every {table.column} reference in Text/ImageURL expressions must match a
* declared Dictionary column (system refs like PageNumber are ignored).
*
* Usage: node scripts/validate_advanced_report_template.js [path/to.mrt]
* Exits non-zero on any violation.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const file = process.argv[2] || path.join(__dirname, '..', 'reports', 'app_advanced.mrt');
const errors = [];
const warn = [];
let rpt;
try {
rpt = JSON.parse(fs.readFileSync(file, 'utf8'));
} catch (e) {
console.error(`FAIL ${file}: not readable/parseable JSON — ${e.message}`);
process.exit(1);
}
// 1 — report-level settings
if (rpt.CalculationMode !== 'Interpretation')
errors.push(`CalculationMode is "${rpt.CalculationMode}", expected "Interpretation"`);
if (rpt.ReportUnit !== 'Millimeters')
warn.push(`ReportUnit is "${rpt.ReportUnit}", legacy templates use "Millimeters"`);
// 2 — empty {} anywhere
(function findEmpty(node, p) {
if (node === null || typeof node !== 'object') return;
if (!Array.isArray(node) && Object.keys(node).length === 0) {
errors.push(`empty {} at ${p} — breaks report.load() in the viewer`);
return;
}
for (const [k, v] of Object.entries(node)) findEmpty(v, `${p}.${k}`);
})(rpt, '$');
// collect components, names, bands
const comps = [];
(function walk(components, ancestors) {
for (const c of Object.values(components || {})) {
comps.push({ c, ancestors });
if (c.Components) walk(c.Components, ancestors.concat(c));
}
})(Object.fromEntries(Object.values(rpt.Pages || {}).map((p, i) => [i, p])), []);
// 5 — unique names
const seen = new Map();
for (const { c } of comps) {
if (!c.Name) continue;
if (seen.has(c.Name)) errors.push(`duplicate component name "${c.Name}"`);
seen.set(c.Name, c);
}
// 3/4 — globalization
const cultures = Object.values(rpt.GlobalizationStrings || {});
for (const want of ['en-US', 'pt-PT', 'es-ES']) {
const g = cultures.find(x => x.CultureName === want);
if (!g) { errors.push(`GlobalizationStrings missing culture ${want}`); continue; }
const items = Object.values(g.Items || {});
if (!items.length) errors.push(`GlobalizationStrings ${want} has empty Items`);
for (const it of items) {
const target = String(it.PropertyName || '').replace(/\.Text$/, '');
if (!seen.has(target))
errors.push(`GlobalizationStrings ${want}: "${it.PropertyName}" targets unknown component "${target}"`);
}
}
// dictionary tables/columns
const tables = {};
for (const ds of Object.values((rpt.Dictionary || {}).DataSources || {})) {
tables[ds.Name] = new Set(Object.values(ds.Columns || {}).map(c => (typeof c === 'string' ? c : c.Name)));
}
const relations = Object.values((rpt.Dictionary || {}).Relations || {});
// 6/7 — band wiring
for (const { c, ancestors } of comps) {
if (c.Ident !== 'StiDataBand') continue;
if (c.DataSourceName && !tables[c.DataSourceName])
errors.push(`${c.Name}: DataSourceName "${c.DataSourceName}" not in Dictionary`);
if (c.MasterComponent && !seen.has(c.MasterComponent))
errors.push(`${c.Name}: MasterComponent "${c.MasterComponent}" does not exist`);
if (c.DataRelationName && !relations.some(r => r.Name === c.DataRelationName || r.NameInSource === c.DataRelationName))
errors.push(`${c.Name}: DataRelationName "${c.DataRelationName}" not in Dictionary.Relations`);
const masterIdx = ancestors.map(a => a.Ident).lastIndexOf('StiDataBand');
if (masterIdx >= 0 && !ancestors.slice(masterIdx + 1).some(a => a.Ident === 'StiPanel'))
errors.push(`${c.Name}: DataBand nested in DataBand "${ancestors[masterIdx].Name}" without a StiPanel wrapper — it will be hoisted above the master's static content`);
}
// 8 — {table.column} expression references
const SYSTEM = new Set(['PageNumber', 'TotalPageCount', 'PageNofM', 'Today', 'Time', 'ReportName', 'ReportAlias']);
for (const { c } of comps) {
const exprs = [];
if (c.Text && typeof c.Text.Value === 'string') exprs.push(['Text', c.Text.Value]);
if (c.ImageURL && typeof c.ImageURL.Value === 'string') exprs.push(['ImageURL', c.ImageURL.Value]);
for (const [prop, val] of exprs) {
for (const m of val.matchAll(/\{([A-Za-z_][\w]*)(?:\.([\w]+))?[^}]*\}/g)) {
const [, tbl, col] = m;
if (SYSTEM.has(tbl)) continue;
if (!col) { warn.push(`${c.Name}.${prop}: unrecognized reference "{${tbl}}"`); continue; }
if (!tables[tbl]) { errors.push(`${c.Name}.${prop}: unknown table "${tbl}" in "${m[0]}"`); continue; }
if (!tables[tbl].has(col)) errors.push(`${c.Name}.${prop}: table "${tbl}" has no column "${col}"`);
}
}
}
for (const w of warn) console.warn('WARN ', w);
if (errors.length) {
for (const e of errors) console.error('ERROR', e);
console.error(`\nFAIL ${file}: ${errors.length} error(s)`);
process.exit(1);
}
console.log(`OK ${file}: ${Object.keys(rpt.Pages || {}).length} pages, ${comps.length} components, ${Object.keys(tables).length} datasources — all checks passed`);