Ctrl K
ComunidadGuías…

tests/bulkrewrite.test.ts

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

Archivo original ↗Preguntar a KiBot
// The two v1.8 bulk verbs and the engine under them: tag rename/merge
// (server/tagRewrite.ts) and heading-link repair (server/headingRepair.ts).
//
// Both are the same promise — "change anything, break nothing" — and both are
// only worth shipping if the rewrites are BYTE-SURGICAL. So most of what is
// asserted here is what did NOT change: the comment above the key, the quote
// style inside the list, the `#define` in the shell fence, the CRLF line
// endings, the alias on the far side of the wikilink. A tag renamer that eats
// one of those is a tag renamer nobody can be given.

import assert from "node:assert/strict";
import { writeFileSync } from "node:fs";
import path from "node:path";
import { after, before, describe, it } from "node:test";
import {
  applyBulk,
  previewBulk,
  undoBulk,
  type BulkTransform,
} from "../server/bulkRewrite.ts";
import {
  anchorsOfContent,
  clearHeadingChains,
  detectHeadingRename,
  observeWrite,
  rewriteHeadingLinks,
} from "../server/headingRepair.ts";
import { isTagName, rewriteTag } from "../server/tagRewrite.ts";
import { initIndexer, notesWithTag } from "../server/indexer.ts";
import { initSite } from "../server/site.ts";
import { initVault, readNote } from "../server/vault.ts";
import { makeDir, makeVault, note, removeVault } from "./helpers/vault.ts";

// ===================================================================== tags

describe("tag names", () => {
  it("accepts what the indexer can read back", () => {
    for (const name of ["task", "zettel/seed", "v2", "_wip", "كتب-عربية", "مسودة"]) {
      assert.ok(isTagName(name), `${name} should be a tag`);
    }
  });

  it("refuses what a note could not carry", () => {
    // A name the rewrite could write and the indexer could not read back would
    // silently DELETE the tag from every note it claimed to rename.
    for (const name of ["", "  ", "-lead", "two words", "a//b", "/lead", "trail/", "a,b", "a#b"]) {
      assert.ok(!isTagName(name), `${JSON.stringify(name)} should not be a tag`);
    }
  });
});

describe("tag rename: inline #tags", () => {
  const rw = (src: string, from = "draft", to = "wip") => rewriteTag("N.md", src, from, to);

  it("rewrites a tag in prose and leaves the sentence alone", () => {
    const out = rw("A #draft note, and (#draft) again — but #drafting is a word.\n");
    assert.equal(
      out?.text,
      "A #wip note, and (#wip) again — but #drafting is a word.\n",
    );
    assert.equal(out?.count, 2);
  });

  it("carries the whole nested subtree", () => {
    const out = rewriteTag("N.md", "#zettel and #zettel/seed and #zettelkasten\n", "zettel", "slip");
    assert.equal(out?.text, "#slip and #slip/seed and #zettelkasten\n");
    assert.equal(out?.count, 2);
  });

  it("NEVER EDITS CODE — the reason this is not a find-and-replace", () => {
    // server/indexer.ts's parseTags reads `#draft` inside a fence as a tag (a
    // known over-count, tests/tags.test.ts pins it). Rewriting it would be data
    // loss in somebody's shell script.
    const src = "```sh\n#draft is a comment here\n```\n\nAnd `#draft` inline.\n\nReal: #draft\n";
    const out = rw(src);
    assert.equal(
      out?.text,
      "```sh\n#draft is a comment here\n```\n\nAnd `#draft` inline.\n\nReal: #wip\n",
    );
    assert.equal(out?.count, 1);
  });

  it("keeps a tag that only TOUCHES a code span", () => {
    // The pattern consumes the boundary character before the hash, so the
    // match can start one byte outside a span the hash is inside — and one
    // byte inside a span the hash is outside.
    const out = rw("`x` #draft and ` #draft ` and `#draft`\n");
    assert.equal(out?.text, "`x` #wip and ` #draft ` and `#draft`\n");
    assert.equal(out?.count, 1);
  });

  it("keeps CRLF line endings byte for byte", () => {
    const out = rw("line one\r\n#draft\r\nline three\r\n");
    assert.equal(out?.text, "line one\r\n#wip\r\nline three\r\n");
  });

  it("does not touch the frontmatter as if it were prose", () => {
    // `title: A #draft of the thing` is a VALUE, not a tag line. It is inside
    // the block, so the inline pass must never see it.
    const src = "---\ntitle: A #draft of the thing\n---\n\nBody #draft\n";
    const out = rw(src);
    assert.equal(out?.text, "---\ntitle: A #draft of the thing\n---\n\nBody #wip\n");
    assert.equal(out?.count, 1);
  });

  it("renames Arabic tags", () => {
    const out = rewriteTag("N.md", "نص فيه #مسودة هنا\n", "مسودة", "قيد-العمل");
    assert.equal(out?.text, "نص فيه #قيد-العمل هنا\n");
  });

  it("answers null when the note carries nothing to change", () => {
    assert.equal(rw("Nothing here.\n"), null);
  });
});

