Spaces:
Configuration error
Configuration error
| import { describe, expect, test } from "vitest"; | |
| import { SATELLITE_COMPONENTS } from "../../config/components"; | |
| import { | |
| boolean, | |
| closedStringList, | |
| decode, | |
| encode, | |
| enumString, | |
| groundStationList, | |
| layerList, | |
| plainString, | |
| stringList, | |
| tildeEscapedStringList, | |
| timestamp, | |
| type FieldSpec, | |
| } from "./urlCodec"; | |
| const components = () => SATELLITE_COMPONENTS as readonly string[]; | |
| // The wire form the ADR pins. Production hands the parameter map to vue-router, | |
| // whose serializer matches this; asserting on it here keeps the format honest. | |
| const queryString = (params: Readonly<Record<string, string>>): string => { | |
| const query = new URLSearchParams(params).toString().replaceAll("%2C", ","); | |
| return query === "" ? "" : `?${query}`; | |
| }; | |
| const providers = () => ["NaturalEarth", "ArcGis", "VersaTiles", "OSM", "BlackMarble", "Tiles", "GOES-IR", "Nextrad"]; | |
| describe("boolean", () => { | |
| const kind = boolean(); | |
| test("parses both literals", () => { | |
| expect(kind.parse("true")).toEqual({ ok: true, value: true }); | |
| expect(kind.parse("false")).toEqual({ ok: true, value: false }); | |
| }); | |
| // The defect this kind exists to fix: with no deserializer, "false" reached | |
| // the store as a truthy string and switched the fps counter on. | |
| test("false is false, not a truthy string", () => { | |
| const parsed = kind.parse("false"); | |
| expect(parsed.ok && parsed.value).toBe(false); | |
| expect(parsed.ok && typeof parsed.value).toBe("boolean"); | |
| }); | |
| test("rejects anything else", () => { | |
| expect(kind.parse("1").ok).toBe(false); | |
| expect(kind.parse("").ok).toBe(false); | |
| expect(kind.parse("TRUE").ok).toBe(false); | |
| }); | |
| }); | |
| describe("enumString", () => { | |
| const kind = enumString(["None", "Maptiler"]); | |
| test("accepts members and rejects non-members", () => { | |
| expect(kind.parse("Maptiler")).toEqual({ ok: true, value: "Maptiler" }); | |
| expect(kind.parse("Garbage").ok).toBe(false); | |
| }); | |
| test("refuses to format a non-member", () => { | |
| expect(kind.format("Garbage").ok).toBe(false); | |
| }); | |
| }); | |
| describe("stringList", () => { | |
| const kind = stringList(); | |
| test("splits and joins on commas, dropping empties", () => { | |
| expect(kind.parse("Weather,Stations")).toEqual({ ok: true, value: ["Weather", "Stations"] }); | |
| expect(kind.parse("Weather,,Stations")).toEqual({ ok: true, value: ["Weather", "Stations"] }); | |
| expect(kind.parse("")).toEqual({ ok: true, value: [] }); | |
| expect(kind.format(["Weather", "Stations"])).toEqual({ ok: true, value: "Weather,Stations" }); | |
| }); | |
| test("spaces need no escaping — the caller's query reader handles them", () => { | |
| expect(kind.parse("Deep Space")).toEqual({ ok: true, value: ["Deep Space"] }); | |
| expect(kind.format(["Deep Space"])).toEqual({ ok: true, value: "Deep Space" }); | |
| }); | |
| // The whole point of dropping the "-" escape from tags. | |
| test("a hyphen in a member survives a round trip", () => { | |
| const formatted = kind.format(["X-Ray"]); | |
| expect(formatted).toEqual({ ok: true, value: "X-Ray" }); | |
| expect(kind.parse("X-Ray")).toEqual({ ok: true, value: ["X-Ray"] }); | |
| }); | |
| test("refuses a member containing the separator instead of corrupting it", () => { | |
| expect(kind.format(["a,b"]).ok).toBe(false); | |
| }); | |
| }); | |
| describe("tildeEscapedStringList (sats / xsats legacy shim)", () => { | |
| const kind = tildeEscapedStringList(); | |
| test("reads the legacy escaped form", () => { | |
| expect(kind.parse("NOAA~19,METOP-C")).toEqual({ ok: true, value: ["NOAA 19", "METOP-C"] }); | |
| }); | |
| test("reads the new unescaped form", () => { | |
| expect(kind.parse("NOAA 19,METOP-C")).toEqual({ ok: true, value: ["NOAA 19", "METOP-C"] }); | |
| }); | |
| test("emits unescaped", () => { | |
| expect(kind.format(["NOAA 19"])).toEqual({ ok: true, value: "NOAA 19" }); | |
| }); | |
| test("hyphens are untouched in both directions", () => { | |
| expect(kind.parse("STARLINK-1007")).toEqual({ ok: true, value: ["STARLINK-1007"] }); | |
| expect(kind.format(["STARLINK-1007"])).toEqual({ ok: true, value: "STARLINK-1007" }); | |
| }); | |
| test("refuses a literal tilde, which the shim would read back as a space", () => { | |
| expect(kind.format(["FOO~BAR"]).ok).toBe(false); | |
| }); | |
| }); | |
| describe("closedStringList (elements)", () => { | |
| const kind = closedStringList(components); | |
| test("reads the legacy hyphen-escaped form", () => { | |
| expect(kind.parse("Point,Label,Sensor-cone,Ground-track")).toEqual({ | |
| ok: true, | |
| value: ["Point", "Label", "Sensor cone", "Ground track"], | |
| }); | |
| }); | |
| test("reads the new form", () => { | |
| expect(kind.parse("Point,Label,Sensor cone")).toEqual({ ok: true, value: ["Point", "Label", "Sensor cone"] }); | |
| }); | |
| test("emits unescaped", () => { | |
| expect(kind.format(["Point", "Label", "Sensor cone"])).toEqual({ ok: true, value: "Point,Label,Sensor cone" }); | |
| }); | |
| test("drops an unknown member and keeps the rest", () => { | |
| expect(kind.parse("Point,Retired thing,Label")).toEqual({ ok: true, value: ["Point", "Label"] }); | |
| }); | |
| // The literal is tried before the shim, which is what makes a hyphenated | |
| // component name possible rather than a breaking change. | |
| test("prefers a literal match over the legacy shim", () => { | |
| const withHyphen = () => [...components(), "X-Ray"]; | |
| const hyphenAware = closedStringList(withHyphen); | |
| expect(hyphenAware.parse("X-Ray")).toEqual({ ok: true, value: ["X-Ray"] }); | |
| // and the escaped form of a real component still resolves | |
| expect(hyphenAware.parse("Sensor-cone")).toEqual({ ok: true, value: ["Sensor cone"] }); | |
| }); | |
| test("refuses to format an unknown component", () => { | |
| expect(kind.format(["Retired thing"]).ok).toBe(false); | |
| }); | |
| // Nothing left to keep, so the element rule gives way and the default selection | |
| // stands. A stale link should leave a satellite drawn as something. | |
| test("rejects the parameter when no member is known", () => { | |
| expect(kind.parse("Retired thing").ok).toBe(false); | |
| expect(kind.parse("Retired thing,Another one").ok).toBe(false); | |
| }); | |
| test("an empty value still means no components", () => { | |
| expect(kind.parse("")).toEqual({ ok: true, value: [] }); | |
| }); | |
| }); | |
| describe("layerList", () => { | |
| const kind = layerList(providers); | |
| test("keeps the alpha suffix", () => { | |
| expect(kind.parse("ArcGis_0.5,Nextrad")).toEqual({ ok: true, value: ["ArcGis_0.5", "Nextrad"] }); | |
| expect(kind.format(["ArcGis_0.5", "Nextrad"])).toEqual({ ok: true, value: "ArcGis_0.5,Nextrad" }); | |
| }); | |
| // An unusable alpha reached Cesium as NaN and rendered an invisible layer. | |
| test("drops a layer whose opacity is not a usable number", () => { | |
| expect(kind.parse("OSM_abc,ArcGis")).toEqual({ ok: true, value: ["ArcGis"] }); | |
| expect(kind.parse("OSM_5,ArcGis")).toEqual({ ok: true, value: ["ArcGis"] }); | |
| expect(kind.parse("OSM_,ArcGis")).toEqual({ ok: true, value: ["ArcGis"] }); | |
| }); | |
| test("0 and 1 are usable opacities", () => { | |
| expect(kind.parse("OSM_0,ArcGis_1")).toEqual({ ok: true, value: ["OSM_0", "ArcGis_1"] }); | |
| }); | |
| test("refuses to format an unusable opacity", () => { | |
| expect(kind.format(["OSM_abc"]).ok).toBe(false); | |
| }); | |
| test("a hyphenated provider name is fine", () => { | |
| expect(kind.parse("GOES-IR")).toEqual({ ok: true, value: ["GOES-IR"] }); | |
| }); | |
| test("drops an unknown provider and keeps the rest", () => { | |
| expect(kind.parse("ArcGis,Bogus,Nextrad")).toEqual({ ok: true, value: ["ArcGis", "Nextrad"] }); | |
| }); | |
| // Reproduced from `?layers=Bogus`, which dropped the one member it had and | |
| // opened a blue globe with no imagery on it. | |
| test("rejects the parameter when nothing it names is usable", () => { | |
| expect(kind.parse("Bogus").ok).toBe(false); | |
| expect(kind.parse("Bogus,OSM_abc").ok).toBe(false); | |
| }); | |
| test("an empty value still means no layers", () => { | |
| expect(kind.parse("")).toEqual({ ok: true, value: [] }); | |
| }); | |
| }); | |
| describe("groundStationList", () => { | |
| const kind = groundStationList(); | |
| test("round-trips a named and an unnamed station", () => { | |
| const raw = "48.1377,11.5754,Munich_-33.8688,151.2093"; | |
| expect(kind.parse(raw)).toEqual({ | |
| ok: true, | |
| value: [ | |
| { lat: 48.1377, lon: 11.5754, name: "Munich" }, | |
| { lat: -33.8688, lon: 151.2093 }, | |
| ], | |
| }); | |
| const formatted = kind.format([ | |
| { lat: 48.1377, lon: 11.5754, name: "Munich" }, | |
| { lat: -33.8688, lon: 151.2093 }, | |
| ]); | |
| expect(formatted).toEqual({ ok: true, value: raw }); | |
| }); | |
| // Zero is a real coordinate. The filter this replaces tested `lat && lon`. | |
| test("keeps a station on the equator and the prime meridian", () => { | |
| expect(kind.parse("0,11.5")).toEqual({ ok: true, value: [{ lat: 0, lon: 11.5 }] }); | |
| expect(kind.parse("48.1,0")).toEqual({ ok: true, value: [{ lat: 48.1, lon: 0 }] }); | |
| expect(kind.format([{ lat: 0, lon: 0 }])).toEqual({ ok: true, value: "0.0000,0.0000" }); | |
| }); | |
| test("drops a malformed station and keeps the valid ones", () => { | |
| expect(kind.parse("48.1,11.5_garbage_52.5,13.4")).toEqual({ | |
| ok: true, | |
| value: [ | |
| { lat: 48.1, lon: 11.5 }, | |
| { lat: 52.5, lon: 13.4 }, | |
| ], | |
| }); | |
| }); | |
| test("never yields NaN coordinates", () => { | |
| const parsed = kind.parse("48.1,"); | |
| expect(parsed).toEqual({ ok: true, value: [] }); | |
| }); | |
| test("refuses a name carrying either separator", () => { | |
| expect(kind.format([{ lat: 1, lon: 2, name: "Munich, DE" }]).ok).toBe(false); | |
| expect(kind.format([{ lat: 1, lon: 2, name: "Site_A" }]).ok).toBe(false); | |
| }); | |
| }); | |
| describe("timestamp", () => { | |
| const kind = timestamp(); | |
| test("rounds to the minute on the way out", () => { | |
| expect(kind.format("2026-07-26T20:46:37.512Z")).toEqual({ ok: true, value: "2026-07-26T20:46Z" }); | |
| }); | |
| test("accepts anything parseable on the way in", () => { | |
| expect(kind.parse("2026-07-26T20:46Z")).toEqual({ ok: true, value: "2026-07-26T20:46Z" }); | |
| expect(kind.parse("2026-07-26T20:46:37Z")).toEqual({ ok: true, value: "2026-07-26T20:46Z" }); | |
| expect(kind.parse("2026-07-26")).toEqual({ ok: true, value: "2026-07-26T00:00Z" }); | |
| }); | |
| test("rejects a non-time", () => { | |
| expect(kind.parse("Point").ok).toBe(false); | |
| expect(kind.parse("").ok).toBe(false); | |
| }); | |
| // null is the live clock: not a value the url carries. | |
| test("refuses to format null, which is how the parameter is omitted", () => { | |
| expect(kind.format(null).ok).toBe(false); | |
| }); | |
| test("round-trips", () => { | |
| const formatted = kind.format("2026-07-26T20:46:37Z"); | |
| expect(formatted.ok && kind.parse(formatted.value)).toEqual({ ok: true, value: "2026-07-26T20:46Z" }); | |
| }); | |
| }); | |
| const SCHEMA: FieldSpec[] = [ | |
| { name: "enabledComponents", url: "elements", kind: closedStringList(components) }, | |
| { name: "enabledTags", url: "tags", kind: stringList() }, | |
| { name: "enabledSatellites", url: "sats", kind: tildeEscapedStringList() }, | |
| { name: "trackedSatellite", url: "track", kind: plainString() }, | |
| { name: "showFps", url: "fps", kind: boolean() }, | |
| { name: "layers", url: "layers", kind: layerList(providers) }, | |
| { name: "time", url: "time", kind: timestamp() }, | |
| ]; | |
| const DEFAULTS = { | |
| enabledComponents: ["Point", "Label"], | |
| enabledTags: [] as string[], | |
| enabledSatellites: [] as string[], | |
| trackedSatellite: "", | |
| showFps: false, | |
| layers: ["NaturalEarth"], | |
| time: null as string | null, | |
| }; | |
| describe("decode", () => { | |
| test("an absent parameter resets its state to the default", () => { | |
| const { patch, invalid } = decode({}, SCHEMA, DEFAULTS); | |
| expect(patch).toEqual(DEFAULTS); | |
| expect(invalid).toEqual([]); | |
| }); | |
| test("an invalid parameter falls back to the default and is reported", () => { | |
| const { patch, invalid } = decode({ fps: "yes" }, SCHEMA, DEFAULTS); | |
| expect(patch.showFps).toBe(false); | |
| expect(invalid).toEqual(["fps"]); | |
| }); | |
| // The default here is the preset's basemap, which is what a misspelled | |
| // provider has to leave standing. | |
| test("an unusable layers value keeps the default stack", () => { | |
| const { patch, invalid } = decode({ layers: "Bogus" }, SCHEMA, DEFAULTS); | |
| expect(patch.layers).toEqual(["NaturalEarth"]); | |
| expect(invalid).toEqual(["layers"]); | |
| }); | |
| test("an unusable elements value keeps the default components", () => { | |
| const { patch, invalid } = decode({ elements: "Retired thing" }, SCHEMA, DEFAULTS); | |
| expect(patch.enabledComponents).toEqual(["Point", "Label"]); | |
| expect(invalid).toEqual(["elements"]); | |
| }); | |
| test("one invalid parameter does not disturb the others", () => { | |
| const { patch, invalid } = decode({ fps: "yes", tags: "Weather" }, SCHEMA, DEFAULTS); | |
| expect(patch.enabledTags).toEqual(["Weather"]); | |
| expect(invalid).toEqual(["fps"]); | |
| }); | |
| }); | |
| describe("encode", () => { | |
| test("omits everything that matches the default", () => { | |
| expect(encode(DEFAULTS, DEFAULTS, SCHEMA)).toEqual({}); | |
| }); | |
| test("emits only what deviates", () => { | |
| expect(encode({ ...DEFAULTS, enabledTags: ["Weather", "Stations"] }, DEFAULTS, SCHEMA)).toEqual({ tags: "Weather,Stations" }); | |
| }); | |
| // Foreign parameters are the adapter's business — see urlSync.test.ts. This | |
| // map cannot express a valueless or repeated one, so it does not carry them. | |
| test("emits only the parameters it owns", () => { | |
| expect(encode({ ...DEFAULTS, showFps: true }, DEFAULTS, SCHEMA)).toEqual({ fps: "true" }); | |
| }); | |
| test("a live clock leaves no time parameter", () => { | |
| expect(encode(DEFAULTS, DEFAULTS, SCHEMA)).toEqual({}); | |
| }); | |
| test("a pinned clock emits one", () => { | |
| expect(encode({ ...DEFAULTS, time: "2026-07-26T20:46Z" }, DEFAULTS, SCHEMA)).toEqual({ time: "2026-07-26T20:46Z" }); | |
| }); | |
| test("omits a value it cannot represent rather than corrupting it", () => { | |
| expect(encode({ ...DEFAULTS, enabledSatellites: ["FOO~BAR"] }, DEFAULTS, SCHEMA)).toEqual({}); | |
| }); | |
| test("leaves commas unescaped in the wire form", () => { | |
| expect(queryString(encode({ ...DEFAULTS, enabledTags: ["a", "b"] }, DEFAULTS, SCHEMA))).toBe("?tags=a,b"); | |
| }); | |
| test("the wire form is empty when nothing deviates", () => { | |
| expect(queryString(encode(DEFAULTS, DEFAULTS, SCHEMA))).toBe(""); | |
| }); | |
| }); | |
| describe("round trip", () => { | |
| test("decode(encode(state)) === state for every field", () => { | |
| const state = { | |
| enabledComponents: ["Point", "Label", "Sensor cone", "Ground track"], | |
| enabledTags: ["Weather", "Stations"], | |
| enabledSatellites: ["NOAA 19", "STARLINK-1007"], | |
| trackedSatellite: "ISS (ZARYA)", | |
| showFps: true, | |
| layers: ["ArcGis_0.5", "Nextrad"], | |
| time: "2026-07-26T20:46Z", | |
| }; | |
| const params = encode(state, DEFAULTS, SCHEMA); | |
| // through the wire form and back, so the round trip covers encoding too | |
| const parsed = Object.fromEntries(new URLSearchParams(queryString(params))); | |
| expect(decode(parsed, SCHEMA, DEFAULTS).patch).toEqual(state); | |
| }); | |
| }); | |
| // Every URL that worked before this codec must still mean the same thing. | |
| describe("read compatibility with the pre-codec format", () => { | |
| test("a legacy url decodes to the values it always meant", () => { | |
| const legacy = "elements=Point,Label,Orbit,Sensor-cone,Ground-track&sats=IRIDIUM-NEXT%7E106&tags=Weather,Stations&track=ISS+%28ZARYA%29&fps=true"; | |
| const query = Object.fromEntries(new URLSearchParams(legacy)); | |
| expect(decode(query, SCHEMA, DEFAULTS).patch).toEqual({ | |
| enabledComponents: ["Point", "Label", "Orbit", "Sensor cone", "Ground track"], | |
| enabledTags: ["Weather", "Stations"], | |
| enabledSatellites: ["IRIDIUM-NEXT 106"], | |
| trackedSatellite: "ISS (ZARYA)", | |
| showFps: true, | |
| layers: ["NaturalEarth"], | |
| time: null, | |
| }); | |
| }); | |
| test("the one intentional break: ?fps=false now means false", () => { | |
| const query = Object.fromEntries(new URLSearchParams("fps=false")); | |
| expect(decode(query, SCHEMA, DEFAULTS).patch.showFps).toBe(false); | |
| }); | |
| }); | |