// src/data/firmsCsv.test.mjs import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { acquisitionMsUtc, filterTrailing24h, isLikelyCsv, parseFirmsCsv, } from 'gods-eye-view/sources/firms-csv'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE = fs.readFileSync( path.join(__dirname, 'fixtures', 'firms-viirs-noaa20-sample.csv'), 'utf8', ); const HEADER = 'latitude,longitude,bright_ti4,scan,track,acq_date,acq_time,satellite,instrument,confidence,version,bright_ti5,frp,daynight'; test('fixture parses to 45 records with finite lat/lon/frp', () => { const records = parseFirmsCsv(FIXTURE); assert.ok(Array.isArray(records)); assert.equal(records.length, 45); for (const record of records) { assert.ok(Number.isFinite(record.lat), `lat finite: ${record.lat}`); assert.ok(Number.isFinite(record.lon), `lon finite: ${record.lon}`); assert.ok(Number.isFinite(record.frp), `frp finite: ${record.frp}`); assert.ok(record.lat >= -90 && record.lat <= 90); assert.ok(record.lon >= -180 && record.lon <= 180); } }); test('fixture first row maps every field, categorical confidence preserved raw', () => { const first = parseFirmsCsv(FIXTURE)[0]; assert.equal(first.lat, 38.99488); assert.equal(first.lon, -121.67046); assert.equal(first.brightness, 303.6); // bright_ti4 assert.equal(first.brightnessTi5, 290.73); // bright_ti5 assert.equal(first.frp, 0.53); assert.equal(first.confidence, 'n'); // categorical, passed through untouched assert.equal(first.daynight, 'N'); assert.equal(first.acqDate, '2026-07-16'); assert.equal(first.acqTime, '1006'); // string, as-is assert.equal(first.satellite, 'N20'); assert.equal(first.instrument, 'VIIRS'); }); test('MODIS schema maps fields and preserves numeric confidence raw', () => { const header = 'latitude,longitude,brightness,scan,track,acq_date,acq_time,satellite,instrument,confidence,version,bright_t31,frp,daynight'; const row = '34.123,-117.456,321.5,1.2,0.8,2026-07-16,1045,Aqua,MODIS,78,6.1NRT,290.25,12.4,D'; const [record] = parseFirmsCsv(`${header}\n${row}\n`); assert.equal(record.lat, 34.123); assert.equal(record.lon, -117.456); assert.equal(record.frp, 12.4); assert.equal(record.brightness, 321.5); assert.equal(record.brightnessTi5, 290.25); assert.equal(record.daynight, 'D'); assert.equal(record.acqDate, '2026-07-16'); assert.equal(record.acqTime, '1045'); assert.equal(record.satellite, 'Aqua'); assert.equal(record.instrument, 'MODIS'); assert.equal(record.confidence, '78'); }); test('acquisitionMsUtc: "1006" = 10:06 UTC', () => { assert.equal( acquisitionMsUtc('2026-07-16', '1006'), Date.UTC(2026, 6, 16, 10, 6), ); }); test('acquisitionMsUtc: non-zero-padded "45" = 00:45 UTC', () => { assert.equal( acquisitionMsUtc('2026-07-16', '45'), Date.UTC(2026, 6, 16, 0, 45), ); }); test('acquisitionMsUtc: "0" = midnight UTC; garbage → NaN', () => { assert.equal( acquisitionMsUtc('2026-07-16', '0'), Date.UTC(2026, 6, 16, 0, 0), ); assert.ok(Number.isNaN(acquisitionMsUtc('not-a-date', '1006'))); assert.ok(Number.isNaN(acquisitionMsUtc('2026-07-16', 'xx'))); }); test('header-only input → []', () => { assert.deepEqual(parseFirmsCsv(`${HEADER}\n`), []); assert.deepEqual(parseFirmsCsv(HEADER), []); }); test('CRLF line endings and trailing newline tolerated', () => { const text = `${HEADER}\r\n38.99488,-121.67046,303.6,0.39,0.36,2026-07-16,1006,N20,VIIRS,n,2.0NRT,290.73,0.53,N\r\n\r\n`; const records = parseFirmsCsv(text); assert.equal(records.length, 1); assert.equal(records[0].lat, 38.99488); assert.equal(records[0].daynight, 'N'); // no trailing \r leaking into the last field }); test('malformed rows are skipped, valid rows kept', () => { const text = [ HEADER, 'not,enough,fields', 'garbage-line-with-no-commas', 'NaN,-121.67046,303.6,0.39,0.36,2026-07-16,1006,N20,VIIRS,n,2.0NRT,290.73,0.53,N', '38.99488,-121.67046,303.6,0.39,0.36,2026-07-16,1006,N20,VIIRS,n,2.0NRT,290.73,0.53,N', ].join('\n'); const records = parseFirmsCsv(text); assert.equal(records.length, 1); assert.equal(records[0].lat, 38.99488); }); test('HTML error page detected as not-CSV', () => { const html = '\n

Service unavailable

'; assert.equal(isLikelyCsv(html), false); assert.equal(parseFirmsCsv(html), null); }); test('plain-text upstream error detected as not-CSV', () => { const text = 'Invalid MAP_KEY.'; assert.equal(isLikelyCsv(text), false); assert.equal(parseFirmsCsv(text), null); }); test('isLikelyCsv accepts the real fixture', () => { assert.equal(isLikelyCsv(FIXTURE), true); }); test('filterTrailing24h: window is [now − 24 h, now + 2 h] inclusive', () => { const now = Date.UTC(2026, 6, 16, 12, 0); // 2026-07-16 12:00Z const rec = (acqDate, acqTime) => ({ acqDate, acqTime }); const records = [ rec('2026-07-15', '1159'), // 24h + 1min old → out rec('2026-07-15', '1200'), // exactly 24h old → in (inclusive) rec('2026-07-15', '1201'), // 23h59m old → in rec('2026-07-16', '1130'), // recent → in rec('2026-07-16', '1400'), // now + 2h (slack boundary) → in rec('2026-07-16', '1401'), // beyond forward slack → out rec('bad-date', '1006'), // unparseable → out ]; const kept = filterTrailing24h(records, now); assert.deepEqual( kept.map((r) => `${r.acqDate} ${r.acqTime}`), [ '2026-07-15 1200', '2026-07-15 1201', '2026-07-16 1130', '2026-07-16 1400', ], ); }); test('filterTrailing24h on the fixture keeps everything for a same-night now', () => { const records = parseFirmsCsv(FIXTURE); // Fixture is a days=2 pull; all rows are 2026-07-16 (10:06Z–21:27Z), so at // 2026-07-17 02:00Z every detection is 4.5–16 h old — all inside 24 h. const kept = filterTrailing24h(records, Date.UTC(2026, 6, 17, 2, 0)); assert.equal(kept.length, records.length); }); const contractCases = JSON.parse( fs.readFileSync( path.join(__dirname, 'fixtures', 'firms-csv-cases.json'), 'utf8', ), ); for (const fixture of contractCases) { test(`portable FIRMS CSV contract: ${fixture.name}`, () => { const records = parseFirmsCsv(fixture.csv); assert.equal(records === null ? null : records.length, fixture.parsedCount); const recent = filterTrailing24h(records, Date.parse(fixture.now)); assert.deepEqual( recent.map((record) => record.acqTime), fixture.recentTimes, ); }); }