Ctrl K
ComunidadGuías…

tests/decks.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 {
  EVERYTHING_ELSE,
  deckNotePath,
  deckOf,
  parseDeckFence,
  parseSteps,
  restoreCardSchedule,
  scanDeckCards,
  serialiseDeck,
  writeCardSchedule,
} from "../shared/decks.ts";
import { clearSchedule, scanCards, writeSchedule } from "../shared/cards.ts";
import { formatSrComments, parseSrComment, parseSrComments } from "../shared/srs.ts";

// ------------------------------------------------------------------ fixtures

/** A kana deck: typed, its own steps, tags, a section, an extra segment. */
const KANA = `---
title: Hiragana
---

\`\`\`deck
icon: あ
kind: typed
new per day: 5
steps: 1m, 10m, 1h
tags: japanese, #kana
\`\`\`

## Row a

あ::a
い::i::as in "eat"
う::u <!--SR:!2026-09-20,6,2500-->

## Row k

か::ka #kana #k-row
き::ki
`;

/** Reversed pairs, a plain one and a scheduled one, plus a kind: both note. */
const PAIRS = `\`\`\`deck
title: Capitals
\`\`\`

Egypt:::Cairo
Iran:::Tehran <!--SR:!2026-09-20,6,2500!2026-09-25,3,2350-->
Iraq:::Baghdad::since 762
`;

/** Sections, highlights, a block card and a quote in one note. */
const MIXED = `# Astronomy

\`\`\`deck
kind: basic
\`\`\`

## Stars

The nearest star is ==Proxima Centauri==.

What is a light year?
?
The distance light travels in a year.

## Quotes

> [!quote] Sagan
> We are made of star stuff.

Betelgeuse::a red supergiant
`;

/** A note from before decks: two-segment cards, one SR comment. */
const LEGACY = `# Cards

Capital of Egypt::Cairo <!--SR:!2026-09-27,4,2500-->

What is the rule of three?
?
Never keep fewer than three copies.
<!--SR:!2026-09-20,6,2500-->
`;

// ------------------------------------------------------------- the comment

describe("the plugin's multi-schedule comment", () => {
  it("reads one, reads two, writes two, and the first is still the first", () => {
    const two = "<!--SR:!2026-09-20,6,2500!2026-09-25,3,2350-->";
    assert.deepEqual(parseSrComment(two), { due: "2026-09-20", interval: 6, ease: 2500 });
    assert.deepEqual(parseSrComments(two), [
      { due: "2026-09-20", interval: 6, ease: 2500 },
      { due: "2026-09-25", interval: 3, ease: 2350 },
    ]);
    assert.equal(formatSrComments(parseSrComments(two)), two);
    assert.deepEqual(parseSrComments("<!--SR:!2026-09-20,6,2500-->"), [{ due: "2026-09-20", interval: 6, ease: 2500 }]);
    assert.deepEqual(parseSrComments("no comment"), []);
  });
});

// ---------------------------------------------------------------- the fence

describe("the deck fence", () => {
  it("reads every key with its spaces folded, and defaults the rest", () => {
    const head = parseDeckFence(KANA)!;
    assert.deepEqual(head, { title: null, icon: "あ", kind: "typed", newPerDay: 5, steps: [1, 10, 60], tags: ["japanese", "kana"] });
    const bare = parseDeckFence("```deck\n```\n")!;
    assert.deepEqual(bare, { title: null, icon: null, kind: "basic", newPerDay: 10, steps: [1, 10], tags: [] });
    assert.equal(parseDeckFence("```deck\nkind: nonsense\nnewPerDay: -3\nsteps: soon\n```")!.kind, "basic");
    assert.equal(parseDeckFence("```deck\nnew-per-day: 0\nkind: Cloze only\n```")!.newPerDay, 0);
    assert.equal(parseDeckFence("```deck\nkind: cloze only\n```")!.kind, "cloze-only");
  });
  it("is null without a fence, and a fence shown inside a code block is documentation", () => {
    assert.equal(parseDeckFence(LEGACY), null);
    assert.equal(parseDeckFence("````markdown\n```deck\nicon: x\n```\n````\n"), null);
    assert.equal(parseDeckFence("---\ntitle: x\n---\n```deck\nicon: y\n```")!.icon, "y");
  });
  it("parses steps in minutes, hours and days", () => {
    assert.deepEqual(parseSteps("1m, 10m"), [1, 10]);
    assert.deepEqual(parseSteps("5 30 2h 1d"), [5, 30, 120, 1440]);
    assert.deepEqual(parseSteps("0m, x"), []);
  });
});

