tests/tracker.test.ts
Rama main · réplica de lectura del repositorio original.
// Trackers: the parser, the stepper's text transform, and the shelf's scope.
//
// Two halves, one file, because they are two halves of one promise:
//
// 1. shared/tracker.ts — the pure model. It loads under `node --test`
// precisely because it drags no CSS and no CodeMirror behind it (the
// constraint tests/tables.test.ts states for tableModel.ts), and the
// arithmetic in it is where a tracker can lie quietly: a bar at the wrong
// percentage looks exactly like a bar at the right one.
// 2. server/indexer.ts's `trackers()` — the SCOPE. Publishing a reading
// shelf is the point of the feature, so "a visitor sees published
// trackers and no others" is a security-shaped claim and is tested
// through the public API against a fixture vault, the way
// tests/excerpt.test.ts tests excerpts.
//
// The invariant with the sharpest teeth is `setTrackerProgress` byte
// discipline: it runs on a button press, in the author's own file, and
// anything it rewrites beyond the one number is a diff nobody asked for.
import assert from "node:assert/strict";
import { after, before, describe, it } from "node:test";
import {
foldKind,
parseBoard,
parseTracker,
scanTrackers,
setTrackerProgress,
trackerAssets,
trackerFenceKind,
trackerIcon,
} from "../shared/tracker.ts";
import { initIndexer, trackers as trackersRaw } from "../server/indexer.ts";
import { initSite } from "../server/site.ts";
import { initVault } from "../server/vault.ts";
import { makeDir, makeVault, note, removeVault } from "./helpers/vault.ts";
// ── The parser ──────────────────────────────────────────────────────────────
describe("parseTracker: step", () => {
it("moves pages and minutes by ten, everything else by one, unless told", () => {
assert.equal(parseTracker("title: A\nkind: book\nprogress: 4/1250")?.step, 10);
assert.equal(parseTracker("title: A\nprogress: 4/1250\nunit: صفحات")?.step, 10);
assert.equal(parseTracker("title: A\nkind: book\nprogress: 1/14\nunit: chapters")?.step, 1);
assert.equal(parseTracker("title: A\nkind: game\nprogress: 62/?")?.step, 1);
assert.equal(parseTracker("title: A\nkind: book\nprogress: 4/1250\nstep: 25")?.step, 25);
assert.equal(parseTracker("title: A\nkind: book\nprogress: 4/1250\nstep: nope")?.step, 10);
});
});
describe("parseTracker: folder", () => {
it("reads a vault folder, trimmed of slashes, and refuses a climb", () => {
assert.equal(parseTracker("title: A\nfolder: /1 - Source/Books/X/")?.folder, "1 - Source/Books/X");
assert.equal(parseTracker("title: A\nfolder: ../secrets")?.folder, null);
assert.equal(parseTracker("title: A")?.folder, null);
});
});
describe("parseTracker: progress", () => {
it("reads a done/total fraction and derives the percentage", () => {
const tr = parseTracker("title: Elden Ring\nprogress: 62/130\n");
assert.ok(tr);
assert.equal(tr.done, 62);
assert.equal(tr.total, 130);
assert.ok(Math.abs((tr.percent ?? 0) - 47.6923) < 0.001);
});
it("reads a bare percentage, with or without the sign", () => {
assert.equal(parseTracker("title: A\nprogress: 45%")?.percent, 45);
assert.equal(parseTracker("title: A\nprogress: 45")?.percent, 45);
});
it("tolerates spacing, the word 'of', and Eastern Arabic digits", () => {
assert.equal(parseTracker("title: A\nprogress: 62 / 130")?.done, 62);
assert.equal(parseTracker("title: A\nprogress: 62 of 130")?.total, 130);
const arabic = parseTracker("title: كتاب\nprogress: ٦٢/١٣٠");
assert.equal(arabic?.done, 62);
assert.equal(arabic?.total, 130);
});
it("clamps a percentage that overshoots and one that goes negative", () => {
assert.equal(parseTracker("title: A\nprogress: 140%")?.percent, 100);
assert.equal(parseTracker("title: A\nprogress: -20%")?.percent, 0);
assert.equal(parseTracker("title: A\nprogress: 200/100")?.percent, 100);
});
it("gives a finished thing 100% even when it names no numbers", () => {
const tr = parseTracker("title: Dune\nstatus: finished");
assert.equal(tr?.status, "done");
assert.equal(tr?.percent, 100);
});
it("derives the status from the progress when none is given", () => {
assert.equal(parseTracker("title: A\nprogress: 0%")?.status, "planned");
assert.equal(parseTracker("title: A\nprogress: 40%")?.status, "active");
assert.equal(parseTracker("title: A\nprogress: 100%")?.status, "done");
});
});
describe("parseTracker: the rest of the fence", () => {
it("folds the status synonyms a person actually types", () => {
const status = (word: string): string | undefined =>
parseTracker(`title: A\nstatus: ${word}`)?.status;
assert.equal(status("reading"), "active");
assert.equal(status("playing"), "active");
assert.equal(status("watching"), "active");
assert.equal(status("in-progress"), "active");
assert.equal(status("finished"), "done");
assert.equal(status("on hold"), "paused");
assert.equal(status("dnf"), "dropped");
assert.equal(status("backlog"), "planned");
// A word we do not know is not a status: the card derives one from the
// progress rather than inventing a chip.
assert.equal(status("marinating"), "planned");
});
it("reads every rating form", () => {
assert.deepEqual(parseTracker("title: A\nrating: 8/10")?.rating, { value: 8, max: 10 });
assert.deepEqual(parseTracker("title: A\nrating: 4/5")?.rating, { value: 4, max: 5 });
assert.deepEqual(parseTracker("title: A\nrating: ★★★★")?.rating, { value: 4, max: 5 });
assert.deepEqual(parseTracker("title: A\nrating: ★★★★☆")?.rating, { value: 4, max: 5 });
assert.deepEqual(parseTracker("title: A\nrating: 4")?.rating, { value: 4, max: 5 });
assert.deepEqual(parseTracker("title: A\nrating: 8")?.rating, { value: 8, max: 10 });
assert.equal(parseTracker("title: A\nrating: ")?.rating, null);
});
it("accepts a cover as a bare name or as an embed", () => {
assert.equal(parseTracker("title: A\ncover: art.jpg")?.cover, "art.jpg");
assert.equal(parseTracker("title: A\ncover: ![[art.jpg]]")?.cover, "art.jpg");
assert.equal(parseTracker("title: A\ncover: [[art.jpg]]")?.cover, "art.jpg");
assert.equal(parseTracker("title: A\ncover: ![[art.jpg|200]]")?.cover, "art.jpg");
});
it("maps the kind to a glyph and keeps the author's own word", () => {
const tr = parseTracker("title: A\nkind: movie");
assert.equal(tr?.kind, "movie");
assert.equal(tr?.kindKey, "film");
assert.equal(tr?.icon, "film");
assert.equal(trackerIcon("boardgame"), "sparkle");
assert.equal(foldKind("Series"), "show");
assert.equal(foldKind("boardgame"), null);
});
it("swallows a block scalar's indented lines as the notes", () => {
const tr = parseTracker(
["title: Elden Ring", "notes: |", " Margit took 14 tries.", " Then he did not.", "rating: 9/10"].join("\n"),
);
assert.equal(tr?.notes, "Margit took 14 tries.\nThen he did not.");
assert.deepEqual(tr?.rating, { value: 9, max: 10 });
});
it("ignores unknown keys and takes a bare first line as the title", () => {
const tr = parseTracker("Elden Ring\nfavourite-boss: Radahn\nprogress: 10%");
assert.equal(tr?.title, "Elden Ring");
assert.equal(tr?.percent, 10);
});
it("returns null for a body with neither a title nor progress", () => {
// The $$-math rule: unparseable content must read as its own source, so
// the renderer needs a null to fall back on rather than an empty card.
assert.equal(parseTracker(""), null);
assert.equal(parseTracker("kind: game\nunit: hours"), null);
});
});
describe("parseBoard", () => {
it("reads the three filters and ignores the rest", () => {
const filter = parseBoard("kind: game\nstatus: playing\nlimit: 12\nnonsense: yes");
assert.deepEqual(filter, { kind: "game", status: "active", limit: 12 });
});
it("is an empty filter for an empty body", () => {
assert.deepEqual(parseBoard(""), {});
assert.deepEqual(parseBoard("status: marinating"), {});
});
});
describe("scanning a note", () => {
const NOTE = [
"# Shelf",
"",
"```tracker",
"title: Dune",
"cover: dune.jpg",
"progress: 100%",
"```",
"",
"```js",
"// not a tracker",
"```",
"",
"````markdown",
"```tracker",
"title: Documentation, not a tracker",
"cover: nope.jpg",
"```",
"````",
"",
"```tracker",
"title: Elden Ring",
"cover: elden.jpg",
"progress: 62/130",
"```",
].join("\n");
it("finds every tracker in the note, in order", () => {
const found = scanTrackers(NOTE);
assert.deepEqual(
found.map((tr) => tr.title),
["Dune", "Elden Ring"],
);
});
it("does not read a tracker shown INSIDE a longer fence", () => {
// The nested-fence trap shared/fences.ts exists for: a closer must be at
// least as long as the run that opened it, so the ```tracker inside the
// ````markdown block is documentation and nothing else.
assert.ok(!scanTrackers(NOTE).some((tr) => tr.cover === "nope.jpg"));
});
it("collects the covers — the names the visitor allowlist is built from", () => {
assert.deepEqual(trackerAssets(NOTE), ["dune.jpg", "elden.jpg"]);
});
it("recognises a tracker fence's opening line and nothing else", () => {
assert.equal(trackerFenceKind("```tracker"), "tracker");
assert.equal(trackerFenceKind("```tracker-board"), "tracker-board");
assert.equal(trackerFenceKind("~~~tracker"), "tracker");
assert.equal(trackerFenceKind("```trackers"), null);
assert.equal(trackerFenceKind("```js"), null);
});
});
// ── The stepper ─────────────────────────────────────────────────────────────
describe("setTrackerProgress", () => {
const BODY = ["title: Elden Ring", "kind: game", "progress: 62 / 130", "notes: |", " Margit."].join("\n");
it("moves the number and changes nothing else, byte for byte", () => {
const next = setTrackerProgress(BODY, 1);
assert.equal(
next,
["title: Elden Ring", "kind: game", "progress: 63 / 130", "notes: |", " Margit."].join("\n"),
);
// Every line but the touched one survives identically — the promise a
// button press in someone's own file has to keep.
const before = BODY.split("\n");
const after_ = next.split("\n");
assert.equal(before.length, after_.length);
for (const [i, line] of before.entries()) {
if (i === 2) continue;
assert.equal(after_[i], line);
}
});
it("round-trips: up then down is the original body", () => {
assert.equal(setTrackerProgress(setTrackerProgress(BODY, 1), -1), BODY);
});
it("clamps at the total and at zero", () => {
const full = "title: A\nprogress: 130/130";
assert.equal(setTrackerProgress(full, 1), full);
const empty = "title: A\nprogress: 0/130";
assert.equal(setTrackerProgress(empty, -1), empty);
});
it("clamps a percentage at 100", () => {
assert.equal(setTrackerProgress("title: A\nprogress: 100%", 1), "title: A\nprogress: 100%");
assert.equal(setTrackerProgress("title: A\nprogress: 99%", 1), "title: A\nprogress: 100%");
});
it("keeps CRLF endings and the author's spacing", () => {
const crlf = "title: A\r\nprogress: 40%\r\nkind: book\r\n";
assert.equal(setTrackerProgress(crlf, 1), "title: A\r\nprogress: 41%\r\nkind: book\r\n");
});
it("writes a progress line under the title when there is none", () => {
assert.equal(
setTrackerProgress("title: A\nkind: book", 1),
"title: A\nprogress: 1%\nkind: book",
);
assert.equal(setTrackerProgress("", 1), "progress: 1%");
});
it("crossing into 100% is a state the parser then agrees with", () => {
const body = setTrackerProgress("title: A\nprogress: 129/130", 1);
const tr = parseTracker(body);
assert.equal(tr?.percent, 100);
assert.equal(tr?.status, "done");
});
});
// ── The shelf's scope (server/indexer.ts) ───────────────────────────────────
const fence = (lines: string[]): string => ["```tracker", ...lines, "```"].join("\n");
const files: Record<string, string> = {
"Public shelf.md": note(
{ publish: "true" },
`# Shelf\n\n${fence(["title: Dune", "kind: book", "progress: 300/600", "cover: dune.jpg"])}\n`,
),
"Private shelf.md": note(
{},
`# Mine\n\n${fence(["title: Disco Elysium", "kind: game", "progress: 40%"])}\n`,
),
// A stencil carrying a tracker skeleton: it must not shelve itself as a
// book nobody has started, in the admin's list any more than the public one.
"Templates/Tracker.md": note(
{ publish: "true" },
`${fence(["title: {{title}}", "kind: book", "progress: 0/100"])}\n`,
),
"No trackers.md": note({ publish: "true" }, "Just prose.\n"),
};
const data = makeDir();
const root = makeVault(files);
before(async () => {
initSite({ ASTROLABE_DATA: data });
initVault(root);
await initIndexer();
});
after(() => {
removeVault(root);
removeVault(data);
});
describe("trackers() — who may see which shelf", () => {
it("gives a visitor the published trackers only", () => {
const list = trackersRaw(true, null);
assert.deepEqual(
list.map((meta) => meta.title),
["Dune"],
);
assert.equal(list[0].path, "Public shelf.md");
assert.equal(list[0].kind, "book");
assert.equal(list[0].icon, "book");
assert.equal(list[0].done, 300);
assert.equal(list[0].total, 600);
assert.equal(list[0].status, "active");
});
it("gives the admin the whole vault, published or not", () => {
const titles = trackersRaw(false, null).map((meta) => meta.title);
assert.ok(titles.includes("Dune"));
assert.ok(titles.includes("Disco Elysium"));
});
it("keeps a template's tracker off BOTH shelves", () => {
for (const visitor of [true, false]) {
const titles = trackersRaw(visitor, null).map((meta) => meta.title);
assert.ok(!titles.some((title) => title.includes("{{title}}")), `template leaked (visitor=${visitor})`);
}
});
it("says nothing about notes that carry no tracker", () => {
assert.ok(!trackersRaw(false, null).some((meta) => meta.path === "No trackers.md"));
});
});
describe("the Media page's edits", () => {
it("rewrites a field in place, keeping the fence's own spelling and bytes", async () => {
const { setTrackerFields } = await import("../shared/tracker.ts");
const body = "Title: Elden Ring\r\nprogress: 62/130\r\nunit: hours\r\nnotes: |\r\n Margit took 14 tries.\r\n Twice.\r\nrating: 9/10\r\n";
const next = setTrackerFields(body, { progress: "70/130", notes: "Radahn next." });
assert.equal(next, "Title: Elden Ring\r\nprogress: 70/130\r\nunit: hours\r\nnotes: |\r\n Radahn next.\r\nrating: 9/10\r\n");
assert.equal(setTrackerFields(body, {}), body);
});
it("adds missing fields under the title in a fixed order and removes on null", async () => {
const { setTrackerFields, parseTracker } = await import("../shared/tracker.ts");
const next = setTrackerFields("title: Dune\nrating: 4/5\n", { status: "active", kind: "book", progress: "10/600", rating: null });
assert.equal(next, "title: Dune\nkind: book\nprogress: 10/600\nstatus: active\n");
const t = parseTracker(next);
assert.equal(t?.kindKey, "book");
assert.equal(t?.total, 600);
});
it("finds every tracker fence's body and edits one by index", async () => {
const { trackerFenceSpans, editTrackerFence } = await import("../shared/tracker.ts");
const md = "# Shelf\n\n```tracker\ntitle: A\nprogress: 1/2\n```\n\ntext\n\n```js\nx\n```\n\n```tracker\ntitle: B\n```\n";
const spans = trackerFenceSpans(md);
assert.deepEqual(spans.map((s) => [s.index, s.body]), [[0, "title: A\nprogress: 1/2\n"], [1, "title: B\n"]]);
const edited = editTrackerFence(md, 1, (b) => b + "status: done\n");
assert.ok(edited.includes("title: B\nstatus: done\n```"));
assert.equal(editTrackerFence(md, 5, (b) => b + "x"), md);
});
it("reads an open-ended count and composes a media note", async () => {
const { parseTracker, scanTrackers } = await import("../shared/tracker.ts");
const open = parseTracker("title: Hades\nprogress: 12/?\nunit: hours\n");
assert.deepEqual([open?.done, open?.total, open?.percent], [12, null, null]);
const { mediaNotePath, mediaNoteContent, mediaProgress } = await import("../shared/media.ts");
assert.equal(mediaNotePath("show", "Severance: Season 2?"), "Media/Shows/Severance Season 2.md");
assert.equal(mediaNotePath("podcast", "X"), "Media/Podcast/X.md");
// An Arabic instance files in Arabic; an existing root of either language wins.
assert.equal(mediaNotePath("book", "الأسود يليق بك", { lang: "ar" }), "وسائط/كتب/الأسود يليق بك.md");
assert.equal(mediaNotePath("podcast", "X", { lang: "ar" }), "وسائط/podcast/X.md");
assert.equal(mediaNotePath("book", "X", { lang: "ar", existing: ["Media"] }), "Media/Books/X.md");
assert.equal(mediaNotePath("film", "X", { lang: "en", existing: ["وسائط"] }), "وسائط/أفلام/X.md");
assert.equal(mediaNotePath("film", "X", { lang: "en", existing: ["وسائط", "Media"] }), "Media/Films/X.md");
assert.equal(mediaProgress(62.5, 130), "62.5/130");
assert.equal(mediaProgress(12, null), "12/?");
const note = mediaNoteContent({ title: "Elden Ring", kind: "game", progress: "62/130", unit: "hours", status: "active", notes: "Margit." });
const [t] = scanTrackers(note);
assert.equal(t.title, "Elden Ring");
assert.equal(Math.round(t.percent ?? 0), 48);
assert.equal(t.notes, "Margit.");
});
});
describe("the pace (3.13.0)", () => {
it("reads pace: and due:, and projects the finish or the pace it takes", async () => {
const { parseTracker, paceProjection } = await import("../shared/tracker.ts");
const t = parseTracker("title: Ledger\nprogress: 100/300\npace: 20\n")!;
assert.equal(t.pace, 20);
assert.deepEqual(paceProjection(t, "2026-09-14"), { kind: "done-by", date: "2026-09-24", pace: 20 });
const d = parseTracker("title: Ledger\nprogress: 100/300\ndue: 2026-09-24\n")!;
assert.equal(d.due, "2026-09-24");
assert.deepEqual(paceProjection(d, "2026-09-14"), { kind: "needs", pace: 20, date: "2026-09-24" });
assert.equal(paceProjection(parseTracker("title: x\nprogress: 300/300\npace: 5\n")!, "2026-09-14"), null);
});
});
describe("reading sessions (3.17.0)", () => {
it("reads a sessions: block leniently, in either language and either order", async () => {
const { parseTracker, parseSessionLine } = await import("../shared/tracker.ts");
const t = parseTracker("title: Muqaddimah\nkind: book\nprogress: 139/500\nfile: [[Books/Muqaddimah.pdf]]\nsessions: |\n 2026-09-14 | 100–112 | 12 pages | 20 min\n ٢٠٢٦-٠٩-١٥ | ٤١ د | ٢٧ صفحة | 112-139\n not a session\n")!;
assert.equal(t.file, "Books/Muqaddimah.pdf");
assert.deepEqual(t.sessions, [
{ date: "2026-09-14", from: 100, to: 112, pages: 12, minutes: 20 },
{ date: "2026-09-15", from: 112, to: 139, pages: 27, minutes: 41 },
]);
// A time after the date is allowed; a bare number is pages.
assert.deepEqual(parseSessionLine("2026-09-15 20:14 | 30"), { date: "2026-09-15", from: null, to: null, pages: 30, minutes: 0 });
assert.equal(parseSessionLine("Sep 15 | 30 pages"), null);
assert.equal(parseTracker("title: x\nfile: ../../etc/passwd\n")?.file, null);
});
it("appends a line to the block, creates the block under the fields, and takes the last line back", async () => {
const { appendTrackerSession, dropLastTrackerSession, formatSessionLine, parseTracker } = await import("../shared/tracker.ts");
const s1 = { date: "2026-09-14", from: 100, to: 112, pages: 12, minutes: 20 };
const s2 = { date: "2026-09-15", from: 112, to: 139, pages: 27, minutes: 41 };
assert.equal(formatSessionLine(s2), "2026-09-15 | 112–139 | 27 pages | 41 min");
const body = "title: Muqaddimah\nprogress: 100/500\nnotes: |\n Ibn Khaldun.\n";
const once = appendTrackerSession(body, s1);
// Before the prose, after the counts — the fixed order every other field keeps.
assert.equal(once, "title: Muqaddimah\nprogress: 100/500\nsessions: |\n 2026-09-14 | 100–112 | 12 pages | 20 min\nnotes: |\n Ibn Khaldun.\n");
const twice = appendTrackerSession(once, s2);
assert.deepEqual(parseTracker(twice)?.sessions, [s1, s2]);
assert.equal(dropLastTrackerSession(twice), once);
// The block goes with its last line: an undone first session leaves no trace.
assert.equal(dropLastTrackerSession(once), body);
assert.equal(dropLastTrackerSession(body), body);
// A bare title stays the title: the block lands under it, not above it.
const bare = appendTrackerSession("Muqaddimah\nprogress: 1/2\n", s1);
assert.equal(parseTracker(bare)?.title, "Muqaddimah");
assert.ok(bare.startsWith("Muqaddimah\nprogress: 1/2\nsessions: |\n"));
// CRLF survives.
const crlf = appendTrackerSession("title: A\r\nprogress: 1/9\r\n", s1);
assert.equal(crlf, "title: A\r\nprogress: 1/9\r\nsessions: |\r\n 2026-09-14 | 100–112 | 12 pages | 20 min\r\n");
});
it("reads the speed off the last five timed sessions and says how long is left", async () => {
const { readingSpeed, minutesLeft, parseTracker, trackerCountsPages, SPEED_SESSIONS } = await import("../shared/tracker.ts");
const s = (pages: number, minutes: number) => ({ date: "2026-09-01", from: null, to: null, pages, minutes });
assert.equal(readingSpeed([]), null);
assert.equal(readingSpeed([s(10, 0)]), null);
// A ratio of sums: the long sitting outweighs the short one.
assert.equal(readingSpeed([s(2, 1), s(30, 30)]), 32 / 31);
// Only the last five count.
const many = [s(100, 1), ...Array.from({ length: SPEED_SESSIONS }, () => s(10, 10))];
assert.equal(readingSpeed(many), 1);
const t = parseTracker("title: M\nkind: book\nprogress: 200/500\nsessions: |\n 2026-09-14 | 30 pages | 20 min\n")!;
assert.equal(trackerCountsPages(t), true);
assert.equal(minutesLeft(t), 200); // 300 pages at 1.5 a minute
assert.equal(trackerCountsPages(parseTracker("title: M\nkind: book\nunit: chapters\n")!), false);
assert.equal(minutesLeft(parseTracker("title: M\nunit: chapters\nprogress: 1/9\nsessions: |\n 2026-09-14 | 30 pages | 20 min\n")!), null);
assert.equal(minutesLeft(parseTracker("title: M\nkind: book\nprogress: 500/500\nsessions: |\n 2026-09-14 | 30 pages | 20 min\n")!), null);
});
});