Public: anyone can read this; the crew can change it.
A test pins the intra-line word diff: unchanged text unmarked, segments rejoin to each line, and a minified line degrades to whole-line #6632
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6621-a-test-pins-the-intra-line-word-diff-unc → main 4 commits fork 3 threads · 3 unresolved
2 files
changed +86
−1
src/web/worddiff.ts +1 −1
| @@ -16,7 +16,7 @@ export interface WordDiff { | ||
| 16 | 16 | } |
| 17 | 17 | |
| 18 | 18 | // Beyond this token count the O(n·m) LCS is not worth it; mark the whole line. |
| 19 | const MAX_TOKENS = 800; | |
| 19 | export const MAX_TOKENS = 800; | |
| 20 | 20 | |
| 21 | 21 | function tokenize(line: string): string[] { |
| 22 | 22 | return line.match(/\w+|\s+|[^\w\s]/g) ?? []; |
test/worddiff.test.ts added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | /** | |
| 2 | * The intra-line word diff (src/web/worddiff.ts) draws the underlines in every | |
| 3 | * pull's diff: unchanged text stays unmarked, each side's segments rejoin to | |
| 4 | * its line, and a minified line degrades to one changed segment per side. | |
| 5 | */ | |
| 6 | import { describe, expect, test } from "bun:test"; | |
| 7 | import { MAX_TOKENS, wordDiff, type Segment } from "../src/web/worddiff"; | |
| 8 | ||
| 9 | const join = (s: Segment[]) => s.map((x) => x.text).join(""); | |
| 10 | const changedText = (s: Segment[]) => | |
| 11 | s.filter((x) => x.changed).map((x) => x.text); | |
| 12 | ||
| 13 | describe("wordDiff", () => { | |
| 14 | test("identical lines have no changed segment", () => { | |
| 15 | const d = wordDiff("const a = 1;", "const a = 1;"); | |
| 16 | expect(d.old.some((s) => s.changed)).toBe(false); | |
| 17 | expect(d.new.some((s) => s.changed)).toBe(false); | |
| 18 | }); | |
| 19 | ||
| 20 | test("a one-word change marks only that word on each side", () => { | |
| 21 | const d = wordDiff("the quick brown fox", "the quick red fox"); | |
| 22 | expect(changedText(d.old)).toEqual(["brown"]); | |
| 23 | expect(changedText(d.new)).toEqual(["red"]); | |
| 24 | }); | |
| 25 | ||
| 26 | const cases: [string, string][] = [ | |
| 27 | ["", ""], | |
| 28 | ["", "added line"], | |
| 29 | ["removed line", ""], | |
| 30 | ["a", "b"], | |
| 31 | ["foo(bar)", "foo(baz, qux)"], | |
| 32 | [" indented", " indented"], | |
| 33 | ["trailing ", "trailing"], | |
| 34 | ["héllo wörld", "héllo wörld!"], | |
| 35 | ["日本語 のテキスト", "日本語 の文章"], | |
| 36 | ["emoji 😀 here", "emoji 😎 here"], | |
| 37 | ["a b c d e", "e d c b a"], | |
| 38 | ["x\ty", "x y"], | |
| 39 | ]; | |
| 40 | ||
| 41 | test("segments rejoin to each line over a dozen cases", () => { | |
| 42 | expect(cases.length).toBeGreaterThanOrEqual(12); | |
| 43 | for (const [o, n] of cases) { | |
| 44 | const d = wordDiff(o, n); | |
| 45 | expect(join(d.old)).toBe(o); | |
| 46 | expect(join(d.new)).toBe(n); | |
| 47 | expect(d.old.length).toBeGreaterThan(0); | |
| 48 | expect(d.new.length).toBeGreaterThan(0); | |
| 49 | } | |
| 50 | }); | |
| 51 | ||
| 52 | // `w0 w1 …` tokenizes to one token per word plus one per space; a trailing | |
| 53 | // space adds one more, so any token count is reachable. | |
| 54 | const lineOfTokens = (count: number, tag: string) => { | |
outdated
300 words gives about 599 tokens, so this hard-codes an assumption about MAX_TOKENS=800. Export the constant and build the line from it so the test fails if the limit moves. |
||
| 55 | const words = Math.ceil(count / 2); | |
outdated
Wall-clock bound can flake on a loaded gate machine. The shape assertions below already prove the degrade path; consider removing this. |
||
| 56 | const text = Array.from({ length: words }, (_, i) => `${tag}${i}`).join(" "); | |
| 57 | return count % 2 === 0 ? text + " " : text; | |
| 58 | }; | |
| 59 | ||
| 60 | test("a pair of exactly MAX_TOKENS tokens is still diffed word by word", () => { | |
| 61 | const o = lineOfTokens(MAX_TOKENS, "w"); | |
| 62 | const n = o.replace("w0 ", "changed "); | |
| 63 | const d = wordDiff(o, n); | |
| 64 | expect(changedText(d.old)).toEqual(["w0"]); | |
| 65 | expect(changedText(d.new)).toEqual(["changed"]); | |
| 66 | expect(join(d.old)).toBe(o); | |
| 67 | expect(join(d.new)).toBe(n); | |
| 68 | }); | |
| 69 | ||
| 70 | test("one token past MAX_TOKENS is one changed segment per side", () => { | |
| 71 | const o = lineOfTokens(MAX_TOKENS + 1, "w"); | |
| 72 | const n = o.replace("w0 ", "changed "); | |
| 73 | const d = wordDiff(o, n); | |
| 74 | expect(d.old).toEqual([{ text: o, changed: true }]); | |
| 75 | expect(d.new).toEqual([{ text: n, changed: true }]); | |
| 76 | }); | |
| 77 | ||
| 78 | test("a far longer pair is one changed segment per side", () => { | |
| 79 | const o = lineOfTokens(MAX_TOKENS * 3, "a"); | |
| 80 | const n = lineOfTokens(MAX_TOKENS * 3, "b"); | |
| 81 | const d = wordDiff(o, n); | |
| 82 | expect(d.old).toEqual([{ text: o, changed: true }]); | |
| 83 | expect(d.new).toEqual([{ text: n, changed: true }]); | |
| 84 | }); | |
| 85 | }); |
new above
offline
This case is resolved by the prefix/suffix trim alone. Add one where the changed word sits between repeated tokens, so the LCS table decides which tokens are marked.