diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index cfb12e8..4b9013f 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -225,10 +225,6 @@ quickpick # Image formats webp -# Test patterns (intentional partial matches/abbreviations) -ello -feTurb -meshgrad # Unicode test data مرحبا diff --git a/cspell.json b/cspell.json index 806d407..e5f132f 100644 --- a/cspell.json +++ b/cspell.json @@ -21,7 +21,12 @@ "**/package-lock.json", "website/src/api/**", "website/_site/api/**", - "**/.dart_tool" + "**/.dart_tool", + "packages/reflux/test/selector_memoization_test.dart", + "packages/reflux/test/store_edge_cases_test.dart", + "packages/reflux/test/store_test.dart", + "packages/dart_node_react/test/svg_elements_test.dart", + "packages/dart_node_react_native/test/testing_library_test.dart" ], "files": ["**/*.md", "**/*.dart", "**/*.ts"], "words": [], diff --git a/packages/reflux/test/enhancers_boundary_test.dart b/packages/reflux/test/enhancers_boundary_test.dart new file mode 100644 index 0000000..045cf0a --- /dev/null +++ b/packages/reflux/test/enhancers_boundary_test.dart @@ -0,0 +1,471 @@ +import 'package:reflux/reflux.dart'; +import 'package:test/test.dart'; + +typedef CounterState = ({int count}); + +sealed class CounterAction extends Action { + const CounterAction(); +} + +final class Increment extends CounterAction { + const Increment(); +} + +final class Decrement extends CounterAction { + const Decrement(); +} + +final class SetValue extends CounterAction { + const SetValue(this.value); + final int value; +} + +CounterState counterReducer(CounterState state, Action action) => + switch (action) { + Increment() => (count: state.count + 1), + Decrement() => (count: state.count - 1), + SetValue(:final value) => (count: value), + _ => state, + }; + +void main() { + group('TimeTravelEnhancer boundary tests', () { + test('canUndo is false at index 0', () { + final timeTravel = TimeTravelEnhancer(); + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer); + + expect(timeTravel.currentIndex, equals(0)); + expect(timeTravel.canUndo, isFalse); + }); + + test('canUndo is true at index 1', () { + final timeTravel = TimeTravelEnhancer(); + createStore(counterReducer, ( + count: 0, + ), enhancer: timeTravel.enhancer).dispatch(const Increment()); + + expect(timeTravel.currentIndex, equals(1)); + expect(timeTravel.canUndo, isTrue); + }); + + test('canRedo is false at last index', () { + final timeTravel = TimeTravelEnhancer(); + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + expect(timeTravel.currentIndex, equals(timeTravel.history.length - 1)); + expect(timeTravel.canRedo, isFalse); + }); + + test('canRedo is true when currentIndex < history.length - 1', () { + final timeTravel = TimeTravelEnhancer(); + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + timeTravel.undo(); + expect(timeTravel.currentIndex, lessThan(timeTravel.history.length - 1)); + expect(timeTravel.canRedo, isTrue); + }); + + test('undo does nothing when canUndo is false', () { + final timeTravel = TimeTravelEnhancer(); + final store = createStore(counterReducer, ( + count: 0, + ), enhancer: timeTravel.enhancer); + + final indexBefore = timeTravel.currentIndex; + timeTravel.undo(); + expect(timeTravel.currentIndex, equals(indexBefore)); + expect(store.getState().count, equals(0)); + }); + + test('redo does nothing when canRedo is false', () { + final timeTravel = TimeTravelEnhancer(); + final store = createStore(counterReducer, ( + count: 0, + ), enhancer: timeTravel.enhancer)..dispatch(const Increment()); + + final indexBefore = timeTravel.currentIndex; + timeTravel.redo(); + expect(timeTravel.currentIndex, equals(indexBefore)); + expect(store.getState().count, equals(1)); + }); + + test('jumpTo index 0 is valid', () { + final timeTravel = TimeTravelEnhancer(); + final store = + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + timeTravel.jumpTo(0); + expect(timeTravel.currentIndex, equals(0)); + expect(store.getState().count, equals(0)); + }); + + test('jumpTo last valid index is valid', () { + final timeTravel = TimeTravelEnhancer(); + final store = + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + final lastIndex = timeTravel.history.length - 1; + timeTravel + ..jumpTo(0) + ..jumpTo(lastIndex); + expect(timeTravel.currentIndex, equals(lastIndex)); + expect(store.getState().count, equals(2)); + }); + + test('jumpTo exactly history.length throws RangeError', () { + final timeTravel = TimeTravelEnhancer(); + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + expect( + () => timeTravel.jumpTo(timeTravel.history.length), + throwsA(isA()), + ); + }); + + test('jumpTo -1 throws RangeError', () { + final timeTravel = TimeTravelEnhancer(); + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer); + + expect(() => timeTravel.jumpTo(-1), throwsA(isA())); + }); + + test( + 'history truncation at exact boundary (currentIndex == length - 1)', + () { + final timeTravel = TimeTravelEnhancer(); + final store = + createStore(counterReducer, ( + count: 0, + ), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + expect(timeTravel.history.length, equals(4)); + + timeTravel.undo(); + expect(timeTravel.currentIndex, equals(2)); + + // After undo, internal store state is still 3 (undo only changes view) + // Dispatching Decrement: reducer(3, Decrement) = 2 + // Truncates future (index 3) and adds new state (2) + store.dispatch(const Decrement()); + expect(timeTravel.history.length, equals(4)); + expect(timeTravel.history.last.count, equals(2)); + }, + ); + + test('dispatch at last index does not truncate (< not <=)', () { + // Tests that when currentIndex == history.length - 1 (last position) + // the truncation condition (_currentIndex < _history.length - 1) is FALSE + // so no truncation occurs - only appending + final timeTravel = TimeTravelEnhancer(); + final store = createStore( + counterReducer, + (count: 0), + enhancer: timeTravel.enhancer, + )..dispatch(const Increment()); // history = [0, 1], index = 1 + + expect(timeTravel.history.length, equals(2)); + expect(timeTravel.currentIndex, equals(1)); // At last index + + // Current index IS at history.length - 1, so condition is FALSE + // No truncation, just append + store.dispatch(const Increment()); // history = [0, 1, 2], index = 2 + expect(timeTravel.history.length, equals(3)); + expect(timeTravel.history[0].count, equals(0)); + expect(timeTravel.history[1].count, equals(1)); + expect(timeTravel.history[2].count, equals(2)); + }); + + test('truncation uses length - 1 not length + 1', () { + // Tests that the comparison uses _history.length - 1 (not + 1) + // When currentIndex is 0 and history.length is 2: + // - With length - 1: 0 < 1 is TRUE, truncation happens + // - With length + 1: 0 < 3 is TRUE, but wrong range would be used + final timeTravel = TimeTravelEnhancer(); + final store = + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) + ..dispatch(const Increment()); // history = [0, 1, 2], index = 2 + + // Jump back to index 0 + timeTravel.jumpTo(0); + expect(timeTravel.currentIndex, equals(0)); + + // Dispatch at index 0 - should truncate indices 1 and 2 + // If using length + 1 incorrectly, the range calculation would be wrong + store.dispatch(const SetValue(99)); + + // History should be [0, 99] - indices 1,2 truncated, 99 added + expect(timeTravel.history.length, equals(2)); + expect(timeTravel.history[0].count, equals(0)); + expect(timeTravel.history[1].count, equals(99)); + }); + + test('dispatching at non-last index truncates future', () { + final timeTravel = TimeTravelEnhancer(); + final store = + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + expect(timeTravel.history.length, equals(4)); + + timeTravel.jumpTo(1); + expect(timeTravel.currentIndex, equals(1)); + + // Internal store state is still 3, jumpTo only changes view + // Dispatching Decrement: reducer(3, Decrement) = 2 + // Truncates indices 2,3 and adds new state + store.dispatch(const Decrement()); + expect(timeTravel.history.length, equals(3)); + expect(timeTravel.history[2].count, equals(2)); + }); + + test('TimeTravelStore getState returns state at currentIndex', () { + final timeTravel = TimeTravelEnhancer(); + final store = + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + expect(store.getState().count, equals(3)); + + timeTravel.jumpTo(1); + expect(store.getState().count, equals(1)); + + timeTravel.jumpTo(0); + expect(store.getState().count, equals(0)); + }); + + test('TimeTravelStore dispatch delegates to wrapped store', () { + final timeTravel = TimeTravelEnhancer(); + createStore(counterReducer, ( + count: 0, + ), enhancer: timeTravel.enhancer).dispatch(const Increment()); + + expect(timeTravel.history.length, equals(2)); + }); + + test('TimeTravelStore subscribe delegates to wrapped store', () { + final timeTravel = TimeTravelEnhancer(); + final store = createStore(counterReducer, ( + count: 0, + ), enhancer: timeTravel.enhancer); + + var notified = false; + store + ..subscribe(() => notified = true) + ..dispatch(const Increment()); + + expect(notified, isTrue); + }); + + test('TimeTravelStore replaceReducer delegates to wrapped store', () { + final timeTravel = TimeTravelEnhancer(); + final store = createStore(counterReducer, ( + count: 0, + ), enhancer: timeTravel.enhancer); + + // Verify replaceReducer can be called without throwing + // Note: replaceReducer replaces the internal store's reducer, which + // bypasses the time travel wrapper for subsequent dispatches + expect(() => store.replaceReducer(counterReducer), returnsNormally); + }); + }); + + group('devToolsEnhancer tests', () { + test('uses default name parameter', () { + final captured = []; + + createStore( + counterReducer, + (count: 0), + enhancer: devToolsEnhancer( + onAction: (action, prev, next) { + captured.add('action:${action.runtimeType}'); + }, + ), + ).dispatch(const Increment()); + + expect(captured.isNotEmpty, isTrue); + }); + + test('uses custom name parameter', () { + final captured = []; + + createStore( + counterReducer, + (count: 0), + enhancer: devToolsEnhancer( + name: 'CustomStore', + onAction: (action, prev, next) { + captured.add('action:${action.runtimeType}'); + }, + ), + ).dispatch(const Increment()); + + expect(captured.isNotEmpty, isTrue); + }); + + test('onAction is optional', () { + final store = createStore(counterReducer, ( + count: 0, + ), enhancer: devToolsEnhancer())..dispatch(const Increment()); + + expect(store.getState().count, equals(1)); + }); + + test('onAction receives correct prev and next state', () { + final states = <(int, int)>[]; + + createStore( + counterReducer, + (count: 0), + enhancer: devToolsEnhancer( + onAction: (action, prev, next) { + if (action is Increment) { + states.add((prev.count, next.count)); + } + }, + ), + ) + ..dispatch(const Increment()) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + expect(states, equals([(0, 1), (1, 2), (2, 3)])); + }); + + test('disabled enhancer still creates working store', () { + final store = + createStore(counterReducer, ( + count: 0, + ), enhancer: devToolsEnhancer(enabled: false)) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + expect(store.getState().count, equals(2)); + }); + }); + + group('composeEnhancers edge cases', () { + test('empty list returns identity enhancer', () { + final store = createStore(counterReducer, ( + count: 5, + ), enhancer: composeEnhancers([])); + + expect(store.getState().count, equals(5)); + store.dispatch(const Increment()); + expect(store.getState().count, equals(6)); + }); + + test('single enhancer returns that enhancer', () { + var enhancerCalled = false; + + Store enhancer( + Store Function(Reducer, CounterState) + createStore, + Reducer reducer, + CounterState preloadedState, + ) { + enhancerCalled = true; + return createStore(reducer, preloadedState); + } + + createStore(counterReducer, ( + count: 0, + ), enhancer: composeEnhancers([enhancer])); + + expect(enhancerCalled, isTrue); + }); + + test('multiple enhancers compose correctly', () { + final order = []; + + StoreEnhancer makeEnhancer(int id) => + (createStore, reducer, preloadedState) { + order.add(id); + return createStore(reducer, preloadedState); + }; + + createStore( + counterReducer, + (count: 0), + enhancer: composeEnhancers([ + makeEnhancer(1), + makeEnhancer(2), + makeEnhancer(3), + ]), + ); + + expect(order, equals([1, 2, 3])); + }); + }); + + group('createEnhancer tests', () { + test('wraps store correctly', () { + var wrapCalled = false; + + final enhancer = createEnhancer((store) { + wrapCalled = true; + return store; + }); + + final store = createStore(counterReducer, (count: 0), enhancer: enhancer); + expect(wrapCalled, isTrue); + expect(store.getState().count, equals(0)); + }); + + test('can modify store behavior', () { + final logs = []; + + final enhancer = createEnhancer( + (store) => _LoggingStore(store, logs), + ); + + createStore(counterReducer, ( + count: 0, + ), enhancer: enhancer).dispatch(const Increment()); + + expect(logs, contains('dispatch')); + }); + }); +} + +class _LoggingStore implements Store { + _LoggingStore(this._store, this._logs); + + final Store _store; + final List _logs; + + @override + CounterState getState() => _store.getState(); + + @override + void dispatch(Action action) { + _logs.add('dispatch'); + _store.dispatch(action); + } + + @override + Unsubscribe subscribe(StateListener listener) => + _store.subscribe(listener); + + @override + void replaceReducer(Reducer nextReducer) => + _store.replaceReducer(nextReducer); +} diff --git a/packages/reflux/test/enhancers_test.dart b/packages/reflux/test/enhancers_test.dart index a774d20..d05d4a7 100644 --- a/packages/reflux/test/enhancers_test.dart +++ b/packages/reflux/test/enhancers_test.dart @@ -16,10 +16,16 @@ final class Decrement extends CounterAction { const Decrement(); } +final class SetValue extends CounterAction { + const SetValue(this.value); + final int value; +} + CounterState counterReducer(CounterState state, Action action) => switch (action) { Increment() => (count: state.count + 1), Decrement() => (count: state.count - 1), + SetValue(:final value) => (count: value), _ => state, }; @@ -123,6 +129,35 @@ void main() { expect(onActionCalled, isFalse); expect(store.getState().count, equals(1)); }); + + test('uses default name parameter', () { + // The name parameter defaults to 'Store' (non-empty string) + // This test verifies the default is used correctly + var nameUsed = false; + createStore( + counterReducer, + (count: 0), + enhancer: devToolsEnhancer( + // name defaults to 'Store' - not empty string + onAction: (action, prev, next) => nameUsed = true, + ), + ).dispatch(const Increment()); + expect(nameUsed, isTrue); + }); + + test('accepts custom name parameter', () { + // Verify custom name is accepted (not empty) + var actionLogged = false; + createStore( + counterReducer, + (count: 0), + enhancer: devToolsEnhancer( + name: 'CustomStore', + onAction: (action, prev, next) => actionLogged = true, + ), + ).dispatch(const Increment()); + expect(actionLogged, isTrue); + }); }); group('TimeTravelEnhancer', () { @@ -256,5 +291,184 @@ void main() { expect(timeTravel.history.length, equals(1)); expect(timeTravel.currentIndex, equals(0)); }); + + test('undo does nothing when canUndo is false', () { + final timeTravel = TimeTravelEnhancer(); + + final store = createStore(counterReducer, ( + count: 0, + ), enhancer: timeTravel.enhancer); + + // At initial state, canUndo should be false + expect(timeTravel.canUndo, isFalse); + expect(timeTravel.currentIndex, equals(0)); + + // Calling undo when canUndo is false should do nothing + timeTravel.undo(); + + // Should still be at index 0 + expect(timeTravel.currentIndex, equals(0)); + expect(store.getState().count, equals(0)); + }); + + test('redo does nothing when canRedo is false', () { + final timeTravel = TimeTravelEnhancer(); + + final store = createStore(counterReducer, ( + count: 0, + ), enhancer: timeTravel.enhancer)..dispatch(const Increment()); + + // At latest state, canRedo should be false + expect(timeTravel.canRedo, isFalse); + expect(store.getState().count, equals(1)); + + // Calling redo when canRedo is false should do nothing + timeTravel.redo(); + + // Should still be at the same state + expect(store.getState().count, equals(1)); + expect(timeTravel.canRedo, isFalse); + }); + + test('canRedo boundary at history.length - 1', () { + final timeTravel = TimeTravelEnhancer(); + + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + // history = [0, 1, 2], currentIndex = 2, length = 3 + // canRedo = currentIndex < history.length - 1 + // canRedo = 2 < 2 = false + expect(timeTravel.history.length, equals(3)); + expect(timeTravel.currentIndex, equals(2)); + expect(timeTravel.canRedo, isFalse); + + timeTravel.undo(); + // currentIndex = 1, canRedo = 1 < 2 = true + expect(timeTravel.currentIndex, equals(1)); + expect(timeTravel.canRedo, isTrue); + + timeTravel.redo(); + // currentIndex = 2, canRedo = 2 < 2 = false + expect(timeTravel.currentIndex, equals(2)); + expect(timeTravel.canRedo, isFalse); + }); + + test('canUndo boundary at index 0', () { + final timeTravel = TimeTravelEnhancer(); + + createStore(counterReducer, ( + count: 0, + ), enhancer: timeTravel.enhancer).dispatch(const Increment()); + + // history = [0, 1], currentIndex = 1 + // canUndo = currentIndex > 0 = true + expect(timeTravel.currentIndex, equals(1)); + expect(timeTravel.canUndo, isTrue); + + timeTravel.undo(); + // currentIndex = 0, canUndo = 0 > 0 = false + expect(timeTravel.currentIndex, equals(0)); + expect(timeTravel.canUndo, isFalse); + }); + + test('jumpTo boundary at index 0', () { + final timeTravel = TimeTravelEnhancer(); + + final store = + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + // Valid: jumpTo(0) should work (index >= 0) + timeTravel.jumpTo(0); + expect(store.getState().count, equals(0)); + + // Invalid: jumpTo(-1) should throw (index < 0) + expect(() => timeTravel.jumpTo(-1), throwsA(isA())); + }); + + test('jumpTo boundary at history.length - 1', () { + final timeTravel = TimeTravelEnhancer(); + + final store = + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + // history = [0, 1, 2], length = 3 + // Valid: jumpTo(2) should work (index < length) + timeTravel.jumpTo(2); + expect(store.getState().count, equals(2)); + + // Invalid: jumpTo(3) should throw (index >= length) + expect(() => timeTravel.jumpTo(3), throwsA(isA())); + }); + + test('truncates history correctly after undo and new dispatch', () { + final timeTravel = TimeTravelEnhancer(); + + final store = + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) // count: 1, index 1 + ..dispatch(const Increment()) // count: 2, index 2 + ..dispatch(const Increment()); // count: 3, index 3 + + // history = [0, 1, 2, 3], length = 4, currentIndex = 3 + expect(timeTravel.history.length, equals(4)); + expect(timeTravel.currentIndex, equals(3)); + + // Undo twice: currentIndex = 1 + timeTravel + ..undo() + ..undo(); + expect(timeTravel.currentIndex, equals(1)); + // getState() returns the viewed state (history at currentIndex) + expect(store.getState().count, equals(1)); + + // Dispatch new action - truncates future and adds new state + // NOTE: The reducer receives internal store state (count=3), not view + // Before: history = [0, 1, 2, 3], currentIndex = 1 + // Decrement(3) = 2 + // Truncates indices 2,3 and adds 2: history = [0, 1, 2] + store.dispatch(const Decrement()); + + // Verify truncation happened correctly + expect(timeTravel.history.length, equals(3)); + expect(timeTravel.history[0].count, equals(0)); + expect(timeTravel.history[1].count, equals(1)); + expect(timeTravel.history[2].count, equals(2)); // 3 - 1 = 2 + expect(timeTravel.currentIndex, equals(2)); + expect(timeTravel.canRedo, isFalse); + }); + + test('truncation uses correct range calculation', () { + final timeTravel = TimeTravelEnhancer(); + + final store = + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) // index 1 + ..dispatch(const Increment()) // index 2 + ..dispatch(const Increment()) // index 3 + ..dispatch(const Increment()); // index 4 + + // history = [0, 1, 2, 3, 4], currentIndex = 4 + expect(timeTravel.history.length, equals(5)); + + // Go back to index 2 + timeTravel + ..undo() + ..undo(); + expect(timeTravel.currentIndex, equals(2)); + + // Dispatch SetValue(99) - this sets count to 99 regardless of state + // Truncates indices 3,4 and adds 99: history = [0, 1, 2, 99] + store.dispatch(const SetValue(99)); + + expect(timeTravel.history.length, equals(4)); // [0, 1, 2, 99] + expect(timeTravel.history[3].count, equals(99)); + expect(timeTravel.currentIndex, equals(3)); + }); }); } diff --git a/packages/reflux/test/mutation_killer_test.dart b/packages/reflux/test/mutation_killer_test.dart new file mode 100644 index 0000000..e6a0e84 --- /dev/null +++ b/packages/reflux/test/mutation_killer_test.dart @@ -0,0 +1,453 @@ +// Tests specifically designed to kill surviving mutations. +// These tests target the exact mutations that survived in mutation testing. +import 'package:reflux/reflux.dart'; +import 'package:test/test.dart'; + +typedef CounterState = ({int count}); +typedef NullableState = ({int? value, String? name, bool? flag}); +typedef MultiNullState = ({int? a, int? b, int? c, int? d, int? e}); + +sealed class CounterAction extends Action { + const CounterAction(); +} + +final class Increment extends CounterAction { + const Increment(); +} + +final class SetValue extends CounterAction { + const SetValue(this.value); + final int value; +} + +CounterState counterReducer(CounterState state, Action action) => + switch (action) { + Increment() => (count: state.count + 1), + SetValue(:final value) => (count: value), + _ => state, + }; + +void main() { + // ============================================================ + // SELECTORS: hasCache = false -> true mutations + // ============================================================ + // These mutations change initial hasCache from false to true. + // If hasCache starts as true, the first call checks: + // if (hasCache && identical(input, lastInput)) + // Since lastInput is null initially, if the actual input is ALSO null, + // identical(null, null) is true and it would return cached null result + // instead of computing. + + group('createSelector3 hasCache mutation killer', () { + test('computes when all inputs are null on first call', () { + var computeCount = 0; + + final selector = + createSelector3( + (s) => s.value, + (s) => s.name, + (s) => s.flag, + (a, b, c) { + computeCount++; + return 'computed: $a, $b, $c'; + }, + ); + + final result = selector((value: null, name: null, flag: null)); + expect(result, equals('computed: null, null, null')); + expect(computeCount, equals(1)); + }); + + test('computes when first input is null', () { + var computeCount = 0; + + final selector = + createSelector3( + (s) => s.value, + (s) => s.name, + (s) => s.flag, + (a, b, c) { + computeCount++; + return 'computed: $a, $b, $c'; + }, + ); + + final result = selector((value: null, name: 'test', flag: true)); + expect(result, equals('computed: null, test, true')); + expect(computeCount, equals(1)); + }); + }); + + group('createSelector4 hasCache mutation killer', () { + test('computes when all inputs are null on first call', () { + var computeCount = 0; + + final selector = + createSelector4( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (a, b, c, d) { + computeCount++; + return 'computed: $a, $b, $c, $d'; + }, + ); + + final result = selector((a: null, b: null, c: null, d: null, e: null)); + expect(result, equals('computed: null, null, null, null')); + expect(computeCount, equals(1)); + }); + }); + + group('createSelector5 hasCache mutation killer', () { + test('computes when all inputs are null on first call', () { + var computeCount = 0; + + final selector = + createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (a, b, c, d, e) { + computeCount++; + return 'computed: $a, $b, $c, $d, $e'; + }, + ); + + final result = selector((a: null, b: null, c: null, d: null, e: null)); + expect(result, equals('computed: null, null, null, null, null')); + expect(computeCount, equals(1)); + }); + }); + + group('ResettableSelector.create1 hasCache mutation killer', () { + test('computes when input is null on first call', () { + var computeCount = 0; + + final selector = ResettableSelector.create1<({int? value}), int?, String>( + (s) => s.value, + (v) { + computeCount++; + return 'computed: $v'; + }, + ); + + final result = selector.select((value: null)); + expect(result, equals('computed: null')); + expect(computeCount, equals(1)); + }); + + test('resetCache sets hasCache to false - verifies reset then null', () { + var computeCount = 0; + + // First call with null, then non-null, reset, then null again + ResettableSelector.create1<({int? value}), int?, String>((s) => s.value, ( + v, + ) { + computeCount++; + return 'computed: $v'; + }) + ..select((value: null)) + ..select((value: 42)) + ..resetCache() + ..select((value: null)); + expect(computeCount, equals(3)); + }); + }); + + group('ResettableSelector.create2 hasCache mutation killer', () { + test('computes when both inputs are null on first call', () { + var computeCount = 0; + + final selector = + ResettableSelector.create2<({int? a, int? b}), int?, int?, String>( + (s) => s.a, + (s) => s.b, + (a, b) { + computeCount++; + return 'computed: $a, $b'; + }, + ); + + final result = selector.select((a: null, b: null)); + expect(result, equals('computed: null, null')); + expect(computeCount, equals(1)); + }); + + test('resetCache sets hasCache to false - verifies reset then null', () { + var computeCount = 0; + + // First call with nulls, then non-null, reset, then nulls again + ResettableSelector.create2<({int? a, int? b}), int?, int?, String>( + (s) => s.a, + (s) => s.b, + (a, b) { + computeCount++; + return 'computed: $a, $b'; + }, + ) + ..select((a: null, b: null)) + ..select((a: 1, b: 2)) + ..resetCache() + ..select((a: null, b: null)); + expect(computeCount, equals(3)); + }); + }); + + // ============================================================ + // STORE: isSubscribed mutations + // ============================================================ + // Line 86: (!isSubscribed) -> (false) - the early return guard + // Line 89: isSubscribed = false -> true - setting the flag + + group('Store isSubscribed mutation killer', () { + test('double unsubscribe does not remove listener twice', () { + final store = createStore(counterReducer, (count: 0)); + var callCount = 0; + + void listener() => callCount++; + + final unsubscribe = store.subscribe(listener); + + store.dispatch(const Increment()); + expect(callCount, equals(1)); + + unsubscribe(); + + store.dispatch(const Increment()); + expect(callCount, equals(1)); + + // Add the SAME listener again + final unsubscribe2 = store.subscribe(listener); + + store.dispatch(const Increment()); + expect(callCount, equals(2)); + + // Call the FIRST unsubscribe again (double unsubscribe) + // If mutation (!isSubscribed) -> (false), this would try to remove + // listener again, removing the second subscription! + unsubscribe(); + + // Listener should STILL be called because second subscription active + store.dispatch(const Increment()); + expect(callCount, equals(3)); + + unsubscribe2(); + }); + + test('isSubscribed flag prevents double removal', () { + final store = createStore(counterReducer, (count: 0)); + final callCounts = [0, 0]; + + final unsubscribe1 = store.subscribe(() => callCounts[0]++); + store + ..subscribe(() => callCounts[1]++) + ..dispatch(const Increment()); + expect(callCounts, equals([1, 1])); + + unsubscribe1(); + store.dispatch(const Increment()); + expect(callCounts, equals([1, 2])); + + // Call unsubscribe again multiple times + unsubscribe1(); + unsubscribe1(); + unsubscribe1(); + + // Second listener should still work + store.dispatch(const Increment()); + expect(callCounts, equals([1, 3])); + }); + }); + + // ============================================================ + // ENHANCERS: TimeTravelEnhancer boundary mutations + // ============================================================ + // Line 128: < -> <= and - -> + + // Original: if (_currentIndex < _history.length - 1) + + group('TimeTravelEnhancer boundary mutation killer', () { + test('truncation boundary: exactly at end vs one before end', () { + final timeTravel = TimeTravelEnhancer(); + final store = + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + expect(timeTravel.history.length, equals(4)); + + // Go back to state 2 (index 2, count=2) + timeTravel.jumpTo(2); + expect(store.getState().count, equals(2)); + + // Dispatch new action - should truncate history after index 2 + store.dispatch(const SetValue(100)); + + // History should now be: 0, 1, 2, 100 (4 items) + expect(timeTravel.history.length, equals(4)); + expect(timeTravel.history.last, equals((count: 100))); + }); + + test('truncation at exact boundary where < vs <= differs', () { + final timeTravel = TimeTravelEnhancer(); + final store = + createStore(counterReducer, (count: 0), enhancer: timeTravel.enhancer) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + + expect(timeTravel.history.length, equals(3)); + + // Jump to last state (index 2) + // _currentIndex = 2, _history.length = 3 + // Check: if (_currentIndex < _history.length - 1) + // = if (2 < 3 - 1) = if (2 < 2) = FALSE -> don't truncate + // Mutation: if (2 <= 3 - 1) = if (2 <= 2) = TRUE -> truncate + timeTravel.jumpTo(2); + expect(store.getState().count, equals(2)); + + // Dispatch new action + store.dispatch(const Increment()); + + // Original: no truncation needed, just append + // History should be: 0, 1, 2, 3 (4 items) + expect(timeTravel.history.length, equals(4)); + expect( + timeTravel.history, + equals([(count: 0), (count: 1), (count: 2), (count: 3)]), + ); + }); + + test('arithmetic boundary: - vs + in history length calculation', () { + final timeTravel = TimeTravelEnhancer(); + final store = createStore(counterReducer, ( + count: 0, + ), enhancer: timeTravel.enhancer)..dispatch(const Increment()); + + expect(timeTravel.history.length, equals(2)); + + // Jump back to state 0 (index 0) + timeTravel.jumpTo(0); + expect(store.getState().count, equals(0)); + + // Try index 1 case + timeTravel.jumpTo(1); + // _currentIndex = 1, _history.length = 2 + // Original: if (1 < 2 - 1) = if (1 < 1) = FALSE + // Mutation: if (1 < 2 + 1) = if (1 < 3) = TRUE + expect(store.getState().count, equals(1)); + + // Dispatch - with mutation this would incorrectly truncate + store.dispatch(const Increment()); + + // Should have 3 states: 0, 1, 2 + expect(timeTravel.history.length, equals(3)); + expect(store.getState().count, equals(2)); + }); + + test('verifies truncation removes future states correctly', () { + final timeTravel = TimeTravelEnhancer(); + final store = createStore(counterReducer, ( + count: 0, + ), enhancer: timeTravel.enhancer); + + for (var i = 0; i < 4; i++) { + store.dispatch(const Increment()); + } + expect(timeTravel.history.length, equals(5)); + expect(store.getState().count, equals(4)); + + // Go back to count=1 (index 1) + timeTravel.jumpTo(1); + expect(store.getState().count, equals(1)); + expect(timeTravel.history.length, equals(5)); + + // Dispatch new action - should truncate states 2,3,4 and add new state + store.dispatch(const SetValue(99)); + + // History should be: 0, 1, 99 (3 items) + expect(timeTravel.history.length, equals(3)); + expect(timeTravel.history, equals([(count: 0), (count: 1), (count: 99)])); + }); + + test('no truncation when at latest state', () { + final timeTravel = TimeTravelEnhancer(); + final store = createStore(counterReducer, ( + count: 0, + ), enhancer: timeTravel.enhancer)..dispatch(const Increment()); + + // At latest state (index 1, length 2) + // _currentIndex=1, _history.length=2 + // Check: if (1 < 2 - 1) = if (1 < 1) = false -> no truncation + expect(timeTravel.currentIndex, equals(1)); + expect(timeTravel.history.length, equals(2)); + + // Dispatch another - should just append, no truncation + store.dispatch(const Increment()); + + expect(timeTravel.history.length, equals(3)); + expect(timeTravel.history, equals([(count: 0), (count: 1), (count: 2)])); + }); + }); + + // ============================================================ + // COMPOSE ENHANCERS: isEmpty and length==1 mutations + // ============================================================ + + group('composeEnhancers mutation killer', () { + test('empty list returns identity enhancer that works', () { + final composed = composeEnhancers([]); + final store = createStore(counterReducer, (count: 0), enhancer: composed) + ..dispatch(const Increment()) + ..dispatch(const Increment()); + expect(store.getState().count, equals(2)); + }); + + test('single enhancer is returned directly and called', () { + var enhancerCalled = false; + var createStoreCalled = false; + + Store singleEnhancer( + Store Function(Reducer, CounterState) + createStoreFn, + Reducer reducer, + CounterState preloadedState, + ) { + enhancerCalled = true; + final store = createStoreFn(reducer, preloadedState); + createStoreCalled = true; + return store; + } + + final composed = composeEnhancers([singleEnhancer]); + createStore(counterReducer, (count: 0), enhancer: composed); + + expect(enhancerCalled, isTrue); + expect(createStoreCalled, isTrue); + }); + + test('multiple enhancers composed right-to-left', () { + final order = []; + + StoreEnhancer makeEnhancer(int id) => + (createStore, reducer, preloadedState) { + order.add(id); + return createStore(reducer, preloadedState); + }; + + final composed = composeEnhancers([ + makeEnhancer(1), + makeEnhancer(2), + makeEnhancer(3), + ]); + + createStore(counterReducer, (count: 0), enhancer: composed); + + // Enhancers should be applied right-to-left but execute in order + expect(order, equals([1, 2, 3])); + }); + }); +} diff --git a/packages/reflux/test/selector_memoization_test.dart b/packages/reflux/test/selector_memoization_test.dart new file mode 100644 index 0000000..656806d --- /dev/null +++ b/packages/reflux/test/selector_memoization_test.dart @@ -0,0 +1,869 @@ +import 'package:reflux/reflux.dart'; +import 'package:test/test.dart'; + +typedef State3 = ({int a, int b, int c}); +typedef State4 = ({int a, int b, int c, int d}); +typedef State5 = ({int a, int b, int c, int d, int e}); +typedef State2 = ({int a, int b}); + +void main() { + group('createSelector3 memoization', () { + test('returns cached result when all inputs identical', () { + var computeCount = 0; + const a = 1; + const b = 2; + const c = 3; + + final selector = createSelector3( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (x, y, z) { + computeCount++; + return x + y + z; + }, + ); + + const state = (a: a, b: b, c: c); + selector(state); + expect(computeCount, equals(1)); + + selector(state); + expect(computeCount, equals(1)); + }); + + test('recomputes when first input changes', () { + var computeCount = 0; + + final selector = createSelector3( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (x, y, z) { + computeCount++; + return x + y + z; + }, + ); + + selector((a: 1, b: 2, c: 3)); + expect(computeCount, equals(1)); + + selector((a: 100, b: 2, c: 3)); + expect(computeCount, equals(2)); + }); + + test('recomputes when second input changes', () { + var computeCount = 0; + + final selector = createSelector3( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (x, y, z) { + computeCount++; + return x + y + z; + }, + ); + + selector((a: 1, b: 2, c: 3)); + expect(computeCount, equals(1)); + + selector((a: 1, b: 200, c: 3)); + expect(computeCount, equals(2)); + }); + + test('recomputes when third input changes', () { + var computeCount = 0; + + final selector = createSelector3( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (x, y, z) { + computeCount++; + return x + y + z; + }, + ); + + selector((a: 1, b: 2, c: 3)); + expect(computeCount, equals(1)); + + selector((a: 1, b: 2, c: 300)); + expect(computeCount, equals(2)); + }); + + test('caches result after first computation', () { + var computeCount = 0; + final list1 = [1]; + final list2 = [2]; + final list3 = [3]; + + final selector = + createSelector3< + ({List a, List b, List c}), + List, + List, + List, + int + >((s) => s.a, (s) => s.b, (s) => s.c, (a, b, c) { + computeCount++; + return a.first + b.first + c.first; + }); + + final state1 = (a: list1, b: list2, c: list3); + final state2 = (a: list1, b: list2, c: list3); + + selector(state1); + expect(computeCount, equals(1)); + + selector(state2); + expect(computeCount, equals(1)); + }); + }); + + group('createSelector4 memoization', () { + test('returns cached result when all inputs identical', () { + var computeCount = 0; + + final selector = createSelector4( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (w, x, y, z) { + computeCount++; + return w + x + y + z; + }, + ); + + const state = (a: 1, b: 2, c: 3, d: 4); + selector(state); + expect(computeCount, equals(1)); + + selector(state); + expect(computeCount, equals(1)); + }); + + test('recomputes when first input changes', () { + var computeCount = 0; + + final selector = createSelector4( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (w, x, y, z) { + computeCount++; + return w + x + y + z; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4)); + expect(computeCount, equals(1)); + + selector((a: 100, b: 2, c: 3, d: 4)); + expect(computeCount, equals(2)); + }); + + test('recomputes when second input changes', () { + var computeCount = 0; + + final selector = createSelector4( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (w, x, y, z) { + computeCount++; + return w + x + y + z; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4)); + expect(computeCount, equals(1)); + + selector((a: 1, b: 200, c: 3, d: 4)); + expect(computeCount, equals(2)); + }); + + test('recomputes when third input changes', () { + var computeCount = 0; + + final selector = createSelector4( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (w, x, y, z) { + computeCount++; + return w + x + y + z; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4)); + expect(computeCount, equals(1)); + + selector((a: 1, b: 2, c: 300, d: 4)); + expect(computeCount, equals(2)); + }); + + test('recomputes when fourth input changes', () { + var computeCount = 0; + + final selector = createSelector4( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (w, x, y, z) { + computeCount++; + return w + x + y + z; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4)); + expect(computeCount, equals(1)); + + selector((a: 1, b: 2, c: 3, d: 400)); + expect(computeCount, equals(2)); + }); + + test('caches result after first computation', () { + var computeCount = 0; + final list1 = [1]; + final list2 = [2]; + final list3 = [3]; + final list4 = [4]; + + final selector = + createSelector4< + ({List a, List b, List c, List d}), + List, + List, + List, + List, + int + >((s) => s.a, (s) => s.b, (s) => s.c, (s) => s.d, (a, b, c, d) { + computeCount++; + return a.first + b.first + c.first + d.first; + }); + + final state1 = (a: list1, b: list2, c: list3, d: list4); + final state2 = (a: list1, b: list2, c: list3, d: list4); + + selector(state1); + expect(computeCount, equals(1)); + + selector(state2); + expect(computeCount, equals(1)); + }); + }); + + group('createSelector5 memoization', () { + test('returns cached result when all inputs identical', () { + var computeCount = 0; + + final selector = createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (v, w, x, y, z) { + computeCount++; + return v + w + x + y + z; + }, + ); + + const state = (a: 1, b: 2, c: 3, d: 4, e: 5); + selector(state); + expect(computeCount, equals(1)); + + selector(state); + expect(computeCount, equals(1)); + }); + + test('recomputes when first input changes', () { + var computeCount = 0; + + final selector = createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (v, w, x, y, z) { + computeCount++; + return v + w + x + y + z; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4, e: 5)); + expect(computeCount, equals(1)); + + selector((a: 100, b: 2, c: 3, d: 4, e: 5)); + expect(computeCount, equals(2)); + }); + + test('recomputes when second input changes', () { + var computeCount = 0; + + final selector = createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (v, w, x, y, z) { + computeCount++; + return v + w + x + y + z; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4, e: 5)); + expect(computeCount, equals(1)); + + selector((a: 1, b: 200, c: 3, d: 4, e: 5)); + expect(computeCount, equals(2)); + }); + + test('recomputes when third input changes', () { + var computeCount = 0; + + final selector = createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (v, w, x, y, z) { + computeCount++; + return v + w + x + y + z; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4, e: 5)); + expect(computeCount, equals(1)); + + selector((a: 1, b: 2, c: 300, d: 4, e: 5)); + expect(computeCount, equals(2)); + }); + + test('recomputes when fourth input changes', () { + var computeCount = 0; + + final selector = createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (v, w, x, y, z) { + computeCount++; + return v + w + x + y + z; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4, e: 5)); + expect(computeCount, equals(1)); + + selector((a: 1, b: 2, c: 3, d: 400, e: 5)); + expect(computeCount, equals(2)); + }); + + test('recomputes when fifth input changes', () { + var computeCount = 0; + + final selector = createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (v, w, x, y, z) { + computeCount++; + return v + w + x + y + z; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4, e: 5)); + expect(computeCount, equals(1)); + + selector((a: 1, b: 2, c: 3, d: 4, e: 500)); + expect(computeCount, equals(2)); + }); + + test('caches result after first computation', () { + var computeCount = 0; + final list1 = [1]; + final list2 = [2]; + final list3 = [3]; + final list4 = [4]; + final list5 = [5]; + + final selector = + createSelector5< + ({List a, List b, List c, List d, List e}), + List, + List, + List, + List, + List, + int + >((s) => s.a, (s) => s.b, (s) => s.c, (s) => s.d, (s) => s.e, ( + a, + b, + c, + d, + e, + ) { + computeCount++; + return a.first + b.first + c.first + d.first + e.first; + }); + + final state1 = (a: list1, b: list2, c: list3, d: list4, e: list5); + final state2 = (a: list1, b: list2, c: list3, d: list4, e: list5); + + selector(state1); + expect(computeCount, equals(1)); + + selector(state2); + expect(computeCount, equals(1)); + }); + }); + + group('ResettableSelector.create1 memoization', () { + test('returns cached result when input identical', () { + var computeCount = 0; + final list = [1, 2, 3]; + + final selector = + ResettableSelector.create1<({List nums}), List, int>( + (s) => s.nums, + (nums) { + computeCount++; + return nums.length; + }, + ); + + final state = (nums: list); + selector.select(state); + expect(computeCount, equals(1)); + + selector.select(state); + expect(computeCount, equals(1)); + }); + + test('recomputes when input changes', () { + var computeCount = 0; + + ResettableSelector.create1<({List nums}), List, int>( + (s) => s.nums, + (nums) { + computeCount++; + return nums.length; + }, + ) + ..select((nums: [1, 2, 3])) + ..select((nums: [1, 2, 3, 4])); + expect(computeCount, equals(2)); + }); + + test('resets cache state variables', () { + var computeCount = 0; + final list = [1, 2, 3]; + + final selector = + ResettableSelector.create1<({List nums}), List, int>( + (s) => s.nums, + (nums) { + computeCount++; + return nums.length; + }, + ); + + final state = (nums: list); + selector.select(state); + expect(computeCount, equals(1)); + + selector + ..resetCache() + ..select(state); + expect(computeCount, equals(2)); + }); + }); + + group('ResettableSelector.create2 memoization', () { + test('returns cached result when both inputs identical', () { + var computeCount = 0; + final list = [1, 2, 3]; + const filter = 'test'; + + final selector = + ResettableSelector.create2< + ({List nums, String filter}), + List, + String, + String + >((s) => s.nums, (s) => s.filter, (nums, f) { + computeCount++; + return '$f: ${nums.length}'; + }); + + final state = (nums: list, filter: filter); + selector + ..select(state) + ..select(state); + expect(computeCount, equals(1)); + }); + + test('recomputes when first input changes', () { + var computeCount = 0; + + ResettableSelector.create2< + ({List nums, String filter}), + List, + String, + String + >((s) => s.nums, (s) => s.filter, (nums, f) { + computeCount++; + return '$f: ${nums.length}'; + }) + ..select((nums: [1, 2, 3], filter: 'test')) + ..select((nums: [1, 2, 3, 4], filter: 'test')); + expect(computeCount, equals(2)); + }); + + test('recomputes when second input changes', () { + var computeCount = 0; + final list = [1, 2, 3]; + + ResettableSelector.create2< + ({List nums, String filter}), + List, + String, + String + >((s) => s.nums, (s) => s.filter, (nums, f) { + computeCount++; + return '$f: ${nums.length}'; + }) + ..select((nums: list, filter: 'test')) + ..select((nums: list, filter: 'changed')); + expect(computeCount, equals(2)); + }); + + test('resets cache state variables', () { + var computeCount = 0; + final list = [1, 2, 3]; + const filter = 'test'; + + final selector = + ResettableSelector.create2< + ({List nums, String filter}), + List, + String, + String + >((s) => s.nums, (s) => s.filter, (nums, f) { + computeCount++; + return '$f: ${nums.length}'; + }); + + final state = (nums: list, filter: filter); + selector.select(state); + expect(computeCount, equals(1)); + + selector + ..resetCache() + ..select(state); + expect(computeCount, equals(2)); + }); + }); + + group('createSelector1 hasCache behavior', () { + test('first call always computes', () { + var computeCount = 0; + final list = [1, 2, 3]; + + final selector = createSelector1<({List nums}), List, int>( + (s) => s.nums, + (nums) { + computeCount++; + return nums.length; + }, + ); + + final state = (nums: list); + final result = selector(state); + expect(result, equals(3)); + expect(computeCount, equals(1)); + }); + + test('caches after first call', () { + var computeCount = 0; + final list = [1, 2, 3]; + + final selector = createSelector1<({List nums}), List, int>( + (s) => s.nums, + (nums) { + computeCount++; + return nums.length; + }, + ); + + final state = (nums: list); + selector(state); + selector(state); + selector(state); + expect(computeCount, equals(1)); + }); + }); + + group('createSelector2 hasCache behavior', () { + test('first call always computes', () { + var computeCount = 0; + + final selector = createSelector2( + (s) => s.a, + (s) => s.b, + (a, b) { + computeCount++; + return a + b; + }, + ); + + const state = (a: 1, b: 2); + final result = selector(state); + expect(result, equals(3)); + expect(computeCount, equals(1)); + }); + + test('caches when both inputs identical', () { + var computeCount = 0; + + final selector = createSelector2( + (s) => s.a, + (s) => s.b, + (a, b) { + computeCount++; + return a + b; + }, + ); + + const state = (a: 1, b: 2); + selector(state); + selector(state); + selector(state); + expect(computeCount, equals(1)); + }); + + test('recomputes only when input1 changes', () { + var computeCount = 0; + + final selector = createSelector2( + (s) => s.a, + (s) => s.b, + (a, b) { + computeCount++; + return a + b; + }, + ); + + selector((a: 1, b: 2)); + expect(computeCount, equals(1)); + + selector((a: 100, b: 2)); + expect(computeCount, equals(2)); + }); + + test('recomputes only when input2 changes', () { + var computeCount = 0; + + final selector = createSelector2( + (s) => s.a, + (s) => s.b, + (a, b) { + computeCount++; + return a + b; + }, + ); + + selector((a: 1, b: 2)); + expect(computeCount, equals(1)); + + selector((a: 1, b: 200)); + expect(computeCount, equals(2)); + }); + }); + + group('createStructuredSelector hasCache behavior', () { + test('first call always computes', () { + var computeCount = 0; + + final selector = createStructuredSelector<({int value}), int>((state) { + computeCount++; + return state.value * 2; + }); + + const state = (value: 5); + final result = selector(state); + expect(result, equals(10)); + expect(computeCount, equals(1)); + }); + + test('caches when state identical', () { + var computeCount = 0; + + final selector = createStructuredSelector<({int value}), int>((state) { + computeCount++; + return state.value * 2; + }); + + const state = (value: 5); + selector(state); + selector(state); + selector(state); + expect(computeCount, equals(1)); + }); + + test('recomputes when state changes', () { + var computeCount = 0; + + final selector = createStructuredSelector<({int value}), int>((state) { + computeCount++; + return state.value * 2; + }); + + selector((value: 5)); + expect(computeCount, equals(1)); + + selector((value: 10)); + expect(computeCount, equals(2)); + }); + }); + + // Tests to kill hasCache = false → true mutations by using null inputs + // When input is null and lastInput is null, identical(null, null) = true + // If hasCache starts as true (mutation), it would return null instead of + // computing + + group('createSelector3 null input handling', () { + test('first call computes even with null inputs', () { + final selector = + createSelector3<({int? a, int? b, int? c}), int?, int?, int?, String>( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (a, b, c) => 'a=$a,b=$b,c=$c', + ); + + final result = selector((a: null, b: null, c: null)); + expect(result, isNotNull); + expect(result, equals('a=null,b=null,c=null')); + }); + }); + + group('createSelector4 null input handling', () { + test('first call computes even with null inputs', () { + final selector = + createSelector4< + ({int? a, int? b, int? c, int? d}), + int?, + int?, + int?, + int?, + String + >( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (a, b, c, d) => 'r=$a$b$c$d', + ); + + final result = selector((a: null, b: null, c: null, d: null)); + expect(result, isNotNull); + expect(result, equals('r=nullnullnullnull')); + }); + }); + + group('createSelector5 null input handling', () { + test('first call computes even with null inputs', () { + final selector = + createSelector5< + ({int? a, int? b, int? c, int? d, int? e}), + int?, + int?, + int?, + int?, + int?, + String + >( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (a, b, c, d, e) => 'r=$a$b$c$d$e', + ); + + final result = selector((a: null, b: null, c: null, d: null, e: null)); + expect(result, isNotNull); + expect(result, equals('r=nullnullnullnullnull')); + }); + }); + + group('ResettableSelector.create1 null input handling', () { + test('first call computes even with null input', () { + final selector = ResettableSelector.create1<({int? value}), int?, String>( + (s) => s.value, + (v) => 'val=$v', + ); + + final result = selector.select((value: null)); + expect(result, isNotNull); + expect(result, equals('val=null')); + }); + + test('after reset computes even with null input', () { + final selector = + ResettableSelector.create1<({int? value}), int?, String>( + (s) => s.value, + (v) => 'val=$v', + ) + ..select((value: 42)) + ..resetCache(); + final result = selector.select((value: null)); + expect(result, isNotNull); + expect(result, equals('val=null')); + }); + }); + + group('ResettableSelector.create2 null input handling', () { + test('first call computes even with null inputs', () { + final selector = + ResettableSelector.create2<({int? a, int? b}), int?, int?, String>( + (s) => s.a, + (s) => s.b, + (a, b) => 'a=$a,b=$b', + ); + + final result = selector.select((a: null, b: null)); + expect(result, isNotNull); + expect(result, equals('a=null,b=null')); + }); + + test('after reset computes even with null inputs', () { + final selector = + ResettableSelector.create2<({int? a, int? b}), int?, int?, String>( + (s) => s.a, + (s) => s.b, + (a, b) => 'a=$a,b=$b', + ) + ..select((a: 1, b: 2)) + ..resetCache(); + final result = selector.select((a: null, b: null)); + expect(result, isNotNull); + expect(result, equals('a=null,b=null')); + }); + }); +} diff --git a/packages/reflux/test/selectors_test.dart b/packages/reflux/test/selectors_test.dart index 67abe75..e7938ec 100644 --- a/packages/reflux/test/selectors_test.dart +++ b/packages/reflux/test/selectors_test.dart @@ -20,6 +20,22 @@ void main() { expect(getSum(state), equals(6)); }); + test('first call always computes (hasCache starts false)', () { + var computeCount = 0; + List getNumbers(AppState state) => state.numbers; + final getSum = createSelector1(getNumbers, (nums) { + computeCount++; + return nums.length; + }); + + // First call MUST compute, hasCache is false + final numbers = [1, 2, 3]; + final state = (numbers: numbers, filter: ''); + final result = getSum(state); + expect(result, equals(3)); + expect(computeCount, equals(1)); + }); + test('memoizes result when input unchanged', () { var computeCount = 0; List getNumbers(AppState state) => state.numbers; @@ -39,6 +55,30 @@ void main() { expect(computeCount, equals(1)); // Should not recompute }); + test('hasCache becomes true after first computation', () { + var computeCount = 0; + List getNumbers(AppState state) => state.numbers; + final selector = createSelector1(getNumbers, (nums) { + computeCount++; + return nums.length; + }); + + final numbers = [1, 2, 3]; + final state = (numbers: numbers, filter: ''); + + // First call computes + selector(state); + expect(computeCount, equals(1)); + + // Second call with same input uses cache (hasCache is now true) + selector(state); + expect(computeCount, equals(1)); + + // Third call still uses cache + selector((numbers: numbers, filter: 'different')); + expect(computeCount, equals(1)); + }); + test('recomputes when input changes', () { var computeCount = 0; List getNumbers(AppState state) => state.numbers; @@ -100,6 +140,64 @@ void main() { getFiltered(state); expect(computeCount, equals(1)); }); + + test('recomputes when only first input changes (tests && not ||)', () { + var computeCount = 0; + List getNumbers(AppState state) => state.numbers; + String getFilter(AppState state) => state.filter; + + final selector = createSelector2(getNumbers, getFilter, (nums, filter) { + computeCount++; + return '${nums.length}-$filter'; + }); + + const filter = 'same'; + selector((numbers: [1], filter: filter)); + expect(computeCount, equals(1)); + + // Change ONLY input1, keep input2 same + selector((numbers: [1, 2], filter: filter)); + expect(computeCount, equals(2)); // Must recompute + }); + + test('recomputes when only second input changes (tests && not ||)', () { + var computeCount = 0; + List getNumbers(AppState state) => state.numbers; + String getFilter(AppState state) => state.filter; + + final selector = createSelector2(getNumbers, getFilter, (nums, filter) { + computeCount++; + return '${nums.length}-$filter'; + }); + + final numbers = [1, 2, 3]; + selector((numbers: numbers, filter: 'a')); + expect(computeCount, equals(1)); + + // Change ONLY input2, keep input1 same + selector((numbers: numbers, filter: 'b')); + expect(computeCount, equals(2)); // Must recompute + }); + + test( + 'first call computes even with same inputs (hasCache starts false)', + () { + var computeCount = 0; + List getNumbers(AppState state) => state.numbers; + String getFilter(AppState state) => state.filter; + + final selector = createSelector2(getNumbers, getFilter, (nums, filter) { + computeCount++; + return nums.length; + }); + + final numbers = [1, 2, 3]; + final state = (numbers: numbers, filter: 'test'); + final result = selector(state); + expect(result, equals(3)); + expect(computeCount, equals(1)); + }, + ); }); group('createSelector3', () { @@ -112,6 +210,113 @@ void main() { expect(getSum((a: 1, b: 2, c: 3)), equals(6)); }); + + test('first call always computes (hasCache starts false)', () { + var computeCount = 0; + final selector = createSelector3( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (a, b, c) { + computeCount++; + return a + b + c; + }, + ); + + final result = selector((a: 1, b: 2, c: 3)); + expect(result, equals(6)); + expect(computeCount, equals(1)); + }); + + test('first call returns computed value not null', () { + // If hasCache starts as true (mutation), lastResult is null + // This test verifies we get actual computed value + final selector = createSelector3( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (a, b, c) => 'result:$a-$b-$c', + ); + + final result = selector((a: 1, b: 2, c: 3)); + expect(result, equals('result:1-2-3')); + expect(result.length, equals(12)); + }); + + test('memoizes when all inputs unchanged', () { + var computeCount = 0; + final selector = createSelector3( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (a, b, c) { + computeCount++; + return a + b + c; + }, + ); + + const state = (a: 1, b: 2, c: 3); + selector(state); + selector(state); + expect(computeCount, equals(1)); + }); + + test('recomputes when only input1 changes (tests && not ||)', () { + var computeCount = 0; + final selector = createSelector3( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (a, b, c) { + computeCount++; + return a + b + c; + }, + ); + + selector((a: 1, b: 2, c: 3)); + expect(computeCount, equals(1)); + + selector((a: 10, b: 2, c: 3)); // Only a changed + expect(computeCount, equals(2)); + }); + + test('recomputes when only input2 changes (tests && not ||)', () { + var computeCount = 0; + final selector = createSelector3( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (a, b, c) { + computeCount++; + return a + b + c; + }, + ); + + selector((a: 1, b: 2, c: 3)); + expect(computeCount, equals(1)); + + selector((a: 1, b: 20, c: 3)); // Only b changed + expect(computeCount, equals(2)); + }); + + test('recomputes when only input3 changes (tests && not ||)', () { + var computeCount = 0; + final selector = createSelector3( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (a, b, c) { + computeCount++; + return a + b + c; + }, + ); + + selector((a: 1, b: 2, c: 3)); + expect(computeCount, equals(1)); + + selector((a: 1, b: 2, c: 30)); // Only c changed + expect(computeCount, equals(2)); + }); }); group('createSelector4', () { @@ -131,6 +336,129 @@ void main() { expect(getSum((a: 1, b: 2, c: 3, d: 4)), equals(10)); }); + + test('first call always computes (hasCache starts false)', () { + var computeCount = 0; + final selector = createSelector4( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (a, b, c, d) { + computeCount++; + return a + b + c + d; + }, + ); + + final result = selector((a: 1, b: 2, c: 3, d: 4)); + expect(result, equals(10)); + expect(computeCount, equals(1)); + }); + + test('first call returns computed value not null', () { + final selector = createSelector4( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (a, b, c, d) => 'r:$a-$b-$c-$d', + ); + + final result = selector((a: 1, b: 2, c: 3, d: 4)); + expect(result, equals('r:1-2-3-4')); + expect(result.length, equals(9)); + }); + + test('memoizes when all inputs unchanged', () { + var computeCount = 0; + final selector = createSelector4( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (a, b, c, d) { + computeCount++; + return a + b + c + d; + }, + ); + + const state = (a: 1, b: 2, c: 3, d: 4); + selector(state); + selector(state); + expect(computeCount, equals(1)); + }); + + test('recomputes when only input1 changes (tests && not ||)', () { + var computeCount = 0; + final selector = createSelector4( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (a, b, c, d) { + computeCount++; + return a + b + c + d; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4)); + selector((a: 10, b: 2, c: 3, d: 4)); // Only a changed + expect(computeCount, equals(2)); + }); + + test('recomputes when only input2 changes (tests && not ||)', () { + var computeCount = 0; + final selector = createSelector4( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (a, b, c, d) { + computeCount++; + return a + b + c + d; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4)); + selector((a: 1, b: 20, c: 3, d: 4)); // Only b changed + expect(computeCount, equals(2)); + }); + + test('recomputes when only input3 changes (tests && not ||)', () { + var computeCount = 0; + final selector = createSelector4( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (a, b, c, d) { + computeCount++; + return a + b + c + d; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4)); + selector((a: 1, b: 2, c: 30, d: 4)); // Only c changed + expect(computeCount, equals(2)); + }); + + test('recomputes when only input4 changes (tests && not ||)', () { + var computeCount = 0; + final selector = createSelector4( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (a, b, c, d) { + computeCount++; + return a + b + c + d; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4)); + selector((a: 1, b: 2, c: 3, d: 40)); // Only d changed + expect(computeCount, equals(2)); + }); }); group('createSelector5', () { @@ -152,6 +480,155 @@ void main() { expect(getSum((a: 1, b: 2, c: 3, d: 4, e: 5)), equals(15)); }); + + test('first call always computes (hasCache starts false)', () { + var computeCount = 0; + final selector = createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (a, b, c, d, e) { + computeCount++; + return a + b + c + d + e; + }, + ); + + final result = selector((a: 1, b: 2, c: 3, d: 4, e: 5)); + expect(result, equals(15)); + expect(computeCount, equals(1)); + }); + + test('first call returns computed value not null', () { + final selector = createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (a, b, c, d, e) => 'v:$a$b$c$d$e', + ); + + final result = selector((a: 1, b: 2, c: 3, d: 4, e: 5)); + expect(result, equals('v:12345')); + expect(result.length, equals(7)); + }); + + test('memoizes when all inputs unchanged', () { + var computeCount = 0; + final selector = createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (a, b, c, d, e) { + computeCount++; + return a + b + c + d + e; + }, + ); + + const state = (a: 1, b: 2, c: 3, d: 4, e: 5); + selector(state); + selector(state); + expect(computeCount, equals(1)); + }); + + test('recomputes when only input1 changes (tests && not ||)', () { + var computeCount = 0; + final selector = createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (a, b, c, d, e) { + computeCount++; + return a + b + c + d + e; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4, e: 5)); + selector((a: 10, b: 2, c: 3, d: 4, e: 5)); // Only a changed + expect(computeCount, equals(2)); + }); + + test('recomputes when only input2 changes (tests && not ||)', () { + var computeCount = 0; + final selector = createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (a, b, c, d, e) { + computeCount++; + return a + b + c + d + e; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4, e: 5)); + selector((a: 1, b: 20, c: 3, d: 4, e: 5)); // Only b changed + expect(computeCount, equals(2)); + }); + + test('recomputes when only input3 changes (tests && not ||)', () { + var computeCount = 0; + final selector = createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (a, b, c, d, e) { + computeCount++; + return a + b + c + d + e; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4, e: 5)); + selector((a: 1, b: 2, c: 30, d: 4, e: 5)); // Only c changed + expect(computeCount, equals(2)); + }); + + test('recomputes when only input4 changes (tests && not ||)', () { + var computeCount = 0; + final selector = createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (a, b, c, d, e) { + computeCount++; + return a + b + c + d + e; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4, e: 5)); + selector((a: 1, b: 2, c: 3, d: 40, e: 5)); // Only d changed + expect(computeCount, equals(2)); + }); + + test('recomputes when only input5 changes (tests && not ||)', () { + var computeCount = 0; + final selector = createSelector5( + (s) => s.a, + (s) => s.b, + (s) => s.c, + (s) => s.d, + (s) => s.e, + (a, b, c, d, e) { + computeCount++; + return a + b + c + d + e; + }, + ); + + selector((a: 1, b: 2, c: 3, d: 4, e: 5)); + selector((a: 1, b: 2, c: 3, d: 4, e: 50)); // Only e changed + expect(computeCount, equals(2)); + }); }); group('ResettableSelector', () { @@ -180,6 +657,80 @@ void main() { expect(computeCount, equals(2)); }); + test('create1 first call computes (hasCache starts false)', () { + var computeCount = 0; + + final selector = ResettableSelector.create1, int>( + (s) => s.numbers, + (nums) { + computeCount++; + return nums.length; + }, + ); + + final numbers = [1, 2, 3]; + final state = (numbers: numbers, filter: ''); + final result = selector.select(state); + expect(result, equals(3)); + expect(computeCount, equals(1)); + }); + + test('create1 first call returns computed value not null', () { + final selector = ResettableSelector.create1, String>( + (s) => s.numbers, + (nums) => 'len:${nums.length}', + ); + + final result = selector.select((numbers: [1, 2, 3], filter: '')); + expect(result, equals('len:3')); + expect(result.length, equals(5)); + }); + + test('create1 recomputes when input changes (tests && not ||)', () { + var computeCount = 0; + + final selector = ResettableSelector.create1, int>( + (s) => s.numbers, + (nums) { + computeCount++; + return nums.length; + }, + )..select((numbers: [1, 2], filter: '')); + expect(computeCount, equals(1)); + + selector.select((numbers: [1, 2, 3], filter: '')); + expect(computeCount, equals(2)); + }); + + test('create1 resetCache sets hasCache to false', () { + var computeCount = 0; + + final selector = ResettableSelector.create1, int>( + (s) => s.numbers, + (nums) { + computeCount++; + return nums.length; + }, + ); + + final numbers = [1, 2, 3]; + final state = (numbers: numbers, filter: ''); + + // First call computes + selector.select(state); + expect(computeCount, equals(1)); + + // Cached call doesn't compute + selector.select(state); + expect(computeCount, equals(1)); + + // Reset clears cache, then compute again with same input + selector + ..resetCache() + ..select(state); + expect(computeCount, equals(2)); + }); + test('create2 allows resetting cache', () { var computeCount = 0; @@ -206,6 +757,106 @@ void main() { ..select(state); expect(computeCount, equals(2)); }); + + test('create2 first call computes (hasCache starts false)', () { + var computeCount = 0; + + final selector = + ResettableSelector.create2, String, String>( + (s) => s.numbers, + (s) => s.filter, + (nums, filter) { + computeCount++; + return '$filter: ${nums.length}'; + }, + ); + + final result = selector.select((numbers: [1, 2, 3], filter: 'test')); + expect(result, equals('test: 3')); + expect(computeCount, equals(1)); + }); + + test('create2 recomputes when only input1 changes (tests && not ||)', () { + var computeCount = 0; + + final selector = + ResettableSelector.create2, String, String>( + (s) => s.numbers, + (s) => s.filter, + (nums, filter) { + computeCount++; + return '$filter: ${nums.length}'; + }, + ); + + const filter = 'same'; + selector.select((numbers: [1], filter: filter)); + expect(computeCount, equals(1)); + + selector.select((numbers: [1, 2], filter: filter)); + expect(computeCount, equals(2)); + }); + + test('create2 recomputes when only input2 changes (tests && not ||)', () { + var computeCount = 0; + + final selector = + ResettableSelector.create2, String, String>( + (s) => s.numbers, + (s) => s.filter, + (nums, filter) { + computeCount++; + return '$filter: ${nums.length}'; + }, + ); + + final numbers = [1, 2, 3]; + selector.select((numbers: numbers, filter: 'a')); + expect(computeCount, equals(1)); + + selector.select((numbers: numbers, filter: 'b')); + expect(computeCount, equals(2)); + }); + + test('create2 resetCache sets hasCache to false', () { + var computeCount = 0; + + final selector = + ResettableSelector.create2, String, String>( + (s) => s.numbers, + (s) => s.filter, + (nums, filter) { + computeCount++; + return '$filter: ${nums.length}'; + }, + ); + + final numbers = [1, 2, 3]; + final state = (numbers: numbers, filter: 'test'); + + selector + ..select(state) + ..select(state); + expect(computeCount, equals(1)); + + selector + ..resetCache() + ..select(state); + expect(computeCount, equals(2)); + }); + + test('create2 first call returns computed value not null', () { + final selector = + ResettableSelector.create2, String, String>( + (s) => s.numbers, + (s) => s.filter, + (nums, filter) => '$filter:${nums.length}', + ); + + final result = selector.select((numbers: [1, 2, 3], filter: 'x')); + expect(result, equals('x:3')); + expect(result.length, equals(3)); + }); }); group('createStructuredSelector', () { @@ -237,5 +888,57 @@ void main() { selector(state); expect(computeCount, equals(1)); }); + + test('first call always computes (hasCache starts false)', () { + var computeCount = 0; + + final selector = createStructuredSelector((state) { + computeCount++; + return state.numbers.length; + }); + + final state = (numbers: [1, 2, 3], filter: ''); + final result = selector(state); + expect(result, equals(3)); + expect(computeCount, equals(1)); + }); + + test('recomputes when state changes', () { + var computeCount = 0; + + final selector = createStructuredSelector((state) { + computeCount++; + return state.numbers.length; + }); + + selector((numbers: [1, 2], filter: '')); + expect(computeCount, equals(1)); + + selector((numbers: [1, 2, 3], filter: '')); + expect(computeCount, equals(2)); + }); + + test('hasCache becomes true after first computation', () { + var computeCount = 0; + + final selector = createStructuredSelector((state) { + computeCount++; + return state.numbers.length; + }); + + final state = (numbers: [1, 2, 3], filter: ''); + + // First call computes + selector(state); + expect(computeCount, equals(1)); + + // Second call uses cache + selector(state); + expect(computeCount, equals(1)); + + // Third call still uses cache + selector(state); + expect(computeCount, equals(1)); + }); }); } diff --git a/packages/reflux/test/store_edge_cases_test.dart b/packages/reflux/test/store_edge_cases_test.dart new file mode 100644 index 0000000..aa46f01 --- /dev/null +++ b/packages/reflux/test/store_edge_cases_test.dart @@ -0,0 +1,310 @@ +import 'package:reflux/reflux.dart'; +import 'package:test/test.dart'; + +typedef CounterState = ({int count}); + +sealed class CounterAction extends Action { + const CounterAction(); +} + +final class Increment extends CounterAction { + const Increment(); +} + +final class Decrement extends CounterAction { + const Decrement(); +} + +final class TriggerBadBehavior extends Action { + const TriggerBadBehavior(); +} + +CounterState counterReducer(CounterState state, Action action) => + switch (action) { + Increment() => (count: state.count + 1), + Decrement() => (count: state.count - 1), + _ => state, + }; + +void main() { + group('Store unsubscribe edge cases', () { + test('unsubscribe when already unsubscribed does nothing', () { + final store = createStore(counterReducer, (count: 0)); + var callCount = 0; + + final unsubscribe = store.subscribe(() => callCount++); + store.dispatch(const Increment()); + expect(callCount, equals(1)); + + unsubscribe(); + store.dispatch(const Increment()); + expect(callCount, equals(1)); + + unsubscribe(); + store.dispatch(const Increment()); + expect(callCount, equals(1)); + }); + + test('unsubscribe sets isSubscribed to false', () { + final store = createStore(counterReducer, (count: 0)); + var callCount = 0; + + final unsubscribe = store.subscribe(() => callCount++); + store.dispatch(const Increment()); + expect(callCount, equals(1)); + + unsubscribe(); + unsubscribe(); + unsubscribe(); + + store.dispatch(const Increment()); + expect(callCount, equals(1)); + }); + + test('throwing during unsubscribe inside reducer', () { + late Store store; + + CounterState badReducer(CounterState state, Action action) { + if (action is TriggerBadBehavior) { + final unsubscribe = store.subscribe(() {}); + try { + unsubscribe(); + } on DispatchInReducerException { + return (count: -999); + } + } + return counterReducer(state, action); + } + + store = createStore(badReducer, (count: 0)); + expect( + () => store.dispatch(const TriggerBadBehavior()), + throwsA(isA()), + ); + }); + + test('dispatch during unsubscribe throws', () { + late Store store; + late Unsubscribe unsubscribe; + + CounterState badReducer(CounterState state, Action action) { + if (action is TriggerBadBehavior) { + unsubscribe(); + } + return counterReducer(state, action); + } + + store = createStore(badReducer, (count: 0)); + unsubscribe = store.subscribe(() {}); + + expect( + () => store.dispatch(const TriggerBadBehavior()), + throwsA(isA()), + ); + }); + }); + + group('DispatchInReducerException tests', () { + test('exception message is descriptive', () { + final exception = DispatchInReducerException(); + final message = exception.toString(); + + expect(message, contains('DispatchInReducerException')); + expect(message, contains('Cannot dispatch')); + expect(message, contains('reducer')); + }); + + test('exception is thrown on nested dispatch', () { + late Store store; + + CounterState badReducer(CounterState state, Action action) { + if (action is TriggerBadBehavior) { + store.dispatch(const Increment()); + } + return counterReducer(state, action); + } + + store = createStore(badReducer, (count: 0)); + expect( + () => store.dispatch(const TriggerBadBehavior()), + throwsA(isA()), + ); + }); + }); + + group('SubscribeInReducerException tests', () { + test('exception message is descriptive', () { + final exception = SubscribeInReducerException(); + final message = exception.toString(); + + expect(message, contains('SubscribeInReducerException')); + expect(message, contains('Cannot subscribe')); + expect(message, contains('reducer')); + }); + + test('exception is thrown on subscribe during reduce', () { + late Store store; + + CounterState badReducer(CounterState state, Action action) { + if (action is TriggerBadBehavior) { + store.subscribe(() {}); + } + return counterReducer(state, action); + } + + store = createStore(badReducer, (count: 0)); + expect( + () => store.dispatch(const TriggerBadBehavior()), + throwsA(isA()), + ); + }); + }); + + group('Store listener during iteration', () { + test('unsubscribing during listener iteration is safe', () { + final store = createStore(counterReducer, (count: 0)); + var callCount1 = 0; + var callCount2 = 0; + + late Unsubscribe unsub1; + unsub1 = store.subscribe(() { + callCount1++; + unsub1(); + }); + store + ..subscribe(() => callCount2++) + ..dispatch(const Increment()); + + expect(callCount1, equals(1)); + expect(callCount2, equals(1)); + + store.dispatch(const Increment()); + + expect(callCount1, equals(1)); + expect(callCount2, equals(2)); + }); + + test('subscribing in listener works for next dispatch', () { + final store = createStore(counterReducer, (count: 0)); + var callCount1 = 0; + var callCount2 = 0; + var added = false; + + store + ..subscribe(() { + callCount1++; + if (!added) { + added = true; + store.subscribe(() => callCount2++); + } + }) + ..dispatch(const Increment()); + expect(callCount1, equals(1)); + expect(callCount2, equals(0)); + + store.dispatch(const Increment()); + expect(callCount1, equals(2)); + expect(callCount2, equals(1)); + }); + }); + + group('Store reducer replacement', () { + test('replaceReducer dispatches ReplaceAction', () { + var replaceActionSeen = false; + + CounterState trackingReducer(CounterState state, Action action) { + if (action is ReplaceAction) { + replaceActionSeen = true; + } + return counterReducer(state, action); + } + + createStore(counterReducer, (count: 0)).replaceReducer(trackingReducer); + + expect(replaceActionSeen, isTrue); + }); + + test('new reducer handles subsequent dispatches', () { + final store = createStore(counterReducer, (count: 0)); + + CounterState doubleReducer(CounterState state, Action action) => + switch (action) { + Increment() => (count: state.count + 2), + _ => state, + }; + + store + ..dispatch(const Increment()) + ..replaceReducer(doubleReducer) + ..dispatch(const Increment()); + + expect(store.getState().count, equals(3)); + }); + }); + + group('Store with enhancer', () { + test('enhancer receives createStore function', () { + var enhancerCalled = false; + + Store enhancer( + Store Function(Reducer, CounterState) + createStoreFn, + Reducer reducer, + CounterState preloadedState, + ) { + enhancerCalled = true; + return createStoreFn(reducer, preloadedState); + } + + createStore(counterReducer, (count: 0), enhancer: enhancer); + expect(enhancerCalled, isTrue); + }); + + test('enhancer can modify reducer', () { + Store enhancer( + Store Function(Reducer, CounterState) + createStoreFn, + Reducer reducer, + CounterState preloadedState, + ) { + CounterState wrappedReducer(CounterState state, Action action) { + final newState = reducer(state, action); + return (count: newState.count * 2); + } + + return createStoreFn(wrappedReducer, preloadedState); + } + + final store = createStore(counterReducer, (count: 1), enhancer: enhancer); + expect(store.getState().count, equals(2)); + }); + }); + + group('InitAction', () { + test('InitAction is dispatched on store creation', () { + var initActionReceived = false; + + CounterState trackingReducer(CounterState state, Action action) { + if (action is InitAction) { + initActionReceived = true; + } + return state; + } + + createStore(trackingReducer, (count: 0)); + expect(initActionReceived, isTrue); + }); + + test('InitAction has correct type', () { + expect(initAction, isA()); + }); + }); + + group('Decrement action', () { + test('Decrement reduces count by 1', () { + final store = createStore(counterReducer, (count: 5)) + ..dispatch(const Decrement()); + expect(store.getState().count, equals(4)); + }); + }); +} diff --git a/packages/reflux/test/store_test.dart b/packages/reflux/test/store_test.dart index 8a800c0..1400e6b 100644 --- a/packages/reflux/test/store_test.dart +++ b/packages/reflux/test/store_test.dart @@ -123,6 +123,75 @@ void main() { unsubscribe(); // Should not throw }); + test('unsubscribe after unsubscribed does not remove other listeners', () { + final store = createStore(counterReducer, (count: 0)); + var listener1Called = 0; + var listener2Called = 0; + + final unsubscribe1 = store.subscribe(() => listener1Called++); + store.subscribe(() => listener2Called++); + + // Unsubscribe listener1 + unsubscribe1(); + + // Calling unsubscribe again should be a no-op (isSubscribed = false) + unsubscribe1(); + + // Dispatch should only call listener2 now + store.dispatch(const Increment()); + + expect(listener1Called, equals(0)); + expect(listener2Called, equals(1)); + }); + + test( + 'unsubscribe sets isSubscribed to false preventing double removal', + () { + final store = createStore(counterReducer, (count: 0)); + var callCount = 0; + + final unsubscribe = store.subscribe(() => callCount++); + + // First dispatch notifies + store.dispatch(const Increment()); + expect(callCount, equals(1)); + + // Unsubscribe + unsubscribe(); + + // Second dispatch doesn't notify + store.dispatch(const Increment()); + expect(callCount, equals(1)); + + // Second unsubscribe is safe (isSubscribed is false) + unsubscribe(); + + // Third dispatch still doesn't notify + store.dispatch(const Increment()); + expect(callCount, equals(1)); + }, + ); + + test('throws when unsubscribing during reduce', () { + late Store store; + late void Function() unsubscribe; + + CounterState badReducer(CounterState state, Action action) { + if (action is UnsubscribeDuringReduceAction) { + unsubscribe(); + } + return state; + } + + store = createStore(badReducer, (count: 0)); + unsubscribe = store.subscribe(() {}); + + expect( + () => store.dispatch(const UnsubscribeDuringReduceAction()), + throwsA(isA()), + ); + }); + test('throws when subscribing during reduce', () { late Store store; CounterState badReducer(CounterState state, Action action) { @@ -138,6 +207,14 @@ void main() { throwsA(isA()), ); }); + + test('SubscribeInReducerException has correct message', () { + final exception = SubscribeInReducerException(); + final message = exception.toString(); + expect(message, contains('SubscribeInReducerException')); + expect(message, contains('Cannot subscribe')); + expect(message, contains('reducer')); + }); }); group('Store.replaceReducer', () { @@ -168,6 +245,75 @@ void main() { }); }); + group('isSubscribed mutation killers', () { + test('double unsubscribe does not remove other listeners from list', () { + // This test kills: if (!isSubscribed) return; → if (false) return; + // If the mutation survives, calling unsubscribe twice would try to + // remove the same listener twice from the list, potentially + // causing issues with the listeners list + + final store = createStore(counterReducer, (count: 0)); + var listener1Count = 0; + var listener2Count = 0; + var listener3Count = 0; + + final unsub1 = store.subscribe(() => listener1Count++); + store + ..subscribe(() => listener2Count++) + ..subscribe(() => listener3Count++) + // Verify all listeners are called + ..dispatch(const Increment()); + expect(listener1Count, equals(1)); + expect(listener2Count, equals(1)); + expect(listener3Count, equals(1)); + + // Unsubscribe listener1 twice - should be a no-op second time + unsub1(); + unsub1(); + + // All other listeners should still work + store.dispatch(const Increment()); + expect(listener1Count, equals(1)); // Still 1 (unsubscribed) + expect(listener2Count, equals(2)); // Incremented + expect(listener3Count, equals(2)); // Incremented + }); + + test('isSubscribed becomes false after unsubscribe', () { + // This test kills: isSubscribed = false; → isSubscribed = true; + // If the mutation survives, isSubscribed stays true after unsubscribe + // and subsequent unsubscribe calls would still try to remove + + final store = createStore(counterReducer, (count: 0)); + var listener1Count = 0; + var listener2Count = 0; + + void listener1() => listener1Count++; + void listener2() => listener2Count++; + + final unsub1 = store.subscribe(listener1); + store + ..subscribe(listener2) + // Verify both work + ..dispatch(const Increment()); + expect(listener1Count, equals(1)); + expect(listener2Count, equals(1)); + + // Unsubscribe first listener + unsub1(); + + // Unsubscribe again - isSubscribed should be false, so this is a no-op + // If isSubscribed stayed true (mutation), this would try to remove again + unsub1(); + unsub1(); + unsub1(); + + // listener2 should still be called + store.dispatch(const Increment()); + expect(listener1Count, equals(1)); // Unchanged + expect(listener2Count, equals(2)); // Incremented + }); + }); + group('Store.getState', () { test('returns current state', () { final store = createStore(counterReducer, (count: 5)); @@ -186,3 +332,8 @@ void main() { final class BadAction extends Action { const BadAction(); } + +/// Test action that triggers unsubscribe during reduce +final class UnsubscribeDuringReduceAction extends Action { + const UnsubscribeDuringReduceAction(); +}