Implements tests for inline completions.
This commit is contained in:
parent
1142237a7c
commit
1b15230de9
6 changed files with 749 additions and 10 deletions
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
103
src/vs/editor/contrib/inlineCompletions/test/utils.ts
Normal file
103
src/vs/editor/contrib/inlineCompletions/test/utils.ts
Normal 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 });
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue