Ctrl K
ComunidadGuías…

tests/tracker.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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);
  });
});