Ctrl K
ComunidadGuías…

tests/srsSession.test.ts

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

Archivo original ↗Preguntar a KiBot
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { DeckCard } from "../shared/decks.ts";
import type { Schedule } from "../shared/srs.ts";
import { createSession, gradeCard, nextCard, phaseOf, previews, remaining, retention, skipCard, waitFor, type Session } from "../shared/srsSession.ts";
import { ORBITS_COPY } from "../client/orbits/copy.ts";
import { delimiterOf, parseDelimited, stripAnkiHeader } from "../client/orbits/csv.ts";
import { cardsOfText } from "../client/orbits/lines.ts";
import { hardest, newIntroduced, readLog, retention as deviceRetention, retentionSeries } from "../client/orbits/log.ts";
import { headOf, keysOf, CardQueue } from "../client/orbits/queue.ts";
import { forecast, statesOf, streakOf } from "../client/orbits/stats.ts";
import { diffAnswer, normaliseAnswer } from "../client/orbits/typed.ts";

// A fake localStorage for the queue's daily counter and the device's log
// (client/orbits/log.ts reads it lazily; node's own wants a flag and warns
// without it).
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
  configurable: true,
  value: {
    getItem: (k: string) => store.get(k) ?? null,
    setItem: (k: string, v: string) => void store.set(k, String(v)),
    removeItem: (k: string) => void store.delete(k),
  },
});

const TODAY = "2026-09-28";
const T0 = Date.UTC(2026, 8, 28, 9, 0, 0);
const MIN = 60_000;

function star(n: number, schedule: Schedule | null = null): DeckCard {
  return { id: `k.md#${n}#fwd`, path: "k.md", line: n, end: n, dir: "fwd", kind: "qa", front: `f${n}`, back: `b${n}`, extra: null, section: null, tags: [], schedule };
}
const due = (day: string, interval = 6): Schedule => ({ due: day, interval, ease: 2500 });

/** Grade whatever is next; returns the new session and what was written. */
function step(s: Session, grade: "again" | "hard" | "good" | "easy", now: number): { session: Session; write: Schedule | null; id: string } {
  const next = nextCard(s, now);
  assert.ok(next, "nothing to show");
  const r = gradeCard(s, next.star.id, grade, now, TODAY);
  return { ...r, id: next.star.id };
}

describe("a session's queues", () => {
  it("shows due reviews soonest-due first, then new cards in document order, up to the limit", () => {
    const stars = [star(1), star(2, due("2026-09-27")), star(3, due("2026-09-20")), star(4, due("2026-10-05")), star(5), star(6)];
    const s = createSession(stars, TODAY, T0, { newLimit: 2 });
    assert.deepEqual(s.reviews, ["k.md#3#fwd", "k.md#2#fwd"]);
    assert.deepEqual(s.fresh, ["k.md#1#fwd", "k.md#5#fwd"]);
    assert.deepEqual(remaining(s), { review: 2, new: 2, learning: 0, total: 4 });
    assert.equal(nextCard(s, T0)!.star.id, "k.md#3#fwd");
    assert.equal(nextCard(s, T0)!.phase, "review");
    // Study ahead brings the not-yet-due one in, after the due ones.
    const ahead = createSession(stars, TODAY, T0, { newLimit: 0, studyAhead: true });
    assert.deepEqual(ahead.reviews, ["k.md#3#fwd", "k.md#2#fwd", "k.md#4#fwd"]);
    assert.deepEqual(ahead.fresh, []);
  });

  it("slips one new star in after every four reviews", () => {
    const stars = [star(1), star(2), ...[3, 4, 5, 6, 7, 8, 9, 10, 11].map((n) => star(n, due("2026-09-27")))];
    let s = createSession(stars, TODAY, T0, { newLimit: 10 });
    const shown: string[] = [];
    for (let i = 0; i < 11; i++) {
      const r = step(s, "easy", T0 + i * MIN);
      shown.push(r.id);
      s = r.session;
    }
    assert.deepEqual(shown, ["k.md#3#fwd", "k.md#4#fwd", "k.md#5#fwd", "k.md#6#fwd", "k.md#1#fwd", "k.md#7#fwd", "k.md#8#fwd", "k.md#9#fwd", "k.md#10#fwd", "k.md#2#fwd", "k.md#11#fwd"]);
    assert.equal(nextCard(s, T0 + 12 * MIN), null);
    assert.equal(s.introduced, 2);
    assert.equal(s.done, 11);
  });
});

