Files
gitmost/apps/client/src/features/comment/utils/suggestion.test.ts
T
claude code agent 227 f13105333a feat(client): intraline diff highlighting in the suggestion before→after block (#331)
The suggestion block (#315) struck the whole `selection` red and showed the whole
`suggestedText` green, so a one-letter edit (заведем→заведём) highlighted the
entire line. Now only the CHANGED fragments are emphasized intraline, git-style.

Pure, render-only — nothing changes in the DB/backend/MCP/IComment/mutations/
Apply/Badge. New pure `computeSuggestionDiff(old, new) => { old: Segment[], new:
Segment[] }` (Segment = {text, changed}) in suggestion.ts: hybrid word+char —
`diffWordsWithSpace` for the word skeleton, then `diffChars` inside an adjacent
removed+added pair so only the differing letters (not the whole word) are
flagged; a lone insertion/deletion is wholly changed; equal parts are common on
both sides. Concatenating each side reproduces the input (lossless). Wrapped in
`useMemo` on [selection, suggestedText].

comment-list-item.tsx renders per-segment spans instead of two whole <Text>;
changed segments get `.suggestionChanged` (a stronger currentColor tint + bold,
NO text-decoration so the old block's inherited line-through survives on the
changed letters — the whole old line still reads removed, new as added).

`diff@8.0.3` (jsdiff, already in the root package.json) added to
apps/client/package.json (+ lockfile, additive) so the workspace resolves it;
it bundles its own types.

Tests: new suggestion.test.ts (one-letter ё/е; word replacement keeping the
shared word common with no per-letter noise; word insertion/deletion; identical)
— asserts segment text + changed flags, non-vacuous. Two pre-existing
comment-list-item.test assertions switched from getByText (a single text node)
to container.textContent (the new line is now multiple spans) — adapts to the
intended DOM change, not a weakening.

Verified: tsc --noEmit clean; client vitest 892 passed | 1 expected-fail.
Visual/pixel check of the tint at the 390px comment panel needs a human (no
screenshot tooling in-repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:17:07 +03:00

103 lines
4.0 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { computeSuggestionDiff, Segment } from "@/features/comment/utils/suggestion";
// Reconstruct the plain string from a segment stream — the diff must be
// lossless (concatenating every fragment yields the original input).
const join = (segments: Segment[]): string =>
segments.map((s) => s.text).join("");
// The subset of segments (in order) that the UI would emphasise.
const changed = (segments: Segment[]): string[] =>
segments.filter((s) => s.changed).map((s) => s.text);
// Find the segment that contains a substring, to assert its `changed` flag.
const segmentWith = (segments: Segment[], needle: string): Segment | undefined =>
segments.find((s) => s.text.includes(needle));
describe("computeSuggestionDiff", () => {
it("highlights only the single changed letter in a one-letter edit", () => {
const { old, new: neu } = computeSuggestionDiff("заведем", "заведём");
// Lossless.
expect(join(old)).toBe("заведем");
expect(join(neu)).toBe("заведём");
// Old side: exactly the `е` is changed, the rest is common.
expect(changed(old)).toEqual(["е"]);
expect(old).toEqual([
{ text: "завед", changed: false },
{ text: "е", changed: true },
{ text: "м", changed: false },
]);
// New side: exactly the `ё` is changed.
expect(changed(neu)).toEqual(["ё"]);
expect(neu).toEqual([
{ text: "завед", changed: false },
{ text: "ё", changed: true },
{ text: "м", changed: false },
]);
});
it("marks the differing words changed but keeps the shared word common", () => {
const { old, new: neu } = computeSuggestionDiff(
"привет мир",
"здравствуй мир",
);
// Lossless.
expect(join(old)).toBe("привет мир");
expect(join(neu)).toBe("здравствуй мир");
// The shared trailing word stays common on both sides (no per-letter noise
// leaking across the differing words into `мир`).
expect(segmentWith(old, "мир")?.changed).toBe(false);
expect(segmentWith(neu, "мир")?.changed).toBe(false);
// The differing words are emphasised somewhere on each side.
expect(changed(old).length).toBeGreaterThan(0);
expect(changed(neu).length).toBeGreaterThan(0);
expect(changed(old).join("")).toContain("п"); // from `привет`
expect(changed(neu).join("")).toContain("зд"); // from `здравствуй`
// No changed fragment on either side touches the word `мир`.
expect(changed(old).some((t) => t.includes("мир"))).toBe(false);
expect(changed(neu).some((t) => t.includes("мир"))).toBe(false);
});
it("marks a whole inserted word changed and leaves the old line common", () => {
const { old, new: neu } = computeSuggestionDiff("a c", "a b c");
expect(join(old)).toBe("a c");
expect(join(neu)).toBe("a b c");
// Old line has no changed fragment (nothing was removed).
expect(changed(old)).toEqual([]);
// The inserted word is the only changed fragment on the new side.
expect(neu).toContainEqual({ text: "b ", changed: true });
expect(changed(neu)).toEqual(["b "]);
});
it("marks a whole deleted word changed and leaves the new line common", () => {
const { old, new: neu } = computeSuggestionDiff("a b c", "a c");
expect(join(old)).toBe("a b c");
expect(join(neu)).toBe("a c");
// The deleted word is the only changed fragment on the old side.
expect(old).toContainEqual({ text: "b ", changed: true });
expect(changed(old)).toEqual(["b "]);
// New line has no changed fragment (nothing was added).
expect(changed(neu)).toEqual([]);
});
it("marks everything common for identical strings", () => {
const { old, new: neu } = computeSuggestionDiff("hello", "hello");
expect(old).toEqual([{ text: "hello", changed: false }]);
expect(neu).toEqual([{ text: "hello", changed: false }]);
expect(changed(old)).toEqual([]);
expect(changed(neu)).toEqual([]);
});
});