import { test } from 'node:test'; import assert from 'node:assert/strict'; import { weatherRequest, parseWeather, sampleWeather, weatherAppearance, WeatherController } from '../src/weather/weather.ts'; import { createSun } from '../src/scene/sun.ts'; import { Vector3 } from 'three'; import { WEATHER } from '../src/constants/weather.ts'; import { createSky } from '../src/scene/sky.ts'; const now = new Date('2026-10-30T03:00:00Z'); const point = { latitude: 37.445, longitude: 126.964 }; const start = Date.parse('2026-10-29T21:00:00Z') / 1000; function payload(cloud = [20, 80], visibility: (number | null)[] = [20000, 4000]) { return { hourly: { time: [start, start + 3600], cloud_cover: cloud, visibility } }; } test('routes forecast, past forecast and archive with precipitation; rejects unsupported dates', () => { const live = weatherRequest(now, point, now)!; assert.equal(new URL(live.url).hostname, 'api.open-meteo.com'); assert.equal(new URL(live.url).searchParams.get('timeformat'), 'unixtime'); const past = weatherRequest(new Date('2026-03-20T00:00:00Z'), point, now)!; assert.equal(new URL(past.url).hostname, 'historical-forecast-api.open-meteo.com'); const archive = weatherRequest(new Date('2000-01-01T00:00:00Z'), point, now)!; assert.equal(new URL(archive.url).hostname, 'archive-api.open-meteo.com'); for (const request of [live, past, archive]) { const variables = new URL(request.url).searchParams.get('hourly')!.split(','); assert.ok(variables.includes('rain') && variables.includes('snowfall')); assert.equal(variables.includes('showers'), request !== archive); } assert.equal(weatherRequest(new Date('1939-01-01T00:00:00Z'), point, now), null); assert.equal(weatherRequest(new Date('2099-01-01T00:00:00Z'), point, now), null); }); test('KST 06:30 samples the correct UTC hour and interpolates smoothly', () => { const series = parseWeather(payload()); assert.deepEqual(sampleWeather(series, new Date('2026-10-30T06:30:00+09:00')), { cloudCover: 50, visibility: 12000, rain: null, snowfall: null, }); assert.equal(sampleWeather(series, new Date((start - 1) * 1000)), null); assert.equal(sampleWeather(series, new Date((start + 7200) * 1000)), null); }); test('rain includes showers, snow stays in cm, and hourly totals are not interpolated into false sleet', () => { const rows = parseWeather({ hourly: { ...payload().hourly, rain: [2, 0], showers: [1, 0], snowfall: [0, 0.7] } }); const rain = sampleWeather(rows, new Date(start * 1000))!; assert.equal(rain.rain, 3); assert.equal(rain.snowfall, 0); const snow = sampleWeather(rows, new Date((start + 1800) * 1000))!; assert.equal(snow.rain, 0); assert.equal(snow.snowfall, 0.7); assert.equal(sampleWeather(rows, new Date((start + 3601) * 1000))!.snowfall, null); }); test('archive rain needs no showers; missing or invalid precipitation remains unknown', () => { const date = new Date(start * 1000); const sample = (fields: object) => sampleWeather(parseWeather({ hourly: { ...payload().hourly, ...fields } }), date)!; assert.equal(sample({ rain: [4, 0], snowfall: [0, 0] }).rain, 4); assert.equal(sample({ rain: [4, 0], showers: [null, 0] }).rain, null); assert.equal(sample({ rain: [-1, 0], snowfall: [null, 0] }).rain, null); assert.equal(sample({ snowfall: [NaN, 0] }).snowfall, null); }); test('missing visibility is not treated as zero fog; missing cloud is not clear weather', () => { const date = new Date((start + 1800) * 1000); assert.equal(sampleWeather(parseWeather(payload([20, 80], [null, null])), date)!.visibility, null); assert.equal(sampleWeather(parseWeather({ hourly: { time: [start], cloud_cover: [null] } }), new Date(start * 1000)), null); assert.throws(() => parseWeather({ error: true }), /weather/i); }); test('overcast and poor visibility progressively reduce sun light and halo without moving the sun', () => { const clear = weatherAppearance({ cloudCover: 0, visibility: 50000, rain: 0, snowfall: 0 }); const cloudy = weatherAppearance({ cloudCover: 100, visibility: 50000, rain: 0, snowfall: 0 }); const foggy = weatherAppearance({ cloudCover: 100, visibility: 500, rain: 0, snowfall: 0 }); assert.ok(clear.transmission > cloudy.transmission && cloudy.transmission > foggy.transmission); assert.ok(cloudy.transmission < 0.1); assert.ok(foggy.fogFar < clear.fogFar); const sun = createSun(new Vector3()); const direction = new Vector3(1, 0.1, 0).normalize(); sun.update(direction, 10, clear.transmission); const position = sun.group.position.clone(), intensity = sun.light.intensity; sun.update(direction, 10, cloudy.transmission); assert.ok(sun.light.intensity < intensity * 0.1); assert.deepEqual(sun.group.position, position); for (const mesh of sun.group.children as any[]) assert.equal(mesh.material.uniforms.transmission.value, cloudy.transmission); }); test('sky and sun receive the same coverage so clouds can occlude the disc and halo', () => { const sky = createSky(), sun = createSun(new Vector3()); const direction = new Vector3(1, 0.02, 0).normalize(); for (const coverage of [0, 0.5, 1]) { sky.update(direction, 1, coverage); sun.update(direction, 1, 0.5, coverage); assert.equal(sky.mesh.material.uniforms.cloudCoverage.value, coverage); for (const mesh of sun.group.children as any[]) { assert.equal(mesh.material.uniforms.cloudCoverage.value, coverage); } } sky.mesh.geometry.dispose(); sky.mesh.material.dispose(); for (const mesh of sun.group.children as any[]) { mesh.geometry.dispose(); mesh.material.dispose(); } }); test('same-day scrubbing reuses in-flight data and obsolete responses cannot replace the selected day', async () => { const pending: ((response: Response) => void)[] = []; const fetcher = (() => new Promise(resolve => pending.push(resolve))) as typeof fetch; const states: any[] = []; const controller = new WeatherController(point, state => states.push(state), fetcher); controller.update(new Date(start * 1000)); controller.update(new Date((start + 1800) * 1000)); assert.equal(pending.length, 1); controller.update(new Date((start - 86400) * 1000)); assert.equal(pending.length, 2); pending[1](Response.json({ hourly: { time: [start - 86400, start - 82800], cloud_cover: [90, 90], visibility: [1000, 1000] } })); await new Promise(resolve => setImmediate(resolve)); assert.equal(states.at(-1).weather.cloudCover, 90); pending[0](Response.json(payload())); await new Promise(resolve => setImmediate(resolve)); assert.equal(states.at(-1).weather.cloudCover, 90); controller.update(new Date((start + 1800) * 1000)); assert.equal(pending.length, 2); assert.equal(states.at(-1).weather.cloudCover, 50); controller.dispose(); }); test('a paused custom selection retries after failure and stops after disposal', async (t) => { t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: now.getTime() }); let calls = 0; const fetcher = (async () => { calls++; return calls === 1 ? new Response('', { status: 503 }) : Response.json(payload()); }) as typeof fetch; const states: any[] = []; const controller = new WeatherController(point, state => states.push(state), fetcher); t.after(() => controller.dispose()); controller.update(new Date(start * 1000)); await new Promise(resolve => setImmediate(resolve)); assert.equal(states.at(-1).status, 'error'); t.mock.timers.tick(WEATHER.retryMs); await new Promise(resolve => setImmediate(resolve)); assert.equal(calls, 2); assert.equal(states.at(-1).status, 'ready'); controller.dispose(); t.mock.timers.tick(WEATHER.refreshMs * 2); assert.equal(calls, 2); }); test('browser fetch is called with the global receiver, not the controller', async () => { let receiver: unknown; const fetcher = (function (this: unknown) { receiver = this; return Promise.resolve(Response.json(payload())); }) as typeof fetch; const controller = new WeatherController(point, () => {}, fetcher); controller.update(new Date(start * 1000)); await new Promise(resolve => setImmediate(resolve)); controller.dispose(); assert.equal(receiver, globalThis); });