tests/sittings.test.ts
Rama main · réplica de lectura del repositorio original.
// 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]);
});
});