132 lines
5.9 KiB
JavaScript
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`);
|