Ctrl K
ComunidadGuías…

src/lib/basemap.test.ts

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

Archivo original ↗Preguntar a KiBot
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { isDeepStrictEqual as equal } from "node:util";
import { afterEach, describe, expect, it, vi } from "vitest";

import {
  basemapOrigins,
  basemapStyle,
  clampLatitude,
  DEFAULT_BASEMAP_ATTRIBUTION,
  DEFAULT_BASEMAP_ORIGIN,
  DEFAULT_STYLE_URL,
  DEFAULT_STYLE_URL_DARK,
  DEFAULT_TILE_ATTRIBUTION,
  ENGLISH_NAME,
  labelLanguage,
  MAX_FIT_ZOOM,
  MAX_LATITUDE,
  MAX_ZOOM,
  MIN_ZOOM,
  MISSING_ATTRIBUTION_MESSAGE,
  rasterStyle,
  resolveBasemap,
  unionBounds,
  withRatioToken,
  withLabelLanguage,
  WORLD_CENTER,
  wrapLongitude,
} from "./basemap";

// The slippy figures these replace. Written out rather than imported now that
// the module holding them has gone with the hand-rolled renderer - they are a
// fact about the two conventions rather than about any code that is left.
const SLIPPY_MIN_ZOOM = 1;
const SLIPPY_MAX_ZOOM = 18;
const SLIPPY_MAX_FIT_ZOOM = 10;
// `PLACED_ZOOM` in `components/map/map-picker.tsx`, which never lived here.
const SLIPPY_PLACED_ZOOM = 12;

afterEach(() => {
  vi.restoreAllMocks();
});

describe("resolveBasemap", () => {
  it("ships the bundled pair when nothing is configured", () => {
    expect(resolveBasemap()).toEqual({
      mode: "vector",
      vendored: true,
      light: DEFAULT_STYLE_URL,
      dark: DEFAULT_STYLE_URL_DARK,
      attribution: DEFAULT_BASEMAP_ATTRIBUTION,
    });
  });

  it("takes a configured style, and falls its dark back to the light one", () => {
    expect(
      resolveBasemap({
        styleUrl: "https://styles.example/day.json",
        attribution: "© Someone",
      }),
    ).toEqual({
      mode: "vector",
      vendored: false,
      light: "https://styles.example/day.json",
      // "Use my style", not "use mine by day and a stranger's by night".
      dark: "https://styles.example/day.json",
      attribution: "© Someone",
    });
  });

  // The whole of the escape hatch: raster stays configurable and the app renders
  // it through the same renderer, so nothing about the two documented keyed
  // blocks has to change.
  it("wraps a raster template, substituting the key into both", () => {
    expect(
      resolveBasemap({
        tileUrl: "https://tiles.example/light/{z}/{x}/{y}{r}.png?api_key={key}",
        tileUrlDark:
          "https://tiles.example/dark/{z}/{x}/{y}{r}.png?api_key={key}",
        apiKey: "s3cret",
      }),
    ).toMatchObject({
      mode: "raster",
      vendored: false,
      light: "https://tiles.example/light/{z}/{x}/{y}{r}.png?api_key=s3cret",
      dark: "https://tiles.example/dark/{z}/{x}/{y}{r}.png?api_key=s3cret",
    });
  });

  it("lets a style win outright, leaving the raster template inert", () => {
    const basemap = resolveBasemap({
      styleUrl: "https://styles.example/day.json",
      tileUrl: "https://tiles.example/{z}/{x}/{y}.png",
      attribution: "© Someone",
    });
    expect(basemap.mode).toBe("vector");
    expect(basemap.light).toBe("https://styles.example/day.json");
  });

  // The three states the refusal has to tell apart. Getting this wrong in the
  // permissive direction ships a false licence credit; getting it wrong in the
  // strict direction takes the whole app down on a stock configuration, which is
  // how a cross-field validator once killed the API repo's test collection and
  // its compose stack at the same time.
  describe("when a style is configured without a credit", () => {
    it("refuses, naming both variables", () => {
      expect(() =>
        resolveBasemap({ styleUrl: "https://styles.example/day.json" }),
      ).toThrow(MISSING_ATTRIBUTION_MESSAGE);
      expect(MISSING_ATTRIBUTION_MESSAGE).toContain("MAP_STYLE_URL");
      expect(MISSING_ATTRIBUTION_MESSAGE).toContain("MAP_ATTRIBUTION");
    });

    it("stays silent for the unconfigured default", () => {
      expect(() => resolveBasemap()).not.toThrow();
      expect(resolveBasemap().attribution).toBe(DEFAULT_BASEMAP_ATTRIBUTION);
    });

    it("stays silent for a raster template, which keeps its own default", () => {
      const basemap = resolveBasemap({
        tileUrl: "https://tiles.example/{z}/{x}/{y}.png",
      });
      expect(basemap.attribution).toContain("OpenStreetMap");
    });
  });

  // A dark style with no light one would mix this app's cartography with
  // somebody else's on a theme toggle, and credit one of them for both.
  it("ignores a dark style URL set on its own, and says so", () => {
    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
    const basemap = resolveBasemap({
      styleUrlDark: "https://styles.example/night.json",
    });
    expect(basemap.vendored).toBe(true);
    expect(basemap.dark).toBe(DEFAULT_STYLE_URL_DARK);
    expect(warn).toHaveBeenCalledWith(
      expect.stringContaining("MAP_STYLE_URL_DARK"),
    );
  });
});