describe("tag rename: frontmatter, byte-surgically", () => {
  it("keeps a flow list's quotes, hashes and spacing", () => {
    const src = '---\ntags: ["#delta", \'draft\',  plain]\nother: x\n---\nbody\n';
    const out = rewriteTag("N.md", src, "draft", "wip");
    assert.equal(out?.text, '---\ntags: ["#delta", \'wip\',  plain]\nother: x\n---\nbody\n');
  });

  it("keeps a trailing comment on the key line", () => {
    // THE DIFFERENTIATOR. Obsidian's properties editor round-trips this away.
    const src = "---\ntags: [draft, ideas] # the workflow ones\n---\nbody\n";
    const out = rewriteTag("N.md", src, "draft", "wip");
    assert.equal(out?.text, "---\ntags: [draft, ideas] # the workflow ones\n---\nbody\n".replace("draft,", "wip,"));
  });

  it("keeps a block list a block list", () => {
    const src = "---\npublish: true\ntags:\n  - zeta\n  - draft\nother: x\n---\nbody\n";
    const out = rewriteTag("N.md", src, "draft", "wip");
    assert.equal(out?.text, "---\npublish: true\ntags:\n  - zeta\n  - wip\nother: x\n---\nbody\n");
  });

  it("reads a bare scalar and a comma scalar", () => {
    assert.equal(
      rewriteTag("N.md", "---\ntags: draft\n---\nb\n", "draft", "wip")?.text,
      "---\ntags: wip\n---\nb\n",
    );
    assert.equal(
      rewriteTag("N.md", "---\ntags: draft, ideas\n---\nb\n", "draft", "wip")?.text,
      "---\ntags: wip, ideas\n---\nb\n",
    );
  });

  it("leaves an INDENTED tags: key alone — it belongs to another mapping", () => {
    const src = "---\nmeta:\n  tags: [draft]\n---\nbody\n";
    assert.equal(rewriteTag("N.md", src, "draft", "wip"), null);
  });

  it("rewrites frontmatter AND body in one pass", () => {
    const src = "---\ntags: [draft]\n---\n\nProse with #draft in it.\n";
    const out = rewriteTag("N.md", src, "draft", "wip");
    assert.equal(out?.text, "---\ntags: [wip]\n---\n\nProse with #wip in it.\n");
    assert.equal(out?.count, 2);
  });

  it("keeps a `.tex` note's comment block commented, and never touches its body", () => {
    // `#` is a macro-parameter character in LaTeX: `#draft` in a .tex file is a
    // compile error, not a tag. Only the frontmatter moves.
    const src = "%---\n% publish: true\n% tags: [draft, ideas]\n%---%\n\\section{A} % #draft\n";
    const out = rewriteTag("Paper.tex", src, "draft", "wip");
    assert.equal(out?.text, "%---\n% publish: true\n% tags: [wip, ideas]\n%---%\n\\section{A} % #draft\n");
    assert.equal(out?.count, 1);
  });
});

describe("tag MERGE: renaming onto a name that exists", () => {
  it("does not print the target twice in a flow list", () => {
    const out = rewriteTag("N.md", "---\ntags: [alpha, beta]\n---\nb\n", "alpha", "beta");
    assert.equal(out?.text, "---\ntags: [beta]\n---\nb\n");
  });

  it("drops the duplicate whichever side it falls on", () => {
    const out = rewriteTag("N.md", "---\ntags: [beta, alpha]\n---\nb\n", "alpha", "beta");
    assert.equal(out?.text, "---\ntags: [beta]\n---\nb\n");
  });

  it("removes the whole line in a block list", () => {
    const src = "---\ntags:\n  - beta\n  - alpha\n  - gamma\n---\nb\n";
    const out = rewriteTag("N.md", src, "alpha", "beta");
    assert.equal(out?.text, "---\ntags:\n  - beta\n  - gamma\n---\nb\n");
  });

  it("removes the duplicate at the END of a comma scalar, separator and all", () => {
    const out = rewriteTag("N.md", "---\ntags: alpha, beta\n---\nb\n", "alpha", "beta");
    assert.equal(out?.text, "---\ntags: beta\n---\nb\n");
  });

  it("leaves PROSE alone — two #betas in one sentence is the author's sentence", () => {
    const out = rewriteTag("N.md", "About #beta and #alpha.\n", "alpha", "beta");
    assert.equal(out?.text, "About #beta and #beta.\n");
  });
});

