Public: anyone can read this; the crew can change it.

A GPS track (.gpx) in a repository renders as its route drawn as an outline, with distance and climb #10382

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10329-a-gps-track-gpx-in-a-repository-renders → main 10 commits fork 2 threads · 2 unresolved

  • Merged. mason/10329-a-gps-track-gpx-in-a-repository-renders was merged into main as 4b3f90b.

Review

Sign in to review this change.
12 files changed +445 −3
docs/design/areas/render.md +4
@@ -21,3 +21,7 @@ Backgrounds, 256-colour above 15 and truecolour are dropped; so are cursor moves
2121 ## Archive listing
2222
2323 A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the existing `.table` alone, with no `.card` around it (path, size, modified; `data-dense`), and a `tfoot` for the count and unpacked size. No new class: `.archive-view` is only a hook on the table and has no rule. How it works: `docs/features/archive-listing.md`. A list that cannot be read falls back to the Download card with the reason as its one sentence.
24
25 ## GPS track (.gpx)
26
27 `src/web/render/gpx.ts` draws the route as one inline SVG in `.gpx-view`: no fixed width or height, `max-width: 100%` and `height: auto` (tokens only, `currentColor` stroke, no accent), so it fits a 360px phone. Its facts (track, distance, climb, points) are the existing `.status-rows` / `.status-row`, with no box. How it works: `docs/features/gpx.md`.
docs/features/gpx.md added +9
@@ -0,0 +1,9 @@
1 # GPS tracks (.gpx) as a route outline
2
3 Open a `.gpx` file in a repository and see the route drawn as an outline, with its track name, distance, climb and point count.
4
5 - **Web:** the blob page draws an inline SVG on the server (`src/web/render/gpx.ts`). It scales to the column (`max-width: 100%`), so it fits a phone; the facts are `status-rows`. `?view=source`, Raw and Download work as for any file.
6 - **Terminal:** the workspace opens a `.gpx` as a Fact / Value table (`src/tui/screens/workspace.ts`, sharing `parseGpx` and `gpxFacts`); `s` flips to the source. The status line reads `4 facts`.
7 - Distance is the haversine sum, climb the sum of rises. No map tiles, no network.
8 - Beyond 20,000 points (`POINT_CAP`) the outline is thinned evenly, ends kept; the count stays true.
9 - A file that is not readable GPX shows as source, with a one-line reason on the web. DOCTYPE entities are never expanded; all text is escaped.
scripts/unreached-allow.json +1
@@ -15,6 +15,7 @@
1515 "src/flow/review_rounds.ts",
1616 "src/flow/shared_red.ts",
1717 "src/flow/stale_fold.ts",
18 "src/kernel/why_now.ts",
1819 "src/spawn/pod.ts",
1920 "src/tui/gallery.ts",
2021 "src/tui/ui/qr.ts",
src/tui/screens/workspace.ts +20 −3
@@ -55,6 +55,7 @@ import { isModelPath, modelFacts } from "../../web/render/model-facts.ts";
5555 import type { RepoFacts, RepoSource } from "../../view/source.ts";
5656 import { detectLanguage, highlightLines } from "../../web/highlight.ts";
5757 import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts";
58 import { gpxFacts, parseGpx } from "../../web/render/gpx.ts";
5859 import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts";
5960 import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts";
6061 import { layoutTable, type Grid } from "../ui/grid.ts";
@@ -336,6 +337,19 @@ function icsTable(source: string): Table | null {
336337 };
337338 }
338339
340 /** `.gpx`: a GPS track, drawn as a table of its facts (track, distance, climb, points). */
341 function isGpxPath(path: string): boolean {
342 return /\.gpx$/i.test(path);
343 }
344
345 /** The facts of a track as a table; null when the file is not a track. Shares the web's parser. */
346 function gpxTable(source: string, path: string): Table | null {
347 const parsed = parseGpx(source);
348 if (parsed === undefined) return null;
349 const rows = gpxFacts(parsed, path);
350 return { header: ["Fact", "Value"], rows, more: 0, delimiter: ",", columns: 2 };
351 }
352
339353 /** `README.md`, `NOTES.markdown`, `readme` — the prose a repo opens on. */
340354 function isMarkdownPath(path: string): boolean {
341355 const name = path.slice(path.lastIndexOf("/") + 1);
@@ -495,6 +509,9 @@ class Tab {
495509 } else if (isIcsPath(this.path)) {
496510 this.table = icsTable(new TextDecoder().decode(source));
497511 if (this.table !== null) this.grid = layoutTable(this.table);
512 } else if (isGpxPath(this.path)) {
513 this.table = gpxTable(new TextDecoder().decode(source), this.path);
514 if (this.table !== null) this.grid = layoutTable(this.table);
498515 } else if (isTablePath(this.path)) {
499516 // The decoded source, not `plain`: `expandTabs` has eaten every tab there.
500517 const delimiter = /\.tsv$/i.test(this.path) ? "\t" : undefined;
@@ -2575,7 +2592,7 @@ export function workspaceScreen(
25752592 ? ""
25762593 : `${t.path} · ${
25772594 t.tabular && t.table !== null
2578 ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path))
2595 ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path), isGpxPath(t.path))
25792596 : `${plural(
25802597 t.reading ? t.doc.length : t.plain.length,
25812598 "line",
@@ -2909,9 +2926,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void {
29092926 }
29102927
29112928 /** `80 rows, first 50 shown · 3 more columns`: what the table leaves out. */
2912 function tableStatus(table: Table, unread = 0, subs = false, events = false): string {
2929 function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false): string {
29132930 const rows = table.rows.length + table.more;
2914 return `${plural(rows, subs ? "cue" : events ? "event" : "row")}${
2931 return `${plural(rows, subs ? "cue" : events ? "event" : facts ? "fact" : "row")}${
29152932 unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : ""
29162933 }${
29172934 table.more > 0 ? `, first ${table.rows.length} shown` : ""
src/web/render/gpx.ts added +233
@@ -0,0 +1,233 @@
1 /**
2 * A GPS track (`.gpx`) drawn as its route: an inline SVG outline of every
3 * track segment and route, fitted to their bounds by an equirectangular
4 * projection (longitude scaled by the cosine of the middle latitude). No map
5 * tiles, no network. Distance is the haversine sum, climb the sum of rises.
6 *
7 * The parser is a small tag scanner, not an XML engine: it reads the five
8 * predefined entities and numeric references in names and nothing else, so a
9 * DOCTYPE with entity definitions is skipped and never expanded. A file whose
10 * tags do not nest, or that holds no point, is not a GPX file to us: the
11 * caller falls back to the text view.
12 */
13
14 import { html, raw } from "../html.ts";
15 import { fileSize } from "../ui.ts";
16 import type { Rendered, RenderInput } from "./renderers.ts";
17
18 /** Points drawn; more than this are thinned evenly across the whole track. */
19 export const POINT_CAP = 20_000;
20
21 export interface Point {
22 readonly lat: number;
23 readonly lon: number;
24 readonly ele?: number;
25 }
26 export interface Line {
27 readonly name: string;
28 readonly points: Point[];
29 }
30 export interface Gpx {
31 readonly lines: Line[];
32 readonly count: number;
33 }
34
35 const ENTITIES: Readonly<Record<string, string>> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
36 /** Predefined and numeric references only; any other `&name;` stays text. */
37 const decode = (s: string): string =>
38 s.replace(/&(?:#(\d{1,6})|#x([0-9a-fA-F]{1,5})|(amp|lt|gt|quot|apos));/g, (m, d, h, n) => {
39 if (n !== undefined) return ENTITIES[n]!;
40 const code = d !== undefined ? Number(d) : parseInt(h, 16);
41 return code > 0x1f || code === 9 || code === 10 ? String.fromCodePoint(code) : m;
42 });
43
44 const TAG = /<!--[\s\S]*?-->|<!DOCTYPE[^[>]*(?:\[[\s\S]*?\])?\s*>|<!\[CDATA\[([\s\S]*?)\]\]>|<[?!][^>]*>|<(\/?)([A-Za-z_][\w:.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)/g;
45 const ATTR = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
46
47 const attr = (src: string, name: string): string | undefined => {
48 for (const m of src.matchAll(ATTR)) if (m[1] === name) return m[2] ?? m[3];
49 return undefined;
50 };
51 const local = (tag: string): string => tag.slice(tag.indexOf(":") + 1).toLowerCase();
52 const coord = (v: string | undefined, max: number): number | undefined => {
53 if (v === undefined || !/^\s*[+-]?\d+(\.\d+)?\s*$/.test(v)) return undefined;
54 const n = Number(v);
55 return Math.abs(n) <= max ? n : undefined;
56 };
57
58 /** Parse GPX text. Pure. Returns undefined when it is not well-formed GPX with a point. */
59 export function parseGpx(source: string): Gpx | undefined {
60 const stack: string[] = [];
61 const lines: Line[] = [];
62 let name = "";
63 let current: { lat: number; lon: number; ele?: number } | undefined;
64 let seg: Point[] | undefined;
65 let text = "";
66 let sawRoot = false;
67 let count = 0;
68
69 const open = (tag: string, attrs: string): void => {
70 if (stack.length === 0) {
71 if (tag !== "gpx") throw new Error("root");
72 sawRoot = true;
73 }
74 if (tag === "trk" || tag === "rte") name = "";
75 if (tag === "trkseg" || tag === "rte") {
76 seg = [];
77 lines.push({ name, points: seg });
78 }
79 if (tag === "trkpt" || tag === "rtept") {
80 const lat = coord(attr(attrs, "lat"), 90);
81 const lon = coord(attr(attrs, "lon"), 180);
82 current = lat !== undefined && lon !== undefined ? { lat, lon } : undefined;
83 }
84 if (tag === "name" || tag === "ele") text = "";
85 };
86 const close = (tag: string): void => {
87 const parent = stack[stack.length - 1];
88 if (tag === "ele" && current !== undefined && /^\s*[+-]?\d+(\.\d+)?([eE][+-]?\d+)?\s*$/.test(text))
89 current.ele = Number(text);
90 if (tag === "name" && (parent === "trk" || parent === "rte") && name === "") name = decode(text.trim());
91 if ((tag === "trkpt" || tag === "rtept") && current !== undefined && seg !== undefined) {
92 seg.push(current);
93 count++;
94 current = undefined;
95 }
96 if (tag === "trkseg" || tag === "rte") seg = undefined;
97 if (tag === "name" || tag === "ele") text = "";
98 };
99
100 try {
101 for (const m of source.replace(/^/, "").matchAll(TAG)) {
102 const [, cdata, closing, rawTag, attrs, selfClose, chars] = m;
103 if (cdata !== undefined) text += cdata;
104 else if (chars !== undefined) {
105 if (stack.length === 0 && chars.trim() !== "") throw new Error("text outside root");
106 text += chars;
107 } else if (rawTag !== undefined) {
108 const tag = local(rawTag);
109 if (closing === "/") {
110 if (stack.pop() !== tag) throw new Error("mismatch");
111 close(tag);
112 } else {
113 if (stack.length === 0 && sawRoot) throw new Error("two roots");
114 open(tag, attrs ?? "");
115 if (selfClose === "/") close(tag);
116 else stack.push(tag);
117 }
118 }
119 }
120 } catch {
121 return undefined;
122 }
123 if (!sawRoot || stack.length > 0 || count === 0) return undefined;
124 return { lines: lines.filter((l) => l.points.length > 0), count };
125 }
126
127 /** Keep at most `cap` points of a list, evenly spaced, first and last included. */
128 export function thin<T>(items: readonly T[], cap: number): T[] {
129 if (items.length <= cap) return [...items];
130 if (cap < 2) return items.slice(0, cap);
131 const out: T[] = [];
132 for (let i = 0; i < cap; i++) out.push(items[Math.round((i * (items.length - 1)) / (cap - 1))]!);
133 return out;
134 }
135
136 const R = 6_371_008.8;
137 const rad = (d: number): number => (d * Math.PI) / 180;
138 /** Great-circle distance in metres between two points. */
139 export function haversine(a: Point, b: Point): number {
140 const dLat = rad(b.lat - a.lat);
141 const dLon = rad(b.lon - a.lon);
142 const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
143 return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
144 }
145
146 export interface Stats {
147 /** Metres. */
148 readonly distance: number;
149 /** Metres climbed. */
150 readonly gain: number;
151 }
152 export function gpxStats(gpx: Gpx): Stats {
153 let distance = 0;
154 let gain = 0;
155 for (const line of gpx.lines) {
156 for (let i = 1; i < line.points.length; i++) {
157 const a = line.points[i - 1]!;
158 const b = line.points[i]!;
159 distance += haversine(a, b);
160 if (a.ele !== undefined && b.ele !== undefined && b.ele > a.ele) gain += b.ele - a.ele;
161 }
162 }
163 return { distance, gain };
164 }
165
166 const W = 640;
167 const MAX_H = 480;
168 const PAD = 12;
169
170 /** The outline as SVG path data in a viewBox, thinned to `POINT_CAP` points overall. */
171 export function outline(gpx: Gpx): { width: number; height: number; paths: string[] } {
172 const keep = gpx.count > POINT_CAP ? POINT_CAP / gpx.count : 1;
173 const lines = gpx.lines.map((l) => thin(l.points, Math.max(2, Math.floor(l.points.length * keep))));
174 let minLat = 90, maxLat = -90, minLon = 180, maxLon = -180;
175 for (const l of gpx.lines)
176 for (const p of l.points) {
177 minLat = Math.min(minLat, p.lat);
178 maxLat = Math.max(maxLat, p.lat);
179 minLon = Math.min(minLon, p.lon);
180 maxLon = Math.max(maxLon, p.lon);
181 }
182 const k = Math.cos(rad((minLat + maxLat) / 2));
183 const spanX = Math.max((maxLon - minLon) * k, 1e-9);
184 const spanY = Math.max(maxLat - minLat, 1e-9);
185 let scale = (W - 2 * PAD) / spanX;
186 let height = spanY * scale + 2 * PAD;
187 if (height > MAX_H) {
188 scale = (MAX_H - 2 * PAD) / spanY;
189 height = MAX_H;
190 }
191 const width = Math.max(spanX * scale + 2 * PAD, 2 * PAD + 1);
192 const f = (n: number): string => n.toFixed(1);
193 const paths = lines.map((pts) =>
194 pts
195 .map((p, i) => `${i === 0 ? "M" : "L"}${f(PAD + (p.lon - minLon) * k * scale)} ${f(PAD + (maxLat - p.lat) * scale)}`)
196 .join(""),
197 );
198 return { width: Math.round(width), height: Math.round(height), paths };
199 }
200
201 const km = (m: number): string => (m / 1000).toLocaleString("en", { maximumFractionDigits: 2 });
202
203 /** Track, Distance, Climb, Points as text: the facts both the page and the terminal show. */
204 export function gpxFacts(parsed: Gpx, path: string): [string, string][] {
205 const { distance, gain } = gpxStats(parsed);
206 const named = parsed.lines.map((l) => l.name).filter((n) => n !== "");
207 return [
208 ["Track", named[0] ?? path.slice(path.lastIndexOf("/") + 1)],
209 ["Distance", `${km(distance)} km`],
210 ["Climb", `${Math.round(gain).toLocaleString("en")} m`],
211 ["Points", parsed.count.toLocaleString("en")],
212 ];
213 }
outdated

Principle 2/phone: fixed width="${width}" height="${height}" overflows a 360px screen and nothing styles .gpx-view. Remove the attributes, keep viewBox, and add CSS max-width:100%;height:auto using tokens (stroke currentColor is fine, no accent).

214
outdated

Principles 7 and 1: use the named components for a fact list (a dl row) rather than a bespoke figcaption with strong; check docs/DESIGN.md for the existing dl/row class and use it so the facts sit in rhythm without a box.

215 export function gpx(input: RenderInput): Rendered {
216 const parsed = parseGpx(new TextDecoder().decode(input.body));
217 if (parsed === undefined)
218 return { content: html``, lines: 0, fallback: "This file is not a readable GPX track." };
219 const { width, height, paths } = outline(parsed);
220 const facts = gpxFacts(parsed, input.path);
221 const d = paths.map((p) => `<path d="${p}"/>`).join("");
222 const title = facts[0]![1];
223 const content = html`<figure class="gpx-view">
224 <svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Route of ${title}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${raw(d)}</svg>
225 <dl class="status-rows">${facts.map(([k, v]) => html`<div class="status-row"><dt>${k}</dt><dd>${v}</dd></div>`)}</dl>
226 </figure>`;
227 const { distance, gain } = gpxStats(parsed);
228 return {
229 content,
230 lines: 0,
231 summary: `${km(distance)} km · ${Math.round(gain)} m climb · ${fileSize(input.size)}`,
232 };
233 }
src/web/render/kinds.ts +3
@@ -28,6 +28,7 @@ export type FileKind =
2828 | "patch"
2929 | "log"
3030 | "subtitles"
31 | "gpx"
3132 | "calendar"
3233 | "text"
3334 | "binary";
@@ -56,6 +57,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
5657 patch: 1 * MB,
5758 log: 1 * MB,
5859 subtitles: 5 * MB,
60 gpx: 20 * MB,
5961 calendar: 5 * MB,
6062 text: 1 * MB,
6163 binary: 0,
@@ -163,6 +165,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
163165 ansi: "log",
164166 srt: "subtitles",
165167 vtt: "subtitles",
168 gpx: "gpx",
166169 ics: "calendar",
167170 };
168171
src/web/render/renderers.ts +2
@@ -11,6 +11,7 @@ import { audio } from "./audio.ts";
1111 import { config } from "./config.ts";
1212 import { isFormConfig } from "./config-format.ts";
1313 import { csv } from "./csv.ts";
14 import { gpx } from "./gpx.ts";
1415 import { font } from "./font.ts";
1516 import { ics } from "./ics.ts";
1617 import { image } from "./image.ts";
@@ -71,6 +72,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
7172 patch,
7273 log,
7374 subtitles,
75 gpx,
7476 calendar: ics,
7577 archive,
7678 font,
src/web/style/content.ts +4
@@ -477,6 +477,10 @@ form.inline { display: inline; margin-left: auto; }
477477
478478 /* A font specimen: set in the font itself once the script has loaded it from
479479 the media route; until then (or without script) in the page's own face. */
480 /* ---- gpx view: the outline scales to the column; its facts are status rows ---- */
481 .gpx-view { margin: 0; }
482 .gpx-view svg { display: block; max-width: 100%; height: auto; margin: 0 auto; color: var(--fg); }
483 .gpx-view .status-rows { margin-top: 12px; }
480484 .font-view { padding: 16px 20px; }
481485 .font-name { margin: 0 0 12px; font-size: 1.75rem; line-height: 1.3; overflow-wrap: anywhere; }
482486 .font-line { margin: 0 0 10px; line-height: 1.4; overflow-wrap: anywhere; }
test/blob-gpx-page.test.ts added +58
@@ -0,0 +1,58 @@
1 /**
2 * A `.gpx` committed to a repository, opened on its blob page over HTTP by
3 * an anonymous reader: the server-rendered route and its facts as rows.
4 */
5
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { seedAdmin } from "./helpers/admin.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 describe("a .gpx on its blob page", () => {
19 let dataDir = "";
20 let app: App;
21 let servers: Servers;
22 let base = "";
23
24 beforeAll(async () => {
25 dataDir = await mkdtemp(join(tmpdir(), "forge-blob-gpx-"));
26 app = createApp({ dataDir, port: 0 });
27 servers = await serve(app);
28 base = servers.url;
29 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-patch-passphrase" });
30 if (!a.ok) throw new Error("signup failed");
31 const created = await createRepo(app, a.value, { name: "p", visibility: "public" });
32 if (!created.ok) throw new Error("repo failed");
33 const fixture = '<gpx><trk><name>Up &lt;b&gt;x&lt;/b&gt;</name><trkseg><trkpt lat="10" lon="20"><ele>100</ele></trkpt><trkpt lat="11" lon="20"><ele>150</ele></trkpt></trkseg></trk></gpx>';
34 const made = await commitToBranch(app, a.value, created.value, {
35 message: "a track",
36 files: [{ path: "ride.gpx", content: fixture }],
37 });
38 if (!made.ok) throw new Error("commit failed");
39 }, 60_000);
40
41 afterAll(async () => {
42 await servers?.stop();
43 app?.close();
44 await rm(dataDir, { recursive: true, force: true });
45 });
46
47 test("the page draws the route, scalable, with its facts as rows", async () => {
48 const res = await fetch(`${base}/alice/p/blob/main/ride.gpx`);
49 const text = await res.text();
50 expect(res.status).toBe(200);
51 expect(text).toContain('class="gpx-view"');
52 expect(text).toMatch(/<svg viewBox="0 0 \d+ \d+" role="img"/);
53 expect(text).toContain('<dt>Distance</dt><dd>111.2 km</dd>');
54 expect(text).toContain('<dt>Climb</dt><dd>50 m</dd>');
55 expect(text).toContain('Up &lt;b&gt;x&lt;/b&gt;');
56 expect(text).not.toContain('<b>x');
57 });
58 });
test/render-gpx.test.ts added +77
@@ -0,0 +1,77 @@
1 /** GPX files as a route outline: distance, a malformed file, entity expansion. */
2 import { describe, expect, test } from "bun:test";
3 import { gpx, gpxStats, parseGpx, outline } from "../src/web/render/gpx.ts";
4 import { fileKind } from "../src/web/render/kinds.ts";
5 import { RENDERERS } from "../src/web/render/renderers.ts";
6
7 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
8 const draw = (text: string) => gpx({ path: "r/a.gpx", size: text.length, body: enc(text), media: "/m", download: "/d" });
9
10 // One degree of latitude is about 111.195 km.
11 const TWO = `<?xml version="1.0"?><gpx version="1.1"><trk><name>Up &amp; <b>down</b></name><trkseg>
12 <trkpt lat="10" lon="20"><ele>100</ele></trkpt><trkpt lat="11" lon="20"><ele>150.5</ele></trkpt></trkseg></trk></gpx>`;
13
14 describe("gpx", () => {
15 test("registered", () => {
16 expect(fileKind("a.gpx", enc(TWO))).toBe("gpx");
17 expect(RENDERERS.gpx).toBe(gpx);
18 });
19
20 test("distance of a known two-point track within 1%, climb and count", () => {
21 const g = parseGpx(TWO)!;
22 const s = gpxStats(g);
23 expect(Math.abs(s.distance - 111_195) / 111_195).toBeLessThan(0.01);
24 expect(s.gain).toBeCloseTo(50.5);
25 const out = String(draw(TWO).content);
26 expect(out).toContain("<svg");
27 expect(out).toContain("111.2 km");
28 expect(out).toContain("<dd>2</dd>");
29 });
30
31 test("the svg has no fixed size, so it scales on a phone; facts are status rows", () => {
32 const out = String(draw(TWO).content);
33 expect(out).toContain("viewBox=");
34 expect(out).not.toMatch(/<svg[^>]*\s(width|height)=/);
35 expect(out).toContain('<div class="status-row"><dt>Distance</dt><dd>111.2 km</dd></div>');
36 expect(out).not.toContain("<figcaption");
37 });
38
39 test("names are escaped", () => {
40 const out = String(draw(TWO.replace("Up &amp;", "&lt;script&gt;")).content);
41 expect(out).not.toContain("<script>");
42 expect(out).toContain("&lt;script&gt; down");
43 });
44
45 test("a malformed file falls back to text", () => {
46 for (const bad of ["", "hello", "<gpx><trk></gpx>", "<gpx></gpx>", "<html><trkpt lat='1' lon='1'/></html>"]) {
47 expect(draw(bad).fallback).toBeDefined();
48 }
49 });
50
51 test("an entity-expansion attempt is not expanded", () => {
52 const evil = `<?xml version="1.0"?><!DOCTYPE gpx [<!ENTITY a "AAAAAAAAAA"><!ENTITY b "&a;&a;&a;&a;">]>
53 <gpx><trk><name>&b;</name><trkseg><trkpt lat="1" lon="1"/><trkpt lat="1.1" lon="1.1"/></trkseg></trk></gpx>`;
54 const g = parseGpx(evil)!;
55 expect(g.count).toBe(2);
56 expect(g.lines[0]!.name).toBe("&b;");
57 expect(String(draw(evil).content)).not.toContain("AAAA");
58 });
59
60 test("more than 20,000 points are thinned evenly, ends kept", () => {
61 let pts = "";
62 for (let i = 0; i < 30_000; i++) pts += `<trkpt lat="${(i / 30_000).toFixed(6)}" lon="0"/>`;
63 const g = parseGpx(`<gpx><trk><trkseg>${pts}</trkseg></trk></gpx>`)!;
64 expect(g.count).toBe(30_000);
65 const path = outline(g).paths[0]!;
66 expect(path.split("L").length).toBeLessThanOrEqual(20_000);
67 expect(path.split("L").length).toBeGreaterThan(19_000);
68 expect(path.startsWith("M")).toBe(true);
69 // The last point is drawn: the top-left of the path is the northmost end.
70 expect(path.endsWith("12.0")).toBe(true);
71 });
72
73 test("routes and a single point do not break the outline", () => {
74 const g = parseGpx(`<gpx><rte><name>r</name><rtept lat="1" lon="1"/></rte></gpx>`)!;
75 expect(outline(g).paths.length).toBe(1);
76 });
77 });
test/render-kinds.test.ts +1
@@ -69,6 +69,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
6969 ansi: [enc("\x1b[31mok\n"), "log", "application/octet-stream"],
7070 srt: [enc("1\n00:00:01,000 --> 00:00:02,000\nHi\n"), "subtitles", "application/octet-stream"],
7171 vtt: [enc("WEBVTT\n"), "subtitles", "application/octet-stream"],
72 gpx: [enc("<gpx/>"), "gpx", "application/octet-stream"],
7273 ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"],
7374 };
7475
test/tui-workspace.test.ts +33
@@ -416,6 +416,39 @@ describe("the terminal workspace", () => {
416416 }
417417 });
418418
419 test("a .gpx opens as its facts, sharing the web parser, and s flips to the source", async () => {
420 const made = await createRepo(forge, alice, {
421 name: "tracks",
422 visibility: "public",
423 description: "routes",
424 });
425 if (!made.ok) throw new Error("repo failed");
426 await commitToBranch(forge, alice, made.value, {
427 message: "seed",
428 files: [
429 {
430 path: "ride.gpx",
431 content:
432 '<gpx><trk><name>Morning ride</name><trkseg><trkpt lat="10" lon="20"><ele>100</ele></trkpt><trkpt lat="11" lon="20"><ele>150</ele></trkpt></trkseg></trk></gpx>',
433 },
434 ],
435 });
436 const h = boot();
437 try {
438 await h.settle();
439 await open(h, "tracks");
440 await openFile(h, "ride.gpx");
441 expect(h.text()).toContain("Morning ride");
442 expect(h.text()).toContain("111.2 km");
443 expect(h.text()).toContain("50 m");
444 expect(h.text()).toContain("4 facts");
445 await h.press("s");
446 expect(h.text()).toContain("<trkpt");
447 } finally {
448 h.stop();
449 }
450 });
451
419452 test("tables: search, edit, the caps in the status line, a ; file, and s on a broken one", async () => {
420453 const made = await createRepo(forge, alice, {
421454 name: "sheets",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs