Ctrl K
ComunidadGuías…

tests/sections.test.ts

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

Archivo original ↗Preguntar a KiBot
// Sections: cutting a note at its headings and putting it back.
//
// Every "move this section", "fold this section" and "preview this section"
// feature is a partition of the note's lines at the line numbers
// client/reading/toc.ts reports. The invariant that keeps those features from
// eating a reader's words is simple and absolute: the partition is EXACT —
// concatenating the pieces returns the original bytes, no line lost, none
// duplicated — and it holds with frontmatter, nested headings, CRLF and a
// fenced code block full of ### lines.

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { extractHeadings } from "../client/reading/toc.ts";
import { isHeadingLine } from "../shared/headings.ts";
import { pick, rng } from "./helpers/vault.ts";

interface Section {
  /** Heading text, or null for the preamble (frontmatter + opening prose). */
  title: string | null;
  level: number; // 0 for the preamble
  lines: string[];
}

/** Partition a note at its headings. The model every section feature uses. */
function splitSections(md: string): Section[] {
  const lines = md.split("\n");
  const headings = extractHeadings(md);
  const out: Section[] = [];
  const firstLine = headings.length > 0 ? headings[0].line - 1 : lines.length;
  out.push({ title: null, level: 0, lines: lines.slice(0, firstLine) });
  for (let i = 0; i < headings.length; i++) {
    const start = headings[i].line - 1;
    const end = i + 1 < headings.length ? headings[i + 1].line - 1 : lines.length;
    out.push({ title: headings[i].text, level: headings[i].level, lines: lines.slice(start, end) });
  }
  return out;
}

function joinSections(sections: Section[]): string {
  return sections.flatMap((s) => s.lines).join("\n");
}

/** A heading's section INCLUDING its sub-sections — what "extract section"
 *  has to take with it, or the children are orphaned under the wrong parent. */
function withChildren(sections: Section[], index: number): Section[] {
  const level = sections[index].level;
  let end = index + 1;
  while (end < sections.length && sections[end].level > level) end++;
  return sections.slice(index, end);
}

const DOC = [
  "---",
  "title: Doc",
  "tags: [a, b]",
  "---",
  "",
  "Opening prose before any heading.",
  "",
  "# One",
  "",
  "First section.",
  "",
  "## One.a",
  "",
  "Nested prose.",
  "",
  "```md",
  "### This is code, not a heading",
  "## Neither is this",
  "```",
  "",
  "### One.a.i",
  "",
  "Deep prose.",
  "",
  "# Two",
  "",
  "Second section.",
  "",
].join("\n");

