Path: blob/main/extensions/copilot/src/extension/inlineEdits/test/node/nesXtabHistoryTracker.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*--------------------------------------------------------------------------------------------*/45import * as fs from 'fs/promises';6import { afterEach, describe, expect, it } from 'vitest';7import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';8import { overrideNowValue } from '../../../../platform/inlineEdits/common/utils/utils';9import { NesXtabHistoryTracker, XtabEditMergeStrategy } from '../../../../platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker';10import { NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';11import { assert } from '../../../../util/vs/base/common/assert';12import { observableValue } from '../../../../util/vs/base/common/observable';13import * as path from '../../../../util/vs/base/common/path';14import { IRecordingInformation, ObservableWorkspaceRecordingReplayer } from '../../common/observableWorkspaceRecordingReplayer';151617describe('NesXtabHistoryTracker', () => {1819afterEach(() => {20overrideNowValue(-1);21});2223function createTracker(replayerWorkspace: any, maxHistorySize?: number | undefined, mergeStrategy = XtabEditMergeStrategy.sameStartLine) {24return new (class extends NesXtabHistoryTracker {25protected override readonly mergeStrategy = observableValue(this, mergeStrategy);26})(replayerWorkspace, maxHistorySize, new DefaultsOnlyConfigurationService(), new NullExperimentationService());27}2829function historyToString(tracker: NesXtabHistoryTracker): string {30const history = tracker.getHistory();31assert(history.every(e => e.kind === 'edit'));32return stripTrailingWhitespace(history.map(h => h.edit.toString()).join('\n---\n'));33}3435/** Strip trailing whitespace from each line to avoid fragile snapshots. */36function stripTrailingWhitespace(s: string): string {37return s.replace(/[^\S\n]+$/gm, '');38}3940it('1 line, 1 edit', () => {41const recording: IRecordingInformation = {42log: [43{ documentType: '[email protected]', kind: 'header', repoRootUri: 'file:///Users/john/myProject', time: 0, uuid: '' },44{ time: 10, id: 0, kind: 'documentEncountered', relativePath: 'src/a.ts' },45{ time: 11, id: 0, v: 1, kind: 'setContent', content: 'hemmo world\ngoodbye' },46{ time: 11, id: 0, v: 1, kind: 'changed', edit: [[2, 4, 'll']] },47]48};49const replayer = new ObservableWorkspaceRecordingReplayer(recording);50const tracker = createTracker(replayer.workspace);51replayer.replay();52expect(historyToString(tracker)).toMatchInlineSnapshot(`53"- 1 hemmo world54+ 1 hello world552 2 goodbye"56`);57});5859it('1 line, 2 edits', () => {60const recording: IRecordingInformation = {61log: [62{ documentType: '[email protected]', kind: 'header', repoRootUri: 'file:///Users/john/myProject', time: 0, uuid: '' },63{ time: 10, id: 0, kind: 'documentEncountered', relativePath: 'src/a.ts' },64{ time: 11, id: 0, v: 1, kind: 'setContent', content: 'hemmo world\ngoodbye' },65{ time: 11, id: 0, v: 1, kind: 'changed', edit: [[2, 4, 'll']] },66{ time: 11, id: 0, v: 1, kind: 'changed', edit: [[8, 8, 'ooooo']] },67]68};69const replayer = new ObservableWorkspaceRecordingReplayer(recording);70const tracker = createTracker(replayer.workspace);71replayer.replay();72expect(historyToString(tracker)).toMatchInlineSnapshot(`73"- 1 hemmo world74+ 1 hello woooooorld752 2 goodbye"76`);77});7879it('handles simple history', () => {80const recording: IRecordingInformation = {81log: [82{ documentType: '[email protected]', kind: 'header', repoRootUri: 'file:///Users/john/myProject', time: 0, uuid: '' },83{ time: 10, id: 0, kind: 'documentEncountered', relativePath: 'src/a.ts' },84{ time: 11, id: 0, v: 1, kind: 'setContent', content: 'hemmo' },85{ time: 11, id: 0, v: 1, kind: 'changed', edit: [[5, 5, '\n']] },86{ time: 11, id: 0, v: 1, kind: 'changed', edit: [[2, 4, 'll']] },87{ time: 11, id: 0, v: 1, kind: 'changed', edit: [[6, 6, 'world']] },88]89};90const replayer = new ObservableWorkspaceRecordingReplayer(recording);91const tracker = createTracker(replayer.workspace);92replayer.replay();93expect(historyToString(tracker)).toMatchInlineSnapshot(`94" 1 1 hemmo95+ 296---97- 1 hemmo98+ 1 hello992 2100---1011 1 hello102- 2103+ 2 world"104`);105});106107it('handles simple history with small maxHistorySize', () => {108const recording: IRecordingInformation = {109log: [110{ documentType: '[email protected]', kind: 'header', repoRootUri: 'file:///Users/john/myProject', time: 0, uuid: '' },111{ time: 10, id: 0, kind: 'documentEncountered', relativePath: 'src/a.ts' },112{ time: 11, id: 0, v: 1, kind: 'setContent', content: 'hemmo' },113{ time: 12, id: 0, v: 2, kind: 'changed', edit: [[5, 5, '\n']] },114{ time: 13, id: 0, v: 3, kind: 'changed', edit: [[2, 4, 'll']] },115{ time: 14, id: 0, v: 4, kind: 'changed', edit: [[6, 6, 'world']] },116{ time: 15, id: 0, v: 5, kind: 'changed', edit: [[0, 5, 'goodbye']] },117]118};119const replayer = new ObservableWorkspaceRecordingReplayer(recording);120const tracker = createTracker(replayer.workspace, 2);121replayer.replay();122expect(historyToString(tracker)).toMatchInlineSnapshot(`123" 1 1 hello124- 2125+ 2 world126---127- 1 hello128+ 1 goodbye1292 2 world"130`);131});132133it('add new lines and edit one of them', async () => {134const recording: IRecordingInformation = await fs.readFile(path.join(__dirname, 'recordings/ArrayToObject.recording.w.json'), 'utf8').then(JSON.parse);135const replayer = new ObservableWorkspaceRecordingReplayer(recording);136const tracker = createTracker(replayer.workspace);137replayer.replay();138expect(historyToString(tracker)).toMatchInlineSnapshot(`139" 147 147 commandsWithArgs.set(commandId, argumentsSchema);140148 148 }141+ 149142+ 150143149 151144150 152 const searchableCommands: Searchables<Command>[] = [];145151 153146---147148 148 }148149 149149- 150150+ 150 function findVscodeDiff(schema: any, path: string[] = []): void {151151 151152152 152 const searchableCommands: Searchables<Command>[] = [];153153 153154---155149 149156150 150 function findVscodeDiff(schema: any, path: string[] = []): void {157+ 151 if (typeof schema === 'object' && schema !== null) {158+ 152 for (const key in schema) {159+ 153 if (schema[key] === 'vscode.diff') {160+ 154 console.log(\`Found "vscode.diff" at path: \${path.concat(key).join('.')}\`);161+ 155 } else {162+ 156 findVscodeDiff(schema[key], path.concat(key));163+ 157 }164+ 158 }165+ 159 }166+ 160 }167+ 161168+ 162 findVscodeDiff(keybindingsSchema);169151 163170152 164 const searchableCommands: Searchables<Command>[] = [];171153 165172---173147 147 commandsWithArgs.set(commandId, argumentsSchema);174148 148 }175- 149176- 150 function findVscodeDiff(schema: any, path: string[] = []): void {177- 151 if (typeof schema === 'object' && schema !== null) {178- 152 for (const key in schema) {179- 153 if (schema[key] === 'vscode.diff') {180- 154 console.log(\`Found "vscode.diff" at path: \${path.concat(key).join('.')}\`);181- 155 } else {182- 156 findVscodeDiff(schema[key], path.concat(key));183- 157 }184- 158 }185- 159 }186- 160 }187- 161188- 162 findVscodeDiff(keybindingsSchema);189163 149190164 150 const searchableCommands: Searchables<Command>[] = [];191165 151192---19325 25 }19426 26195+ 27 export interface Searchables196+ 2819727 29 export class Configurations implements vscode.Disposable {19828 3019929 31 private readonly miniSearch: MiniSearch<Searchables<Setting | Command>>;200---20124 24 when?: string;20225 25 }203- 26204- 27 export interface Searchables20528 2620629 27 export class Configurations implements vscode.Disposable {20730 28208---20918 18 }21019 19211- 20 private validateSettings(settings: IStringDictionary<any>): [string, any][] {212+ 20 private validateSettings(settings: IStringDictionary<any>): {key: string, value:any}[] {21321 21 const result: [string, any][] = [];21422 22 for (const [key, value] of Object.entries(settings)) {21523 23 result.push([key, value]);"216`);217});218219it('doesnt throw with empty line edit', async () => {220const recording: IRecordingInformation = await fs.readFile(path.join(__dirname, 'recordings/DeclaringConstructorArgument.recording.w.json'), 'utf8').then(JSON.parse);221const replayer = new ObservableWorkspaceRecordingReplayer(recording);222const tracker = createTracker(replayer.workspace);223replayer.replay();224const history = tracker.getHistory();225assert(history.every(e => e.kind === 'edit'));226expect(stripTrailingWhitespace(history.map(h => `${h.docId.path}\n---\n${h.edit.toString()}`).join('\n--------------\n'))).toMatchInlineSnapshot(`227"/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts228---22936 36 }23037 37231+ 38 class FifoQueue<T> {232+ 39233+ 40 }234+ 4123538 42 class DocumentState {23639 43 private baseValue: StringValue;23740 44 private currentValue: StringValue;238--------------239/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts240---24137 3724238 38 class FifoQueue<T> {243- 39244+ 39 constructor(245+ 40 public readonly size: number246+ 41 )24740 42 }24841 4324942 44 class DocumentState {250--------------251/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts252---25339 39 constructor(25440 40 public readonly size: number255- 41 )256+ 41 ) {257+ 42258+ 43 }25942 44 }26043 4526144 46 class DocumentState {262--------------263/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts264---26540 40 public readonly size: number26641 41 ) {267- 4226843 42 }26944 43 }27045 44271--------------272/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts273---27441 41 ) {27542 42 }276+ 43277+ 4427843 45 }27944 4628045 47 class DocumentState {281--------------282/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts283---28438 38 class FifoQueue<T> {28539 39 constructor(286- 40 public readonly size: number287+ 40 public readonly maxSize: number28841 41 ) {28942 42 }29043 43291- 44292+ 4429345 45 }29446 4629547 47 class DocumentState {296--------------297/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts298---29941 41 ) {30042 42 }301- 43302+ 4330344 4430445 45 }30546 46306--------------307/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts308---30937 3731038 38 class FifoQueue<T> {311+ 39 private _arr: T[] = [];31239 40 constructor(31340 41 public readonly maxSize: number31441 42 ) {31542 43 }316- 43317+ 4431844 4531945 46 }32046 47321--------------322/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts323---32438 38 class FifoQueue<T> {32539 39 private _arr: T[] = [];326+ 4032740 41 constructor(32841 42 public readonly maxSize: number32942 43 ) {33043 44 }33144 45332- 45333+ 4633446 47 }33547 4833648 49 class DocumentState {337--------------338/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts339---34044 44 }34145 45342- 46343+ 46 push(e: T): void {344+ 47 this._arr.push(e);345+ 48 if (this._arr.length > this.maxSize) {346+ 49 this._arr.shift();347+ 50 }348+ 51 }34947 52 }35048 5335149 54 class DocumentState {352--------------353/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts354---35515 15 export class NesWorkspaceEditTracker implements IWorkspaceEditTracker {35616 16 private readonly _documentState = new Map<DocumentUri, DocumentState>();357+ 17 private readonly _lastDocuments = new FifoQueue<DocumentUri>(5);35817 1835918 19 public handleDocumentOpened(docUri: DocumentUri, state: StringValue): void {36019 20 this._documentState.set(docUri, new DocumentState(state.value));361--------------362/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts363---36423 23 public handleEdit(docUri: DocumentUri, edit: Edit): void {36524 24 const state = this._documentState.get(docUri)!;366+ 25 this._lastDocuments.push()36725 26 state.handleEdit(edit);36826 27 }36927 28370--------------371/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts372---37315 15 export class NesWorkspaceEditTracker implements IWorkspaceEditTracker {37416 16 private readonly _documentState = new Map<DocumentUri, DocumentState>();375- 17 private readonly _lastDocuments = new FifoQueue<DocumentUri>(5);376+ 17 private readonly _lastDocuments = new FifoSet<DocumentState>(5);37718 1837819 19 public handleDocumentOpened(docUri: DocumentUri, state: StringValue): void {37920 20 this._documentState.set(docUri, new DocumentState(state.value));380---38138 38 }38239 39383- 40 class FifoQueue<T> {384+ 40 class FifoSet<T> {38541 41 private _arr: T[] = [];38642 4238743 43 constructor(388--------------389/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts390---39147 4739248 48 push(e: T): void {393- 49 this._arr.push(e);394- 50 if (this._arr.length > this.maxSize) {395- 51 this._arr.shift();396- 52 }397+ 4939853 50 }39954 51 }40055 52401--------------402/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts403---404405--------------406/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts407---40847 4740948 48 push(e: T): void {410- 49411+ 49 const existing = this._arr.indexOf(e);412+ 5041350 51 }41451 52 }41552 53416--------------417/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts418---41948 48 push(e: T): void {42049 49 const existing = this._arr.indexOf(e);421- 50422+ 50 if (existing !== -1) {423+ 51 this._arr.splice(existing, 1);424+ 52 } else if (this._arr.length >= this.maxSize) {425+ 53 this._arr.shift();426+ 54 }42751 55 }42852 56 }42953 57430--------------431/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts432---43350 50 if (existing !== -1) {43451 51 this._arr.splice(existing, 1);435+ 5243652 53 } else if (this._arr.length >= this.maxSize) {43753 54 this._arr.shift();43854 55 }439--------------440/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts441---44254 54 this._arr.shift();44355 55 }444+ 56 this._arr.push(e);44556 57 }44657 58 }44758 59448--------------449/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts450---45150 50 if (existing !== -1) {45251 51 this._arr.splice(existing, 1);453- 5245453 52 } else if (this._arr.length >= this.maxSize) {45554 53 this._arr.shift();45655 54 }457--------------458/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts459---46023 23 public handleEdit(docUri: DocumentUri, edit: Edit): void {46124 24 const state = this._documentState.get(docUri)!;462- 25 this._lastDocuments.push()463+ 25 this._lastDocuments.push(state);46426 26 state.handleEdit(edit);46527 27 }46628 28467--------------468/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts469---47086 86 }47187 87472- 88 getRecentEdit(): RecentWorkspaceEdits | undefined {473+ 88 getRecentEdit(editCount: number): RecentWorkspaceEdits | undefined {47489 89 this._applyStaleEdits();47590 9047691 91 if (this.edits.length === 0) { return undefined; }477--------------478/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts479---48087 8748188 88 getRecentEdit(editCount: number): RecentWorkspaceEdits | undefined {482- 89 this._applyStaleEdits();483+ 89 this._applyStaleEdits(editCount);48490 9048591 91 if (this.edits.length === 0) { return undefined; }48692 92487--------------488/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts489---49086 86 }49187 87492- 88 getRecentEdit(editCount: number): RecentWorkspaceEdits | undefined {493+ 88 getRecentEdit(editCount: number): { edits: RecentWorkspaceEdits; editCount: number } | undefined {49489 89 this._applyStaleEdits(editCount);49590 9049691 91 if (this.edits.length === 0) { return undefined; }497--------------498/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts499---50099 99 }501100 100502- 101 private _applyStaleEdits(): void {503+ 101 private _applyStaleEdits(editCount: number): void {504102 102 let recentEdit = Edit.empty;505103 103 let i: number;506104 104 let count = 0;507--------------508/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts509---510103 103 let i: number;511104 104 let count = 0;512- 105 for (i = this.edits.length - 1; i >= 0 && count < 5; i--, count++) {513+ 105 for (i = this.edits.length - 1; i >= 0 && count < editCount; i--, count++) {514106 106 const e = this.edits[i];515107 107516108 108 if (now() - e.instant > 10 * 60 * 1000) { break; }517--------------518/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts519---52096 9652197 97 const result = new RootedEdit(this.baseValue, composedEdits);522- 98 return new RecentWorkspaceEdits(result, recentEditRange!);523+ 98 return {524+ 99 edits: new RecentWorkspaceEdits(result, recentEditRange!) };52599 100 }526100 101527101 102 private _applyStaleEdits(editCount: number): void {528--------------529/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts530---53197 97 const result = new RootedEdit(this.baseValue, composedEdits);53298 98 return {533- 99 edits: new RecentWorkspaceEdits(result, recentEditRange!) };534+ 99 edits: new RecentDocumentEdit(result, recentEditRange!),535+ 100 editCount: this.edits.length,536+ 101 };537100 102 }538101 103539102 104 private _applyStaleEdits(editCount: number): void {540--------------541/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts542---54311 11 import { TextLengthEdit } from '../dataTypes/textEditLength';54412 12 import { Instant, now } from '../utils/utils';545- 13 import { IWorkspaceEditTracker, RecentWorkspaceEdits } from './workspaceEditTracker';546+ 13 import { IWorkspaceEditTracker, RecentDocumentEdit, RecentWorkspaceEdits } from './workspaceEditTracker';54714 1454815 15 export class NesWorkspaceEditTracker implements IWorkspaceEditTracker {54916 16 private readonly _documentState = new Map<DocumentUri, DocumentState>();550--------------551/c:/code/src/platform/inlineEdits/common/workspaceEditTracker/nesWorkspaceEditTracker.ts552---55397 97 const result = new RootedEdit(this.baseValue, composedEdits);55498 98 return {555- 99 edits: new RecentDocumentEdit(result, recentEditRange!),556+ 99 edits: new RecentDocumentEdit(this.docUri, result, recentEditRange!),557100 100 editCount: this.edits.length,558101 101 };559102 102 }"560`);561});562563describe('proximity strategy', () => {564565/**566* Content layout (5 lines):567* line 1: "aaa"568* line 2: "bbb"569* line 3: "ccc"570* line 4: "ddd"571* line 5: "eee"572*573* Edit on line 1, then edit on line 2 — 0 lines apart → should merge with lineGap=1574*/575it('merges edits within lineGap', () => {576const recording: IRecordingInformation = {577log: [578{ documentType: '[email protected]', kind: 'header', repoRootUri: 'file:///Users/john/myProject', time: 0, uuid: '' },579{ time: 10, id: 0, kind: 'documentEncountered', relativePath: 'src/a.ts' },580{ time: 11, id: 0, v: 1, kind: 'setContent', content: 'aaa\nbbb\nccc\nddd\neee' },581// Replace line 1: "aaa" → "AAA" (offset 0-3)582{ time: 12, id: 0, v: 2, kind: 'changed', edit: [[0, 3, 'AAA']] },583// Replace line 2: "bbb" → "BBB" (offset 4-7, after "AAA\n")584{ time: 13, id: 0, v: 3, kind: 'changed', edit: [[4, 7, 'BBB']] },585]586};587const replayer = new ObservableWorkspaceRecordingReplayer(recording);588const tracker = createTracker(replayer.workspace, undefined, XtabEditMergeStrategy.proximity(1));589replayer.replay();590591// Should produce 1 merged entry (adjacent lines, gap=0 ≤ 1)592expect(tracker.getHistory().length).toBe(1);593expect(historyToString(tracker)).toMatchInlineSnapshot(`594"- 1 aaa595+ 1 AAA596- 2 bbb597+ 2 BBB5983 3 ccc5994 4 ddd6005 5 eee"601`);602});603604/**605* Edit on line 1, then edit on line 5 — 3 lines apart → should NOT merge with lineGap=1606*/607it('does not merge edits beyond lineGap', () => {608const recording: IRecordingInformation = {609log: [610{ documentType: '[email protected]', kind: 'header', repoRootUri: 'file:///Users/john/myProject', time: 0, uuid: '' },611{ time: 10, id: 0, kind: 'documentEncountered', relativePath: 'src/a.ts' },612{ time: 11, id: 0, v: 1, kind: 'setContent', content: 'aaa\nbbb\nccc\nddd\neee' },613// Replace line 1: "aaa" → "AAA" (offset 0-3)614{ time: 12, id: 0, v: 2, kind: 'changed', edit: [[0, 3, 'AAA']] },615// Replace line 5: "eee" → "EEE" (offset 16-19, after "AAA\nbbb\nccc\nddd\n")616{ time: 13, id: 0, v: 3, kind: 'changed', edit: [[16, 19, 'EEE']] },617]618};619const replayer = new ObservableWorkspaceRecordingReplayer(recording);620const tracker = createTracker(replayer.workspace, undefined, XtabEditMergeStrategy.proximity(1));621replayer.replay();622623// Should produce 2 separate entries (distance = 3 > 1)624expect(historyToString(tracker)).toMatchInlineSnapshot(`625"- 1 aaa626+ 1 AAA6272 2 bbb6283 3 ccc6294 4 ddd630---6313 3 ccc6324 4 ddd633- 5 eee634+ 5 EEE"635`);636});637638/**639* Edit on line 1, then edit on line 3 with lineGap=2 → distance=1, should merge640*/641it('merges edits exactly at lineGap boundary', () => {642const recording: IRecordingInformation = {643log: [644{ documentType: '[email protected]', kind: 'header', repoRootUri: 'file:///Users/john/myProject', time: 0, uuid: '' },645{ time: 10, id: 0, kind: 'documentEncountered', relativePath: 'src/a.ts' },646{ time: 11, id: 0, v: 1, kind: 'setContent', content: 'aaa\nbbb\nccc\nddd\neee' },647// Replace line 1648{ time: 12, id: 0, v: 2, kind: 'changed', edit: [[0, 3, 'AAA']] },649// Replace line 3 (offset 8-11, after "AAA\nbbb\n")650{ time: 13, id: 0, v: 3, kind: 'changed', edit: [[8, 11, 'CCC']] },651]652};653const replayer = new ObservableWorkspaceRecordingReplayer(recording);654const tracker = createTracker(replayer.workspace, undefined, XtabEditMergeStrategy.proximity(2));655replayer.replay();656657// distance between line 1 and line 3 is 1 (one line apart), which is ≤ 2658expect(historyToString(tracker)).toMatchInlineSnapshot(`659"- 1 aaa660+ 1 AAA6612 2 bbb662- 3 ccc663+ 3 CCC6644 4 ddd6655 5 eee"666`);667});668669/** lineGap=0 merges only when edits are on the same line (or touching lines) */670it('lineGap=0 does not merge edits on non-adjacent lines', () => {671const recording: IRecordingInformation = {672log: [673{ documentType: '[email protected]', kind: 'header', repoRootUri: 'file:///Users/john/myProject', time: 0, uuid: '' },674{ time: 10, id: 0, kind: 'documentEncountered', relativePath: 'src/a.ts' },675{ time: 11, id: 0, v: 1, kind: 'setContent', content: 'aaa\nbbb\nccc' },676// Replace on line 1677{ time: 12, id: 0, v: 2, kind: 'changed', edit: [[0, 3, 'AAA']] },678// Replace on line 3 (offset 8-11)679{ time: 13, id: 0, v: 3, kind: 'changed', edit: [[8, 11, 'CCC']] },680]681};682const replayer = new ObservableWorkspaceRecordingReplayer(recording);683const tracker = createTracker(replayer.workspace, undefined, XtabEditMergeStrategy.proximity(0));684replayer.replay();685686// distance=1 > 0 → should NOT merge687expect(historyToString(tracker)).toMatchInlineSnapshot(`688"- 1 aaa689+ 1 AAA6902 2 bbb6913 3 ccc692---6931 1 AAA6942 2 bbb695- 3 ccc696+ 3 CCC"697`);698});699});700701describe('hybrid strategy', () => {702703/**704* Two rapid edits on adjacent lines → should merge705*/706it('merges rapid edits in same region', () => {707const recording: IRecordingInformation = {708log: [709{ documentType: '[email protected]', kind: 'header', repoRootUri: 'file:///Users/john/myProject', time: 0, uuid: '' },710{ time: 10, id: 0, kind: 'documentEncountered', relativePath: 'src/a.ts' },711{ time: 11, id: 0, v: 1, kind: 'setContent', content: 'aaa\nbbb\nccc\nddd\neee' },712{ time: 12, id: 0, v: 2, kind: 'changed', edit: [[0, 3, 'AAA']] },713{ time: 13, id: 0, v: 3, kind: 'changed', edit: [[4, 7, 'BBB']] },714]715};716717overrideNowValue(1000);718const replayer = new ObservableWorkspaceRecordingReplayer(recording);719const tracker = createTracker(replayer.workspace, undefined, XtabEditMergeStrategy.hybrid(1, 2000));720replayer.replay();721722// Both edits arrive at the same overridden time, within splitAfterMs and within lineGap → merge723expect(tracker.getHistory().length).toBe(1);724expect(historyToString(tracker)).toMatchInlineSnapshot(`725"- 1 aaa726+ 1 AAA727- 2 bbb728+ 2 BBB7293 3 ccc7304 4 ddd7315 5 eee"732`);733});734735/**736* Same region but long pause between edits → should split737*/738it('splits edits separated by long pause', () => {739const recording: IRecordingInformation = {740log: [741{ documentType: '[email protected]', kind: 'header', repoRootUri: 'file:///Users/john/myProject', time: 0, uuid: '' },742{ time: 10, id: 0, kind: 'documentEncountered', relativePath: 'src/a.ts' },743{ time: 11, id: 0, v: 1, kind: 'setContent', content: 'aaa\nbbb\nccc\nddd\neee' },744{ time: 12, id: 0, v: 2, kind: 'changed', edit: [[0, 3, 'AAA']] },745{ time: 13, id: 0, v: 3, kind: 'changed', edit: [[4, 7, 'BBB']] },746]747};748749overrideNowValue(1000);750const replayer = new ObservableWorkspaceRecordingReplayer(recording);751const tracker = createTracker(replayer.workspace, undefined, XtabEditMergeStrategy.hybrid(1, 500));752753// Replay header + document + setContent754replayer.step(); // header755replayer.step(); // documentEncountered756replayer.step(); // setContent757758// First edit at time 1000759overrideNowValue(1000);760replayer.step(); // changed: AAA761762// Second edit at time 2000, 1000ms later > 500ms splitAfterMs763overrideNowValue(2000);764replayer.step(); // changed: BBB765766// Should produce 2 separate entries767expect(historyToString(tracker)).toMatchInlineSnapshot(`768"- 1 aaa769+ 1 AAA7702 2 bbb7713 3 ccc7724 4 ddd773---7741 1 AAA775- 2 bbb776+ 2 BBB7773 3 ccc7784 4 ddd7795 5 eee"780`);781});782783/**784* Rapid edits but far apart → should split due to distance despite being rapid785*/786it('splits rapid edits that are far apart', () => {787overrideNowValue(1000);788789const recording: IRecordingInformation = {790log: [791{ documentType: '[email protected]', kind: 'header', repoRootUri: 'file:///Users/john/myProject', time: 0, uuid: '' },792{ time: 10, id: 0, kind: 'documentEncountered', relativePath: 'src/a.ts' },793{ time: 11, id: 0, v: 1, kind: 'setContent', content: 'aaa\nbbb\nccc\nddd\neee' },794// Line 1 edit795{ time: 12, id: 0, v: 2, kind: 'changed', edit: [[0, 3, 'AAA']] },796// Line 5 edit (offset 16-19, distance=3 > lineGap=1)797{ time: 13, id: 0, v: 3, kind: 'changed', edit: [[16, 19, 'EEE']] },798]799};800const replayer = new ObservableWorkspaceRecordingReplayer(recording);801const tracker = createTracker(replayer.workspace, undefined, XtabEditMergeStrategy.hybrid(1, 5000));802replayer.replay();803804// Even though both are rapid (same overrideNowValue), distance=3 > lineGap=1 → split805expect(historyToString(tracker)).toMatchInlineSnapshot(`806"- 1 aaa807+ 1 AAA8082 2 bbb8093 3 ccc8104 4 ddd811---8123 3 ccc8134 4 ddd814- 5 eee815+ 5 EEE"816`);817});818819/**820* Three edit bursts: rapid-on-same-line, pause, rapid-on-same-line → 2 entries821*/822it('creates separate entries per logical burst', () => {823const recording: IRecordingInformation = {824log: [825{ documentType: '[email protected]', kind: 'header', repoRootUri: 'file:///Users/john/myProject', time: 0, uuid: '' },826{ time: 10, id: 0, kind: 'documentEncountered', relativePath: 'src/a.ts' },827{ time: 11, id: 0, v: 1, kind: 'setContent', content: 'aaa\nbbb\nccc' },828// Burst 1: two rapid edits on line 1829{ time: 12, id: 0, v: 2, kind: 'changed', edit: [[0, 1, 'A']] },830{ time: 13, id: 0, v: 3, kind: 'changed', edit: [[1, 2, 'A']] },831// Burst 2: edit on line 1 again, after pause832{ time: 14, id: 0, v: 4, kind: 'changed', edit: [[2, 3, 'A']] },833]834};835836overrideNowValue(1000);837const replayer = new ObservableWorkspaceRecordingReplayer(recording);838const tracker = createTracker(replayer.workspace, undefined, XtabEditMergeStrategy.hybrid(1, 500));839840// Replay header + document + setContent841replayer.step(); // header842replayer.step(); // documentEncountered843replayer.step(); // setContent844845// Burst 1846overrideNowValue(1000);847replayer.step(); // edit 1848overrideNowValue(1100);849replayer.step(); // edit 2850851// Pause...852// Burst 2853overrideNowValue(5000);854replayer.step(); // edit 3855856// Burst 1 merged into 1 entry, burst 2 is separate → 2 entries857expect(historyToString(tracker)).toMatchInlineSnapshot(`858"- 1 aaa859+ 1 AAa8602 2 bbb8613 3 ccc862---863- 1 AAa864+ 1 AAA8652 2 bbb8663 3 ccc"867`);868});869});870});871872873