// ================================================================== headings

const anchors = (src: string) => anchorsOfContent("N.md", src);

describe("heading rename detection", () => {
  it("sees one heading renamed in place", () => {
    const hit = detectHeadingRename(
      anchors("# Top\n\n## Introduction\n\n## End\n"),
      anchors("# Top\n\n## Preface\n\n## End\n"),
    );
    assert.equal(hit?.from.id, "introduction");
    assert.equal(hit?.to.id, "preface");
    assert.equal(hit?.to.title, "Preface");
  });

  it("refuses to guess when two headings moved at once", () => {
    // A paste, not a rename. Rewriting the vault on a heuristic is exactly the
    // failure mode a bulk tool cannot have.
    assert.equal(
      detectHeadingRename(anchors("# A\n\n## B\n"), anchors("# X\n\n## Y\n")),
      null,
    );
  });

  it("says nothing when a heading is added or removed", () => {
    assert.equal(detectHeadingRename(anchors("# A\n"), anchors("# A\n\n## B\n")), null);
    assert.equal(detectHeadingRename(anchors("# A\n\n## B\n"), anchors("# A\n")), null);
  });

  it("says nothing when the SLUG survives the edit", () => {
    // "Introduction" → "Introduction!" is the same anchor; no link broke, so
    // there is nothing to offer and nothing to interrupt anybody about.
    assert.equal(
      detectHeadingRename(anchors("## Introduction\n"), anchors("## Introduction!\n")),
      null,
    );
  });
});

describe("the rename CHAIN — a save fires while the reader is still typing", () => {
  const before0 = anchors("## Introduction\n");

  it("keeps naming the anchor the vault's links actually point at", () => {
    clearHeadingChains();
    // Three saves, one word being retyped a letter at a time.
    const one = observeWrite("N.md", before0, "## Introductio\n");
    assert.equal(one?.from, "introduction");
    const two = observeWrite("N.md", anchors("## Introductio\n"), "## Introduc\n");
    assert.equal(two?.from, "introduction", "the chain must not forget where it started");
    const three = observeWrite("N.md", anchors("## Introduc\n"), "## Preface\n");
    assert.equal(three?.from, "introduction");
    assert.equal(three?.to, "preface");
    assert.equal(three?.toTitle, "Preface");
  });

  it("dissolves when the heading is typed back to what it was", () => {
    clearHeadingChains();
    observeWrite("N.md", before0, "## Introductio\n");
    const back = observeWrite("N.md", anchors("## Introductio\n"), "## Introduction\n");
    assert.equal(back, null, "an identity rename is not a rename");
  });

  it("starts a NEW chain when the next edit is somewhere else", () => {
    clearHeadingChains();
    observeWrite("N.md", anchors("## A\n\n## B\n"), "## X\n\n## B\n");
    const next = observeWrite("N.md", anchors("## X\n\n## B\n"), "## X\n\n## Y\n");
    assert.equal(next?.from, "b", "a second heading is its own rename");
  });
});

describe("heading-link rewriting", () => {
  const yes = () => true;
  const from = { id: "introduction", title: "Introduction" };
  const to = { id: "preface", title: "Preface" };

  it("answers a slug tail with the new slug and a title tail with the new title", () => {
    // findAnchor() accepts both spellings, so the repair has to answer in the
    // register it was addressed in — rewriting `[[N#Introduction]]` into
    // `[[N#preface]]` would work and would also vandalise the reader's prose.
    const out = rewriteHeadingLinks("[[N#introduction]] and [[N#Introduction]]\n", yes, from, to);
    assert.equal(out.text, "[[N#preface]] and [[N#Preface]]\n");
    assert.equal(out.count, 2);
  });

  it("keeps the alias, the embed bang and the target's own spelling", () => {
    const out = rewriteHeadingLinks("![[Folder/N#Introduction|see here]]\n", yes, from, to);
    assert.equal(out.text, "![[Folder/N#Preface|see here]]\n");
  });

  it("leaves links into OTHER notes alone", () => {
    const out = rewriteHeadingLinks(
      "[[N#Introduction]] and [[Other#Introduction]]\n",
      (target) => target === "N",
      from,
      to,
    );
    assert.equal(out.text, "[[N#Preface]] and [[Other#Introduction]]\n");
    assert.equal(out.count, 1);
  });

  it("leaves a link with no anchor alone", () => {
    const out = rewriteHeadingLinks("[[N]] and [[N|alias]]\n", yes, from, to);
    assert.equal(out.count, 0);
  });
});

