Path: blob/main/extensions/copilot/src/extension/inlineEdits/test/vscode-node/inlineEditTriggerer.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 { afterEach, assert, beforeEach, suite, test } from 'vitest';6import { TextDocumentChangeReason, TextEditor, type TextDocument } from 'vscode';7import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';8import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';9import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/documentId';10import { DocumentSwitchTriggerStrategy } from '../../../../platform/inlineEdits/common/dataTypes/triggerOptions';11import { ILogService } from '../../../../platform/log/common/logService';12import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';13import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspaceService';14import { createTextDocumentData } from '../../../../util/common/test/shims/textDocument';15import { ExtHostTextEditor } from '../../../../util/common/test/shims/textEditor';16import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';17import { IReader } from '../../../../util/vs/base/common/observableInternal';18import { Selection, TextEditorSelectionChangeKind, Uri } from '../../../../vscodeTypes';19import { createExtensionUnitTestingServices } from '../../../test/node/services';20import { NesChangeHint, NesTriggerReason } from '../../common/nesTriggerHint';21import { NesOutcome, NextEditProvider } from '../../node/nextEditProvider';22import {23InlineEditTriggerer,24TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT,25TRIGGER_INLINE_EDIT_ON_SAME_LINE_COOLDOWN,26TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN27} from '../../vscode-node/inlineEditTriggerer';28import { IVSCodeObservableDocument } from '../../vscode-node/parts/vscodeWorkspace';293031suite('InlineEditTriggerer', () => {32let disposables: DisposableStore;33let vscWorkspace: MockVSCodeWorkspace;34let workspaceService: TestWorkspaceService;35let firedEvents: NesChangeHint[];36let nextEditProvider: MockNextEditProvider;37let configurationService: InMemoryConfigurationService;38let triggerer: InlineEditTriggerer;3940class MockNextEditProvider {41public lastRejectionTime: number = Date.now();42public lastTriggerTime: number = Date.now();43public lastOutcome: NesOutcome | undefined = undefined;44}4546class MockVSCodeWorkspace {47public readonly documents = new WeakMap<TextDocument, IVSCodeObservableDocument>();48public addDoc(doc: TextDocument, obsDoc: IVSCodeObservableDocument): void {49this.documents.set(doc, obsDoc);50}51public getDocumentByTextDocument(doc: TextDocument, _reader?: IReader): IVSCodeObservableDocument | undefined {52return this.documents.get(doc);53}54}5556beforeEach(() => {57disposables = new DisposableStore();58firedEvents = [];59vscWorkspace = new MockVSCodeWorkspace();60nextEditProvider = new MockNextEditProvider();6162workspaceService = disposables.add(new TestWorkspaceService());63const services = disposables.add(createExtensionUnitTestingServices());64const accessor = disposables.add(services.createTestingAccessor());6566configurationService = accessor.get(IConfigurationService) as InMemoryConfigurationService;67triggerer = disposables.add(new InlineEditTriggerer(68vscWorkspace as any,69nextEditProvider as any as NextEditProvider,70accessor.get(ILogService),71configurationService,72accessor.get(IExperimentationService),73workspaceService74));75disposables.add(triggerer.onChange(e => firedEvents.push(e)));76});7778afterEach(() => {79disposables.dispose();80});8182// #region Helper functions8384function triggerTextChange(document: TextDocument, reason?: TextDocumentChangeReason): void {85workspaceService.didChangeTextDocumentEmitter.fire({86document,87contentChanges: [],88reason,89detailedReason: undefined,90});91}9293function triggerTextSelectionChange(textEditor: TextEditor, selection: Selection, kind = TextEditorSelectionChangeKind.Keyboard): void {94workspaceService.didChangeTextEditorSelectionEmitter.fire({95kind,96selections: [selection],97textEditor,98});99}100101function triggerMultipleSelectionChange(textEditor: TextEditor, selections: Selection[]): void {102workspaceService.didChangeTextEditorSelectionEmitter.fire({103kind: TextEditorSelectionChangeKind.Keyboard,104selections,105textEditor,106});107}108109function createObservableTextDoc(uri: Uri): IVSCodeObservableDocument {110return {111id: DocumentId.create(uri.toString()),112toRange: (_: any, range: any) => range113} as any;114}115116function createTextDocument(117selection: Selection = new Selection(0, 0, 0, 0),118uri: Uri = Uri.file('sample.py'),119content = 'print("Hello World")'120): { document: TextDocument; textEditor: TextEditor; selection: Selection } {121const doc = createTextDocumentData(uri, content, 'python');122const textEditor = new ExtHostTextEditor(doc.document, [selection], {}, [], undefined);123vscWorkspace.addDoc(doc.document, createObservableTextDoc(doc.document.uri));124return {125document: doc.document,126textEditor: textEditor.value,127selection128};129}130131function createOutputDocument(): { document: TextDocument; textEditor: TextEditor; selection: Selection } {132const uri = Uri.parse('output:extension-output-GitHub.copilot-chat-#1-GitHub Copilot Chat');133const doc = createTextDocumentData(uri, 'output logs', 'log');134const selection = new Selection(0, 0, 0, 0);135const textEditor = new ExtHostTextEditor(doc.document, [selection], {}, [], undefined);136return { document: doc.document, textEditor: textEditor.value, selection };137}138139function getLastFiredReason(): NesTriggerReason | undefined {140return firedEvents.at(-1)?.data.reason;141}142143// #endregion144145// #region Basic behaviors146147suite('Basic behaviors', () => {148test('No signal if there were no text changes', () => {149const { textEditor, selection } = createTextDocument();150151triggerTextSelectionChange(textEditor, selection);152153assert.strictEqual(firedEvents.length, 0, 'Signal should not have been fired');154});155156test('No signal if selection is not empty', () => {157const { document, textEditor, selection } = createTextDocument(new Selection(0, 0, 0, 10));158159triggerTextChange(document);160triggerTextSelectionChange(textEditor, selection);161162assert.strictEqual(firedEvents.length, 0, 'Signal should not have been fired');163});164165test('Signal fires when text changes and cursor moves with empty selection', () => {166const { document, textEditor } = createTextDocument();167nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;168169triggerTextChange(document);170triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));171172assert.isAtLeast(firedEvents.length, 1, 'Signal should have been fired');173assert.strictEqual(getLastFiredReason(), NesTriggerReason.SelectionChange);174});175176test('No signal with multiple selections', () => {177const { document, textEditor } = createTextDocument();178nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;179180triggerTextChange(document);181triggerMultipleSelectionChange(textEditor, [182new Selection(0, 0, 0, 0),183new Selection(1, 0, 1, 0)184]);185186assert.strictEqual(firedEvents.length, 0, 'Signal should not have been fired for multiple selections');187});188});189190// #endregion191192// #region Rejection cooldown193194suite('Rejection cooldown', () => {195test('No signal when last rejection was within cooldown period', () => {196const { document, textEditor } = createTextDocument();197nextEditProvider.lastRejectionTime = Date.now() - (TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1000);198199triggerTextChange(document);200triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));201202assert.strictEqual(firedEvents.length, 0, 'Signal should not fire during rejection cooldown');203});204205test('Signal fires when last rejection was over cooldown ago', () => {206const { document, textEditor } = createTextDocument();207nextEditProvider.lastRejectionTime = Date.now() - (TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN + 1);208209triggerTextChange(document);210triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));211212assert.isAtLeast(firedEvents.length, 1, 'Signal should have been fired');213});214215test('Rejection clears tracking for the document', () => {216const { document, textEditor } = createTextDocument();217nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;218219triggerTextChange(document);220// Now set rejection time to be recent221nextEditProvider.lastRejectionTime = Date.now();222triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));223224assert.strictEqual(firedEvents.length, 0, 'Signal should not fire');225226// Make another change and ensure tracking was cleared227triggerTextSelectionChange(textEditor, new Selection(0, 10, 0, 10));228assert.strictEqual(firedEvents.length, 0, 'Signal should still not fire as doc was cleared');229});230});231232// #endregion233234// #region Document filtering235236suite('Document filtering', () => {237test('Ignores output pane documents for text changes', () => {238const { document, textEditor, selection } = createOutputDocument();239nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;240241triggerTextChange(document);242triggerTextSelectionChange(textEditor, selection);243244assert.strictEqual(firedEvents.length, 0, 'Signal should not fire for output documents');245});246247test('Ignores copilot-ignored documents (not in workspace)', () => {248const { document, textEditor } = createTextDocument();249// Remove from workspace to simulate copilot-ignored250vscWorkspace.documents.delete(document);251nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;252253triggerTextChange(document);254triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));255256assert.strictEqual(firedEvents.length, 0, 'Signal should not fire for ignored documents');257});258});259260// #endregion261262// #region Undo/Redo handling263264suite('Undo/Redo handling', () => {265test('Ignores undo changes', () => {266const { document, textEditor } = createTextDocument();267nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;268269triggerTextChange(document, TextDocumentChangeReason.Undo);270triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));271272assert.strictEqual(firedEvents.length, 0, 'Signal should not fire for undo changes');273});274275test('Ignores redo changes', () => {276const { document, textEditor } = createTextDocument();277nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;278279triggerTextChange(document, TextDocumentChangeReason.Redo);280triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));281282assert.strictEqual(firedEvents.length, 0, 'Signal should not fire for redo changes');283});284});285286// #endregion287288// #region Edit timestamp limits289290suite('Edit timestamp limits', () => {291test('No signal if edit is too old', async () => {292const { document } = createTextDocument();293nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;294295triggerTextChange(document);296297// Simulate time passing beyond the limit by manipulating internal state298// We need to wait for the limit to pass - but since we can't easily mock Date.now(),299// we test the boundary condition instead by verifying the constant is used correctly300assert.strictEqual(TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT, 10000, 'Limit should be 10 seconds');301});302303test('Signal fires when edit is within time limit', () => {304const { document, textEditor } = createTextDocument();305nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;306307triggerTextChange(document);308triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));309310assert.isAtLeast(firedEvents.length, 1, 'Signal should fire for recent edits');311});312});313314// #endregion315316// #region Trigger time checks317318suite('Trigger time checks', () => {319test('No signal if last trigger time is too old', () => {320const { document, textEditor } = createTextDocument();321nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;322nextEditProvider.lastTriggerTime = Date.now() - TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT - 1;323324triggerTextChange(document);325triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));326327assert.strictEqual(firedEvents.length, 0, 'Signal should not fire when last trigger is too old');328});329330test('Signal fires when last trigger time is recent', () => {331const { document, textEditor } = createTextDocument();332nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;333nextEditProvider.lastTriggerTime = Date.now();334335triggerTextChange(document);336triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));337338assert.isAtLeast(firedEvents.length, 1, 'Signal should fire for recent triggers');339});340});341342// #endregion343344// #region Same line cooldown345346suite('Same line cooldown', () => {347test('No signal for same line within cooldown period', () => {348const { document, textEditor } = createTextDocument();349nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;350351triggerTextChange(document);352triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));353354const initialCount = firedEvents.length;355assert.isAtLeast(initialCount, 1, 'First signal should fire');356357// Same line, different column - should be in cooldown358triggerTextSelectionChange(textEditor, new Selection(0, 10, 0, 10));359360assert.strictEqual(firedEvents.length, initialCount, 'Signal should not fire for same line in cooldown');361});362363test('Signal fires on different line', () => {364const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2\nline3');365nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;366367triggerTextChange(document);368triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));369370const initialCount = firedEvents.length;371assert.isAtLeast(initialCount, 1, 'First signal should fire');372373// Different line374triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0));375376assert.isAtLeast(firedEvents.length, initialCount + 1, 'Signal should fire for different line');377});378379test('Cooldown constant is 5 seconds', () => {380assert.strictEqual(TRIGGER_INLINE_EDIT_ON_SAME_LINE_COOLDOWN, 5000, 'Same line cooldown should be 5s');381});382});383384// #endregion385386// #region Document switch behavior387388suite('Document switch behavior', () => {389test('Triggers on document switch when configured', () => {390const doc1 = createTextDocument(undefined, Uri.file('file1.py'));391const doc2 = createTextDocument(undefined, Uri.file('file2.py'));392393nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;394nextEditProvider.lastOutcome = NesOutcome.Accepted;395396// Configure to trigger on document switch397void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);398399// Make a change in doc1400triggerTextChange(doc1.document);401triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));402403const initialCount = firedEvents.length;404405// Switch to doc2406triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));407408assert.isAtLeast(firedEvents.length, initialCount + 1, 'Signal should fire on document switch');409assert.strictEqual(getLastFiredReason(), NesTriggerReason.ActiveDocumentSwitch);410});411412test('Does not trigger on same document', () => {413const { document, textEditor } = createTextDocument();414nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;415416void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);417418triggerTextChange(document);419triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));420421// Same document, just moving cursor (no tracked change for line 1)422triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));423424// Should not trigger a document switch event for same document425const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);426assert.strictEqual(switchEvents.length, 0, 'Should not trigger document switch for same doc');427});428429test('Does not trigger when document switch is disabled', () => {430const doc1 = createTextDocument(undefined, Uri.file('file1.py'));431const doc2 = createTextDocument(undefined, Uri.file('file2.py'));432433nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;434435// Don't configure document switch trigger (leave as undefined)436void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, undefined);437438triggerTextChange(doc1.document);439triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));440441// Switch to doc2 without making changes there442triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));443444// Should not trigger because doc2 has no tracked changes and switch trigger is disabled445const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);446assert.strictEqual(switchEvents.length, 0, 'Should not trigger document switch when disabled');447});448449test('Does not trigger on document switch when there is no recent NES trigger (lastTriggerTime is 0)', () => {450const doc1 = createTextDocument(undefined, Uri.file('file1.py'));451const doc2 = createTextDocument(undefined, Uri.file('file2.py'));452453nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;454nextEditProvider.lastTriggerTime = 0; // No previous trigger455456// Configure to trigger on document switch457void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);458459// Make a change in doc1460triggerTextChange(doc1.document);461triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));462463const initialCount = firedEvents.length;464465// Switch to doc2466triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));467468// Should not trigger document switch because lastTriggerTime is 0469const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);470assert.strictEqual(switchEvents.length, 0, 'Should not trigger document switch when lastTriggerTime is 0');471assert.strictEqual(firedEvents.length, initialCount, 'No new events should fire');472});473474test('Does not trigger on document switch when NES trigger was too long ago', () => {475const doc1 = createTextDocument(undefined, Uri.file('file1.py'));476const doc2 = createTextDocument(undefined, Uri.file('file2.py'));477478nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;479480const triggerAfterSeconds = 30;481// Configure to trigger on document switch482void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, triggerAfterSeconds);483484// Make a change in doc1485triggerTextChange(doc1.document);486triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));487488const initialCount = firedEvents.length;489490// Set lastTriggerTime to be older than the configured threshold491nextEditProvider.lastTriggerTime = Date.now() - (triggerAfterSeconds * 1000) - 1;492493// Switch to doc2494triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));495496// Should not trigger document switch because last trigger was too long ago497const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);498assert.strictEqual(switchEvents.length, 0, 'Should not trigger document switch when last trigger was too long ago');499assert.strictEqual(firedEvents.length, initialCount, 'No new events should fire');500});501502test('Triggers on document switch when NES trigger was recent', () => {503const doc1 = createTextDocument(undefined, Uri.file('file1.py'));504const doc2 = createTextDocument(undefined, Uri.file('file2.py'));505506nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;507nextEditProvider.lastOutcome = NesOutcome.Accepted;508509const triggerAfterSeconds = 30;510// Configure to trigger on document switch511void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, triggerAfterSeconds);512513// Make a change in doc1514triggerTextChange(doc1.document);515triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));516517const initialCount = firedEvents.length;518519// Set lastTriggerTime to be within the configured threshold520nextEditProvider.lastTriggerTime = Date.now() - (triggerAfterSeconds * 1000) + 5000; // 5 seconds within the threshold521522// Switch to doc2523triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));524525// Should trigger document switch because last trigger was recent526const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);527assert.strictEqual(switchEvents.length, 1, 'Should trigger document switch when last trigger was recent');528assert.isAtLeast(firedEvents.length, initialCount + 1, 'Should have fired an additional event');529});530});531532// #endregion533534// #region Debounce behavior535536suite('Debounce behavior', () => {537test('First two selection changes fire immediately when debounce is configured', () => {538const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2\nline3');539nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;540541// Configure debounce542void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsDebounceOnSelectionChange, 100);543544triggerTextChange(document);545546// First selection change - should fire immediately547triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));548assert.strictEqual(firedEvents.length, 1, 'First selection change should fire immediately');549550// Second selection change - should also fire immediately551triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0));552assert.strictEqual(firedEvents.length, 2, 'Second selection change should fire immediately');553});554555test('Third and subsequent selection changes are debounced', async () => {556const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2\nline3\nline4\nline5');557nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;558559const debounceMs = 50;560void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsDebounceOnSelectionChange, debounceMs);561562triggerTextChange(document);563564// First two fire immediately565triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));566triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0));567assert.strictEqual(firedEvents.length, 2, 'First two should fire immediately');568569// Third selection change - should be debounced570triggerTextSelectionChange(textEditor, new Selection(2, 0, 2, 0));571assert.strictEqual(firedEvents.length, 2, 'Third should not fire immediately');572573// Wait for debounce574await new Promise(resolve => setTimeout(resolve, debounceMs + 20));575assert.strictEqual(firedEvents.length, 3, 'Third should fire after debounce');576});577578test('No debounce when config is undefined', () => {579const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2\nline3\nline4');580nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;581582// No debounce config583void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsDebounceOnSelectionChange, undefined);584585triggerTextChange(document);586587// All selection changes should fire immediately588triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));589triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0));590triggerTextSelectionChange(textEditor, new Selection(2, 0, 2, 0));591triggerTextSelectionChange(textEditor, new Selection(3, 0, 3, 0));592593assert.strictEqual(firedEvents.length, 4, 'All selection changes should fire immediately without debounce');594});595});596597// #endregion598599// #region Event data validation600601suite('Event data validation', () => {602test('Fired event has valid NesChangeHint structure', () => {603const { document, textEditor } = createTextDocument();604nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;605606triggerTextChange(document);607triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));608609assert.isAtLeast(firedEvents.length, 1, 'Should have fired at least one event');610611const event = firedEvents[0];612assert.isTrue(NesChangeHint.is(event), 'Event should be a valid NesChangeHint');613assert.isString(event.data.uuid, 'UUID should be a string');614assert.isNotEmpty(event.data.uuid, 'UUID should not be empty');615assert.strictEqual(event.data.reason, NesTriggerReason.SelectionChange);616});617618test('Each trigger has a unique UUID', () => {619const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2');620nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;621622triggerTextChange(document);623triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));624triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0));625626assert.isAtLeast(firedEvents.length, 2, 'Should have at least 2 events');627628const uuids = firedEvents.map(e => e.data.uuid);629const uniqueUuids = new Set(uuids);630assert.strictEqual(uniqueUuids.size, uuids.length, 'All UUIDs should be unique');631});632});633634// #endregion635636// #region toRange returning undefined637638suite('toRange returning undefined', () => {639test('No signal when toRange returns undefined', () => {640const uri = Uri.file('norange.py');641const doc = createTextDocumentData(uri, 'content', 'python');642const selection = new Selection(0, 0, 0, 0);643const textEditor = new ExtHostTextEditor(doc.document, [selection], {}, [], undefined);644645// Register doc with a toRange that always returns undefined646const obsDoc: IVSCodeObservableDocument = {647id: DocumentId.create(uri.toString()),648toRange: () => undefined649} as any;650vscWorkspace.addDoc(doc.document, obsDoc);651652nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;653654triggerTextChange(doc.document);655triggerTextSelectionChange(textEditor.value, new Selection(0, 5, 0, 5));656657assert.strictEqual(firedEvents.length, 0, 'Signal should not fire when toRange returns undefined');658});659});660661// #endregion662663// #region Notebook cell same-line cooldown bypass664665suite('Notebook cell behavior', () => {666function createNotebookCellDocument(667cellId: string = '1',668content = 'print("hello")'669): { document: TextDocument; textEditor: TextEditor } {670const uri = Uri.parse(`vscode-notebook-cell://notebook/${cellId}`);671const doc = createTextDocumentData(uri, content, 'python');672const selection = new Selection(0, 0, 0, 0);673const textEditor = new ExtHostTextEditor(doc.document, [selection], {}, [], undefined);674vscWorkspace.addDoc(doc.document, createObservableTextDoc(doc.document.uri));675return { document: doc.document, textEditor: textEditor.value };676}677678test('Notebook cell bypasses same-line cooldown when documentTrigger differs', () => {679// Create two notebook cells: edit one, then move in another680const cell1 = createNotebookCellDocument('cell1');681const cell2 = createNotebookCellDocument('cell2', 'x = 1');682683nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;684685// Ensure triggerOnActiveEditorChange is NOT set so the notebook-specific path is the only way to bypass686void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, undefined);687688// Edit cell1 (this registers `documentTrigger` as cell1.document)689triggerTextChange(cell1.document);690triggerTextSelectionChange(cell1.textEditor, new Selection(0, 0, 0, 0));691692const countAfterFirst = firedEvents.length;693assert.isAtLeast(countAfterFirst, 1, 'First trigger should fire');694695// Now manually set the internal tracking to point at cell2's docId, simulating that the696// user has moved to cell2 which is a notebook cell with a different document than documentTrigger.697// We trigger a text change on cell2 so it gets tracked, then selection on the same line.698triggerTextChange(cell2.document);699triggerTextSelectionChange(cell2.textEditor, new Selection(0, 0, 0, 0));700701const countAfterSecond = firedEvents.length;702assert.isAtLeast(countAfterSecond, countAfterFirst + 1, 'Should trigger in cell2 on line 0');703704// Move within cell2 on the SAME line — because cell2.document !== documentTrigger (cell2.document705// was set as documentTrigger by the previous trigger, so same-doc, same-line cooldown applies normally)706// But if we trigger another text change (like in cell1) then move to cell2 on same line,707// the notebook path bypasses the cooldown since e.textEditor.document !== mostRecentChange.documentTrigger708triggerTextChange(cell1.document);709// Now the tracking for cell1 is refreshed. Move selection in cell2:710// For this to test the notebook path, we need the mostRecentChange to be for the cell2 doc id711// but documentTrigger to differ from the current textEditor document.712// Actually, the code checks: isNotebookCell(uri) || doc === mostRecentChange.documentTrigger713// When isNotebookCell is true AND doc !== mostRecentChange.documentTrigger, cooldown is bypassed.714715// Let's set up this scenario cleanly:716// 1. Edit cell2 — now tracking cell2 with documentTrigger = cell2.document717triggerTextChange(cell2.document);718triggerTextSelectionChange(cell2.textEditor, new Selection(0, 0, 0, 0));719const countBeforeBypass = firedEvents.length;720721// 2. Now manually change the documentTrigger for the tracked entry of cell2722// by editing cell1 which shares the same docId tracking area — no, each doc has its own entry.723// Instead, the natural way notebook cells work: user edits cell1, then moves to cell2.724// But cell2 wouldn't have mostRecentChange unless it was edited.725// The key scenario: user edits cell2, triggers on line 0. Then edits cell1 (different cell).726// Now cell2 still has its LastChange with documentTrigger = cell2.document.727// User moves BACK to cell2 and the selection fires. Since no new edit on cell2,728// the existing LastChange is used. documentTrigger is cell2.document, and textEditor.document729// is also cell2.document — so they ARE equal, cooldown applies normally.730731// The bypass scenario: triggerTextChange fires for cell2.document, creating LastChange with732// documentTrigger = cell2.document. Then another selection event comes for cell2 but from733// a DIFFERENT textEditor.document. This happens when VS Code reloads cell documents.734// We can simulate this by registering a new doc object for the same notebook cell URI.735const cell2Alt = createNotebookCellDocument('cell2', 'x = 1');736// cell2Alt.document is a NEW object but has same URI737// The docToLastChangeMap tracks by DocumentId (keyed on URI string), so the existing LastChange738// for cell2 is reused. Its documentTrigger is cell2.document, but now e.textEditor.document739// is cell2Alt.document — a different object => bypass cooldown.740741triggerTextSelectionChange(cell2Alt.textEditor, new Selection(0, 0, 0, 0));742assert.isAtLeast(firedEvents.length, countBeforeBypass + 1,743'Should bypass same-line cooldown for notebook cell when documentTrigger differs');744});745746test('Notebook cell respects same-line cooldown when documentTrigger matches', () => {747const cell = createNotebookCellDocument('cell1');748749nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;750void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, undefined);751752triggerTextChange(cell.document);753triggerTextSelectionChange(cell.textEditor, new Selection(0, 0, 0, 0));754755const countAfterFirst = firedEvents.length;756assert.isAtLeast(countAfterFirst, 1, 'First trigger should fire');757758// Same line, same document object — cooldown should apply even for notebook cells759triggerTextSelectionChange(cell.textEditor, new Selection(0, 5, 0, 5));760assert.strictEqual(firedEvents.length, countAfterFirst,761'Should respect same-line cooldown when documentTrigger matches');762});763});764765// #endregion766767// #region Line trigger cleanup768769suite('Line trigger cleanup', () => {770test('Stale line triggers are cleaned up when count exceeds 100', () => {771// Generate a document with >102 lines772const lines = Array.from({ length: 110 }, (_, i) => `line${i}`).join('\n');773const { document, textEditor } = createTextDocument(undefined, undefined, lines);774nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;775776triggerTextChange(document);777778// Trigger selection changes on 101 different lines to fill the map779for (let i = 0; i < 101; i++) {780triggerTextSelectionChange(textEditor, new Selection(i, 0, i, 0));781}782783// All 101 triggers should have fired (each on a different line, no same-line cooldown)784assert.strictEqual(firedEvents.length, 101, 'All 101 triggers should fire');785786// The next trigger (line 101) should still work — the cleanup runs but all entries are recent787// so none are actually removed, and the trigger still fires788triggerTextSelectionChange(textEditor, new Selection(101, 0, 101, 0));789assert.strictEqual(firedEvents.length, 102, 'Trigger should still work after cleanup runs');790});791});792793// #endregion794795// #region Debounce edge cases796797suite('Debounce edge cases', () => {798test('New text change resets consecutive selection change counter', async () => {799const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2\nline3\nline4\nline5\nline6');800nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;801802const debounceMs = 50;803void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsDebounceOnSelectionChange, debounceMs);804805// First change cycle806triggerTextChange(document);807triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0)); // immediate (1st)808triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0)); // immediate (2nd)809triggerTextSelectionChange(textEditor, new Selection(2, 0, 2, 0)); // debounced (3rd)810assert.strictEqual(firedEvents.length, 2, 'Third should be debounced');811812// Wait for debounce to complete813await new Promise(resolve => setTimeout(resolve, debounceMs + 20));814assert.strictEqual(firedEvents.length, 3, 'Debounced event should fire');815816// New text change resets the counter by creating a new LastChange817triggerTextChange(document);818triggerTextSelectionChange(textEditor, new Selection(3, 0, 3, 0)); // immediate again (1st of new cycle)819assert.strictEqual(firedEvents.length, 4, 'First selection after new change should fire immediately');820821triggerTextSelectionChange(textEditor, new Selection(4, 0, 4, 0)); // immediate (2nd of new cycle)822assert.strictEqual(firedEvents.length, 5, 'Second selection after new change should fire immediately');823824triggerTextSelectionChange(textEditor, new Selection(5, 0, 5, 0)); // debounced (3rd of new cycle)825assert.strictEqual(firedEvents.length, 5, 'Third selection after new change should be debounced again');826});827828test('Later debounced event replaces earlier pending one', async () => {829const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join('\n');830const { document, textEditor } = createTextDocument(undefined, undefined, lines);831nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;832833const debounceMs = 80;834void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsDebounceOnSelectionChange, debounceMs);835836triggerTextChange(document);837838// First two fire immediately839triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));840triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0));841assert.strictEqual(firedEvents.length, 2);842843// Third is debounced844triggerTextSelectionChange(textEditor, new Selection(2, 0, 2, 0));845assert.strictEqual(firedEvents.length, 2, 'Third should be debounced');846847// Fourth replaces the third's pending timeout (MutableDisposable)848triggerTextSelectionChange(textEditor, new Selection(3, 0, 3, 0));849assert.strictEqual(firedEvents.length, 2, 'Fourth should also be debounced');850851// Wait for debounce — only ONE additional event should fire (the latest one)852await new Promise(resolve => setTimeout(resolve, debounceMs + 30));853assert.strictEqual(firedEvents.length, 3, 'Only one debounced event should fire (the latest)');854});855});856857// #endregion858859// #region Document switch edge cases860861suite('Document switch edge cases', () => {862test('Does not trigger on document switch to copilot-ignored destination', () => {863const doc1 = createTextDocument(undefined, Uri.file('file1.py'));864// doc2 is NOT added to vscWorkspace (copilot-ignored)865const uri2 = Uri.file('ignored.py');866const doc2Data = createTextDocumentData(uri2, 'ignored content', 'python');867const doc2Editor = new ExtHostTextEditor(doc2Data.document, [new Selection(0, 0, 0, 0)], {}, [], undefined);868869nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;870void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);871872triggerTextChange(doc1.document);873triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));874const initialCount = firedEvents.length;875876// Switch to ignored doc2877triggerTextSelectionChange(doc2Editor.value, new Selection(0, 0, 0, 0));878879const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);880assert.strictEqual(switchEvents.length, 0, 'Should not trigger switch for copilot-ignored destination');881assert.strictEqual(firedEvents.length, initialCount, 'No new events should fire');882});883884test('Does not trigger on document switch when toRange returns undefined at destination', () => {885const doc1 = createTextDocument(undefined, Uri.file('file1.py'));886887// doc2 has toRange that returns undefined888const uri2 = Uri.file('norange2.py');889const doc2Data = createTextDocumentData(uri2, 'content', 'python');890const doc2Editor = new ExtHostTextEditor(doc2Data.document, [new Selection(0, 0, 0, 0)], {}, [], undefined);891const obsDoc2: IVSCodeObservableDocument = {892id: DocumentId.create(uri2.toString()),893toRange: () => undefined894} as any;895vscWorkspace.addDoc(doc2Data.document, obsDoc2);896897nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;898void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);899900triggerTextChange(doc1.document);901triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));902const initialCount = firedEvents.length;903904// Switch to doc2 where toRange returns undefined905triggerTextSelectionChange(doc2Editor.value, new Selection(0, 0, 0, 0));906907const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);908assert.strictEqual(switchEvents.length, 0, 'Should not trigger switch when toRange returns undefined');909assert.strictEqual(firedEvents.length, initialCount, 'No new events should fire');910});911912test('Does not trigger on document switch when no edit has ever happened', () => {913// Do NOT fire any text change — lastEditTimestamp stays undefined914const doc1 = createTextDocument(undefined, Uri.file('file1.py'));915const doc2 = createTextDocument(undefined, Uri.file('file2.py'));916917nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;918void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);919920// Select in doc1 first (no text change, so no tracked change)921triggerTextSelectionChange(doc1.textEditor, new Selection(0, 0, 0, 0));922// Switch to doc2923triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));924925const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);926assert.strictEqual(switchEvents.length, 0, 'Should not trigger switch when no edits ever happened');927});928929test('Document switch adds doc to tracking map, enabling subsequent cursor moves to trigger', () => {930const doc1 = createTextDocument(undefined, Uri.file('file1.py'));931const doc2 = createTextDocument(undefined, Uri.file('file2.py'), 'line1\nline2');932933nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;934nextEditProvider.lastOutcome = NesOutcome.Accepted;935void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);936937// Edit doc1 and trigger938triggerTextChange(doc1.document);939triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));940941// Switch to doc2 — this triggers ActiveDocumentSwitch AND inserts LastChange for doc2942triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));943assert.strictEqual(getLastFiredReason(), NesTriggerReason.ActiveDocumentSwitch);944const countAfterSwitch = firedEvents.length;945946// Now move cursor in doc2 to a different line — should trigger SelectionChange947// because the document switch added doc2 to the tracking map948triggerTextSelectionChange(doc2.textEditor, new Selection(1, 0, 1, 0));949assert.isAtLeast(firedEvents.length, countAfterSwitch + 1,950'Cursor move in switched-to doc should trigger');951assert.strictEqual(getLastFiredReason(), NesTriggerReason.SelectionChange);952});953});954955// #endregion956957// #region Text change listener edge cases958959// #region Document switch afterAcceptance strategy960961suite('Document switch afterAcceptance strategy', () => {962963function setupForDocSwitch() {964const doc1 = createTextDocument(undefined, Uri.file('file1.py'));965const doc2 = createTextDocument(undefined, Uri.file('file2.py'));966967nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;968void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);969void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsTriggerOnEditorChangeStrategy, DocumentSwitchTriggerStrategy.AfterAcceptance);970971// Edit doc1 and trigger to establish state972triggerTextChange(doc1.document);973triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));974975return { doc1, doc2, eventsBeforeSwitch: firedEvents.length };976}977978test('triggers on document switch when lastOutcome is Accepted', () => {979const { doc2, eventsBeforeSwitch } = setupForDocSwitch();980nextEditProvider.lastOutcome = NesOutcome.Accepted;981982triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));983984const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);985assert.strictEqual(switchEvents.length, 1, 'Should trigger document switch after acceptance');986assert.isAbove(firedEvents.length, eventsBeforeSwitch);987});988989test('does not trigger on document switch when lastOutcome is Rejected', () => {990const { doc2, eventsBeforeSwitch } = setupForDocSwitch();991nextEditProvider.lastOutcome = NesOutcome.Rejected;992993triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));994995const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);996assert.strictEqual(switchEvents.length, 0, 'Should not trigger document switch after rejection');997assert.strictEqual(firedEvents.length, eventsBeforeSwitch);998});9991000test('does not trigger on document switch when lastOutcome is Ignored', () => {1001const { doc2, eventsBeforeSwitch } = setupForDocSwitch();1002nextEditProvider.lastOutcome = NesOutcome.Ignored;10031004triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));10051006const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);1007assert.strictEqual(switchEvents.length, 0, 'Should not trigger document switch after ignore');1008assert.strictEqual(firedEvents.length, eventsBeforeSwitch);1009});10101011test('does not trigger on document switch when lastOutcome is undefined (pending)', () => {1012const { doc2, eventsBeforeSwitch } = setupForDocSwitch();1013nextEditProvider.lastOutcome = undefined;10141015triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));10161017const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);1018assert.strictEqual(switchEvents.length, 0, 'Should not trigger document switch when outcome is pending');1019assert.strictEqual(firedEvents.length, eventsBeforeSwitch);1020});10211022test('triggers on document switch with default strategy regardless of lastOutcome', () => {1023const doc1 = createTextDocument(undefined, Uri.file('file1.py'));1024const doc2 = createTextDocument(undefined, Uri.file('file2.py'));10251026nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;1027void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);1028void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsTriggerOnEditorChangeStrategy, DocumentSwitchTriggerStrategy.Always);10291030nextEditProvider.lastOutcome = NesOutcome.Rejected;10311032triggerTextChange(doc1.document);1033triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));1034const eventsBeforeSwitch = firedEvents.length;10351036triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));10371038const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);1039assert.strictEqual(switchEvents.length, 1, 'Default strategy should trigger on doc switch regardless of outcome');1040assert.isAbove(firedEvents.length, eventsBeforeSwitch);1041});10421043test('triggers on document switch with always strategy regardless of lastOutcome', () => {1044const doc1 = createTextDocument(undefined, Uri.file('file1.py'));1045const doc2 = createTextDocument(undefined, Uri.file('file2.py'));10461047nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;1048void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);1049void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsTriggerOnEditorChangeStrategy, DocumentSwitchTriggerStrategy.Always);10501051nextEditProvider.lastOutcome = NesOutcome.Ignored;10521053triggerTextChange(doc1.document);1054triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));1055const eventsBeforeSwitch = firedEvents.length;10561057triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));10581059const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);1060assert.strictEqual(switchEvents.length, 1, 'Always strategy should trigger on doc switch regardless of outcome');1061assert.isAbove(firedEvents.length, eventsBeforeSwitch);1062});10631064suite('race condition: suggestion shown but not yet resolved', () => {1065test('previous NES was accepted, new suggestion shown (clears outcome), then doc switch — should NOT trigger', () => {1066// Scenario: user accepted an NES, a new suggestion is shown (handleShown1067// clears lastOutcome to undefined), then user switches documents before1068// the new suggestion is accepted/rejected/ignored.1069const { doc2, eventsBeforeSwitch } = setupForDocSwitch();10701071// Simulate: previous NES was accepted...1072nextEditProvider.lastOutcome = NesOutcome.Accepted;1073// ...then a new suggestion is shown, which clears lastOutcome1074nextEditProvider.lastOutcome = undefined;10751076// User switches documents while the new suggestion outcome is pending1077triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));10781079const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);1080assert.strictEqual(switchEvents.length, 0,1081'Should not trigger: stale acceptance must not carry over when a new suggestion is pending');1082assert.strictEqual(firedEvents.length, eventsBeforeSwitch);1083});10841085test('NES shown, then accepted, then doc switch — should trigger', () => {1086// Scenario: suggestion shown → user accepts → user switches doc.1087// The acceptance callback has arrived, so lastOutcome is Accepted.1088const { doc2 } = setupForDocSwitch();10891090// Simulate: suggestion shown (clears outcome)...1091nextEditProvider.lastOutcome = undefined;1092// ...then accepted1093nextEditProvider.lastOutcome = NesOutcome.Accepted;10941095triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));10961097const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);1098assert.strictEqual(switchEvents.length, 1, 'Should trigger after resolved acceptance');1099});11001101test('NES shown, then rejected, then doc switch — should NOT trigger', () => {1102// Scenario: suggestion shown → user rejects → user switches doc.1103const { doc2, eventsBeforeSwitch } = setupForDocSwitch();11041105nextEditProvider.lastOutcome = undefined;1106nextEditProvider.lastOutcome = NesOutcome.Rejected;11071108triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));11091110const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);1111assert.strictEqual(switchEvents.length, 0, 'Should not trigger after resolved rejection');1112assert.strictEqual(firedEvents.length, eventsBeforeSwitch);1113});1114});1115});11161117// #endregion11181119suite('Text change listener edge cases', () => {1120test('Text change on copilot-ignored doc does not track but updates lastEditTimestamp', () => {1121// Create a doc that is NOT in vscWorkspace (copilot-ignored)1122const uri = Uri.file('ignored.py');1123const doc = createTextDocumentData(uri, 'content', 'python');1124// Do NOT call vscWorkspace.addDoc — simulates copilot-ignored11251126const trackedDoc = createTextDocument(undefined, Uri.file('tracked.py'));11271128nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;1129void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);11301131// Fire text change on ignored doc — lastEditTimestamp gets set1132triggerTextChange(doc.document);11331134// Now switch to tracked doc — document switch should work because lastEditTimestamp was set1135triggerTextSelectionChange(trackedDoc.textEditor, new Selection(0, 0, 0, 0));11361137// Need to actually switch docs (first establish doc1 as "last")1138const doc2 = createTextDocument(undefined, Uri.file('tracked2.py'));1139triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));11401141// The point is that lastEditTimestamp was updated by the ignored doc's change1142// which allows document switch to work for other docs1143// (This is tested indirectly — the triggerTextChange on an ignored doc1144// still sets lastEditTimestamp, which is a global field)1145assert.isTrue(true, 'Test verifies that ignored doc change does not throw');1146});11471148test('Undo/redo still updates lastEditTimestamp (only skips tracking)', () => {1149const doc1 = createTextDocument(undefined, Uri.file('file1.py'));1150const doc2 = createTextDocument(undefined, Uri.file('file2.py'));11511152nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;1153nextEditProvider.lastOutcome = NesOutcome.Accepted;1154void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);11551156// Fire an undo change — this should still update lastEditTimestamp1157// even though it doesn't track the doc in docToLastChangeMap1158triggerTextChange(doc1.document, TextDocumentChangeReason.Undo);11591160// Select in doc1 to set lastDocWithSelectionUri.1161// Since lastDocWithSelectionUri starts undefined, this is also considered a "switch"1162// and fires an ActiveDocumentSwitch (because lastEditTimestamp was set by undo).1163triggerTextSelectionChange(doc1.textEditor, new Selection(0, 0, 0, 0));11641165// Switch to doc2 — document switch should work because lastEditTimestamp was set by the undo1166triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));11671168const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);1169assert.isAtLeast(switchEvents.length, 1,1170'Undo should still update lastEditTimestamp enabling document switch');1171});11721173test('Output pane text change does not update lastEditTimestamp', () => {1174const doc1 = createTextDocument(undefined, Uri.file('file1.py'));1175const doc2 = createTextDocument(undefined, Uri.file('file2.py'));1176const { document: outputDocument } = createOutputDocument();11771178nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;1179void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);11801181// Fire text change on output document — should be completely ignored1182triggerTextChange(outputDocument);11831184// Select in doc1 to establish lastDocWithSelectionUri1185triggerTextSelectionChange(doc1.textEditor, new Selection(0, 0, 0, 0));11861187// Switch to doc2 — should NOT trigger because lastEditTimestamp was never set1188triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));11891190const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);1191assert.strictEqual(switchEvents.length, 0,1192'Output doc change should not update lastEditTimestamp');1193});1194});11951196// #endregion11971198// #region Interaction edge cases11991200suite('Interaction edge cases', () => {1201test('Rejection cooldown prevents document switch triggers too', () => {1202const doc1 = createTextDocument(undefined, Uri.file('file1.py'));1203const doc2 = createTextDocument(undefined, Uri.file('file2.py'));12041205// Set rejection to be recent1206nextEditProvider.lastRejectionTime = Date.now();1207void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);12081209// Make a change in doc11210triggerTextChange(doc1.document);1211triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));1212assert.strictEqual(firedEvents.length, 0, 'Should not fire during rejection cooldown');12131214// Switch to doc2 — rejection cooldown clears the tracked change,1215// so document switch's _maybeTriggerOnDocumentSwitch won't find a tracked entry1216// AND the rejection check happens before the switch check1217triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));12181219assert.strictEqual(firedEvents.length, 0, 'Should not fire on doc switch during rejection cooldown');1220});12211222test('Same-line cooldown is bypassed after switching away and back', () => {1223const doc1 = createTextDocument(undefined, Uri.file('file1.py'));1224const doc2 = createTextDocument(undefined, Uri.file('file2.py'));1225nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;12261227// Edit doc1 and trigger on line 01228triggerTextChange(doc1.document);1229triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));12301231const initialCount = firedEvents.length;1232assert.isAtLeast(initialCount, 1, 'First trigger should fire');12331234// Same line — cooldown blocks1235triggerTextSelectionChange(doc1.textEditor, new Selection(0, 10, 0, 10));1236assert.strictEqual(firedEvents.length, initialCount, 'Same-line cooldown should block');12371238// Switch to doc21239triggerTextChange(doc2.document);1240triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));1241const countAfterDoc2 = firedEvents.length;12421243// Switch back to doc1, same line — cooldown should be cleared by the doc switch1244triggerTextSelectionChange(doc1.textEditor, new Selection(0, 10, 0, 10));1245assert.isAtLeast(firedEvents.length, countAfterDoc2 + 1,1246'Same-line cooldown should be bypassed after switching away and back');1247});12481249test('Output pane documents are ignored for selection changes', () => {1250const { textEditor, selection } = createOutputDocument();1251nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;12521253// Even without a text change, selection in output pane should be ignored1254triggerTextSelectionChange(textEditor, selection);1255assert.strictEqual(firedEvents.length, 0, 'Selection in output pane should be ignored');1256});12571258test('Copilot-ignored doc in selection listener returns early before rejection check', () => {1259// Create a doc not in the workspace1260const uri = Uri.file('not-in-workspace.py');1261const doc = createTextDocumentData(uri, 'content', 'python');1262const textEditor = new ExtHostTextEditor(doc.document, [new Selection(0, 0, 0, 0)], {}, [], undefined);1263// Do NOT add to vscWorkspace12641265nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;12661267triggerTextChange(doc.document); // won't track since not in workspace1268triggerTextSelectionChange(textEditor.value, new Selection(0, 5, 0, 5));12691270assert.strictEqual(firedEvents.length, 0,1271'Copilot-ignored doc should return early in selection listener');1272});12731274test('Multiple documents can independently track and trigger', () => {1275const doc1 = createTextDocument(undefined, Uri.file('file1.py'), 'line1\nline2');1276const doc2 = createTextDocument(undefined, Uri.file('file2.py'), 'line1\nline2');1277nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;12781279// Edit and trigger in doc11280triggerTextChange(doc1.document);1281triggerTextSelectionChange(doc1.textEditor, new Selection(0, 0, 0, 0));1282assert.strictEqual(firedEvents.length, 1, 'Doc1 first trigger');12831284// Edit and trigger in doc21285triggerTextChange(doc2.document);1286triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));1287assert.strictEqual(firedEvents.length, 2, 'Doc2 first trigger');12881289// Move in doc1 to a different line — should still work independently1290triggerTextSelectionChange(doc1.textEditor, new Selection(1, 0, 1, 0));1291assert.strictEqual(firedEvents.length, 3, 'Doc1 second trigger on different line');12921293// Move in doc2 to a different line1294triggerTextSelectionChange(doc2.textEditor, new Selection(1, 0, 1, 0));1295assert.strictEqual(firedEvents.length, 4, 'Doc2 second trigger on different line');1296});12971298test('Text change resets line triggers for the document', () => {1299const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2');1300nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;13011302// Trigger on line 01303triggerTextChange(document);1304triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));1305const count1 = firedEvents.length;1306assert.isAtLeast(count1, 1);13071308// Same line — should be in cooldown, no trigger1309triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));1310assert.strictEqual(firedEvents.length, count1, 'Same line should be in cooldown');13111312// New text change resets line triggers (creates a new LastChange)1313triggerTextChange(document);1314triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));1315assert.isAtLeast(firedEvents.length, count1 + 1,1316'After text change, same line should trigger again');1317});1318});13191320// #endregion1321});132213231324