mirror of
https://github.com/leethobbit/obsidian-strong-start-plugin.git
synced 2026-07-22 08:28:17 +00:00
Full senior-review sweep (all findings fixed, 530 tests green): - Prep board: sync cached body after own section writes (typed scenes could vanish from the UI and then be erased from disk) - Dashboard: pip toggle is now a read-modify-write against the note's current Fronts section, re-located by name (stale cache silently deleted fronts added on Foundation) - CampaignStore: notify on body-only changes to managed notes (root cause under both criticals); self-write marks now outlive done() so the cache echo still reads as a self-write (in-flight counted) - sections.ts: fence-aware parsing (fenced ## lines no longer corrupt managed writes); body-split tolerates BOM and empty frontmatter - Frontmatter codecs: unknown-key passthrough on write (newer-version fields survive older-plugin writes), id-bearing secret rows survive empty text (tombstone resurrection), ledger/session-zero patches read current state inside processFrontMatter instead of metadataCache - Monster builder: attack-count round-trip no longer corrupts damage at CR 1/8, 9, 16, 17; CR fractions render as 1/8-1/2; preview title live - Wizard double-fire guard; loadIfDeferred in openView; starter/demo builders refuse re-runs; safer filenames (#^[], reserved names) - Per-render listener scopes (RenderScopes) for header/rail, prep, run scenes/secrets, tables; popout-safe timers; toast/undo handle cleanup - Log-row rollback on failed writes; 0x table weights round-trip; duplicate doom lines preserved; obsidian dep pinned to 1.13.1 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
179 lines
7.4 KiB
TypeScript
179 lines
7.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { buildScaffold, healSections, parseSections, removeSection, replaceSection, sectionContent } from "../src/lib/sections";
|
|
|
|
const SCAFFOLD = "## Campaign pitch\n\n## Six truths\n\n## Fronts\n\n## House rules\n";
|
|
|
|
describe("parseSections", () => {
|
|
it("finds every H2 section in order", () => {
|
|
const sections = parseSections(SCAFFOLD);
|
|
expect(sections.map((s) => s.heading)).toEqual(["Campaign pitch", "Six truths", "Fronts", "House rules"]);
|
|
});
|
|
|
|
it("ignores H3+ headings", () => {
|
|
const body = "## Fronts\n\n### The Veiled Hand\ngoal line\n\n## House rules\n";
|
|
const sections = parseSections(body);
|
|
expect(sections.map((s) => s.heading)).toEqual(["Fronts", "House rules"]);
|
|
expect(sections[0].content).toContain("### The Veiled Hand");
|
|
});
|
|
|
|
it("captures content exactly between headings", () => {
|
|
const body = "## Pitch\nA doomed city.\n\n## Truths\n- one\n";
|
|
const sections = parseSections(body);
|
|
expect(sections[0].content).toBe("A doomed city.\n\n");
|
|
expect(sections[1].content).toBe("- one\n");
|
|
});
|
|
|
|
it("returns nothing for a body with no managed headings", () => {
|
|
expect(parseSections("just some prose")).toEqual([]);
|
|
});
|
|
|
|
it("ignores a heading-like line inside a backtick fence", () => {
|
|
const body = "## Fronts\n```\n## Fake\n```\ngoal line\n\n## House rules\n";
|
|
const sections = parseSections(body);
|
|
expect(sections.map((s) => s.heading)).toEqual(["Fronts", "House rules"]);
|
|
});
|
|
|
|
it("ignores a heading-like line inside a tilde fence", () => {
|
|
const body = "## Fronts\n~~~\n## Fake\n~~~\ngoal line\n\n## House rules\n";
|
|
const sections = parseSections(body);
|
|
expect(sections.map((s) => s.heading)).toEqual(["Fronts", "House rules"]);
|
|
});
|
|
|
|
it("treats an unclosed fence as running to end of body", () => {
|
|
const body = "## Fronts\n```\n## Fake\nstill fenced\n";
|
|
const sections = parseSections(body);
|
|
expect(sections.map((s) => s.heading)).toEqual(["Fronts"]);
|
|
expect(sections[0].content).toBe("```\n## Fake\nstill fenced\n");
|
|
});
|
|
|
|
it("still finds a real heading after a properly closed fence", () => {
|
|
const body = "## Fronts\n```\ncode\n```\n\n## House rules\nno flanking\n";
|
|
const sections = parseSections(body);
|
|
expect(sections.map((s) => s.heading)).toEqual(["Fronts", "House rules"]);
|
|
});
|
|
|
|
it("treats an indented (<=3 space) fence opener as a fence", () => {
|
|
const body = "## Fronts\n ```\n## Fake\n ```\n\n## House rules\n";
|
|
const sections = parseSections(body);
|
|
expect(sections.map((s) => s.heading)).toEqual(["Fronts", "House rules"]);
|
|
});
|
|
|
|
it("does not treat a 4-space-indented ``` as a fence opener", () => {
|
|
const body = "## Fronts\n ```\n## Real\ntext\n";
|
|
const sections = parseSections(body);
|
|
expect(sections.map((s) => s.heading)).toEqual(["Fronts", "Real"]);
|
|
});
|
|
});
|
|
|
|
describe("replaceSection", () => {
|
|
it("replaces one section's content, leaving everything else byte-for-byte identical", () => {
|
|
const body = "## Pitch\nold pitch\n\n## Truths\n- one\n- two\n";
|
|
const updated = replaceSection(body, "Pitch", "new pitch");
|
|
expect(updated).toBe("## Pitch\nnew pitch\n\n## Truths\n- one\n- two\n");
|
|
});
|
|
|
|
it("matches heading names case-insensitively, preserving the original heading casing", () => {
|
|
const body = "## campaign pitch\nold\n";
|
|
const updated = replaceSection(body, "Campaign Pitch", "new");
|
|
expect(updated).toBe("## campaign pitch\nnew\n");
|
|
});
|
|
|
|
it("tolerates reordered/user headings and only touches the target", () => {
|
|
const body = "## House rules\nno flanking\n\n## Pitch\nold\n";
|
|
const updated = replaceSection(body, "Pitch", "new");
|
|
expect(updated).toBe("## House rules\nno flanking\n\n## Pitch\nnew\n");
|
|
});
|
|
|
|
it("appends the section at the end when the heading is missing", () => {
|
|
const body = "## Pitch\nold\n";
|
|
const updated = replaceSection(body, "Truths", "- one");
|
|
expect(updated).toBe("## Pitch\nold\n\n## Truths\n- one\n");
|
|
});
|
|
|
|
it("creates the heading in an empty body", () => {
|
|
expect(replaceSection("", "Pitch", "new")).toBe("## Pitch\nnew\n");
|
|
});
|
|
|
|
it("treats a fenced fake heading as section content, not a boundary — replacing spans past it to the real next section", () => {
|
|
// The fence belongs to Pitch's content, so replacing Pitch replaces it
|
|
// too; the win is that the span reaches the REAL next heading instead
|
|
// of stopping at (or corrupting) the fenced `## Fake` line.
|
|
const body = "## Pitch\nold\n```\n## Fake\n```\n\n## Truths\n- one\n";
|
|
const updated = replaceSection(body, "Pitch", "new");
|
|
expect(updated).toBe("## Pitch\nnew\n\n## Truths\n- one\n");
|
|
});
|
|
|
|
it("appends a new section rather than editing inside a fence when the target heading only exists fenced", () => {
|
|
const body = "## Pitch\nold\n```\n## Fake\n```\n";
|
|
const updated = replaceSection(body, "Fake", "x");
|
|
expect(updated).toBe("## Pitch\nold\n```\n## Fake\n```\n\n## Fake\nx\n");
|
|
});
|
|
|
|
it("leaves a fence inside an untouched section's content byte-for-byte when replacing a different section", () => {
|
|
const body = "## Pitch\nold\n\n## Truths\n```\n## Fake\ncode\n```\nkept\n";
|
|
const updated = replaceSection(body, "Pitch", "new");
|
|
expect(updated).toBe("## Pitch\nnew\n\n## Truths\n```\n## Fake\ncode\n```\nkept\n");
|
|
});
|
|
});
|
|
|
|
describe("healSections", () => {
|
|
it("appends only the managed headings that are missing, preserving existing content", () => {
|
|
const body = "## Pitch\nold pitch\n";
|
|
const healed = healSections(body, ["Pitch", "Truths", "Fronts"]);
|
|
expect(healed).toBe("## Pitch\nold pitch\n\n## Truths\n\n## Fronts\n");
|
|
});
|
|
|
|
it("is a no-op when every required heading is already present", () => {
|
|
const body = "## Pitch\nold\n\n## Truths\n- one\n";
|
|
expect(healSections(body, ["Pitch", "Truths"])).toBe(body);
|
|
});
|
|
});
|
|
|
|
describe("buildScaffold", () => {
|
|
it("produces one empty section per heading", () => {
|
|
const scaffold = buildScaffold(["Pitch", "Truths"]);
|
|
expect(parseSections(scaffold).map((s) => s.heading)).toEqual(["Pitch", "Truths"]);
|
|
});
|
|
});
|
|
|
|
describe("sectionContent", () => {
|
|
it("returns one section's trimmed content", () => {
|
|
const body = "## Strong start\nDrop them in the fight.\n\n## Scenes\n- one\n";
|
|
expect(sectionContent(body, "Strong start")).toBe("Drop them in the fight.");
|
|
});
|
|
|
|
it("matches case-insensitively", () => {
|
|
expect(sectionContent("## strong start\ntext\n", "Strong Start")).toBe("text");
|
|
});
|
|
|
|
it("returns an empty string when the heading is missing", () => {
|
|
expect(sectionContent("## Other\ntext\n", "Strong start")).toBe("");
|
|
});
|
|
|
|
it("includes a fenced fake heading as part of the section's content", () => {
|
|
const body = "## Strong start\nintro\n```\n## Fake\ncode\n```\noutro\n";
|
|
expect(sectionContent(body, "Strong start")).toBe("intro\n```\n## Fake\ncode\n```\noutro");
|
|
});
|
|
});
|
|
|
|
describe("removeSection", () => {
|
|
it("removes the heading line and content, leaving neighbors byte-identical", () => {
|
|
const body = "## Aspects\n- steamy\n- deep\n\n## History\nOld stone.\n";
|
|
expect(removeSection(body, "Aspects")).toBe("## History\nOld stone.\n");
|
|
});
|
|
|
|
it("matches the heading case-insensitively", () => {
|
|
const body = "intro\n\n## ASPECTS\n- one\n\n## Next\nkeep\n";
|
|
expect(removeSection(body, "aspects")).toBe("intro\n\n## Next\nkeep\n");
|
|
});
|
|
|
|
it("removes a last section without touching prior content", () => {
|
|
const body = "## Keep\ntext\n\n## Aspects\n- one\n";
|
|
expect(removeSection(body, "Aspects")).toBe("## Keep\ntext\n\n");
|
|
});
|
|
|
|
it("no-ops when the heading is absent", () => {
|
|
const body = "## Keep\ntext\n";
|
|
expect(removeSection(body, "Aspects")).toBe(body);
|
|
});
|
|
});
|