Ctrl K
ComunidadGuías…

tests/anchors.test.ts

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

Archivo original ↗Preguntar a KiBot
// Anchors: the "#" half of a wikilink.
//
// Astrolabe has ONE anchor space with TWO resolvers, and a [[Note#Anchor]] has to
// land in both:
//   • the editor jumps by heading TEXT       (shared/headings.ts findHeadingLine)
//   • the reading view jumps by heading SLUG (client/reading/toc.ts Slugger,
//     with a textContent fallback in ReadingView)
// Every case below asks the same question of both, because a link that works
// in the editor and dies in the reading view is the bug this file exists for.

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { findHeadingLine, scanHeadings } from "../shared/headings.ts";

/** The headings the editor sees, by title (shared/headings.ts's scan). */
const editorHeadings = (md: string): string[] => scanHeadings(md).map((h) => h.title);
import { extractHeadings, Slugger, stripInline } from "../client/reading/toc.ts";

/** What the reading view would scroll to for `anchor`: the heading whose slug
 *  matches, else (ReadingView's fallback) the one whose display text matches. */
function readingAnchor(md: string, anchor: string): string | null {
  const headings = extractHeadings(md);
  const wantSlug = new Slugger().slug(stripInline(anchor));
  const bySlug = headings.find((h) => h.slug === wantSlug);
  if (bySlug) return bySlug.text;
  const want = anchor.trim().toLowerCase();
  return headings.find((h) => h.text.trim().toLowerCase() === want)?.text ?? null;
}

describe("Slugger", () => {
  it("lowercases, drops punctuation and hyphenates spaces", () => {
    const s = new Slugger();
    assert.equal(s.slug("What's next? (draft)"), "whats-next-draft");
  });

  it("keeps unicode letters and digits", () => {
    const s = new Slugger();
    assert.equal(s.slug("مقدمة"), "مقدمة");
    assert.equal(s.slug("Étude № 3"), "étude-3");
  });

  it("numbers collisions deterministically", () => {
    const s = new Slugger();
    assert.equal(s.slug("Notes"), "notes");
    assert.equal(s.slug("notes"), "notes-1");
    assert.equal(s.slug("NOTES"), "notes-2");
  });

  it("falls back to 'section' when nothing survives", () => {
    const s = new Slugger();
    assert.equal(s.slug("†‡"), "section");
    assert.equal(s.slug("***"), "section-1");
  });

  it("is a pure function of the sequence, not of time", () => {
    const a = new Slugger();
    const b = new Slugger();
    for (const text of ["A", "A", "B", "A"]) assert.equal(a.slug(text), b.slug(text));
  });
});

describe("stripInline (heading display text)", () => {
  const CASES: [string, string][] = [
    ["Plain heading", "Plain heading"],
    ["**Bold** heading", "Bold heading"],
    ["`code` heading", "code heading"],
    ["A [[Wikilink]] here", "A Wikilink here"],
    ["A [[Target|Alias]] here", "A Alias here"],
    ["An ![[embed.png]]", "An embed.png"],
    ["A [link](https://example.com)", "A link"],
    ["==highlighted==", "highlighted"],
    ["Closed ATX ##", "Closed ATX"],
    ["مقدمة **قصيرة**", "مقدمة قصيرة"],
  ];
  for (const [input, expected] of CASES) {
    it(JSON.stringify(input), () => assert.equal(stripInline(input), expected));
  }
});

describe("extractHeadings", () => {
  it("skips frontmatter and reports 1-based source lines", () => {
    const md = "---\ntitle: A\n---\n# One\n\ntext\n\n## Two\n";
    assert.deepEqual(
      extractHeadings(md).map((h) => [h.level, h.text, h.line]),
      [
        [1, "One", 4],
        [2, "Two", 8],
      ],
    );
  });

  it("never reports a ### inside a fenced code block", () => {
    const md = "# Real\n\n```md\n### Not a heading\n```\n\n## Also real\n";
    assert.deepEqual(extractHeadings(md).map((h) => h.text), ["Real", "Also real"]);
  });

  it("handles nested levels and duplicate names", () => {
    const md = "# A\n## B\n### B\n## B\n";
    assert.deepEqual(
      extractHeadings(md).map((h) => `${h.level}:${h.slug}`),
      ["1:a", "2:b", "3:b-1", "2:b-2"],
    );
  });

  it("marks a link-list section as furniture (TOC noise, still anchorable)", () => {
    const md = "# Real\n\nProse here.\n\n## Related\n\n- [[A]]\n- [[B]]\n";
    const [real, related] = extractHeadings(md);
    assert.equal(real.furniture, undefined);
    assert.equal(related.furniture, true);
  });
});

