57 lines
3.3 KiB
JavaScript
57 lines
3.3 KiB
JavaScript
#!/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);
|