Files
ponomar-pwa/scripts/assembler-test.ts
T

279 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Assembler integration test: exercise key service templates through
* assembleService with the same varNode injection the app uses.
* Run with: npx tsx scripts/assembler-test.ts
*/
import { readFileSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { assembleService, type ServiceContext, type EvalContext } from '../src/core/service-assembler';
import { computeDay } from '../src/core/day-computer';
import { JDate } from '../src/core/jdate';
const __dirname = new URL('.', import.meta.url).pathname;
const SHARED = join(__dirname, '..', 'static', 'data', 'shared');
const CU_SERVICES = join(__dirname, '..', 'static', 'data', 'cu', 'services');
const TEMPLATES = join(SHARED, 'services', 'templates');
const CANONS = join(SHARED, 'services', 'canons');
let errors = 0;
let warnings = 0;
function err(msg: string) { console.error(` ERROR: ${msg}`); errors++; }
function warn(msg: string) { console.warn(` WARN: ${msg}`); warnings++; }
/** Filesystem-backed ServiceContext for testing. */
function makeTestContext(varNodes: Map<string, ServiceNode[]>): ServiceContext {
const langs = ['cu', 'en', 'shared'];
const loadJson = (p: string) => JSON.parse(readFileSync(p, 'utf-8'));
function resolveFile(path: string, type: 'json'): string | null {
let base = path;
if (base.startsWith('Services/')) base = base.slice('Services/'.length);
base = base.replace('.xml', '');
for (const lang of langs) {
let p: string | null = null;
if (base.startsWith('Var/')) {
continue;
}
if (base.startsWith('Text/')) {
p = join(lang === 'shared' ? SHARED : (lang === 'cu' ? CU_SERVICES : join(__dirname, '..', 'static', 'data', lang, 'services')), 'texts', base.slice('Text/'.length) + '.json');
} else if (base.startsWith('CommonPrayers/')) {
p = join(lang === 'shared' ? SHARED : (lang === 'cu' ? CU_SERVICES : join(__dirname, '..', 'static', 'data', lang, 'services')), 'prayers', base.slice('CommonPrayers/'.length) + '.json');
} else if (base.startsWith('Command/')) {
p = join(lang === 'shared' ? SHARED : (lang === 'cu' ? CU_SERVICES : join(__dirname, '..', 'static', 'data', lang, 'services')), 'commands', base.slice('Command/'.length) + '.json');
} else if (base.startsWith('Header/')) {
p = join(lang === 'shared' ? SHARED : (lang === 'cu' ? CU_SERVICES : join(__dirname, '..', 'static', 'data', lang, 'services')), 'headers', base.slice('Header/'.length) + '.json');
} else if (base.startsWith('Octoecheos/')) {
p = join(lang === 'shared' ? SHARED : (lang === 'cu' ? CU_SERVICES : join(__dirname, '..', 'static', 'data', lang, 'services')), 'octoecheos', base.slice('Octoecheos/'.length) + '.json');
} else {
p = join(TEMPLATES, base + '.json');
}
if (p && existsSync(p)) return p;
}
return null;
}
// Minimal evalCtx with variables Matins template checks
const evalCtx: EvalContext = {
dow: 3, doy: 200, nday: -100, ndayP: 0, ndayF: 0,
Year: 2026, dRank: 3, PFlag: 0, PFlag1: 1, PFlag2: 1, PFlag3: 0,
PS: 1, Tone: 3, Easter: 0, Pentecost: 0, LentStart: 0, eothinon: 0, LS: 1, GS: 1,
};
return {
evalCtx,
lang: 'cu',
async fetchText(path: string) {
const file = resolveFile(path, 'json');
if (!file) return null;
const nodes = loadJson(file);
if (!Array.isArray(nodes)) return null;
const isHeader = path.endsWith('.header');
const filtered = nodes.filter((n: any) => isHeader ? n.type === 'HEADER' : n.type === 'TEXT');
return filtered.map((n: any) => String(n.value ?? '')).filter(Boolean).join(isHeader ? ' ' : '<br>');
},
async fetchServiceNodes(path: string) {
const m = path.match(/Services\/(?:Var\/)?(\w+)\.xml$/);
if (m && varNodes.has(m[1])) return varNodes.get(m[1]) || null;
const file = resolveFile(path, 'json');
if (!file) return null;
const nodes = loadJson(file);
if (!Array.isArray(nodes)) return null;
return nodes as any[];
},
async fetchPrayerNodes(path: string) {
const file = resolveFile(path, 'json');
if (!file) return null;
const nodes = loadJson(file);
if (!Array.isArray(nodes)) return null;
return nodes as any[];
},
async fetchBibleText() { return ''; },
async fetchLives() { return null; },
async fetchCommandText() { return ''; },
async resolveTimes(t: number) { return `${t}×`; },
};
}
/** Load nodes from a JSON file. */
function loadNodes(file: string): any[] | null {
if (!existsSync(file)) return null;
return JSON.parse(readFileSync(file, 'utf-8'));
}
/** Run a test case: assemble template with varNodes, check output. */
async function runTest(name: string, template: string, varNodes: Map<string, ServiceNode[]>, check: (html: string) => boolean, checkMsg: string): Promise<void> {
try {
const templateNodes = loadNodes(join(TEMPLATES, `${template}.json`));
if (!templateNodes) { err(`${name}: template ${template}.json not found`); return; }
const ctx = makeTestContext(varNodes);
const { html } = await assembleService(templateNodes as any, ctx);
if (!html.trim()) { err(`${name}: empty HTML output`); return; }
if (!check(html)) { err(`${name}: ${checkMsg}`); return; }
console.log(` ✓ ${name}`);
} catch (e) {
err(`${name}: exception: ${(e as Error).message}`);
}
}
async function main() {
console.log('Running assembler integration tests...\n');
// 1. Matins weekday → Paraclete canon
{
const tone = 3;
const dow = 3; // Wednesday
const dayKey = ['', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'][dow];
const canonFile = join(CANONS, 'paraclete', `tone${tone}`, `${dayKey}.json`);
const nodes = loadNodes(canonFile);
if (nodes) {
const varNodes = new Map<string, ServiceNode[]>();
varNodes.set('PCanonU', nodes as any[]);
await runTest(
'Matins weekday (Tone 3 Wed) → Paraclete',
'Matins',
varNodes,
html => html.includes('Пѣснь') || html.includes('Пѣ́снь'),
'output missing ode markers'
);
} else {
warn('Skipping Matins weekday: canon file not found');
}
}
// 2. Matins Sunday → Octoechos Sunday canon
{
const tone = 5;
const toneNum = tone === 8 ? 0 : tone;
const canonFile = join(CANONS, `tone${toneNum}`, 'sunday.json');
const canonData = loadNodes(canonFile);
if (canonData) {
// Build nodes same way loadCanonData does
const ODE_SLAV = ['', 'а', 'в', 'г', 'д', 'є', 'ѕ', 'з', 'и', 'ѳ'];
const nodes: any[] = [];
for (const ode of (canonData as any).odes) {
const slavNum = ODE_SLAV[ode.ode] ?? String(ode.ode);
nodes.push({ type: 'HEADER', value: `Пѣснь ${slavNum}҃.` });
for (const canon of ode.canons) {
if (canon.irmos) {
nodes.push({ type: 'HEADER', value: `Ірмосъ, гла́съ ${canonData.tone}:` });
nodes.push({ type: 'TEXT', value: canon.irmos });
}
for (const trop of canon.troparia) nodes.push({ type: 'TEXT', value: trop });
if (canon.theotokion) {
nodes.push({ type: 'HEADER', value: 'Бг҃ородиченъ:' });
nodes.push({ type: 'TEXT', value: canon.theotokion });
}
}
}
const varNodes = new Map<string, ServiceNode[]>();
varNodes.set('PCanonU', nodes);
await runTest(
'Matins Sunday (Tone 5) → Octoechos',
'Matins',
varNodes,
html => html.includes('Пѣснь') && html.includes('Ірмосъ'),
'output missing ode/irmos markers'
);
} else {
warn('Skipping Matins Sunday: canon file not found');
}
}
// 3. Triodion template with PTriodion (first section)
{
const triNodes = loadNodes(join(SHARED, 'triodion', '01.json'));
if (triNodes) {
const varNodes = new Map<string, ServiceNode[]>();
varNodes.set('PTriodion', triNodes as any[]);
await runTest(
'Triodion section 01',
'Triodion',
varNodes,
html => html.trim().length > 100,
'output too short'
);
} else {
err('Triodion 01.json not found');
}
}
// 4. MenaionDay with PMenaionDay (sample date 09-08 = Nativity of Theotokos)
{
const menNodes = loadNodes(join(SHARED, 'menaion-daily', '09-08', '1.json'));
if (menNodes) {
const varNodes = new Map<string, ServiceNode[]>();
varNodes.set('PMenaionDay', menNodes as any[]);
await runTest(
'MenaionDay (09-08 Nativity Theotokos)',
'MenaionDay',
varNodes,
html => html.trim().length > 100,
'output too short'
);
} else {
err('menaion-daily/09-08/1.json not found');
}
}
// 5. Pascha (data-backed service) with PPascha
{
const paschaNodes = loadNodes(join(SHARED, 'services', 'pascha', 'full.json'));
if (paschaNodes) {
const varNodes = new Map<string, ServiceNode[]>();
varNodes.set('PPascha', paschaNodes as any[]);
await runTest(
'Pascha (data-backed)',
'Pascha',
varNodes,
html => html.includes('Хрїсто́съ воскре́се') || html.includes('Воскресе́нїе'),
'output missing Pascha text'
);
} else {
err('services/pascha/full.json not found');
}
}
// 6. Paralytic (I-prefix var) with IParalytic
{
const paralyticNodes = loadNodes(join(SHARED, 'services', 'paralytic', 'full.json'));
if (paralyticNodes) {
const varNodes = new Map<string, ServiceNode[]>();
varNodes.set('IParalytic', paralyticNodes as any[]);
await runTest(
'Paralytic (I-prefix var)',
'Paralytic',
varNodes,
html => html.trim().length > 100 && !html.includes('Service template not found'),
'output too short or template missing'
);
} else {
err('services/paralytic/full.json not found');
}
}
// 7. Sergius (bare P-prefix var) with PSergius
{
const sergiusNodes = loadNodes(join(SHARED, 'services', 'sergius', 'full.json'));
if (sergiusNodes) {
const varNodes = new Map<string, ServiceNode[]>();
varNodes.set('PSergius', sergiusNodes as any[]);
await runTest(
'Sergius (bare P-prefix var)',
'Sergius',
varNodes,
html => html.trim().length > 100 && !html.includes('Service template not found'),
'output too short or template missing'
);
} else {
err('services/sergius/full.json not found');
}
}
console.log(`\nAssembler tests: ${errors} errors, ${warnings} warnings`);
process.exit(errors > 0 ? 1 : 0);
}
main();