// -------------------------------------------------------------------- cards

describe("cards, extended", () => {
  it("reads the extra segment, trailing tags and the section", () => {
    const cards = scanCards(KANA);
    assert.deepEqual(cards.map((c) => [c.front, c.back, c.extra, c.section, c.tags]), [
      ["あ", "a", null, "Row a", []],
      ["い", "i", 'as in "eat"', "Row a", []],
      ["う", "u", null, "Row a", []],
      ["か", "ka", null, "Row k", ["kana", "k-row"]],
      ["き", "ki", null, "Row k", []],
    ]);
    assert.equal(cards[2].schedule?.interval, 6);
    assert.equal(cards.every((c) => !c.reversed), true);
  });
  it("reads a ::: pair with two schedules, and an extra after the pair", () => {
    const cards = scanCards(PAIRS);
    assert.equal(cards.length, 3);
    assert.deepEqual(cards.map((c) => [c.front, c.back, c.reversed, c.extra]), [
      ["Egypt", "Cairo", true, null],
      ["Iran", "Tehran", true, null],
      ["Iraq", "Baghdad", true, "since 762"],
    ]);
    assert.equal(cards[0].schedule, null);
    assert.equal(cards[0].scheduleRev, null);
    assert.deepEqual(cards[1].schedule, { due: "2026-09-20", interval: 6, ease: 2500 });
    assert.deepEqual(cards[1].scheduleRev, { due: "2026-09-25", interval: 3, ease: 2350 });
  });
  it("keeps the legacy shapes exactly as before", () => {
    const cards = scanCards(LEGACY);
    assert.deepEqual(cards.map((c) => [c.kind, c.line, c.end, c.front, c.back, c.extra, c.reversed, c.tags, c.section]), [
      ["qa", 3, 3, "Capital of Egypt", "Cairo", null, false, [], "Cards"],
      ["qa", 5, 7, "What is the rule of three?", "Never keep fewer than three copies.", null, false, [], "Cards"],
    ]);
    assert.equal(cards[0].schedule?.interval, 4);
    assert.equal(cards[1].schedule?.interval, 6);
  });
  it("round-trips a legacy note byte for byte when the schedule is unchanged", () => {
    const same = writeSchedule(LEGACY, 3, { due: "2026-09-27", interval: 4, ease: 2500 });
    assert.equal(same, LEGACY);
    const sameBlock = writeSchedule(LEGACY, 5, { due: "2026-09-20", interval: 6, ease: 2500 });
    assert.equal(sameBlock, LEGACY);
  });
});

// -------------------------------------------------------------------- stars

