Path: blob/main/src/vs/workbench/contrib/chat/test/browser/chatEditingTimeline.test.ts
3296 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';7import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js';8import { ChatEditingTimeline } from '../../browser/chatEditing/chatEditingTimeline.js';9import { IChatEditingSessionStop } from '../../browser/chatEditing/chatEditingSessionStorage.js';10import { transaction } from '../../../../../base/common/observable.js';11import { IChatRequestDisablement } from '../../common/chatModel.js';12import { ResourceMap } from '../../../../../base/common/map.js';13import { URI } from '../../../../../base/common/uri.js';14import { ISnapshotEntry } from '../../common/chatEditingService.js';1516suite('ChatEditingTimeline', () => {17const ds = ensureNoDisposablesAreLeakedInTestSuite();18let timeline: ChatEditingTimeline;1920setup(() => {21const instaService = workbenchInstantiationService(undefined, ds);22timeline = instaService.createInstance(ChatEditingTimeline);23});2425suite('undo/redo', () => {26test('undo/redo with empty history', () => {27assert.strictEqual(timeline.getUndoSnapshot(), undefined);28assert.strictEqual(timeline.getRedoSnapshot(), undefined);29assert.strictEqual(timeline.canRedo.get(), false);30assert.strictEqual(timeline.canUndo.get(), false);31});32});3334function createSnapshot(stopId: string | undefined, requestId = 'req1'): IChatEditingSessionStop {35return {36stopId,37entries: stopId === undefined ? new ResourceMap() : new ResourceMap([[38URI.file(`file:///path/to/${stopId}`),39{ requestId, current: `Content for ${stopId}` } as Partial<ISnapshotEntry> as ISnapshotEntry40]]),41};42}4344suite('Basic functionality', () => {45test('pushSnapshot and undo/redo navigation', () => {46// Push two snapshots47timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));48timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));4950// After two pushes, canUndo should be true, canRedo false51assert.strictEqual(timeline.canUndo.get(), true);52assert.strictEqual(timeline.canRedo.get(), false);5354// Undo should move back to stop155const undoSnap = timeline.getUndoSnapshot();56assert.ok(undoSnap);57assert.strictEqual(undoSnap.stop.stopId, 'stop1');58undoSnap.apply();59assert.strictEqual(timeline.canUndo.get(), false);60assert.strictEqual(timeline.canRedo.get(), true);6162// Redo should move forward to stop263const redoSnap = timeline.getRedoSnapshot();64assert.ok(redoSnap);65assert.strictEqual(redoSnap.stop.stopId, 'stop2');66redoSnap.apply();67assert.strictEqual(timeline.canUndo.get(), true);68assert.strictEqual(timeline.canRedo.get(), false);69});7071test('restoreFromState restores history and index', () => {72timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));73timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));74const state = timeline.getStateForPersistence();7576// Move back77timeline.getUndoSnapshot()?.apply();7879// Restore state80transaction(tx => timeline.restoreFromState(state, tx));81assert.strictEqual(timeline.canUndo.get(), true);82assert.strictEqual(timeline.canRedo.get(), false);83});8485test('getSnapshotForRestore returns correct snapshot', () => {86timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));87timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));8889const snap = timeline.getSnapshotForRestore('req1', 'stop1');90assert.ok(snap);91assert.strictEqual(snap.stop.stopId, 'stop1');92snap.apply();9394assert.strictEqual(timeline.canRedo.get(), true);95assert.strictEqual(timeline.canUndo.get(), false);9697const snap2 = timeline.getSnapshotForRestore('req1', 'stop2');98assert.ok(snap2);99assert.strictEqual(snap2.stop.stopId, 'stop2');100snap2.apply();101102assert.strictEqual(timeline.canRedo.get(), false);103assert.strictEqual(timeline.canUndo.get(), true);104});105106test('getRequestDisablement returns correct requests', () => {107timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));108timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2'));109110// Move back to first111timeline.getUndoSnapshot()?.apply();112113const disables = timeline.requestDisablement.get();114assert.ok(Array.isArray(disables));115assert.ok(disables.some(d => d.requestId === 'req2'));116});117});118119suite('Multiple requests', () => {120test('handles multiple requests with separate snapshots', () => {121timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));122timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2'));123timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3'));124125assert.strictEqual(timeline.canUndo.get(), true);126assert.strictEqual(timeline.canRedo.get(), false);127128// Undo should go back through requests129let undoSnap = timeline.getUndoSnapshot();130assert.ok(undoSnap);131assert.strictEqual(undoSnap.stop.stopId, 'stop2');132undoSnap.apply();133134undoSnap = timeline.getUndoSnapshot();135assert.ok(undoSnap);136assert.strictEqual(undoSnap.stop.stopId, 'stop1');137});138139test('handles same request with multiple stops', () => {140timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));141timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));142timeline.pushSnapshot('req1', 'stop3', createSnapshot('stop3'));143144const state = timeline.getStateForPersistence();145assert.strictEqual(state.history.length, 1);146assert.strictEqual(state.history[0].stops.length, 3);147assert.strictEqual(state.history[0].requestId, 'req1');148});149150test('mixed requests and stops', () => {151timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));152timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));153timeline.pushSnapshot('req2', 'stop3', createSnapshot('stop3', 'req2'));154timeline.pushSnapshot('req2', 'stop4', createSnapshot('stop4', 'req2'));155156const state = timeline.getStateForPersistence();157assert.strictEqual(state.history.length, 2);158assert.strictEqual(state.history[0].stops.length, 2);159assert.strictEqual(state.history[1].stops.length, 2);160});161});162163suite('Edge cases', () => {164test('getSnapshotForRestore with non-existent request', () => {165timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));166167const snap = timeline.getSnapshotForRestore('nonexistent', 'stop1');168assert.strictEqual(snap, undefined);169});170171test('getSnapshotForRestore with non-existent stop', () => {172timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));173174const snap = timeline.getSnapshotForRestore('req1', 'nonexistent');175assert.strictEqual(snap, undefined);176});177});178179suite('History manipulation', () => {180test('pushing snapshots after undo truncates future history', () => {181timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));182timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));183timeline.pushSnapshot('req1', 'stop3', createSnapshot('stop3'));184185// Undo twice186timeline.getUndoSnapshot()?.apply();187timeline.getUndoSnapshot()?.apply();188189// Push new snapshot - should truncate stop3190timeline.pushSnapshot('req1', 'new_stop', createSnapshot('new_stop'));191192const state = timeline.getStateForPersistence();193assert.strictEqual(state.history[0].stops.length, 2); // stop1 + new_stop194assert.strictEqual(state.history[0].stops[1].stopId, 'new_stop');195});196197test('branching from middle of history creates new branch', () => {198timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));199timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2'));200timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3'));201202// Undo to middle203timeline.getUndoSnapshot()?.apply();204205// Push new request206timeline.pushSnapshot('req4', 'stop4', createSnapshot('stop4'));207208const state = timeline.getStateForPersistence();209assert.strictEqual(state.history.length, 3); // req1, req2, req4210assert.strictEqual(state.history[2].requestId, 'req4');211});212});213214suite('State persistence', () => {215test('getStateForPersistence returns complete state', () => {216timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));217timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2'));218219const state = timeline.getStateForPersistence();220assert.ok(state.history);221assert.ok(typeof state.index === 'number');222assert.strictEqual(state.history.length, 2);223assert.strictEqual(state.index, 2);224});225226test('restoreFromState handles empty history', () => {227const emptyState = { history: [], index: 0 };228229transaction(tx => timeline.restoreFromState(emptyState, tx));230231assert.strictEqual(timeline.canUndo.get(), false);232assert.strictEqual(timeline.canRedo.get(), false);233});234235test('restoreFromState with complex history', () => {236// Create complex state237timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));238timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));239timeline.pushSnapshot('req2', 'stop3', createSnapshot('stop3', 'req2'));240241const originalState = timeline.getStateForPersistence();242243// Create new timeline and restore244const instaService = workbenchInstantiationService(undefined, ds);245const newTimeline = instaService.createInstance(ChatEditingTimeline);246transaction(tx => newTimeline.restoreFromState(originalState, tx));247248const restoredState = newTimeline.getStateForPersistence();249assert.deepStrictEqual(restoredState.index, originalState.index);250assert.strictEqual(restoredState.history.length, originalState.history.length);251});252});253254suite('Request disablement', () => {255test('getRequestDisablement at various positions', () => {256timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));257timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2'));258timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3'));259260// At end - no disabled requests261let disables = timeline.requestDisablement.get();262assert.strictEqual(disables.length, 0);263264// Move back one265timeline.getUndoSnapshot()?.apply();266disables = timeline.requestDisablement.get();267assert.strictEqual(disables.length, 1);268assert.strictEqual(disables[0].requestId, 'req3');269270// Move back to beginning271timeline.getUndoSnapshot()?.apply();272timeline.getUndoSnapshot()?.apply();273disables = timeline.requestDisablement.get();274assert.strictEqual(disables.length, 2);275});276277test('getRequestDisablement with mixed request/stop structure', () => {278timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));279timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));280timeline.pushSnapshot('req2', 'stop3', createSnapshot('stop3', 'req2'));281282// Move to middle of req1283timeline.getUndoSnapshot()?.apply();284timeline.getUndoSnapshot()?.apply();285286const disables = timeline.requestDisablement.get();287assert.strictEqual(disables.length, 2);288289// Should have partial disable for req1 and full disable for req2290const req1Disable = disables.find(d => d.requestId === 'req1');291const req2Disable = disables.find(d => d.requestId === 'req2');292293assert.ok(req1Disable);294assert.ok(req2Disable);295assert.ok(req1Disable.afterUndoStop);296assert.strictEqual(req2Disable.afterUndoStop, undefined);297});298});299300suite('Boundary conditions', () => {301test('undo/redo at boundaries', () => {302// Empty timeline303assert.strictEqual(timeline.getUndoSnapshot(), undefined);304assert.strictEqual(timeline.getRedoSnapshot(), undefined);305306// Single snapshot307timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));308timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));309assert.ok(timeline.getUndoSnapshot());310assert.strictEqual(timeline.getRedoSnapshot(), undefined);311312// At beginning after undo313timeline.getUndoSnapshot()?.apply();314assert.strictEqual(timeline.getUndoSnapshot(), undefined);315assert.ok(timeline.getRedoSnapshot());316});317318test('multiple undos and redos', () => {319timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));320timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2'));321timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3'));322323// Undo all324const stops: string[] = [];325let undoSnap = timeline.getUndoSnapshot();326while (undoSnap) {327stops.push(undoSnap.stop.stopId!);328undoSnap.apply();329undoSnap = timeline.getUndoSnapshot();330}331assert.deepStrictEqual(stops, ['stop2', 'stop1']);332333// Redo all334const redoStops: string[] = [];335let redoSnap = timeline.getRedoSnapshot();336while (redoSnap) {337redoStops.push(redoSnap.stop.stopId!);338redoSnap.apply();339redoSnap = timeline.getRedoSnapshot();340}341assert.deepStrictEqual(redoStops, ['stop2', 'stop3']);342});343344test('getRequestDisablement with root request ID', () => {345timeline.pushSnapshot('req1', undefined, createSnapshot(undefined));346timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));347timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));348349timeline.pushSnapshot('req2', undefined, createSnapshot(undefined, 'req2'));350timeline.pushSnapshot('req2', 'stop1-2', createSnapshot('stop1-2', 'req2'));351timeline.pushSnapshot('req2', 'stop2-2', createSnapshot('stop2-2', 'req2'));352353const expected: IChatRequestDisablement[][] = [354[{ requestId: 'req2', afterUndoStop: 'stop1-2' }],355[{ requestId: 'req2' }],356// stop2 is not in this because we're at stop2 when undoing req2357[{ requestId: 'req1', afterUndoStop: 'stop1' }, { requestId: 'req2' }],358[{ requestId: 'req1', afterUndoStop: undefined }, { requestId: 'req2' }],359];360361let ei = 0;362while (timeline.canUndo.get()) {363timeline.getUndoSnapshot()!.apply();364const actual = timeline.requestDisablement.get();365366assert.deepStrictEqual(actual, expected[ei++]);367}368369expected.unshift([]);370371while (timeline.canRedo.get()) {372timeline.getRedoSnapshot()!.apply();373const actual = timeline.requestDisablement.get();374assert.deepStrictEqual(actual, expected[--ei]);375}376});377});378379suite('Static methods', () => {380test('createEmptySnapshot creates valid snapshot', () => {381const snapshot = ChatEditingTimeline.createEmptySnapshot('test-stop');382assert.strictEqual(snapshot.stopId, 'test-stop');383assert.ok(snapshot.entries);384assert.strictEqual(snapshot.entries.size, 0);385});386387test('createEmptySnapshot with undefined stopId', () => {388const snapshot = ChatEditingTimeline.createEmptySnapshot(undefined);389assert.strictEqual(snapshot.stopId, undefined);390assert.ok(snapshot.entries);391});392393test('POST_EDIT_STOP_ID is consistent', () => {394assert.strictEqual(typeof ChatEditingTimeline.POST_EDIT_STOP_ID, 'string');395assert.ok(ChatEditingTimeline.POST_EDIT_STOP_ID.length > 0);396});397});398399suite('Observable behavior', () => {400test('canUndo observable updates correctly', () => {401assert.strictEqual(timeline.canUndo.get(), false);402403timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));404timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));405assert.strictEqual(timeline.canUndo.get(), true);406407timeline.getUndoSnapshot()?.apply();408assert.strictEqual(timeline.canUndo.get(), false);409});410411test('canRedo observable updates correctly', () => {412timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));413timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));414assert.strictEqual(timeline.canRedo.get(), false);415416timeline.getUndoSnapshot()?.apply();417assert.strictEqual(timeline.canRedo.get(), true);418419timeline.getRedoSnapshot()?.apply();420assert.strictEqual(timeline.canRedo.get(), false);421});422});423424suite('Complex scenarios', () => {425test('interleaved requests and undos', () => {426timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));427timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2'));428429// Undo req2430timeline.getUndoSnapshot()?.apply();431432// Add req3 (should branch from req1)433timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3'));434435const state = timeline.getStateForPersistence();436assert.strictEqual(state.history.length, 2); // req1, req3437assert.strictEqual(state.history[1].requestId, 'req3');438});439440test('large number of snapshots', () => {441// Push 100 snapshots442for (let i = 1; i <= 100; i++) {443timeline.pushSnapshot(`req${i}`, `stop${i}`, createSnapshot(`stop${i}`));444}445446assert.strictEqual(timeline.canUndo.get(), true);447assert.strictEqual(timeline.canRedo.get(), false);448449const state = timeline.getStateForPersistence();450assert.strictEqual(state.history.length, 100);451assert.strictEqual(state.index, 100);452});453454test('alternating single and multi-stop requests', () => {455// Single stop request456timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));457458// Multi-stop request459timeline.pushSnapshot('req2', 'stop2a', createSnapshot('stop2a', 'req2'));460timeline.pushSnapshot('req2', 'stop2b', createSnapshot('stop2b', 'req2'));461timeline.pushSnapshot('req2', 'stop2c', createSnapshot('stop2c', 'req2'));462463// Single stop request464timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3'));465466const state = timeline.getStateForPersistence();467assert.strictEqual(state.history.length, 3);468assert.strictEqual(state.history[0].stops.length, 1);469assert.strictEqual(state.history[1].stops.length, 3);470assert.strictEqual(state.history[2].stops.length, 1);471});472});473474suite('Error resilience', () => {475test('handles invalid apply calls gracefully', () => {476timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));477timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));478479const undoSnap = timeline.getUndoSnapshot();480assert.ok(undoSnap);481482// Apply twice - second should be safe483undoSnap.apply();484undoSnap.apply(); // Should not throw485486assert.strictEqual(timeline.canUndo.get(), false);487});488489test('getSnapshotForRestore with malformed stopId', () => {490timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));491492const snap = timeline.getSnapshotForRestore('req1', '');493assert.strictEqual(snap, undefined);494});495496test('handles restoration edge cases', () => {497const emptyState = { history: [], index: 0 };498transaction(tx => timeline.restoreFromState(emptyState, tx));499500// Should be safe to call methods on empty timeline501assert.strictEqual(timeline.getUndoSnapshot(), undefined);502assert.strictEqual(timeline.getRedoSnapshot(), undefined);503assert.deepStrictEqual(timeline.requestDisablement.get(), []);504});505});506});507508509