import test from 'node:test'; import assert from 'node:assert/strict'; import { acquireInfraredMosaic, acquireWeatherImage, decodeInfraredImage, processInfraredImage, MAX_MOSAIC_BYTES, } from './infraredImage.js'; function canvas() { const output = {}; output.getContext = () => ({ setTransform: (...matrix) => { output.transforms = [...(output.transforms ?? []), matrix]; }, drawImage: (image) => { output.image = image; output.drawnWith = output.transforms?.at(-1) ?? null; }, getImageData: () => ({ data: new Uint8ClampedArray([194, 0, 0, 200]) }), putImageData: (pixels) => { output.pixels = pixels.data; }, }); return output; } test('the transfer is applied to the canvas pixels in place, with no copy written back', () => { let read = null; const output = {}; output.getContext = () => ({ drawImage() {}, getImageData: () => { read = { data: new Uint8ClampedArray([194, 0, 0, 200]) }; // A second full-size buffer copied over the first is what this guards. read.data.set = () => { throw new Error('the canvas pixels were overwritten from a copy'); }; return read; }, putImageData: (pixels) => { output.pixels = pixels; }, }); processInfraredImage({ width: 1, height: 1 }, 'filtered', () => output); assert.equal(output.pixels, read); assert.deepEqual([...read.data], [194, 0, 0, 98]); }); test('decoded pixels are processed once on a same-sized canvas', () => { const image = { width: 1, height: 1 }; const output = processInfraredImage(image, 'filtered', canvas); assert.equal(output.width, 1); assert.equal(output.height, 1); assert.equal(output.image, image); assert.deepEqual([...output.pixels], [194, 0, 0, 98]); assert.deepEqual( [...processInfraredImage(image, 50, canvas).pixels], [194, 0, 0, 149], 'half-strength filter retains a midpoint haze', ); assert.deepEqual( [...processInfraredImage(image, 0, canvas).pixels], [194, 0, 0, 200], ); assert.deepEqual( [...processInfraredImage(image, 'full', canvas).pixels], [194, 0, 0, 200], ); }); test('flipY draws a bottom-up image upright, then restores the transform', () => { const image = { width: 4, height: 8 }; assert.equal(processInfraredImage(image, 'full', canvas).drawnWith, null); const output = processInfraredImage(image, 'full', canvas, { flipY: true }); assert.deepEqual(output.drawnWith, [1, 0, 0, -1, 0, 8]); assert.deepEqual(output.transforms.at(-1), [1, 0, 0, 1, 0, 0]); }); test('mosaic fetch is capped at 4 MiB, forwards cancellation, and releases decoded images', async () => { const controller = new AbortController(); let closes = 0, fetches = 0, clock = 10, fetched = false; const options = { signal: controller.signal, mode: 'filtered', createCanvas: canvas, now: () => clock, onFetched: () => { fetched = true; }, fetchImpl: async (url, { signal }) => { fetches++; assert.match(url, /^\/api\/weather\/image\?product=clouds&time=/); assert.equal(signal, controller.signal); return new Response(new Uint8Array([1, 2, 3])); }, decodeImage: async (blob) => { assert.equal(fetched, true); assert.equal(blob.size, 3); assert.equal(blob.type, 'image/png'); clock = 25; return { width: 2048, height: 1024, close() { closes++; }, }; }, }; const result = await acquireInfraredMosaic( '2026-09-15T20:00:00.000Z', options, ); assert.equal(fetches, 1); assert.equal(closes, 1); assert.equal(result.decodeMs, 15); assert.equal(result.texture.width, 2048); for (const response of [ new Response('', { headers: { 'content-length': String(MAX_MOSAIC_BYTES + 1) }, }), new Response(new Uint8Array(MAX_MOSAIC_BYTES + 1)), ]) { await assert.rejects( acquireInfraredMosaic('2026-09-15', { ...options, fetchImpl: async () => response, decodeImage: () => assert.fail('oversized image must not decode'), }), { code: 'RESPONSE_TOO_LARGE' }, ); } await assert.rejects( acquireInfraredMosaic('2026-09-15', { ...options, decodeImage: async () => { controller.abort(); return { width: 2048, height: 1024, close() { closes++; }, }; }, }), { name: 'AbortError' }, ); assert.equal(closes, 2); }); test('bitmap decoding and Image fallback release resources on success, error and abort', async (t) => { const original = Object.getOwnPropertyDescriptor( globalThis, 'createImageBitmap', ); t.after(() => { if (original) Object.defineProperty(globalThis, 'createImageBitmap', original); else delete globalThis.createImageBitmap; }); const bitmap = {}; globalThis.createImageBitmap = async () => bitmap; assert.equal( await decodeInfraredImage(new Blob(), new AbortController().signal), bitmap, ); globalThis.createImageBitmap = undefined; const images = [], revoked = []; const originalImage = globalThis.Image; globalThis.Image = class { constructor() { images.push(this); } }; t.after(() => { if (originalImage) globalThis.Image = originalImage; else delete globalThis.Image; }); t.mock.method(URL, 'createObjectURL', () => 'blob:test'); t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url)); for (const action of ['load', 'error', 'abort']) { const controller = new AbortController(); const pending = decodeInfraredImage(new Blob(), controller.signal); const image = images.at(-1); assert.equal(image.src, 'blob:test'); if (action === 'load') { image.onload(); assert.equal(await pending, image); } else if (action === 'error') { image.onerror(); await assert.rejects(pending, /decode failed/); } else { controller.abort(); await assert.rejects(pending, { name: 'AbortError' }); assert.equal(image.src, ''); } assert.equal(image.onload, null); assert.equal(image.onerror, null); } assert.deepEqual(revoked, ['blob:test', 'blob:test', 'blob:test']); }); const pixels = (width, height) => ({ width, height, closes: 0, close() { this.closes++; }, }); test('a frame source supplies the image: nothing is fetched or decoded and the pixels are released', async () => { const controller = new AbortController(); const requests = []; const bitmap = pixels(2048, 1024); let clock = 5, fetched = false; const result = await acquireInfraredMosaic('2026-09-15T20:00:00.000Z', { signal: controller.signal, mode: 'filtered', createCanvas: canvas, now: () => clock, onFetched: () => { fetched = true; }, fetchImpl: () => assert.fail('a supplied frame is not fetched'), decodeImage: () => assert.fail('a supplied frame is not decoded'), frameSource: { getImage: async (request) => { requests.push(request); clock = 12; return bitmap; }, }, }); assert.deepEqual(requests, [ { product: 'clouds', time: '2026-09-15T20:00:00.000Z', size: { width: 2048, height: 1024 }, bbox: null, signal: controller.signal, }, ]); assert.equal(fetched, true); assert.equal(result.decodeMs, 7); assert.equal(result.texture.width, 2048); assert.equal(result.texture.height, 1024); assert.equal(result.texture.image, bitmap); assert.deepEqual( [...result.texture.pixels], [194, 0, 0, 98], 'infrared frames get the display transfer', ); assert.equal(bitmap.closes, 1); }); test('supplied detail windows carry their bbox; ImageData is put, radar keeps its pixels', async (t) => { const had = Object.hasOwn(globalThis, 'ImageData'); const previous = globalThis.ImageData; globalThis.ImageData = class ImageData { width = 4096; height = 2048; data = new Uint8ClampedArray([1, 2, 3, 4]); }; t.after(() => { if (had) globalThis.ImageData = previous; else delete globalThis.ImageData; }); const data = new globalThis.ImageData(); const bbox = { west: -102, south: 34, east: -96, north: 37 }; let request; const { texture } = await acquireWeatherImage('radar', 't', { signal: new AbortController().signal, size: { width: 4096, height: 2048 }, bbox, createCanvas: canvas, fetchImpl: () => assert.fail('no fetch'), frameSource: { getImage: async (value) => { request = value; return data; }, }, }); assert.equal(request.bbox, bbox); assert.equal( texture.pixels, data.data, 'ImageData is put as it is; radar has no transfer', ); assert.equal(texture.image, undefined, 'ImageData is not drawn as an image'); assert.equal(texture.width, 4096); }); test('a supplied frame of the wrong size is refused and still released', async () => { const wrong = pixels(1024, 512); await assert.rejects( acquireInfraredMosaic('t', { signal: new AbortController().signal, mode: 'filtered', createCanvas: canvas, frameSource: { getImage: async () => wrong }, }), /Invalid weather image dimensions/, ); assert.equal(wrong.closes, 1); await assert.rejects( acquireInfraredMosaic('t', { signal: new AbortController().signal, mode: 'filtered', createCanvas: canvas, frameSource: { getImage: async () => undefined }, }), /Invalid weather image dimensions/, ); }); test('cancellation reaches the frame source, and a frame that arrives after it is released', async () => { const controller = new AbortController(); const late = pixels(2048, 1024); let seen; const pending = acquireInfraredMosaic('t', { signal: controller.signal, mode: 'filtered', createCanvas: () => assert.fail('a cancelled frame is not drawn'), frameSource: { getImage: async ({ signal }) => { seen = signal; await flushMicrotasks(); return late; }, }, }); controller.abort(); await assert.rejects(pending, { name: 'AbortError' }); assert.equal(seen, controller.signal); assert.equal(late.closes, 1); const aborted = new AbortController(); aborted.abort(); await assert.rejects( acquireInfraredMosaic('t', { signal: aborted.signal, frameSource: { getImage: () => assert.fail('already cancelled') }, }), { name: 'AbortError' }, ); }); async function flushMicrotasks() { for (let i = 0; i < 5; i++) await Promise.resolve(); }