From 324d1b341b74a8f0beaecd8d6fe3726c900eb3c2 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Mon, 7 Apr 2025 11:30:55 +0200 Subject: [PATCH] Adds inline edit tests --- src/vs/editor/common/core/textEdit.ts | 58 ++++++++ .../controller/inlineCompletionsController.ts | 4 + ...tion.test.ts => inlineCompletions.test.ts} | 0 .../test/browser/inlineEdits.test.ts | 120 +++++++++++++++++ .../inlineCompletions/test/browser/utils.ts | 125 +++++++++++++++++- .../test/common/testAccessibilityService.ts | 2 +- 6 files changed, 306 insertions(+), 3 deletions(-) rename src/vs/editor/contrib/inlineCompletions/test/browser/{contribution.test.ts => inlineCompletions.test.ts} (100%) create mode 100644 src/vs/editor/contrib/inlineCompletions/test/browser/inlineEdits.test.ts diff --git a/src/vs/editor/common/core/textEdit.ts b/src/vs/editor/common/core/textEdit.ts index 83baa3bff43..3b5ecc2ac55 100644 --- a/src/vs/editor/common/core/textEdit.ts +++ b/src/vs/editor/common/core/textEdit.ts @@ -193,6 +193,64 @@ export class TextEdit { equals(other: TextEdit): boolean { return equals(this.edits, other.edits, (a, b) => a.equals(b)); } + + toString(text: AbstractText | string): string { + if (typeof text === 'string') { + return this.toString(new StringText(text)); + } + + if (this.edits.length === 0) { + return ''; + } + + return this.edits.map(edit => { + const maxLength = 10; + const originalText = text.getValueOfRange(edit.range); + + // Get text before the edit + const beforeRange = Range.fromPositions( + new Position(Math.max(1, edit.range.startLineNumber - 1), 1), + edit.range.getStartPosition() + ); + let beforeText = text.getValueOfRange(beforeRange); + if (beforeText.length > maxLength) { + beforeText = '...' + beforeText.substring(beforeText.length - maxLength); + } + + // Get text after the edit + const afterRange = Range.fromPositions( + edit.range.getEndPosition(), + new Position(edit.range.endLineNumber + 1, 1) + ); + let afterText = text.getValueOfRange(afterRange); + if (afterText.length > maxLength) { + afterText = afterText.substring(0, maxLength) + '...'; + } + + // Format the replaced text + let replacedText = originalText; + if (replacedText.length > maxLength) { + const halfMax = Math.floor(maxLength / 2); + replacedText = replacedText.substring(0, halfMax) + '...' + + replacedText.substring(replacedText.length - halfMax); + } + + // Format the new text + let newText = edit.text; + if (newText.length > maxLength) { + const halfMax = Math.floor(maxLength / 2); + newText = newText.substring(0, halfMax) + '...' + + newText.substring(newText.length - halfMax); + } + + if (replacedText.length === 0) { + // allow-any-unicode-next-line + return `${beforeText}❰${newText}❱${afterText}`; + } + // allow-any-unicode-next-line + return `${beforeText}❰${replacedText}↦${newText}❱${afterText}`; + }).join('\n'); + } } export class SingleTextEdit { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts b/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts index 4a21a9e7592..79a3939990c 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts @@ -350,4 +350,8 @@ export class InlineCompletionsController extends Disposable { m.jump(); } } + + public testOnlyDisableUi() { + this._view.dispose(); + } } diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/contribution.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts similarity index 100% rename from src/vs/editor/contrib/inlineCompletions/test/browser/contribution.test.ts rename to src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineEdits.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineEdits.test.ts new file mode 100644 index 00000000000..ea8896ee649 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineEdits.test.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AnnotatedText, InlineEditContext, IWithAsyncTestCodeEditorAndInlineCompletionsModel, MockSearchReplaceCompletionsProvider, withAsyncTestCodeEditorAndInlineCompletionsModel } from './utils.js'; + +suite('Inline Edits', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const val = new AnnotatedText(` +class Point { + constructor(public x: number, public y: number) {} + + getLength2D(): number { + return↓ Math.sqrt(this.x * this.x + this.y * this.y↓); + } +} +`); + + async function runTest(cb: (ctx: IWithAsyncTestCodeEditorAndInlineCompletionsModel, provider: MockSearchReplaceCompletionsProvider, view: InlineEditContext) => Promise): Promise { + const provider = new MockSearchReplaceCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel(val.value, + { fakeClock: true, provider, inlineSuggest: { enabled: true } }, + async (ctx) => { + const view = new InlineEditContext(ctx.model, ctx.editor); + ctx.store.add(view); + await cb(ctx, provider, view); + } + ); + } + + test('Can Accept Inline Edit', async function () { + await runTest(async ({ context, model, editor, editorViewModel }, provider, view) => { + provider.add(`getLength2D(): number { + return Math.sqrt(this.x * this.x + this.y * this.y); + }`, `getLength3D(): number { + return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); + }`); + + await model.trigger(); + await timeout(10000); + assert.deepStrictEqual(view.getAndClearViewStates(), ([ + undefined, + "\n\tget❰Length2↦Length3❱D(): numbe...\n...y * this.y❰ + th...his.z❱);\n" + ])); + + model.accept(); + + assert.deepStrictEqual(editor.getValue(), ` +class Point { + constructor(public x: number, public y: number) {} + + getLength3D(): number { + return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); + } +} +`); + }); + }); + + test('Can Type Inline Edit', async function () { + await runTest(async ({ context, model, editor, editorViewModel }, provider, view) => { + provider.add(`getLength2D(): number { + return Math.sqrt(this.x * this.x + this.y * this.y); + }`, `getLength3D(): number { + return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); + }`); + await model.trigger(); + await timeout(10000); + assert.deepStrictEqual(view.getAndClearViewStates(), ([ + undefined, + "\n\tget❰Length2↦Length3❱D(): numbe...\n...y * this.y❰ + th...his.z❱);\n" + ])); + + editor.setPosition(val.getMarkerPosition(1)); + editorViewModel.type(' + t'); + + assert.deepStrictEqual(view.getAndClearViewStates(), ([ + "\n\tget❰Length2↦Length3❱D(): numbe...\n...this.y + t❰his.z...his.z❱);\n" + ])); + + editorViewModel.type('his.z * this.z'); + assert.deepStrictEqual(view.getAndClearViewStates(), ([ + "\n\tget❰Length2↦Length3❱D(): numbe..." + ])); + }); + }); + + test('Inline Edit Stays On Unrelated Edit', async function () { + await runTest(async ({ context, model, editor, editorViewModel }, provider, view) => { + provider.add(`getLength2D(): number { + return Math.sqrt(this.x * this.x + this.y * this.y); + }`, `getLength3D(): number { + return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); + }`); + await model.trigger(); + await timeout(10000); + assert.deepStrictEqual(view.getAndClearViewStates(), ([ + undefined, + "\n\tget❰Length2↦Length3❱D(): numbe...\n...y * this.y❰ + th...his.z❱);\n" + ])); + + editor.setPosition(val.getMarkerPosition(0)); + editorViewModel.type('/* */'); + + assert.deepStrictEqual(view.getAndClearViewStates(), ([ + "\n\tget❰Length2↦Length3❱D(): numbe...\n...y * this.y❰ + th...his.z❱);\n" + ])); + + await timeout(10000); + assert.deepStrictEqual(view.getAndClearViewStates(), ([ + undefined + ])); + }); + }); +}); diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index 9a682972c5f..ffbb9897aa6 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -9,10 +9,10 @@ import { Disposable, DisposableStore } from '../../../../../base/common/lifecycl import { CoreEditingCommands, CoreNavigationCommands } from '../../../../browser/coreCommands.js'; import { Position } from '../../../../common/core/position.js'; import { ITextModel } from '../../../../common/model.js'; -import { InlineCompletion, InlineCompletionContext, InlineCompletionsProvider } from '../../../../common/languages.js'; +import { InlineCompletion, InlineCompletionContext, InlineCompletions, InlineCompletionsProvider } from '../../../../common/languages.js'; import { ITestCodeEditor, TestCodeEditorInstantiationOptions, withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; import { InlineCompletionsModel } from '../../browser/model/inlineCompletionsModel.js'; -import { autorun } from '../../../../../base/common/observable.js'; +import { autorun, derived } from '../../../../../base/common/observable.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; @@ -20,6 +20,10 @@ import { ILanguageFeaturesService } from '../../../../common/services/languageFe import { LanguageFeaturesService } from '../../../../common/services/languageFeaturesService.js'; import { ViewModel } from '../../../../common/viewModel/viewModelImpl.js'; import { InlineCompletionsController } from '../../browser/controller/inlineCompletionsController.js'; +import { Range } from '../../../../common/core/range.js'; +import { TextEdit } from '../../../../common/core/textEdit.js'; +import { BugIndicatingError } from '../../../../../base/common/errors.js'; +import { PositionOffsetTransformer } from '../../../../common/core/positionToOffset.js'; export class MockInlineCompletionsProvider implements InlineCompletionsProvider { private returnValue: InlineCompletion[] = []; @@ -83,6 +87,66 @@ export class MockInlineCompletionsProvider implements InlineCompletionsProvider handleItemDidShow() { } } +export class MockSearchReplaceCompletionsProvider implements InlineCompletionsProvider { + private _map = new Map(); + + public add(search: string, replace: string): void { + this._map.set(search, replace); + } + + async provideInlineCompletions(model: ITextModel, position: Position, context: InlineCompletionContext, token: CancellationToken): Promise { + const text = model.getValue(); + for (const [search, replace] of this._map) { + const idx = text.indexOf(search); + // replace idx...idx+text.length with replace + if (idx !== -1) { + const range = Range.fromPositions(model.getPositionAt(idx), model.getPositionAt(idx + search.length)); + return { + items: [ + { range, insertText: replace, isInlineEdit: true } + ] + }; + } + } + return { items: [] }; + } + freeInlineCompletions() { } + handleItemDidShow() { } +} + +export class InlineEditContext extends Disposable { + public readonly prettyViewStates = new Array(); + + constructor(model: InlineCompletionsModel, private readonly editor: ITestCodeEditor) { + super(); + + const edit = derived(reader => { + const state = model.state.read(reader); + return state ? new TextEdit(state.edits) : undefined; + }); + + this._register(autorun(reader => { + /** @description update */ + const e = edit.read(reader); + let view: string | undefined; + + if (e) { + view = e.toString(this.editor.getValue()); + } else { + view = undefined; + } + + this.prettyViewStates.push(view); + })); + } + + public getAndClearViewStates(): (string | undefined)[] { + const arr = [...this.prettyViewStates]; + this.prettyViewStates.length = 0; + return arr; + } +} + export class GhostTextContext extends Disposable { public readonly prettyViewStates = new Array(); private _currentPrettyViewState: string | undefined; @@ -180,6 +244,7 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel( let result: T; await withAsyncTestCodeEditor(text, options, async (editor, editorViewModel, instantiationService) => { const controller = instantiationService.createInstance(InlineCompletionsController, editor); + controller.testOnlyDisableUi(); const model = controller.model.get()!; const context = new GhostTextContext(model, editor); try { @@ -201,3 +266,59 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel( } }); } + +export class AnnotatedString { + public readonly value: string; + public readonly markers: { mark: string; idx: number }[]; + + constructor(src: string, annotations: string[] = ['↓']) { + const markers = findMarkers(src, annotations); + this.value = markers.textWithoutMarkers; + this.markers = markers.results; + } + + getMarkerOffset(markerIdx = 0): number { + if (markerIdx >= this.markers.length) { + throw new BugIndicatingError(`Marker index ${markerIdx} out of bounds`); + } + return this.markers[markerIdx].idx; + } +} + +function findMarkers(text: string, markers: string[]): { + results: { mark: string; idx: number }[]; + textWithoutMarkers: string; +} { + const results: { mark: string; idx: number }[] = []; + let textWithoutMarkers = ''; + + markers.sort((a, b) => b.length - a.length); + + let pos = 0; + for (let i = 0; i < text.length;) { + let foundMarker = false; + for (const marker of markers) { + if (text.startsWith(marker, i)) { + results.push({ mark: marker, idx: pos }); + i += marker.length; + foundMarker = true; + break; + } + } + if (!foundMarker) { + textWithoutMarkers += text[i]; + pos++; + i++; + } + } + + return { results, textWithoutMarkers }; +} + +export class AnnotatedText extends AnnotatedString { + private readonly _transformer = new PositionOffsetTransformer(this.value); + + getMarkerPosition(markerIdx = 0): Position { + return this._transformer.getPosition(this.getMarkerOffset(markerIdx)); + } +} diff --git a/src/vs/platform/accessibility/test/common/testAccessibilityService.ts b/src/vs/platform/accessibility/test/common/testAccessibilityService.ts index 3357330e6cd..4f21111492e 100644 --- a/src/vs/platform/accessibility/test/common/testAccessibilityService.ts +++ b/src/vs/platform/accessibility/test/common/testAccessibilityService.ts @@ -14,7 +14,7 @@ export class TestAccessibilityService implements IAccessibilityService { onDidChangeReducedMotion = Event.None; isScreenReaderOptimized(): boolean { return false; } - isMotionReduced(): boolean { return false; } + isMotionReduced(): boolean { return true; } alwaysUnderlineAccessKeys(): Promise { return Promise.resolve(false); } setAccessibilitySupport(accessibilitySupport: AccessibilitySupport): void { } getAccessibilitySupport(): AccessibilitySupport { return AccessibilitySupport.Unknown; }