describe("stars of a note", () => {
  it("names each star by path, line and direction, and applies the kind", () => {
    const stars = scanDeckCards(KANA, "Orbits/Hiragana.md", "typed");
    assert.deepEqual(stars.map((s) => s.id), [
      "Orbits/Hiragana.md#15#fwd",
      "Orbits/Hiragana.md#16#fwd",
      "Orbits/Hiragana.md#17#fwd",
      "Orbits/Hiragana.md#21#fwd",
      "Orbits/Hiragana.md#22#fwd",
    ]);
    assert.equal(stars[1].extra, 'as in "eat"');
    const both = scanDeckCards(KANA, "k.md", "both");
    assert.equal(both.length, 10);
    assert.deepEqual([both[0].dir, both[0].front, both[0].back, both[1].dir, both[1].front, both[1].back], ["fwd", "あ", "a", "rev", "a", "あ"]);
    const reversed = scanDeckCards(KANA, "k.md", "reversed");
    assert.equal(reversed.length, 5);
    assert.deepEqual([reversed[0].dir, reversed[0].front, reversed[0].back, reversed[0].id], ["rev", "a", "あ", "k.md#15#rev"]);
  });
  it("makes two stars of a ::: pair, each with its own schedule", () => {
    const stars = scanDeckCards(PAIRS, "c.md", "basic");
    assert.equal(stars.length, 6);
    const tehran = stars.filter((s) => s.line === 6);
    assert.deepEqual(tehran.map((s) => [s.dir, s.front, s.back, s.schedule?.interval]), [["fwd", "Iran", "Tehran", 6], ["rev", "Tehran", "Iran", 3]]);
    assert.equal(stars[5].extra, "since 762");
    assert.equal(stars[5].dir, "rev");
    // A `reversed` note leaves an explicit pair as the pair it is.
    assert.equal(scanDeckCards(PAIRS, "c.md", "reversed").length, 6);
  });
  it("keeps sections, clozes, blocks and quotes; cloze-only keeps only the clozes", () => {
    const stars = scanDeckCards(MIXED, "a.md", "basic");
    assert.deepEqual(stars.map((s) => [s.kind, s.section]), [["cloze", "Stars"], ["qa", "Stars"], ["quote", "Quotes"], ["qa", "Quotes"]]);
    assert.deepEqual(scanDeckCards(MIXED, "a.md", "cloze-only").map((s) => s.back), ["Proxima Centauri"]);
  });
  it("builds the deck with its sections in order and the note's title as a fallback", () => {
    const c = deckOf(KANA, "Orbits/Hiragana.md", "Hiragana")!;
    assert.equal(c.title, "Hiragana");
    assert.equal(c.icon, "あ");
    assert.deepEqual(c.sections, ["Row a", "Row k"]);
    assert.equal(c.cards.length, 5);
    assert.deepEqual(c.steps, [1, 10, 60]);
    assert.equal(deckOf(PAIRS, "c.md", "capitals")!.title, "Capitals");
    assert.equal(deckOf(LEGACY, "l.md", "Legacy"), null);
    assert.equal(deckOf(MIXED, "a.md", "A")!.sections.length, 2);
  });
});

// ----------------------------------------------------------------- writing

