#!/usr/bin/env node /* * Validates the public in-browser engine (assets/js/engine-public.js), which * only receives the folded model of engine_client_model(), against the values * recalculated by Microsoft Excel (tests/excel-fixtures.json). Only the fields * a visitor sees are compared (no deposito, income or spread). * * node tests/validate_public.js */ 'use strict'; const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process'); const Engine = require('../assets/js/engine-public.js'); const root = path.join(__dirname, '..'); const fixtures = JSON.parse(fs.readFileSync(path.join(__dirname, 'excel-fixtures.json'), 'utf8').replace(/^\uFEFF/, '')); const php = process.env.PHP_BIN || 'C:/laragon/bin/php/php-8.1.10-Win32-vs16-x64/php.exe'; const model = JSON.parse(execFileSync(php, ['-d', 'display_startup_errors=0', path.join(root, 'database/migrate.php'), '--dump-public-model'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })); // What a visitor receives must not contain the internal parameters. const leaked = ['gwm', 'kas', 'porsi_puab', 'porsi_kredit', 'puab', 'kredit', 'ohc', 'depositoRates', 'settings'].filter(k => JSON.stringify(model).includes('"' + k + '"')); if (leaked.length) { console.log('Model memuat parameter internal:', leaked.join(', ')); process.exit(1); } const close = (a, b, kind) => kind === 'rate' ? Math.abs(a - b) < 1e-9 : Math.round(a) === Math.round(b) && Math.abs(a - b) < 0.01; const RATE_FIELDS = new Set(['equiv', 'rateGiro', 'rateSimanja', 'rateSimpeda', 'totalRateGiro', 'totalRateSimanja', 'totalRateSimpeda', 'shareNasabah']); function view(r) { const by = Object.fromEntries(r.comparison.map(c => [c.key, c])); const ok = r.eligible; return { cashbackGross: ok ? r.cashback.gross : null, cashbackNet: ok ? r.cashback.net : null, giroNet: by.giro.bungaNet, simanjaNet: by.simanja.bungaNet, simpedaNet: by.simpeda.bungaNet, totalGiro: ok ? by.giro.total : null, totalSimanja: ok ? by.simanja.total : null, totalSimpeda: ok ? by.simpeda.total : null, rateGiro: by.giro.rate, rateSimanja: by.simanja.rate, rateSimpeda: by.simpeda.rate, equiv: ok ? r.cashback.equiv : null, totalRateGiro: ok ? by.giro.totalRate : null, totalRateSimanja: ok ? by.simanja.totalRate : null, totalRateSimpeda: ok ? by.simpeda.totalRate : null, penaltyRefund: ok ? r.penalty.refund : null, penaltyTax: ok ? r.penalty.tax : null, penaltyFee: ok ? r.penalty.fee : null, penaltyTotal: ok ? r.penalty.total : null, shareNasabah: ok ? r.share : null }; } let checks = 0; const failures = []; for (const c of fixtures.cases) { const got = view(Engine.calculate(model, { nominal: c.nominal, tenor: c.tenor, customerType: 'perorangan' })); for (const [field, expected] of Object.entries(c)) { if (!(field in got)) continue; checks++; const actual = got[field]; const pass = expected === null ? actual === null : actual !== null && close(actual, expected, RATE_FIELDS.has(field) ? 'rate' : 'idr'); if (!pass) failures.push({ nominal: c.nominal, tenor: c.tenor, field, excel: expected, app: actual }); } } console.log(`Public engine (model koefisien): ${checks - failures.length}/${checks} checks match Excel over ${fixtures.cases.length} cases`); failures.slice(0, 20).forEach(f => console.log(' FAIL', JSON.stringify(f))); process.exit(failures.length ? 1 : 0);