describe("learning steps", () => {
  it("walks a new star through 1m and 10m and graduates to one day on good", () => {
    let s = createSession([star(1)], TODAY, T0, { newLimit: 10, steps: [1, 10] });
    assert.deepEqual(previews(s, "k.md#1#fwd", TODAY), { again: { minutes: 1 }, hard: { minutes: 1 }, good: { minutes: 10 }, easy: { days: 4 } });
    let r = step(s, "good", T0);
    s = r.session;
    assert.equal(r.write, null);
    assert.equal(phaseOf(s, "k.md#1#fwd"), "learning");
    assert.equal(s.introduced, 1);
    assert.deepEqual(previews(s, "k.md#1#fwd", TODAY), { again: { minutes: 1 }, hard: { minutes: 10 }, good: { days: 1 }, easy: { days: 4 } });
    // Not due for ten minutes: shown early only because nothing else remains.
    assert.equal(nextCard(s, T0 + MIN)!.early, true);
    assert.equal(waitFor(s, T0 + MIN), 9 * MIN);
    assert.equal(nextCard(s, T0 + 10 * MIN)!.early, false);
    assert.equal(waitFor(s, T0 + 10 * MIN), null);
    r = step(s, "good", T0 + 10 * MIN);
    s = r.session;
    assert.deepEqual(r.write, { due: "2026-09-29", interval: 1, ease: 2500 });
    assert.equal(nextCard(s, T0 + 11 * MIN), null);
    assert.equal(s.done, 1);
    assert.deepEqual(s.stars["k.md#1#fwd"].schedule, r.write);
  });

  it("again restarts the steps, hard repeats one, easy graduates at once to four days", () => {
    let s = createSession([star(1)], TODAY, T0, { newLimit: 10, steps: [1, 10] });
    s = step(s, "good", T0).session; // → step 1 (10m)
    let r = step(s, "again", T0 + 10 * MIN);
    s = r.session;
    assert.equal(r.write, null);
    assert.equal(s.learning[0].step, 0);
    assert.equal(s.learning[0].dueAt, T0 + 11 * MIN);
    assert.deepEqual(s.again, ["k.md#1#fwd"]);
    r = step(s, "hard", T0 + 11 * MIN);
    s = r.session;
    assert.equal(s.learning[0].step, 0);
    assert.equal(s.learning[0].dueAt, T0 + 12 * MIN);
    r = step(s, "easy", T0 + 12 * MIN);
    assert.deepEqual(r.write, { due: "2026-10-02", interval: 4, ease: 2650 });
    assert.equal(nextCard(r.session, T0 + 13 * MIN), null);
  });

  it("a due learning star comes before every review and every new star", () => {
    const stars = [star(1), star(2, due("2026-09-27")), star(3, due("2026-09-27"))];
    let s = createSession(stars, TODAY, T0, { newLimit: 10, steps: [1, 10] });
    s = step(s, "good", T0).session; // review 2
    s = step(s, "good", T0).session; // review 3
    s = step(s, "good", T0).session; // new 1 → learning, due at +10m
    assert.equal(nextCard(s, T0 + 10 * MIN)!.star.id, "k.md#1#fwd");
    const withMore = createSession([star(4), ...stars], TODAY, T0, { newLimit: 10 });
    let w = step(withMore, "good", T0).session; // review 2
    // 4 is new; 1 is new; the learning one, once due, outranks both.
    w = { ...w, learning: [{ id: "k.md#3#fwd", phase: "learning", step: 1, dueAt: T0 + MIN }], reviews: [] };
    assert.equal(nextCard(w, T0)!.star.id, "k.md#4#fwd");
    assert.equal(nextCard(w, T0 + MIN)!.star.id, "k.md#3#fwd");
  });
});

