Implements tests for inline completions.

This commit is contained in:
Henning Dieterichs 2021-06-09 11:36:36 +02:00
parent 1142237a7c
commit 1b15230de9
No known key found for this signature in database
GPG key ID: 771381EFFDB9EC06
6 changed files with 749 additions and 10 deletions

View file

@ -28,8 +28,8 @@ export class GhostTextController extends Disposable {
return editor.getContribution<GhostTextController>(GhostTextController.ID);
}
private readonly widget: GhostTextWidget;
private readonly activeController = this._register(new MutableDisposable<ActiveGhostTextController>());
protected readonly widget: GhostTextWidget;
protected readonly activeController = this._register(new MutableDisposable<ActiveGhostTextController>());
private readonly contextKeys: GhostTextContextKeys;
private triggeredExplicitly = false;
@ -142,7 +142,7 @@ export class ActiveGhostTextController extends Disposable {
private readonly suggestWidgetAdapterModel = this._register(new SuggestWidgetAdapterModel(this.editor));
private readonly inlineCompletionsModel = this._register(new InlineCompletionsModel(this.editor, this.commandService));
private get activeInlineCompletionsModel(): InlineCompletionsModel | undefined {
public get activeInlineCompletionsModel(): InlineCompletionsModel | undefined {
if (this.widget.model === this.inlineCompletionsModel) {
return this.inlineCompletionsModel;
}
@ -210,7 +210,7 @@ export class ActiveGhostTextController extends Disposable {
}
public triggerInlineCompletion(): void {
this.activeInlineCompletionsModel?.startSession();
this.activeInlineCompletionsModel?.trigger();
}
public commitInlineCompletion(): void {

View file

@ -84,6 +84,13 @@ export class GhostTextWidget extends Disposable {
private viewZoneId: string | null = null;
private viewMoreContentWidget: ViewMoreLinesContentWidget | null = null;
private readonly onWillRenderEmitter = new Emitter<void>();
/**
* @deprecated Only use this for testing. This will get removed after the GhostTextController is refactored to use a single model.
*/
public readonly onWillRender = this.onWillRenderEmitter.event;
constructor(
private readonly editor: ICodeEditor,
@ICodeEditorService private readonly _codeEditorService: ICodeEditorService,
@ -147,6 +154,8 @@ export class GhostTextWidget extends Disposable {
}
private render(): void {
this.onWillRenderEmitter.fire();
const renderData = this.getRenderData();
if (this.codeEditorDecorationTypeKey) {

View file

@ -26,13 +26,13 @@ export class InlineCompletionsModel extends Disposable implements GhostTextWidge
protected readonly onDidChangeEmitter = new Emitter<void>();
public readonly onDidChange = this.onDidChangeEmitter.event;
private readonly completionSession = this._register(new MutableDisposable<InlineCompletionsSession>());
public readonly completionSession = this._register(new MutableDisposable<InlineCompletionsSession>());
private active: boolean = false;
constructor(
private readonly editor: IActiveCodeEditor,
private readonly commandService: ICommandService
@ICommandService private readonly commandService: ICommandService
) {
super();
@ -108,10 +108,10 @@ export class InlineCompletionsModel extends Disposable implements GhostTextWidge
return;
}
this.startSession();
this.trigger();
}
public startSession(): void {
public trigger(): void {
if (this.completionSession.value) {
return;
}
@ -142,13 +142,13 @@ export class InlineCompletionsModel extends Disposable implements GhostTextWidge
}
}
class InlineCompletionsSession extends BaseGhostTextWidgetModel {
export class InlineCompletionsSession extends BaseGhostTextWidgetModel {
public readonly minReservedLineCount = 0;
private readonly updateOperation = this._register(new MutableDisposable<UpdateOperation>());
private readonly cache = this._register(new MutableDisposable<SynchronizedInlineCompletionsCache>());
private updateSoon = this._register(new RunOnceScheduler(() => this.update(InlineCompletionTriggerKind.Automatic), 50));
private readonly updateSoon = this._register(new RunOnceScheduler(() => this.update(InlineCompletionTriggerKind.Automatic), 50));
private readonly textModel = this.editor.getModel();
constructor(
@ -345,6 +345,11 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
}
public commitCurrentCompletion(): void {
if (!this.ghostText) {
// No ghost text was shown for this completion.
// Thus, we don't want to commit anything.
return;
}
const completion = this.currentCompletion;
if (completion) {
this.commit(completion);

View file

@ -0,0 +1,461 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import { timeout } from 'vs/base/common/async';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { CoreEditingCommands } from 'vs/editor/browser/controller/coreCommands';
import { Range } from 'vs/editor/common/core/range';
import { InlineCompletionsProvider, InlineCompletionsProviderRegistry } from 'vs/editor/common/modes';
import { ViewModel } from 'vs/editor/common/viewModel/viewModelImpl';
import { GhostTextController } from 'vs/editor/contrib/inlineCompletions/ghostTextController';
import { GhostTextWidget } from 'vs/editor/contrib/inlineCompletions/ghostTextWidget';
import { InlineCompletionsModel, InlineCompletionsSession, inlineCompletionToGhostText } from 'vs/editor/contrib/inlineCompletions/inlineCompletionsModel';
import { GhostTextContext, MockInlineCompletionsProvider } from 'vs/editor/contrib/inlineCompletions/test/utils';
import { ITestCodeEditor, TestCodeEditorCreationOptions, withAsyncTestCodeEditor } from 'vs/editor/test/browser/testCodeEditor';
import { createTextModel } from 'vs/editor/test/common/editorTestUtils';
import sinon = require('sinon');
test('inlineCompletionToGhostText', function () {
function getOutput(text: string, suggestion: string): unknown {
const range = new Range(1, text.indexOf('[') + 1, 1, text.indexOf(']'));
const tempModel = createTextModel(text.replace('[', '').replace(']', ''));
const ghostText = inlineCompletionToGhostText({ text: suggestion, range }, tempModel);
if (!ghostText) {
return undefined;
}
return {
text: ghostText.lines.join('\n'),
column: ghostText.position.column,
};
}
assert.deepStrictEqual(getOutput('[foo]baz', 'foobar'), { text: 'bar', column: 4 });
assert.deepStrictEqual(getOutput('[foo]baz', 'boobar'), undefined);
// Empty ghost text
assert.deepStrictEqual(getOutput('[foo]', 'foo'), { text: '', column: 4 });
// Whitespace (in indentation)
assert.deepStrictEqual(getOutput('[ foo]', 'foobar'), { text: 'bar', column: 5 });
assert.deepStrictEqual(getOutput('[\tfoo]', 'foobar'), { text: 'bar', column: 5 });
assert.deepStrictEqual(getOutput('[\t foo]', '\tfoobar'), { text: 'bar', column: 6 });
assert.deepStrictEqual(getOutput('[\tfoo]', '\t\tfoobar'), { text: 'bar', column: 5 });
assert.deepStrictEqual(getOutput('[\t]', '\t\tfoobar'), { text: '\tfoobar', column: 2 });
// (outside of indentation)
assert.deepStrictEqual(getOutput('bar[ foo]', 'foobar'), undefined);
assert.deepStrictEqual(getOutput('bar[\tfoo]', 'foobar'), undefined);
});
test('Does not trigger automatically by default', async function () {
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider },
async ({ editor, editorViewModel, model, context }) => {
model.setActive(true);
context.keyboardType('foo');
await timeout(1000);
// Provider is not called, no ghost text is shown.
assert.deepStrictEqual(provider.getAndClearCallHistory(), []);
assert.deepStrictEqual(context.getAndClearViewStates(), ['']);
}
);
});
test('Ghost text is shown after trigger', async function () {
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider },
async ({ editor, editorViewModel, model, context }) => {
model.setActive(true);
context.keyboardType('foo');
provider.setReturnValue({ text: 'foobar', range: new Range(1, 1, 1, 4) });
model.trigger();
await timeout(1000);
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,4)', text: 'foo', triggerKind: 0, }
]);
assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'foo[bar]']);
}
);
});
test('Ghost text is shown automatically when configured', async function () {
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider, inlineSuggest: { enabled: true } },
async ({ editor, editorViewModel, model, context }) => {
model.setActive(true);
context.keyboardType('foo');
provider.setReturnValue({ text: 'foobar', range: new Range(1, 1, 1, 4) });
await timeout(1000);
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,4)', text: 'foo', triggerKind: 0, }
]);
assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'foo[bar]']);
}
);
});
test('Ghost text is updated automatically', async function () {
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider },
async ({ editor, editorViewModel, model, context }) => {
model.setActive(true);
context.keyboardType('foo');
provider.setReturnValue({ text: 'foobar', range: new Range(1, 1, 1, 4) });
model.trigger();
await timeout(1000);
provider.setReturnValue({ text: 'foobizz', range: new Range(1, 1, 1, 6) });
context.keyboardType('bi');
await timeout(1000);
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,4)', text: 'foo', triggerKind: 0, },
{ position: '(1,6)', text: 'foobi', triggerKind: 0, }
]);
assert.deepStrictEqual(
context.getAndClearViewStates(),
['', 'foo[bar]', 'foob[ar]', 'foobi', 'foobi[zz]']
);
}
);
});
test('Unindent whitespace', async function () {
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider },
async ({ editor, editorViewModel, model, context }) => {
model.setActive(true);
context.keyboardType(' ');
provider.setReturnValue({ text: 'foo', range: new Range(1, 2, 1, 3) });
model.trigger();
await timeout(1000);
assert.deepStrictEqual(context.getAndClearViewStates(), ['', ' [foo]']);
model.commitCurrentSuggestion();
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,3)', text: ' ', triggerKind: 0, },
]);
assert.deepStrictEqual(context.getAndClearViewStates(), [' foo']);
}
);
});
test('Unindent tab', async function () {
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider },
async ({ editor, editorViewModel, model, context }) => {
model.setActive(true);
context.keyboardType('\t\t');
provider.setReturnValue({ text: 'foo', range: new Range(1, 2, 1, 3) });
model.trigger();
await timeout(1000);
assert.deepStrictEqual(context.getAndClearViewStates(), ['', '\t\t[foo]']);
model.commitCurrentSuggestion();
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,3)', text: '\t\t', triggerKind: 0, },
]);
assert.deepStrictEqual(context.getAndClearViewStates(), ['\tfoo']);
}
);
});
test('No unindent after indentation', async function () {
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider },
async ({ editor, editorViewModel, model, context }) => {
model.setActive(true);
context.keyboardType('buzz ');
provider.setReturnValue({ text: 'foo', range: new Range(1, 6, 1, 7) });
model.trigger();
await timeout(1000);
assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'buzz ']);
model.commitCurrentSuggestion();
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,7)', text: 'buzz ', triggerKind: 0, },
]);
assert.deepStrictEqual(context.getAndClearViewStates(), []);
}
);
});
test('Next/previous', async function () {
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider },
async ({ editor, editorViewModel, model, context }) => {
model.setActive(true);
context.keyboardType('foo');
provider.setReturnValue({ text: 'foobar1', range: new Range(1, 1, 1, 4) });
model.trigger();
await timeout(1000);
assert.deepStrictEqual(
context.getAndClearViewStates(),
['', 'foo[bar1]']
);
provider.setReturnValues([
{ text: 'foobar1', range: new Range(1, 1, 1, 4) },
{ text: 'foobizz2', range: new Range(1, 1, 1, 4) },
{ text: 'foobuzz3', range: new Range(1, 1, 1, 4) }
]);
model.showNext();
await timeout(1000);
assert.deepStrictEqual(context.getAndClearViewStates(), ['foo[bizz2]']);
model.showNext();
await timeout(1000);
assert.deepStrictEqual(context.getAndClearViewStates(), ['foo[buzz3]']);
model.showNext();
await timeout(1000);
assert.deepStrictEqual(context.getAndClearViewStates(), ['foo[bar1]']);
model.showPrevious();
await timeout(1000);
assert.deepStrictEqual(context.getAndClearViewStates(), ['foo[buzz3]']);
model.showPrevious();
await timeout(1000);
assert.deepStrictEqual(context.getAndClearViewStates(), ['foo[bizz2]']);
model.showPrevious();
await timeout(1000);
assert.deepStrictEqual(context.getAndClearViewStates(), ['foo[bar1]']);
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,4)', text: 'foo', triggerKind: 0, },
{ position: '(1,4)', text: 'foo', triggerKind: 1, },
]);
}
);
});
test('Calling the provider is debounced', async function () {
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider },
async ({ editor, editorViewModel, model, context }) => {
model.setActive(true);
model.trigger();
context.keyboardType('f');
await timeout(40);
context.keyboardType('o');
await timeout(40);
context.keyboardType('o');
await timeout(40);
// The provider is not called
assert.deepStrictEqual(provider.getAndClearCallHistory(), []);
await timeout(400);
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,4)', text: 'foo', triggerKind: 0, }
]);
}
);
});
test('Forward stability', async function () {
// The user types the text as suggested and the provider is forward-stable
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider },
async ({ editor, editorViewModel, model, context }) => {
model.setActive(true);
provider.setReturnValue({ text: 'foobar', range: new Range(1, 1, 1, 4) });
context.keyboardType('foo');
model.trigger();
await timeout(1000);
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,4)', text: 'foo', triggerKind: 0, }
]);
assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'foo[bar]']);
provider.setReturnValue({ text: 'foobar', range: new Range(1, 1, 1, 5) });
context.keyboardType('b');
assert.deepStrictEqual(context.currentPrettyViewState, 'foob[ar]');
await timeout(1000);
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,5)', text: 'foob', triggerKind: 0, }
]);
assert.deepStrictEqual(context.getAndClearViewStates(), ['foob[ar]']);
provider.setReturnValue({ text: 'foobar', range: new Range(1, 1, 1, 6) });
context.keyboardType('a');
assert.deepStrictEqual(context.currentPrettyViewState, 'fooba[r]');
await timeout(1000);
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,6)', text: 'fooba', triggerKind: 0, }
]);
assert.deepStrictEqual(context.getAndClearViewStates(), ['fooba[r]']);
}
);
});
test('Support forward instability', async function () {
// The user types the text as suggested and the provider reports a different suggestion.
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider },
async ({ editor, editorViewModel, model, context }) => {
model.setActive(true);
provider.setReturnValue({ text: 'foobar', range: new Range(1, 1, 1, 4) });
context.keyboardType('foo');
model.trigger();
await timeout(100);
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,4)', text: 'foo', triggerKind: 0, }
]);
assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'foo[bar]']);
provider.setReturnValue({ text: 'foobaz', range: new Range(1, 1, 1, 5) });
context.keyboardType('b');
assert.deepStrictEqual(context.currentPrettyViewState, 'foob[ar]');
await timeout(100);
// This behavior might change!
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,5)', text: 'foob', triggerKind: 0, }
]);
assert.deepStrictEqual(context.getAndClearViewStates(), ['foob[ar]', 'foob[az]']);
}
);
});
test('Support backward instability', async function () {
// The user deletes text and the suggestion changes
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider },
async ({ editor, editorViewModel, model, context }) => {
model.setActive(true);
context.keyboardType('fooba');
provider.setReturnValue({ text: 'foobar', range: new Range(1, 1, 1, 6) });
model.trigger();
await timeout(1000);
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,6)', text: 'fooba', triggerKind: 0, }
]);
assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'fooba[r]']);
provider.setReturnValue({ text: 'foobaz', range: new Range(1, 1, 1, 5) });
CoreEditingCommands.DeleteLeft.runEditorCommand(null, editor, null);
await timeout(1000);
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
{ position: '(1,5)', text: 'foob', triggerKind: 0, }
]);
assert.deepStrictEqual(context.getAndClearViewStates(), ['foob[ar]', 'foob[az]']);
}
);
});
test('No race conditions', async function () {
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider, },
async ({ editor, editorViewModel, model, context }) => {
model.setActive(true);
context.keyboardType('h');
provider.setReturnValue({ text: 'helloworld', range: new Range(1, 1, 1, 2) }, 1000);
model.trigger();
await timeout(1030);
context.keyboardType('ello');
provider.setReturnValue({ text: 'helloworld', range: new Range(1, 1, 1, 6) }, 1000);
// after 20ms: Inline completion provider answers back
// after 50ms: Debounce is triggered
await timeout(2000);
assert.deepStrictEqual(context.getAndClearViewStates(), [
'',
'hello[world]',
]);
});
});
async function withAsyncTestCodeEditorAndInlineCompletionsModel(
text: string,
options: TestCodeEditorCreationOptions & { provider?: InlineCompletionsProvider, fakeClock?: boolean },
callback: (args: { editor: ITestCodeEditor, editorViewModel: ViewModel, model: InlineCompletionsModel, context: GhostTextContext }) => Promise<void>
): Promise<void> {
const disposableStore = new DisposableStore();
if (options.provider) {
const d = InlineCompletionsProviderRegistry.register({ pattern: '**' }, options.provider);
disposableStore.add(d);
}
let clock: sinon.SinonFakeTimers | undefined;
if (options.fakeClock) {
clock = sinon.useFakeTimers();
}
try {
const p = clock?.runAllAsync();
await withAsyncTestCodeEditor(text, options, async (editor, editorViewModel, instantiationService) => {
const model = instantiationService.createInstance(InlineCompletionsModel, editor);
const context = new GhostTextContext(model, editor);
await callback({ editor, editorViewModel, model, context });
});
await p;
} finally {
clock?.restore();
disposableStore.dispose();
}
}
export class MockedGhostTextController extends GhostTextController {
public getInlineCompletionSession(): InlineCompletionsSession | undefined {
return this.activeController.value?.activeInlineCompletionsModel?.completionSession.value;
}
public get ghostTextWidget(): GhostTextWidget {
return this.widget;
}
}