// ============================================== the candidate list, on a vault

const data = makeDir();
const root = makeVault({
  "Inline.md": "Prose with #draft and #zettel/seed.\n",
  "Front.md": note({ tags: "[draft, ideas]" }, "body\n"),
  "Both.md": note({ tags: "draft" }, "and #draft again\n"),
  "Clean.md": "nothing here\n",
  "Nested.md": "#zettel/seed only\n",
});

before(async () => {
  initSite({ ASTROLABE_DATA: data });
  initVault(root);
  await initIndexer();
});

after(() => {
  removeVault(root);
  removeVault(data);
});

describe("notesWithTag: the candidate list", () => {
  it("finds inline, frontmatter and both", () => {
    assert.deepEqual(notesWithTag("draft"), ["Both.md", "Front.md", "Inline.md"]);
  });

  it("includes the nested children of the tag being renamed", () => {
    assert.deepEqual(notesWithTag("zettel"), ["Inline.md", "Nested.md"]);
  });

  it("is empty for a tag nothing carries", () => {
    assert.deepEqual(notesWithTag("nothing"), []);
  });
});

// ================================================= the engine, on a real vault
//
// Everything above is pure. This is the part that writes: the preview and the
// apply must run the SAME transform over the SAME reads (or the preview is a
// preview of a different operation), and the undo must put every byte back.
// These run LAST in the file because they change the fixture underneath.

describe("bulkRewrite: preview, apply, undo", () => {
  const rename = (from: string, to: string): BulkTransform => (relPath, content) =>
    rewriteTag(relPath, content, from, to);

  it("previews exactly what the apply will change, and writes nothing", async () => {
    const preview = await previewBulk(notesWithTag("draft"), rename("draft", "wip"));
    assert.deepEqual(
      preview.files.map((f) => f.path),
      ["Both.md", "Front.md", "Inline.md"],
    );
    assert.equal(preview.edits, 4, "Both.md carries the tag twice");
    assert.ok(preview.files[0].lines.length > 0, "a preview shows the lines it will touch");
    // The dry run is a dry run.
    assert.match((await readNote("Inline.md")).content, /#draft/);
  });

  it("applies, and hands back a way out", async () => {
    const before = new Map<string, string>();
    for (const p of notesWithTag("draft")) before.set(p, (await readNote(p)).content);

    const result = await applyBulk(notesWithTag("draft"), rename("draft", "wip"));
    assert.equal(result.notes, 3);
    assert.equal(result.edits, 4);
    assert.deepEqual(result.skipped, []);
    assert.ok(result.undoId, "an edit this size must be undoable");
    assert.match((await readNote("Inline.md")).content, /#wip/);
    assert.equal((await readNote("Front.md")).content.includes("draft"), false);
    // The index moved with the files: the old tag is gone, the new one is there.
    assert.deepEqual(notesWithTag("draft"), []);
    assert.deepEqual(notesWithTag("wip"), ["Both.md", "Front.md", "Inline.md"]);

    // …and back, byte for byte.
    const undone = await undoBulk(result.undoId as string);
    assert.equal(undone.notes, 3);
    for (const [p, text] of before) {
      assert.equal((await readNote(p)).content, text, `${p} did not come back unchanged`);
    }
    assert.deepEqual(notesWithTag("draft"), ["Both.md", "Front.md", "Inline.md"]);
  });

  it("SKIPS a note that changed under it rather than clobbering the change", async () => {
    // The promise the whole engine rests on. A second writer lands between our
    // read and our write; that note is left exactly as they left it, and the
    // reader is told which one.
    const result = await applyBulk(notesWithTag("draft"), (relPath, content) => {
      if (relPath === "Front.md") {
        // Somebody else's save, in the window this call is meant to close.
        writeFileSync(path.join(root, "Front.md"), "---\ntags: [draft]\n---\nSOMEBODY ELSE\n");
      }
      return rewriteTag(relPath, content, "draft", "wip");
    });
    assert.deepEqual(result.skipped, [{ path: "Front.md", reason: "conflict" }]);
    assert.match((await readNote("Front.md")).content, /SOMEBODY ELSE/);
    if (result.undoId) await undoBulk(result.undoId);
  });

  it("refuses an undo it can no longer honour", async () => {
    await assert.rejects(() => undoBulk("not-a-bundle"), /taken back/);
  });
});