import { expect, test } from "vitest"; import { buildCommentScaffold, computeCommentInsertion, parseCommentBlock, sanitizeQuote, } from "./comments.ts"; test("minimal hand-typed comment", () => { const p = parseCommentBlock(``); expect(p).not.toBeNull(); expect(p!.thread).toEqual([{ addressee: "pete", text: "check this" }]); expect(p!.waitingOn).toBe("pete"); expect(p!.addressees).toEqual(["pete"]); expect(p!.quote).toBeUndefined(); }); test("anchor line, signature, and thread", () => { const raw = ``; const p = parseCommentBlock(raw)!; expect(p.quote).toBe("matching Obsidian's anchor"); expect(p.thread.length).toBe(2); expect(p.thread[0]).toEqual({ addressee: "pete", text: "is this still current?", author: "john", date: "2026-08-04", }); expect(p.waitingOn).toBe("john"); expect(p.lastDate).toBe("2026-08-05"); expect(p.addressees).toEqual(["pete", "john"]); }); test("curly-quote anchor delimiters accepted", () => { const raw = ``; const p = parseCommentBlock(raw)!; expect(p.quote).toBe("some “nested” phrase"); }); test("continuation lines attach to previous message", () => { const p = parseCommentBlock( ``, )!; expect(p.thread[0].text).toBe("first line second line of same message"); }); // A signature closes a message: a bare line after a signed message starts a // NEW unaddressed message instead of continuing the signed one. Without // this, an unaddressed reply appended after a signed message is // indistinguishable from a continuation of it (the bug a real Reply-button // round-trip hit: see client/codemirror/comment_widget.ts buildReplyInsertion). test("a signature closes a message: a following bare line starts a new message", () => { const raw = ``; const p = parseCommentBlock(raw)!; expect(p.quote).toBe("the surrounding text"); expect(p.thread).toEqual([ { text: "Commenting on a specific phrase", date: "2026-08-05" }, { text: "Hello" }, ]); }); test("a signature mid-continuation closes the message, and a later bare line starts a new one", () => { const raw = ``; const p = parseCommentBlock(raw)!; expect(p.thread).toEqual([ { addressee: "john", text: "line1 line2", author: "pete", date: "2026-08-05" }, { text: "follow-up" }, ]); }); test("an unsigned multi-line bare note still joins into a single message", () => { const raw = ``; const p = parseCommentBlock(raw)!; expect(p.thread).toEqual([{ text: "first line second line third line" }]); }); test("a signed line followed directly by another signed line stays two messages", () => { const raw = ``; const p = parseCommentBlock(raw)!; expect(p.thread).toEqual([ { addressee: "pete", text: "first", author: "john", date: "2026-08-04" }, { addressee: "john", text: "second", author: "pete", date: "2026-08-05" }, ]); }); test("double-hyphen inside message tolerated", () => { const p = parseCommentBlock(``)!; expect(p.thread[0].text).toBe("check the -- flag"); }); test("double-dash signature separator is accepted, with author", () => { const p = parseCommentBlock(``)!; expect(p.thread).toEqual([{ text: "get milk", author: "john", date: "2026-08-05", }]); }); test("single-dash signature separator is accepted, date-only", () => { const p = parseCommentBlock(``)!; expect(p.thread).toEqual([{ text: "note", date: "2026-08-05" }]); }); test("en-dash signature separator is accepted", () => { const p = parseCommentBlock(``)!; expect(p.thread).toEqual([{ text: "note", author: "john", date: "2026-08-05", }]); }); // The signature separator is the LAST valid dash-like split on the line, // not the first. `String.match` alone always returns the leftmost match, so // `extractSig` anchors the whole pattern and uses a greedy text prefix to // force the rightmost split -- these two cases would otherwise mis-split on // an earlier, incidental hyphen in the message text. test("incidental hyphen earlier in the text doesn't steal the signature split", () => { const p = parseCommentBlock( ``, )!; expect(p.thread).toEqual([{ text: "draft - then revise", author: "john", date: "2026-08-05", }]); }); test("leading hyphen (e.g. a list item) doesn't steal the signature split", () => { const p = parseCommentBlock(``)!; expect(p.thread).toEqual([{ text: "- a list item", author: "john", date: "2026-08-05", }]); }); test("a comma inside the message text doesn't get mistaken for the author's trailing comma", () => { const p = parseCommentBlock(``)!; expect(p.thread).toEqual([{ text: "meet ana, maria", date: "2026-08-05" }]); expect(p.thread[0].author).toBeUndefined(); }); test("a bare date in prose with no separator character isn't mistaken for a signature", () => { const p = parseCommentBlock( ``, )!; expect(p.thread).toEqual([{ text: "the value was set on 2026-08-05 in prose", }]); }); test("non-conforming comments return null", () => { // "just a note to self" is no longer non-conforming -- every HTML comment // that isn't a machine directive is now a note (see the "fully bare // comment" test below). expect(parseCommentBlock(``)).toBeNull(); expect(parseCommentBlock(``)).toBeNull(); expect(parseCommentBlock(``)).toBeNull(); }); test("sanitizeQuote truncates, collapses, substitutes", () => { expect(sanitizeQuote(`a "b"\n c -- d`)).toBe(`a 'b' c – d`); expect(sanitizeQuote("x".repeat(100)).length).toBe(80); }); // Spec change: the scaffold no longer has an "@" addressee slot at all -- // addressing is now optional, so the scaffold just carries a signature // (the block's marker) and the cursor lands at the message position, right // before that signature. // // Formatting: generated comments are left-aligned (no indentation), and a // block that spans multiple lines gets its closing `-->` on its own line. // A quoted scaffold is multi-line (the `re:` line plus the message line), // so the closer moves to a third line. test("scaffold has cursor at the message position, before the signature", () => { const { text, cursorOffset } = buildCommentScaffold({ quote: "some phrase", author: "john", date: "2026-08-04", }); expect(text).toBe( ``, ); expect(text.slice(cursorOffset)).toBe(" — john, 2026-08-04\n-->"); }); test("scaffold without quote or author falls back to a date-only signature", () => { const { text, cursorOffset } = buildCommentScaffold({ date: "2026-08-04", }); // No quote means a single line, so the closer stays inline. expect(text).toBe(``); expect(text.slice(cursorOffset)).toBe(" — 2026-08-04 -->"); // The date-only signature is still a marker, so this parses as conforming. expect(parseCommentBlock(text)).not.toBeNull(); }); test("insertion lands after the selection's block", () => { const doc = `First para line one.\nStill first para.\n\nSecond para.\n`; const selFrom = doc.indexOf("Still"); const selTo = selFrom + 5; const r = computeCommentInsertion(doc, selFrom, selTo, { author: "john", date: "2026-08-04", }); // insertAt = end of "Still first para." line (before the blank line) expect(doc.slice(0, r.insertAt).endsWith("Still first para.")).toBe(true); expect(r.text.startsWith(`\n", ); }); test("insertion at end of document without trailing newline", () => { const doc = `Only line`; const r = computeCommentInsertion(doc, 0, 4, { date: "2026-08-04" }); expect(r.insertAt).toBe(doc.length); }); test("unaddressed note with author signature parses as a note-to-self", () => { const p = parseCommentBlock( ``, )!; expect(p).not.toBeNull(); expect(p.thread).toEqual([{ text: "rephrase this later", author: "john", date: "2026-08-05", }]); expect(p.thread[0].addressee).toBeUndefined(); expect(p.waitingOn).toBeUndefined(); expect(p.addressees).toEqual([]); expect(p.lastDate).toBe("2026-08-05"); }); test("unaddressed note with a date-only signature parses", () => { const p = parseCommentBlock(``)!; expect(p).not.toBeNull(); expect(p.thread).toEqual([{ text: "rephrase this later", date: "2026-08-05" }]); expect(p.thread[0].author).toBeUndefined(); expect(p.waitingOn).toBeUndefined(); }); // REVERSES the previous "stray line before the first message degrades to // null" expectation: bare text before the first @name: line is no longer // treated as a corrupt/non-conforming block -- it's now a legitimate // unaddressed message that precedes an addressed one, and the leading // `re:` anchor is itself already a sufficient marker for conformance. test("re: anchor plus bare text before the first @name: line is conforming", () => { const raw = ``; const p = parseCommentBlock(raw)!; expect(p).not.toBeNull(); expect(p.quote).toBe("foo"); expect(p.thread).toEqual([ { text: "junk" }, { addressee: "pete", text: "hi" }, ]); expect(p.waitingOn).toBe("pete"); expect(p.addressees).toEqual(["pete"]); }); test("fully bare comment with no marker at all parses as one unaddressed, unsigned note", () => { const p = parseCommentBlock(``)!; expect(p).not.toBeNull(); expect(p.thread).toEqual([{ text: "remember this" }]); expect(p.thread[0].addressee).toBeUndefined(); expect(p.thread[0].author).toBeUndefined(); expect(p.thread[0].date).toBeUndefined(); expect(p.waitingOn).toBeUndefined(); expect(p.addressees).toEqual([]); expect(p.quote).toBeUndefined(); expect(p.lastDate).toBeUndefined(); }); test("empty and whitespace-only comments stay null", () => { expect(parseCommentBlock(``)).toBeNull(); expect(parseCommentBlock(``)).toBeNull(); }); test("multi-line bare comment joins its lines into one message", () => { const p = parseCommentBlock(``)!; expect(p).not.toBeNull(); expect(p.thread).toEqual([{ text: "first line second line" }]); }); test("baked lua section is not treated as a comment", () => { expect(parseCommentBlock(``)).toBeNull(); expect(parseCommentBlock(``)).toBeNull(); }); // Formatting change: generated comments are now left-aligned (no // indentation), with the closing `-->` on its own line whenever the block // spans multiple lines. The parser itself needs no grammar change for // this -- lines are trimmed regardless of indentation, and a bare `-->` // terminator line never reaches the per-line loop (it's consumed by the // outer `` match before splitting into lines). These two tests // pin that down: the new left-aligned/own-line-closer shape, and that a // legacy indented/inline-closer block on disk still parses identically. test("parses the new left-aligned, closer-on-its-own-line format", () => { const raw = ``; const p = parseCommentBlock(raw)!; expect(p.quote).toBe("making a claim"); expect(p.thread).toEqual([ { addressee: "pete", text: "verify this", author: "john", date: "2026-08-04" }, { addressee: "john", text: "confirmed", author: "pete", date: "2026-08-06" }, ]); expect(p.waitingOn).toBe("john"); expect(p.addressees).toEqual(["pete", "john"]); expect(p.lastDate).toBe("2026-08-06"); }); test("a legacy indented block with an inline closer still parses identically", () => { const raw = ``; const p = parseCommentBlock(raw)!; expect(p.quote).toBe("making a claim"); expect(p.thread).toEqual([ { addressee: "pete", text: "verify this", author: "john", date: "2026-08-04" }, { addressee: "john", text: "confirmed", author: "pete", date: "2026-08-06" }, ]); expect(p.waitingOn).toBe("john"); expect(p.addressees).toEqual(["pete", "john"]); expect(p.lastDate).toBe("2026-08-06"); });