154 lines
8.0 KiB
TypeScript
154 lines
8.0 KiB
TypeScript
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<Response>(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);
|
|
});
|