clone

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

feat/atlas-next-horizon
atlas / render / test / visibility.test.ts
visibility.test.ts 8.0K
1// What stands, and why: the rules of `world/visibility.ts` over a small world.2//3// This is the seam's half of the count three surfaces have to agree on, so4// the tests are written as the sentences the rules are: a hidden collection5// takes its pins off the map, a search takes everything it does not name, a6// highlight conjoins across collections and unites within one, a held cell7// narrows the same way — and the selected and the searched are spared by both8// of the last two, because the card open about a feature must not lose it.910import test from "node:test";11import { strict as assert } from "node:assert";12import { EMPTY_SCENE } from "../scene/read.ts";13import type { Scene } from "../scene/read.ts";14import { WorldModel, project } from "../world/model.ts";15import { Visibility } from "../world/visibility.ts";16import type { Collection, TileGrid, WorldPayload } from "../data/payload.ts";17import { LocationTable } from "../data/atlasloc.ts";18import { legacyOpenWorld } from "./legacy.ts";1920const grid: TileGrid = { sourceZoom: 5, firstTile: 0, tileSize: 256, size: 8192 };2122/** Two point collections, one area collection with two areas side by side. */23function payload(): WorldPayload {24 // GeoJSON pairs are [lng, lat], in the volume's own world space: a square25 // anchored at its north-west corner and running east and south.26 const square = (lng: number, lat: number, size: number) => [[27 [lng, lat], [lng + size, lat], [lng + size, lat - size], [lng, lat - size], [lng, lat],28 ]];29 const collections: Collection[] = [30 { id: 1, title: "Shrines", kind: "point", visible: true },31 { id: 2, title: "Chests", kind: "point", visible: true },32 {33 id: 3, title: "Regions", kind: "area", visible: true,34 features: [35 { id: 100, title: "West", geometry: [{ type: "Polygon", coordinates: square(0, 90, 90) }] },36 { id: 101, title: "East", geometry: [{ type: "Polygon", coordinates: square(90, 90, 90) }] },37 ],38 },39 {40 id: 4, title: "Districts", kind: "area", visible: true,41 features: [42 { id: 200, title: "North", geometry: [{ type: "Polygon", coordinates: square(0, 90, 180) }] },43 ],44 },45 ];46 return { lenses: [], collections };47}4849/** Positions are lat/lng in the world's own space; these are far apart. */50const points = [51 { id: 1, owner: 0, lat: 40, lng: 10, member: 0, shard: 0, title: "Alpha" },52 { id: 2, owner: 0, lat: 40, lng: 120, member: 0, shard: 0, title: "Beta" },53 { id: 3, owner: 1, lat: 10, lng: 10, member: 0, shard: 1, title: "Gamma" },54 { id: 4, owner: 1, lat: 10, lng: 120, member: 0, shard: 2, title: "Alpha Two" },55];5657function table(): LocationTable {58 const encoder = new TextEncoder();59 const n = points.length;60 const titles = points.map((point) => encoder.encode(point.title));61 const total = titles.reduce((held, run) => held + run.length, 0);62 const buffer = new ArrayBuffer(20 + 26 * n + total);63 const bytes = new Uint8Array(buffer);64 bytes.set(encoder.encode("ATLASLOC"), 0);65 const header = new DataView(buffer);66 header.setUint16(8, 3, true);67 header.setUint32(10, n, true);68 const id = new Int32Array(buffer, 16, n);69 const lat = new Float32Array(buffer, 16 + 4 * n, n);70 const lng = new Float32Array(buffer, 16 + 8 * n, n);71 const shard = new Int32Array(buffer, 16 + 16 * n, n);72 const offsets = new Uint32Array(buffer, 16 + 20 * n, n + 1);73 const owner = new Uint16Array(buffer, 20 + 24 * n, n);74 let at = 0;75 points.forEach((point, i) => {76 id[i] = point.id; lat[i] = point.lat; lng[i] = point.lng;77 shard[i] = point.shard; owner[i] = point.owner; offsets[i] = at;78 bytes.set(titles[i] ?? new Uint8Array(), 20 + 26 * n + at);79 at += (titles[i] ?? new Uint8Array()).length;80 });81 offsets[n] = at;82 return LocationTable.over(buffer);83}8485const model = new WorldModel(legacyOpenWorld("w", payload(), grid, table()));8687function scene(over: Partial<Scene> = {}): Scene {88 return { ...EMPTY_SCENE, ...over };89}9091test("with nothing filtered, everything stands", () => {92 const standing = new Visibility(model, scene(), 0, null);93 assert.equal(standing.eligible, 4);94 assert.equal(standing.drawn, 4 + 3, "pins and shapes are one count");95 assert.equal(standing.listable, 7);96});9798test("a hidden collection takes its own off the map and out of the count", () => {99 const standing = new Visibility(model, scene({ hidden: new Set(["1"]) }), 0, null);100 assert.equal(standing.eligible, 2);101 assert.deepEqual([...standing.standing()].map((point) => point.title), ["Gamma", "Alpha Two"]);102103 const shapes = new Visibility(model, scene({ hidden: new Set(["3"]) }), 0, null);104 assert.equal(shapes.shapesShown.length, 1, "a hidden area collection is not drawn");105});106107test("a search leaves what it names, and promotes it", () => {108 const standing = new Visibility(model, scene({ search: "alpha" }), 0, null);109 assert.deepEqual([...standing.standing()].map((point) => point.title), ["Alpha", "Alpha Two"]);110 assert.equal(standing.at(0).promoted, true);111 assert.equal(standing.at(1).promoted, false, "a filtered pin is not promoted");112});113114test("a split world offers one layer at a time", () => {115 // Shard 1 admits the unsharded pins and its own; shard 2's belongs116 // elsewhere in the world rather than being merely filtered out.117 const standing = new Visibility(model, scene(), 1, null);118 assert.deepEqual([...standing.standing()].map((point) => point.title),119 ["Alpha", "Beta", "Gamma"]);120});121122test("highlights conjoin across collections and unite within one", () => {123 // West holds Alpha and Gamma; North holds all four. Highlighting West alone124 // leaves the two inside it.125 const west = new Visibility(model, scene({ highlighted: new Set(["100"]) }), 0, null);126 assert.deepEqual([...west.standing()].map((point) => point.title), ["Alpha", "Gamma"]);127128 // West and East together are alternatives within one collection: their129 // union stands.130 const both = new Visibility(model, scene({ highlighted: new Set(["100", "101"]) }), 0, null);131 assert.equal(both.eligible, 4);132133 // West and North are two collections, so they are conditions: the pins134 // inside both. North contains everything, so the answer is West's.135 const across = new Visibility(model, scene({ highlighted: new Set(["100", "200"]) }), 0, null);136 assert.deepEqual([...across.standing()].map((point) => point.title), ["Alpha", "Gamma"]);137 assert.equal(across.focusedPins, 2);138});139140test("the selected and the searched are spared a cull, and nothing else is", () => {141 const spared = new Visibility(142 model, scene({ highlighted: new Set(["100"]), selected: "2" }), 0, null);143 assert.ok([...spared.standing()].some((point) => point.title === "Beta"),144 "the card open about a feature does not lose it");145146 const searched = new Visibility(147 model, scene({ highlighted: new Set(["100"]), search: "beta" }), 0, null);148 assert.deepEqual([...searched.standing()].map((point) => point.title), ["Beta"]);149});150151test("a held cell narrows exactly the way a highlight does", () => {152 const west = project(grid, 0, 0)[0];153 const middle = project(grid, 0, 90)[0];154 const inCell = (at: readonly [number, number]) => at[0] >= west && at[0] < middle;155 const standing = new Visibility(model, scene({ gridCell: "9q" }), 0, inCell);156 assert.deepEqual([...standing.standing()].map((point) => point.title), ["Alpha", "Gamma"]);157 assert.equal(standing.priorityPins, 2, "what the cell holds is what it counts");158 assert.equal(standing.at(0).promoted, true, "a pin in the held cell is promoted");159});160161test("a pin's place in the crowd is stable and rarity-first", () => {162 const shrines = model.points.filter((point) => point.collection.id === "1");163 const chests = model.points.filter((point) => point.collection.id === "2");164 // Equal-sized collections rank equally by rarity, so the tie-break is the165 // id's own hash — and it is the same on every run.166 assert.equal(shrines.length, 2);167 assert.equal(chests.length, 2);168 const again = new WorldModel(legacyOpenWorld("w", payload(), grid, table()));169 assert.deepEqual(again.points.map((point) => point.priority),170 model.points.map((point) => point.priority));171});