Ctrl K
ComunidadGuías…

tests/composer.test.ts

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

Archivo original ↗Preguntar a KiBot
// THE COMPOSER'S ARITHMETIC, ASSERTED. client/editor/composeText.ts is the
// pure half of the selection-menu composer commands (commands.ts dispatches
// exactly what these functions return), kept free of CodeMirror and the DOM
// precisely so this file can drive it under `node --test` — the same split
// keymap.ts makes for the keyboard gate.
//
// What each block guards:
//   - EXTRACT: the splice and its round trip. The live command writes the
//     source through the editor and the target through the API; what can rot
//     silently is the arithmetic — the link standing exactly where the
//     selection was, and the snapshot restore being a true inverse (and an
//     IDEMPOTENT one: the toast's Undo runs at most once by construction, but
//     a restore that only works once is a restore that breaks the moment a
//     retry path appears).
//   - FOOTNOTES: the numbering. A command that always inserts [^1] is
//     actively harmful from the second footnote on, so the number is the
//     feature and every case that decides it is pinned: fresh note, append,
//     insert-between (renumbering refs AND defs), gaps kept, word labels
//     untouched, code refused, ambiguity refused.
//   - CASE: the skip list. Uppercasing `[[iPhone|the phone]]` into
//     `[[IPHONE|…]]` points at a file that does not exist — the target is an
//     address, only the alias is prose.
//   - CALLOUT: the blank line. A blank line ends a blockquote, so the naive
//     wrap breaks the callout at the first paragraph break.

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
  applyChanges,
  calloutWrap,
  extractedNote,
  linkFor,
  noteFileName,
  planFootnote,
  spliceSelection,
  suggestedSelectionName,
  transformCase,
} from "../client/editor/composeText.ts";

// ── Extract ────────────────────────────────────────────────────────────────

describe("extract selection", () => {
  const content = "# Notes\n\nAlpha beta gamma delta.\n\nLast paragraph.\n";
  const from = content.indexOf("beta");
  const to = content.indexOf(" delta");

  it("replaces exactly the selection with the link", () => {
    const source = spliceSelection(content, from, to, "Beta Gamma");
    assert.equal(source, "# Notes\n\nAlpha [[Beta Gamma]] delta.\n\nLast paragraph.\n");
  });

  it("round-trips: carried text + splice cover the original exactly", () => {
    const carried = content.slice(from, to);
    const source = spliceSelection(content, from, to, "Beta Gamma");
    // Undo is a snapshot restore; the arithmetic inverse is putting the
    // carried text back where the link stands.
    const restored =
      source.slice(0, from) + carried + source.slice(from + linkFor("Beta Gamma").length);
    assert.equal(restored, content);
  });

  it("undo (snapshot restore) is idempotent — a double undo changes nothing", () => {
    // The live undo writes the snapshot back through applyNoteContent; a
    // second application of the same snapshot must be a no-op.
    const snapshot = content;
    let doc = spliceSelection(content, from, to, "Beta Gamma");
    doc = snapshot; // first undo
    const again = snapshot; // second undo
    assert.equal(doc, again);
    assert.equal(again, content);
  });

  it("the extracted note is the selection plus a final newline, once", () => {
    assert.equal(extractedNote("body"), "body\n");
    assert.equal(extractedNote("body\n"), "body\n");
  });

  it("offers a filename from the selection's opening words, vault-spellable", () => {
    assert.equal(suggestedSelectionName("**Bold start** of a thought\nmore"), "Bold start of a thought.md");
    // A sentence's trailing punctuation must not become "delta..md".
    assert.equal(suggestedSelectionName("Alpha beta gamma delta."), "Alpha beta gamma delta.md");
    // [, ] and # cannot appear in a name the [[stub]] must spell.
    assert.equal(suggestedSelectionName("weird ]] name [[x#y"), "weird name x y.md");
    // Whitespace-only selections still offer something typeable.
    assert.equal(suggestedSelectionName("   \n\t"), "Note.md");
    assert.equal(noteFileName("a/b\\c", "Note"), "a b c.md");
  });
});