describe("writing a star's schedule", () => {
  const s1 = { due: "2026-10-01", interval: 3, ease: 2500 };
  const s2 = { due: "2026-10-05", interval: 7, ease: 2650 };

  it("writes the forward half of a pair into the first slot and the reverse into the second", () => {
    const [fwd, rev] = scanDeckCards(PAIRS, "c.md", "basic").filter((s) => s.line === 5);
    const afterFwd = writeCardSchedule(PAIRS, fwd, s1);
    assert.ok(afterFwd.includes("Egypt:::Cairo <!--SR:!2026-10-01,3,2500-->\n"));
    const afterBoth = writeCardSchedule(afterFwd, rev, s2);
    assert.ok(afterBoth.includes("Egypt:::Cairo <!--SR:!2026-10-01,3,2500!2026-10-05,7,2650-->\n"));
    const back = scanDeckCards(afterBoth, "c.md", "basic").filter((s) => s.line === 5);
    assert.deepEqual(back.map((s) => s.schedule), [s1, s2]);
    // The other pair's two schedules are untouched.
    assert.ok(afterBoth.includes("Iran:::Tehran <!--SR:!2026-09-20,6,2500!2026-09-25,3,2350-->"));
  });
  it("fills the first slot with a placeholder when the reverse half is graded first, and reads it back as new", () => {
    const [, rev] = scanDeckCards(PAIRS, "c.md", "basic").filter((s) => s.line === 5);
    const afterRev = writeCardSchedule(PAIRS, rev, s1);
    assert.ok(afterRev.includes("Egypt:::Cairo <!--SR:!2026-09-28,0,2500!2026-10-01,3,2500-->\n"));
    const [fwd2, rev2] = scanDeckCards(afterRev, "c.md", "basic").filter((s) => s.line === 5);
    assert.equal(fwd2.schedule, null);
    assert.deepEqual(rev2.schedule, s1);
    const afterFwd = writeCardSchedule(afterRev, fwd2, s2);
    assert.ok(afterFwd.includes("Egypt:::Cairo <!--SR:!2026-10-05,7,2650!2026-10-01,3,2500-->\n"));
  });
  it("replaces one half of an existing two-schedule comment without touching the other", () => {
    const [fwd, rev] = scanDeckCards(PAIRS, "c.md", "basic").filter((s) => s.line === 6);
    const next = writeCardSchedule(PAIRS, rev, s2);
    assert.ok(next.includes("Iran:::Tehran <!--SR:!2026-09-20,6,2500!2026-10-05,7,2650-->"));
    const next2 = writeCardSchedule(next, fwd, s1);
    assert.ok(next2.includes("Iran:::Tehran <!--SR:!2026-10-01,3,2500!2026-10-05,7,2650-->"));
    assert.equal((next2.match(/<!--SR/g) ?? []).length, 1);
  });
  it("knows a `both` note's reverse star from a `reversed` note's lone star", () => {
    const both = "```deck\nkind: both\n```\n\nあ::a\n";
    const rev = scanDeckCards(both, "k.md", "both")[1];
    assert.equal(rev.dir, "rev");
    assert.ok(writeCardSchedule(both, rev, s1).includes("あ::a <!--SR:!2026-09-28,0,2500!2026-10-01,3,2500-->"));
    const reversed = "```deck\nkind: reversed\n```\n\nあ::a\n";
    const lone = scanDeckCards(reversed, "k.md", "reversed")[0];
    assert.equal(lone.dir, "rev");
    assert.ok(writeCardSchedule(reversed, lone, s1).includes("あ::a <!--SR:!2026-10-01,3,2500-->"));
  });
  it("writes after a block, a cloze and a quote as before, with the section untouched", () => {
    const stars = scanDeckCards(MIXED, "a.md", "basic");
    const cloze = writeCardSchedule(MIXED, stars[0], s1);
    assert.ok(cloze.includes("==Proxima Centauri==.\n<!--SR:!2026-10-01,3,2500-->\n"));
    const quote = writeCardSchedule(MIXED, stars[2], s1);
    assert.ok(quote.includes("> We are made of star stuff.\n<!--SR:!2026-10-01,3,2500-->\n"));
    assert.equal(scanDeckCards(quote, "a.md", "basic").length, 4);
  });
  it("leaves a note alone for a star that is gone", () => {
    const ghost = { ...scanDeckCards(KANA, "k.md", "typed")[0], line: 99 };
    assert.equal(writeCardSchedule(KANA, ghost, s1), KANA);
  });
});

