Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/inlineEdits/test/common/userInteractionMonitor.spec.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { beforeEach, describe, expect, test } from 'vitest';
7
import { ConfigKey } from '../../../../platform/configuration/common/configurationService';
8
import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';
9
import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';
10
import { AggressivenessLevel, DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION, UserHappinessScoreConfiguration } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions';
11
import { ILogService } from '../../../../platform/log/common/logService';
12
import { IExperimentationService, NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';
13
import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';
14
import { ITelemetryService, TelemetryEventMeasurements, TelemetryEventProperties } from '../../../../platform/telemetry/common/telemetry';
15
import { TestLogService } from '../../../../platform/testing/common/testLogService';
16
import { ActionKind, MAX_INTERACTIONS_CONSIDERED, MAX_INTERACTIONS_STORED, UserInteractionMonitor } from '../../common/userInteractionMonitor';
17
18
19
/**
20
* Test-friendly subclass of UserInteractionMonitor that exposes internal state for verification.
21
*/
22
class TestUserInteractionMonitor extends UserInteractionMonitor {
23
/**
24
* Get a copy of the recent user actions for aggressiveness calculation.
25
*/
26
getActionsForAggressiveness(): { time: number; kind: ActionKind }[] {
27
// Access private field through type assertion
28
return [...this._recentUserActionsForAggressiveness];
29
}
30
31
/**
32
* Get a copy of the recent user actions for timing calculation.
33
*/
34
getActionsForTiming(): { time: number; kind: ActionKind.Accepted | ActionKind.Rejected }[] {
35
return [...this._recentUserActionsForTiming];
36
}
37
38
/**
39
* Get the parsed user happiness score configuration.
40
*/
41
getUserHappinessScoreConfiguration(): UserHappinessScoreConfiguration {
42
return this._getUserHappinessScoreConfiguration();
43
}
44
}
45
46
/**
47
* Mock configuration service that allows setting specific config values for testing.
48
*/
49
class MockConfigurationService extends InMemoryConfigurationService {
50
constructor() {
51
super(new DefaultsOnlyConfigurationService());
52
}
53
}
54
55
interface TelemetryCall {
56
eventName: string;
57
properties?: TelemetryEventProperties;
58
measurements?: TelemetryEventMeasurements;
59
}
60
61
/**
62
* Mock telemetry service that records telemetry events for verification.
63
*/
64
class MockTelemetryService extends NullTelemetryService {
65
readonly msftEvents: TelemetryCall[] = [];
66
67
override sendMSFTTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void {
68
this.msftEvents.push({ eventName, properties, measurements });
69
}
70
71
reset(): void {
72
this.msftEvents.length = 0;
73
}
74
}
75
76
describe('UserInteractionMonitor', () => {
77
let configurationService: MockConfigurationService;
78
let experimentationService: IExperimentationService;
79
let logService: ILogService;
80
let telemetryService: ITelemetryService;
81
let monitor: TestUserInteractionMonitor;
82
83
beforeEach(() => {
84
configurationService = new MockConfigurationService();
85
experimentationService = new NullExperimentationService();
86
logService = new TestLogService();
87
telemetryService = new NullTelemetryService();
88
monitor = new TestUserInteractionMonitor(configurationService, experimentationService, logService, telemetryService);
89
});
90
91
describe('history logging', () => {
92
test('handleAcceptance logs accepted action to both histories', () => {
93
monitor.handleAcceptance();
94
95
const aggressivenessActions = monitor.getActionsForAggressiveness();
96
const timingActions = monitor.getActionsForTiming();
97
98
expect(aggressivenessActions).toHaveLength(1);
99
expect(aggressivenessActions[0].kind).toBe(ActionKind.Accepted);
100
101
expect(timingActions).toHaveLength(1);
102
expect(timingActions[0].kind).toBe(ActionKind.Accepted);
103
});
104
105
test('handleRejection logs rejected action to both histories', () => {
106
monitor.handleRejection();
107
108
const aggressivenessActions = monitor.getActionsForAggressiveness();
109
const timingActions = monitor.getActionsForTiming();
110
111
expect(aggressivenessActions).toHaveLength(1);
112
expect(aggressivenessActions[0].kind).toBe(ActionKind.Rejected);
113
114
expect(timingActions).toHaveLength(1);
115
expect(timingActions[0].kind).toBe(ActionKind.Rejected);
116
});
117
118
test('handleIgnored logs only to aggressiveness history, not timing', () => {
119
monitor.handleIgnored();
120
121
const aggressivenessActions = monitor.getActionsForAggressiveness();
122
const timingActions = monitor.getActionsForTiming();
123
124
expect(aggressivenessActions).toHaveLength(1);
125
expect(aggressivenessActions[0].kind).toBe(ActionKind.Ignored);
126
127
// Ignored actions should NOT be recorded for timing
128
expect(timingActions).toHaveLength(0);
129
});
130
131
test('actions are recorded with timestamps', () => {
132
const beforeTime = Date.now();
133
monitor.handleAcceptance();
134
const afterTime = Date.now();
135
136
const actions = monitor.getActionsForAggressiveness();
137
expect(actions[0].time).toBeGreaterThanOrEqual(beforeTime);
138
expect(actions[0].time).toBeLessThanOrEqual(afterTime);
139
});
140
141
test('multiple actions are recorded in order', () => {
142
monitor.handleAcceptance();
143
monitor.handleRejection();
144
monitor.handleIgnored();
145
monitor.handleAcceptance();
146
147
const aggressivenessActions = monitor.getActionsForAggressiveness();
148
expect(aggressivenessActions).toHaveLength(4);
149
expect(aggressivenessActions[0].kind).toBe(ActionKind.Accepted);
150
expect(aggressivenessActions[1].kind).toBe(ActionKind.Rejected);
151
expect(aggressivenessActions[2].kind).toBe(ActionKind.Ignored);
152
expect(aggressivenessActions[3].kind).toBe(ActionKind.Accepted);
153
154
// Timing history should only have accepts and rejects
155
const timingActions = monitor.getActionsForTiming();
156
expect(timingActions).toHaveLength(3);
157
expect(timingActions[0].kind).toBe(ActionKind.Accepted);
158
expect(timingActions[1].kind).toBe(ActionKind.Rejected);
159
expect(timingActions[2].kind).toBe(ActionKind.Accepted);
160
});
161
162
test('aggressiveness history is limited to MAX_INTERACTIONS_STORED', () => {
163
// Record more than max actions
164
for (let i = 0; i < MAX_INTERACTIONS_STORED + 5; i++) {
165
monitor.handleAcceptance();
166
}
167
168
const actions = monitor.getActionsForAggressiveness();
169
expect(actions).toHaveLength(MAX_INTERACTIONS_STORED);
170
});
171
172
test('timing history is limited to MAX_INTERACTIONS_CONSIDERED', () => {
173
// Record more than max actions
174
for (let i = 0; i < MAX_INTERACTIONS_CONSIDERED + 5; i++) {
175
monitor.handleAcceptance();
176
}
177
178
const actions = monitor.getActionsForTiming();
179
expect(actions).toHaveLength(MAX_INTERACTIONS_CONSIDERED);
180
});
181
182
test('timing history does not include "ignored" events', () => {
183
// The timing history only contains accepts/rejects
184
// The aggressiveness history contains all actions
185
// They should be independent
186
187
monitor.handleIgnored();
188
monitor.handleIgnored();
189
monitor.handleIgnored();
190
191
// Timing history should be empty
192
expect(monitor.getActionsForTiming()).toHaveLength(0);
193
194
// Aggressiveness history should have 3 ignored
195
expect(monitor.getActionsForAggressiveness()).toHaveLength(3);
196
});
197
});
198
199
describe('aggressiveness level calculation', () => {
200
test('returns neutral aggressiveness with no history', () => {
201
// With no data, score is 0.5, which is between low and medium thresholds for the default config
202
const level = monitor.getAggressivenessLevel().aggressivenessLevel;
203
expect(level).toBe(AggressivenessLevel.Medium);
204
});
205
206
test('returns high aggressiveness after many acceptances', () => {
207
// Fill with 10 acceptances
208
for (let i = 0; i < 10; i++) {
209
monitor.handleAcceptance();
210
}
211
212
const level = monitor.getAggressivenessLevel().aggressivenessLevel;
213
expect(level).toBe(AggressivenessLevel.High);
214
});
215
216
test('returns low aggressiveness after many rejections', () => {
217
// Fill with 10 rejections
218
for (let i = 0; i < 10; i++) {
219
monitor.handleRejection();
220
}
221
222
const level = monitor.getAggressivenessLevel().aggressivenessLevel;
223
expect(level).toBe(AggressivenessLevel.Low);
224
});
225
226
test('respects configured aggressiveness level override', () => {
227
configurationService.setConfig(
228
ConfigKey.TeamInternal.InlineEditsXtabAggressivenessLevel,
229
AggressivenessLevel.Low
230
);
231
232
// Even with many acceptances, should return configured level
233
for (let i = 0; i < 10; i++) {
234
monitor.handleAcceptance();
235
}
236
237
const level = monitor.getAggressivenessLevel().aggressivenessLevel;
238
expect(level).toBe(AggressivenessLevel.Low);
239
});
240
241
test('recent actions have more weight than older ones', () => {
242
// Start with acceptances, end with rejections
243
for (let i = 0; i < 5; i++) {
244
monitor.handleAcceptance();
245
}
246
for (let i = 0; i < 5; i++) {
247
monitor.handleRejection();
248
}
249
250
const levelRejectionsRecent = monitor.getAggressivenessLevel().aggressivenessLevel;
251
252
// Reset and do opposite order
253
monitor = new TestUserInteractionMonitor(configurationService, experimentationService, logService, telemetryService);
254
for (let i = 0; i < 5; i++) {
255
monitor.handleRejection();
256
}
257
for (let i = 0; i < 5; i++) {
258
monitor.handleAcceptance();
259
}
260
261
const levelAcceptancesRecent = monitor.getAggressivenessLevel().aggressivenessLevel;
262
263
// When acceptances are more recent, aggressiveness should be higher
264
const aggressivenessOrder = [AggressivenessLevel.Low, AggressivenessLevel.Medium, AggressivenessLevel.High];
265
expect(aggressivenessOrder.indexOf(levelAcceptancesRecent)).toBeGreaterThanOrEqual(
266
aggressivenessOrder.indexOf(levelRejectionsRecent)
267
);
268
});
269
});
270
271
describe('ignored action limiting', () => {
272
test('ignored actions are included in aggressiveness calculation', () => {
273
// With custom config that includes ignored actions
274
const customConfig: UserHappinessScoreConfiguration = {
275
...DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION,
276
includeIgnored: true,
277
limitTotalIgnored: false,
278
limitConsecutiveIgnored: false,
279
};
280
configurationService.setConfig(
281
ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString,
282
JSON.stringify(customConfig)
283
);
284
285
// Mix of actions
286
monitor.handleAcceptance();
287
monitor.handleIgnored();
288
monitor.handleIgnored();
289
monitor.handleRejection();
290
291
const level = monitor.getAggressivenessLevel().aggressivenessLevel;
292
// With ignored having score 0.5, result should be medium
293
expect(level).toBe(AggressivenessLevel.Medium);
294
});
295
296
test('total ignored limit is respected', () => {
297
const customConfig: UserHappinessScoreConfiguration = {
298
...DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION,
299
includeIgnored: true,
300
limitTotalIgnored: true,
301
limitConsecutiveIgnored: false,
302
ignoredLimit: 2,
303
};
304
configurationService.setConfig(
305
ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString,
306
JSON.stringify(customConfig)
307
);
308
309
// Add many ignored actions scattered between accepts
310
monitor.handleIgnored();
311
monitor.handleAcceptance();
312
monitor.handleIgnored();
313
monitor.handleAcceptance();
314
monitor.handleIgnored();
315
monitor.handleIgnored();
316
monitor.handleIgnored();
317
318
// Only 2 ignored should be counted due to limit
319
const level = monitor.getAggressivenessLevel().aggressivenessLevel;
320
expect([AggressivenessLevel.Medium, AggressivenessLevel.High]).toContain(level);
321
});
322
});
323
324
describe('config parse error telemetry', () => {
325
let mockTelemetryService: MockTelemetryService;
326
327
beforeEach(() => {
328
mockTelemetryService = new MockTelemetryService();
329
monitor = new TestUserInteractionMonitor(configurationService, experimentationService, logService, mockTelemetryService);
330
});
331
332
test('emits telemetry event when config is invalid JSON', () => {
333
configurationService.setConfig(
334
ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString,
335
'not valid json'
336
);
337
338
monitor.getAggressivenessLevel();
339
340
expect(mockTelemetryService.msftEvents).toHaveLength(1);
341
expect(mockTelemetryService.msftEvents[0].eventName).toBe('incorrectNesAdaptiveAggressivenessConfig');
342
expect(mockTelemetryService.msftEvents[0].properties).toMatchObject({
343
configName: ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString.id,
344
configValue: 'not valid json',
345
});
346
expect(mockTelemetryService.msftEvents[0].properties?.errorMessage).toBeDefined();
347
});
348
349
test('emits telemetry event when config has missing required fields', () => {
350
// Missing ignoredLimit and other required fields
351
const incompleteConfig = JSON.stringify({
352
acceptedScore: 1,
353
rejectedScore: 0,
354
});
355
configurationService.setConfig(
356
ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString,
357
incompleteConfig
358
);
359
360
monitor.getAggressivenessLevel();
361
362
expect(mockTelemetryService.msftEvents).toHaveLength(1);
363
expect(mockTelemetryService.msftEvents[0].eventName).toBe('incorrectNesAdaptiveAggressivenessConfig');
364
expect(mockTelemetryService.msftEvents[0].properties?.configValue).toBe(incompleteConfig);
365
});
366
367
test('emits telemetry event when config has invalid score relationships', () => {
368
// acceptedScore must be greater than rejectedScore
369
const invalidConfig = JSON.stringify({
370
acceptedScore: 0.3,
371
rejectedScore: 0.7,
372
ignoredScore: 0.5,
373
highThreshold: 0.7,
374
mediumThreshold: 0.4,
375
includeIgnored: false,
376
ignoredLimit: 0,
377
limitConsecutiveIgnored: false,
378
limitTotalIgnored: true,
379
});
380
configurationService.setConfig(
381
ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString,
382
invalidConfig
383
);
384
385
monitor.getAggressivenessLevel();
386
387
expect(mockTelemetryService.msftEvents).toHaveLength(1);
388
expect(mockTelemetryService.msftEvents[0].eventName).toBe('incorrectNesAdaptiveAggressivenessConfig');
389
expect(mockTelemetryService.msftEvents[0].properties?.errorMessage).toContain('acceptedScore must be greater than rejectedScore');
390
});
391
392
test('returns default config when parse fails', () => {
393
configurationService.setConfig(
394
ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString,
395
'invalid'
396
);
397
398
// Get the config that was parsed (should fall back to default)
399
const parsedConfig = monitor.getUserHappinessScoreConfiguration();
400
401
// Should be exactly equal to the default config
402
expect(parsedConfig).toEqual(DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION);
403
});
404
405
test('does not emit telemetry for valid config', () => {
406
const validConfig: UserHappinessScoreConfiguration = {
407
...DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION,
408
acceptedScore: 0.9,
409
rejectedScore: 0.1,
410
};
411
configurationService.setConfig(
412
ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString,
413
JSON.stringify(validConfig)
414
);
415
416
// Get the config that was parsed
417
const parsedConfig = monitor.getUserHappinessScoreConfiguration();
418
419
// Should be exactly equal to the custom config (not the default)
420
expect(parsedConfig).toEqual(validConfig);
421
expect(parsedConfig).not.toEqual(DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION);
422
423
// No telemetry should be emitted
424
expect(mockTelemetryService.msftEvents).toHaveLength(0);
425
});
426
});
427
});
428
429