Ctrl K
ComunidadGuías…

tests/sittings.test.ts

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

Archivo original ↗Preguntar a KiBot
// Sittings from progress: the rule (shared/sittings.ts) and the server's
// detection (server/sittings.ts, hooked into the indexer's read of a note).
//
// The owner's words: "I read 4 of my books today and update the tracker for
// them — they should all show as reading under today." So the server half is
// tested as a day of moves on real files: forward, backward, the reader's own
// save, a second press, `sittings: manual`, four books at once, and a restart.

import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { after, before, describe, it } from "node:test";
import {
  appendSittingLine,
  DEFAULT_MINUTES,
  estimateMinutes,
  estimatedSession,
  isReadingTracker,
  minutesPerUnit,
  observeProgress,
  replaceSittingLine,
  sittingUnit,
  type SeenTracker,
} from "../shared/sittings.ts";
import { formatSessionLine, parseSessionLine, parseTracker, readingSpeed, type TrackerSession } from "../shared/tracker.ts";
import { makeDir, makeVault, removeVault } from "./helpers/vault.ts";

const measured = (date: string, pages: number, minutes: number): TrackerSession => ({ date, from: null, to: null, pages, minutes });

describe("the mark", () => {
  it("reads ~ as estimated, `default pace` as the default's, and a count in any unit", () => {
    assert.deepEqual(parseSessionLine("2026-09-27 | 139–160 | 21 pages | ~32 min"), {
      date: "2026-09-27", from: 139, to: 160, pages: 21, minutes: 32, estimate: "pace",
    });
    assert.deepEqual(parseSessionLine("2026-09-27 | 3–5 | 2 chapters | ~60 min | default pace"), {
      date: "2026-09-27", from: 3, to: 5, pages: 0, minutes: 60, estimate: "default", count: 2, unit: "chapters",
    });
    // Arabic digits and words, the mark kept.
    assert.equal(parseSessionLine("٢٠٢٦-٠٩-٢٧ | ٢١ صفحة | ~٣٢ د")?.estimate, "pace");
    // A measured line stays exactly what it was.
    assert.deepEqual(parseSessionLine("2026-09-15 | 112–139 | 27 pages | 41 min"), { date: "2026-09-15", from: 112, to: 139, pages: 27, minutes: 41 });
    // Hours are time, not a count.
    assert.equal(parseSessionLine("2026-09-15 | 40 pages | 1 h")?.minutes, 60);
  });

  it("writes the line it reads back, byte for byte", () => {
    for (const line of [
      "2026-09-27 | 139–160 | 21 pages | ~32 min",
      "2026-09-27 | 2 chapters | ~60 min | default pace",
      "2026-09-27 | 10–20 | 10 pages | ~15 min | default pace",
    ]) {
      assert.equal(formatSessionLine(parseSessionLine(line)!), line);
    }
  });

  it("keeps estimated sittings out of the card's reading speed", () => {
    const sessions = [measured("2026-09-01", 30, 20), { ...measured("2026-09-02", 30, 90), estimate: "default" as const }];
    assert.equal(readingSpeed(sessions), 1.5);
  });
});

describe("the estimate", () => {
  it("prefers the tracker's own measured pace", () => {
    const own = [measured("2026-09-01", 20, 40), measured("2026-09-02", 20, 40)];
    assert.deepEqual(estimateMinutes({ count: 10, unit: null, own, vault: [], total: 300, bookPages: null }), { minutes: 20, basis: "pace" });
  });

  it("falls back to the vault's measured sittings in the same unit, then to the default", () => {
    const vault = [measured("2026-09-01", 30, 30)];
    assert.deepEqual(estimateMinutes({ count: 10, unit: "pages", own: [], vault, total: null, bookPages: null }), { minutes: 10, basis: "pace" });
    assert.deepEqual(estimateMinutes({ count: 10, unit: "صفحات", own: [], vault: [], total: null, bookPages: null }), { minutes: 15, basis: "default" });
    assert.equal(DEFAULT_MINUTES.pages, 1.5);
  });

  it("never reads an estimate as pace", () => {
    const own = [{ ...measured("2026-09-01", 20, 400), estimate: "pace" as const }];
    assert.equal(minutesPerUnit(own, "pages"), null);
  });

  it("spreads the book's pages over its chapters, else the chapter default", () => {
    // 300 pages in 12 chapters = 25 pages a chapter, at the default 1.5.
    assert.deepEqual(estimateMinutes({ count: 2, unit: "chapters", own: [], vault: [], total: 12, bookPages: 300 }), { minutes: 75, basis: "default" });
    // …at the reader's own page pace when there is one.
    assert.deepEqual(estimateMinutes({ count: 2, unit: "chapters", own: [measured("2026-09-01", 50, 50)], vault: [], total: 12, bookPages: 300 }), { minutes: 50, basis: "pace" });
    assert.deepEqual(estimateMinutes({ count: 2, unit: "فصول", own: [], vault: [], total: 12, bookPages: null }), { minutes: 60, basis: "default" });
    assert.equal(sittingUnit("Sections"), "sections");
    assert.equal(sittingUnit("volumes"), "parts");
    assert.equal(sittingUnit("stanzas"), "other");
  });

  it("builds the line with the unit's own word", () => {
    assert.equal(formatSessionLine(estimatedSession("2026-09-27", 3, 5, 2, "chapters", { minutes: 60, basis: "default" })), "2026-09-27 | 3–5 | 2 chapters | ~60 min | default pace");
    assert.equal(formatSessionLine(estimatedSession("2026-09-27", 62.5, 70, 7.5, null, { minutes: 11, basis: "pace" })), "2026-09-27 | 7.5 pages | ~11 min");
  });
});

describe("which trackers are reading", () => {
  it("is a book, or a bare fence in pages — never a game's hours", () => {
    assert.ok(isReadingTracker(parseTracker("title: A\nkind: book\nunit: chapters\nprogress: 1/9")!));
    assert.ok(isReadingTracker(parseTracker("title: A\nkind: novel\nprogress: 1/9")!));
    assert.ok(isReadingTracker(parseTracker("title: A\nunit: pages\nprogress: 1/9")!));
    assert.ok(!isReadingTracker(parseTracker("title: A\nkind: game\nprogress: 1/9")!));
    assert.ok(!isReadingTracker(parseTracker("title: A\nprogress: 1/9")!));
  });
});

describe("observeProgress", () => {
  const book = (progress: string, extra = "") => parseTracker(`title: Dune\nkind: book\nprogress: ${progress}\n${extra}`)!;
  const today = "2026-09-27";
  const seen = (over: Partial<SeenTracker> = {}): SeenTracker => ({ title: "Dune", done: 50, high: 50, day: today, measured: [], line: null, ...over });

  it("writes nothing on a first sighting", () => {
    assert.equal(observeProgress(undefined, book("50/300"), today).action, null);
  });
  it("answers a forward move with a sitting from the last value", () => {
    assert.deepEqual(observeProgress(seen(), book("70/300"), today).action, { kind: "add", from: 50, to: 70 });
  });
  it("counts from the day's high-water mark, not from a dip typed on the way", () => {
    const dip = observeProgress(seen(), book("7/300"), today);
    assert.equal(dip.action, null);
    assert.deepEqual(observeProgress(dip.seen!, book("70/300"), today).action, { kind: "add", from: 50, to: 70 });
    // …but a new day starts where the last one ended.
    assert.deepEqual(observeProgress(dip.seen!, book("20/300"), "2026-09-28").action, { kind: "add", from: 7, to: 20 });
  });
  it("gives back what the day's own line counted when the number goes down", () => {
    const line = "2026-09-27 | 50–70 | 20 pages | ~30 min | default pace";
    const t = book("60/300", `sessions: |\n  ${line}\n`);
    assert.deepEqual(observeProgress(seen({ done: 70, high: 70, line }), t, today).action, { kind: "shrink", by: 10 });
    // A line the owner edited is not the instance's to shrink.
    const edited = book("60/300", "sessions: |\n  2026-09-27 | 50–70 | 20 pages | 25 min\n");
    assert.equal(observeProgress(seen({ done: 70, high: 70, line, measured: [] }), edited, today).action, null);
  });
  it("leaves a move the save's own sitting accounts for", () => {
    const t = book("80/300", "sessions: |\n  2026-09-27 | 50–80 | 30 pages | 40 min\n");
    assert.equal(observeProgress(seen(), t, today).action, null);
  });
  it("is off under `sittings: manual`, and for a game", () => {
    assert.equal(observeProgress(seen(), book("70/300", "sittings: manual\n"), today).action, null);
    const game = parseTracker("title: Dune\nkind: game\nprogress: 70/300")!;
    assert.equal(observeProgress(seen(), game, today).action, null);
  });
  it("meets a renamed tracker fresh", () => {
    assert.equal(observeProgress(seen({ title: "Other" }), book("70/300"), today).action, null);
  });
});

