clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / render / test / texture.test.ts
texture.test.ts 6.4K
1// The sphere's skin, and the one rule a pass that can be interrupted owes.2//3// `Skin` composites the base skin into one texture, once per lens and never4// again. The pass walks tiles one image at a time and awaits each, so it can5// be standing between two awaits when the lens changes or the sphere is put6// away -- and the whole of this file is the consequence of that: a pass that7// was cancelled must leave the texture asking for its composite again rather8// than claiming to hold it.9//10// The defect these tests are the record of: a key committed before the paint11// meant an abandoned pass was remembered as painted, so nothing ever asked12// for it again and the sphere came up black and stayed black.13//14// Tiles are delivered by hand here. `Image` is stubbed so that every request15// is parked until a test says it may arrive, which is the only way to be16// standing between two awaits when the interruption lands.1718import test from "node:test";19import { strict as assert } from "node:assert";20import type { Lens, TileGrid } from "../data/payload.ts";21import { setLevel } from "../log.ts";2223setLevel("error");2425// ---- the host the skin is composited in ------------------------------2627/** One image the seam has asked for and not yet been given. */28interface Parked {29 readonly url: string;30 readonly arrive: () => void;31}3233const parked: Parked[] = [];3435class StubImage {36 crossOrigin = "";37 onload: (() => void) | null = null;38 onerror: (() => void) | null = null;39 private asked = "";4041 get src(): string {42 return this.asked;43 }4445 set src(value: string) {46 this.asked = value;47 parked.push({ url: value, arrive: () => this.onload?.() });48 }49}5051/** Everything drawn onto the texture, in order, by the URL it came from. */52const drawn: string[] = [];53/** Which parked image a `drawImage` belongs to: the one just handed over. */54let arriving = "";5556const paper = {57 imageSmoothingEnabled: false,58 clearRect: () => { drawn.length = 0; },59 drawImage: () => { drawn.push(arriving); },60};6162const canvas = { width: 0, height: 0, getContext: () => paper };6364(globalThis as unknown as { document: unknown }).document = {65 createElement: () => canvas,66};67(globalThis as unknown as { Image: unknown }).Image = StubImage;6869// Imported after the host exists: the module itself touches neither, but a70// reader should not have to know that to trust the file.71const { Skin } = await import("../globe/texture.ts");7273/** Let the microtasks behind a delivered image run to the next request. */74function settle(): Promise<void> {75 return new Promise((resolve) => { setTimeout(resolve, 0); });76}7778/** Hand over every image asked for, and every image those ask for in turn. */79async function deliverAll(): Promise<void> {80 for (let guard = 0; guard < 200 && parked.length; guard++) {81 const next = parked.shift();82 if (!next) break;83 arriving = next.url;84 next.arrive();85 await settle();86 }87}8889// ---- the pyramid the tests composite ---------------------------------9091const grid: TileGrid = { sourceZoom: 0, firstTile: 0, tileSize: 256, size: 1024 };9293function lensNamed(tiles: string): Lens {94 return {95 name: tiles,96 tiles,97 minZoom: 0,98 maxZoom: 4,99 // `fullZoom: 1` puts the base level at 1, which is four tiles of the100 // declared window -- enough for "interrupted half way" to mean something101 // and few enough to deliver one at a time.102 fullZoom: 1,103 sourceZoom: 0,104 formats: ["png"],105 interpolate: true,106 bounds: { x: 0, y: 0, width: 1024, height: 1024 },107 };108}109110const alpha = lensNamed("alpha");111const beta = lensNamed("beta");112113function urls(tag: string) {114 return (z: number, x: number, y: number): string => `${tag}/${z}/${x}/${y}`;115}116117function fresh() {118 parked.length = 0;119 drawn.length = 0;120 return new Skin({ x: 0, y: 0, width: 1024, height: 1024 }, grid);121}122123const nothing = () => {};124125/** The four tiles of the base level, in the order the window walks them. */126function baseTiles(tag: string): string[] {127 return [`${tag}/1/0/0`, `${tag}/1/0/1`, `${tag}/1/1/0`, `${tag}/1/1/1`];128}129130// ---- the skin is composited once, and kept --------------------------131132test("the base skin arrives whole", async () => {133 const skin = fresh();134 const painting = skin.base("v1", "mars", alpha, urls("alpha"), nothing);135 await settle();136 assert.equal(parked.length, 1, "the base pass is suspended on its first tile");137 await deliverAll();138 await painting;139 assert.deepEqual(drawn, baseTiles("alpha"), "the whole base skin arrived");140 assert.equal(skin.lens, "alpha");141});142143test("a base skin that did arrive is not composited a second time", async () => {144 const skin = fresh();145 const first = skin.base("v1", "mars", alpha, urls("alpha"), nothing);146 await deliverAll();147 await first;148 assert.equal(drawn.length, 4);149150 // Leave, and come back: `show` asks for the same lens again, and the skin151 // is on the texture and is left there. Recompositing it is the expensive152 // half of coming back, and this is why coming back does not pay it.153 await skin.base("v1", "mars", alpha, urls("alpha"), nothing);154 assert.equal(parked.length, 0, "no tile was asked for");155 assert.equal(drawn.length, 4, "and nothing was drawn again");156});157158test("a base skin cancelled by a whole-skin clear is asked for again", async () => {159 const skin = fresh();160 const abandoned = skin.base("v1", "mars", alpha, urls("alpha"), nothing);161 await settle();162 skin.clear();163 await deliverAll();164 await abandoned;165 assert.deepEqual(drawn, [], "the cancelled pass drew nothing after the clear");166167 // The key was never committed, so the very same request repaints.168 const retry = skin.base("v1", "mars", alpha, urls("alpha"), nothing);169 await deliverAll();170 await retry;171 assert.deepEqual(drawn, baseTiles("alpha"));172});173174test("a newer base skin supersedes the one in flight, and it is the one kept", async () => {175 const skin = fresh();176 const stale = skin.base("v1", "mars", alpha, urls("alpha"), nothing);177 await settle();178 const current = skin.base("v1", "mars", beta, urls("beta"), nothing);179 await deliverAll();180 await Promise.all([stale, current]);181 assert.deepEqual(drawn, baseTiles("beta"), "only the newer lens is on the texture");182 assert.equal(skin.lens, "beta");183184 // The lens that was abandoned is not remembered as painted: going back to185 // it composites it, rather than trusting a texture it never finished.186 const back = skin.base("v1", "mars", alpha, urls("alpha"), nothing);187 await deliverAll();188 await back;189 assert.deepEqual(drawn, baseTiles("alpha"));190});