// The session's undo: POST /api/orbits/card/review with `restore` writes the
// schedule the star had before the grade back verbatim — or, for a first
// grade, takes the comment out — so the note is the note it was.
describe("restoring a star's schedule", () => {
  const s1 = { due: "2026-10-01", interval: 3, ease: 2500 };
  const s2 = { due: "2026-10-05", interval: 7, ease: 2650 };

  it("puts a previous schedule back verbatim, and strips a first grade's comment byte for byte", () => {
    const stars = scanDeckCards(KANA, "k.md", "typed");
    const graded = writeCardSchedule(KANA, stars[0], s1);
    assert.notEqual(graded, KANA);
    assert.equal(restoreCardSchedule(graded, { ...stars[0], schedule: s1 }, null), KANA);
    // う had a schedule before its grade; restoring it is the old comment again.
    const u = stars[2];
    const regraded = writeCardSchedule(KANA, u, s2);
    assert.ok(regraded.includes("う::u <!--SR:!2026-10-05,7,2650-->"));
    assert.equal(restoreCardSchedule(regraded, { ...u, schedule: s2 }, u.schedule), KANA);
  });
  it("keeps the twin's slot when one half of a pair is un-graded", () => {
    const [fwd, rev] = scanDeckCards(PAIRS, "c.md", "basic").filter((s) => s.line === 6);
    // Iran:::Tehran carries two schedules; undoing the reverse half's first
    // grade would leave the forward one — a trailing slot simply goes.
    const revGone = restoreCardSchedule(PAIRS, rev, null);
    assert.ok(revGone.includes("Iran:::Tehran <!--SR:!2026-09-20,6,2500-->\n"), revGone);
    // Undoing the forward half instead leaves the placeholder the plugin
    // can read and the scanner reads as "new", with the twin's kept.
    const fwdGone = restoreCardSchedule(PAIRS, fwd, null);
    assert.ok(fwdGone.includes("Iran:::Tehran <!--SR:!2026-09-20,0,2500!2026-09-25,3,2350-->\n"), fwdGone);
    const back = scanDeckCards(fwdGone, "c.md", "basic").filter((s) => s.line === 6);
    assert.deepEqual(back.map((s) => s.schedule), [null, { due: "2026-09-25", interval: 3, ease: 2350 }]);
    // Both gone: no comment at all.
    const none = restoreCardSchedule(fwdGone, back[1], null);
    assert.ok(none.includes("Iran:::Tehran\n"), none);
    assert.equal((none.match(/<!--SR/g) ?? []).length, 0);
  });
  it("removes a block's comment line, not the block", () => {
    const stars = scanDeckCards(MIXED, "a.md", "basic");
    const cloze = writeCardSchedule(MIXED, stars[0], s1);
    assert.equal(restoreCardSchedule(cloze, { ...stars[0], schedule: s1 }, null), MIXED);
    const quote = writeCardSchedule(MIXED, stars[2], s1);
    assert.equal(clearSchedule(quote, stars[2].line), MIXED);
    // Nothing to clear is nothing changed.
    assert.equal(clearSchedule(MIXED, stars[0].line), MIXED);
  });
});

// ------------------------------------------------------------- a new note

describe("a new deck note", () => {
  it("serialises the fence and the cards under their sections, and reads back the same", () => {
    const text = serialiseDeck({ title: "Hiragana", icon: "あ", kind: "typed", tags: ["japanese", "#kana"], newPerDay: 5 }, [
      { front: "あ", back: "a", section: "Row a" },
      { front: "い", back: "i", extra: "as in eat", section: "Row a" },
      { front: "か", back: "ka", section: "Row k" },
      { front: "", back: "dropped" },
      // A `::` inside a face is spaced, not collapsed: the importer's rule
      // (tests/deckImport.test.ts), now the one rule — `std::vector` keeps
      // both its colons and still cannot split the line.
      { front: "no::colons", back: "line\nbreak" },
    ]);
    assert.equal(
      text,
      [
        "---",
        "title: Hiragana",
        "---",
        "",
        "```deck",
        "icon: あ",
        "kind: typed",
        "new per day: 5",
        "tags: japanese, kana",
        "```",
        "",
        "## Row a",
        "",
        "あ::a",
        "い::i::as in eat",
        "",
        "## Row k",
        "",
        "か::ka",
        "no: :colons::line break",
        "",
      ].join("\n"),
    );
    const c = deckOf(text, "Orbits/Hiragana.md", "Hiragana")!;
    assert.deepEqual(c.sections, ["Row a", "Row k"]);
    assert.equal(c.cards.length, 4);
    assert.equal(c.kind, "typed");
    assert.equal(c.newPerDay, 5);
    assert.deepEqual(c.tags, ["japanese", "kana"]);
    assert.equal(c.cards[1].extra, "as in eat");
  });
  it("quotes a title YAML would misread and escapes a front that would be a heading", () => {
    const text = serialiseDeck({ title: "Lesson 3: verbs" }, [{ front: "# not a heading", back: "b" }]);
    assert.ok(text.startsWith('---\ntitle: "Lesson 3: verbs"\n---\n\n```deck\ntitle: Lesson 3: verbs\nkind: basic\n```\n'));
    assert.ok(text.includes("\\# not a heading::b\n"));
    const c = deckOf(text, "Orbits/Lesson 3 verbs.md", "Lesson 3 verbs")!;
    assert.equal(c.title, "Lesson 3: verbs");
    assert.equal(c.cards.length, 1);
    // A title the filename keeps whole needs no fence title.
    assert.ok(!serialiseDeck({ title: "Hiragana" }, []).includes("```deck\ntitle:"));
  });
  it("names the file by the composer's rule, in the default folder", () => {
    assert.equal(deckNotePath(undefined, "Hiragana"), "Orbits/Hiragana.md");
    assert.equal(deckNotePath("Study/Decks/", "Lesson 3: verbs [draft] #1"), "Study/Decks/Lesson 3 verbs draft 1.md");
    assert.equal(deckNotePath("", "   "), "Deck.md");
    assert.equal(EVERYTHING_ELSE, "*");
  });
});