describe("[[Note#Anchor]] resolution, both resolvers", () => {
  const md = [
    "---",
    "title: Doc",
    "---",
    "# Doc",
    "",
    "## What's next? (draft)",
    "",
    "prose",
    "",
    "## مقدمة",
    "",
    "نص",
    "",
    "## **Bold** section",
    "",
    "## Repeat",
    "",
    "## Repeat",
    "",
    "```",
    "## Fenced not-a-heading",
    "```",
    "",
  ].join("\n");

  const RESOLVES = ["Doc", "What's next? (draft)", "مقدمة", "Repeat"];
  for (const anchor of RESOLVES) {
    it(`#${anchor} resolves in the editor AND the reading view`, () => {
      assert.notEqual(findHeadingLine(md, anchor), null, "editor cannot find it");
      assert.notEqual(readingAnchor(md, anchor), null, "reading view cannot find it");
    });
  }

  it("is case-insensitive in both", () => {
    assert.notEqual(findHeadingLine(md, "what's NEXT? (draft)"), null);
    assert.notEqual(readingAnchor(md, "what's NEXT? (draft)"), null);
  });

  it("never resolves to a heading inside a code fence", () => {
    assert.equal(findHeadingLine(md, "Fenced not-a-heading"), null);
    assert.equal(readingAnchor(md, "Fenced not-a-heading"), null);
  });

  it("resolves a duplicated heading to the FIRST one in both", () => {
    assert.equal(findHeadingLine(md, "Repeat"), 16);
    const headings = extractHeadings(md);
    const first = headings.find((h) => h.text === "Repeat");
    assert.equal(first?.line, 16);
    assert.equal(first?.slug, "repeat", "the first keeps the un-suffixed slug");
  });

  // These three were pinned here as KNOWN BUGS until shared/headings.ts made
  // the editor, the outline, the anchor table and the reading view read one
  // rule. They now assert the agreement.
  it("emphasis: both spellings resolve in both views", () => {
    // The editor used to match only the RAW source ("**Bold** section") and
    // the reading view only the display text ("Bold section").
    for (const spelling of ["**Bold** section", "Bold section"]) {
      assert.equal(findHeadingLine(md, spelling), 14, spelling);
      assert.equal(readingAnchor(md, spelling), "Bold section", spelling);
    }
    assert.ok(editorHeadings(md).includes("Bold section"), "the completion offers what the reader sees");
  });

  it("an indented heading is a heading for the editor AND the reader", () => {
    // CommonMark allows up to three spaces; the reading view and the outline
    // used to anchor `#` to column 0, so the editor offered a link that
    // landed nowhere.
    const indented = "# Top\n\n   ## Indented\n\ntext\n";
    assert.equal(findHeadingLine(indented, "Indented"), 3);
    assert.deepEqual(editorHeadings(indented), ["Top", "Indented"]);
    assert.deepEqual(extractHeadings(indented).map((h) => h.text), ["Top", "Indented"]);
    assert.equal(readingAnchor(indented, "Indented"), "Indented");
    // Four spaces is indented code, in every view.
    assert.deepEqual(editorHeadings("    ## Code\n"), []);
    assert.deepEqual(extractHeadings("    ## Code\n"), []);
  });

  it("a closed-ATX heading is reachable by its title from the editor", () => {
    const closed = "# Top\n\n## Notes ##\n\ntext\n";
    assert.equal(readingAnchor(closed, "Notes"), "Notes");
    assert.equal(findHeadingLine(closed, "Notes"), 3);
  });

  it("a YAML `# comment` in the frontmatter is never offered as a heading", () => {
    const withComment = "---\n# a YAML comment\ntitle: T\n---\n# Real\n";
    assert.deepEqual(editorHeadings(withComment), ["Real"]);
    assert.equal(findHeadingLine(withComment, "a YAML comment"), null);
    assert.deepEqual(extractHeadings(withComment).map((h) => h.text), ["Real"]);
  });

  it("an empty or whitespace anchor resolves to nothing", () => {
    assert.equal(findHeadingLine(md, ""), null);
    assert.equal(findHeadingLine(md, "   "), null);
  });
});