describe("the block, edited one line at a time", () => {
  it("appends at the block's own indent and leaves the other lines alone", () => {
    const body = "title: A\nsessions: |\n    2026-09-01 | 10 pages | 12 min\nnotes: |\n  hi\n";
    assert.equal(appendSittingLine(body, "2026-09-27 | 5 pages | ~8 min"), "title: A\nsessions: |\n    2026-09-01 | 10 pages | 12 min\n    2026-09-27 | 5 pages | ~8 min\nnotes: |\n  hi\n");
    assert.equal(appendSittingLine("title: A\n", "x"), null);
  });
  it("replaces or removes only the exact line", () => {
    const body = "title: A\nsessions: |\n  2026-09-01 | 10 pages | 12 min\n  2026-09-27 | 5 pages | ~8 min\n";
    assert.equal(replaceSittingLine(body, "2026-09-27 | 5 pages | ~8 min", "2026-09-27 | 9 pages | ~14 min"), "title: A\nsessions: |\n  2026-09-01 | 10 pages | 12 min\n  2026-09-27 | 9 pages | ~14 min\n");
    assert.equal(replaceSittingLine(body, "2026-09-27 | 5 pages | ~8 min", null), "title: A\nsessions: |\n  2026-09-01 | 10 pages | 12 min\n");
    assert.equal(replaceSittingLine("title: A\nsessions: |\n  2026-09-27 | 5 pages | ~8 min\n", "2026-09-27 | 5 pages | ~8 min", null), "title: A\n");
    assert.equal(replaceSittingLine(body, "2026-09-27 | 6 pages | ~8 min", null), null);
  });
});

// ─── The server: real notes, the indexer's own read ─────────────────────────

const fence = (title: string, progress: string, extra = "") => `# ${title}\n\n\`\`\`tracker\ntitle: ${title}\nkind: book\nprogress: ${progress}\n${extra}\`\`\`\n\nMy notes.\n`;

const data = makeDir();
const root = makeVault({
  "Media/Books/Dune.md": fence("Dune", "50/300", "sessions: |\n  2026-09-20 | 10–50 | 40 pages | 60 min\n"),
  "Media/Books/Emma.md": fence("Emma", "10/400"),
  "Media/Books/Kim.md": fence("Kim", "10/200"),
  "Media/Books/Ulysses.md": fence("Ulysses", "10/700"),
  "Media/Books/Manual.md": fence("Manual", "10/100", "sittings: manual\n"),
  "Media/Games/Elden.md": "```tracker\ntitle: Elden\nkind: game\nprogress: 10/100\n```\n",
});

// eslint-disable-next-line @typescript-eslint/consistent-type-imports
let server: {
  vault: typeof import("../server/vault.ts");
  indexer: typeof import("../server/indexer.ts");
  sittings: typeof import("../server/sittings.ts");
};

