#!/usr/bin/env node /* * Validates assets/js/engine.js against values recalculated by Microsoft Excel * from the official workbook (tests/excel-fixtures.json, produced by * tests/export-excel-fixtures.ps1), using the parameters stored in MySQL. * * node tests/validate.js engine (DB params) vs Excel * node tests/validate.js --legacy also measure the original single-file HTML app */ 'use strict'; const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process'); const Engine = require('../assets/js/engine.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 params = JSON.parse(execFileSync(php, ['-d', 'display_startup_errors=0', path.join(root, 'database/migrate.php'), '--dump-params'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })); // Rupiah amounts must match to the rupiah after rounding; rates to 1e-9. 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; function engineView(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: r.products.giro.bungaNet, simanjaNet: r.products.simanja.bungaNet, simpedaNet: r.products.simpeda.bungaNet, depositoNet: r.deposito.net, depositoGross: r.deposito.gross, rateDeposito: r.deposito.rate, totalGiro: ok ? by.giro.total : null, totalSimanja: ok ? by.simanja.total : null, totalSimpeda: ok ? by.simpeda.total : null, totalDeposito: r.deposito.net, rateGiro: r.products.giro.rate, rateSimanja: r.products.simanja.rate, rateSimpeda: r.products.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, shareBank: ok ? r.shareBank : null, income: r.fund.income, puabIncome: r.fund.puabIncome, creditIncome: r.fund.creditIncome, spreadSimanja: r.products.simanja.spread, spreadSimpeda: r.products.simpeda.spread, spreadGiro: r.products.giro.spread }; } const RATE_FIELDS = new Set(['equiv', 'rateGiro', 'rateSimanja', 'rateSimpeda', 'rateDeposito', 'totalRateGiro', 'totalRateSimanja', 'totalRateSimpeda', 'shareNasabah', 'shareBank']); function compare(label, viewFn) { let checks = 0; const failures = []; for (const c of fixtures.cases) { const got = viewFn(c); for (const [field, expected] of Object.entries(c)) { if (field === 'nominal' || field === 'tenor' || !(field in got)) continue; checks++; const actual = got[field]; const kind = RATE_FIELDS.has(field) ? 'rate' : 'idr'; const pass = expected === null ? actual === null : actual !== null && close(actual, expected, kind); if (!pass) failures.push({ nominal: c.nominal, tenor: c.tenor, field, excel: expected, app: actual }); } } console.log(`\n${label}: ${checks - failures.length}/${checks} checks match Excel over ${fixtures.cases.length} cases`); return failures; } const failures = compare('Engine (parameter dari MySQL)', c => engineView(Engine.calculate(params, { nominal: c.nominal, tenor: c.tenor, customerType: 'perorangan' }))); for (const f of failures.slice(0, 20)) console.log(' MISMATCH', JSON.stringify(f)); if (process.argv.includes('--legacy')) { const legacy = require('./legacy-original.js'); const lf = compare('Aplikasi HTML lama', c => legacy(c.nominal, c.tenor)); const byField = {}; for (const f of lf) (byField[f.field] = byField[f.field] || []).push(f); for (const [field, list] of Object.entries(byField)) { const ex = list[0]; console.log(` ${field}: ${list.length} mismatch, contoh ${ex.nominal}/${ex.tenor} bln -> Excel ${ex.excel} vs lama ${ex.app}`); } } process.exitCode = failures.length ? 1 : 0;