// ------------------------------------------------------ two old mistakes

describe("a graded inline card", () => {
  it("is still a card, and a following card's comment is never mistaken for its own", () => {
    // Before: a line ending in the comment was skipped as if it were only a
    // comment, so the first grade made an inline card vanish from the vault.
    const md = "a::b\nc::d <!--SR:!2026-09-20,6,2500-->\n";
    const cards = scanCards(md);
    assert.deepEqual(cards.map((c) => [c.front, c.schedule?.interval ?? null]), [["a", null], ["c", 6]]);
    const graded = writeSchedule(md, 1, { due: "2026-10-01", interval: 1, ease: 2500 });
    assert.equal(graded, "a::b <!--SR:!2026-10-01,1,2500-->\nc::d <!--SR:!2026-09-20,6,2500-->\n");
    assert.equal(scanCards(graded).length, 2);
  });
  it("does not lose a block's neighbour when the block is graded", () => {
    const md = "Q\n?\nA\nc::d <!--SR:!2026-09-20,6,2500-->\n";
    const graded = writeSchedule(md, 1, { due: "2026-10-01", interval: 1, ease: 2500 });
    assert.equal(graded, "Q\n?\nA\n<!--SR:!2026-10-01,1,2500-->\nc::d <!--SR:!2026-09-20,6,2500-->\n");
    assert.equal(scanCards(graded).length, 2);
  });
});

// ---------------------------------------------------------------- the edges
// What the owner will meet mid-session: a line the syntax almost matches, a
// note Obsidian's plugin graded first, a note saved by Windows.

describe("the edges of a card line", () => {
  it("reads an empty extra as no extra, not as an answer ending in ::", () => {
    assert.deepEqual(scanCards("a::b::\n").map((c) => [c.front, c.back, c.extra]), [["a", "b", null]]);
  });
  it("never reads a card inside a code fence, whichever fence", () => {
    assert.deepEqual(scanCards("```\na::b\n```\n~~~\nc:::d\n~~~\nreal::yes\n").map((c) => c.front), ["real"]);
  });
  it("keeps a full-width colon in a Japanese front and back", () => {
    assert.deepEqual(scanCards("日本:東京::Japan: Tokyo\n読み:よみ::reading::example:here\n").map((c) => [c.front, c.back, c.extra]), [
      ["日本:東京", "Japan: Tokyo", null],
      ["読み:よみ", "reading", "example:here"],
    ]);
  });
  it("reads a ::: pair with only its first schedule as half graded", () => {
    const [card] = scanCards("Egypt:::Cairo <!--SR:!2026-09-20,6,2500-->\n");
    assert.equal(card.schedule?.interval, 6);
    assert.equal(card.scheduleRev, null);
  });
  it("reads a comment with spaces inside the markers", () => {
    assert.equal(scanCards("a::b <!-- SR:!2026-09-20,6,2500 -->\n")[0].schedule?.interval, 6);
  });
  it("reads and writes a CRLF note without changing its line endings", () => {
    const crlf = "```deck\r\nkind: both\r\n```\r\n\r\n## S\r\n\r\nx::y\r\nq:::w <!--SR:!2026-09-20,6,2500!2026-09-25,3,2350-->\r\n";
    assert.deepEqual(scanCards(crlf).map((c) => [c.front, c.section, c.scheduleRev?.interval ?? null]), [["x", "S", null], ["q", "S", 3]]);
    assert.equal(parseDeckFence(crlf)?.kind, "both");
    const written = writeSchedule(crlf, 7, { due: "2026-10-01", interval: 1, ease: 2500 }, 1);
    assert.equal(written.includes("\n"), true);
    assert.equal(written.split("\r\n").length, crlf.split("\r\n").length);
    assert.equal(/[^\r]\n/.test(written), false);
    assert.equal(scanCards(written)[0].scheduleRev?.interval, 1);
  });
});

