diff --git a/packages/replay_bloc/lib/src/change_stack.dart b/packages/replay_bloc/lib/src/change_stack.dart index 823072cf5e5..97e95e748f3 100644 --- a/packages/replay_bloc/lib/src/change_stack.dart +++ b/packages/replay_bloc/lib/src/change_stack.dart @@ -31,19 +31,55 @@ class _ChangeStack { _redos.clear(); } - void redo() { - if (canRedo) { - final change = _redos.removeFirst(); - _history.addLast(change); - return _shouldReplay(change._newValue) ? change.execute() : redo(); + void redo(int steps) { + if (steps <= 0) return; + + var effectiveSteps = steps; + while (effectiveSteps > 0 && canRedo) { + _Change? changeToExecute; + while (_redos.isNotEmpty) { + final change = _redos.first; + if (_shouldReplay(change._newValue)) { + changeToExecute = _redos.removeFirst(); + break; + } else { + _history.addLast(_redos.removeFirst()); + } + } + + if (changeToExecute != null) { + _history.addLast(changeToExecute); + changeToExecute.execute(); + effectiveSteps--; + } else { + break; + } } } - void undo() { - if (canUndo) { - final change = _history.removeLast(); - _redos.addFirst(change); - return _shouldReplay(change._oldValue) ? change.undo() : undo(); + void undo(int steps) { + if (steps <= 0) return; + + var effectiveSteps = steps; + while (effectiveSteps > 0 && canUndo) { + _Change? changeToUndo; + while (_history.isNotEmpty) { + final change = _history.last; + if (_shouldReplay(change._oldValue)) { + changeToUndo = _history.removeLast(); + break; + } else { + _redos.addFirst(_history.removeLast()); + } + } + + if (changeToUndo != null) { + _redos.addFirst(changeToUndo); + changeToUndo.undo(); + effectiveSteps--; + } else { + break; + } } } } diff --git a/packages/replay_bloc/lib/src/replay_bloc.dart b/packages/replay_bloc/lib/src/replay_bloc.dart index 5ad6298f404..bd5bbd8d2f8 100644 --- a/packages/replay_bloc/lib/src/replay_bloc.dart +++ b/packages/replay_bloc/lib/src/replay_bloc.dart @@ -132,10 +132,10 @@ mixin ReplayBlocMixin on Bloc { } /// Undo the last change. - void undo() => _changeStack.undo(); + void undo([int steps = 1]) => _changeStack.undo(steps); /// Redo the previous change. - void redo() => _changeStack.redo(); + void redo([int steps = 1]) => _changeStack.redo(steps); /// Checks whether the undo/redo stack is empty. bool get canUndo => _changeStack.canUndo; diff --git a/packages/replay_bloc/lib/src/replay_cubit.dart b/packages/replay_bloc/lib/src/replay_cubit.dart index 6c430cfcfed..d17cc3f0bcd 100644 --- a/packages/replay_bloc/lib/src/replay_cubit.dart +++ b/packages/replay_bloc/lib/src/replay_cubit.dart @@ -76,10 +76,10 @@ mixin ReplayCubitMixin on Cubit { } /// Undo the last change. - void undo() => _changeStack.undo(); + void undo([int steps = 1]) => _changeStack.undo(steps); /// Redo the previous change. - void redo() => _changeStack.redo(); + void redo([int steps = 1]) => _changeStack.redo(steps); /// Checks whether the undo/redo stack is empty. bool get canUndo => _changeStack.canUndo; diff --git a/packages/replay_bloc/test/replay_cubit_test.dart b/packages/replay_bloc/test/replay_cubit_test.dart index 13efa65758f..d5fdab662f7 100644 --- a/packages/replay_bloc/test/replay_cubit_test.dart +++ b/packages/replay_bloc/test/replay_cubit_test.dart @@ -172,6 +172,89 @@ void main() { await subscription.cancel(); expect(states, const [1, 2, 1]); }); + + test('undoes multiple steps correctly', () async { + final states = []; + final cubit = CounterCubit(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..increment() + ..increment() + ..undo(2); + await cubit.close(); + await subscription.cancel(); + + expect(states, const [1, 2, 3, 2, 1]); + expect(cubit.state, 1); + expect(cubit.canUndo, isTrue); + expect(cubit.canRedo, isTrue); + }); + + test('undo stops when history is exhausted even if steps remain', + () async { + final states = []; + final cubit = CounterCubit(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..increment() + ..undo(5); + await cubit.close(); + await subscription.cancel(); + + expect(states, const [1, 2, 1, 0]); + expect(cubit.state, 0); + expect(cubit.canUndo, isFalse); + expect(cubit.canRedo, isTrue); + }); + + test('undo with steps: 0 does nothing', () async { + final states = []; + final cubit = CounterCubit(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..increment() + ..undo(0); + await cubit.close(); + await subscription.cancel(); + expect(states, const [1, 2]); + expect(cubit.state, 2); + }); + + test('undo with negative steps does nothing', () async { + final states = []; + final cubit = CounterCubit(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..increment() + ..undo(-1); + await cubit.close(); + await subscription.cancel(); + expect(states, const [1, 2]); + expect(cubit.state, 2); + }); + + test('undo(N) skips states filtered out by shouldReplay', () async { + final states = []; + + final cubit = CounterCubit(shouldReplayCallback: (i) => i.isOdd); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..increment() + ..increment() + ..increment() + ..undo(2); + + await cubit.close(); + await subscription.cancel(); + + expect(states, const [1, 2, 3, 4, 3, 1]); + expect(cubit.state, 1); + }); }); group('redo', () { @@ -290,6 +373,93 @@ void main() { await subscription.cancel(); expect(states, const [1, 2, 3, 1, 2, 3]); }); + + test('redoes multiple steps correctly', () async { + final states = []; + final cubit = CounterCubit(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..increment() + ..increment() + ..undo(3) + ..redo(2); + await cubit.close(); + await subscription.cancel(); + + expect(states, const [1, 2, 3, 2, 1, 0, 1, 2]); + expect(cubit.state, 2); + expect(cubit.canUndo, isTrue); + expect(cubit.canRedo, isTrue); + }); + + test('redo stops when redo stack is exhausted even if steps remain', + () async { + final states = []; + final cubit = CounterCubit(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..increment() + ..increment() + ..undo(2) + ..redo(4); + await cubit.close(); + await subscription.cancel(); + + expect(states, const [1, 2, 3, 2, 1, 2, 3]); + expect(cubit.state, 3); + expect(cubit.canUndo, isTrue); + expect(cubit.canRedo, isFalse); + }); + + test('redo with steps: 0 does nothing', () async { + final states = []; + final cubit = CounterCubit(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..undo() + ..redo(0); + await cubit.close(); + await subscription.cancel(); + expect(states, const [1, 0]); + expect(cubit.state, 0); + }); + + test('redo with negative steps does nothing', () async { + final states = []; + final cubit = CounterCubit(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..undo() + ..redo(-1); + await cubit.close(); + await subscription.cancel(); + expect(states, const [1, 0]); + expect(cubit.state, 0); + }); + + test('redo(N) skips states filtered out by shouldReplay', () async { + final states = []; + + final cubit = CounterCubit(shouldReplayCallback: (i) => i.isOdd); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..increment() + ..increment() + ..increment() + ..undo(4) + ..redo(); + + await cubit.close(); + await subscription.cancel(); + + expect(states, const [1, 2, 3, 4, 3, 1, 3]); + expect(cubit.state, 3); + }); }); }); @@ -488,6 +658,135 @@ void main() { await subscription.cancel(); expect(states, const [1, 2, 1, 0]); }); + + test('undoes multiple steps correctly', () async { + final states = []; + final cubit = CounterCubitMixin(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..increment() + ..increment() + ..undo(2); + await cubit.close(); + await subscription.cancel(); + expect(states, const [1, 2, 3, 2, 1]); + expect(cubit.state, 1); + expect(cubit.canUndo, isTrue); + expect(cubit.canRedo, isTrue); + }); + + test('undo stops when history is exhausted even if steps remain', + () async { + final states = []; + final cubit = CounterCubitMixin(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..increment() + ..undo(5); + await cubit.close(); + await subscription.cancel(); + expect(states, const [1, 2, 1, 0]); + expect(cubit.state, 0); + expect(cubit.canUndo, isFalse); + expect(cubit.canRedo, isTrue); + }); + + test('undo with steps: 0 does nothing', () async { + final states = []; + final cubit = CounterCubitMixin(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..increment() + ..undo(0); + await cubit.close(); + await subscription.cancel(); + expect(states, const [1, 2]); + expect(cubit.state, 2); + }); + + test('undo with negative steps does nothing', () async { + final states = []; + final cubit = CounterCubitMixin(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..increment() + ..undo(-1); + await cubit.close(); + await subscription.cancel(); + expect(states, const [1, 2]); + expect(cubit.state, 2); + }); + }); + + group('redo', () { + test('redoes multiple steps correctly', () async { + final states = []; + final cubit = CounterCubitMixin(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..increment() + ..increment() + ..undo(3) + ..redo(2); + await cubit.close(); + await subscription.cancel(); + expect(states, const [1, 2, 3, 2, 1, 0, 1, 2]); + expect(cubit.state, 2); + expect(cubit.canUndo, isTrue); + expect(cubit.canRedo, isTrue); + }); + + test('redo stops when redo stack is exhausted even if steps remain', + () async { + final states = []; + final cubit = CounterCubitMixin(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..increment() + ..increment() + ..undo(2) + ..redo(4); + await cubit.close(); + await subscription.cancel(); + expect(states, const [1, 2, 3, 2, 1, 2, 3]); + expect(cubit.state, 3); + expect(cubit.canUndo, isTrue); + expect(cubit.canRedo, isFalse); + }); + + test('redo with steps: 0 does nothing', () async { + final states = []; + final cubit = CounterCubitMixin(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..undo() + ..redo(0); + await cubit.close(); + await subscription.cancel(); + expect(states, const [1, 0]); + expect(cubit.state, 0); + }); + + test('redo with negative steps does nothing', () async { + final states = []; + final cubit = CounterCubitMixin(); + final subscription = cubit.stream.listen(states.add); + cubit + ..increment() + ..undo() + ..redo(-1); + await cubit.close(); + await subscription.cancel(); + expect(states, const [1, 0]); + expect(cubit.state, 0); + }); }); }); }