Path: blob/main/extensions/copilot/src/extension/inlineEdits/test/common/userInteractionMonitor.spec.ts
13405 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 { beforeEach, describe, expect, test } from 'vitest';6import { ConfigKey } from '../../../../platform/configuration/common/configurationService';7import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';8import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';9import { AggressivenessLevel, DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION, UserHappinessScoreConfiguration } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions';10import { ILogService } from '../../../../platform/log/common/logService';11import { IExperimentationService, NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';12import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';13import { ITelemetryService, TelemetryEventMeasurements, TelemetryEventProperties } from '../../../../platform/telemetry/common/telemetry';14import { TestLogService } from '../../../../platform/testing/common/testLogService';15import { ActionKind, MAX_INTERACTIONS_CONSIDERED, MAX_INTERACTIONS_STORED, UserInteractionMonitor } from '../../common/userInteractionMonitor';161718/**19* Test-friendly subclass of UserInteractionMonitor that exposes internal state for verification.20*/21class TestUserInteractionMonitor extends UserInteractionMonitor {22/**23* Get a copy of the recent user actions for aggressiveness calculation.24*/25getActionsForAggressiveness(): { time: number; kind: ActionKind }[] {26// Access private field through type assertion27return [...this._recentUserActionsForAggressiveness];28}2930/**31* Get a copy of the recent user actions for timing calculation.32*/33getActionsForTiming(): { time: number; kind: ActionKind.Accepted | ActionKind.Rejected }[] {34return [...this._recentUserActionsForTiming];35}3637/**38* Get the parsed user happiness score configuration.39*/40getUserHappinessScoreConfiguration(): UserHappinessScoreConfiguration {41return this._getUserHappinessScoreConfiguration();42}43}4445/**46* Mock configuration service that allows setting specific config values for testing.47*/48class MockConfigurationService extends InMemoryConfigurationService {49constructor() {50super(new DefaultsOnlyConfigurationService());51}52}5354interface TelemetryCall {55eventName: string;56properties?: TelemetryEventProperties;57measurements?: TelemetryEventMeasurements;58}5960/**61* Mock telemetry service that records telemetry events for verification.62*/63class MockTelemetryService extends NullTelemetryService {64readonly msftEvents: TelemetryCall[] = [];6566override sendMSFTTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void {67this.msftEvents.push({ eventName, properties, measurements });68}6970reset(): void {71this.msftEvents.length = 0;72}73}7475describe('UserInteractionMonitor', () => {76let configurationService: MockConfigurationService;77let experimentationService: IExperimentationService;78let logService: ILogService;79let telemetryService: ITelemetryService;80let monitor: TestUserInteractionMonitor;8182beforeEach(() => {83configurationService = new MockConfigurationService();84experimentationService = new NullExperimentationService();85logService = new TestLogService();86telemetryService = new NullTelemetryService();87monitor = new TestUserInteractionMonitor(configurationService, experimentationService, logService, telemetryService);88});8990describe('history logging', () => {91test('handleAcceptance logs accepted action to both histories', () => {92monitor.handleAcceptance();9394const aggressivenessActions = monitor.getActionsForAggressiveness();95const timingActions = monitor.getActionsForTiming();9697expect(aggressivenessActions).toHaveLength(1);98expect(aggressivenessActions[0].kind).toBe(ActionKind.Accepted);99100expect(timingActions).toHaveLength(1);101expect(timingActions[0].kind).toBe(ActionKind.Accepted);102});103104test('handleRejection logs rejected action to both histories', () => {105monitor.handleRejection();106107const aggressivenessActions = monitor.getActionsForAggressiveness();108const timingActions = monitor.getActionsForTiming();109110expect(aggressivenessActions).toHaveLength(1);111expect(aggressivenessActions[0].kind).toBe(ActionKind.Rejected);112113expect(timingActions).toHaveLength(1);114expect(timingActions[0].kind).toBe(ActionKind.Rejected);115});116117test('handleIgnored logs only to aggressiveness history, not timing', () => {118monitor.handleIgnored();119120const aggressivenessActions = monitor.getActionsForAggressiveness();121const timingActions = monitor.getActionsForTiming();122123expect(aggressivenessActions).toHaveLength(1);124expect(aggressivenessActions[0].kind).toBe(ActionKind.Ignored);125126// Ignored actions should NOT be recorded for timing127expect(timingActions).toHaveLength(0);128});129130test('actions are recorded with timestamps', () => {131const beforeTime = Date.now();132monitor.handleAcceptance();133const afterTime = Date.now();134135const actions = monitor.getActionsForAggressiveness();136expect(actions[0].time).toBeGreaterThanOrEqual(beforeTime);137expect(actions[0].time).toBeLessThanOrEqual(afterTime);138});139140test('multiple actions are recorded in order', () => {141monitor.handleAcceptance();142monitor.handleRejection();143monitor.handleIgnored();144monitor.handleAcceptance();145146const aggressivenessActions = monitor.getActionsForAggressiveness();147expect(aggressivenessActions).toHaveLength(4);148expect(aggressivenessActions[0].kind).toBe(ActionKind.Accepted);149expect(aggressivenessActions[1].kind).toBe(ActionKind.Rejected);150expect(aggressivenessActions[2].kind).toBe(ActionKind.Ignored);151expect(aggressivenessActions[3].kind).toBe(ActionKind.Accepted);152153// Timing history should only have accepts and rejects154const timingActions = monitor.getActionsForTiming();155expect(timingActions).toHaveLength(3);156expect(timingActions[0].kind).toBe(ActionKind.Accepted);157expect(timingActions[1].kind).toBe(ActionKind.Rejected);158expect(timingActions[2].kind).toBe(ActionKind.Accepted);159});160161test('aggressiveness history is limited to MAX_INTERACTIONS_STORED', () => {162// Record more than max actions163for (let i = 0; i < MAX_INTERACTIONS_STORED + 5; i++) {164monitor.handleAcceptance();165}166167const actions = monitor.getActionsForAggressiveness();168expect(actions).toHaveLength(MAX_INTERACTIONS_STORED);169});170171test('timing history is limited to MAX_INTERACTIONS_CONSIDERED', () => {172// Record more than max actions173for (let i = 0; i < MAX_INTERACTIONS_CONSIDERED + 5; i++) {174monitor.handleAcceptance();175}176177const actions = monitor.getActionsForTiming();178expect(actions).toHaveLength(MAX_INTERACTIONS_CONSIDERED);179});180181test('timing history does not include "ignored" events', () => {182// The timing history only contains accepts/rejects183// The aggressiveness history contains all actions184// They should be independent185186monitor.handleIgnored();187monitor.handleIgnored();188monitor.handleIgnored();189190// Timing history should be empty191expect(monitor.getActionsForTiming()).toHaveLength(0);192193// Aggressiveness history should have 3 ignored194expect(monitor.getActionsForAggressiveness()).toHaveLength(3);195});196});197198describe('aggressiveness level calculation', () => {199test('returns neutral aggressiveness with no history', () => {200// With no data, score is 0.5, which is between low and medium thresholds for the default config201const level = monitor.getAggressivenessLevel().aggressivenessLevel;202expect(level).toBe(AggressivenessLevel.Medium);203});204205test('returns high aggressiveness after many acceptances', () => {206// Fill with 10 acceptances207for (let i = 0; i < 10; i++) {208monitor.handleAcceptance();209}210211const level = monitor.getAggressivenessLevel().aggressivenessLevel;212expect(level).toBe(AggressivenessLevel.High);213});214215test('returns low aggressiveness after many rejections', () => {216// Fill with 10 rejections217for (let i = 0; i < 10; i++) {218monitor.handleRejection();219}220221const level = monitor.getAggressivenessLevel().aggressivenessLevel;222expect(level).toBe(AggressivenessLevel.Low);223});224225test('respects configured aggressiveness level override', () => {226configurationService.setConfig(227ConfigKey.TeamInternal.InlineEditsXtabAggressivenessLevel,228AggressivenessLevel.Low229);230231// Even with many acceptances, should return configured level232for (let i = 0; i < 10; i++) {233monitor.handleAcceptance();234}235236const level = monitor.getAggressivenessLevel().aggressivenessLevel;237expect(level).toBe(AggressivenessLevel.Low);238});239240test('recent actions have more weight than older ones', () => {241// Start with acceptances, end with rejections242for (let i = 0; i < 5; i++) {243monitor.handleAcceptance();244}245for (let i = 0; i < 5; i++) {246monitor.handleRejection();247}248249const levelRejectionsRecent = monitor.getAggressivenessLevel().aggressivenessLevel;250251// Reset and do opposite order252monitor = new TestUserInteractionMonitor(configurationService, experimentationService, logService, telemetryService);253for (let i = 0; i < 5; i++) {254monitor.handleRejection();255}256for (let i = 0; i < 5; i++) {257monitor.handleAcceptance();258}259260const levelAcceptancesRecent = monitor.getAggressivenessLevel().aggressivenessLevel;261262// When acceptances are more recent, aggressiveness should be higher263const aggressivenessOrder = [AggressivenessLevel.Low, AggressivenessLevel.Medium, AggressivenessLevel.High];264expect(aggressivenessOrder.indexOf(levelAcceptancesRecent)).toBeGreaterThanOrEqual(265aggressivenessOrder.indexOf(levelRejectionsRecent)266);267});268});269270describe('ignored action limiting', () => {271test('ignored actions are included in aggressiveness calculation', () => {272// With custom config that includes ignored actions273const customConfig: UserHappinessScoreConfiguration = {274...DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION,275includeIgnored: true,276limitTotalIgnored: false,277limitConsecutiveIgnored: false,278};279configurationService.setConfig(280ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString,281JSON.stringify(customConfig)282);283284// Mix of actions285monitor.handleAcceptance();286monitor.handleIgnored();287monitor.handleIgnored();288monitor.handleRejection();289290const level = monitor.getAggressivenessLevel().aggressivenessLevel;291// With ignored having score 0.5, result should be medium292expect(level).toBe(AggressivenessLevel.Medium);293});294295test('total ignored limit is respected', () => {296const customConfig: UserHappinessScoreConfiguration = {297...DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION,298includeIgnored: true,299limitTotalIgnored: true,300limitConsecutiveIgnored: false,301ignoredLimit: 2,302};303configurationService.setConfig(304ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString,305JSON.stringify(customConfig)306);307308// Add many ignored actions scattered between accepts309monitor.handleIgnored();310monitor.handleAcceptance();311monitor.handleIgnored();312monitor.handleAcceptance();313monitor.handleIgnored();314monitor.handleIgnored();315monitor.handleIgnored();316317// Only 2 ignored should be counted due to limit318const level = monitor.getAggressivenessLevel().aggressivenessLevel;319expect([AggressivenessLevel.Medium, AggressivenessLevel.High]).toContain(level);320});321});322323describe('config parse error telemetry', () => {324let mockTelemetryService: MockTelemetryService;325326beforeEach(() => {327mockTelemetryService = new MockTelemetryService();328monitor = new TestUserInteractionMonitor(configurationService, experimentationService, logService, mockTelemetryService);329});330331test('emits telemetry event when config is invalid JSON', () => {332configurationService.setConfig(333ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString,334'not valid json'335);336337monitor.getAggressivenessLevel();338339expect(mockTelemetryService.msftEvents).toHaveLength(1);340expect(mockTelemetryService.msftEvents[0].eventName).toBe('incorrectNesAdaptiveAggressivenessConfig');341expect(mockTelemetryService.msftEvents[0].properties).toMatchObject({342configName: ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString.id,343configValue: 'not valid json',344});345expect(mockTelemetryService.msftEvents[0].properties?.errorMessage).toBeDefined();346});347348test('emits telemetry event when config has missing required fields', () => {349// Missing ignoredLimit and other required fields350const incompleteConfig = JSON.stringify({351acceptedScore: 1,352rejectedScore: 0,353});354configurationService.setConfig(355ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString,356incompleteConfig357);358359monitor.getAggressivenessLevel();360361expect(mockTelemetryService.msftEvents).toHaveLength(1);362expect(mockTelemetryService.msftEvents[0].eventName).toBe('incorrectNesAdaptiveAggressivenessConfig');363expect(mockTelemetryService.msftEvents[0].properties?.configValue).toBe(incompleteConfig);364});365366test('emits telemetry event when config has invalid score relationships', () => {367// acceptedScore must be greater than rejectedScore368const invalidConfig = JSON.stringify({369acceptedScore: 0.3,370rejectedScore: 0.7,371ignoredScore: 0.5,372highThreshold: 0.7,373mediumThreshold: 0.4,374includeIgnored: false,375ignoredLimit: 0,376limitConsecutiveIgnored: false,377limitTotalIgnored: true,378});379configurationService.setConfig(380ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString,381invalidConfig382);383384monitor.getAggressivenessLevel();385386expect(mockTelemetryService.msftEvents).toHaveLength(1);387expect(mockTelemetryService.msftEvents[0].eventName).toBe('incorrectNesAdaptiveAggressivenessConfig');388expect(mockTelemetryService.msftEvents[0].properties?.errorMessage).toContain('acceptedScore must be greater than rejectedScore');389});390391test('returns default config when parse fails', () => {392configurationService.setConfig(393ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString,394'invalid'395);396397// Get the config that was parsed (should fall back to default)398const parsedConfig = monitor.getUserHappinessScoreConfiguration();399400// Should be exactly equal to the default config401expect(parsedConfig).toEqual(DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION);402});403404test('does not emit telemetry for valid config', () => {405const validConfig: UserHappinessScoreConfiguration = {406...DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION,407acceptedScore: 0.9,408rejectedScore: 0.1,409};410configurationService.setConfig(411ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString,412JSON.stringify(validConfig)413);414415// Get the config that was parsed416const parsedConfig = monitor.getUserHappinessScoreConfiguration();417418// Should be exactly equal to the custom config (not the default)419expect(parsedConfig).toEqual(validConfig);420expect(parsedConfig).not.toEqual(DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION);421422// No telemetry should be emitted423expect(mockTelemetryService.msftEvents).toHaveLength(0);424});425});426});427428429