// ── Footnotes ──────────────────────────────────────────────────────────────

/** Plan + apply in one move, so every case asserts the final document. */
function footnote(doc: string, at: number): { doc: string; id: number; caret: number } | null {
  const plan = planFootnote(doc, at);
  if (plan === null) return null;
  const out = applyChanges(doc, plan.changes);
  return { doc: out, id: plan.id, caret: plan.caret };
}

describe("footnote insertion", () => {
  it("first footnote in a note: [^1], stub after a blank line", () => {
    const doc = "One paragraph.";
    const got = footnote(doc, doc.length);
    assert.ok(got);
    assert.equal(got.doc, "One paragraph.[^1]\n\n[^1]: ");
    assert.equal(got.id, 1);
    // The caret lands in the stub — the next thing typed is the footnote.
    assert.equal(got.caret, got.doc.length);
  });

  it("appending after the last footnote continues the numbering", () => {
    const doc = "Alpha[^1] beta.\n\n[^1]: first\n";
    const at = doc.indexOf(" beta") + " beta".length;
    const got = footnote(doc, at);
    assert.ok(got);
    assert.equal(got.doc, "Alpha[^1] beta[^2].\n\n[^1]: first\n[^2]: ");
    assert.equal(got.id, 2);
  });

  it("inserting BETWEEN footnotes renumbers the later ones, refs and defs", () => {
    const doc = "Alpha[^1] beta gamma[^2].\n\n[^1]: first\n[^2]: second\n";
    const at = doc.indexOf(" gamma");
    const got = footnote(doc, at);
    assert.ok(got);
    // New ref is [^2]; the old [^2] moves to [^3] in BOTH places.
    assert.equal(got.doc, "Alpha[^1] beta[^2] gamma[^3].\n\n[^1]: first\n[^3]: second\n[^2]: ");
    assert.equal(got.id, 2);
  });

  it("keeps a numbering gap the author made — never 'tidies' 5 down", () => {
    const doc = "Alpha[^1] beta gamma[^5].\n\n[^1]: a\n[^5]: b\n";
    const at = doc.indexOf(" gamma");
    const got = footnote(doc, at);
    assert.ok(got);
    // 2 fits between 1 and 5; renumbering 5 would be an edit nobody asked for.
    assert.equal(got.doc, "Alpha[^1] beta[^2] gamma[^5].\n\n[^1]: a\n[^5]: b\n[^2]: ");
  });

  it("a renumbering cascade stays strictly ordered", () => {
    const doc = "a[^1] b c[^2] d[^3].\n\n[^1]: x\n[^2]: y\n[^3]: z\n";
    const at = doc.indexOf(" c");
    const got = footnote(doc, at);
    assert.ok(got);
    assert.equal(got.doc, "a[^1] b[^2] c[^3] d[^4].\n\n[^1]: x\n[^3]: y\n[^4]: z\n[^2]: ");
  });

  it("word-labelled footnotes are prose, not arithmetic — never renamed", () => {
    const doc = "a[^note] b[^1].\n\n[^note]: words\n[^1]: x\n";
    const at = doc.indexOf(" b");
    const got = footnote(doc, at);
    assert.ok(got);
    assert.equal(got.doc, "a[^note][^1] b[^2].\n\n[^note]: words\n[^2]: x\n[^1]: ");
  });

  it("a second reference to one footnote stays with its first appearance", () => {
    const doc = "a[^1] b[^1] c.\n\n[^1]: x\n";
    const got = footnote(doc, doc.indexOf(" c") + 2);
    assert.ok(got);
    // Both refs of 1 keep their number; the new footnote is 2.
    assert.equal(got.doc, "a[^1] b[^1] c[^2].\n\n[^1]: x\n[^2]: ");
  });

  it("ignores [^…] inside fenced blocks and inline code", () => {
    const doc = "```py\nx[^9]\n```\nprose `[^7]` here.";
    const got = footnote(doc, doc.length);
    assert.ok(got);
    // Neither 9 nor 7 counts; this is the note's first real footnote.
    assert.equal(got.id, 1);
    assert.ok(got.doc.endsWith("here.[^1]\n\n[^1]: "));
  });

  it("refuses with the caret inside a code fence", () => {
    const doc = "```\ncode here\n```\n";
    assert.equal(planFootnote(doc, doc.indexOf("here")), null);
  });

  it("refuses an id defined twice — renumbering ambiguity is corruption", () => {
    const doc = "a[^1].\n\n[^1]: x\n[^1]: y\n";
    assert.equal(planFootnote(doc, doc.indexOf(".")), null);
  });

  it("joins an existing definition block without a blank line", () => {
    // Caret BEFORE the existing footnote: the newcomer takes 1, the old 1
    // moves up, and the new stub lands directly under the definition block —
    // no second blank line opening a second block.
    const doc = "a b[^1].\n\n[^1]: x\n";
    const got = footnote(doc, 1);
    assert.ok(got);
    assert.equal(got.doc, "a[^1] b[^2].\n\n[^2]: x\n[^1]: ");
    assert.equal(got.id, 1);
  });
});

