Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/test/browser/chatEditingTimeline.test.ts
3296 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 * as assert from 'assert';
7
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
8
import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js';
9
import { ChatEditingTimeline } from '../../browser/chatEditing/chatEditingTimeline.js';
10
import { IChatEditingSessionStop } from '../../browser/chatEditing/chatEditingSessionStorage.js';
11
import { transaction } from '../../../../../base/common/observable.js';
12
import { IChatRequestDisablement } from '../../common/chatModel.js';
13
import { ResourceMap } from '../../../../../base/common/map.js';
14
import { URI } from '../../../../../base/common/uri.js';
15
import { ISnapshotEntry } from '../../common/chatEditingService.js';
16
17
suite('ChatEditingTimeline', () => {
18
const ds = ensureNoDisposablesAreLeakedInTestSuite();
19
let timeline: ChatEditingTimeline;
20
21
setup(() => {
22
const instaService = workbenchInstantiationService(undefined, ds);
23
timeline = instaService.createInstance(ChatEditingTimeline);
24
});
25
26
suite('undo/redo', () => {
27
test('undo/redo with empty history', () => {
28
assert.strictEqual(timeline.getUndoSnapshot(), undefined);
29
assert.strictEqual(timeline.getRedoSnapshot(), undefined);
30
assert.strictEqual(timeline.canRedo.get(), false);
31
assert.strictEqual(timeline.canUndo.get(), false);
32
});
33
});
34
35
function createSnapshot(stopId: string | undefined, requestId = 'req1'): IChatEditingSessionStop {
36
return {
37
stopId,
38
entries: stopId === undefined ? new ResourceMap() : new ResourceMap([[
39
URI.file(`file:///path/to/${stopId}`),
40
{ requestId, current: `Content for ${stopId}` } as Partial<ISnapshotEntry> as ISnapshotEntry
41
]]),
42
};
43
}
44
45
suite('Basic functionality', () => {
46
test('pushSnapshot and undo/redo navigation', () => {
47
// Push two snapshots
48
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
49
timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));
50
51
// After two pushes, canUndo should be true, canRedo false
52
assert.strictEqual(timeline.canUndo.get(), true);
53
assert.strictEqual(timeline.canRedo.get(), false);
54
55
// Undo should move back to stop1
56
const undoSnap = timeline.getUndoSnapshot();
57
assert.ok(undoSnap);
58
assert.strictEqual(undoSnap.stop.stopId, 'stop1');
59
undoSnap.apply();
60
assert.strictEqual(timeline.canUndo.get(), false);
61
assert.strictEqual(timeline.canRedo.get(), true);
62
63
// Redo should move forward to stop2
64
const redoSnap = timeline.getRedoSnapshot();
65
assert.ok(redoSnap);
66
assert.strictEqual(redoSnap.stop.stopId, 'stop2');
67
redoSnap.apply();
68
assert.strictEqual(timeline.canUndo.get(), true);
69
assert.strictEqual(timeline.canRedo.get(), false);
70
});
71
72
test('restoreFromState restores history and index', () => {
73
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
74
timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));
75
const state = timeline.getStateForPersistence();
76
77
// Move back
78
timeline.getUndoSnapshot()?.apply();
79
80
// Restore state
81
transaction(tx => timeline.restoreFromState(state, tx));
82
assert.strictEqual(timeline.canUndo.get(), true);
83
assert.strictEqual(timeline.canRedo.get(), false);
84
});
85
86
test('getSnapshotForRestore returns correct snapshot', () => {
87
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
88
timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));
89
90
const snap = timeline.getSnapshotForRestore('req1', 'stop1');
91
assert.ok(snap);
92
assert.strictEqual(snap.stop.stopId, 'stop1');
93
snap.apply();
94
95
assert.strictEqual(timeline.canRedo.get(), true);
96
assert.strictEqual(timeline.canUndo.get(), false);
97
98
const snap2 = timeline.getSnapshotForRestore('req1', 'stop2');
99
assert.ok(snap2);
100
assert.strictEqual(snap2.stop.stopId, 'stop2');
101
snap2.apply();
102
103
assert.strictEqual(timeline.canRedo.get(), false);
104
assert.strictEqual(timeline.canUndo.get(), true);
105
});
106
107
test('getRequestDisablement returns correct requests', () => {
108
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
109
timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2'));
110
111
// Move back to first
112
timeline.getUndoSnapshot()?.apply();
113
114
const disables = timeline.requestDisablement.get();
115
assert.ok(Array.isArray(disables));
116
assert.ok(disables.some(d => d.requestId === 'req2'));
117
});
118
});
119
120
suite('Multiple requests', () => {
121
test('handles multiple requests with separate snapshots', () => {
122
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
123
timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2'));
124
timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3'));
125
126
assert.strictEqual(timeline.canUndo.get(), true);
127
assert.strictEqual(timeline.canRedo.get(), false);
128
129
// Undo should go back through requests
130
let undoSnap = timeline.getUndoSnapshot();
131
assert.ok(undoSnap);
132
assert.strictEqual(undoSnap.stop.stopId, 'stop2');
133
undoSnap.apply();
134
135
undoSnap = timeline.getUndoSnapshot();
136
assert.ok(undoSnap);
137
assert.strictEqual(undoSnap.stop.stopId, 'stop1');
138
});
139
140
test('handles same request with multiple stops', () => {
141
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
142
timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));
143
timeline.pushSnapshot('req1', 'stop3', createSnapshot('stop3'));
144
145
const state = timeline.getStateForPersistence();
146
assert.strictEqual(state.history.length, 1);
147
assert.strictEqual(state.history[0].stops.length, 3);
148
assert.strictEqual(state.history[0].requestId, 'req1');
149
});
150
151
test('mixed requests and stops', () => {
152
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
153
timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));
154
timeline.pushSnapshot('req2', 'stop3', createSnapshot('stop3', 'req2'));
155
timeline.pushSnapshot('req2', 'stop4', createSnapshot('stop4', 'req2'));
156
157
const state = timeline.getStateForPersistence();
158
assert.strictEqual(state.history.length, 2);
159
assert.strictEqual(state.history[0].stops.length, 2);
160
assert.strictEqual(state.history[1].stops.length, 2);
161
});
162
});
163
164
suite('Edge cases', () => {
165
test('getSnapshotForRestore with non-existent request', () => {
166
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
167
168
const snap = timeline.getSnapshotForRestore('nonexistent', 'stop1');
169
assert.strictEqual(snap, undefined);
170
});
171
172
test('getSnapshotForRestore with non-existent stop', () => {
173
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
174
175
const snap = timeline.getSnapshotForRestore('req1', 'nonexistent');
176
assert.strictEqual(snap, undefined);
177
});
178
});
179
180
suite('History manipulation', () => {
181
test('pushing snapshots after undo truncates future history', () => {
182
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
183
timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));
184
timeline.pushSnapshot('req1', 'stop3', createSnapshot('stop3'));
185
186
// Undo twice
187
timeline.getUndoSnapshot()?.apply();
188
timeline.getUndoSnapshot()?.apply();
189
190
// Push new snapshot - should truncate stop3
191
timeline.pushSnapshot('req1', 'new_stop', createSnapshot('new_stop'));
192
193
const state = timeline.getStateForPersistence();
194
assert.strictEqual(state.history[0].stops.length, 2); // stop1 + new_stop
195
assert.strictEqual(state.history[0].stops[1].stopId, 'new_stop');
196
});
197
198
test('branching from middle of history creates new branch', () => {
199
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
200
timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2'));
201
timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3'));
202
203
// Undo to middle
204
timeline.getUndoSnapshot()?.apply();
205
206
// Push new request
207
timeline.pushSnapshot('req4', 'stop4', createSnapshot('stop4'));
208
209
const state = timeline.getStateForPersistence();
210
assert.strictEqual(state.history.length, 3); // req1, req2, req4
211
assert.strictEqual(state.history[2].requestId, 'req4');
212
});
213
});
214
215
suite('State persistence', () => {
216
test('getStateForPersistence returns complete state', () => {
217
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
218
timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2'));
219
220
const state = timeline.getStateForPersistence();
221
assert.ok(state.history);
222
assert.ok(typeof state.index === 'number');
223
assert.strictEqual(state.history.length, 2);
224
assert.strictEqual(state.index, 2);
225
});
226
227
test('restoreFromState handles empty history', () => {
228
const emptyState = { history: [], index: 0 };
229
230
transaction(tx => timeline.restoreFromState(emptyState, tx));
231
232
assert.strictEqual(timeline.canUndo.get(), false);
233
assert.strictEqual(timeline.canRedo.get(), false);
234
});
235
236
test('restoreFromState with complex history', () => {
237
// Create complex state
238
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
239
timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));
240
timeline.pushSnapshot('req2', 'stop3', createSnapshot('stop3', 'req2'));
241
242
const originalState = timeline.getStateForPersistence();
243
244
// Create new timeline and restore
245
const instaService = workbenchInstantiationService(undefined, ds);
246
const newTimeline = instaService.createInstance(ChatEditingTimeline);
247
transaction(tx => newTimeline.restoreFromState(originalState, tx));
248
249
const restoredState = newTimeline.getStateForPersistence();
250
assert.deepStrictEqual(restoredState.index, originalState.index);
251
assert.strictEqual(restoredState.history.length, originalState.history.length);
252
});
253
});
254
255
suite('Request disablement', () => {
256
test('getRequestDisablement at various positions', () => {
257
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
258
timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2'));
259
timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3'));
260
261
// At end - no disabled requests
262
let disables = timeline.requestDisablement.get();
263
assert.strictEqual(disables.length, 0);
264
265
// Move back one
266
timeline.getUndoSnapshot()?.apply();
267
disables = timeline.requestDisablement.get();
268
assert.strictEqual(disables.length, 1);
269
assert.strictEqual(disables[0].requestId, 'req3');
270
271
// Move back to beginning
272
timeline.getUndoSnapshot()?.apply();
273
timeline.getUndoSnapshot()?.apply();
274
disables = timeline.requestDisablement.get();
275
assert.strictEqual(disables.length, 2);
276
});
277
278
test('getRequestDisablement with mixed request/stop structure', () => {
279
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
280
timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));
281
timeline.pushSnapshot('req2', 'stop3', createSnapshot('stop3', 'req2'));
282
283
// Move to middle of req1
284
timeline.getUndoSnapshot()?.apply();
285
timeline.getUndoSnapshot()?.apply();
286
287
const disables = timeline.requestDisablement.get();
288
assert.strictEqual(disables.length, 2);
289
290
// Should have partial disable for req1 and full disable for req2
291
const req1Disable = disables.find(d => d.requestId === 'req1');
292
const req2Disable = disables.find(d => d.requestId === 'req2');
293
294
assert.ok(req1Disable);
295
assert.ok(req2Disable);
296
assert.ok(req1Disable.afterUndoStop);
297
assert.strictEqual(req2Disable.afterUndoStop, undefined);
298
});
299
});
300
301
suite('Boundary conditions', () => {
302
test('undo/redo at boundaries', () => {
303
// Empty timeline
304
assert.strictEqual(timeline.getUndoSnapshot(), undefined);
305
assert.strictEqual(timeline.getRedoSnapshot(), undefined);
306
307
// Single snapshot
308
timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));
309
timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));
310
assert.ok(timeline.getUndoSnapshot());
311
assert.strictEqual(timeline.getRedoSnapshot(), undefined);
312
313
// At beginning after undo
314
timeline.getUndoSnapshot()?.apply();
315
assert.strictEqual(timeline.getUndoSnapshot(), undefined);
316
assert.ok(timeline.getRedoSnapshot());
317
});
318
319
test('multiple undos and redos', () => {
320
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
321
timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2'));
322
timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3'));
323
324
// Undo all
325
const stops: string[] = [];
326
let undoSnap = timeline.getUndoSnapshot();
327
while (undoSnap) {
328
stops.push(undoSnap.stop.stopId!);
329
undoSnap.apply();
330
undoSnap = timeline.getUndoSnapshot();
331
}
332
assert.deepStrictEqual(stops, ['stop2', 'stop1']);
333
334
// Redo all
335
const redoStops: string[] = [];
336
let redoSnap = timeline.getRedoSnapshot();
337
while (redoSnap) {
338
redoStops.push(redoSnap.stop.stopId!);
339
redoSnap.apply();
340
redoSnap = timeline.getRedoSnapshot();
341
}
342
assert.deepStrictEqual(redoStops, ['stop2', 'stop3']);
343
});
344
345
test('getRequestDisablement with root request ID', () => {
346
timeline.pushSnapshot('req1', undefined, createSnapshot(undefined));
347
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
348
timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));
349
350
timeline.pushSnapshot('req2', undefined, createSnapshot(undefined, 'req2'));
351
timeline.pushSnapshot('req2', 'stop1-2', createSnapshot('stop1-2', 'req2'));
352
timeline.pushSnapshot('req2', 'stop2-2', createSnapshot('stop2-2', 'req2'));
353
354
const expected: IChatRequestDisablement[][] = [
355
[{ requestId: 'req2', afterUndoStop: 'stop1-2' }],
356
[{ requestId: 'req2' }],
357
// stop2 is not in this because we're at stop2 when undoing req2
358
[{ requestId: 'req1', afterUndoStop: 'stop1' }, { requestId: 'req2' }],
359
[{ requestId: 'req1', afterUndoStop: undefined }, { requestId: 'req2' }],
360
];
361
362
let ei = 0;
363
while (timeline.canUndo.get()) {
364
timeline.getUndoSnapshot()!.apply();
365
const actual = timeline.requestDisablement.get();
366
367
assert.deepStrictEqual(actual, expected[ei++]);
368
}
369
370
expected.unshift([]);
371
372
while (timeline.canRedo.get()) {
373
timeline.getRedoSnapshot()!.apply();
374
const actual = timeline.requestDisablement.get();
375
assert.deepStrictEqual(actual, expected[--ei]);
376
}
377
});
378
});
379
380
suite('Static methods', () => {
381
test('createEmptySnapshot creates valid snapshot', () => {
382
const snapshot = ChatEditingTimeline.createEmptySnapshot('test-stop');
383
assert.strictEqual(snapshot.stopId, 'test-stop');
384
assert.ok(snapshot.entries);
385
assert.strictEqual(snapshot.entries.size, 0);
386
});
387
388
test('createEmptySnapshot with undefined stopId', () => {
389
const snapshot = ChatEditingTimeline.createEmptySnapshot(undefined);
390
assert.strictEqual(snapshot.stopId, undefined);
391
assert.ok(snapshot.entries);
392
});
393
394
test('POST_EDIT_STOP_ID is consistent', () => {
395
assert.strictEqual(typeof ChatEditingTimeline.POST_EDIT_STOP_ID, 'string');
396
assert.ok(ChatEditingTimeline.POST_EDIT_STOP_ID.length > 0);
397
});
398
});
399
400
suite('Observable behavior', () => {
401
test('canUndo observable updates correctly', () => {
402
assert.strictEqual(timeline.canUndo.get(), false);
403
404
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
405
timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));
406
assert.strictEqual(timeline.canUndo.get(), true);
407
408
timeline.getUndoSnapshot()?.apply();
409
assert.strictEqual(timeline.canUndo.get(), false);
410
});
411
412
test('canRedo observable updates correctly', () => {
413
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
414
timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));
415
assert.strictEqual(timeline.canRedo.get(), false);
416
417
timeline.getUndoSnapshot()?.apply();
418
assert.strictEqual(timeline.canRedo.get(), true);
419
420
timeline.getRedoSnapshot()?.apply();
421
assert.strictEqual(timeline.canRedo.get(), false);
422
});
423
});
424
425
suite('Complex scenarios', () => {
426
test('interleaved requests and undos', () => {
427
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
428
timeline.pushSnapshot('req2', 'stop2', createSnapshot('stop2', 'req2'));
429
430
// Undo req2
431
timeline.getUndoSnapshot()?.apply();
432
433
// Add req3 (should branch from req1)
434
timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3'));
435
436
const state = timeline.getStateForPersistence();
437
assert.strictEqual(state.history.length, 2); // req1, req3
438
assert.strictEqual(state.history[1].requestId, 'req3');
439
});
440
441
test('large number of snapshots', () => {
442
// Push 100 snapshots
443
for (let i = 1; i <= 100; i++) {
444
timeline.pushSnapshot(`req${i}`, `stop${i}`, createSnapshot(`stop${i}`));
445
}
446
447
assert.strictEqual(timeline.canUndo.get(), true);
448
assert.strictEqual(timeline.canRedo.get(), false);
449
450
const state = timeline.getStateForPersistence();
451
assert.strictEqual(state.history.length, 100);
452
assert.strictEqual(state.index, 100);
453
});
454
455
test('alternating single and multi-stop requests', () => {
456
// Single stop request
457
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
458
459
// Multi-stop request
460
timeline.pushSnapshot('req2', 'stop2a', createSnapshot('stop2a', 'req2'));
461
timeline.pushSnapshot('req2', 'stop2b', createSnapshot('stop2b', 'req2'));
462
timeline.pushSnapshot('req2', 'stop2c', createSnapshot('stop2c', 'req2'));
463
464
// Single stop request
465
timeline.pushSnapshot('req3', 'stop3', createSnapshot('stop3'));
466
467
const state = timeline.getStateForPersistence();
468
assert.strictEqual(state.history.length, 3);
469
assert.strictEqual(state.history[0].stops.length, 1);
470
assert.strictEqual(state.history[1].stops.length, 3);
471
assert.strictEqual(state.history[2].stops.length, 1);
472
});
473
});
474
475
suite('Error resilience', () => {
476
test('handles invalid apply calls gracefully', () => {
477
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
478
timeline.pushSnapshot('req1', 'stop2', createSnapshot('stop2'));
479
480
const undoSnap = timeline.getUndoSnapshot();
481
assert.ok(undoSnap);
482
483
// Apply twice - second should be safe
484
undoSnap.apply();
485
undoSnap.apply(); // Should not throw
486
487
assert.strictEqual(timeline.canUndo.get(), false);
488
});
489
490
test('getSnapshotForRestore with malformed stopId', () => {
491
timeline.pushSnapshot('req1', 'stop1', createSnapshot('stop1'));
492
493
const snap = timeline.getSnapshotForRestore('req1', '');
494
assert.strictEqual(snap, undefined);
495
});
496
497
test('handles restoration edge cases', () => {
498
const emptyState = { history: [], index: 0 };
499
transaction(tx => timeline.restoreFromState(emptyState, tx));
500
501
// Should be safe to call methods on empty timeline
502
assert.strictEqual(timeline.getUndoSnapshot(), undefined);
503
assert.strictEqual(timeline.getRedoSnapshot(), undefined);
504
assert.deepStrictEqual(timeline.requestDisablement.get(), []);
505
});
506
});
507
});
508
509