Path: blob/main/src/vs/editor/contrib/suggest/test/browser/suggestController.test.ts
4798 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 assert from 'assert';6import { timeout } from '../../../../../base/common/async.js';7import { Event } from '../../../../../base/common/event.js';8import { DisposableStore } from '../../../../../base/common/lifecycle.js';9import { URI } from '../../../../../base/common/uri.js';10import { mock } from '../../../../../base/test/common/mock.js';11import { Range } from '../../../../common/core/range.js';12import { Selection } from '../../../../common/core/selection.js';13import { TextModel } from '../../../../common/model/textModel.js';14import { CompletionItemInsertTextRule, CompletionItemKind } from '../../../../common/languages.js';15import { IEditorWorkerService } from '../../../../common/services/editorWorker.js';16import { SnippetController2 } from '../../../snippet/browser/snippetController2.js';17import { SuggestController } from '../../browser/suggestController.js';18import { ISuggestMemoryService } from '../../browser/suggestMemory.js';19import { createTestCodeEditor, ITestCodeEditor } from '../../../../test/browser/testCodeEditor.js';20import { createTextModel } from '../../../../test/common/testTextModel.js';21import { IMenu, IMenuService } from '../../../../../platform/actions/common/actions.js';22import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';23import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js';24import { MockKeybindingService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js';25import { ILabelService } from '../../../../../platform/label/common/label.js';26import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js';27import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js';28import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';29import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js';30import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';31import { LanguageFeaturesService } from '../../../../common/services/languageFeaturesService.js';32import { ILanguageFeaturesService } from '../../../../common/services/languageFeatures.js';33import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js';34import { DeleteLinesAction } from '../../../linesOperations/browser/linesOperations.js';3536suite('SuggestController', function () {3738const disposables = new DisposableStore();3940let controller: SuggestController;41let editor: ITestCodeEditor;42let model: TextModel;43const languageFeaturesService = new LanguageFeaturesService();4445teardown(function () {4647disposables.clear();48});4950// ensureNoDisposablesAreLeakedInTestSuite();5152setup(function () {5354const serviceCollection = new ServiceCollection(55[ILanguageFeaturesService, languageFeaturesService],56[ITelemetryService, NullTelemetryService],57[ILogService, new NullLogService()],58[IStorageService, disposables.add(new InMemoryStorageService())],59[IKeybindingService, new MockKeybindingService()],60[IEditorWorkerService, new class extends mock<IEditorWorkerService>() {61override computeWordRanges() {62return Promise.resolve({});63}64}],65[ISuggestMemoryService, new class extends mock<ISuggestMemoryService>() {66override memorize(): void { }67override select(): number { return 0; }68}],69[IMenuService, new class extends mock<IMenuService>() {70override createMenu() {71return new class extends mock<IMenu>() {72override onDidChange = Event.None;73override dispose() { }74};75}76}],77[ILabelService, new class extends mock<ILabelService>() { }],78[IWorkspaceContextService, new class extends mock<IWorkspaceContextService>() { }],79[IEnvironmentService, new class extends mock<IEnvironmentService>() {80override isBuilt: boolean = true;81override isExtensionDevelopment: boolean = false;82}],83);8485model = disposables.add(createTextModel('', undefined, undefined, URI.from({ scheme: 'test-ctrl', path: '/path.tst' })));86editor = disposables.add(createTestCodeEditor(model, { serviceCollection }));8788editor.registerAndInstantiateContribution(SnippetController2.ID, SnippetController2);89controller = editor.registerAndInstantiateContribution(SuggestController.ID, SuggestController);90});9192test('postfix completion reports incorrect position #86984', async function () {93disposables.add(languageFeaturesService.completionProvider.register({ scheme: 'test-ctrl' }, {94_debugDisplayName: 'test',95provideCompletionItems(doc, pos) {96return {97suggestions: [{98kind: CompletionItemKind.Snippet,99label: 'let',100insertText: 'let ${1:name} = foo$0',101insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet,102range: { startLineNumber: 1, startColumn: 9, endLineNumber: 1, endColumn: 11 },103additionalTextEdits: [{104text: '',105range: { startLineNumber: 1, startColumn: 5, endLineNumber: 1, endColumn: 9 }106}]107}]108};109}110}));111112editor.setValue(' foo.le');113editor.setSelection(new Selection(1, 11, 1, 11));114115// trigger116const p1 = Event.toPromise(controller.model.onDidSuggest);117controller.triggerSuggest();118await p1;119120//121const p2 = Event.toPromise(controller.model.onDidCancel);122controller.acceptSelectedSuggestion(false, false);123await p2;124125assert.strictEqual(editor.getValue(), ' let name = foo');126});127128test('use additionalTextEdits sync when possible', async function () {129130disposables.add(languageFeaturesService.completionProvider.register({ scheme: 'test-ctrl' }, {131_debugDisplayName: 'test',132provideCompletionItems(doc, pos) {133return {134suggestions: [{135kind: CompletionItemKind.Snippet,136label: 'let',137insertText: 'hello',138range: Range.fromPositions(pos),139additionalTextEdits: [{140text: 'I came sync',141range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }142}]143}]144};145},146async resolveCompletionItem(item) {147return item;148}149}));150151editor.setValue('hello\nhallo');152editor.setSelection(new Selection(2, 6, 2, 6));153154// trigger155const p1 = Event.toPromise(controller.model.onDidSuggest);156controller.triggerSuggest();157await p1;158159//160const p2 = Event.toPromise(controller.model.onDidCancel);161controller.acceptSelectedSuggestion(false, false);162await p2;163164// insertText happens sync!165assert.strictEqual(editor.getValue(), 'I came synchello\nhallohello');166});167168test('resolve additionalTextEdits async when needed', async function () {169170let resolveCallCount = 0;171172disposables.add(languageFeaturesService.completionProvider.register({ scheme: 'test-ctrl' }, {173_debugDisplayName: 'test',174provideCompletionItems(doc, pos) {175return {176suggestions: [{177kind: CompletionItemKind.Snippet,178label: 'let',179insertText: 'hello',180range: Range.fromPositions(pos)181}]182};183},184async resolveCompletionItem(item) {185resolveCallCount += 1;186await timeout(10);187item.additionalTextEdits = [{188text: 'I came late',189range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }190}];191return item;192}193}));194195editor.setValue('hello\nhallo');196editor.setSelection(new Selection(2, 6, 2, 6));197198// trigger199const p1 = Event.toPromise(controller.model.onDidSuggest);200controller.triggerSuggest();201await p1;202203//204const p2 = Event.toPromise(controller.model.onDidCancel);205controller.acceptSelectedSuggestion(false, false);206await p2;207208// insertText happens sync!209assert.strictEqual(editor.getValue(), 'hello\nhallohello');210assert.strictEqual(resolveCallCount, 1);211212// additional edits happened after a litte wait213await timeout(20);214assert.strictEqual(editor.getValue(), 'I came latehello\nhallohello');215216// single undo stop217editor.getModel()?.undo();218assert.strictEqual(editor.getValue(), 'hello\nhallo');219});220221test('resolve additionalTextEdits async when needed (typing)', async function () {222223let resolveCallCount = 0;224let resolve: Function = () => { };225disposables.add(languageFeaturesService.completionProvider.register({ scheme: 'test-ctrl' }, {226_debugDisplayName: 'test',227provideCompletionItems(doc, pos) {228return {229suggestions: [{230kind: CompletionItemKind.Snippet,231label: 'let',232insertText: 'hello',233range: Range.fromPositions(pos)234}]235};236},237async resolveCompletionItem(item) {238resolveCallCount += 1;239await new Promise(_resolve => resolve = _resolve);240item.additionalTextEdits = [{241text: 'I came late',242range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }243}];244return item;245}246}));247248editor.setValue('hello\nhallo');249editor.setSelection(new Selection(2, 6, 2, 6));250251// trigger252const p1 = Event.toPromise(controller.model.onDidSuggest);253controller.triggerSuggest();254await p1;255256//257const p2 = Event.toPromise(controller.model.onDidCancel);258controller.acceptSelectedSuggestion(false, false);259await p2;260261// insertText happens sync!262assert.strictEqual(editor.getValue(), 'hello\nhallohello');263assert.strictEqual(resolveCallCount, 1);264265// additional edits happened after a litte wait266assert.ok(editor.getSelection()?.equalsSelection(new Selection(2, 11, 2, 11)));267editor.trigger('test', 'type', { text: 'TYPING' });268269assert.strictEqual(editor.getValue(), 'hello\nhallohelloTYPING');270271resolve();272await timeout(10);273assert.strictEqual(editor.getValue(), 'I came latehello\nhallohelloTYPING');274assert.ok(editor.getSelection()?.equalsSelection(new Selection(2, 17, 2, 17)));275});276277// additional edit come late and are AFTER the selection -> cancel278test('resolve additionalTextEdits async when needed (simple conflict)', async function () {279280let resolveCallCount = 0;281let resolve: Function = () => { };282disposables.add(languageFeaturesService.completionProvider.register({ scheme: 'test-ctrl' }, {283_debugDisplayName: 'test',284provideCompletionItems(doc, pos) {285return {286suggestions: [{287kind: CompletionItemKind.Snippet,288label: 'let',289insertText: 'hello',290range: Range.fromPositions(pos)291}]292};293},294async resolveCompletionItem(item) {295resolveCallCount += 1;296await new Promise(_resolve => resolve = _resolve);297item.additionalTextEdits = [{298text: 'I came late',299range: { startLineNumber: 1, startColumn: 6, endLineNumber: 1, endColumn: 6 }300}];301return item;302}303}));304305editor.setValue('');306editor.setSelection(new Selection(1, 1, 1, 1));307308// trigger309const p1 = Event.toPromise(controller.model.onDidSuggest);310controller.triggerSuggest();311await p1;312313//314const p2 = Event.toPromise(controller.model.onDidCancel);315controller.acceptSelectedSuggestion(false, false);316await p2;317318// insertText happens sync!319assert.strictEqual(editor.getValue(), 'hello');320assert.strictEqual(resolveCallCount, 1);321322resolve();323await timeout(10);324assert.strictEqual(editor.getValue(), 'hello');325});326327// additional edit come late and are AFTER the position at which the user typed -> cancelled328test('resolve additionalTextEdits async when needed (conflict)', async function () {329330let resolveCallCount = 0;331let resolve: Function = () => { };332disposables.add(languageFeaturesService.completionProvider.register({ scheme: 'test-ctrl' }, {333_debugDisplayName: 'test',334provideCompletionItems(doc, pos) {335return {336suggestions: [{337kind: CompletionItemKind.Snippet,338label: 'let',339insertText: 'hello',340range: Range.fromPositions(pos)341}]342};343},344async resolveCompletionItem(item) {345resolveCallCount += 1;346await new Promise(_resolve => resolve = _resolve);347item.additionalTextEdits = [{348text: 'I came late',349range: { startLineNumber: 1, startColumn: 2, endLineNumber: 1, endColumn: 2 }350}];351return item;352}353}));354355editor.setValue('hello\nhallo');356editor.setSelection(new Selection(2, 6, 2, 6));357358// trigger359const p1 = Event.toPromise(controller.model.onDidSuggest);360controller.triggerSuggest();361await p1;362363//364const p2 = Event.toPromise(controller.model.onDidCancel);365controller.acceptSelectedSuggestion(false, false);366await p2;367368// insertText happens sync!369assert.strictEqual(editor.getValue(), 'hello\nhallohello');370assert.strictEqual(resolveCallCount, 1);371372// additional edits happened after a litte wait373editor.setSelection(new Selection(1, 1, 1, 1));374editor.trigger('test', 'type', { text: 'TYPING' });375376assert.strictEqual(editor.getValue(), 'TYPINGhello\nhallohello');377378resolve();379await timeout(10);380assert.strictEqual(editor.getValue(), 'TYPINGhello\nhallohello');381assert.ok(editor.getSelection()?.equalsSelection(new Selection(1, 7, 1, 7)));382});383384test('resolve additionalTextEdits async when needed (cancel)', async function () {385386const resolve: Function[] = [];387disposables.add(languageFeaturesService.completionProvider.register({ scheme: 'test-ctrl' }, {388_debugDisplayName: 'test',389provideCompletionItems(doc, pos) {390return {391suggestions: [{392kind: CompletionItemKind.Snippet,393label: 'let',394insertText: 'hello',395range: Range.fromPositions(pos)396}, {397kind: CompletionItemKind.Snippet,398label: 'let',399insertText: 'hallo',400range: Range.fromPositions(pos)401}]402};403},404async resolveCompletionItem(item) {405await new Promise(_resolve => resolve.push(_resolve));406item.additionalTextEdits = [{407text: 'additionalTextEdits',408range: { startLineNumber: 1, startColumn: 2, endLineNumber: 1, endColumn: 2 }409}];410return item;411}412}));413414editor.setValue('abc');415editor.setSelection(new Selection(1, 1, 1, 1));416417// trigger418const p1 = Event.toPromise(controller.model.onDidSuggest);419controller.triggerSuggest();420await p1;421422//423const p2 = Event.toPromise(controller.model.onDidCancel);424controller.acceptSelectedSuggestion(true, false);425await p2;426427// insertText happens sync!428assert.strictEqual(editor.getValue(), 'helloabc');429430// next431controller.acceptNextSuggestion();432433// resolve additional edits (MUST be cancelled)434resolve.forEach(fn => fn);435resolve.length = 0;436await timeout(10);437438// next suggestion used439assert.strictEqual(editor.getValue(), 'halloabc');440});441442test('Completion edits are applied inconsistently when additionalTextEdits and textEdit start at the same offset #143888', async function () {443444445disposables.add(languageFeaturesService.completionProvider.register({ scheme: 'test-ctrl' }, {446_debugDisplayName: 'test',447provideCompletionItems(doc, pos) {448return {449suggestions: [{450kind: CompletionItemKind.Text,451label: 'MyClassName',452insertText: 'MyClassName',453range: Range.fromPositions(pos),454additionalTextEdits: [{455range: Range.fromPositions(pos),456text: 'import "my_class.txt";\n'457}]458}]459};460}461}));462463editor.setValue('');464editor.setSelection(new Selection(1, 1, 1, 1));465466// trigger467const p1 = Event.toPromise(controller.model.onDidSuggest);468controller.triggerSuggest();469await p1;470471//472const p2 = Event.toPromise(controller.model.onDidCancel);473controller.acceptSelectedSuggestion(true, false);474await p2;475476// insertText happens sync!477assert.strictEqual(editor.getValue(), 'import "my_class.txt";\nMyClassName');478479});480481test('Pressing enter on autocomplete should always apply the selected dropdown completion, not a different, hidden one #161883', async function () {482disposables.add(languageFeaturesService.completionProvider.register({ scheme: 'test-ctrl' }, {483_debugDisplayName: 'test',484provideCompletionItems(doc, pos) {485486const word = doc.getWordUntilPosition(pos);487const range = new Range(pos.lineNumber, word.startColumn, pos.lineNumber, word.endColumn);488489return {490suggestions: [{491kind: CompletionItemKind.Text,492label: 'filterBankSize',493insertText: 'filterBankSize',494sortText: 'a',495range496}, {497kind: CompletionItemKind.Text,498label: 'filter',499insertText: 'filter',500sortText: 'b',501range502}]503};504}505}));506507editor.setValue('filte');508editor.setSelection(new Selection(1, 6, 1, 6));509510const p1 = Event.toPromise(controller.model.onDidSuggest);511controller.triggerSuggest();512513const { completionModel } = await p1;514assert.strictEqual(completionModel.items.length, 2);515516const [first, second] = completionModel.items;517assert.strictEqual(first.textLabel, 'filterBankSize');518assert.strictEqual(second.textLabel, 'filter');519520assert.deepStrictEqual(editor.getSelection(), new Selection(1, 6, 1, 6));521editor.trigger('keyboard', 'type', { text: 'r' }); // now filter "overtakes" filterBankSize because it is fully matched522assert.deepStrictEqual(editor.getSelection(), new Selection(1, 7, 1, 7));523524controller.acceptSelectedSuggestion(false, false);525assert.strictEqual(editor.getValue(), 'filter');526});527528test('Fast autocomple typing selects the previous autocomplete suggestion, #71795', async function () {529disposables.add(languageFeaturesService.completionProvider.register({ scheme: 'test-ctrl' }, {530_debugDisplayName: 'test',531provideCompletionItems(doc, pos) {532533const word = doc.getWordUntilPosition(pos);534const range = new Range(pos.lineNumber, word.startColumn, pos.lineNumber, word.endColumn);535536return {537suggestions: [{538kind: CompletionItemKind.Text,539label: 'false',540insertText: 'false',541range542}, {543kind: CompletionItemKind.Text,544label: 'float',545insertText: 'float',546range547}, {548kind: CompletionItemKind.Text,549label: 'for',550insertText: 'for',551range552}, {553kind: CompletionItemKind.Text,554label: 'foreach',555insertText: 'foreach',556range557}]558};559}560}));561562editor.setValue('f');563editor.setSelection(new Selection(1, 2, 1, 2));564565const p1 = Event.toPromise(controller.model.onDidSuggest);566controller.triggerSuggest();567568const { completionModel } = await p1;569assert.strictEqual(completionModel.items.length, 4);570571const [first, second, third, fourth] = completionModel.items;572assert.strictEqual(first.textLabel, 'false');573assert.strictEqual(second.textLabel, 'float');574assert.strictEqual(third.textLabel, 'for');575assert.strictEqual(fourth.textLabel, 'foreach');576577assert.deepStrictEqual(editor.getSelection(), new Selection(1, 2, 1, 2));578editor.trigger('keyboard', 'type', { text: 'o' }); // filters`false` and `float`579assert.deepStrictEqual(editor.getSelection(), new Selection(1, 3, 1, 3));580581controller.acceptSelectedSuggestion(false, false);582assert.strictEqual(editor.getValue(), 'for');583});584585test.skip('Suggest widget gets orphaned in editor #187779', async function () {586587disposables.add(languageFeaturesService.completionProvider.register({ scheme: 'test-ctrl' }, {588_debugDisplayName: 'test',589provideCompletionItems(doc, pos) {590591const word = doc.getLineContent(pos.lineNumber);592const range = new Range(pos.lineNumber, 1, pos.lineNumber, pos.column);593594return {595suggestions: [{596kind: CompletionItemKind.Text,597label: word,598insertText: word,599range600}]601};602}603}));604605editor.setValue(`console.log(example.)\nconsole.log(EXAMPLE.not)`);606editor.setSelection(new Selection(1, 21, 1, 21));607608const p1 = Event.toPromise(controller.model.onDidSuggest);609controller.triggerSuggest();610611await p1;612613const p2 = Event.toPromise(controller.model.onDidCancel);614new DeleteLinesAction().run(null!, editor);615616await p2;617});618619test('Ranges where additionalTextEdits are applied are not appropriate when characters are typed #177591', async function () {620disposables.add(languageFeaturesService.completionProvider.register({ scheme: 'test-ctrl' }, {621_debugDisplayName: 'test',622provideCompletionItems(doc, pos) {623return {624suggestions: [{625kind: CompletionItemKind.Snippet,626label: 'aaa',627insertText: 'aaa',628range: Range.fromPositions(pos),629additionalTextEdits: [{630range: Range.fromPositions(pos.delta(0, 10)),631text: 'aaa'632}]633}]634};635}636}));637638{ // PART1 - no typing639editor.setValue(`123456789123456789`);640editor.setSelection(new Selection(1, 1, 1, 1));641const p1 = Event.toPromise(controller.model.onDidSuggest);642controller.triggerSuggest();643644const e = await p1;645assert.strictEqual(e.completionModel.items.length, 1);646assert.strictEqual(e.completionModel.items[0].textLabel, 'aaa');647648controller.acceptSelectedSuggestion(false, false);649650assert.strictEqual(editor.getValue(), 'aaa1234567891aaa23456789');651}652653{ // PART2 - typing654editor.setValue(`123456789123456789`);655editor.setSelection(new Selection(1, 1, 1, 1));656const p1 = Event.toPromise(controller.model.onDidSuggest);657controller.triggerSuggest();658659const e = await p1;660assert.strictEqual(e.completionModel.items.length, 1);661assert.strictEqual(e.completionModel.items[0].textLabel, 'aaa');662663editor.trigger('keyboard', 'type', { text: 'aa' });664665controller.acceptSelectedSuggestion(false, false);666667assert.strictEqual(editor.getValue(), 'aaa1234567891aaa23456789');668}669});670671test.skip('[Bug] "No suggestions" persists while typing if the completion helper is set to return an empty list for empty content#3557', async function () {672let requestCount = 0;673674disposables.add(languageFeaturesService.completionProvider.register({ scheme: 'test-ctrl' }, {675_debugDisplayName: 'test',676provideCompletionItems(doc, pos) {677requestCount += 1;678679if (requestCount === 1) {680return undefined;681}682683return {684suggestions: [{685kind: CompletionItemKind.Text,686label: 'foo',687insertText: 'foo',688range: new Range(pos.lineNumber, 1, pos.lineNumber, pos.column)689}],690};691}692}));693694const p1 = Event.toPromise(controller.model.onDidSuggest);695controller.triggerSuggest();696697const e1 = await p1;698assert.strictEqual(e1.completionModel.items.length, 0);699assert.strictEqual(requestCount, 1);700701const p2 = Event.toPromise(controller.model.onDidSuggest);702editor.trigger('keyboard', 'type', { text: 'f' });703704const e2 = await p2;705assert.strictEqual(e2.completionModel.items.length, 1);706assert.strictEqual(requestCount, 2);707708});709});710711712