From d642570a6e0a937e48428a93409cb5f107b0b2e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=AA=20Cao=20Nguy=C3=AAn?= Date: Fri, 11 Sep 2026 17:05:07 +0700 Subject: [PATCH 1/2] AIS-5214: Clamp ending overlap in add/remove fold beginningOverlap and endingOverlap were computed independently, so a repeated boundary char (' ' vs ' ') counted toward both and getNonOverlap over-trimmed, dropping a char from the change list's original or modified projection. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DZPwQoG2HtFHPnTDvsncrR --- src/helpers.ts | 12 +++++++----- tests/index.test.ts | 16 +++++++++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/helpers.ts b/src/helpers.ts index 7e50290..492eb12 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -228,11 +228,15 @@ export const concatChanges = (baseChanges: Change[], changesToAdd: Change[]) => // Search for overlap between the two changes for simplification if (isAdd(prevChange) && isRemove(change)) { + const minLength = Math.min(getChangeLength(prevChange), getChangeLength(change)) const beginningOverlap = getBeginningOverlap(getChangeText(prevChange), getChangeText(change)) - const endingOverlap = getEndingOverlap(getChangeText(prevChange), getChangeText(change)) + // Clamp so a repeated boundary char (' ' vs ' ') isn't counted as both beginning and ending overlap + const endingOverlap = Math.min( + getEndingOverlap(getChangeText(prevChange), getChangeText(change)), + minLength - beginningOverlap + ) if (beginningOverlap > 0 || endingOverlap > 0) { - const minLength = Math.min(getChangeLength(prevChange), getChangeLength(change)) const getNonOverlap = (text: string) => text.slice(beginningOverlap, endingOverlap > 0 ? -endingOverlap : Infinity) const newChanges = [ @@ -240,9 +244,7 @@ export const concatChanges = (baseChanges: Change[], changesToAdd: Change[]) => replaceChangeText(prevChange, getNonOverlap(getChangeText(prevChange))), replaceChangeText(change, getNonOverlap(getChangeText(change))), ] - if (endingOverlap > 0 && minLength !== beginningOverlap) { - newChanges.push(getChangeText(change).slice(-Math.min(endingOverlap, minLength - beginningOverlap))) - } + if (endingOverlap > 0) newChanges.push(getChangeText(change).slice(-endingOverlap)) changes.splice(index - 1, 2, ...newChanges.filter(getChangeLength)) index-- diff --git a/tests/index.test.ts b/tests/index.test.ts index e6af460..edce5c9 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,4 +1,5 @@ -import { createStringTracker, StringOp, StringTracker } from '../src' +import { Change, createStringTracker, StringOp, StringTracker } from '../src' +import { assertValidTracker } from './helpers' const str1 = 'this is my word' @@ -180,6 +181,19 @@ it('should remove subsequent adds/strings with a remove in between in changes ar expect(tracker.getChanges()).toStrictEqual(['asd', [1, 'a '], 'pog']) }) +it.each<[string, StringTracker, Change[]]>([ + [ + 'add shorter than remove', + createStringTracker(' 0').remove(2, 3).add(2, '😊 ').remove(0, 5), + [' ', [StringOp.Remove, ' 0']], + ], + ['add longer than remove', createStringTracker('aa').add(0, 'aaa').remove(3, 5), ['aa', [StringOp.Add, 'a']]], + ['overlaps cross mid-text', createStringTracker('aab').add(0, 'ab').remove(2, 5), ['a', [StringOp.Remove, 'a'], 'b']], +])('should not drop repeated chars when folding an overlapping add and remove (%s)', (_, tracker, changes) => { + expect(tracker.getChanges()).toStrictEqual(changes) + assertValidTracker(tracker) +}) + // getIndexOnOriginal it('getIndexOnOriginal should throw a RangeError when called with a negative number', () => { expect(() => createStringTracker('asd').getIndexOnOriginal(-1)) From 30cb8c058543695fa8c37af0b80ada22ab64d3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=AA=20Cao=20Nguy=C3=AAn?= Date: Thu, 17 Sep 2026 09:44:04 +0700 Subject: [PATCH 2/2] AIS-5214: Build regression trackers inside each test case Constructing trackers in the it.each table ran them at file load, so a throw would fail the whole file instead of one case. Also swap the emoji repro for ASCII: surrogate pairs play no part in the bug. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DZPwQoG2HtFHPnTDvsncrR --- tests/index.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/index.test.ts b/tests/index.test.ts index edce5c9..d4a9f95 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -181,15 +181,20 @@ it('should remove subsequent adds/strings with a remove in between in changes ar expect(tracker.getChanges()).toStrictEqual(['asd', [1, 'a '], 'pog']) }) -it.each<[string, StringTracker, Change[]]>([ +it.each<[string, () => StringTracker, Change[]]>([ [ 'add shorter than remove', - createStringTracker(' 0').remove(2, 3).add(2, '😊 ').remove(0, 5), + () => createStringTracker(' 0').remove(2, 3).add(2, 'x ').remove(0, 4), [' ', [StringOp.Remove, ' 0']], ], - ['add longer than remove', createStringTracker('aa').add(0, 'aaa').remove(3, 5), ['aa', [StringOp.Add, 'a']]], - ['overlaps cross mid-text', createStringTracker('aab').add(0, 'ab').remove(2, 5), ['a', [StringOp.Remove, 'a'], 'b']], -])('should not drop repeated chars when folding an overlapping add and remove (%s)', (_, tracker, changes) => { + ['add longer than remove', () => createStringTracker('aa').add(0, 'aaa').remove(3, 5), ['aa', [StringOp.Add, 'a']]], + [ + 'overlaps cross mid-text', + () => createStringTracker('aab').add(0, 'ab').remove(2, 5), + ['a', [StringOp.Remove, 'a'], 'b'], + ], +])('should not drop repeated chars when folding an overlapping add and remove (%s)', (_, build, changes) => { + const tracker = build() expect(tracker.getChanges()).toStrictEqual(changes) assertValidTracker(tracker) })