orbit-studio / src /modules /util /urlCodec.test.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
15.9 kB
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);
});
});