describe("lapses", () => {
  it("writes SM-2's again at once and relearns for ten minutes", () => {
    let s = createSession([star(1, due("2026-09-27", 15))], TODAY, T0, { newLimit: 0 });
    assert.deepEqual(previews(s, "k.md#1#fwd", TODAY), { again: { minutes: 10 }, hard: { days: 18 }, good: { days: 38 }, easy: { days: 52 } });
    let r = step(s, "again", T0);
    s = r.session;
    assert.deepEqual(r.write, { due: "2026-09-29", interval: 1, ease: 2300 });
    assert.equal(phaseOf(s, "k.md#1#fwd"), "relearning");
    assert.equal(s.learning[0].dueAt, T0 + 10 * MIN);
    assert.equal(s.done, 0);
    assert.deepEqual(previews(s, "k.md#1#fwd", TODAY), { again: { minutes: 10 }, hard: { minutes: 10 }, good: { days: 1 }, easy: { days: 1 } });
    // Graduating relearning writes nothing more: the lapse already did.
    r = step(s, "good", T0 + 10 * MIN);
    assert.equal(r.write, null);
    assert.equal(r.session.done, 1);
    assert.equal(nextCard(r.session, T0 + 11 * MIN), null);
  });

  it("good, hard and easy on a review write SM-2 and finish the star", () => {
    const s = createSession([star(1, due("2026-09-27", 6)), star(2, due("2026-09-27", 6))], TODAY, T0, { newLimit: 0 });
    const r = gradeCard(s, "k.md#1#fwd", "hard", T0, TODAY);
    assert.deepEqual(r.write, { due: "2026-10-05", interval: 7, ease: 2350 });
    assert.deepEqual(r.session.reviews, ["k.md#2#fwd"]);
    assert.equal(r.session.done, 1);
    assert.equal(r.session.sinceNew, 1);
  });
});

describe("the rest of a session", () => {
  it("skips, counts, tells retention, ignores an unknown star, and never mutates", () => {
    const stars = [star(1), star(2, due("2026-09-27"))];
    const s = createSession(stars, TODAY, T0, { newLimit: 10 });
    const skipped = skipCard(s, "k.md#2#fwd");
    assert.deepEqual(remaining(skipped), { review: 0, new: 1, learning: 0, total: 1 });
    assert.deepEqual(remaining(s), { review: 1, new: 1, learning: 0, total: 2 });
    assert.equal(gradeCard(s, "nope", "good", T0, TODAY).session, s);
    assert.equal(phaseOf(s, "nope"), null);
    assert.equal(previews(s, "nope", TODAY), null);
    assert.equal(retention([]), null);
    let t = s;
    t = gradeCard(t, "k.md#2#fwd", "good", T0, TODAY).session;
    t = gradeCard(t, "k.md#1#fwd", "again", T0, TODAY).session;
    t = gradeCard(t, "k.md#1#fwd", "easy", T0 + MIN, TODAY).session;
    assert.equal(retention(t.log), 2 / 3);
    assert.deepEqual(s.reviews, ["k.md#2#fwd"]);
    assert.equal(s.log.length, 0);
    assert.equal(nextCard(createSession([], TODAY, T0, { newLimit: 10 }), T0), null);
  });
});

// The surface's copy travels in its own chunk (client/orbits/copy.ts), which
// check-i18n does not walk — so this is that table's parity gate, with the
// same rules: both halves present, the Arabic actually Arabic, and the
// placeholders the same on both sides.
describe("the decks' own copy", () => {
  it("has both languages and matching placeholders for every key", () => {
    const ph = (s: string): string => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort().join(",");
    for (const [key, text] of Object.entries(ORBITS_COPY)) {
      assert.ok(text.en.trim() !== "", `${key}: empty en`);
      assert.ok(text.ar.trim() !== "", `${key}: empty ar`);
      assert.ok(/[؀-ۿ]/.test(text.ar), `${key}: ar has no Arabic script`);
      assert.notEqual(text.en, text.ar, `${key}: untranslated`);
      assert.equal(ph(text.en), ph(text.ar), `${key}: placeholders differ`);
    }
    assert.ok(Object.keys(ORBITS_COPY).length > 80);
  });
});


