Files
james.maesandClaude Opus 4.6 568c3f38db test: add 22 tests for recent fixes (TDD catch-up)
- Block scope edge cases: empty blocks, nested markers, inline {{ }},
  scope isolation between blocks (5 tests)
- Streaming fallback: all 4 providers verified to use httpFn when
  globalThis.fetch unavailable, onToken accepted without errors (12 tests)
- Command ID validation: no penny: prefix, kebab-case, no dupes (4 tests)
- Pipeline upsert paths: first-run create and subsequent modify (2 tests)

415 total tests across 19 test files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 08:55:59 -05:00

510 lines
16 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { parseAnnotations } from "../src/parser";
describe("parseAnnotations", () => {
describe("basic annotation types", () => {
it("parses a REWRITE annotation", () => {
const content = [
"Some paragraph text here.",
"%% REWRITE: make this more vivid %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].tag).toBe("REWRITE");
expect(result[0].instruction).toBe("make this more vivid");
expect(result[0].actionable).toBe(true);
expect(result[0].scope).toBe("paragraph");
});
it("parses an EXPAND annotation", () => {
const content = [
"She walked into the room.",
"%% EXPAND: add more sensory detail %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].tag).toBe("EXPAND");
expect(result[0].instruction).toBe("add more sensory detail");
});
it("parses a CUT annotation", () => {
const content = [
"A very long paragraph that goes on and on.",
"%% CUT: trim to essentials %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].tag).toBe("CUT");
});
it("parses TONE, DIALOG, PLOT, PACING, CHARACTER tags", () => {
const tags = ["TONE", "DIALOG", "PLOT", "PACING", "CHARACTER"];
for (const tag of tags) {
const content = `Some text.\n%% ${tag}: fix this %%`;
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].tag).toBe(tag);
expect(result[0].actionable).toBe(true);
}
});
});
describe("passthrough tags", () => {
it("parses NOTE as non-actionable", () => {
const content = [
"Some paragraph.",
"%% NOTE: remember to check this later %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].tag).toBe("NOTE");
expect(result[0].actionable).toBe(false);
});
it("parses RESEARCH as non-actionable", () => {
const content = [
"The bridge was built in 1842.",
"%% RESEARCH: verify this date %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].tag).toBe("RESEARCH");
expect(result[0].actionable).toBe(false);
});
});
describe("scope detection", () => {
it("detects inline scope when annotation is on the same line as text", () => {
const content = 'She said "hello" to him. %% TONE: too flat %%';
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("inline");
expect(result[0].lineStart).toBe(0);
expect(result[0].lineEnd).toBe(0);
});
it("detects paragraph scope when annotation is on its own line after text", () => {
const content = [
"First sentence of the paragraph.",
"Second sentence continues here.",
"%% REWRITE: needs more tension %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("paragraph");
expect(result[0].lineStart).toBe(0);
expect(result[0].lineEnd).toBe(2);
});
it("detects section scope when annotation follows a heading", () => {
const content = [
"## Scene One",
"%% REWRITE: this entire scene needs work %%",
"",
"She walked into the bar.",
"The lights were dim.",
"",
"## Scene Two",
"Something else happens.",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("section");
expect(result[0].lineStart).toBe(0);
// Should extend to just before "## Scene Two"
expect(result[0].lineEnd).toBe(5);
});
it("section scope extends to end of file if no next heading", () => {
const content = [
"## Final Scene",
"%% EXPAND: add more here %%",
"",
"Last paragraph.",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("section");
expect(result[0].lineEnd).toBe(3);
});
it("section scope respects heading hierarchy (stops at equal level)", () => {
const content = [
"## Act One",
"%% PACING: too slow %%",
"",
"### Scene 1a",
"Text here.",
"",
"### Scene 1b",
"More text.",
"",
"## Act Two",
"Different act.",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("section");
// Should stop before ## Act Two (line 9)
expect(result[0].lineEnd).toBe(8);
});
});
describe("hash generation", () => {
it("generates a 12-character hex hash", () => {
const content = "Some text.\n%% REWRITE: fix this %%";
const result = parseAnnotations(content);
expect(result[0].hash).toMatch(/^[0-9a-f]{12}$/);
});
it("generates consistent hashes for the same input", () => {
const content = "Some text.\n%% REWRITE: fix this %%";
const r1 = parseAnnotations(content);
const r2 = parseAnnotations(content);
expect(r1[0].hash).toBe(r2[0].hash);
});
it("generates different hashes for different annotations", () => {
const c1 = "Text A.\n%% REWRITE: fix this %%";
const c2 = "Text B.\n%% REWRITE: fix this %%";
const r1 = parseAnnotations(c1);
const r2 = parseAnnotations(c2);
expect(r1[0].hash).not.toBe(r2[0].hash);
});
});
describe("multiple annotations", () => {
it("parses multiple annotations in the same file", () => {
const content = [
"First paragraph.",
"%% REWRITE: fix this %%",
"",
"Second paragraph.",
"%% EXPAND: add detail %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(2);
expect(result[0].tag).toBe("REWRITE");
expect(result[1].tag).toBe("EXPAND");
});
it("parses multiple annotations on the same line", () => {
const content = "She ran fast. %% TONE: too flat %% %% REWRITE: more vivid %%";
const result = parseAnnotations(content);
expect(result).toHaveLength(2);
expect(result[0].tag).toBe("TONE");
expect(result[1].tag).toBe("REWRITE");
});
it("returns annotations sorted by lineStart", () => {
const content = [
"Paragraph one.",
"%% EXPAND: add stuff %%",
"",
"Paragraph two.",
"%% REWRITE: redo this %%",
"",
"Paragraph three.",
"%% CUT: trim %%",
].join("\n");
const result = parseAnnotations(content);
for (let i = 1; i < result.length; i++) {
expect(result[i].lineStart).toBeGreaterThanOrEqual(result[i - 1].lineStart);
}
});
});
describe("malformed annotations", () => {
it("skips annotations with unrecognized tags", () => {
const content = "Some text.\n%% BANANA: not a real tag %%";
const result = parseAnnotations(content);
expect(result).toHaveLength(0);
});
it("skips annotations with empty instructions", () => {
const content = "Some text.\n%% REWRITE: %%";
const result = parseAnnotations(content);
expect(result).toHaveLength(0);
});
it("does not throw on malformed input", () => {
const badInputs = [
"%% %%",
"%% : %%",
"%%REWRITE: no space%%",
"%% REWRITE no colon %%",
"just plain text",
"",
"%%",
"%% REWRITE: incomplete",
];
for (const input of badInputs) {
expect(() => parseAnnotations(input)).not.toThrow();
}
});
it("handles file with no annotations", () => {
const content = "Just a normal chapter with no annotations at all.";
const result = parseAnnotations(content);
expect(result).toHaveLength(0);
});
it("handles empty string input", () => {
const result = parseAnnotations("");
expect(result).toHaveLength(0);
});
});
describe("original text extraction", () => {
it("extracts paragraph text for paragraph scope", () => {
const content = [
"She walked into the room and sat down.",
"%% REWRITE: more atmosphere %%",
].join("\n");
const result = parseAnnotations(content);
expect(result[0].originalText).toContain("She walked into the room");
});
it("extracts inline text minus the annotation", () => {
const content = "The coffee was good. %% TONE: too bland %%";
const result = parseAnnotations(content);
expect(result[0].originalText).toBe("The coffee was good.");
});
it("extracts section text including sub-content", () => {
const content = [
"## The Bar",
"%% REWRITE: whole scene %%",
"",
"She sat at the bar.",
"The music was loud.",
].join("\n");
const result = parseAnnotations(content);
expect(result[0].originalText).toContain("The Bar");
expect(result[0].originalText).toContain("She sat at the bar.");
});
});
describe("whitespace handling", () => {
it("trims whitespace from instructions", () => {
const content = "Text.\n%% REWRITE: lots of spaces %%";
const result = parseAnnotations(content);
expect(result[0].instruction).toBe("lots of spaces");
});
it("handles Windows-style line endings", () => {
const content = "Some text.\r\n%% REWRITE: fix this %%\r\n";
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].tag).toBe("REWRITE");
});
});
describe("REVISED markers", () => {
it("does not parse REVISED markers as annotations", () => {
const content = [
'Some revised text.',
'%% REVISED(v2): [REWRITE] "fix this" -- 10 words -> 12 words %%',
].join("\n");
const result = parseAnnotations(content);
// REVISED is not a valid tag, so it should be skipped.
expect(result).toHaveLength(0);
});
});
describe("block scope with {{ }}", () => {
it("scopes to content inside {{ }} markers", () => {
const content = [
"{{",
"First paragraph of the block.",
"",
"Second paragraph of the block.",
"}}",
"%% REWRITE: rewrite the whole block %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("block");
expect(result[0].originalText).toContain("First paragraph");
expect(result[0].originalText).toContain("Second paragraph");
});
it("does not include {{ }} markers in originalText", () => {
const content = [
"{{",
"The target text.",
"}}",
"%% TONE: make it darker %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].originalText).toBe("The target text.");
expect(result[0].originalText).not.toContain("{{");
expect(result[0].originalText).not.toContain("}}");
});
it("handles blank line between }} and annotation", () => {
const content = [
"{{",
"Block content here.",
"}}",
"",
"%% EXPAND: add more detail %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("block");
expect(result[0].originalText).toBe("Block content here.");
});
it("captures multi-paragraph blocks", () => {
const content = [
"{{",
"I grind the beans. Burr grinder, not blade.",
"",
"I fill the kettle. 205 degrees.",
"",
"The grinder is the loudest thing.",
"",
"I pour it black.",
"}}",
"%% REWRITE: change to espresso %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("block");
expect(result[0].originalText).toContain("I grind the beans");
expect(result[0].originalText).toContain("I pour it black.");
});
it("falls back to paragraph scope when no matching {{ found", () => {
const content = [
"Some text above.",
"}}",
"%% REWRITE: rewrite this %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
// }} without matching {{ -- parser falls through, finds paragraph above
expect(result[0].scope).toBe("paragraph");
});
it("handles multiple block-scoped annotations", () => {
const content = [
"{{",
"First block.",
"}}",
"%% REWRITE: fix first block %%",
"",
"{{",
"Second block.",
"}}",
"%% TONE: change tone of second block %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(2);
expect(result[0].scope).toBe("block");
expect(result[0].originalText).toBe("First block.");
expect(result[1].scope).toBe("block");
expect(result[1].originalText).toBe("Second block.");
});
it("handles empty block ({{ }} with no content between them)", () => {
const content = [
"{{",
"}}",
"%% REWRITE: fill this in %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("block");
// lineStart > lineEnd when block is empty (open+1 > close-1)
// originalText should be empty since there is nothing between {{ and }}
expect(result[0].originalText).toBe("");
});
it("finds nearest matching {{ for nested-looking markers", () => {
// When there are two {{ before a }}, the parser walks backwards
// and finds the first {{ it encounters (nearest to the }}).
const content = [
"{{",
"Outer block start.",
"{{",
"Inner block content.",
"}}",
"%% REWRITE: rewrite inner %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("block");
// Should find the nearest {{ (line 2) as the match for }} (line 4)
expect(result[0].originalText).toContain("Inner block content.");
});
it("does NOT treat {{ as block open when mixed with other text on same line", () => {
// isBlockOpen requires the line to be exactly "{{" (with optional whitespace)
const content = [
"She said {{ something }}",
"%% REWRITE: fix this line %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
// {{ on a line with other text is not treated as a block opener,
// so scope resolution falls through to paragraph
expect(result[0].scope).toBe("paragraph");
});
it("second annotation after a block does not get block scope", () => {
// After a {{ }} block and its annotation, a subsequent annotation
// separated by other content should NOT resolve to block scope.
const content = [
"{{",
"Block content here.",
"}}",
"%% REWRITE: rewrite the block %%",
"",
"Some unrelated paragraph.",
"%% TONE: make it darker %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(2);
// Find each annotation by tag since sort order depends on lineStart
const rewrite = result.find((a) => a.tag === "REWRITE");
const tone = result.find((a) => a.tag === "TONE");
expect(rewrite).toBeDefined();
expect(tone).toBeDefined();
// REWRITE sits right after }}, so it gets block scope
expect(rewrite!.scope).toBe("block");
expect(rewrite!.originalText).toBe("Block content here.");
// TONE follows a normal paragraph, so it gets paragraph scope
expect(tone!.scope).toBe("paragraph");
});
});
});