mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
184 lines
7.2 KiB
TypeScript
184 lines
7.2 KiB
TypeScript
|
|
import { describe, expect, it } from "vitest";
|
||
|
|
import {
|
||
|
|
createDocumentAnchorSelector,
|
||
|
|
projectMarkdownToText,
|
||
|
|
remapDocumentAnchor,
|
||
|
|
resolveProjectionRange,
|
||
|
|
verifyDocumentAnchorSelector,
|
||
|
|
} from "./document-anchors.js";
|
||
|
|
|
||
|
|
function selectorFor(markdown: string, quote: string) {
|
||
|
|
const projection = projectMarkdownToText(markdown);
|
||
|
|
const start = projection.text.indexOf(quote);
|
||
|
|
expect(start).toBeGreaterThanOrEqual(0);
|
||
|
|
const range = resolveProjectionRange(projection, start, start + quote.length);
|
||
|
|
expect(range).not.toBeNull();
|
||
|
|
return createDocumentAnchorSelector(projection, range!);
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("document text projection", () => {
|
||
|
|
it("projects markdown into normalized rendered text with source ranges", () => {
|
||
|
|
const markdown = [
|
||
|
|
"# Heading",
|
||
|
|
"",
|
||
|
|
"- Ship **bold** [link text](https://example.com) and `code span`.",
|
||
|
|
"| Name | Value |",
|
||
|
|
"| --- | --- |",
|
||
|
|
"| Alpha | Beta |",
|
||
|
|
].join("\n");
|
||
|
|
|
||
|
|
const projection = projectMarkdownToText(markdown);
|
||
|
|
|
||
|
|
expect(projection.text).toContain("Heading");
|
||
|
|
expect(projection.text).toContain("Ship bold link text and code span.");
|
||
|
|
expect(projection.text).toContain("Name Value");
|
||
|
|
expect(projection.text).toContain("Alpha Beta");
|
||
|
|
expect(projection.text).not.toContain("https://example.com");
|
||
|
|
expect(projection.positions).toHaveLength(projection.text.length);
|
||
|
|
|
||
|
|
const linkStart = projection.text.indexOf("link text");
|
||
|
|
const range = resolveProjectionRange(projection, linkStart, linkStart + "link text".length);
|
||
|
|
expect(range?.markdownStart).toBe(markdown.indexOf("link text"));
|
||
|
|
expect(range?.markdownEnd).toBe(markdown.indexOf("link text") + "link text".length);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("normalizes whitespace while retaining markdown offsets", () => {
|
||
|
|
const markdown = "First line\n\nSecond\t\tline";
|
||
|
|
const projection = projectMarkdownToText(markdown);
|
||
|
|
|
||
|
|
expect(projection.text).toBe("First line Second line");
|
||
|
|
const range = resolveProjectionRange(projection, projection.text.indexOf("Second"), projection.text.length);
|
||
|
|
expect(range?.markdownStart).toBe(markdown.indexOf("Second"));
|
||
|
|
expect(range?.markdownEnd).toBe(markdown.length);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("preserves non-link punctuation", () => {
|
||
|
|
const markdown = "Keep (parenthetical) [plain brackets] visible.";
|
||
|
|
const projection = projectMarkdownToText(markdown);
|
||
|
|
|
||
|
|
expect(projection.text).toBe("Keep (parenthetical) [plain brackets] visible.");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("document anchor verification and remapping", () => {
|
||
|
|
it("verifies a selector against its base revision", () => {
|
||
|
|
const markdown = "Intro text with **selected text** inside.";
|
||
|
|
const selector = selectorFor(markdown, "selected text");
|
||
|
|
|
||
|
|
const result = verifyDocumentAnchorSelector({ markdown, selector });
|
||
|
|
|
||
|
|
expect(result.ok).toBe(true);
|
||
|
|
expect(result.anchor?.selectedText).toBe("selected text");
|
||
|
|
expect(result.anchor?.markdownStart).toBe(markdown.indexOf("selected text"));
|
||
|
|
});
|
||
|
|
|
||
|
|
it("remaps exact anchors after surrounding text moves", () => {
|
||
|
|
const selector = selectorFor("Alpha paragraph.\n\nTarget sentence here.\n\nOmega paragraph.", "Target sentence here.");
|
||
|
|
const previousAnchor = {
|
||
|
|
selectedText: selector.quote.exact,
|
||
|
|
prefixText: selector.quote.prefix,
|
||
|
|
suffixText: selector.quote.suffix,
|
||
|
|
normalizedStart: selector.position.normalizedStart,
|
||
|
|
normalizedEnd: selector.position.normalizedEnd,
|
||
|
|
markdownStart: selector.position.markdownStart,
|
||
|
|
markdownEnd: selector.position.markdownEnd,
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = remapDocumentAnchor({
|
||
|
|
previousAnchor,
|
||
|
|
nextMarkdown: "Omega paragraph.\n\nAlpha paragraph.\n\nTarget sentence here.",
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.anchorState).toBe("active");
|
||
|
|
expect(result.confidence).toBe("exact");
|
||
|
|
expect(result.anchor?.selectedText).toBe("Target sentence here.");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("uses context and proximity to disambiguate duplicate quotes", () => {
|
||
|
|
const selector = selectorFor("One apple near the start.\n\nTwo apple near the end.", "apple");
|
||
|
|
const previousAnchor = {
|
||
|
|
selectedText: selector.quote.exact,
|
||
|
|
prefixText: selector.quote.prefix,
|
||
|
|
suffixText: selector.quote.suffix,
|
||
|
|
normalizedStart: selector.position.normalizedStart,
|
||
|
|
normalizedEnd: selector.position.normalizedEnd,
|
||
|
|
markdownStart: selector.position.markdownStart,
|
||
|
|
markdownEnd: selector.position.markdownEnd,
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = remapDocumentAnchor({
|
||
|
|
previousAnchor,
|
||
|
|
nextMarkdown: "Zero apple elsewhere.\n\nOne apple near the start.\n\nTwo apple near the end.",
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.anchorState).toBe("active");
|
||
|
|
expect(result.confidence).toBe("duplicate");
|
||
|
|
expect(result.anchor?.prefixText).toContain("One");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("marks duplicate anchors ambiguous when context cannot distinguish them", () => {
|
||
|
|
const selector = selectorFor("apple apple", "apple");
|
||
|
|
const previousAnchor = {
|
||
|
|
selectedText: selector.quote.exact,
|
||
|
|
prefixText: "",
|
||
|
|
suffixText: "",
|
||
|
|
normalizedStart: selector.position.normalizedStart,
|
||
|
|
normalizedEnd: selector.position.normalizedEnd,
|
||
|
|
markdownStart: selector.position.markdownStart,
|
||
|
|
markdownEnd: selector.position.markdownEnd,
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = remapDocumentAnchor({ previousAnchor, nextMarkdown: "apple apple" });
|
||
|
|
|
||
|
|
expect(result.anchorState).toBe("stale");
|
||
|
|
expect(result.confidence).toBe("ambiguous");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("keeps edited anchors as stale fuzzy matches", () => {
|
||
|
|
const selector = selectorFor("We rely on an important launch assumption for scope.", "important launch assumption");
|
||
|
|
const previousAnchor = {
|
||
|
|
selectedText: selector.quote.exact,
|
||
|
|
prefixText: selector.quote.prefix,
|
||
|
|
suffixText: selector.quote.suffix,
|
||
|
|
normalizedStart: selector.position.normalizedStart,
|
||
|
|
normalizedEnd: selector.position.normalizedEnd,
|
||
|
|
markdownStart: selector.position.markdownStart,
|
||
|
|
markdownEnd: selector.position.markdownEnd,
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = remapDocumentAnchor({
|
||
|
|
previousAnchor,
|
||
|
|
nextMarkdown: "We rely on an important product launch assumption for scope.",
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.anchorState).toBe("stale");
|
||
|
|
expect(result.confidence).toBe("fuzzy");
|
||
|
|
expect(result.anchor?.selectedText).toBe("important product launch assumption");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("marks deleted anchors orphaned and allows future remapping from the latest known anchor", () => {
|
||
|
|
const selector = selectorFor("Keep this reviewed phrase in mind.", "reviewed phrase");
|
||
|
|
const previousAnchor = {
|
||
|
|
selectedText: selector.quote.exact,
|
||
|
|
prefixText: selector.quote.prefix,
|
||
|
|
suffixText: selector.quote.suffix,
|
||
|
|
normalizedStart: selector.position.normalizedStart,
|
||
|
|
normalizedEnd: selector.position.normalizedEnd,
|
||
|
|
markdownStart: selector.position.markdownStart,
|
||
|
|
markdownEnd: selector.position.markdownEnd,
|
||
|
|
};
|
||
|
|
|
||
|
|
const missing = remapDocumentAnchor({ previousAnchor, nextMarkdown: "The target disappeared." });
|
||
|
|
const recovered = remapDocumentAnchor({
|
||
|
|
previousAnchor,
|
||
|
|
nextMarkdown: "The target came back: reviewed phrase.",
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(missing.anchorState).toBe("orphaned");
|
||
|
|
expect(missing.confidence).toBe("missing");
|
||
|
|
expect(missing.anchor).toBeNull();
|
||
|
|
expect(recovered.anchorState).toBe("active");
|
||
|
|
expect(recovered.anchor?.selectedText).toBe("reviewed phrase");
|
||
|
|
});
|
||
|
|
});
|