before(async () => {
  const { initSite } = await import("../server/site.ts");
  initSite({ ASTROLABE_DATA: data });
  const vault = await import("../server/vault.ts");
  vault.initVault(root);
  const sittings = await import("../server/sittings.ts");
  const indexer = await import("../server/indexer.ts");
  sittings.setSittingsClock(() => new Date(2026, 8, 27, 12));
  sittings.initSittings();
  await indexer.initIndexer();
  server = { vault, indexer, sittings };
});

after(() => {
  removeVault(root);
  removeVault(data);
});

/** A save, the way PUT /api/note makes one: write, then the index reads it. */
async function save(rel: string, edit: (text: string) => string): Promise<string> {
  const note = await server.vault.readNote(rel);
  await server.vault.writeNote(rel, edit(note.content), note.mtimeMs);
  await server.indexer.indexFile(rel);
  return readFileSync(path.join(root, rel), "utf8");
}
const setProgress = (to: string) => (text: string) => text.replace(/progress: [^\n]+/, `progress: ${to}`);
const sessionsOf = (text: string) => parseTracker(/```tracker\n([\s\S]*?)```/.exec(text)![1])!.sessions;

describe("detection on the server", () => {
  it("met every tracker at boot and wrote nothing", () => {
    assert.ok(!readFileSync(path.join(root, "Media/Books/Emma.md"), "utf8").includes("sessions"));
    assert.ok(existsSync(path.join(data, "trackers-progress.json")));
  });

  it("a forward move writes one estimated line for today, at the book's own pace", async () => {
    const text = await save("Media/Books/Dune.md", setProgress("70/300"));
    assert.ok(text.includes("  2026-09-27 | 50–70 | 20 pages | ~30 min\n"), text);
    // The measured line and the prose are as they were.
    assert.ok(text.includes("  2026-09-20 | 10–50 | 40 pages | 60 min\n"));
    assert.ok(text.endsWith("```\n\nMy notes.\n"));
  });

  it("a second move the same day folds into the same line", async () => {
    const text = await save("Media/Books/Dune.md", setProgress("80/300"));
    const today = sessionsOf(text).filter((s) => s.date === "2026-09-27");
    assert.equal(today.length, 1);
    assert.deepEqual(today[0], { date: "2026-09-27", from: 50, to: 80, pages: 30, minutes: 45, estimate: "pace" });
  });

  it("a move backwards writes nothing new and takes back what the line counted", async () => {
    const text = await save("Media/Books/Dune.md", setProgress("75/300"));
    const today = sessionsOf(text).filter((s) => s.date === "2026-09-27");
    assert.equal(today.length, 1);
    assert.equal(today[0].pages, 25);
    assert.equal(today[0].to, 75);
  });

  it("a save that brings its own sitting is left alone", async () => {
    const text = await save("Media/Books/Dune.md", (t) => setProgress("95/300")(t).replace("sessions: |\n", "sessions: |\n  2026-09-27 | 75–95 | 20 pages | 25 min\n"));
    assert.equal(sessionsOf(text).filter((s) => s.date === "2026-09-27").length, 2);
  });

  it("four books moved on one day are four sittings under that day", async () => {
    for (const [rel, to] of [["Media/Books/Emma.md", "40/400"], ["Media/Books/Kim.md", "25/200"], ["Media/Books/Ulysses.md", "30/700"]] as const) {
      const text = await save(rel, setProgress(to));
      assert.equal(sessionsOf(text).length, 1, rel);
    }
    const { agendaByDay } = await import("../shared/dayAgenda.ts");
    const trackers = server.indexer.trackers(false, null).filter((t) => t.kind === "book");
    const agenda = agendaByDay(["2026-09-27"], { notes: new Map(), sigils: [], trackers, grades: [] }, "2026-09-27");
    assert.deepEqual(agenda.get("2026-09-27")!.trackers.map((t) => t.title).sort(), ["Dune", "Emma", "Kim", "Ulysses"]);
    // Emma and Kim have no measured sittings of their own: the vault's pace
    // (Dune's measured lines) is what their minutes rest on.
    const emma = sessionsOf(readFileSync(path.join(root, "Media/Books/Emma.md"), "utf8"))[0];
    assert.equal(emma.estimate, "pace");
  });

  it("writes nothing under `sittings: manual`, or for a game", async () => {
    assert.ok(!(await save("Media/Books/Manual.md", setProgress("40/100"))).includes("~"));
    assert.ok(!(await save("Media/Games/Elden.md", setProgress("40/100"))).includes("sessions"));
  });

  it("never re-adds a line the owner deleted, and a restart counts nothing twice", async () => {
    const rel = "Media/Books/Kim.md";
    const cut = await save(rel, (t) => t.replace(/sessions: \|\n[^\n]*\n/, ""));
    assert.ok(!cut.includes("sessions"));
    server.sittings.forgetSittingsMemory(); // the instance restarts…
    await server.indexer.indexFile(rel); // …and reads the note again
    assert.ok(!readFileSync(path.join(root, rel), "utf8").includes("sessions"));
    // A real move after the restart still counts, from where it stood.
    const text = await save(rel, setProgress("35/200"));
    assert.deepEqual(sessionsOf(text).map((s) => [s.from, s.to, s.pages]), [[25, 35, 10]]);
  });

  it("hands the rewrite to the save that caused it, once", async () => {
    await save("Media/Books/Ulysses.md", setProgress("40/700"));
    const hit = server.sittings.takeSittingRewrite("Media/Books/Ulysses.md");
    assert.ok(hit !== null && hit.content.includes("10–40 | 30 pages"));
    assert.equal(server.sittings.takeSittingRewrite("Media/Books/Ulysses.md"), null);
  });
});