View file

@ -0,0 +1,161 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { SuggestWidgetAdapterModel } from 'vs/editor/contrib/inlineCompletions/suggestWidgetAdapterModel';
import { SuggestController } from 'vs/editor/contrib/suggest/suggestController';
import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2';
import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection';
import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtils';
import { ILogService, NullLogService } from 'vs/platform/log/common/log';
import { InMemoryStorageService, IStorageService } from 'vs/platform/storage/common/storage';
import { MockKeybindingService } from 'vs/platform/keybinding/test/common/mockKeybindingService';
import { mock } from 'vs/base/test/common/mock';
import { IEditorWorkerService } from 'vs/editor/common/services/editorWorkerService';
import { ISuggestMemoryService } from 'vs/editor/contrib/suggest/suggestMemory';
import { IMenuService, IMenu } from 'vs/platform/actions/common/actions';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import sinon = require('sinon');
import { timeout } from 'vs/base/common/async';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { CompletionItemKind, CompletionItemProvider, CompletionProviderRegistry } from 'vs/editor/common/modes';
import { ViewModel } from 'vs/editor/common/viewModel/viewModelImpl';
import { TestCodeEditorCreationOptions, ITestCodeEditor, withAsyncTestCodeEditor } from 'vs/editor/test/browser/testCodeEditor';
import { Event } from 'vs/base/common/event';
import assert = require('assert');
import { GhostTextContext } from 'vs/editor/contrib/inlineCompletions/test/utils';
import { Range } from 'vs/editor/common/core/range';
test('Active', async () => {
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider, },
async ({ editor, editorViewModel, context, model }) => {
let last: boolean | undefined = undefined;
const history = new Array<boolean>();
model.onDidChange(() => {
if (last !== model.isActive) {
last = model.isActive;
history.push(last);
}
});
context.keyboardType('h');
const suggestController = (editor.getContribution(SuggestController.ID) as SuggestController);
suggestController.triggerSuggest();
await timeout(1000);
assert.deepStrictEqual(history.splice(0), [true]);
context.keyboardType('.');
await timeout(1000);
// No flicker here
assert.deepStrictEqual(history.splice(0), []);
suggestController.cancelSuggestWidget();
await timeout(1000);
assert.deepStrictEqual(history.splice(0), [false]);
}
);
});
test('Ghost Text', async () => {
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider, suggest: { preview: true } },
async ({ editor, editorViewModel, context, model }) => {
context.keyboardType('h');
const suggestController = (editor.getContribution(SuggestController.ID) as SuggestController);
suggestController.triggerSuggest();
await timeout(1000);
assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'h[ello]']);
context.keyboardType('.');
await timeout(1000);
assert.deepStrictEqual(context.getAndClearViewStates(), ['hello', 'hello.[hello]']);
suggestController.cancelSuggestWidget();
await timeout(1000);
assert.deepStrictEqual(context.getAndClearViewStates(), ['hello.']);
}
);
});
const provider: CompletionItemProvider = {
triggerCharacters: ['.'],
async provideCompletionItems(model, pos) {
const word = model.getWordAtPosition(pos);
const range = word
? { startLineNumber: 1, startColumn: word.startColumn, endLineNumber: 1, endColumn: word.endColumn }
: Range.fromPositions(pos);
return {
suggestions: [{
insertText: 'hello',
kind: CompletionItemKind.Text,
label: 'hello',
range,
commitCharacters: ['.'],
}]
};
},
};
async function withAsyncTestCodeEditorAndInlineCompletionsModel(
text: string,
options: TestCodeEditorCreationOptions & { provider?: CompletionItemProvider, fakeClock?: boolean, serviceCollection?: never },
callback: (args: { editor: ITestCodeEditor, editorViewModel: ViewModel, model: SuggestWidgetAdapterModel, context: GhostTextContext }) => Promise<void>
): Promise<void> {
const serviceCollection = new ServiceCollection(
[ITelemetryService, NullTelemetryService],
[ILogService, new NullLogService()],
[IStorageService, new InMemoryStorageService()],
[IKeybindingService, new MockKeybindingService()],
[IEditorWorkerService, new class extends mock<IEditorWorkerService>() {
override computeWordRanges() {
return Promise.resolve({});
}
}],
[ISuggestMemoryService, new class extends mock<ISuggestMemoryService>() {
override memorize(): void { }
override select(): number { return 0; }
}],
[IMenuService, new class extends mock<IMenuService>() {
override createMenu() {
return new class extends mock<IMenu>() {
override onDidChange = Event.None;
override dispose() { }
};
}
}]
);
const disposableStore = new DisposableStore();
if (options.provider) {
const d = CompletionProviderRegistry.register({ pattern: '**' }, options.provider);
disposableStore.add(d);
}
let clock: sinon.SinonFakeTimers | undefined;
if (options.fakeClock) {
clock = sinon.useFakeTimers();
}
try {
const p = clock?.runAllAsync();
await withAsyncTestCodeEditor(text, { ...options, serviceCollection }, async (editor, editorViewModel, instantiationService) => {
editor.registerAndInstantiateContribution(SnippetController2.ID, SnippetController2);
editor.registerAndInstantiateContribution(SuggestController.ID, SuggestController);
const model = instantiationService.createInstance(SuggestWidgetAdapterModel, editor);
const context = new GhostTextContext(model, editor);
await callback({ editor, editorViewModel, model, context });
});
await p;
} finally {
clock?.restore();
disposableStore.dispose();
}
}