describe("withRatioToken", () => {
  // This app's templates have always written `{r}`, and both keyed blocks in
  // `.env.example` are `{z}/{x}/{y}{r}.png`. Handed to MapLibre unchanged they
  // would request a literal `{r}` and 404 - breaking precisely the two
  // configurations the escape hatch exists to preserve.
  it("renames the density placeholder to MapLibre's spelling", () => {
    expect(
      withRatioToken(
        "https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png",
      ),
    ).toBe(
      "https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{ratio}.png",
    );
  });

  it("leaves a template without one alone", () => {
    const template = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
    expect(withRatioToken(template)).toBe(template);
  });
});

describe("rasterStyle", () => {
  // The style spec's default is 512, and every template this app documents
  // serves 256 - taking the default would draw each tile over four tiles' worth
  // of ground.
  it("states the 256px tile size rather than taking the spec's default", () => {
    const style = rasterStyle("https://tiles.example/{z}/{x}/{y}.png");
    expect(style.sources.basemap).toMatchObject({
      type: "raster",
      tileSize: 256,
    });
  });

  it("renames the density placeholder on the way in", () => {
    const style = rasterStyle("https://tiles.example/{z}/{x}/{y}{r}.png");
    expect(style.sources.basemap).toMatchObject({
      tiles: ["https://tiles.example/{z}/{x}/{y}{ratio}.png"],
    });
  });

  it("draws the source it declares", () => {
    const style = rasterStyle("https://tiles.example/{z}/{x}/{y}.png");
    expect(style.layers).toEqual([
      { id: "basemap", type: "raster", source: "basemap" },
    ]);
  });
});

describe("basemapOrigins", () => {
  // The bundled styles are same-origin documents, but everything they name is
  // not, and a host the renderer contacts without the policy naming it fails as
  // a blocked request with a console line nobody reads.
  it("names the provider the bundled styles fetch from", () => {
    expect(basemapOrigins(resolveBasemap())).toEqual([DEFAULT_BASEMAP_ORIGIN]);
  });

  it("names a configured style's own origin, once for a matching pair", () => {
    expect(
      basemapOrigins(
        resolveBasemap({
          styleUrl: "https://styles.example/day.json",
          styleUrlDark: "https://styles.example/night.json",
          attribution: "© Someone",
        }),
      ),
    ).toEqual(["https://styles.example"]);
  });

  it("names both hosts of a raster pair", () => {
    expect(
      basemapOrigins(
        resolveBasemap({
          tileUrl: "https://a.example/{z}/{x}/{y}.png",
          tileUrlDark: "https://b.example/{z}/{x}/{y}.png",
        }),
      ),
    ).toEqual(["https://a.example", "https://b.example"]);
  });

  // Failing closed keeps middleware up - this runs in the request path, and a
  // typo in an optional map's variable must not take the site down.
  it("drops a malformed value, and names the variable while doing it", () => {
    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
    expect(
      basemapOrigins({
        mode: "vector",
        vendored: false,
        light: "htp://styles.example/day.json",
        dark: "htp://styles.example/day.json",
        attribution: "© Someone",
      }),
    ).toEqual([]);
    // "htp://" parses happily as a non-special scheme and yields the opaque
    // origin "null", which browsers discard with nothing said about why.
    expect(warn).toHaveBeenCalled();
  });

  // The one shape that is not a mistake: a style an operator serves themselves,
  // which `'self'` already covers.
  it("says nothing about a same-origin style", () => {
    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
    expect(
      basemapOrigins({
        mode: "vector",
        vendored: false,
        light: "/my-style.json",
        dark: "/my-style.json",
        attribution: "© Someone",
      }),
    ).toEqual([]);
    expect(warn).not.toHaveBeenCalled();
  });
});

describe("basemapStyle", () => {
  it("hands a configured style over as a URL, for MapLibre to fetch", async () => {
    const basemap = resolveBasemap({
      styleUrl: "https://styles.example/day.json",
      styleUrlDark: "https://styles.example/night.json",
      attribution: "© Someone",
    });
    await expect(basemapStyle(basemap, "light")).resolves.toBe(
      "https://styles.example/day.json",
    );
    await expect(basemapStyle(basemap, "dark")).resolves.toBe(
      "https://styles.example/night.json",
    );
  });

  it("builds a document around a raster template", async () => {
    const basemap = resolveBasemap({
      tileUrl: "https://tiles.example/{z}/{x}/{y}.png",
    });
    await expect(basemapStyle(basemap, "light")).resolves.toMatchObject({
      version: 8,
    });
  });

  // MapLibre runs a style's `sprite` through `new URL(value)` with no base and
  // throws "must be absolute" on anything else - unlike a style's sources, which
  // it does resolve. So the bundled pair, whose sprite travels with it, has to be
  // fetched and resolved here; the absolute form cannot be baked into the file
  // because a published image runs on whatever domain it is given.
  it("resolves the bundled style's relative sprite against this origin", async () => {
    vi.spyOn(globalThis, "fetch").mockResolvedValue(
      new Response(
        JSON.stringify({
          version: 8,
          sprite: "/basemap/sprite/ofm",
          sources: {},
          layers: [],
        }),
      ),
    );
    const style = await basemapStyle(resolveBasemap(), "light");
    expect(typeof style).not.toBe("string");
    expect((style as { sprite: string }).sprite).toBe(
      `${window.location.origin}/basemap/sprite/ofm`,
    );
  });

  it("says which file it could not read", async () => {
    vi.spyOn(globalThis, "fetch").mockResolvedValue(
      new Response("", { status: 404, statusText: "Not Found" }),
    );
    await expect(basemapStyle(resolveBasemap(), "dark")).rejects.toThrow(
      DEFAULT_STYLE_URL_DARK,
    );
  });
});