// ── The client's walk, as the UI builder wrote it, over the kept machine ────

const DAY = "2026-09-15";
const NOW = Date.parse("2026-09-15T09:00:00Z");

function s2(line: number, schedule: DeckCard["schedule"] = null): DeckCard {
  return { id: `n.md#${line}#fwd`, path: "n.md", line, end: line, dir: "fwd", kind: "qa", front: `f${line}`, back: `b${line}`, extra: null, section: null, tags: [], schedule };
}

describe("a session's queue, from the client's side", () => {
  it("shows due reviews soonest first, then new stars, one after every four reviews", () => {
    const stars = [
      s2(1, { due: "2026-09-14", interval: 3, ease: 2500 }),
      s2(2, { due: "2026-09-10", interval: 1, ease: 2500 }),
      s2(3, { due: "2026-09-20", interval: 6, ease: 2500 }),
      s2(4),
      s2(5),
      s2(6, { due: "2026-09-15", interval: 2, ease: 2500 }),
      s2(7, { due: "2026-09-15", interval: 2, ease: 2500 }),
      s2(8, { due: "2026-09-15", interval: 2, ease: 2500 }),
    ];
    let s = createSession(stars, DAY, NOW, { newLimit: 10, steps: [1, 10] });
    assert.deepEqual(s.reviews, ["n.md#2#fwd", "n.md#1#fwd", "n.md#6#fwd", "n.md#7#fwd", "n.md#8#fwd"]);
    assert.deepEqual(s.fresh, ["n.md#4#fwd", "n.md#5#fwd"]);
    const order: string[] = [];
    for (let i = 0; i < 7; i++) {
      const p = nextCard(s, NOW)!;
      order.push(`${p.phase}:${p.star.id.split("#")[1]}`);
      s = gradeCard(s, p.star.id, "easy", NOW, DAY).session;
    }
    assert.deepEqual(order, ["review:2", "review:1", "review:6", "review:7", "new:4", "review:8", "new:5"]);
    assert.equal(nextCard(s, NOW), null);
  });

  it("caps new stars at the daily allowance and never limits reviews", () => {
    const stars = [s2(1), s2(2), s2(3), s2(4, { due: "2026-01-01", interval: 1, ease: 2500 })];
    const s = createSession(stars, DAY, NOW, { newLimit: 2 });
    assert.equal(s.fresh.length, 2);
    assert.equal(s.reviews.length, 1);
    assert.equal(createSession(stars, DAY, NOW, { newLimit: 0 }).fresh.length, 0);
  });

  it("walks a new star through the learning steps and writes only on graduation", () => {
    const n = s2(1);
    let s = createSession([n], DAY, NOW, { newLimit: 10, steps: [1, 10] });
    let p = nextCard(s, NOW)!;
    assert.equal(p.phase, "new");
    assert.deepEqual(previews(s, n.id, DAY)!.good, { minutes: 10 });
    assert.deepEqual(previews(s, n.id, DAY)!.easy, { days: 4 });
    assert.deepEqual(previews(s, n.id, DAY)!.again, { minutes: 1 });
    let r = gradeCard(s, p.star.id, "good", NOW, DAY);
    assert.equal(r.write, null);
    s = r.session;
    assert.equal(s.learning[0].step, 1);
    assert.equal(s.learning[0].dueAt, NOW + 10 * MIN);
    // Not due yet, but nothing else is left: shown early.
    p = nextCard(s, NOW + MIN)!;
    assert.equal(p.phase, "learning");
    assert.equal(p.early, true);
    assert.deepEqual(previews(s, n.id, DAY)!.good, { days: 1 });
    r = gradeCard(s, p.star.id, "good", NOW + 11 * MIN, DAY);
    assert.deepEqual(r.write, { due: "2026-09-16", interval: 1, ease: 2500 });
    assert.equal(nextCard(r.session, NOW), null);
  });

  it("a lapse writes SM-2's again and relearns for ten minutes, then writes nothing more", () => {
    const rv = s2(1, { due: "2026-09-14", interval: 12, ease: 2500 });
    const s = createSession([rv], DAY, NOW, { newLimit: 10, steps: [1, 10] });
    const p = nextCard(s, NOW)!;
    assert.equal(p.phase, "review");
    assert.deepEqual(previews(s, rv.id, DAY)!.good, { days: 30 });
    const r = gradeCard(s, p.star.id, "again", NOW, DAY);
    assert.deepEqual(r.write, { due: "2026-09-16", interval: 1, ease: 2300 });
    assert.equal(r.session.learning.length, 1);
    assert.equal(r.session.learning[0].phase, "relearning");
    assert.equal(r.session.learning[0].dueAt, NOW + 10 * MIN);
    // Ready learning stars come before anything else.
    const again = nextCard(r.session, NOW + 10 * MIN)!;
    assert.equal(again.phase, "relearning");
    assert.equal(again.early, false);
    const done = gradeCard(r.session, again.star.id, "good", NOW + 10 * MIN, DAY);
    assert.equal(done.write, null);
    assert.equal(nextCard(done.session, NOW + 10 * MIN), null);
  });

  it("study ahead takes the stars not yet due, soonest first, and no new ones", () => {
    const stars = [s2(1, { due: "2026-09-20", interval: 6, ease: 2500 }), s2(2, { due: "2026-09-17", interval: 2, ease: 2500 }), s2(3)];
    const s = createSession(stars, DAY, NOW, { newLimit: 0, studyAhead: true });
    assert.deepEqual(s.reviews, ["n.md#2#fwd", "n.md#1#fwd"]);
    assert.deepEqual(s.fresh, []);
  });
});