// ── Case transforms ────────────────────────────────────────────────────────

describe("case transforms", () => {
  it("upper / lower over plain prose", () => {
    assert.equal(transformCase("Alpha beta", "upper"), "ALPHA BETA");
    assert.equal(transformCase("Alpha BETA", "lower"), "alpha beta");
  });

  it("Title Case: small words interior, first and last always capitalized", () => {
    assert.equal(transformCase("the lord of the rings", "title"), "The Lord of the Rings");
    assert.equal(transformCase("from template a", "title"), "From Template A");
  });

  it("Title Case leaves an author's inner capitals alone (iOS, not Ios)", () => {
    assert.equal(transformCase("iOS notes", "title"), "iOS Notes");
  });

  it("skips a wikilink entirely when it has no alias — the text IS the address", () => {
    assert.equal(
      transformCase("see [[iPhone Notes]] today", "upper"),
      "SEE [[iPhone Notes]] TODAY",
    );
  });

  it("transforms only the ALIAS half of an aliased wikilink", () => {
    assert.equal(
      transformCase("see [[iPhone|the phone]] today", "upper"),
      "SEE [[iPhone|THE PHONE]] TODAY",
    );
    // "the" is INTERIOR to the phrase being titled (the alias is display
    // text inside it, not a title of its own), so the small-word rule reads
    // across the link boundary.
    assert.equal(
      transformCase("read [[guide|the guide to x]] now", "title"),
      "Read [[guide|the Guide to X]] Now",
    );
  });

  it("skips code spans, backticks included", () => {
    assert.equal(
      transformCase("run `npm test` twice", "upper"),
      "RUN `npm test` TWICE",
    );
  });

  it("word positions count across skipped spans — 'the' after code is interior", () => {
    // "of" sits straight after a code span; a per-segment implementation
    // would see it as a segment's first word and capitalize it.
    assert.equal(
      transformCase("state `x` of the world", "title"),
      "State `x` of the World",
    );
  });
});

// ── Callouts ───────────────────────────────────────────────────────────────

describe("callout wrap", () => {
  it("wraps a single line", () => {
    assert.equal(calloutWrap("One line.", "note"), "> [!note]\n> One line.");
  });

  it("keeps a multi-paragraph selection in ONE callout: blank lines become >", () => {
    assert.equal(
      calloutWrap("First para.\n\nSecond para.", "warning"),
      "> [!warning]\n> First para.\n>\n> Second para.",
    );
  });

  it("a whitespace-only line is blank too", () => {
    assert.equal(
      calloutWrap("a\n   \nb", "tip"),
      "> [!tip]\n> a\n>\n> b",
    );
  });
});