View file

@ -0,0 +1,103 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { timeout } from 'vs/base/common/async';
import { CancellationToken } from 'vs/base/common/cancellation';
import { Disposable } from 'vs/base/common/lifecycle';
import { Position } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { ITextModel } from 'vs/editor/common/model';
import { InlineCompletionsProvider, InlineCompletion, InlineCompletionContext } from 'vs/editor/common/modes';
import { GhostTextWidgetModel } from 'vs/editor/contrib/inlineCompletions/ghostTextWidget';
import { ITestCodeEditor } from 'vs/editor/test/browser/testCodeEditor';
import { createTextModel } from 'vs/editor/test/common/editorTestUtils';
export class MockInlineCompletionsProvider implements InlineCompletionsProvider {
private returnValue: InlineCompletion[] = [];
private delayMs: number = 0;
private callHistory = new Array<unknown>();
public setReturnValue(value: InlineCompletion | undefined, delayMs: number = 0): void {
this.returnValue = value ? [value] : [];
this.delayMs = delayMs;
}
public setReturnValues(values: InlineCompletion[], delayMs: number = 0): void {
this.returnValue = values;
this.delayMs = delayMs;
}
public getAndClearCallHistory() {
const history = [...this.callHistory];
this.callHistory = [];
return history;
}
async provideInlineCompletions(model: ITextModel, position: Position, context: InlineCompletionContext, token: CancellationToken) {
this.callHistory.push({
position: position.toString(),
triggerKind: context.triggerKind,
text: model.getValue()
});
const result = new Array<InlineCompletion>();
result.push(...this.returnValue);
if (this.delayMs > 0) {
await timeout(this.delayMs);
}
return { items: result };
}
freeInlineCompletions() { }
handleItemDidShow() { }
}
export class GhostTextContext extends Disposable {
public readonly prettyViewStates = new Array<string | undefined>();
private _currentPrettyViewState: string | undefined;
public get currentPrettyViewState() {
return this._currentPrettyViewState;
}
constructor(private readonly model: GhostTextWidgetModel, private readonly editor: ITestCodeEditor) {
super();
this._register(
model.onDidChange(() => {
this.update();
})
);
this.update();
}
private update(): void {
const ghostText = this.model?.ghostText;
let view: string | undefined;
if (ghostText) {
const insertText = ghostText.lines.join('\n');
const tempModel = createTextModel(this.editor.getValue());
tempModel.applyEdits([{ range: Range.fromPositions(ghostText.position), text: `[${insertText}]` }]);
view = tempModel.getValue();
} else {
view = this.editor.getValue();
}
if (this._currentPrettyViewState !== view) {
this.prettyViewStates.push(view);
}
this._currentPrettyViewState = view;
}
public getAndClearViewStates(): (string | undefined)[] {
const arr = [...this.prettyViewStates];
this.prettyViewStates.length = 0;
return arr;
}
public keyboardType(text: string): void {
this.editor.trigger('keyboard', 'type', { text });
}
}