Path: blob/main/extensions/copilot/src/extension/test/node/utils.spec.ts
13399 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 * as assert from 'assert';6import { expect, suite, test } from 'vitest';7import { EditSurvivalTracker, applyEditsToRanges, compute4GramTextSimilarity } from '../../../platform/editSurvivalTracking/common/editSurvivalTracker';8import { ISerializedStringEdit, StringEdit, StringReplacement } from '../../../util/vs/editor/common/core/edits/stringEdit';9import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange';1011suite('OffsetEdit.join', () => {12for (let seed = 0; seed < 50; seed++) {13test('test' + seed, () => {14runTest(seed);15});16}17});1819function runTest(seed: number) {20const rng = new MersenneTwister(seed);2122const s0 = 'abcde\nfghij\nklmno\npqrst\n';2324const edits1 = getRandomEdits(s0, rng.nextIntRange(1, 4), rng);25const s1 = edits1.apply(s0);2627const edits2 = getRandomEdits(s1, rng.nextIntRange(1, 4), rng);28const s2 = edits2.apply(s1);2930const combinedEdits = edits1.compose(edits2);31const s2C = combinedEdits.apply(s0);3233assert.strictEqual(s2C, s2);34}3536function getRandomEdits(str: string, count: number, rng: MersenneTwister): StringEdit {37const edits: StringReplacement[] = [];38let i = 0;39for (let j = 0; j < count; j++) {40if (i >= str.length) {41break;42}43edits.push(getRandomEdit(str, i, rng));44i = edits[j].replaceRange.endExclusive + 1;45}46return new StringEdit(edits);47}4849function getRandomEdit(str: string, rangeOffsetStart: number, rng: MersenneTwister): StringReplacement {50const offsetStart = rng.nextIntRange(rangeOffsetStart, str.length);51const offsetEnd = rng.nextIntRange(offsetStart, str.length);5253const textStart = rng.nextIntRange(0, str.length);54const textLen = rng.nextIntRange(0, Math.min(7, str.length - textStart));5556return new StringReplacement(57new OffsetRange(offsetStart, offsetEnd),58str.substring(textStart, textStart + textLen)59);60}6162// Generated by copilot63class MersenneTwister {64private readonly mt = new Array(624);65private index = 0;6667constructor(seed: number) {68this.mt[0] = seed >>> 0;69for (let i = 1; i < 624; i++) {70const s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30);71this.mt[i] = (((((s & 0xffff0000) >>> 16) * 0x6c078965) << 16) + (s & 0x0000ffff) * 0x6c078965 + i) >>> 0;72}73}7475public nextInt() {76if (this.index === 0) {77this.generateNumbers();78}7980let y = this.mt[this.index];81y = y ^ (y >>> 11);82y = y ^ ((y << 7) & 0x9d2c5680);83y = y ^ ((y << 15) & 0xefc60000);84y = y ^ (y >>> 18);8586this.index = (this.index + 1) % 624;8788return y >>> 0;89}9091public nextIntRange(start: number, endExclusive: number) {92const range = endExclusive - start;93return Math.floor(this.nextInt() / (0x100000000 / range)) + start;94}9596private generateNumbers() {97for (let i = 0; i < 624; i++) {98const y = (this.mt[i] & 0x80000000) + (this.mt[(i + 1) % 624] & 0x7fffffff);99this.mt[i] = this.mt[(i + 397) % 624] ^ (y >>> 1);100if ((y % 2) !== 0) {101this.mt[i] = this.mt[i] ^ 0x9908b0df;102}103}104}105}106107const loremIpsum = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi.`;108109function getRandomString(rng: MersenneTwister): string {110let result = '';111for (let i = 0; i < 4; i++) {112const start = rng.nextIntRange(0, loremIpsum.length);113const end = rng.nextIntRange(start, loremIpsum.length);114result += loremIpsum.substring(start, end);115}116return result;117}118119suite('applyEditsToRanges', () => {120test('edit after ranges', () => {121const ranges = [122new OffsetRange(10, 20),123new OffsetRange(30, 40),124new OffsetRange(50, 60),125];126127const edits = new StringEdit([128new StringReplacement(new OffsetRange(100, 110), 'abc'),129]);130131const newRanges = applyEditsToRanges(ranges, edits);132assert.deepStrictEqual(newRanges.map(r => r.toString()), [133'[10, 20)',134'[30, 40)',135'[50, 60)',136]);137});138139test('edit before ranges', () => {140const ranges = [141new OffsetRange(10, 20),142new OffsetRange(30, 40),143new OffsetRange(50, 60),144];145146const edits = new StringEdit([147new StringReplacement(new OffsetRange(5, 6), 'abc'),148]);149150const newRanges = applyEditsToRanges(ranges, edits);151assert.deepStrictEqual(newRanges.map(r => r.toString()), [152'[12, 22)',153'[32, 42)',154'[52, 62)',155]);156});157158test('edit in range', () => {159const ranges = [160new OffsetRange(10, 20),161new OffsetRange(30, 40),162new OffsetRange(50, 60),163];164165const edits = new StringEdit([166new StringReplacement(new OffsetRange(11, 19), 'x'),167]);168169const newRanges = applyEditsToRanges(ranges, edits);170assert.deepStrictEqual(newRanges.map(r => r.toString()), [171'[10, 13)',172'[23, 33)',173'[43, 53)',174]);175});176177test('edit in multiple ranges', () => {178const ranges = [179new OffsetRange(10, 20),180new OffsetRange(30, 40),181new OffsetRange(50, 60),182];183184const edits = new StringEdit([185new StringReplacement(new OffsetRange(15, 55), 'x'),186]);187188const newRanges = applyEditsToRanges(ranges, edits);189assert.deepStrictEqual(newRanges.map(r => r.toString()), [190'[10, 16)',191'[16, 16)',192'[16, 21)',193]);194});195196test('edit in multiple ranges 2', () => {197const ranges = [198new OffsetRange(10, 20),199new OffsetRange(30, 40),200new OffsetRange(50, 60),201];202203const edits = new StringEdit([204new StringReplacement(new OffsetRange(15, 55), 'x'),205new StringReplacement(new OffsetRange(58, 59), 'yy'),206]);207208const newRanges = applyEditsToRanges(ranges, edits);209assert.deepStrictEqual(newRanges.map(r => r.toString()), [210'[10, 16)',211'[16, 16)',212'[16, 22)',213]);214});215216test('touching edit', () => {217const ranges = [218new OffsetRange(10, 20),219new OffsetRange(30, 40),220new OffsetRange(50, 60),221];222223const edits = new StringEdit([224new StringReplacement(new OffsetRange(40, 40), 'x'),225new StringReplacement(new OffsetRange(50, 50), 'x'),226]);227228const newRanges = applyEditsToRanges(ranges, edits);229assert.deepStrictEqual(newRanges.map(r => r.toString()), [230'[10, 20)',231'[30, 41)',232'[51, 62)'233]);234});235});236237238function projectableValue_editable<T>(arg: T): T {239return arg;240}241242243suite('compute4GramTextSimilarity', () => {244for (let seed = 0; seed < 50; seed++) {245test('test' + seed, () => {246runTest(seed);247});248}249250function runTest(seed: number) {251const rng = new MersenneTwister(seed);252253const s1 = getRandomString(rng);254const s2 = getRandomString(rng);255256const similarity = compute4GramTextSimilarity(s1, s2);257258assert.ok(similarity >= 0 && similarity <= 1, `similarity should be between 0 and 1, but was ${similarity}`);259}260});261262suite('EditSurvivalTracker', () => {263function renameProps<T extends object>(obj: T, map: { [K in keyof T]?: string }): any {264const result: any = {};265for (const key of Object.keys(obj) as (keyof T)[]) {266const newKey = map[key] || key;267result[newKey] = obj[key];268}269return result;270}271272function getScore(input: { text: string; edits: ISerializedStringEdit[] }): unknown {273const originalText = input.text;274const t = new EditSurvivalTracker(originalText, StringEdit.fromJson(input.edits[0]));275t.handleEdits(StringEdit.fromJson(input.edits[1]));276const score = t.computeTrackedEditsSurvivalScore();277return renameProps(score, {278textBeforeAiEdits: 'text1BeforeAiEdits',279textAfterAiEdits: 'text2AfterAiEdits',280textAfterUserEdits: 'text3AfterUserEdits',281});282}283284test('simple', async () => {285expect(286getScore(projectableValue_editable({287'text': 'console.log(123456);',288'edits': [289[290{291'pos': 12,292'len': 6,293'txt': `'hello'`294}295],296[297{298'pos': 12,299'len': 7,300'txt': `'Hello'`301}302]303],304'x-editor': 'edit-editor'305})),306).toMatchInlineSnapshot(`307{308"fourGram": 0.5,309"noRevert": 1,310"text1BeforeAiEdits": [311"123456",312],313"text2AfterAiEdits": [314"'hello'",315],316"text3AfterUserEdits": [317"'Hello'",318],319}320`);321});322323test('multi edit', async () => {324expect(325getScore(projectableValue_editable({326'text': 'console.log(123456);',327'edits': [328[329{330'pos': 0,331'len': 0,332'txt': '// comment\\n'333},334{335'pos': 12,336'len': 6,337'txt': `'hello'`338}339],340[341{342'pos': 0,343'len': 2,344'txt': '/*'345},346{347'pos': 10,348'len': 2,349'txt': ' */'350},351{352'pos': 25,353'len': 7,354'txt': 'Hello'355}356]357],358'x-editor': 'edit-editor'359})),360).toMatchInlineSnapshot(`361{362"fourGram": 0.4376731301939058,363"noRevert": 1,364"text1BeforeAiEdits": [365"",366"123456",367],368"text2AfterAiEdits": [369"// comment\\n",370"'hello'",371],372"text3AfterUserEdits": [373"/* comment */",374"'Hello",375],376}377`);378});379380test('realistic example', async () => {381expect(382getScore(projectableValue_editable({383'text': `import {\r\n\tTextDocument,\r\n\tWebviewPanel,\r\n\tCancellationToken,\r\n\tworkspace,\r\n\tWorkspaceEdit,\r\n\tRange,\r\n\tCustomTextEditorProvider,\r\n} from "vscode";\r\nimport { WebviewInitializer } from "./WebviewInitializer";\r\n\r\ninterface EditableDocument {\r\n\t"x-editable"?: {\r\n\t\tkind: string;\r\n\t\tdefaultUrl: string;\r\n\t};\r\n}\r\n\r\nexport class TextEditorProvider implements CustomTextEditorProvider {\r\n\tconstructor(private readonly webviewInitializer: WebviewInitializer) {}\r\n\r\n\tpublic async resolveCustomTextEditor(\r\n\t\tdocument: TextDocument,\r\n\t\twebviewPanel: WebviewPanel,\r\n\t\ttoken: CancellationToken\r\n\t): Promise<void> {\r\n\t\tlet isThisEditorSaving = false;\r\n\r\n\t\tconst text = document.getText();\r\n\t\tconst doc = JSON.parse(text) as EditableDocument;\r\n\t\tconst args = doc["x-editable"];\r\n\r\n\r\n\t\tconst bridge = this.webviewInitializer.setupWebview(\r\n\t\t\t{ editorUrl: args.defaultUrl },\r\n\t\t\twebviewPanel.webview\r\n\t\t);\r\n\r\n\t\tconst setContentFromDocument = () => {\r\n\t\t\tconst newText = document.getText();\r\n\t\t\tconst content = JSON.parse(newText);\r\n\t\t\tbridge.setContent(content);\r\n\t\t};\r\n\r\n\t\tworkspace.onDidChangeTextDocument(async (evt) => {\r\n\t\t\tif (evt.document !== document) {\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\t\t\tif (isThisEditorSaving) {\r\n\t\t\t\t// We don't want to integrate our own changes\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\t\t\tif (evt.contentChanges.length === 0) {\r\n\t\t\t\t// Sometimes VS Code reports a document change without a change.\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\tsetContentFromDocument();\r\n\t\t});\r\n\r\n\t\tbridge.onChange.sub(async ({ newContent }) => {\r\n\t\t\tconst workspaceEdit = new WorkspaceEdit();\r\n\t\t\tconst data = newContent as EditableDocument;\r\n\t\t\tif (!data['x-editable']) {\r\n\t\t\t\tdata['x-editable'] = args;\r\n\t\t\t}\r\n\t\t\tconst output = JSON.stringify(newContent, undefined, 4);\r\n\t\t\tworkspaceEdit.replace(\r\n\t\t\t\tdocument.uri,\r\n\t\t\t\tnew Range(0, 0, document.lineCount, 0),\r\n\t\t\t\toutput\r\n\t\t\t);\r\n\r\n\t\t\tisThisEditorSaving = true;\r\n\t\t\ttry {\r\n\t\t\t\tawait workspace.applyEdit(workspaceEdit);\r\n\t\t\t} finally {\r\n\t\t\t\tisThisEditorSaving = false;\r\n\t\t\t}\r\n\t\t});\r\n\r\n\t\tbridge.onInit.sub(() => {\r\n\t\t\tsetContentFromDocument();\r\n\t\t});\r\n\t}\r\n}\r\n`,384'edits': [385[386{387'pos': 762,388'len': 2,389'txt': '\r\n\r\n\t\tif (!args) {\r\n\t\t\tthrow new Error("invalid json document!");\r\n\t\t}'390}391],392[393{394'pos': 801,395'len': 24,396'txt': '""'397}398]399],400'x-editor': 'edit-editor'401}))).toMatchInlineSnapshot(402`403{404"fourGram": 0.75,405"noRevert": 1,406"text1BeforeAiEdits": [407"408",409],410"text2AfterAiEdits": [411"412413if (!args) {414throw new Error("invalid json document!");415}",416],417"text3AfterUserEdits": [418"419420if (!args) {421throw new Error("");422}",423],424}425`);426});427});428429suite('OffsetEdits', () => {430suite('removeCommonSuffixPrefix', () => {431test('simple', () => {432const str = 'abcde';433434const e = new StringEdit([435new StringReplacement(new OffsetRange(0, 2), 'ax'),436new StringReplacement(new OffsetRange(2, 5), 'cye'),437]);438439const e2 = e.removeCommonSuffixPrefix(str);440441assert.deepStrictEqual(e2.apply(str), e.apply(str));442assert.deepStrictEqual(e2.toString(), '[[1, 2) -> "x", [3, 4) -> "y"]');443});444445for (let seed = 0; seed < 50; seed++) {446test('test' + seed, () => {447const rng = new MersenneTwister(seed);448449const s0 = loremIpsum;450451const edits = getRandomEdits(s0, rng.nextIntRange(1, 4), rng);452const edits2 = edits.removeCommonSuffixPrefix(s0);453454assert.deepStrictEqual(edits2.apply(s0), edits.apply(s0));455});456}457});458});459460461