src/lib/basemap.test.ts
Rama main · réplica de lectura del repositorio original.
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();
},
);
});