import { describe, expect, it } from "vitest"; import { chunkText, DEFAULT_CHUNK_OPTIONS, estimateTokens } from "./chunking.js"; describe("estimateTokens", () => { it("estimates ~4 chars/token via words/0.75", () => { expect(estimateTokens("")).toBe(0); expect(estimateTokens("one two three four")).toBe(Math.ceil(4 / 0.75)); }); }); describe("chunkText", () => { it("returns [] for empty/whitespace input", () => { expect(chunkText("")).toEqual([]); expect(chunkText(" \n\n ")).toEqual([]); }); it("keeps a short text as a single chunk", () => { const chunks = chunkText("Hello world"); expect(chunks).toHaveLength(1); expect(chunks[0]!.text).toBe("Hello world"); expect(chunks[0]!.chunkIndex).toBe(0); expect(chunks[0]!.charStart).toBe(0); expect(chunks[0]!.charEnd).toBe(11); }); it("splits on paragraph boundaries and keeps char offsets exact", () => { const a = "alpha paragraph here"; const b = "beta paragraph here"; const text = a + "\n\n" + b; // maxChars 30 < 40 (a + sep + b) so the pieces stay separate const chunks = chunkText(text, { maxChars: 30, overlapChars: 0 }); expect(chunks).toHaveLength(2); // offsets point into the original text expect(text.slice(chunks[0]!.charStart, chunks[0]!.charEnd)).toBe(a); expect(text.slice(chunks[1]!.charStart, chunks[1]!.charEnd)).toBe(b); }); it("merges two paragraphs that fit in one chunk (byte-exact source span)", () => { const a = "alpha paragraph here"; const b = "beta paragraph here"; const text = a + "\n\n" + b; const chunks = chunkText(text, { maxChars: 50, overlapChars: 0 }); expect(chunks).toHaveLength(1); expect(chunks[0]!.charStart).toBe(0); expect(chunks[0]!.charEnd).toBe(text.length); // the merged text is exactly the source span (original gap preserved) expect(chunks[0]!.text).toBe(text); expect(text.slice(chunks[0]!.charStart, chunks[0]!.charEnd)).toBe(chunks[0]!.text); }); it("hard-splits oversized paragraphs on word boundaries", () => { const words = Array.from({ length: 100 }, (_, i) => `w${i}`).join(" "); const chunks = chunkText(words, { maxChars: 100, overlapChars: 0 }); expect(chunks.length).toBeGreaterThan(1); const originalWords = words.split(" "); let cursor = 0; for (const c of chunks) { expect(c.text.length).toBeLessThanOrEqual(100); // each piece is a contiguous run of whole words from the original const pieceWords = c.text.split(/\s+/).filter(Boolean); const run = originalWords.slice(cursor, cursor + pieceWords.length).join(" "); expect(c.text).toBe(run); cursor += pieceWords.length; } expect(cursor).toBe(100); // every word accounted for, in order }); it("keeps small fences intact in a single chunk", () => { const code = "```ts\n" + "line ".repeat(80).trim() + "\n```"; const text = "before\n\n" + code + "\nafter"; const chunks = chunkText(text, { maxChars: 500, overlapChars: 0 }); // the fence is never split across chunks const fenceChunks = chunks.filter((c) => c.text.includes("```ts")); expect(fenceChunks).toHaveLength(1); expect(fenceChunks[0]!.text).toContain("line line line"); expect(fenceChunks[0]!.text).toContain("\n```"); }); it("re-brackets an oversized fence that must be hard-split", () => { const code = "```ts\n" + "x ".repeat(200).trim() + "\n```"; const chunks = chunkText(code, { maxChars: 100, overlapChars: 0 }); expect(chunks.length).toBeGreaterThan(1); // first chunk opens the fence expect(chunks[0]!.text.startsWith("```ts")).toBe(true); // every chunk re-opens it; only the last closes it for (const c of chunks) { expect(c.text.startsWith("```ts")).toBe(true); } const last = chunks[chunks.length - 1]!; expect(last.text.trimEnd().endsWith("```")).toBe(true); for (const c of chunks.slice(0, -1)) { expect(c.text.trimEnd().endsWith("```")).toBe(false); } }); it("blank lines inside a fence do not split", () => { const code = "```\nalpha\n\nbeta\n```"; const chunks = chunkText("intro\n\n" + code, { maxChars: 500, overlapChars: 0 }); const fence = chunks.find((c) => c.text.includes("```")); expect(fence).toBeDefined(); expect(fence!.text).toContain("alpha\n\nbeta"); }); it("merges adjacent small pieces up to maxChars", () => { const text = ["one", "two", "three", "four", "five"].map((w) => w + " " + w).join("\n\n"); const chunks = chunkText(text, { maxChars: 200, overlapChars: 0 }); expect(chunks).toHaveLength(1); // all small pieces merge expect(chunks[0]!.text).toContain("one"); expect(chunks[0]!.text).toContain("five"); }); it("overlaps consecutive chunks by the tail of the previous one", () => { const para1 = "A".repeat(300) + " TAILMARK"; const para2 = "B".repeat(300); const text = para1 + "\n\n" + para2; const chunks = chunkText(text, { maxChars: 400, overlapChars: 60 }); expect(chunks).toHaveLength(2); expect(chunks[1]!.text).toContain("TAILMARK"); // the overlap is a tail of chunk 0's own text expect(chunks[0]!.text.endsWith(chunks[1]!.text.split("\n")[0]!)).toBe(true); // overlap never exceeds budget for (const c of chunks) expect(c.text.length).toBeLessThanOrEqual(400); }); it("clamps overlap to maxChars/4", () => { const text = "x ".repeat(500); const chunks = chunkText(text, { maxChars: 200, overlapChars: 5000 }); expect(chunks.length).toBeGreaterThan(0); }); it("is deterministic", () => { const text = "para one\n\npara two is longer than the first one so it wraps around a bit\n\npara three"; const a = chunkText(text, { maxChars: 90, overlapChars: 20 }); const b = chunkText(text, { maxChars: 90, overlapChars: 20 }); expect(a).toEqual(b); }); it("defaults to 800/120", () => { expect(DEFAULT_CHUNK_OPTIONS).toEqual({ maxChars: 800, overlapChars: 120 }); }); });