appflowy-editor/test/legacy/undo_manager_test.dart

463 lines
14 KiB
Dart
Raw Normal View History

2022-09-13 17:33:26 +08:00
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/history/undo_manager.dart';
2022-09-13 17:33:26 +08:00
import 'package:flutter_test/flutter_test.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
test('HistoryItem #1', () {
final editorState = EditorState.blank();
2022-09-13 17:33:26 +08:00
final historyItem = HistoryItem();
historyItem.add(DeleteOperation([0], [paragraphNode(text: '0')]));
historyItem.add(DeleteOperation([0], [paragraphNode(text: '1')]));
historyItem.add(DeleteOperation([0], [paragraphNode(text: '2')]));
2022-09-13 17:33:26 +08:00
final transaction = historyItem.toTransaction(editorState);
assert(isInsertAndPathEqual(transaction.operations[0], [0], '2'));
assert(isInsertAndPathEqual(transaction.operations[1], [0], '1'));
assert(isInsertAndPathEqual(transaction.operations[2], [0], '0'));
});
test('HistoryItem #2', () {
final editorState = EditorState.blank();
2022-09-13 17:33:26 +08:00
final historyItem = HistoryItem();
historyItem.add(DeleteOperation([0], [paragraphNode(text: '0')]));
historyItem.add(
const UpdateOperation([0], {'subType': 'number'}, {'subType': null}),
);
historyItem.add(DeleteOperation([0], [paragraphNode(), paragraphNode()]));
historyItem.add(DeleteOperation([0], [paragraphNode()]));
2022-09-13 17:33:26 +08:00
final transaction = historyItem.toTransaction(editorState);
assert(isInsertAndPathEqual(transaction.operations[0], [0]));
assert(isInsertAndPathEqual(transaction.operations[1], [0]));
assert(transaction.operations[2] is UpdateOperation);
assert(isInsertAndPathEqual(transaction.operations[3], [0], '0'));
});
2023-03-24 13:59:03 +01:00
test('HistoryItem sealed', () {
2023-03-24 13:59:03 +01:00
final historyItem = HistoryItem();
historyItem.add(DeleteOperation([0], [paragraphNode(text: '0')]));
2023-03-24 13:59:03 +01:00
historyItem.seal();
expect(historyItem.sealed, true);
});
test('Multi-step redo preserves redo stack', () {
final editorState = EditorState(
document: Document(
root: pageNode(
children: [
paragraphNode(text: ''),
],
),
),
);
editorState.disableSealTimer = true;
// Apply 3 separate transactions, sealing each one
for (var i = 0; i < 3; i++) {
final transaction = editorState.transaction;
transaction.insertText(
editorState.document.nodeAtPath([0])!,
i, // offset
String.fromCharCode(65 + i), // 'A', 'B', 'C'
);
editorState.apply(transaction);
editorState.undoManager.undoStack.last.seal();
}
// Document should be "ABC"
expect(
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
'ABC',
);
expect(editorState.undoManager.undoStack.length, 3);
expect(editorState.undoManager.redoStack.length, 0);
// Undo 3 times → back to empty
editorState.undoManager.undo();
expect(
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
'AB',
);
expect(editorState.undoManager.undoStack.length, 2);
expect(editorState.undoManager.redoStack.length, 1);
editorState.undoManager.undo();
expect(
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
'A',
);
expect(editorState.undoManager.undoStack.length, 1);
expect(editorState.undoManager.redoStack.length, 2);
editorState.undoManager.undo();
expect(
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
'',
);
expect(editorState.undoManager.undoStack.length, 0);
expect(editorState.undoManager.redoStack.length, 3);
// Redo 3 times → should restore each step
editorState.undoManager.redo();
expect(
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
'A',
);
expect(editorState.undoManager.undoStack.length, 1);
expect(editorState.undoManager.redoStack.length, 2);
// Manually seal to simulate the 50ms debounce timer
editorState.undoManager.undoStack.last.seal();
editorState.undoManager.redo();
expect(
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
'AB',
);
expect(editorState.undoManager.undoStack.length, 2);
expect(editorState.undoManager.redoStack.length, 1);
editorState.undoManager.undoStack.last.seal();
editorState.undoManager.redo();
expect(
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
'ABC',
);
expect(editorState.undoManager.undoStack.length, 3);
expect(editorState.undoManager.redoStack.length, 0);
});
test('New edit after undo still clears redo stack', () {
final editorState = EditorState(
document: Document(
root: pageNode(
children: [
paragraphNode(text: ''),
],
),
),
);
editorState.disableSealTimer = true;
// Insert "A"
final t1 = editorState.transaction;
t1.insertText(editorState.document.nodeAtPath([0])!, 0, 'A');
editorState.apply(t1);
editorState.undoManager.undoStack.last.seal();
// Insert "B"
final t2 = editorState.transaction;
t2.insertText(editorState.document.nodeAtPath([0])!, 1, 'B');
editorState.apply(t2);
editorState.undoManager.undoStack.last.seal();
// Undo once → "A", redo stack should have 1 item
editorState.undoManager.undo();
expect(
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
'A',
);
expect(editorState.undoManager.redoStack.isNonEmpty, true);
// Make a NEW edit (not redo) → redo stack should be cleared
final t3 = editorState.transaction;
t3.insertText(editorState.document.nodeAtPath([0])!, 1, 'X');
editorState.apply(t3);
expect(editorState.undoManager.redoStack.isEmpty, true);
});
test('Undo after redo restores previous state', () {
final editorState = EditorState(
document: Document(
root: pageNode(
children: [
paragraphNode(text: ''),
],
),
),
);
editorState.disableSealTimer = true;
// Insert A, B, C
for (var i = 0; i < 3; i++) {
final transaction = editorState.transaction;
transaction.insertText(
editorState.document.nodeAtPath([0])!,
i,
String.fromCharCode(65 + i),
);
editorState.apply(transaction);
editorState.undoManager.undoStack.last.seal();
}
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'ABC');
// Undo all
editorState.undoManager.undo();
editorState.undoManager.undo();
editorState.undoManager.undo();
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), '');
// Redo 2
editorState.undoManager.redo();
editorState.undoManager.redo();
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'AB');
expect(editorState.undoManager.undoStack.length, 2);
expect(editorState.undoManager.redoStack.length, 1);
// Undo 1 — should undo the last redo
editorState.undoManager.undo();
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'A');
expect(editorState.undoManager.undoStack.length, 1);
expect(editorState.undoManager.redoStack.length, 2);
// Redo all remaining
editorState.undoManager.redo();
editorState.undoManager.redo();
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'ABC');
});
test('Partial undo then redo', () {
final editorState = EditorState(
document: Document(
root: pageNode(
children: [
paragraphNode(text: ''),
],
),
),
);
editorState.disableSealTimer = true;
// Insert A, B, C, D
for (var i = 0; i < 4; i++) {
final transaction = editorState.transaction;
transaction.insertText(
editorState.document.nodeAtPath([0])!,
i,
String.fromCharCode(65 + i),
);
editorState.apply(transaction);
editorState.undoManager.undoStack.last.seal();
}
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'ABCD');
// Undo 2 of 4
editorState.undoManager.undo();
editorState.undoManager.undo();
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'AB');
expect(editorState.undoManager.undoStack.length, 2);
expect(editorState.undoManager.redoStack.length, 2);
// Redo 2
editorState.undoManager.redo();
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'ABC');
editorState.undoManager.redo();
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'ABCD');
expect(editorState.undoManager.undoStack.length, 4);
expect(editorState.undoManager.redoStack.length, 0);
});
test('Interleaved undo and redo', () {
final editorState = EditorState(
document: Document(
root: pageNode(
children: [
paragraphNode(text: ''),
],
),
),
);
editorState.disableSealTimer = true;
// Insert A, B, C
for (var i = 0; i < 3; i++) {
final transaction = editorState.transaction;
transaction.insertText(
editorState.document.nodeAtPath([0])!,
i,
String.fromCharCode(65 + i),
);
editorState.apply(transaction);
editorState.undoManager.undoStack.last.seal();
}
// undo → redo → undo → redo cycle
editorState.undoManager.undo(); // ABC → AB
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'AB');
editorState.undoManager.redo(); // AB → ABC
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'ABC');
editorState.undoManager.undo(); // ABC → AB
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'AB');
editorState.undoManager.undo(); // AB → A
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'A');
editorState.undoManager.redo(); // A → AB
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'AB');
editorState.undoManager.redo(); // AB → ABC
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'ABC');
});
test('New edit mid-redo clears remaining redo stack', () {
final editorState = EditorState(
document: Document(
root: pageNode(
children: [
paragraphNode(text: ''),
],
),
),
);
editorState.disableSealTimer = true;
// Insert A, B, C
for (var i = 0; i < 3; i++) {
final transaction = editorState.transaction;
transaction.insertText(
editorState.document.nodeAtPath([0])!,
i,
String.fromCharCode(65 + i),
);
editorState.apply(transaction);
editorState.undoManager.undoStack.last.seal();
}
// Undo all 3
editorState.undoManager.undo();
editorState.undoManager.undo();
editorState.undoManager.undo();
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), '');
expect(editorState.undoManager.redoStack.length, 3);
// Redo 1
editorState.undoManager.redo();
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'A');
expect(editorState.undoManager.redoStack.length, 2);
// Make a NEW edit → remaining redo should be cleared
final t = editorState.transaction;
t.insertText(editorState.document.nodeAtPath([0])!, 1, 'X');
editorState.apply(t);
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'AX');
expect(editorState.undoManager.redoStack.length, 0);
// Redo should be no-op now
editorState.undoManager.redo();
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'AX');
});
test('Selection is preserved during multi-step redo', () {
final editorState = EditorState(
document: Document(
root: pageNode(
children: [
paragraphNode(text: ''),
],
),
),
);
editorState.disableSealTimer = true;
// Insert A at offset 0 with selection
final t1 = editorState.transaction;
t1.insertText(editorState.document.nodeAtPath([0])!, 0, 'A');
t1.afterSelection = Selection.collapsed(Position(path: [0], offset: 1));
editorState.apply(t1);
editorState.undoManager.undoStack.last.seal();
// Insert B at offset 1 with selection
final t2 = editorState.transaction;
t2.insertText(editorState.document.nodeAtPath([0])!, 1, 'B');
t2.afterSelection = Selection.collapsed(Position(path: [0], offset: 2));
editorState.apply(t2);
editorState.undoManager.undoStack.last.seal();
// Undo both
editorState.undoManager.undo();
editorState.undoManager.undo();
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), '');
// Redo first — selection should be at offset 1
editorState.undoManager.redo();
expect(
editorState.selection,
Selection.collapsed(Position(path: [0], offset: 1)),
);
// Redo second — selection should be at offset 2
editorState.undoManager.redo();
expect(
editorState.selection,
Selection.collapsed(Position(path: [0], offset: 2)),
);
});
test('TransactionSource.none does not record to either stack', () {
final editorState = EditorState(
document: Document(
root: pageNode(
children: [
paragraphNode(text: ''),
],
),
),
);
editorState.disableSealTimer = true;
// Apply with source: none
final transaction = editorState.transaction;
transaction.insertText(
editorState.document.nodeAtPath([0])!,
0,
'A',
);
editorState.apply(
transaction,
options: const ApplyOptions(
recordUndo: false,
source: TransactionSource.none,
),
);
// Document is modified but neither stack is affected
expect(
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
'A',
);
expect(editorState.undoManager.undoStack.length, 0);
expect(editorState.undoManager.redoStack.length, 0);
});
2022-09-13 17:33:26 +08:00
}
bool isInsertAndPathEqual(Operation operation, Path path, [String? content]) {
if (operation is! InsertOperation) {
return false;
}
2022-10-09 23:53:18 +08:00
if (!operation.path.equals(path)) {
2022-09-13 17:33:26 +08:00
return false;
}
final delta = operation.nodes.first.delta;
if (delta == null) {
2022-09-13 17:33:26 +08:00
return false;
}
if (content == null) {
return true;
}
return delta.toPlainText() == content;
2022-09-13 17:33:26 +08:00
}