describe("the surfaces that read sittings", () => {
  const sessions: TrackerSession[] = [
    { date: "2026-09-27", from: 1, to: 21, pages: 20, minutes: 30 },
    parseSessionLine("2026-09-27 | 21–41 | 20 pages | ~30 min | default pace")!,
    parseSessionLine("2026-09-28 | 2 chapters | ~50 min")!,
  ];
  it("the day agenda counts estimated minutes, measured sittings, the basis and other units", async () => {
    const { agendaByDay } = await import("../shared/dayAgenda.ts");
    const agenda = agendaByDay(["2026-09-27", "2026-09-28"], { notes: new Map(), sigils: [], trackers: [{ path: "B.md", index: 0, title: "B", sessions }], grades: [] }, "2026-09-28");
    const d27 = agenda.get("2026-09-27")!.trackers[0];
    assert.deepEqual([d27.pages, d27.minutes, d27.estimated, d27.measured, d27.defaultPace], [40, 60, 30, 1, true]);
    const d28 = agenda.get("2026-09-28")!.trackers[0];
    assert.deepEqual([d28.measured, d28.defaultPace, d28.units], [0, false, [{ unit: "chapters", count: 2 }]]);
  });
  it("the week review and the year say how much was estimated", async () => {
    const { booksThisWeek } = await import("../shared/weekReview.ts");
    const [book] = booksThisWeek([{ path: "B.md", index: 0, title: "B", kind: "book", kindKey: "book", unit: null, status: "active", done: 41, total: 300, percent: 13, pace: null, due: null, sessions }], { start: "2026-09-21", end: "2026-09-28" });
    assert.equal(book.estimated, 80);
    const { yearNumbers } = await import("../shared/yearReview.ts");
    const n = yearNumbers({ year: 2026, today: "2026-12-31", notes: [], daily: new Map(), sigils: [], trackers: [{ path: "B.md", title: "B", kind: "book", finished: null, rating: null, sessions }], grades: [], edges: [] });
    assert.deepEqual([n.sittings, n.estimatedSittings, n.estimatedMinutes, n.pages], [3, 2, 80, 40]);
  });
});