describe("the shelf's numbers", () => {
  it("counts a streak back from today, forgiving a day not yet studied", () => {
    assert.equal(streakOf(["2026-09-15", "2026-09-14", "2026-09-13", "2026-09-10"], "2026-09-15"), 3);
    assert.equal(streakOf(["2026-09-14", "2026-09-13"], "2026-09-15"), 2);
    assert.equal(streakOf(["2026-09-12"], "2026-09-15"), 0);
    assert.equal(streakOf([], "2026-09-15"), 0);
  });
  it("forecasts due stars per day with the overdue on today", () => {
    const f = forecast([s2(1, { due: "2026-09-01", interval: 1, ease: 2500 }), s2(2, { due: "2026-09-17", interval: 2, ease: 2500 }), s2(3, { due: "2027-01-01", interval: 100, ease: 2500 }), s2(4)], DAY, 30);
    assert.equal(f.length, 30);
    assert.equal(f[0], 1);
    assert.equal(f[2], 1);
    assert.equal(f.reduce((a, b) => a + b, 0), 2);
  });
  it("splits stars into new, learning, young and mature", () => {
    const st = statesOf([s2(1), s2(2, { due: "x", interval: 3, ease: 2500 }), s2(3, { due: "x", interval: 21, ease: 2500 })], new Set(["n.md#1#fwd"]));
    assert.deepEqual(st, { new: 0, learning: 1, young: 1, mature: 1 });
  });
});

describe("a typed answer", () => {
  it("forgives case, space, Unicode form and kana width", () => {
    assert.equal(normaliseAnswer("  Tokyo  "), "tokyo");
    assert.equal(normaliseAnswer("カタカナ"), "カタカナ");
    assert.equal(diffAnswer("Tokyo ", "tokyo").ok, true);
  });
  it("marks what was typed wrongly and what was missed", () => {
    const d = diffAnswer("tokio", "tokyo");
    assert.equal(d.ok, false);
    assert.deepEqual(
      d.ops.map((o) => `${o.kind}:${o.text}`),
      ["same:tok", "drop:i", "add:y", "same:o"],
    );
  });
});

describe("the CSV reader", () => {
  it("picks the delimiter from the first line and reads quoted fields", () => {
    assert.equal(delimiterOf("a\tb\tc\n"), "\t");
    assert.equal(delimiterOf("a,b\n"), ",");
    assert.equal(delimiterOf("a;b\n", "x.csv"), ";");
    assert.deepEqual(parseDelimited('"Hello, world",b\n"say ""hi""",c\r\n', ","), [
      ["Hello, world", "b"],
      ['say "hi"', "c"],
    ]);
  });
  it("skips Anki's #-header lines", () => {
    assert.equal(stripAnkiHeader("#separator:tab\n#html:true\nfront\tback\n"), "front\tback\n");
  });
});

