Ctrl K
ComunidadGuías…

src/map-renderer/engine.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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/);
  });
});