// The styles are vendored, so a re-vendor is a file swap with no code change to
// review - which is exactly when a second host slips into `connect-src` unnoticed
// and the map half-renders in production with a console line nobody reads. This
// reads what is actually shipped rather than trusting the constant.
describe("the bundled styles", () => {
  const shipped = (file: string) =>
    JSON.parse(readFileSync(`public/basemap/${file}`, "utf8"));

  it.each([
    ["liberty.json", DEFAULT_STYLE_URL],
    ["dark.json", DEFAULT_STYLE_URL_DARK],
  ])("%s is the file the default points at", (file, url) => {
    expect(url).toBe(`/basemap/${file}`);
    expect(shipped(file).version).toBe(8);
  });

  it.each(["liberty.json", "dark.json"])(
    "%s fetches from no origin the policy does not name",
    (file) => {
      const style = shipped(file);
      const absolute = JSON.stringify(style).match(/https?:\/\/[^"']+/g) ?? [];
      const origins = new Set(absolute.map((url) => new URL(url).origin));
      expect([...origins]).toEqual([DEFAULT_BASEMAP_ORIGIN]);
    },
  );

  it.each(["liberty.json", "dark.json"])(
    "%s points at the sprite set vendored beside it",
    (file) => {
      expect(shipped(file).sprite).toBe("/basemap/sprite/ofm");
    },
  );

  // What makes a place read the same in both themes: every label's text, case,
  // size and zoom range lives outside `paint`.
  it("dark.json is liberty.json with only its paint changed", () => {
    const withoutPaint = (style: { layers: Record<string, unknown>[] }) => ({
      ...style,
      layers: style.layers.map((layer) =>
        Object.fromEntries(
          Object.entries(layer).filter(([key]) => key !== "paint"),
        ),
      ),
    });
    expect(withoutPaint(shipped("dark.json"))).toEqual(
      withoutPaint(shipped("liberty.json")),
    );
  });

  // `--map-water` stands in for the map where there is no place to draw, so it
  // has to be the colour the map draws its water in, in each theme.
  it("share their water with the theme's --map-water tokens", () => {
    const channels = (color: string) => color.match(/\d+/g)?.join(",");
    const css = readFileSync("src/app/globals.css", "utf8");
    const tokens = (name: string) =>
      [...css.matchAll(new RegExp(`--${name}: ([^;]+);`, "g"))].map(([, v]) =>
        channels(v),
      );
    const paint = (file: string, id: string, key: string) =>
      channels(
        shipped(file).layers.find((layer: { id: string }) => layer.id === id)
          .paint[key],
      );
    const files = ["liberty.json", "dark.json"];
    expect(tokens("map-water")).toEqual(
      files.map((file) => paint(file, "water", "fill-color")),
    );
    expect(tokens("map-water-foreground")).toEqual(
      files.map((file) => paint(file, "water_name_point_label", "text-color")),
    );
  });

  // A re-vendored Liberty that skipped the script fails here whatever upstream
  // changed, paint included: its water is Liberty's own again.
  it("are what scripts/generate-basemaps.mjs makes of them", () => {
    expect(() =>
      execFileSync(
        process.execPath,
        ["scripts/generate-basemaps.mjs", "--check"],
        { stdio: "pipe" },
      ),
    ).not.toThrow();
  });
});

// MapLibre measures zoom against a 512px tile, the slippy convention against
// 256, so the same view is one number lower here. Carried across as the same
// integers these would each open one level too deep - and nothing about the
// rendered map would say so, which is why the relationship is asserted rather
// than described.
describe("zoom, against the numbers it replaces", () => {
  it.each([
    ["MIN_ZOOM", MIN_ZOOM, SLIPPY_MIN_ZOOM],
    ["MAX_ZOOM", MAX_ZOOM, SLIPPY_MAX_ZOOM],
    ["MAX_FIT_ZOOM", MAX_FIT_ZOOM, SLIPPY_MAX_FIT_ZOOM],
  ])("%s is one level below the slippy figure", (_name, maplibre, slippy) => {
    expect(maplibre).toBe(slippy - 1);
  });

  // The picker's own opening zoom is the fourth of these, and it is the one that
  // has to stay *deeper* than the read-only map's cap: a dive site's map opens
  // further out than the picker that placed its pin, and neither number says so
  // on its own.
  it("keeps a placed pin deeper than a lone place is fitted", () => {
    expect(SLIPPY_PLACED_ZOOM - 1).toBeGreaterThan(MAX_FIT_ZOOM);
    expect(MAX_FIT_ZOOM).toBeGreaterThan(MIN_ZOOM);
    expect(MAX_ZOOM).toBeGreaterThan(SLIPPY_PLACED_ZOOM - 1);
  });
});

// The coordinate folds, which arrived here with the hand-rolled renderer's
// arithmetic and outlived it. MapLibre clamps its own camera and would happily
// report a longitude of 542 after a pan; what these protect is the *emitted*
// pair, whose range has to stay a strict subset of what the form's own parser
// and the API accept.
describe("wrapLongitude", () => {
  it("leaves an in-range longitude alone", () => {
    expect(wrapLongitude(34.5372)).toBeCloseTo(34.5372, 10);
    expect(wrapLongitude(-179.9)).toBeCloseTo(-179.9, 10);
  });

  it("folds a longitude that has run past the antimeridian", () => {
    // Panning east from 179 degrees by 3 arrives at 178W, not at 182E.
    expect(wrapLongitude(182)).toBeCloseTo(-178, 10);
    expect(wrapLongitude(-182)).toBeCloseTo(178, 10);
    expect(wrapLongitude(540)).toBeCloseTo(180 - 360, 10);
  });

  it("settles the two names for the antimeridian on one", () => {
    expect(wrapLongitude(180)).toBe(-180);
    expect(wrapLongitude(-180)).toBe(-180);
  });

  it("is idempotent", () => {
    for (const longitude of [0, 34.5372, 182, -182, 359.9, -540]) {
      const once = wrapLongitude(longitude);
      expect(wrapLongitude(once)).toBeCloseTo(once, 10);
    }
  });
});

describe("clampLatitude", () => {
  it("cuts at the latitudes Mercator can represent", () => {
    expect(clampLatitude(89)).toBe(MAX_LATITUDE);
    expect(clampLatitude(-89)).toBe(-MAX_LATITUDE);
    expect(clampLatitude(28.5717)).toBe(28.5717);
  });

  // Web Mercator is undefined at the poles and conventionally cut where the
  // projected world becomes square. Anything the fold produces is a latitude the
  // API's own bound accepts, which is the property that matters here.
  it("stays inside the range the API accepts", () => {
    expect(MAX_LATITUDE).toBeLessThan(90);
    expect(MAX_LATITUDE).toBeGreaterThan(85);
  });
});

describe("WORLD_CENTER", () => {
  // Shared by both surfaces so the two open on the same view by construction,
  // rather than on two literals that agree until somebody edits one.
  it("is a little north of the equator, where the diving is", () => {
    expect(WORLD_CENTER).toEqual({ latitude: 20, longitude: 0 });
  });
});

describe("DEFAULT_TILE_ATTRIBUTION", () => {
  // The raster escape hatch's fallback credit, and it has to stay parseable as
  // `[label](url)` - a licence link nobody can follow is not much of a credit.
  it("credits OpenStreetMap with a link to the licence", () => {
    expect(DEFAULT_TILE_ATTRIBUTION).toBe(
      "[© OpenStreetMap contributors](https://www.openstreetmap.org/copyright)",
    );
    expect(
      resolveBasemap({ tileUrl: "https://tiles.example/{z}/{x}/{y}.png" })
        .attribution,
    ).toBe(DEFAULT_TILE_ATTRIBUTION);
  });
});

describe("unionBounds", () => {
  it("has nothing to say about no places", () => {
    expect(unionBounds([])).toBeNull();
  });

  // **Three, not two.** MapLibre repairs a single finished box on its own -
  // `cameraForBounds` calls `adjustAntiMeridian()` - so a two-point check passes
  // whether or not this function survived the move off the hand-rolled renderer.
  // Fiji and Samoa are the recorded case: about six degrees of ocean, not the
  // 354 going the other way round the planet.
  it("keeps three places either side of the antimeridian six degrees apart", () => {
    const union = unionBounds([
      { south: -18.1, north: -18.1, west: 178.4, east: 178.4 }, // Suva, Fiji
      { south: -16.8, north: -16.8, west: 179.9, east: 179.9 }, // Taveuni, Fiji
      { south: -13.8, north: -13.8, west: -171.8, east: -171.8 }, // Apia, Samoa
    ])!;
    expect(union.east - union.west).toBeCloseTo(9.8, 5);
    // The eastern place is expressed in the copy of the world next to the
    // others rather than 350 degrees away from them.
    expect(union.east).toBeCloseTo(188.2, 5);
  });

  it("reads a box that crosses the antimeridian as one interval", () => {
    const union = unionBounds([
      { south: -18, north: -16, west: 179, east: -179 },
    ])!;
    expect(union.east - union.west).toBeCloseTo(2, 5);
  });

  it("gives back the whole world rather than an extent wider than one", () => {
    const union = unionBounds([
      { south: -60, north: 60, west: -170, east: 170 },
      { south: -60, north: 60, west: 170, east: -170 },
    ])!;
    expect(union.west).toBe(-180);
    expect(union.east).toBe(180);
  });

  it("takes the outer edges of overlapping boxes", () => {
    const union = unionBounds([
      { south: 9.4, north: 11.3, west: 123.3, east: 124.1 },
      { south: 8.0, north: 10.0, west: 123.8, east: 125.0 },
    ])!;
    expect(union).toMatchObject({ south: 8.0, north: 11.3 });
    // `toBeCloseTo` on the longitudes: they go out and back through the [-180,
    // 180) fold, which is exact in degrees and not in binary.
    expect(union.west).toBeCloseTo(123.3, 5);
    expect(union.east).toBeCloseTo(125.0, 5);
  });
});

describe("withLabelLanguage", () => {
  const vendored = (file: string) =>
    JSON.parse(readFileSync(`public/basemap/${file}`, "utf8"));
  const field = (layer: { layout?: Record<string, unknown> }) =>
    layer.layout?.["text-field"];

  it.each(["liberty.json", "dark.json"])(
    "%s labels each name in English alone",
    (file) => {
      const fields = vendored(file).layers.map(field).filter(Boolean);
      expect(
        fields.filter((f: unknown) => equal(f, ENGLISH_NAME)),
      ).toHaveLength(20);
      expect(JSON.stringify(fields)).not.toContain("name:nonlatin");
    },
  );

  it("leaves a style without a language as it is", () => {
    const style = vendored("liberty.json");
    expect(withLabelLanguage(style, undefined)).toBe(style);
  });

  it("adds the language's name under a point label and beside a line label", () => {
    const style = vendored("liberty.json");
    const labelled = withLabelLanguage(style, "de");
    const byId = (id: string) =>
      labelled.layers.find((layer) => layer.id === id) as {
        layout: Record<string, unknown>;
      };
    const second = (separator: string) => [
      "let",
      "english",
      ENGLISH_NAME,
      [
        "case",
        [
          "all",
          ["has", "name:de"],
          ["!=", ["get", "name:de"], ["var", "english"]],
        ],
        ["concat", ["var", "english"], separator, ["get", "name:de"]],
        ["var", "english"],
      ],
    ];
    expect(field(byId("label_city"))).toEqual(second("\n"));
    expect(field(byId("highway-name-major"))).toEqual(second(" "));
  });

  // The language changes what a label says and nothing else - and never the
  // style a later draw in another language starts from.
  it("changes the name labels' text and nothing else, in a copy", () => {
    const style = vendored("liberty.json");
    const before = structuredClone(style);
    const labelled = withLabelLanguage(style, "ar");
    expect(style).toEqual(before);

    const withoutText = (s: typeof style) =>
      s.layers.map(
        ({ layout, ...layer }: { layout?: Record<string, unknown> }) => ({
          ...layer,
          layout: { ...layout, "text-field": undefined },
        }),
      );
    expect(withoutText(labelled)).toEqual(withoutText(style));
    expect({ ...labelled, layers: [] }).toEqual({ ...style, layers: [] });

    const changed = labelled.layers.filter(
      (layer, i) => !equal(field(layer as never), field(style.layers[i])),
    );
    expect(changed).toHaveLength(20);
    // Route shields keep their number.
    expect(
      field(labelled.layers.find((l) => l.id === "road_shield_us") as never),
    ).toEqual(["to-string", ["get", "ref"]]);
  });

  it("leaves a style whose labels it does not know alone", () => {
    const style = {
      version: 8 as const,
      sources: {},
      layers: [
        {
          id: "place",
          type: "symbol" as const,
          source: "openmaptiles",
          layout: { "text-field": ["get", "name"] },
        },
      ],
    };
    expect(withLabelLanguage(style as never, "de")).toEqual(style);
  });

  // Unchecked against any list on a live map, so the field name is all a
  // language can become.
  it.each([["de-DE"], ["DE"], ['x"]'], ["deutsch"], [""]])(
    "leaves the style as it is for %j, which is no bare subtag",
    (language) => {
      const style = vendored("liberty.json");
      expect(withLabelLanguage(style, language)).toBe(style);
    },
  );
});

describe("labelLanguage", () => {
  it.each([
    ["de-DE", "de"],
    ["de", "de"],
    ["zh-Hant-TW", "zh"],
    ["pt_BR", "pt"],
    ["FR-ca", "fr"],
    ["nb-NO", "no"],
    ["nn", "no"],
    ["xx-YY", "xx"],
  ])("reads %s as %s", (tag, language) => {
    expect(labelLanguage(tag)).toBe(language);
  });

  it.each([["en"], ["en-GB"], ["EN-us"], [""], [undefined]])(
    "reads %j as English alone",
    (tag) => {
      expect(labelLanguage(tag)).toBeUndefined();
    },
  );
});