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(); 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" }]); }); });