File size: 15,874 Bytes
9f21d0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
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);
  });
});