describe("splitting a note into sections", () => {
  it("reconstructs the original byte for byte", () => {
    assert.equal(joinSections(splitSections(DOC)), DOC);
  });

  it("puts frontmatter and the opening prose in the preamble", () => {
    const [preamble] = splitSections(DOC);
    assert.equal(preamble.title, null);
    assert.ok(preamble.lines.includes("title: Doc"));
    assert.ok(preamble.lines.includes("Opening prose before any heading."));
  });

  it("does not split inside a fenced code block", () => {
    const titles = splitSections(DOC).map((s) => s.title);
    assert.deepEqual(titles, [null, "One", "One.a", "One.a.i", "Two"]);
    const oneA = splitSections(DOC).find((s) => s.title === "One.a");
    assert.ok(oneA);
    assert.ok(oneA.lines.includes("### This is code, not a heading"), "the fence stayed put");
    assert.ok(oneA.lines.includes("## Neither is this"));
  });

  it("carries sub-sections with their parent", () => {
    const sections = splitSections(DOC);
    const oneIndex = sections.findIndex((s) => s.title === "One");
    const block = withChildren(sections, oneIndex);
    assert.deepEqual(block.map((s) => s.title), ["One", "One.a", "One.a.i"]);
  });

  it("extract + reinsert is the identity", () => {
    const sections = splitSections(DOC);
    const index = sections.findIndex((s) => s.title === "One.a");
    const block = withChildren(sections, index);
    const rest = [...sections.slice(0, index), ...sections.slice(index + block.length)];
    assert.notEqual(joinSections(rest), DOC, "…the cut really removed something");
    const back = [...rest.slice(0, index), ...block, ...rest.slice(index)];
    assert.equal(joinSections(back), DOC);
  });

  it("reordering top-level sections loses and duplicates nothing", () => {
    const sections = splitSections(DOC);
    const one = withChildren(sections, sections.findIndex((s) => s.title === "One"));
    const two = withChildren(sections, sections.findIndex((s) => s.title === "Two"));
    const preamble = sections[0];
    const swapped = joinSections([preamble, ...two, ...one]);
    const bag = (text: string): string[] => text.split("\n").slice().sort();
    assert.deepEqual(bag(swapped), bag(DOC), "same lines, different order");
    assert.equal(swapped.length, DOC.length, "same byte count");
    assert.ok(swapped.indexOf("# Two") < swapped.indexOf("# One"), "…and they really swapped");
  });

  it("handles CRLF without dropping the carriage returns", () => {
    const crlf = DOC.replace(/\n/g, "\r\n");
    assert.equal(joinSections(splitSections(crlf)), crlf);
    assert.deepEqual(
      splitSections(crlf).map((s) => s.title),
      [null, "One", "One.a", "One.a.i", "Two"],
    );
  });

  it("handles a note with no headings at all", () => {
    const md = "Just prose.\n\nMore prose.\n";
    const sections = splitSections(md);
    assert.equal(sections.length, 1);
    assert.equal(joinSections(sections), md);
  });

  it("handles a note that is nothing but headings", () => {
    const md = "# A\n## B\n### C\n";
    assert.equal(joinSections(splitSections(md)), md);
    assert.deepEqual(splitSections(md).map((s) => s.lines.length), [0, 1, 1, 2]);
  });

  it("treats an UNTERMINATED frontmatter fence as body (headings inside count)", () => {
    const md = "---\ntitle: A\n# Heading inside a broken block\n";
    assert.deepEqual(extractHeadings(md).map((h) => h.text), ["Heading inside a broken block"]);
    assert.equal(joinSections(splitSections(md)), md);
  });
});

describe("property: the partition is exact for any document", () => {
  const LINES = [
    "# H1",
    "## H2",
    "### H3",
    "#### H4",
    "###### H6",
    "####### not a heading (7 hashes)",
    "#NoSpace",
    "   ## indented heading",
    "",
    "prose line",
    "- list item",
    "> quote",
    "```",
    "### fenced",
    "```",
    "~~~python",
    "# fenced too",
    "~~~",
    "---",
    "| a | b |",
    "نص عربي",
    "$$",
    "\\int_0^1 x\\,dx",
    "$$",
  ];

  for (let seed = 1; seed <= 100; seed++) {
    it(`seed ${seed}`, () => {
      const next = rng(seed);
      const count = 2 + Math.floor(next() * 24);
      const body: string[] = [];
      for (let i = 0; i < count; i++) body.push(pick(next, LINES));
      const md = (next() < 0.4 ? "---\ntitle: T\n---\n" : "") + body.join("\n");

      const sections = splitSections(md);
      // 1. Exact reconstruction — the whole point.
      assert.equal(joinSections(sections), md);
      // 2. No line is claimed twice, and none is dropped.
      const total = sections.reduce((n, s) => n + s.lines.length, 0);
      assert.equal(total, md.split("\n").length);
      // 3. Every section after the preamble starts with its own heading line.
      for (const section of sections.slice(1)) {
        assert.ok(isHeadingLine(section.lines[0]), `section "${section.title}" lost its heading`);
      }
      // 4. Line numbers are strictly increasing.
      const lineNumbers = extractHeadings(md).map((h) => h.line);
      for (let i = 1; i < lineNumbers.length; i++) {
        assert.ok(lineNumbers[i] > lineNumbers[i - 1], "heading lines out of order");
      }
    });
  }
});