solar-spa/test.mjs
Aric Camarata fb0c14e761 v2.0.0: TypeScript rewrite with WASM recompilation
Complete rewrite of the package from plain JavaScript to TypeScript,
compiled to dual CJS/ESM via tsup. The NREL SPA C source is recompiled
to WASM with Emscripten using SINGLE_FILE base64 inlining, eliminating
bundler path-resolution issues.

Changes:
- Rewrite JS wrapper in TypeScript with full type definitions
- Recompile WASM with -O3 -flto, 1MB fixed memory, no filesystem
- Add input validation with descriptive error messages
- Add spaFormatted() for HH:MM:SS time strings
- Add formatTime() utility and init() for eager WASM loading
- Add SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL function code exports
- Dual CJS/ESM output via tsup with proper exports map
- Test suite: 68 ESM + 13 CJS assertions
- 100-scenario validation suite across 7 categories
- GitHub Wiki with 8 documentation pages
- CI workflow: Node 20/22/24 matrix, typecheck, pack-check
- NREL attribution in LICENSE and README per their license terms
- Minimum Node.js 20
2026-02-25 10:35:24 -05:00

258 lines
10 KiB
JavaScript

import { spa, spaFormatted, formatTime, init, SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL } from './dist/index.mjs';
let passed = 0;
let failed = 0;
function assert(condition, message) {
if (condition) {
passed++;
} else {
failed++;
console.error(' FAIL: ' + message);
}
}
function approx(actual, expected, tolerance, label) {
const diff = Math.abs(actual - expected);
assert(diff <= tolerance, label + ': expected ' + expected + ', got ' + actual + ' (diff: ' + diff.toFixed(6) + ')');
}
async function assertThrows(fn, check, label) {
try {
await fn();
assert(false, label + ': should have thrown');
} catch (e) {
if (check) {
assert(check(e), label + ': ' + e.message);
} else {
passed++;
}
}
}
async function run() {
console.log('solar-spa test suite\n');
// ── Test 1: New York City, April 1 2023 ──
console.log('1. NYC, April 1 2023, midnight local (UTC-4)');
const nyc = await spa(
new Date(2023, 3, 1, 0, 0, 0),
40.7128, -74.006,
{ timezone: -4, elevation: 10, temperature: 20, pressure: 1013.25 },
);
approx(nyc.zenith, 132.82, 0.1, 'zenith');
approx(nyc.azimuth, 339.38, 0.1, 'azimuth');
approx(nyc.sunrise, 6.665, 0.01, 'sunrise');
approx(nyc.sunset, 19.343, 0.01, 'sunset');
approx(nyc.suntransit, 12.998, 0.01, 'solar noon');
approx(nyc.sun_transit_alt, 53.916, 0.1, 'transit altitude');
assert(nyc.error_code === 0, 'error_code is 0');
// ── Test 2: London, Summer Solstice ──
console.log('2. London, June 21 2025, noon UTC');
const london = await spa(
new Date(2025, 5, 21, 12, 0, 0),
51.5074, -0.1278,
{ timezone: 0, elevation: 11, temperature: 18 },
);
assert(london.zenith < 30, 'zenith near noon is below 30 degrees');
assert(london.azimuth > 170 && london.azimuth < 200, 'azimuth roughly south at noon');
assert(london.sunrise > 3 && london.sunrise < 6, 'sunrise between 3 and 6');
assert(london.sunset > 19 && london.sunset < 23, 'sunset between 19 and 23');
assert(london.error_code === 0, 'error_code is 0');
// ── Test 3: Equator, Equinox ──
console.log('3. Quito (equator), March 20 2025, noon UTC-5');
const quito = await spa(
new Date(2025, 2, 20, 12, 0, 0),
-0.1807, -78.4678,
{ timezone: -5, elevation: 2850 },
);
assert(quito.zenith < 20, 'near-overhead sun at equinox on equator');
assert(quito.error_code === 0, 'error_code is 0');
// ── Test 4: Sydney, Winter ──
console.log('4. Sydney, June 21 2025 (winter), noon AEST');
const sydney = await spa(
new Date(2025, 5, 21, 12, 0, 0),
-33.8688, 151.2093,
{ timezone: 10 },
);
assert(sydney.zenith > 50, 'low sun in southern winter');
assert(sydney.sunrise > 6 && sydney.sunrise < 8, 'winter sunrise after 6');
assert(sydney.error_code === 0, 'error_code is 0');
// ── Test 5: Formatted output ──
console.log('5. Formatted output (NYC)');
const fmt = await spaFormatted(
new Date(2023, 3, 1, 0, 0, 0),
40.7128, -74.006,
{ timezone: -4, elevation: 10, temperature: 20, pressure: 1013.25 },
);
assert(typeof fmt.sunrise === 'string', 'sunrise is a string');
assert(typeof fmt.sunset === 'string', 'sunset is a string');
assert(typeof fmt.suntransit === 'string', 'suntransit is a string');
assert(/^\d{2}:\d{2}:\d{2}$/.test(fmt.sunrise), 'sunrise matches HH:MM:SS');
assert(typeof fmt.zenith === 'number', 'zenith remains numeric');
assert(typeof fmt.error_code === 'number', 'error_code is present in formatted result');
// ── Test 6: formatTime utility ──
console.log('6. formatTime utility');
assert(formatTime(0) === '00:00:00', 'midnight');
assert(formatTime(12) === '12:00:00', 'noon');
assert(formatTime(6.5) === '06:30:00', '6.5 hours');
assert(formatTime(23.9997) === '23:59:59', 'end of day');
assert(formatTime(Infinity) === 'N/A', 'Infinity returns N/A');
assert(formatTime(-Infinity) === 'N/A', '-Infinity returns N/A');
assert(formatTime(NaN) === 'N/A', 'NaN returns N/A');
assert(formatTime(-1) === 'N/A', 'negative returns N/A');
assert(formatTime(-0.5) === 'N/A', 'negative fractional returns N/A');
assert(formatTime(24.0) === '00:00:00', '24h wraps to midnight');
assert(formatTime(24.5) === '00:30:00', '24.5h wraps to 00:30');
// ── Test 7: SPA error handling ──
console.log('7. SPA error handling');
await assertThrows(
() => spa(new Date(2023, 0, 1), 40, -74, { timezone: 100 }),
(e) => e.message.includes('error code'),
'invalid timezone throws with error code',
);
// ── Test 8: Input validation ──
console.log('8. Input validation');
await assertThrows(
() => spa(null, 40, -74),
(e) => e instanceof TypeError,
'null date throws TypeError',
);
await assertThrows(
() => spa(new Date('invalid'), 40, -74),
(e) => e instanceof TypeError,
'invalid date throws TypeError',
);
await assertThrows(
() => spa(new Date(), 'forty', -74),
(e) => e instanceof TypeError,
'string latitude throws TypeError',
);
await assertThrows(
() => spa(new Date(), 40, undefined),
(e) => e instanceof TypeError,
'undefined longitude throws TypeError',
);
await assertThrows(
() => spa(new Date(), 91, -74),
(e) => e instanceof RangeError,
'latitude > 90 throws RangeError',
);
await assertThrows(
() => spa(new Date(), -91, -74),
(e) => e instanceof RangeError,
'latitude < -90 throws RangeError',
);
await assertThrows(
() => spa(new Date(), 40, 181),
(e) => e instanceof RangeError,
'longitude > 180 throws RangeError',
);
await assertThrows(
() => spa(new Date(), 40, -181),
(e) => e instanceof RangeError,
'longitude < -180 throws RangeError',
);
// ── Test 9: Function code selection ──
console.log('9. Function code SPA_ZA (zenith/azimuth only)');
const za = await spa(
new Date(2023, 3, 1, 12, 0, 0),
40.7128, -74.006,
{ timezone: -4, function: SPA_ZA },
);
assert(za.zenith > 0, 'zenith computed');
assert(za.azimuth > 0, 'azimuth computed');
assert(za.error_code === 0, 'error_code is 0');
// ── Test 10: Repeated calls (verify singleton init) ──
console.log('10. Repeated calls');
const a = await spa(new Date(2023, 0, 1, 12, 0, 0), 40, -74, { timezone: -5 });
const b = await spa(new Date(2023, 6, 1, 12, 0, 0), 40, -74, { timezone: -4 });
assert(a.zenith !== b.zenith, 'different dates produce different results');
assert(a.error_code === 0 && b.error_code === 0, 'both succeed');
// ── Test 11: Concurrent calls (verify init dedup) ──
console.log('11. Concurrent calls');
const [c1, c2, c3] = await Promise.all([
spa(new Date(2023, 0, 1, 12, 0, 0), 40, -74, { timezone: -5 }),
spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4 }),
spa(new Date(2023, 6, 1, 12, 0, 0), 40, -74, { timezone: -4 }),
]);
assert(c1.error_code === 0 && c2.error_code === 0 && c3.error_code === 0, 'all three concurrent calls succeed');
assert(c1.zenith !== c2.zenith, 'concurrent results differ by date');
// ── Test 12: Boundary coordinates ──
console.log('12. Boundary coordinates');
const northPole = await spa(new Date(2025, 5, 21, 12, 0, 0), 90, 0, { timezone: 0 });
assert(northPole.error_code === 0, 'north pole succeeds');
const southPole = await spa(new Date(2025, 5, 21, 12, 0, 0), -90, 0, { timezone: 0 });
assert(southPole.error_code === 0, 'south pole succeeds');
const dateLine = await spa(new Date(2025, 5, 21, 12, 0, 0), 0, 180, { timezone: 12 });
assert(dateLine.error_code === 0, 'date line (180) succeeds');
const dateLineNeg = await spa(new Date(2025, 5, 21, 12, 0, 0), 0, -180, { timezone: -12 });
assert(dateLineNeg.error_code === 0, 'date line (-180) succeeds');
// ── Test 13: Arctic polar day (sun never sets) ──
console.log('13. Arctic polar day');
const tromso = await spa(
new Date(2025, 5, 21, 12, 0, 0),
69.6496, 18.9560,
{ timezone: 2 },
);
assert(tromso.error_code === 0, 'Tromso summer succeeds');
// During polar day, sunrise/sunset values from SPA may be non-standard
// The key is that the computation succeeds and zenith is low (sun is up)
assert(tromso.zenith < 50, 'sun is high at Tromso in summer');
// ── Test 14: Explicit init() call ──
console.log('14. Explicit init()');
await init(); // should be a no-op since module is already loaded
const afterInit = await spa(new Date(2023, 0, 1, 12, 0, 0), 40, -74, { timezone: -5 });
assert(afterInit.error_code === 0, 'spa works after explicit init');
// ── Test 15: Constants are correct ──
console.log('15. Constants');
assert(SPA_ZA === 0, 'SPA_ZA is 0');
assert(SPA_ZA_INC === 1, 'SPA_ZA_INC is 1');
assert(SPA_ZA_RTS === 2, 'SPA_ZA_RTS is 2');
assert(SPA_ALL === 3, 'SPA_ALL is 3');
// ── Test 16: Historical date ──
console.log('16. Historical date (year 1000)');
const historical = await spa(
new Date(1000, 5, 21, 12, 0, 0),
40.7128, -74.006,
{ timezone: -5, delta_t: 1574 },
);
assert(historical.error_code === 0, 'historical date succeeds');
assert(historical.zenith > 0 && historical.zenith < 90, 'historical zenith is reasonable');
// ── Test 17: All function codes ──
console.log('17. All function codes');
const zaRes = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4, function: SPA_ZA });
const zaIncRes = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4, function: SPA_ZA_INC });
const zaRtsRes = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4, function: SPA_ZA_RTS });
const allRes = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4, function: SPA_ALL });
assert(zaRes.error_code === 0, 'SPA_ZA succeeds');
assert(zaIncRes.error_code === 0, 'SPA_ZA_INC succeeds');
assert(zaRtsRes.error_code === 0, 'SPA_ZA_RTS succeeds');
assert(allRes.error_code === 0, 'SPA_ALL succeeds');
approx(zaRes.zenith, allRes.zenith, 0.001, 'zenith consistent across function codes');
// ── Results ──
console.log('\n' + passed + ' passed, ' + failed + ' failed');
if (failed > 0) process.exit(1);
}
run().catch(function (err) {
console.error(err);
process.exit(1);
});