Path: blob/main/extensions/copilot/src/extension/inlineEdits/test/common/editRebase.spec.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/4import { expect, suite, test } from 'vitest';5import { decomposeStringEdit } from '../../../../platform/inlineEdits/common/dataTypes/editUtils';6import { TestLogService } from '../../../../platform/testing/common/testLogService';7import { StringEdit, StringReplacement } from '../../../../util/vs/editor/common/core/edits/stringEdit';8import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange';9import { maxAgreementOffset, maxImperfectAgreementLength, tryRebase, tryRebaseStringEdits } from '../../common/editRebase';101112suite('NextEditCache', () => {13test('tryRebase keeps index and full edit', async () => {14const originalDocument = `15class Point3D {16constructor(x, y) {17this.x = x;18this.y = y;19}20}21`;22const suggestedEdit = StringEdit.create([23StringReplacement.replace(new OffsetRange(17, 37), ' constructor(x, y, z) {'),24StringReplacement.replace(new OffsetRange(65, 65), '\n this.z = z;'),25]);26const userEdit = StringEdit.create([27StringReplacement.replace(new OffsetRange(34, 34), ', z'),28StringReplacement.replace(new OffsetRange(65, 65), '\n this.'),29]);30const final = suggestedEdit.apply(originalDocument);31expect(final).toStrictEqual(`32class Point3D {33constructor(x, y, z) {34this.x = x;35this.y = y;36this.z = z;37}38}39`);40const currentDocument = userEdit.apply(originalDocument);41expect(currentDocument).toStrictEqual(`42class Point3D {43constructor(x, y, z) {44this.x = x;45this.y = y;46this.47}48}49`);5051const logger = new TestLogService();52{53const res = tryRebase(originalDocument, undefined, decomposeStringEdit(suggestedEdit).edits, [], userEdit, currentDocument, [], 'strict', logger);54expect(res).toBeTypeOf('object');55const result = res as Exclude<typeof res, string | undefined>;56expect(result[0].rebasedEditIndex).toBe(1);57expect(result[0].rebasedEdit.toString()).toMatchInlineSnapshot(`"[68, 76) -> "\\n\\t\\tthis.z = z;""`);58}59{60const res = tryRebase(originalDocument, undefined, decomposeStringEdit(suggestedEdit).edits, [], userEdit, currentDocument, [], 'lenient', logger);61expect(res).toBeTypeOf('object');62const result = res as Exclude<typeof res, string | undefined>;63expect(result[0].rebasedEditIndex).toBe(1);64expect(result[0].rebasedEdit.toString()).toMatchInlineSnapshot(`"[68, 76) -> "\\n\\t\\tthis.z = z;""`);65}66});6768test('tryRebase matches up edits', async () => {69// Ambiguity with shifted edits.70const originalDocument = `71function getEnvVar(name): string | undefined {72const value = process.env[name] || undefined;73if (!value) {74console.warn(\`Environment variable \${name} is not set\`);75}76return value;77}7879function main() {80const foo = getEnvVar("FOO");81if (!foo) {82return;83}84}85`;86const suggestedEdit = StringEdit.create([87StringReplacement.replace(new OffsetRange(265, 266), ` // Do something with foo88}`),89]);90const userEdit = StringEdit.create([91StringReplacement.replace(new OffsetRange(264, 264), `92939495// Do something with foo`),96]);97const final = suggestedEdit.apply(originalDocument);98expect(final).toStrictEqual(`99function getEnvVar(name): string | undefined {100const value = process.env[name] || undefined;101if (!value) {102console.warn(\`Environment variable \${name} is not set\`);103}104return value;105}106107function main() {108const foo = getEnvVar("FOO");109if (!foo) {110return;111}112// Do something with foo113}114`);115const currentDocument = userEdit.apply(originalDocument);116expect(currentDocument).toStrictEqual(`117function getEnvVar(name): string | undefined {118const value = process.env[name] || undefined;119if (!value) {120console.warn(\`Environment variable \${name} is not set\`);121}122return value;123}124125function main() {126const foo = getEnvVar("FOO");127if (!foo) {128return;129}130131132133// Do something with foo134}135`);136137const logger = new TestLogService();138expect(tryRebase(originalDocument, undefined, suggestedEdit.replacements, [], userEdit, currentDocument, [], 'strict', logger)).toStrictEqual('rebaseFailed');139expect(tryRebase(originalDocument, undefined, suggestedEdit.replacements, [], userEdit, currentDocument, [], 'lenient', logger)).toStrictEqual('rebaseFailed');140});141142test('tryRebase correct offsets', async () => {143const originalDocument = `144#include <vector>145namespace146{147size_t func()148{149std::vector<int> result42;150if (result.empty())151return result.size();152result.clear();153return result.size();154}155}156157158int main()159{160return 0;161}162`;163const suggestedEdit = StringEdit.create([164StringReplacement.replace(new OffsetRange(78, 178), ` if (result42.empty())165return result42.size();166result42.clear();167return result42.size();168`),169]);170const userEdit = StringEdit.create([171StringReplacement.replace(new OffsetRange(86, 92), `r`),172]);173const final = suggestedEdit.apply(originalDocument);174expect(final).toStrictEqual(`175#include <vector>176namespace177{178size_t func()179{180std::vector<int> result42;181if (result42.empty())182return result42.size();183result42.clear();184return result42.size();185}186}187188189int main()190{191return 0;192}193`);194const currentDocument = userEdit.apply(originalDocument);195expect(currentDocument).toStrictEqual(`196#include <vector>197namespace198{199size_t func()200{201std::vector<int> result42;202if (r.empty())203return result.size();204result.clear();205return result.size();206}207}208209210int main()211{212return 0;213}214`);215216const logger = new TestLogService();217{218const res = tryRebase(originalDocument, undefined, suggestedEdit.replacements, [], userEdit, currentDocument, [], 'strict', logger);219expect(res).toBeTypeOf('object');220const result = res as Exclude<typeof res, string | undefined>;221expect(result[0].rebasedEditIndex).toBe(0);222expect(StringEdit.single(result[0].rebasedEdit).apply(currentDocument)).toStrictEqual(final);223expect(result[0].rebasedEdit.removeCommonSuffixAndPrefix(currentDocument).toString()).toMatchInlineSnapshot(`"[87, 164) -> "esult42.empty())\\n return result42.size();\\n result42.clear();\\n return result42""`);224}225{226const res = tryRebase(originalDocument, undefined, suggestedEdit.replacements, [], userEdit, currentDocument, [], 'lenient', logger);227expect(res).toBeTypeOf('object');228const result = res as Exclude<typeof res, string | undefined>;229expect(result[0].rebasedEditIndex).toBe(0);230expect(StringEdit.single(result[0].rebasedEdit).apply(currentDocument)).toStrictEqual(final);231expect(result[0].rebasedEdit.removeCommonSuffixAndPrefix(currentDocument).toString()).toMatchInlineSnapshot(`"[87, 164) -> "esult42.empty())\\n return result42.size();\\n result42.clear();\\n return result42""`);232}233});234235test('tryRebase fails when user types characters absent from the suggestion', () => {236// Document state when suggestion was cached:237// "function fib\n"238// ^ cursor at offset 12239//240// Suggestion (two edits):241// edit 0: replace [0,12) "function fib" → "function fib(n: number): number {"242// edit 1: insert at 34 → " if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n"243//244// User then types "()" at offset 12, producing:245// "function fib()\n"246//247// Rebase fails because the diff of edit 0 inserts "(n: number): number {" at offset 12,248// but the user typed "()" — and "()" is not a substring of "(n: number): number {",249// so agreementIndexOf returns -1 and the rebase cannot reconcile the two.250const originalDocument = 'function fib\n';251const originalEdits = [252StringReplacement.replace(new OffsetRange(0, 12), 'function fib(n: number): number {'),253StringReplacement.replace(new OffsetRange(34, 34), ' if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n'),254];255const userEditSince = StringEdit.create([256StringReplacement.replace(new OffsetRange(12, 12), '()'),257]);258const currentDocumentContent = 'function fib()\n';259const editWindow = new OffsetRange(0, 13);260const currentSelection = [new OffsetRange(13, 13)];261262const logger = new TestLogService();263expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger)).toBe('rebaseFailed');264expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'lenient', logger)).toBe('rebaseFailed');265});266267test('absorbSubsequenceTyping: parentheses typed by user are absorbed', () => {268// The "()" the user typed is a subsequence of the suggestion's "(n: number): number {",269// so the rebased edit replaces it with the suggestion's text.270const originalDocument = 'function fib\n';271const originalEdits = [272StringReplacement.replace(new OffsetRange(0, 12), 'function fib(n: number): number {'),273StringReplacement.replace(new OffsetRange(34, 34), ' if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n'),274];275const userEditSince = StringEdit.create([276StringReplacement.replace(new OffsetRange(12, 12), '()'),277]);278const currentDocumentContent = 'function fib()\n';279const editWindow = new OffsetRange(0, 13);280const currentSelection = [new OffsetRange(13, 13)];281const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };282const logger = new TestLogService();283284const final = 'function fib(n: number): number {\n if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n';285286{287const res = tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs);288expect(res).toBeTypeOf('object');289const result = res as Exclude<typeof res, string>;290expect(StringEdit.create(result.map(r => r.rebasedEdit)).apply(currentDocumentContent)).toBe(final);291}292{293const res = tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'lenient', logger, nesConfigs);294expect(res).toBeTypeOf('object');295const result = res as Exclude<typeof res, string>;296expect(StringEdit.create(result.map(r => r.rebasedEdit)).apply(currentDocumentContent)).toBe(final);297}298});299300test('absorbSubsequenceTyping: user types partial params "(n: )" NOT absorbed (not an auto-close pair)', () => {301// User types "(n: )" in "function fib" → "function fib(n: )\n"302// "(n: )" is a subsequence of the suggestion but is NOT an auto-close pair,303// so absorption does not apply.304const originalDocument = 'function fib\n';305const originalEdits = [306StringReplacement.replace(new OffsetRange(0, 12), 'function fib(n: number): number {'),307StringReplacement.replace(new OffsetRange(34, 34), ' if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n'),308];309const userEditSince = StringEdit.create([310StringReplacement.replace(new OffsetRange(12, 12), '(n: )'),311]);312const currentDocumentContent = 'function fib(n: )\n';313const editWindow = new OffsetRange(0, 13);314const currentSelection = [new OffsetRange(16, 16)];315const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };316const logger = new TestLogService();317318expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs)).toBe('rebaseFailed');319expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'lenient', logger, nesConfigs)).toBe('rebaseFailed');320});321322test('absorbSubsequenceTyping: semicolon NOT absorbed when it cannot align with suggestion', () => {323// User types ";" but suggestion wants to insert ": string = \"hello\""324// ";" is not a subsequence of ": string = \"hello\"", so absorption fails325const originalDocument = 'const x\n';326const originalEdits = [327StringReplacement.replace(new OffsetRange(0, 7), 'const x: string = "hello"'),328];329const userEditSince = StringEdit.create([330StringReplacement.replace(new OffsetRange(7, 7), ';'),331]);332const currentDocumentContent = 'const x;\n';333const editWindow = new OffsetRange(0, 8);334const currentSelection = [new OffsetRange(8, 8)];335const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };336const logger = new TestLogService();337338expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs)).toBe('rebaseFailed');339expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'lenient', logger, nesConfigs)).toBe('rebaseFailed');340});341342test('absorbSubsequenceTyping: semicolon NOT absorbed (not an auto-close pair)', () => {343// User types ";" and suggestion inserts ": string = \"hello\";"344// ";" is present in the suggestion but is NOT an auto-close pair,345// so absorption does not apply.346const originalDocument = 'const x\n';347const originalEdits = [348StringReplacement.replace(new OffsetRange(0, 7), 'const x: string = "hello";'),349];350const userEditSince = StringEdit.create([351StringReplacement.replace(new OffsetRange(7, 7), ';'),352]);353const currentDocumentContent = 'const x;\n';354const editWindow = new OffsetRange(0, 8);355const currentSelection = [new OffsetRange(8, 8)];356const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };357const logger = new TestLogService();358359// Strict rejects the exact match (offset 25 > maxAgreementOffset) and absorption360// doesn't apply because ";" is not an auto-close pair.361expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs)).toBe('rebaseFailed');362});363364test('absorbSubsequenceTyping: text NOT a subsequence of suggestion is NOT absorbed', () => {365// User types "abc" — not a subsequence of "(n: number): number {", so not absorbed366const originalDocument = 'function fib\n';367const originalEdits = [368StringReplacement.replace(new OffsetRange(0, 12), 'function fib(n: number): number {'),369StringReplacement.replace(new OffsetRange(34, 34), ' if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n'),370];371const userEditSince = StringEdit.create([372StringReplacement.replace(new OffsetRange(12, 12), 'abc'),373]);374const currentDocumentContent = 'function fibabc\n';375const editWindow = new OffsetRange(0, 13);376const currentSelection = [new OffsetRange(15, 15)];377const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };378const logger = new TestLogService();379380expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs)).toBe('rebaseFailed');381expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'lenient', logger, nesConfigs)).toBe('rebaseFailed');382});383384test('absorbSubsequenceTyping: text NOT a subsequence of suggestion is NOT absorbed (2)', () => {385// User types "(a" — "a" is not found in "(n: number): number {", so not absorbed386const originalDocument = 'function fib\n';387const originalEdits = [388StringReplacement.replace(new OffsetRange(0, 12), 'function fib(n: number): number {'),389StringReplacement.replace(new OffsetRange(34, 34), ' if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n'),390];391const userEditSince = StringEdit.create([392StringReplacement.replace(new OffsetRange(12, 12), '(a'),393]);394const currentDocumentContent = 'function fib(a\n';395const editWindow = new OffsetRange(0, 13);396const currentSelection = [new OffsetRange(14, 14)];397const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };398const logger = new TestLogService();399400expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs)).toBe('rebaseFailed');401expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'lenient', logger, nesConfigs)).toBe('rebaseFailed');402});403404test('absorbSubsequenceTyping: config disabled means punctuation is NOT absorbed', () => {405// Same fib scenario with "()" but config is explicitly false406const originalDocument = 'function fib\n';407const originalEdits = [408StringReplacement.replace(new OffsetRange(0, 12), 'function fib(n: number): number {'),409StringReplacement.replace(new OffsetRange(34, 34), ' if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n'),410];411const userEditSince = StringEdit.create([412StringReplacement.replace(new OffsetRange(12, 12), '()'),413]);414const currentDocumentContent = 'function fib()\n';415const editWindow = new OffsetRange(0, 13);416const currentSelection = [new OffsetRange(13, 13)];417const logger = new TestLogService();418419// Explicitly disabled420expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, { absorbSubsequenceTyping: false, maxImperfectAgreementLength })).toBe('rebaseFailed');421// Default (no config)422expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger)).toBe('rebaseFailed');423});424425test('absorbSubsequenceTyping: normal agreement still works when user types text present in suggestion', () => {426// User types "(n" which IS a prefix found in the suggestion "(n: number): number {"427// Normal agreement should handle this regardless of the config428const originalDocument = 'function fib\n';429const suggestedEdit = StringEdit.create([430StringReplacement.replace(new OffsetRange(0, 12), 'function fib(n: number): number {'),431]);432const userEdit = StringEdit.create([433StringReplacement.replace(new OffsetRange(12, 12), '(n'),434]);435const currentDocument = userEdit.apply(originalDocument);436expect(currentDocument).toBe('function fib(n\n');437438const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };439const res = tryRebaseStringEdits(originalDocument, suggestedEdit, userEdit, 'strict', nesConfigs);440expect(res).toBeDefined();441expect(res!.apply(currentDocument)).toBe(suggestedEdit.apply(originalDocument));442});443444test('absorbSubsequenceTyping via tryRebaseStringEdits: single curly brace NOT absorbed (not an auto-close pair)', () => {445const text = 'if (true)\n';446const suggestion = StringEdit.create([447StringReplacement.replace(new OffsetRange(0, 9), 'if (true) {\n console.log("yes");\n}'),448]);449const userEdit = StringEdit.create([450StringReplacement.replace(new OffsetRange(9, 9), '{'),451]);452const current = userEdit.apply(text);453expect(current).toBe('if (true){\n');454455// Without config: fails456expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();457458// With config: still fails because a single "{" is not an auto-close pair459expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict', { absorbSubsequenceTyping: true, maxImperfectAgreementLength })).toBeUndefined();460});461462test('absorbSubsequenceTyping: "{}" NOT absorbed when suggestion only has opening brace', () => {463// User types "{}" but suggestion only inserts " {" (no closing brace in suggestion text)464// "}" is not found after "{" in " {", so subsequence check fails465const text = 'if (true)\n';466const suggestion = StringEdit.create([467StringReplacement.replace(new OffsetRange(0, 9), 'if (true) {\n console.log("yes");'),468]);469const userEdit = StringEdit.create([470StringReplacement.replace(new OffsetRange(9, 9), '{}'),471]);472const current = userEdit.apply(text);473expect(current).toBe('if (true){}\n');474475expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict', { absorbSubsequenceTyping: true, maxImperfectAgreementLength })).toBeUndefined();476});477478test('absorbSubsequenceTyping: "{}" absorbed when suggestion has both braces', () => {479const text = 'if (true)\n';480const suggestion = StringEdit.create([481StringReplacement.replace(new OffsetRange(0, 9), 'if (true) {\n console.log("yes");\n}'),482]);483const userEdit = StringEdit.create([484StringReplacement.replace(new OffsetRange(9, 9), '{}'),485]);486const current = userEdit.apply(text);487expect(current).toBe('if (true){}\n');488489const final = suggestion.apply(text);490expect(final).toBe('if (true) {\n console.log("yes");\n}\n');491492const result = tryRebaseStringEdits(text, suggestion, userEdit, 'strict', { absorbSubsequenceTyping: true, maxImperfectAgreementLength });493expect(result).toBeDefined();494expect(result!.apply(current)).toBe(final);495});496497test('absorbSubsequenceTyping: "{}" typed after function signature, suggestion fills body', () => {498// User types "{}" after "function fib(n: number) " → "function fib(n: number) {}\n"499// Suggestion wants to replace with a full function body including { ... }500// "{}" is a subsequence of "{\n if ...\n}" so absorption succeeds501const originalDocument = 'function fib(n: number) \n';502const originalEdits = [503StringReplacement.replace(new OffsetRange(0, 24), 'function fib(n: number) {\n if (n <= 1) return 1;\n return n * factorial(n - 1);\n}'),504];505const userEditSince = StringEdit.create([506StringReplacement.replace(new OffsetRange(24, 24), '{}'),507]);508const currentDocumentContent = 'function fib(n: number) {}\n';509const editWindow = new OffsetRange(0, 25);510const currentSelection = [new OffsetRange(26, 26)];511const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };512const logger = new TestLogService();513514const final = 'function fib(n: number) {\n if (n <= 1) return 1;\n return n * factorial(n - 1);\n}\n';515516{517const res = tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs);518expect(res).toBeTypeOf('object');519const result = res as Exclude<typeof res, string>;520expect(StringEdit.create(result.map(r => r.rebasedEdit)).apply(currentDocumentContent)).toBe(final);521}522{523const res = tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'lenient', logger, nesConfigs);524expect(res).toBeTypeOf('object');525const result = res as Exclude<typeof res, string>;526expect(StringEdit.create(result.map(r => r.rebasedEdit)).apply(currentDocumentContent)).toBe(final);527}528});529});530531suite('NextEditCache.tryRebaseStringEdits', () => {532test('insert', () => {533const text = 'class Point3 {';534const edit = StringEdit.create([535StringReplacement.replace(new OffsetRange(0, 14), 'class Point3D {'),536]);537const base = StringEdit.create([538StringReplacement.replace(new OffsetRange(12, 12), 'D'),539]);540expect(edit.apply(text)).toStrictEqual('class Point3D {');541expect(base.apply(text)).toStrictEqual('class Point3D {');542543expect(tryRebaseStringEdits(text, edit, base, 'strict')?.replacements.toString()).toMatchInlineSnapshot(`"[0, 15) -> "class Point3D {""`);544expect(tryRebaseStringEdits(text, edit, base, 'lenient')?.replacements.toString()).toMatchInlineSnapshot(`"[0, 15) -> "class Point3D {""`);545});546test('replace', () => {547const text = 'class Point3d {';548const edit = StringEdit.create([549StringReplacement.replace(new OffsetRange(0, 15), 'class Point3D {'),550]);551const base = StringEdit.create([552StringReplacement.replace(new OffsetRange(12, 13), 'D'),553]);554expect(edit.apply(text)).toStrictEqual('class Point3D {');555expect(base.apply(text)).toStrictEqual('class Point3D {');556557expect(tryRebaseStringEdits(text, edit, base, 'strict')?.replacements.toString()).toMatchInlineSnapshot(`"[0, 15) -> "class Point3D {""`);558expect(tryRebaseStringEdits(text, edit, base, 'lenient')?.replacements.toString()).toMatchInlineSnapshot(`"[0, 15) -> "class Point3D {""`);559});560test('delete', () => {561const text = 'class Point34D {';562const edit = StringEdit.create([563StringReplacement.replace(new OffsetRange(0, 16), 'class Point3D {'),564]);565const base = StringEdit.create([566StringReplacement.replace(new OffsetRange(12, 13), ''),567]);568expect(edit.apply(text)).toStrictEqual('class Point3D {');569expect(base.apply(text)).toStrictEqual('class Point3D {');570571expect(tryRebaseStringEdits(text, edit, base, 'strict')?.replacements.toString()).toMatchInlineSnapshot(`"[0, 15) -> "class Point3D {""`);572expect(tryRebaseStringEdits(text, edit, base, 'lenient')?.replacements.toString()).toMatchInlineSnapshot(`"[0, 15) -> "class Point3D {""`);573});574test('insert', () => {575const text = 'class Point3 {';576const edit = StringEdit.create([577StringReplacement.replace(new OffsetRange(0, 14), 'class Point3D {'),578]);579const base = StringEdit.create([580StringReplacement.replace(new OffsetRange(12, 12), 'd'),581]);582expect(edit.apply(text)).toStrictEqual('class Point3D {');583expect(base.apply(text)).toStrictEqual('class Point3d {');584585expect(tryRebaseStringEdits(text, edit, base, 'strict')?.replacements.toString()).toBeUndefined();586expect(tryRebaseStringEdits(text, edit, base, 'lenient')?.replacements.toString()).toBeUndefined();587});588589test('insert 2 edits', () => {590const text = `591class Point3D {592constructor(x, y) {593this.x = x;594this.y = y;595}596}597`;598const edit = StringEdit.create([599StringReplacement.replace(new OffsetRange(17, 37), ' constructor(x, y, z) {'),600StringReplacement.replace(new OffsetRange(66, 66), ' this.z = z;\n'),601]);602const base = StringEdit.create([603StringReplacement.replace(new OffsetRange(34, 34), ', z'),604]);605const final = edit.apply(text);606expect(final).toStrictEqual(`607class Point3D {608constructor(x, y, z) {609this.x = x;610this.y = y;611this.z = z;612}613}614`);615const current = base.apply(text);616expect(current).toStrictEqual(`617class Point3D {618constructor(x, y, z) {619this.x = x;620this.y = y;621}622}623`);624625const strict = tryRebaseStringEdits(text, edit, base, 'strict')?.removeCommonSuffixAndPrefix(current);626expect(strict?.apply(current)).toStrictEqual(final);627expect(strict?.replacements.toString()).toMatchInlineSnapshot(`"[69, 69) -> "\\t\\tthis.z = z;\\n""`);628const lenient = tryRebaseStringEdits(text, edit, base, 'lenient')?.removeCommonSuffixAndPrefix(current);629expect(lenient?.apply(current)).toStrictEqual(final);630expect(lenient?.replacements.toString()).toMatchInlineSnapshot(`"[69, 69) -> "\\t\\tthis.z = z;\\n""`);631});632test('insert 2 and 2 edits', () => {633const text = `634class Point3D {635constructor(x, y) {636this.x = x;637this.y = y;638}639}640`;641const edit = StringEdit.create([642StringReplacement.replace(new OffsetRange(17, 37), ' constructor(x, y, z) {'),643StringReplacement.replace(new OffsetRange(65, 65), '\n this.z = z;'),644]);645const base = StringEdit.create([646StringReplacement.replace(new OffsetRange(34, 34), ', z'),647StringReplacement.replace(new OffsetRange(65, 65), '\n this.z = z;'),648]);649const final = edit.apply(text);650expect(final).toStrictEqual(`651class Point3D {652constructor(x, y, z) {653this.x = x;654this.y = y;655this.z = z;656}657}658`);659const current = base.apply(text);660expect(current).toStrictEqual(`661class Point3D {662constructor(x, y, z) {663this.x = x;664this.y = y;665this.z = z;666}667}668`);669670const strict = tryRebaseStringEdits(text, edit, base, 'strict')?.removeCommonSuffixAndPrefix(current);671expect(strict?.apply(current)).toStrictEqual(final);672expect(strict?.replacements.toString()).toMatchInlineSnapshot(`""`);673const lenient = tryRebaseStringEdits(text, edit, base, 'lenient')?.removeCommonSuffixAndPrefix(current);674expect(lenient?.apply(current)).toStrictEqual(final);675expect(lenient?.replacements.toString()).toMatchInlineSnapshot(`""`);676});677test('insert 2 and 1 edits, 1 fully contained', () => {678const text = `abcdefghi`;679const suggestion = StringEdit.create([680StringReplacement.replace(new OffsetRange(4, 5), '234'),681StringReplacement.replace(new OffsetRange(7, 8), 'ABC'),682]);683const userEdit = StringEdit.create([684StringReplacement.replace(new OffsetRange(1, 6), '123456'),685]);686const intermediate = suggestion.apply(text);687expect(intermediate).toStrictEqual(`abcd234fgABCi`);688const current = userEdit.apply(text);689expect(current).toStrictEqual(`a123456ghi`);690691expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();692expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();693});694695test('2 user edits contained in 1', () => {696const text = `abcdef`;697const suggestion = StringEdit.create([698StringReplacement.replace(new OffsetRange(1, 4), 'b1c2d'),699]);700const applied = suggestion.apply(text);701expect(applied).toStrictEqual(`ab1c2def`);702703const userEdit = StringEdit.create([704StringReplacement.replace(new OffsetRange(2, 2), '1'),705StringReplacement.replace(new OffsetRange(3, 3), '2'),706StringReplacement.replace(new OffsetRange(5, 5), '3'),707]);708const current = userEdit.apply(text);709expect(current).toStrictEqual(`ab1c2de3f`);710711expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();712const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')?.removeCommonSuffixAndPrefix(current);713expect(lenient?.apply(current)).toStrictEqual('ab1c2de3f');714expect(lenient?.replacements.toString()).toMatchInlineSnapshot(`""`);715});716717test('2 user edits contained in 1, conflicting 1', () => {718const text = `abcde`;719const suggestion = StringEdit.create([720StringReplacement.replace(new OffsetRange(1, 4), 'b1c2d'),721]);722const applied = suggestion.apply(text);723expect(applied).toStrictEqual(`ab1c2de`);724725const userEdit = StringEdit.create([726StringReplacement.replace(new OffsetRange(2, 2), '1'),727StringReplacement.replace(new OffsetRange(3, 3), '3'),728]);729const current = userEdit.apply(text);730expect(current).toStrictEqual(`ab1c3de`);731732expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();733expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();734});735736test('2 user edits contained in 1, conflicting 2', () => {737const text = `abcde`;738const suggestion = StringEdit.create([739StringReplacement.replace(new OffsetRange(1, 4), 'b1c2d'),740]);741const applied = suggestion.apply(text);742expect(applied).toStrictEqual(`ab1c2de`);743744const userEdit = StringEdit.create([745StringReplacement.replace(new OffsetRange(2, 2), '2'),746StringReplacement.replace(new OffsetRange(3, 3), '1'),747]);748const current = userEdit.apply(text);749expect(current).toStrictEqual(`ab2c1de`);750751expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();752expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();753});754755test('2 edits contained in 1 user edit', () => {756const text = `abcdef`;757const userEdit = StringEdit.create([758StringReplacement.replace(new OffsetRange(1, 4), 'b1c2d'),759]);760const current = userEdit.apply(text);761expect(current).toStrictEqual(`ab1c2def`);762763const suggestion = StringEdit.create([764StringReplacement.replace(new OffsetRange(2, 2), '1'),765StringReplacement.replace(new OffsetRange(3, 3), '2'),766StringReplacement.replace(new OffsetRange(5, 5), '3'),767]);768const applied = suggestion.apply(text);769expect(applied).toStrictEqual(`ab1c2de3f`);770771expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();772expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();773});774775test('2 edits contained in 1 user edit, conflicting 1', () => {776const text = `abcde`;777const userEdit = StringEdit.create([778StringReplacement.replace(new OffsetRange(1, 4), 'b1c2d'),779]);780const current = userEdit.apply(text);781expect(current).toStrictEqual(`ab1c2de`);782783const suggestion = StringEdit.create([784StringReplacement.replace(new OffsetRange(2, 2), '1'),785StringReplacement.replace(new OffsetRange(3, 3), '3'),786]);787const applied = suggestion.apply(text);788expect(applied).toStrictEqual(`ab1c3de`);789790expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();791expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();792});793794test('2 edits contained in 1 user edit, conflicting 2', () => {795const text = `abcde`;796const userEdit = StringEdit.create([797StringReplacement.replace(new OffsetRange(1, 4), 'b1c2d'),798]);799const current = userEdit.apply(text);800expect(current).toStrictEqual(`ab1c2de`);801802const suggestion = StringEdit.create([803StringReplacement.replace(new OffsetRange(2, 2), '2'),804StringReplacement.replace(new OffsetRange(3, 3), '1'),805]);806const applied = suggestion.apply(text);807expect(applied).toStrictEqual(`ab2c1de`);808809expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();810expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();811});812813test('1 additional user edit', () => {814const text = `abcdef`;815const userEdit = StringEdit.create([816StringReplacement.replace(new OffsetRange(2, 2), '1'),817StringReplacement.replace(new OffsetRange(3, 3), '2'),818StringReplacement.replace(new OffsetRange(5, 5), '3'),819]);820const current = userEdit.apply(text);821expect(current).toStrictEqual(`ab1c2de3f`);822823const suggestion = StringEdit.create([824StringReplacement.replace(new OffsetRange(2, 2), '1'),825StringReplacement.replace(new OffsetRange(5, 5), '3'),826]);827const applied = suggestion.apply(text);828expect(applied).toStrictEqual(`ab1cde3f`);829830expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();831const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')?.removeCommonSuffixAndPrefix(current);832expect(lenient?.apply(current)).toStrictEqual('ab1c2de3f');833expect(lenient?.replacements.toString()).toMatchInlineSnapshot(`""`);834});835836test('1 additional suggestion edit', () => {837const text = `abcdef`;838const userEdit = StringEdit.create([839StringReplacement.replace(new OffsetRange(2, 2), '1'),840StringReplacement.replace(new OffsetRange(5, 5), '3'),841]);842const current = userEdit.apply(text);843expect(current).toStrictEqual(`ab1cde3f`);844845const suggestion = StringEdit.create([846StringReplacement.replace(new OffsetRange(2, 2), '1'),847StringReplacement.replace(new OffsetRange(3, 3), '2'),848StringReplacement.replace(new OffsetRange(5, 5), '3'),849]);850const applied = suggestion.apply(text);851expect(applied).toStrictEqual(`ab1c2de3f`);852853const strict = tryRebaseStringEdits(text, suggestion, userEdit, 'strict');854expect(strict?.apply(current)).toStrictEqual('ab1c2de3f');855expect(strict?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[4, 4) -> "2""`);856const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient');857expect(lenient?.apply(current)).toStrictEqual('ab1c2de3f');858expect(lenient?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[4, 4) -> "2""`);859});860861test('shifted edits 1', () => {862const text = `abcde`;863const userEdit = StringEdit.create([864StringReplacement.replace(new OffsetRange(2, 2), 'c1'),865]);866const current = userEdit.apply(text);867expect(current).toStrictEqual(`abc1cde`);868869const suggestion = StringEdit.create([870StringReplacement.replace(new OffsetRange(1, 1), '0'),871StringReplacement.replace(new OffsetRange(3, 3), '1c'),872StringReplacement.replace(new OffsetRange(4, 4), '2'),873]);874const applied = suggestion.apply(text);875expect(applied).toStrictEqual(`a0bc1cd2e`);876877const strict = tryRebaseStringEdits(text, suggestion, userEdit, 'strict');878expect(strict?.apply(current)).toStrictEqual('a0bc1cd2e');879expect(strict?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[1, 1) -> "0",[6, 6) -> "2""`);880const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient');881expect(lenient?.apply(current)).toStrictEqual('a0bc1cd2e');882expect(lenient?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[1, 1) -> "0",[6, 6) -> "2""`);883});884885test('shifted edits 2', () => {886const text = `abcde`;887const userEdit = StringEdit.create([888StringReplacement.replace(new OffsetRange(3, 3), '1c'),889]);890const current = userEdit.apply(text);891expect(current).toStrictEqual(`abc1cde`);892893const suggestion = StringEdit.create([894StringReplacement.replace(new OffsetRange(1, 1), '0'),895StringReplacement.replace(new OffsetRange(2, 2), 'c1'),896]);897const applied = suggestion.apply(text);898expect(applied).toStrictEqual(`a0bc1cde`);899900const strict = tryRebaseStringEdits(text, suggestion, userEdit, 'strict');901expect(strict?.apply(current)).toStrictEqual('a0bc1cde');902expect(strict?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[1, 1) -> "0""`);903const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient');904expect(lenient?.apply(current)).toStrictEqual('a0bc1cde');905expect(lenient?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[1, 1) -> "0""`);906});907908test('user deletes 1', () => {909const text = `abcde`;910const userEdit = StringEdit.create([911StringReplacement.replace(new OffsetRange(2, 3), ''),912]);913const current = userEdit.apply(text);914expect(current).toStrictEqual(`abde`);915916const suggestion = StringEdit.create([917StringReplacement.replace(new OffsetRange(3, 3), '1c'),918]);919const applied = suggestion.apply(text);920expect(applied).toStrictEqual(`abc1cde`);921922expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();923expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();924});925926test('user deletes 2', () => {927const text = `abcde`;928const userEdit = StringEdit.create([929StringReplacement.replace(new OffsetRange(2, 3), ''),930]);931const current = userEdit.apply(text);932expect(current).toStrictEqual(`abde`);933934const suggestion = StringEdit.create([935StringReplacement.replace(new OffsetRange(2, 2), 'c1'),936]);937const applied = suggestion.apply(text);938expect(applied).toStrictEqual(`abc1cde`);939940expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();941expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();942});943944test('overlap: suggestion replaces in disagreement', () => {945const text = `this.myPet = g`;946const userEdit = StringEdit.create([947StringReplacement.replace(new OffsetRange(14, 14), 'et'),948]);949const current = userEdit.apply(text);950expect(current).toStrictEqual(`this.myPet = get`);951952const suggestion = StringEdit.create([953StringReplacement.replace(new OffsetRange(13, 14), 'new Pet("Buddy", 3);'),954]);955const applied = suggestion.apply(text);956expect(applied).toStrictEqual(`this.myPet = new Pet("Buddy", 3);`);957958expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();959expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();960});961962test('overlap: suggestion replaces in agreement', () => {963const text = `this.myPet = g`;964const userEdit = StringEdit.create([965StringReplacement.replace(new OffsetRange(14, 14), 'et'),966]);967const current = userEdit.apply(text);968expect(current).toStrictEqual(`this.myPet = get`);969970const suggestion = StringEdit.create([971StringReplacement.replace(new OffsetRange(13, 14), 'getPet();'),972]);973const applied = suggestion.apply(text);974expect(applied).toStrictEqual(`this.myPet = getPet();`);975976const strict = tryRebaseStringEdits(text, suggestion, userEdit, 'strict');977expect(strict?.apply(current)).toStrictEqual('this.myPet = getPet();');978expect(strict?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[16, 16) -> "Pet();""`);979const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient');980expect(lenient?.apply(current)).toStrictEqual('this.myPet = getPet();');981expect(lenient?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[16, 16) -> "Pet();""`);982});983984test('overlap: both replace in agreement 1', () => {985const text = `abcdefg`;986const userEdit = StringEdit.create([987StringReplacement.replace(new OffsetRange(2, 5), 'CD'),988]);989const current = userEdit.apply(text);990expect(current).toStrictEqual(`abCDfg`);991992const suggestion = StringEdit.create([993StringReplacement.replace(new OffsetRange(1, 6), 'bCDEF'),994]);995const applied = suggestion.apply(text);996expect(applied).toStrictEqual(`abCDEFg`);997998const strict = tryRebaseStringEdits(text, suggestion, userEdit, 'strict');999expect(strict?.apply(current)).toStrictEqual('abCDEFg');1000expect(strict?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[4, 5) -> "EF""`);1001const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient');1002expect(lenient?.apply(current)).toStrictEqual('abCDEFg');1003expect(lenient?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[4, 5) -> "EF""`);1004});10051006test('overlap: both replace in agreement 2', () => {1007const text = `abcdefg`;1008const userEdit = StringEdit.create([1009StringReplacement.replace(new OffsetRange(1, 5), 'bC'),1010]);1011const current = userEdit.apply(text);1012expect(current).toStrictEqual(`abCfg`);10131014const suggestion = StringEdit.create([1015StringReplacement.replace(new OffsetRange(2, 5), 'CDE'),1016]);1017const applied = suggestion.apply(text);1018expect(applied).toStrictEqual(`abCDEfg`);10191020const strict = tryRebaseStringEdits(text, suggestion, userEdit, 'strict');1021expect(strict?.apply(current)).toStrictEqual('abCDEfg');1022expect(strict?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[3, 3) -> "DE""`);1023const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient');1024expect(lenient?.apply(current)).toStrictEqual('abCDEfg');1025expect(lenient?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[3, 3) -> "DE""`);1026});10271028test('overlap: both insert in agreement with large offset', () => {1029const text = `abcdefg`;1030const userEdit = StringEdit.create([1031StringReplacement.replace(new OffsetRange(7, 7), 'h'),1032]);1033const current = userEdit.apply(text);1034expect(current).toStrictEqual(`abcdefgh`);10351036const suggestion1 = StringEdit.create([1037StringReplacement.replace(new OffsetRange(7, 7), 'x'.repeat(maxAgreementOffset) + 'h'),1038]);1039const applied1 = suggestion1.apply(text);1040expect(applied1).toStrictEqual(`abcdefg${'x'.repeat(maxAgreementOffset)}h`);10411042const strict1 = tryRebaseStringEdits(text, suggestion1, userEdit, 'strict');1043expect(strict1?.apply(current)).toStrictEqual(applied1);1044expect(strict1?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[7, 7) -> "${'x'.repeat(maxAgreementOffset)}""`);1045const lenient1 = tryRebaseStringEdits(text, suggestion1, userEdit, 'lenient');1046expect(lenient1?.apply(current)).toStrictEqual(applied1);1047expect(lenient1?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[7, 7) -> "${'x'.repeat(maxAgreementOffset)}""`);10481049const suggestion2 = StringEdit.create([1050StringReplacement.replace(new OffsetRange(7, 7), 'x'.repeat(maxAgreementOffset + 1) + 'h'),1051]);1052const applied2 = suggestion2.apply(text);1053expect(applied2).toStrictEqual(`abcdefg${'x'.repeat(maxAgreementOffset + 1)}h`);10541055expect(tryRebaseStringEdits(text, suggestion2, userEdit, 'strict')).toBeUndefined();1056const lenient2 = tryRebaseStringEdits(text, suggestion2, userEdit, 'lenient');1057expect(lenient2?.apply(current)).toStrictEqual(applied2);1058expect(lenient2?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[7, 7) -> "${'x'.repeat(maxAgreementOffset + 1)}""`);1059});10601061test('overlap: both insert in agreement with an offset with longish user edit', () => {1062const text = `abcdefg`;1063const userEdit1 = StringEdit.create([1064StringReplacement.replace(new OffsetRange(7, 7), 'h'.repeat(maxImperfectAgreementLength)),1065]);1066const current1 = userEdit1.apply(text);1067expect(current1).toStrictEqual(`abcdefg${'h'.repeat(maxImperfectAgreementLength)}`);10681069const suggestion = StringEdit.create([1070StringReplacement.replace(new OffsetRange(7, 7), `x${'h'.repeat(maxImperfectAgreementLength + 2)}x`),1071]);1072const applied = suggestion.apply(text);1073expect(applied).toStrictEqual(`abcdefgx${'h'.repeat(maxImperfectAgreementLength + 2)}x`);10741075const strict1 = tryRebaseStringEdits(text, suggestion, userEdit1, 'strict');1076expect(strict1?.apply(current1)).toStrictEqual(applied);1077expect(strict1?.removeCommonSuffixAndPrefix(current1).replacements.toString()).toMatchInlineSnapshot(`"[7, ${7 + maxImperfectAgreementLength}) -> "x${'h'.repeat(maxImperfectAgreementLength + 2)}x""`);1078const lenient1 = tryRebaseStringEdits(text, suggestion, userEdit1, 'lenient');1079expect(lenient1?.apply(current1)).toStrictEqual(applied);1080expect(lenient1?.removeCommonSuffixAndPrefix(current1).replacements.toString()).toMatchInlineSnapshot(`"[7, ${7 + maxImperfectAgreementLength}) -> "x${'h'.repeat(maxImperfectAgreementLength + 2)}x""`);10811082const userEdit2 = StringEdit.create([1083StringReplacement.replace(new OffsetRange(7, 7), 'h'.repeat(maxImperfectAgreementLength + 1)),1084]);1085const current2 = userEdit2.apply(text);1086expect(current2).toStrictEqual(`abcdefg${'h'.repeat(maxImperfectAgreementLength + 1)}`);10871088expect(tryRebaseStringEdits(text, suggestion, userEdit2, 'strict')).toBeUndefined();1089const lenient2 = tryRebaseStringEdits(text, suggestion, userEdit2, 'lenient');1090expect(lenient2?.apply(current2)).toStrictEqual(applied);1091expect(lenient2?.removeCommonSuffixAndPrefix(current2).replacements.toString()).toMatchInlineSnapshot(`"[7, ${7 + maxImperfectAgreementLength + 1}) -> "x${'h'.repeat(maxImperfectAgreementLength + 2)}x""`);1092});10931094test('reverse agreement: user typed more than model predicted at same position', () => {1095// Model predicts two edits: insert "{" and insert body.1096// User typed "{\n\t" which covers the first edit and the start of the second.1097// Rebase should succeed, offering the unconsumed portion of the second edit.1098const originalDocument = 'class Fibonacci \n';1099const originalEdits = [1100StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {'),1101StringReplacement.replace(OffsetRange.emptyAt(17), '\n\tprivate memo: Map<number, number>;\n}'),1102];1103const userEditSince = StringEdit.create([1104StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {\n\t'),1105]);1106const currentDocumentContent = 'class Fibonacci {\n\t\n';1107const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };11081109const logger = new TestLogService();1110// Without flag: rebase fails1111expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger)).toBe('rebaseFailed');1112// With flag: rebase succeeds1113const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs);1114expect(res).toBeTypeOf('object');1115const result = res as Exclude<typeof res, string>;1116expect(result.length).toBe(1);1117expect(result[0].rebasedEditIndex).toBe(1);1118// The unconsumed portion of the body edit should be offered1119expect(result[0].rebasedEdit.newText).toContain('private memo');1120});11211122test('reverse agreement: user typed exactly the first model edit', () => {1123// User typed exactly "{" which is the model's first edit.1124// The second edit (body) should be offered in full.1125// Note: this case is actually handled by the existing forward agreement path1126// (user text length == model text length), so it works regardless of the flag.1127const originalDocument = 'class Foo \n';1128const originalEdits = [1129StringReplacement.replace(new OffsetRange(0, 10), 'class Foo {'),1130StringReplacement.replace(OffsetRange.emptyAt(12), '\n\tbar(): void {}\n}'),1131];1132const userEditSince = StringEdit.create([1133StringReplacement.replace(new OffsetRange(0, 10), 'class Foo {'),1134]);1135const currentDocumentContent = 'class Foo {\n';11361137const logger = new TestLogService();1138// Works without reverse agreement flag (handled by forward agreement)1139const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger);1140expect(res).toBeTypeOf('object');1141const result = res as Exclude<typeof res, string>;1142expect(result.length).toBe(1);1143expect(result[0].rebasedEditIndex).toBe(1);1144expect(result[0].rebasedEdit.newText).toContain('bar(): void {}');1145});11461147test('reverse agreement: user typed completely different text — should conflict', () => {1148// Model: "class Foo " → "class Foo {"1149// User: "class Foo " → "class Foo XYZ"1150// "XYZ" is NOT found in "{", so this should fail.1151const originalDocument = 'class Foo \n';1152const originalEdits = [1153StringReplacement.replace(new OffsetRange(0, 10), 'class Foo {'),1154];1155const userEditSince = StringEdit.create([1156StringReplacement.replace(new OffsetRange(0, 10), 'class Foo XYZ'),1157]);1158const currentDocumentContent = 'class Foo XYZ\n';1159const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };11601161const logger = new TestLogService();1162expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');1163expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'lenient', logger, nesConfigs)).toBe('rebaseFailed');1164});11651166test('reverse agreement: user typed text that accidentally contains model text as substring', () => {1167// Model: replace [0,5) "hello" → "hello{" (diff: insert "{" at 5), then insert body at 6.1168// User: replace [0,5) "hello" → "helloXX{YY" (diff: insert "XX{YY" at 5).1169// The model's first diff ("{") IS found in user's "XX{YY" at offset 2, so it's consumed.1170// But the model's second edit ("\n\tworld\n}") can't be found in the remaining1171// user text "YY" — partial consumption also fails ("YY" doesn't start with "\n\tworld\n}").1172// So the rebase correctly fails for the second edit.1173const originalDocument = 'hello\n';1174const originalEdits = [1175StringReplacement.replace(new OffsetRange(0, 5), 'hello{'),1176StringReplacement.replace(OffsetRange.emptyAt(6), '\n\tworld\n}'),1177];1178const userEditSince = StringEdit.create([1179StringReplacement.replace(new OffsetRange(0, 5), 'helloXX{YY'),1180]);1181const currentDocumentContent = 'helloXX{YY\n';1182const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };11831184const logger = new TestLogService();1185// Fails because user's remaining text "YY" doesn't match model's second edit1186expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');1187expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'lenient', logger, nesConfigs)).toBe('rebaseFailed');1188});11891190test('reverse agreement: user typed text with model text at large offset — strict rejects', () => {1191// Model: "a" → "a{"1192// User: "a" → "a" + "X".repeat(15) + "{"1193// The "{" is at offset 15 into the user text, which exceeds maxAgreementOffset (10).1194// Strict should reject; lenient should also fail since there's no lenient fallback1195// in the reverse branch.1196const pad = 'X'.repeat(maxAgreementOffset + 1);1197const originalDocument = 'a\n';1198const originalEdits = [1199StringReplacement.replace(new OffsetRange(0, 1), 'a{'),1200];1201const userEditSince = StringEdit.create([1202StringReplacement.replace(new OffsetRange(0, 1), 'a' + pad + '{'),1203]);1204const currentDocumentContent = 'a' + pad + '{\n';1205const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };12061207const logger = new TestLogService();1208expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');1209});12101211test('reverse agreement: user typed long text at small offset — strict rejects imperfect agreement', () => {1212// Model: "a" → "a{"1213// User: "a" → "aX" + "{".repeat(maxImperfectAgreementLength + 1)1214// The model text "{" is found at offset 1 (> 0) and the effective text length1215// is 1 (≤ maxImperfectAgreementLength), so this should pass strict.1216// But if effectiveText were longer...1217const longText = 'Z'.repeat(maxImperfectAgreementLength + 1);1218const originalDocument = 'a\n';1219const originalEdits = [1220StringReplacement.replace(new OffsetRange(0, 1), 'a' + longText),1221];1222const userEditSince = StringEdit.create([1223StringReplacement.replace(new OffsetRange(0, 1), 'aX' + longText),1224]);1225const currentDocumentContent = 'aX' + longText + '\n';1226const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };12271228const logger = new TestLogService();1229// offset = 1 > 0, effectiveText.length = longText.length > maxImperfectAgreementLength1230// → strict rejected1231expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');1232});12331234test('reverse agreement: all model edits fully consumed by user — no rebased edit emitted', () => {1235// Model predicts single edit: insert "{\n\t"1236// User typed "{\n\tfoo\n}" which fully contains "{\n\t"1237// All model edits consumed → nothing to offer1238const originalDocument = 'fn \n';1239const originalEdits = [1240StringReplacement.replace(new OffsetRange(0, 3), 'fn {\n\t'),1241];1242const userEditSince = StringEdit.create([1243StringReplacement.replace(new OffsetRange(0, 3), 'fn {\n\tfoo\n}'),1244]);1245const currentDocumentContent = 'fn {\n\tfoo\n}\n';1246const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };12471248const logger = new TestLogService();1249// Without flag: rebase fails1250expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger)).toBe('rebaseFailed');1251// With flag: succeeds with no edits to offer1252const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs);1253expect(res).toBeTypeOf('object');1254const result = res as Exclude<typeof res, string>;1255// The single model edit was fully consumed — nothing left to suggest1256expect(result.length).toBe(0);1257});12581259test('reverse agreement: consistency check — rebased edit applied to current doc produces expected result', () => {1260// This is the key correctness check: applying the rebased edit to the current1261// document should produce the same result as applying the original edits to1262// the original document.1263const originalDocument = 'class Fibonacci \n';1264const originalEdits = [1265StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {'),1266StringReplacement.replace(OffsetRange.emptyAt(17), '\n\tprivate memo: Map<number, number>;\n}'),1267];1268const userEditSince = StringEdit.create([1269StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {\n\t'),1270]);1271const currentDocumentContent = 'class Fibonacci {\n\t\n';1272const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };12731274// Expected final: apply both model edits in sequence to original1275const expectedFinal = new StringEdit([originalEdits[0]]).apply(originalDocument);1276const expectedFinal2 = new StringEdit([originalEdits[1]]).apply(expectedFinal);12771278const logger = new TestLogService();1279const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs);1280expect(res).toBeTypeOf('object');1281const result = res as Exclude<typeof res, string>;1282expect(result.length).toBe(1);12831284// Apply rebased edit to current document1285const actualFinal = StringEdit.single(result[0].rebasedEdit).apply(currentDocumentContent);1286expect(actualFinal).toBe(expectedFinal2);1287});12881289test('reverse agreement: pure inserts at same position — user insert is superset of model insert', () => {1290// Both edits are pure inserts at position 5.1291// Model inserts "X", user inserts "XY".1292// After removeCommonSuffixAndPrefix on user edit:1293// user edit: insert at 5 → "XY", model edit: insert at 5 → "X"1294// These have equal replaceRange (both emptyAt(5)).1295// The reverse branch should fire: "X" found in "XY" at offset 0 → consumed.1296// Nothing left to suggest from this model edit.1297const originalDocument = 'hello world\n';1298const suggestedEdit = StringEdit.create([1299StringReplacement.replace(OffsetRange.emptyAt(5), 'X'),1300]);1301const userEdit = StringEdit.create([1302StringReplacement.replace(OffsetRange.emptyAt(5), 'XY'),1303]);1304const current = userEdit.apply(originalDocument);1305expect(current).toBe('helloXY world\n');13061307// Without flag: rebase fails1308expect(tryRebaseStringEdits(originalDocument, suggestedEdit, userEdit, 'strict')).toBeUndefined();1309// With flag: model edit fully consumed → empty result1310const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };1311const res = tryRebaseStringEdits(originalDocument, suggestedEdit, userEdit, 'strict', nesConfigs);1312expect(res).toBeDefined();1313expect(res!.replacements.length).toBe(0);1314});13151316test('reverse agreement: does NOT fire when ranges differ', () => {1317// Model replaces [0,3), user replaces [0,5) — different ranges.1318// The reverse branch requires equal ranges, so this should NOT trigger it.1319// Instead, this falls through to the conflict branch.1320const originalDocument = 'abcde\n';1321const originalEdits = [1322StringReplacement.replace(new OffsetRange(0, 3), 'XYZ'),1323];1324const userEditSince = StringEdit.create([1325StringReplacement.replace(new OffsetRange(0, 5), 'XYZWV'),1326]);1327const currentDocumentContent = 'XYZWV\n';1328const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };13291330const logger = new TestLogService();1331// The ranges don't match after removeCommonSuffixAndPrefix, so this conflicts1332expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');1333});1334});133513361337