// ── The client's queue over the machine (client/orbits/queue.ts) ─────────────

function twin(line: number, front: string, back: string, dir: "fwd" | "rev" = "fwd", schedule: DeckCard["schedule"] = null): DeckCard {
  return { id: `n.md#${line}#${dir}`, path: "n.md", line, end: line, dir, kind: "qa", front, back, extra: null, section: null, tags: [], schedule };
}
const HEAD = { path: "n.md", steps: [1, 10], newPerDay: 10 };

describe("the session keys", () => {
  it("tell two stars with the same faces apart by their order, and survive a shifted line", () => {
    const first = [twin(3, "dog", "chien"), twin(4, "cat", "chat"), twin(9, "dog", "chien"), twin(3, "chien", "dog", "rev")];
    const keys = keysOf(first);
    assert.equal(new Set(keys).size, 4, "four distinct keys");
    // A comment line written after the cat moves the second dog down one.
    const later = [twin(3, "dog", "chien"), twin(4, "cat", "chat"), twin(10, "dog", "chien"), twin(3, "chien", "dog", "rev")];
    assert.deepEqual(keysOf(later), keys);
  });

  it("the queue shows both twins and writes each grade to its own line", () => {
    store.clear();
    const stars = [twin(3, "dog", "chien"), twin(9, "dog", "perro")];
    const q = new CardQueue(HEAD, stars, DAY, false, NOW);
    const a = q.grade("easy", NOW)!;
    const b = q.grade("easy", NOW)!;
    assert.deepEqual([a.star.line, b.star.line], [3, 9]);
    assert.notEqual(a.key, b.key);
    assert.equal(q.next(NOW), null);
    assert.equal(newIntroduced("n.md", DAY), 2);
  });

  it("a re-read after a write keeps the reader's place and hands the write the moved line", () => {
    store.clear();
    const stars = [twin(5, "a", "1"), twin(7, "b", "2"), twin(9, "c", "3")];
    const q = new CardQueue(HEAD, stars, DAY, false, NOW);
    const first = q.grade("easy", NOW)!;
    assert.equal(first.star.line, 5);
    // The write put a comment line under `a`; every star below moved.
    q.refresh([twin(5, "a", "1", "fwd", first.write), twin(8, "b", "2"), twin(10, "c", "3")]);
    const next = q.next(NOW)!;
    assert.equal(next.star.front, "b");
    assert.equal(next.star.line, 8);
    const second = q.grade("easy", NOW)!;
    assert.equal(q.cardOf(second.key)?.line, 8);
    // A star that vanished between reads is dropped, not shown blank.
    q.refresh([twin(5, "a", "1", "fwd", first.write), twin(8, "b", "2", "fwd", second.write)]);
    assert.equal(q.next(NOW), null);
  });

  it("undo gives back the schedule, the counter and the log entry; skip leaves a due star due", () => {
    store.clear();
    const due = { due: "2026-09-10", interval: 3, ease: 2500 };
    const stars = [twin(3, "x", "1", "fwd", due), twin(4, "y", "2", "fwd", due), twin(5, "z", "3")];
    const q = new CardQueue(HEAD, stars, DAY, false, NOW);
    const g = q.grade("again", NOW)!;
    assert.equal(g.star.front, "x");
    assert.deepEqual(g.write, { due: "2026-09-16", interval: 1, ease: 2300 });
    assert.equal(readLog().length, 1);
    const back = q.undoLast()!;
    assert.equal(back.wrote, true);
    assert.deepEqual(back.restore, due);
    assert.equal(readLog().length, 0);
    assert.equal(q.next(NOW)!.star.front, "x", "the undone star is back in front");
    q.grade("good", NOW);
    assert.equal(q.next(NOW)!.star.front, "y");
    q.skip();
    const z = q.grade("easy", NOW)!;
    assert.equal(z.star.front, "z");
    assert.equal(newIntroduced("n.md", DAY), 1);
    assert.equal(q.next(NOW), null);
    const s = q.summary(NOW + 65_000);
    assert.equal(s.graded, 2);
    assert.equal(s.dueLeft, 1, "the skipped y is still due today");
    assert.equal(s.seconds, 65);
    assert.equal(q.canUndo(), true);
    q.undoLast();
    assert.equal(newIntroduced("n.md", DAY), 0, "undoing a new star's grade gives the day its allowance back");
  });

  it("reads the fence's steps and limit from the shelf row, defaults when the row's are unusable", () => {
    const row = { path: "n.md", title: "n", icon: null, kind: "basic" as const, tags: [], implicit: false, counts: { total: 0, new: 0, due: 0 }, sections: [], steps: [], newPerDay: -1 };
    assert.deepEqual(headOf(row), { path: "n.md", steps: [1, 10], newPerDay: 10 });
    assert.deepEqual(headOf({ ...row, steps: [2, 20, 60], newPerDay: 0 }), { path: "n.md", steps: [2, 20, 60], newPerDay: 0 });
  });
});

