Adds inline edit tests

This commit is contained in:
Henning Dieterichs 2025-04-07 11:30:55 +02:00 committed by Henning Dieterichs
parent 0bfd3b35ed
commit 324d1b341b
6 changed files with 306 additions and 3 deletions

View File

@ -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 {

View File

@ -350,4 +350,8 @@ export class InlineCompletionsController extends Disposable {
m.jump();
}
}
public testOnlyDisableUi() {
this._view.dispose();
}
}

View File

@ -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<void>): Promise<void> {
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
]));
});
});
});

View File

@ -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<string, string>();
public add(search: string, replace: string): void {
this._map.set(search, replace);
}
async provideInlineCompletions(model: ITextModel, position: Position, context: InlineCompletionContext, token: CancellationToken): Promise<InlineCompletions> {
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<string | undefined>();
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<string | undefined>();
private _currentPrettyViewState: string | undefined;
@ -180,6 +244,7 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel<T>(
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<T>(
}
});
}
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));
}
}

View File

@ -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<boolean> { return Promise.resolve(false); }
setAccessibilitySupport(accessibilitySupport: AccessibilitySupport): void { }
getAccessibilitySupport(): AccessibilitySupport { return AccessibilitySupport.Unknown; }