#!/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`);