describe("a kind: both note", () => {
  it("keeps the back→front grade of a :: line, and the next front→back grade keeps it too", () => {
    const both = "```deck\nkind: both\n```\n\nx::y\n";
    const stars = scanDeckCards(both, "n.md", "both");
    const rev = writeCardSchedule(both, stars[1], { due: "2026-10-01", interval: 4, ease: 2650 });
    assert.equal(rev, "```deck\nkind: both\n```\n\nx::y <!--SR:!2026-09-27,0,2500!2026-10-01,4,2650-->\n");
    const back = scanDeckCards(rev, "n.md", "both");
    assert.equal(back[0].schedule, null);
    assert.equal(back[1].schedule?.interval, 4);
    const fwd = writeCardSchedule(rev, back[0], { due: "2026-09-16", interval: 1, ease: 2500 });
    assert.equal(fwd, "```deck\nkind: both\n```\n\nx::y <!--SR:!2026-09-16,1,2500!2026-10-01,4,2650-->\n");
  });
});

describe("where the comment goes", () => {
  it("rewrites the plugin's next-line comment in place rather than adding a second", () => {
    const md = "a::b\n<!--SR:!2026-09-20,6,2500-->\nc::d\n";
    const written = writeSchedule(md, 1, { due: "2026-10-01", interval: 10, ease: 2500 });
    assert.equal(written, "a::b\n<!--SR:!2026-10-01,10,2500-->\nc::d\n");
    assert.deepEqual(scanCards(written).map((c) => [c.front, c.schedule?.interval ?? null]), [["a", 10], ["c", null]]);
  });
  it("keeps the second schedule of a two-cloze paragraph the plugin graded", () => {
    const md = "A ==b== and ==c==.\n<!--SR:!2026-09-20,6,2500!2026-09-25,3,2350-->\n";
    const written = writeSchedule(md, 1, { due: "2026-10-01", interval: 15, ease: 2500 });
    assert.equal(written, "A ==b== and ==c==.\n<!--SR:!2026-10-01,15,2500!2026-09-25,3,2350-->\n");
  });
});

describe("a note written from the modal", () => {
  it("folds a newline in the title, the icon and a tag so the fence cannot be closed early", () => {
    const text = serialiseDeck({ title: "Bad\n```\nfoo::bar", icon: "😀\nq::a", tags: ["t\n```", "two words"] }, [{ front: "x", back: "y" }]);
    const head = parseDeckFence(text)!;
    assert.equal(head.title, "Bad ``` foo::bar");
    assert.equal(head.icon, "😀 q::a");
    assert.deepEqual(head.tags, ["t-```", "two-words"]);
    assert.deepEqual(scanCards(text).map((c) => c.front), ["x"]);
  });
  it("writes a card's tag with a space the way the fence writes one, so the tag does not read back as the answer's tail", () => {
    const text = serialiseDeck({ title: "T" }, [{ front: "x", back: "y", tags: ["two words", "#c"] }]);
    assert.equal(text.endsWith("x::y #two-words #c\n"), true);
    assert.deepEqual(scanCards(text).map((c) => c.back), ["y"]);
  });
  it("never names a file with a leading dot", () => {
    assert.equal(deckNotePath(null, "..."), "Orbits/Deck.md");
    assert.equal(deckNotePath(null, ".hidden"), "Orbits/hidden.md");
  });
});