f13105333a
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>
103 lines
4.0 KiB
TypeScript
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([]);
|
|
});
|
|
});
|