describe("the device's log", () => {
  it("narrows retention and the hardest list to one note, and buckets the series by local day", () => {
    const at = (day: string, h = 12): number => new Date(`${day}T${String(h).padStart(2, "0")}:00:00`).getTime();
    const log = [
      { path: "a.md", line: 1, grade: "again" as const, ts: at("2026-09-15") },
      { path: "a.md", line: 1, grade: "good" as const, ts: at("2026-09-15") },
      { path: "a.md", line: 2, grade: "again" as const, ts: at("2026-09-14", 23) },
      { path: "b.md", line: 1, grade: "easy" as const, ts: at("2026-09-01") },
      { path: "b.md", line: 1, grade: "easy" as const, ts: at("2026-07-01") },
    ];
    assert.equal(deviceRetention(log, DAY, 30, "a.md"), 1 / 3);
    assert.equal(deviceRetention(log, DAY, 30, "b.md"), 1, "July is outside the window");
    assert.equal(deviceRetention(log, DAY, 30, "c.md"), null);
    const series = retentionSeries(log, DAY, 30, "a.md");
    assert.equal(series.length, 30);
    assert.equal(series[29], 0.5);
    assert.equal(series[28], 0, "eleven at night is still that day");
    assert.deepEqual(hardest(log, "a.md").map((r) => [r.line, r.again]), [[1, 1], [2, 1]]);
    assert.deepEqual(hardest(log, "b.md"), []);
  });
});

describe("the modal's card lines", () => {
  it("reads two and three segments, the plugin's :::, CRLF, and skips what is not a card", () => {
    const cards = cardsOfText("to eat::食べる::taberu\r\nto drink:::飲む\r\nnot a card\r\n::nothing\r\nfront::\r\nあ:a\r\n  spaced :: out :: \r\n");
    assert.deepEqual(cards, [
      { front: "to eat", back: "食べる", extra: "taberu" },
      { front: "to drink", back: "飲む", extra: null },
      { front: "spaced", back: "out", extra: null },
    ]);
  });
});

describe("the CSV reader, harder", () => {
  it("survives a BOM, CRLF, a semicolon file and a newline inside quotes", () => {
    const text = 'front;back\r\n"a;b";"line one\nline two"\r\n\r\nc;d\r\n';
    assert.equal(delimiterOf(text), ";");
    assert.deepEqual(parseDelimited(text, ";"), [["front", "back"], ["a;b", "line one\nline two"], ["c", "d"]]);
  });
});

describe("a typed answer, harder", () => {
  it("folds width and case for kana and romaji, and reads an empty answer as all missed", () => {
    assert.equal(normaliseAnswer("カタカナ"), normaliseAnswer("カタカナ"));
    assert.equal(normaliseAnswer("KA "), "ka");
    assert.equal(diffAnswer("か", "か").ok, true);
    const empty = diffAnswer("", "ka");
    assert.equal(empty.ok, false);
    assert.deepEqual(empty.ops, [{ kind: "add", text: "ka" }]);
  });
});