src/map-renderer/engine.test.ts
Rama main · réplica de lectura del repositorio original.
import path from "node:path";
import sharp from "sharp";
import { afterEach, describe, expect, it, vi } from "vitest";
import { basemapOrigins, withLabelLanguage } from "@/lib/basemap";
import { tileCenter, TILE_SIZE } from "@/lib/map-grid";
import { createEngine, PIXEL_RATIO, type NativeModule } from "./engine";
import { parsePayload } from "./payload";
import { createResources } from "./resources";
import { readRendererConfig, tileStyle } from "./style";
// The engine against a stand-in for MapLibre Native, which records what it is
// asked and answers each render as a test says - so a failed render and one
// that never finishes can be had on purpose. The WebP at the end is real.
const SIDE = TILE_SIZE * PIXEL_RATIO;
type Answer = "draw" | "fail" | "hang";
interface FakeMap {
options: { ratio: number; mode: string };
loaded: {
sources: Record<string, unknown>;
layers: { id: string }[];
}[];
renders: {
zoom: number;
center: [number, number];
width: number;
height: number;
}[];
cancelled: number;
released: boolean;
}
function fakeNative(answers: Answer[] = []) {
const maps: FakeMap[] = [];
const native: NativeModule = {
on: () => {},
Map: class {
private state: FakeMap;
private pending?: (error: Error) => void;
constructor(options: FakeMap["options"]) {
this.state = {
options: { ratio: options.ratio, mode: options.mode },
loaded: [],
renders: [],
cancelled: 0,
released: false,
};
maps.push(this.state);
}
load(style: FakeMap["loaded"][number]) {
this.state.loaded.push(style);
}
render(
options: FakeMap["renders"][number],
callback: (error: Error | null, pixels?: Uint8Array) => void,
) {
this.state.renders.push(options);
const answer = answers.shift() ?? "draw";
if (answer === "hang") {
this.pending = callback;
return;
}
setTimeout(() =>
answer === "fail"
? callback(new Error("Failed to load glyphs"))
: callback(null, new Uint8Array(SIDE * SIDE * 4).fill(200)),
);
}
cancel() {
if (!this.pending) throw new Error("No render in progress");
this.state.cancelled += 1;
const pending = this.pending;
this.pending = undefined;
pending(new Error("Canceled"));
}
release() {
if (this.state.released) {
throw new Error("Map resources have already been released");
}
this.state.released = true;
}
} as unknown as NativeModule["Map"],
};
return { native, maps };
}
const config = readRendererConfig({ SITE_URL: "https://dives.example.com" });
const resources = createResources({
siteUrl: config.siteUrl,
publicDir: path.resolve("public"),
origins: basemapOrigins(config.basemap),
});
// The tile over Dahab at the deepest zoom a map is fitted at.
const TILE = parsePayload({
kind: "tile",
theme: "light",
z: 9,
x: 305,
y: 215,
});
let close: (() => void) | undefined;
afterEach(() => close?.());
async function engineWith(answers?: Answer[], deadlineMs?: number) {
const { native, maps } = fakeNative(answers);
const engine = createEngine({
native,
config,
resources,
log: () => {},
deadlineMs,
});
close = () => engine.close();
await vi.waitFor(() => expect(engine.isReady()).toBe(true));
// The two the engine parsed both themes' styles with, to become ready.
expect(maps).toHaveLength(2);
return { engine, maps, drawn: () => maps.slice(2) };
}
describe("the engine", () => {
it("is ready once a map has loaded both themes' styles", async () => {
const { maps } = await engineWith();
expect(maps.every((map) => map.loaded.length === 1)).toBe(true);
expect(maps.every((map) => map.released)).toBe(true);
});
it("draws a 1024x1024 WebP of the tile, once, at its middle and its zoom", async () => {
const { engine, drawn } = await engineWith();
const tile = await engine.draw(TILE);
const { format, width, height } = await sharp(tile).metadata();
expect({ format, width, height }).toEqual({
format: "webp",
width: 1024,
height: 1024,
});
const [map] = drawn();
expect(map.options).toEqual({ ratio: 2, mode: "tile" });
const middle = tileCenter(9, 305, 215);
expect(map.renders).toEqual([
{
zoom: 9,
center: [middle.longitude, middle.latitude],
width: 512,
height: 512,
},
]);
});
// Nothing of any record: the style as a tile has it, with no source or layer
// of this app's own.
it("draws the theme's style as a tile has it, and nothing over it", async () => {
const { engine, maps, drawn } = await engineWith();
await engine.draw({ ...TILE, theme: "dark" });
const [, darkWarm] = maps;
const [style] = drawn()[0].loaded;
expect(style).toEqual(tileStyle(darkWarm.loaded[0] as never, TILE.z));
});
it("labels a tile in its language, and the next one without it in English", async () => {
const { engine, maps, drawn } = await engineWith();
await engine.draw({ ...TILE, language: "ar" });
await engine.draw(TILE);
const [lightWarm] = maps;
const [inArabic, inEnglish] = drawn().map((map) => map.loaded[0]);
const asLoaded = lightWarm.loaded[0] as never;
expect(inArabic).toEqual(
tileStyle(withLabelLanguage(asLoaded, "ar"), TILE.z),
);
expect(inArabic).not.toEqual(inEnglish);
expect(inEnglish).toEqual(tileStyle(asLoaded, TILE.z));
});
it("answers the style behind a draw in that theme and language", async () => {
const { engine, drawn } = await engineWith();
await engine.draw({ ...TILE, theme: "dark", language: "ar" });
await engine.draw(TILE);
const [inArabic, inEnglish] = drawn().map((map) => map.loaded[0]);
const behind = async (theme: "dark" | "light", language?: "ar") =>
tileStyle((await engine.style(theme, language)) as never, TILE.z);
expect(await behind("dark", "ar")).toEqual(inArabic);
expect(await behind("light")).toEqual(inEnglish);
});
// A live map draws this style, and a diver choosing a place reads its labels.
it("keeps the labels of the style it answers at full strength after a draw", async () => {
const { engine, maps } = await engineWith();
await engine.draw(TILE);
const [lightWarm] = maps;
expect(await engine.style("light")).toEqual(lightWarm.loaded[0]);
});
it("draws the whole world with no lettering, and its zoom as it asked", async () => {
const { engine, drawn } = await engineWith();
await engine.draw({ ...TILE, z: 0, x: 0, y: 0 });
const [map] = drawn();
expect(map.renders[0].zoom).toBe(0);
expect(map.loaded[0].layers.length).toBeGreaterThan(0);
expect(map.loaded[0].layers).not.toContainEqual(
expect.objectContaining({ type: "symbol" }),
);
});
it("names the vendored sprite against this instance's origin", async () => {
const { engine } = await engineWith();
expect(await engine.style("light")).toMatchObject({
sprite: expect.stringMatching(/^https:\/\/dives\.example\.com\//),
});
});
// MapLibre Native keeps every source tile a map has drawn, so a map is never
// kept past its tile.
it("draws every tile on a map of its own, and lets it go", async () => {
const { engine, drawn } = await engineWith();
await engine.draw(TILE);
await engine.draw(TILE);
expect(drawn()).toHaveLength(2);
expect(drawn().every((map) => map.released)).toBe(true);
});
it("answers a failed render with an error, and lets its map go", async () => {
const { engine, drawn } = await engineWith(["fail"]);
await expect(engine.draw(TILE)).rejects.toThrow(/glyphs/);
expect(drawn()[0].released).toBe(true);
expect((await engine.draw(TILE)).length).toBeGreaterThan(0);
});
it("cancels and lets go a render that passes its deadline", async () => {
const { engine, drawn } = await engineWith(["hang"], 50);
await expect(engine.draw(TILE)).rejects.toThrow(/deadline/);
const [hung] = drawn();
expect(hung.cancelled).toBe(1);
expect(hung.released).toBe(true);
expect((await engine.draw(TILE)).length).toBeGreaterThan(0);
});
it("is not ready while its basemap cannot load", async () => {
const { native } = fakeNative();
const unreachable = readRendererConfig({
SITE_URL: "https://dives.example.com",
MAP_STYLE_URL: "https://maps.example.invalid/style.json",
MAP_ATTRIBUTION: "© Nobody",
});
const engine = createEngine({
native,
config: unreachable,
resources: createResources({
siteUrl: config.siteUrl,
publicDir: path.resolve("public"),
origins: basemapOrigins(unreachable.basemap),
fetch: async () => new Response("Down", { status: 502 }),
}),
log: () => {},
});
close = () => engine.close();
await new Promise((done) => setTimeout(done, 20));
expect(engine.isReady()).toBe(false);
await expect(engine.draw(TILE)).rejects.toThrow(/502/);
await expect(engine.style("light")).rejects.toThrow(/502/);
});
});