diff --git a/packages/mix/test/src/animation/animation_config_test.dart b/packages/mix/test/src/animation/animation_config_test.dart index 0249873d3..f0adcfc2c 100644 --- a/packages/mix/test/src/animation/animation_config_test.dart +++ b/packages/mix/test/src/animation/animation_config_test.dart @@ -3,6 +3,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mix/mix.dart'; import 'package:mix/src/animation/animation_config.dart'; +import '../../helpers/testing_utils.dart'; + void main() { group('AnimationConfig', () { group('CurveAnimationConfig', () { @@ -63,6 +65,182 @@ void main() { config.onEnd!(); expect(called, true); }); + + group('delay', () { + test('defaults to Duration.zero', () { + final config = AnimationConfig.curve( + duration: const Duration(seconds: 1), + curve: Curves.linear, + ); + + expect((config as CurveAnimationConfig).delay, Duration.zero); + }); + + test('stores custom delay value', () { + final config = AnimationConfig.curve( + duration: const Duration(seconds: 1), + curve: Curves.linear, + delay: const Duration(milliseconds: 500), + ); + + expect( + (config as CurveAnimationConfig).delay, + const Duration(milliseconds: 500), + ); + }); + + test('delay is included in equality check', () { + final config1 = AnimationConfig.curve( + duration: const Duration(seconds: 1), + curve: Curves.linear, + delay: const Duration(milliseconds: 100), + ); + final config2 = AnimationConfig.curve( + duration: const Duration(seconds: 1), + curve: Curves.linear, + delay: const Duration(milliseconds: 200), + ); + + expect(config1, isNot(equals(config2))); + }); + }); + + group('totalDuration', () { + test('equals duration when no delay', () { + const config = CurveAnimationConfig( + duration: Duration(seconds: 1), + curve: Curves.linear, + ); + + expect(config.totalDuration, const Duration(seconds: 1)); + }); + + test('equals duration plus delay', () { + const config = CurveAnimationConfig( + duration: Duration(milliseconds: 500), + curve: Curves.linear, + delay: Duration(milliseconds: 200), + ); + + expect(config.totalDuration, const Duration(milliseconds: 700)); + }); + }); + + group('copyWith', () { + test('returns new instance with same values when no args', () { + const original = CurveAnimationConfig( + duration: Duration(seconds: 1), + curve: Curves.easeIn, + delay: Duration(milliseconds: 100), + ); + + final copy = original.copyWith(); + + expect(copy.duration, original.duration); + expect(copy.curve, original.curve); + expect(copy.delay, original.delay); + expect(copy, isNot(same(original))); + }); + + test('updates duration only', () { + const original = CurveAnimationConfig( + duration: Duration(seconds: 1), + curve: Curves.easeIn, + ); + + final copy = original.copyWith( + duration: const Duration(seconds: 2), + ); + + expect(copy.duration, const Duration(seconds: 2)); + expect(copy.curve, Curves.easeIn); + }); + + test('updates curve only', () { + const original = CurveAnimationConfig( + duration: Duration(seconds: 1), + curve: Curves.easeIn, + ); + + final copy = original.copyWith(curve: Curves.bounceOut); + + expect(copy.duration, const Duration(seconds: 1)); + expect(copy.curve, Curves.bounceOut); + }); + + test('updates delay only', () { + const original = CurveAnimationConfig( + duration: Duration(seconds: 1), + curve: Curves.linear, + ); + + final copy = original.copyWith( + delay: const Duration(milliseconds: 500), + ); + + expect(copy.delay, const Duration(milliseconds: 500)); + }); + + test('updates onEnd callback', () { + bool called = false; + const original = CurveAnimationConfig( + duration: Duration(seconds: 1), + curve: Curves.linear, + ); + + final copy = original.copyWith(onEnd: () => called = true); + + expect(copy.onEnd, isNotNull); + copy.onEnd!(); + expect(called, true); + }); + }); + + group('withDefaults', () { + test('creates config with default duration', () { + final config = AnimationConfig.withDefaults(); + + expect(config.duration, const Duration(milliseconds: 200)); + }); + + test('creates config with linear curve', () { + final config = AnimationConfig.withDefaults(); + + expect(config.curve, Curves.linear); + }); + }); + + group('spring factory methods', () { + test('spring creates CurveAnimationConfig with SpringCurve', () { + final config = CurveAnimationConfig.spring( + const Duration(milliseconds: 500), + mass: 1.0, + stiffness: 200.0, + damping: 15.0, + ); + + expect(config.duration, const Duration(milliseconds: 500)); + expect(config.curve, isNotNull); + }); + + test('springWithDampingRatio creates config with ratio', () { + final config = CurveAnimationConfig.springWithDampingRatio( + const Duration(milliseconds: 500), + ratio: 0.8, + ); + + expect(config.duration, const Duration(milliseconds: 500)); + }); + + test('springDurationBased creates config with bounce', () { + final config = CurveAnimationConfig.springDurationBased( + const Duration(milliseconds: 500), + bounce: 0.3, + ); + + expect(config.duration, const Duration(milliseconds: 500)); + }); + }); }); group('SpringAnimationConfig', () { @@ -169,4 +347,136 @@ void main() { }); }); }); + + group('PhaseAnimationConfig', () { + test('stores all required parameters', () { + final trigger = ValueNotifier(false); + final styles = [MockStyle(0.0), MockStyle(1.0)]; + final curveConfigs = [ + const CurveAnimationConfig( + duration: Duration(milliseconds: 100), + curve: Curves.easeIn, + ), + const CurveAnimationConfig( + duration: Duration(milliseconds: 200), + curve: Curves.easeOut, + ), + ]; + + final config = PhaseAnimationConfig, MockStyle>( + styles: styles, + curveConfigs: curveConfigs, + trigger: trigger, + ); + + expect(config.styles, styles); + expect(config.curveConfigs, curveConfigs); + expect(config.trigger, trigger); + expect(config.onEnd, isNull); + + trigger.dispose(); + }); + + test('stores optional onEnd callback', () { + var called = false; + final trigger = ValueNotifier(false); + + final config = PhaseAnimationConfig, MockStyle>( + styles: [MockStyle(0.0)], + curveConfigs: [ + const CurveAnimationConfig( + duration: Duration(milliseconds: 100), + curve: Curves.linear, + ), + ], + trigger: trigger, + onEnd: () => called = true, + ); + + expect(config.onEnd, isNotNull); + config.onEnd!(); + expect(called, true); + + trigger.dispose(); + }); + + test('props contains styles, trigger, and curveConfigs for equality', () { + final trigger = ValueNotifier(false); + final styles = [MockStyle(0.0)]; + final curveConfigs = [ + const CurveAnimationConfig( + duration: Duration(milliseconds: 100), + curve: Curves.linear, + ), + ]; + + final config = PhaseAnimationConfig, MockStyle>( + styles: styles, + curveConfigs: curveConfigs, + trigger: trigger, + ); + + expect(config.props, contains(styles)); + expect(config.props, contains(trigger)); + expect(config.props, contains(curveConfigs)); + + trigger.dispose(); + }); + + test('equal configs have same props', () { + final trigger = ValueNotifier(false); + final styles = [MockStyle(0.0)]; + final curveConfigs = [ + const CurveAnimationConfig( + duration: Duration(milliseconds: 100), + curve: Curves.linear, + ), + ]; + + final config1 = PhaseAnimationConfig, MockStyle>( + styles: styles, + curveConfigs: curveConfigs, + trigger: trigger, + ); + + final config2 = PhaseAnimationConfig, MockStyle>( + styles: styles, + curveConfigs: curveConfigs, + trigger: trigger, + ); + + expect(config1.props, equals(config2.props)); + + trigger.dispose(); + }); + + test('different triggers produce different configs', () { + final trigger1 = ValueNotifier(false); + final trigger2 = ValueNotifier(false); + final styles = [MockStyle(0.0)]; + final curveConfigs = [ + const CurveAnimationConfig( + duration: Duration(milliseconds: 100), + curve: Curves.linear, + ), + ]; + + final config1 = PhaseAnimationConfig, MockStyle>( + styles: styles, + curveConfigs: curveConfigs, + trigger: trigger1, + ); + + final config2 = PhaseAnimationConfig, MockStyle>( + styles: styles, + curveConfigs: curveConfigs, + trigger: trigger2, + ); + + expect(config1, isNot(equals(config2))); + + trigger1.dispose(); + trigger2.dispose(); + }); + }); } diff --git a/packages/mix/test/src/animation/animation_util_test.dart b/packages/mix/test/src/animation/animation_util_test.dart new file mode 100644 index 000000000..8ee6ea2a5 --- /dev/null +++ b/packages/mix/test/src/animation/animation_util_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; + +import '../../helpers/testing_utils.dart'; + +void main() { + group('AnimationConfigUtility', () { + late AnimationConfigUtility> utility; + + setUp(() { + utility = AnimationConfigUtility(MockStyle.new); + }); + + group('implicit', () { + test('creates CurveAnimationConfig with duration', () { + final result = utility.implicit( + duration: const Duration(milliseconds: 300), + ); + + final config = result.value as CurveAnimationConfig; + expect(config.duration, const Duration(milliseconds: 300)); + }); + + test('uses Curves.linear as default curve', () { + final result = utility.implicit( + duration: const Duration(milliseconds: 300), + ); + + final config = result.value as CurveAnimationConfig; + expect(config.curve, Curves.linear); + }); + + test('accepts custom curve', () { + final result = utility.implicit( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + + final config = result.value as CurveAnimationConfig; + expect(config.curve, Curves.easeInOut); + }); + }); + + group('call', () { + test('is an alias for implicit', () { + final implicitResult = utility.implicit( + duration: const Duration(milliseconds: 300), + curve: Curves.ease, + ); + + final callResult = utility( + duration: const Duration(milliseconds: 300), + curve: Curves.ease, + ); + + final implicitConfig = implicitResult.value as CurveAnimationConfig; + final callConfig = callResult.value as CurveAnimationConfig; + + expect(callConfig.duration, implicitConfig.duration); + expect(callConfig.curve, implicitConfig.curve); + }); + + test('requires duration parameter', () { + // This should compile - duration is required + final result = utility(duration: const Duration(milliseconds: 100)); + + expect(result.value, isA()); + }); + }); + }); +} diff --git a/packages/mix/test/src/animation/keyframe_test.dart b/packages/mix/test/src/animation/keyframe_test.dart new file mode 100644 index 000000000..2466d92e4 --- /dev/null +++ b/packages/mix/test/src/animation/keyframe_test.dart @@ -0,0 +1,411 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:mix/src/animation/animation_config.dart'; + +import '../../helpers/testing_utils.dart'; + +void main() { + group('Keyframe', () { + group('construction', () { + test('creates with required parameters', () { + const keyframe = Keyframe( + 0.5, + Duration(milliseconds: 300), + curve: Curves.easeIn, + ); + + expect(keyframe.value, 0.5); + expect(keyframe.duration, const Duration(milliseconds: 300)); + expect(keyframe.curve, Curves.easeIn); + }); + + test('linear constructor uses Curves.linear', () { + const keyframe = Keyframe.linear( + 1.0, + Duration(milliseconds: 200), + ); + + expect(keyframe.curve, Curves.linear); + expect(keyframe.value, 1.0); + expect(keyframe.duration, const Duration(milliseconds: 200)); + }); + + test('ease constructor uses Curves.ease', () { + const keyframe = Keyframe.ease( + 0.8, + Duration(milliseconds: 150), + ); + + expect(keyframe.curve, Curves.ease); + }); + + test('easeInOut constructor uses Curves.easeInOut', () { + const keyframe = Keyframe.easeInOut( + 0.5, + Duration(milliseconds: 100), + ); + + expect(keyframe.curve, Curves.easeInOut); + }); + + test('bounceOut constructor uses Curves.bounceOut', () { + const keyframe = Keyframe.bounceOut( + 1.0, + Duration(milliseconds: 400), + ); + + expect(keyframe.curve, Curves.bounceOut); + }); + }); + + group('spring constructors', () { + test('springWithBounce creates SpringCurve', () { + final keyframe = Keyframe.springWithBounce( + 1.0, + const Duration(milliseconds: 500), + bounce: 0.3, + ); + + expect(keyframe.curve, isNotNull); + expect(keyframe.value, 1.0); + }); + + test('springWithDampingRatio creates SpringCurve', () { + final keyframe = Keyframe.springWithDampingRatio( + 1.0, + const Duration(milliseconds: 500), + ratio: 0.8, + ); + + expect(keyframe.curve, isNotNull); + }); + + test('spring creates SpringCurve with custom parameters', () { + final keyframe = Keyframe.spring( + 1.0, + const Duration(milliseconds: 500), + mass: 2.0, + stiffness: 200.0, + damping: 15.0, + ); + + expect(keyframe.curve, isNotNull); + }); + }); + + group('equality', () { + test('equal keyframes have same hashCode', () { + const keyframe1 = Keyframe.linear( + 1.0, + Duration(milliseconds: 300), + ); + const keyframe2 = Keyframe.linear( + 1.0, + Duration(milliseconds: 300), + ); + + expect(keyframe1, equals(keyframe2)); + expect(keyframe1.hashCode, equals(keyframe2.hashCode)); + }); + + test('different values produce different keyframes', () { + const keyframe1 = Keyframe.linear( + 1.0, + Duration(milliseconds: 300), + ); + const keyframe2 = Keyframe.linear( + 0.5, + Duration(milliseconds: 300), + ); + + expect(keyframe1, isNot(equals(keyframe2))); + }); + + test('different durations produce different keyframes', () { + const keyframe1 = Keyframe.linear( + 1.0, + Duration(milliseconds: 300), + ); + const keyframe2 = Keyframe.linear( + 1.0, + Duration(milliseconds: 500), + ); + + expect(keyframe1, isNot(equals(keyframe2))); + }); + + test('different curves produce different keyframes', () { + const keyframe1 = Keyframe.linear( + 1.0, + Duration(milliseconds: 300), + ); + const keyframe2 = Keyframe.ease( + 1.0, + Duration(milliseconds: 300), + ); + + expect(keyframe1, isNot(equals(keyframe2))); + }); + }); + + group('props', () { + test('props contains duration, value, and curve', () { + const keyframe = Keyframe( + 0.5, + Duration(milliseconds: 300), + curve: Curves.easeIn, + ); + + expect(keyframe.props, contains(const Duration(milliseconds: 300))); + expect(keyframe.props, contains(0.5)); + expect(keyframe.props, contains(Curves.easeIn)); + }); + }); + }); + + group('KeyframeTrack', () { + group('totalDuration', () { + test('sums all segment durations', () { + final track = KeyframeTrack( + 'opacity', + const [ + Keyframe.linear(0.5, Duration(milliseconds: 100)), + Keyframe.linear(1.0, Duration(milliseconds: 200)), + Keyframe.linear(0.8, Duration(milliseconds: 150)), + ], + initial: 0.0, + ); + + expect(track.totalDuration, const Duration(milliseconds: 450)); + }); + + test('returns zero for empty segments', () { + final track = KeyframeTrack('empty', const [], initial: 0.0); + + expect(track.totalDuration, Duration.zero); + }); + + test('handles single segment', () { + final track = KeyframeTrack( + 'single', + const [Keyframe.linear(1.0, Duration(milliseconds: 500))], + initial: 0.0, + ); + + expect(track.totalDuration, const Duration(milliseconds: 500)); + }); + }); + + group('createSequenceItems', () { + test('creates correct number of items', () { + final track = KeyframeTrack( + 'test', + const [ + Keyframe.linear(0.5, Duration(milliseconds: 100)), + Keyframe.linear(1.0, Duration(milliseconds: 200)), + ], + initial: 0.0, + ); + + final items = track.createSequenceItems(); + + expect(items.length, 2); + }); + + test('items have correct weights based on duration', () { + final track = KeyframeTrack( + 'test', + const [ + Keyframe.linear(0.5, Duration(milliseconds: 100)), + Keyframe.linear(1.0, Duration(milliseconds: 300)), + ], + initial: 0.0, + ); + + final items = track.createSequenceItems(); + + expect(items[0].weight, 100.0); + expect(items[1].weight, 300.0); + }); + + test('returns empty list for empty segments', () { + final track = KeyframeTrack('empty', const [], initial: 0.0); + + final items = track.createSequenceItems(); + + expect(items, isEmpty); + }); + }); + + group('createAnimatable', () { + test('creates animatable for timeline duration', () { + final track = KeyframeTrack( + 'test', + const [ + Keyframe.linear(0.5, Duration(milliseconds: 100)), + Keyframe.linear(1.0, Duration(milliseconds: 100)), + ], + initial: 0.0, + ); + + final animatable = track.createAnimatable( + const Duration(milliseconds: 200), + ); + + expect(animatable, isNotNull); + }); + + test('animatable transforms values correctly at boundaries', () { + final track = KeyframeTrack( + 'test', + const [Keyframe.linear(1.0, Duration(milliseconds: 100))], + initial: 0.0, + ); + + final animatable = track.createAnimatable( + const Duration(milliseconds: 100), + ); + + // At t=0, should be at initial value + expect(animatable.transform(0.0), closeTo(0.0, 0.01)); + + // At t=1, should be at final value + expect(animatable.transform(1.0), closeTo(1.0, 0.01)); + }); + }); + + group('custom tweenBuilder', () { + test('uses default Tween when not provided', () { + final track = KeyframeTrack( + 'test', + const [Keyframe.linear(1.0, Duration(milliseconds: 100))], + initial: 0.0, + ); + + // Should not throw + expect(() => track.createSequenceItems(), returnsNormally); + }); + + test('uses custom tweenBuilder when provided', () { + var builderCalled = false; + + final track = KeyframeTrack( + 'test', + const [Keyframe.linear(1.0, Duration(milliseconds: 100))], + initial: 0.0, + tweenBuilder: ({double? begin, double? end}) { + builderCalled = true; + return Tween(begin: begin, end: end); + }, + ); + + track.createSequenceItems(); + + expect(builderCalled, true); + }); + }); + + group('equality', () { + test('equal tracks have same props', () { + final track1 = KeyframeTrack( + 'test', + const [Keyframe.linear(1.0, Duration(milliseconds: 100))], + initial: 0.0, + ); + final track2 = KeyframeTrack( + 'test', + const [Keyframe.linear(1.0, Duration(milliseconds: 100))], + initial: 0.0, + ); + + expect(track1.props, equals(track2.props)); + }); + }); + }); + + group('KeyframeAnimationResult', () { + group('get', () { + test('returns value for existing key', () { + const result = KeyframeAnimationResult({ + 'opacity': 0.5, + 'scale': 1.2, + }); + + expect(result.get('opacity'), 0.5); + expect(result.get('scale'), 1.2); + }); + + test('throws ArgumentError for missing key', () { + const result = KeyframeAnimationResult({'opacity': 0.5}); + + expect( + () => result.get('nonexistent'), + throwsA(isA()), + ); + }); + + test('throws StateError for wrong type', () { + const result = KeyframeAnimationResult({'opacity': 0.5}); + + expect(() => result.get('opacity'), throwsA(isA())); + }); + + test('works with various types', () { + const result = KeyframeAnimationResult({ + 'color': Colors.red, + 'offset': Offset(10, 20), + 'size': Size(100, 200), + }); + + expect(result.get('color'), Colors.red); + expect(result.get('offset'), const Offset(10, 20)); + expect(result.get('size'), const Size(100, 200)); + }); + }); + }); + + group('KeyframeAnimationConfig', () { + test('stores all required parameters', () { + final trigger = ValueNotifier(false); + final timeline = [ + KeyframeTrack( + 'test', + const [Keyframe.linear(1.0, Duration(milliseconds: 100))], + initial: 0.0, + ), + ]; + + final config = KeyframeAnimationConfig>( + trigger: trigger, + timeline: timeline, + styleBuilder: (result, style) => style, + initialStyle: MockStyle(0.0), + ); + + expect(config.trigger, trigger); + expect(config.timeline, timeline); + expect(config.styleBuilder, isNotNull); + expect(config.initialStyle, isA()); + + trigger.dispose(); + }); + + test('props contains all fields for equality', () { + final trigger = ValueNotifier(false); + final timeline = []; + + final config = KeyframeAnimationConfig>( + trigger: trigger, + timeline: timeline, + styleBuilder: (result, style) => style, + initialStyle: MockStyle(0.0), + ); + + expect(config.props, contains(trigger)); + expect(config.props, contains(timeline)); + + trigger.dispose(); + }); + }); +} diff --git a/packages/mix/test/src/animation/spring_curves_test.dart b/packages/mix/test/src/animation/spring_curves_test.dart new file mode 100644 index 000000000..835ff63e5 --- /dev/null +++ b/packages/mix/test/src/animation/spring_curves_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/src/animation/spring_curves.dart'; + +void main() { + group('SpringCurve', () { + group('default constructor', () { + test('starts at 0 when t = 0', () { + final curve = SpringCurve(); + + expect(curve.transform(0.0), closeTo(0.0, 0.001)); + }); + + test('ends near 1 when t = 1', () { + final curve = SpringCurve(); + + // Spring may overshoot, but should settle near 1 + expect(curve.transform(1.0), closeTo(1.0, 0.1)); + }); + + test('produces intermediate values', () { + final curve = SpringCurve(); + + final midValue = curve.transform(0.5); + expect(midValue, greaterThan(0.0)); + expect(midValue, lessThan(1.5)); // Allow for overshoot + }); + + test('respects custom mass parameter', () { + final lightCurve = SpringCurve(mass: 0.5); + final heavyCurve = SpringCurve(mass: 2.0); + + // Lighter mass should move faster initially + final lightValue = lightCurve.transform(0.2); + final heavyValue = heavyCurve.transform(0.2); + + expect(lightValue, isNot(equals(heavyValue))); + }); + + test('respects custom stiffness parameter', () { + final softCurve = SpringCurve(stiffness: 100.0); + final stiffCurve = SpringCurve(stiffness: 300.0); + + // Different stiffness should produce different animation curves + final softValue = softCurve.transform(0.3); + final stiffValue = stiffCurve.transform(0.3); + + expect(stiffValue, isNot(equals(softValue))); + }); + + test('respects custom damping parameter', () { + final underdampedCurve = SpringCurve(damping: 5.0); + final overdampedCurve = SpringCurve(damping: 30.0); + + // Underdamped should overshoot more + final underdampedValue = underdampedCurve.transform(0.5); + final overdampedValue = overdampedCurve.transform(0.5); + + expect(underdampedValue, isNot(equals(overdampedValue))); + }); + }); + + group('withDampingRatio constructor', () { + test('starts at 0 when t = 0', () { + final curve = SpringCurve.withDampingRatio(); + + expect(curve.transform(0.0), closeTo(0.0, 0.001)); + }); + + test('ends near 1 when t = 1', () { + final curve = SpringCurve.withDampingRatio(); + + expect(curve.transform(1.0), closeTo(1.0, 0.1)); + }); + + test('critically damped (ratio = 1.0) does not overshoot', () { + final curve = SpringCurve.withDampingRatio(ratio: 1.0); + + // Sample multiple points - critically damped should never exceed 1 + for (var t = 0.0; t <= 1.0; t += 0.1) { + expect(curve.transform(t), lessThanOrEqualTo(1.05)); + } + }); + + test('underdamped (ratio < 1.0) may overshoot', () { + final curve = SpringCurve.withDampingRatio(ratio: 0.3); + + // Find max value to check for overshoot + var maxValue = 0.0; + for (var t = 0.0; t <= 1.0; t += 0.05) { + final value = curve.transform(t); + if (value > maxValue) maxValue = value; + } + + // Underdamped spring should overshoot + expect(maxValue, greaterThan(1.0)); + }); + }); + + group('withDurationAndBounce constructor', () { + test('starts at 0 when t = 0', () { + final curve = SpringCurve.withDurationAndBounce(); + + expect(curve.transform(0.0), closeTo(0.0, 0.001)); + }); + + test('ends near 1 when t = 1', () { + final curve = SpringCurve.withDurationAndBounce(); + + expect(curve.transform(1.0), closeTo(1.0, 0.1)); + }); + + test('zero bounce produces minimal overshoot', () { + final curve = SpringCurve.withDurationAndBounce(bounce: 0.0); + + var maxValue = 0.0; + for (var t = 0.0; t <= 1.0; t += 0.05) { + final value = curve.transform(t); + if (value > maxValue) maxValue = value; + } + + // Zero bounce should have minimal overshoot + expect(maxValue, lessThan(1.1)); + }); + + test('positive bounce produces overshoot', () { + final curve = SpringCurve.withDurationAndBounce(bounce: 0.5); + + var maxValue = 0.0; + for (var t = 0.0; t <= 1.0; t += 0.05) { + final value = curve.transform(t); + if (value > maxValue) maxValue = value; + } + + // Positive bounce should overshoot + expect(maxValue, greaterThan(1.0)); + }); + + test('respects duration parameter', () { + final shortCurve = SpringCurve.withDurationAndBounce( + duration: const Duration(milliseconds: 200), + ); + final longCurve = SpringCurve.withDurationAndBounce( + duration: const Duration(milliseconds: 1000), + ); + + // Different durations should produce different spring behaviors + final shortValue = shortCurve.transform(0.5); + final longValue = longCurve.transform(0.5); + + expect(shortValue, isNot(equals(longValue))); + }); + }); + + group('monotonicity', () { + test('overdamped spring is monotonically increasing', () { + final curve = SpringCurve.withDampingRatio(ratio: 1.5); + + var previousValue = -1.0; + for (var t = 0.0; t <= 1.0; t += 0.05) { + final value = curve.transform(t); + expect(value, greaterThanOrEqualTo(previousValue)); + previousValue = value; + } + }); + }); + }); +} diff --git a/packages/mix/test/src/animation/style_animation_builder_test.dart b/packages/mix/test/src/animation/style_animation_builder_test.dart index 5cdcaca94..d2f5f4ee9 100644 --- a/packages/mix/test/src/animation/style_animation_builder_test.dart +++ b/packages/mix/test/src/animation/style_animation_builder_test.dart @@ -193,6 +193,141 @@ void main() { // Should render container even with empty/default spec expect(find.byKey(const Key('test-container')), findsOneWidget); }); + + testWidgets('switches from curve to spring animation config', ( + tester, + ) async { + const curveConfig = CurveAnimationConfig( + duration: Duration(milliseconds: 100), + curve: Curves.linear, + ); + final springConfig = SpringAnimationConfig.standard(); + + const specWithCurve = StyleSpec( + spec: TestSpec(color: Colors.red), + animation: curveConfig, + ); + final specWithSpring = StyleSpec( + spec: const TestSpec(color: Colors.blue), + animation: springConfig, + ); + + // Start with curve animation + await tester.pumpWidget( + MaterialApp( + home: StyleAnimationBuilder( + spec: specWithCurve, + builder: (context, spec) => Container( + key: const Key('test-container'), + color: spec.spec.color, + ), + ), + ), + ); + + await tester.pump(); + + // Switch to spring animation + await tester.pumpWidget( + MaterialApp( + home: StyleAnimationBuilder( + spec: specWithSpring, + builder: (context, spec) => Container( + key: const Key('test-container'), + color: spec.spec.color, + ), + ), + ), + ); + + // Should not throw, animation driver should switch correctly + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('test-container')), findsOneWidget); + }); + + testWidgets('switches from animation to no animation config', ( + tester, + ) async { + const curveConfig = CurveAnimationConfig( + duration: Duration(milliseconds: 100), + curve: Curves.linear, + ); + + const specWithAnimation = StyleSpec( + spec: TestSpec(color: Colors.red), + animation: curveConfig, + ); + const specWithoutAnimation = StyleSpec( + spec: TestSpec(color: Colors.blue), + animation: null, + ); + + // Start with animation + await tester.pumpWidget( + MaterialApp( + home: StyleAnimationBuilder( + spec: specWithAnimation, + builder: (context, spec) => Container( + key: const Key('test-container'), + color: spec.spec.color, + ), + ), + ), + ); + + await tester.pump(); + + // Switch to no animation + await tester.pumpWidget( + MaterialApp( + home: StyleAnimationBuilder( + spec: specWithoutAnimation, + builder: (context, spec) => Container( + key: const Key('test-container'), + color: spec.spec.color, + ), + ), + ), + ); + + // Should update immediately (no animation) + await tester.pump(); + + expect(find.byKey(const Key('test-container')), findsOneWidget); + }); + + testWidgets('handles null animation value gracefully', (tester) async { + // Create spec with animation that produces null value + const animationConfig = CurveAnimationConfig( + duration: Duration(milliseconds: 100), + curve: Curves.linear, + ); + const spec = StyleSpec( + spec: TestSpec(), + animation: animationConfig, + ); + + await tester.pumpWidget( + MaterialApp( + home: StyleAnimationBuilder( + spec: spec, + builder: (context, spec) { + // Builder should receive valid spec even during animation + expect(spec, isNotNull); + return Container(key: const Key('test-container')); + }, + ), + ), + ); + + // Pump through animation + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byKey(const Key('test-container')), findsOneWidget); + }); }); } diff --git a/packages/mix/test/src/animation/style_animation_driver_test.dart b/packages/mix/test/src/animation/style_animation_driver_test.dart index 8338e44f6..7ca7c11d8 100644 --- a/packages/mix/test/src/animation/style_animation_driver_test.dart +++ b/packages/mix/test/src/animation/style_animation_driver_test.dart @@ -57,9 +57,8 @@ void main() { driver.dispose(); }); - test('reset should restore the driver to the begining', () { - driver.triggerAnimation(MockSpec(resolvedValue: .0).toStyleSpec()); - driver.triggerAnimation(MockSpec(resolvedValue: 1.0).toStyleSpec()); + test('reset should restore the driver to the beginning', () { + driver.controller.value = 0.5; driver.reset(); @@ -487,9 +486,11 @@ void main() { }); test('calculates duration from timeline tracks', () { + final trigger = ValueNotifier(false); + addTearDown(trigger.dispose); setUpDriver( KeyframeAnimationConfig( - trigger: ValueNotifier(false), + trigger: trigger, timeline: [ KeyframeTrack('track1', [ Keyframe.linear(1.0, Duration(milliseconds: 100)), @@ -508,9 +509,11 @@ void main() { }); test('returns zero duration for empty timeline', () { + final trigger = ValueNotifier(false); + addTearDown(trigger.dispose); setUpDriver( KeyframeAnimationConfig( - trigger: ValueNotifier(false), + trigger: trigger, timeline: [], styleBuilder: (result, style) => style, initialStyle: MockStyle(MockSpec(resolvedValue: 0.0).toStyleSpec()), @@ -520,9 +523,11 @@ void main() { }); test('uses maximum duration from all tracks', () { + final trigger = ValueNotifier(false); + addTearDown(trigger.dispose); setUpDriver( KeyframeAnimationConfig( - trigger: ValueNotifier(false), + trigger: trigger, timeline: [ KeyframeTrack('track1', [ Keyframe.linear(1.0, Duration(milliseconds: 100)), @@ -638,8 +643,10 @@ void main() { group('error handling', () { test('handles empty timeline gracefully', () { + final trigger = ValueNotifier(false); + addTearDown(trigger.dispose); final config = KeyframeAnimationConfig( - trigger: ValueNotifier(false), + trigger: trigger, timeline: [], styleBuilder: (result, style) => style, initialStyle: MockStyle(MockSpec(resolvedValue: 0.0)), @@ -657,4 +664,69 @@ void main() { }); }); }); + + group('NoAnimationDriver', () { + late NoAnimationDriver> driver; + + setUp(() { + driver = NoAnimationDriver>( + vsync: const TestVSync(), + initialSpec: MockSpec(resolvedValue: 0.0).toStyleSpec(), + ); + }); + + tearDown(() { + driver.dispose(); + }); + + test('initializes with initial spec as animation value', () { + final value = driver.animation.value; + + expect(value, isNotNull); + expect(value?.spec.resolvedValue, 0.0); + }); + + test('animation status is always forward (stopped)', () { + // AlwaysStoppedAnimation has status = forward, so isAnimating is true + // but the animation value never changes + expect(driver.animation.status, AnimationStatus.forward); + }); + + test('didUpdateSpec immediately updates animation value', () { + final oldSpec = MockSpec(resolvedValue: 0.0).toStyleSpec(); + final newSpec = MockSpec(resolvedValue: 1.0).toStyleSpec(); + + driver.didUpdateSpec(oldSpec, newSpec); + + expect(driver.animation.value?.spec.resolvedValue, 1.0); + }); + + test('executeAnimation sets controller value to 1.0', () async { + await driver.executeAnimation(); + + expect(driver.controller.value, 1.0); + }); + + test('updateDriver does nothing (no-op)', () { + const config = CurveAnimationConfig( + duration: Duration(milliseconds: 100), + curve: Curves.linear, + ); + + // Should not throw + expect(() => driver.updateDriver(config), returnsNormally); + }); + + test('successive spec updates replace previous value', () { + final spec1 = MockSpec(resolvedValue: 0.0).toStyleSpec(); + final spec2 = MockSpec(resolvedValue: 0.5).toStyleSpec(); + final spec3 = MockSpec(resolvedValue: 1.0).toStyleSpec(); + + driver.didUpdateSpec(spec1, spec2); + expect(driver.animation.value?.spec.resolvedValue, 0.5); + + driver.didUpdateSpec(spec2, spec3); + expect(driver.animation.value?.spec.resolvedValue, 1.0); + }); + }); } diff --git a/packages/mix/test/src/core/style_builder_test.dart b/packages/mix/test/src/core/style_builder_test.dart index d529626cb..1d4421e05 100644 --- a/packages/mix/test/src/core/style_builder_test.dart +++ b/packages/mix/test/src/core/style_builder_test.dart @@ -792,6 +792,53 @@ void main() { ); }, ); + + testWidgets( + 'preserves widget state when external controller is removed', + (tester) async { + final externalController = WidgetStatesController(); + addTearDown(externalController.dispose); + externalController.update(WidgetState.hovered, true); + + WidgetStatesController? controller = externalController; + late void Function(VoidCallback) setState; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, stateSetter) { + setState = stateSetter; + return StyleBuilder( + controller: controller, + style: BoxStyler() + .color(Colors.red) + .onHovered(BoxStyler().color(Colors.blue)), + builder: (context, spec) { + return Container(decoration: spec.decoration); + }, + ); + }, + ), + ), + ); + + final initial = tester.widget(find.byType(Container)); + expect(initial.decoration, BoxDecoration(color: Colors.blue)); + + setState(() { + controller = null; + }); + await tester.pump(); + + final afterSwap = tester.widget(find.byType(Container)); + expect(afterSwap.decoration, BoxDecoration(color: Colors.blue)); + + expect( + () => externalController.update(WidgetState.hovered, false), + returnsNormally, + ); + }, + ); }); testWidgets( diff --git a/packages/mix/test/src/modifiers/visibility_modifier_test.dart b/packages/mix/test/src/modifiers/visibility_modifier_test.dart new file mode 100644 index 000000000..7462696f3 --- /dev/null +++ b/packages/mix/test/src/modifiers/visibility_modifier_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/src/modifiers/visibility_modifier.dart'; + +void main() { + group('VisibilityModifier', () { + test('lerp keeps visible during transition', () { + const start = VisibilityModifier(false); + const end = VisibilityModifier(true); + + final mid = start.lerp(end, 0.5); + + expect(mid.visible, isTrue); + }); + + test('lerp respects endpoints', () { + const start = VisibilityModifier(false); + const end = VisibilityModifier(true); + + final atStart = start.lerp(end, 0.0); + final atEnd = start.lerp(end, 1.0); + + expect(identical(atStart, start), isTrue); + expect(identical(atEnd, end), isTrue); + }); + + test('lerp returns self when visibility does not change', () { + const start = VisibilityModifier(true); + const end = VisibilityModifier(true); + + final mid = start.lerp(end, 0.5); + + expect(identical(mid, start), isTrue); + }); + }); +} diff --git a/packages/mix/test/src/providers/icon_scope_test.dart b/packages/mix/test/src/providers/icon_scope_test.dart index 44ff3d4eb..aa1982a1e 100644 --- a/packages/mix/test/src/providers/icon_scope_test.dart +++ b/packages/mix/test/src/providers/icon_scope_test.dart @@ -94,23 +94,69 @@ void main() { ); }); - testWidgets('updateShouldNotify returns true when icon changes', ( - tester, - ) async { + testWidgets('notifies dependents when icon changes', (tester) async { final icon1 = IconStyler(size: 16.0); final icon2 = IconStyler(size: 24.0); + var dependencyChanges = 0; + + await tester.pumpWidget( + IconScope( + icon: icon1, + child: _IconDependencyProbe( + key: const Key('icon-probe'), + onDependenciesChanged: () => dependencyChanges++, + child: const SizedBox(), + ), + ), + ); + + expect(dependencyChanges, 1); - // Test through InheritedWidget implementation - expect(icon1, isNot(equals(icon2))); + await tester.pumpWidget( + IconScope( + icon: icon2, + child: _IconDependencyProbe( + key: const Key('icon-probe'), + onDependenciesChanged: () => dependencyChanges++, + child: const SizedBox(), + ), + ), + ); + + expect(dependencyChanges, 2); }); - testWidgets('updateShouldNotify returns false when icon is same', ( + testWidgets('does not notify dependents when icon is same', ( tester, ) async { final icon = IconStyler(size: 16.0); + var dependencyChanges = 0; + + await tester.pumpWidget( + IconScope( + icon: icon, + child: _IconDependencyProbe( + key: const Key('icon-probe'), + onDependenciesChanged: () => dependencyChanges++, + child: const SizedBox(), + ), + ), + ); + + expect(dependencyChanges, 1); + + await tester.pumpWidget( + IconScope( + icon: icon, + child: _IconDependencyProbe( + key: const Key('icon-probe'), + onDependenciesChanged: () => dependencyChanges++, + child: const SizedBox(), + ), + ), + ); - // Test through InheritedWidget implementation - expect(icon, equals(icon)); + expect(dependencyChanges, 1); }); testWidgets('handles null icon properties', (tester) async { @@ -133,5 +179,117 @@ void main() { ), ); }); + + group('nested scopes', () { + testWidgets('inner scope overrides outer scope', (tester) async { + final outerIcon = IconStyler(size: 48.0); + final innerIcon = IconStyler(size: 24.0); + + late IconStyler capturedScope; + + await tester.pumpWidget( + MaterialApp( + home: IconScope( + icon: outerIcon, + child: IconScope( + icon: innerIcon, + child: Builder( + builder: (context) { + capturedScope = IconScope.of(context); + return const SizedBox(); + }, + ), + ), + ), + ), + ); + + // Inner scope should be accessible, not outer + expect(capturedScope, equals(innerIcon)); + expect(capturedScope, isNot(equals(outerIcon))); + }); + + testWidgets('inner IconTheme overrides outer', (tester) async { + final outerIcon = IconStyler(size: 48.0); + final innerIcon = IconStyler(size: 24.0); + + await tester.pumpWidget( + MaterialApp( + home: IconScope( + icon: outerIcon, + child: IconScope( + icon: innerIcon, + child: Builder( + builder: (context) { + final iconTheme = IconTheme.of(context); + expect(iconTheme.size, 24.0); + return const SizedBox(); + }, + ), + ), + ), + ), + ); + }); + + testWidgets('scope is accessible at multiple nesting depths', ( + tester, + ) async { + final icon = IconStyler(size: 32.0, color: Colors.green); + late IconStyler capturedScope; + + await tester.pumpWidget( + MaterialApp( + home: IconScope( + icon: icon, + child: Column( + children: [ + Row( + children: [ + Builder( + builder: (context) { + capturedScope = IconScope.of(context); + return const SizedBox(); + }, + ), + ], + ), + ], + ), + ), + ), + ); + + expect(capturedScope, equals(icon)); + }); + }); }); } + +class _IconDependencyProbe extends StatefulWidget { + const _IconDependencyProbe({ + required this.onDependenciesChanged, + required this.child, + super.key, + }); + + final VoidCallback onDependenciesChanged; + final Widget child; + + @override + State<_IconDependencyProbe> createState() => _IconDependencyProbeState(); +} + +class _IconDependencyProbeState extends State<_IconDependencyProbe> { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + widget.onDependenciesChanged(); + } + + @override + Widget build(BuildContext context) { + IconScope.of(context); + return widget.child; + } +} diff --git a/packages/mix/test/src/providers/text_scope_test.dart b/packages/mix/test/src/providers/text_scope_test.dart index 1dbf17af7..f54bc6528 100644 --- a/packages/mix/test/src/providers/text_scope_test.dart +++ b/packages/mix/test/src/providers/text_scope_test.dart @@ -82,23 +82,69 @@ void main() { ); }); - testWidgets('updateShouldNotify returns true when text changes', ( - tester, - ) async { + testWidgets('notifies dependents when text changes', (tester) async { final text1 = TextStyler(maxLines: 1); final text2 = TextStyler(maxLines: 2); + var dependencyChanges = 0; + + await tester.pumpWidget( + TextScope( + text: text1, + child: _TextDependencyProbe( + key: const Key('text-probe'), + onDependenciesChanged: () => dependencyChanges++, + child: const SizedBox(), + ), + ), + ); + + expect(dependencyChanges, 1); - // Test through InheritedWidget implementation - expect(text1, isNot(equals(text2))); + await tester.pumpWidget( + TextScope( + text: text2, + child: _TextDependencyProbe( + key: const Key('text-probe'), + onDependenciesChanged: () => dependencyChanges++, + child: const SizedBox(), + ), + ), + ); + + expect(dependencyChanges, 2); }); - testWidgets('updateShouldNotify returns false when text is same', ( + testWidgets('does not notify dependents when text is same', ( tester, ) async { final text = TextStyler(maxLines: 1); + var dependencyChanges = 0; + + await tester.pumpWidget( + TextScope( + text: text, + child: _TextDependencyProbe( + key: const Key('text-probe'), + onDependenciesChanged: () => dependencyChanges++, + child: const SizedBox(), + ), + ), + ); + + expect(dependencyChanges, 1); + + await tester.pumpWidget( + TextScope( + text: text, + child: _TextDependencyProbe( + key: const Key('text-probe'), + onDependenciesChanged: () => dependencyChanges++, + child: const SizedBox(), + ), + ), + ); - // Test through InheritedWidget implementation - expect(text, equals(text)); + expect(dependencyChanges, 1); }); testWidgets('handles null text properties', (tester) async { @@ -121,5 +167,206 @@ void main() { ), ); }); + + group('default values', () { + testWidgets('softWrap defaults to true when null', (tester) async { + // TextStyler with no softWrap specified + final text = TextStyler(textAlign: TextAlign.left); + + await tester.pumpWidget( + MaterialApp( + home: TextScope( + text: text, + child: Builder( + builder: (context) { + final defaultTextStyle = DefaultTextStyle.of(context); + expect(defaultTextStyle.softWrap, true); + return const SizedBox(); + }, + ), + ), + ), + ); + }); + + testWidgets('overflow defaults to TextOverflow.clip when null', ( + tester, + ) async { + // TextStyler with no overflow specified + final text = TextStyler(textAlign: TextAlign.left); + + await tester.pumpWidget( + MaterialApp( + home: TextScope( + text: text, + child: Builder( + builder: (context) { + final defaultTextStyle = DefaultTextStyle.of(context); + expect(defaultTextStyle.overflow, TextOverflow.clip); + return const SizedBox(); + }, + ), + ), + ), + ); + }); + + testWidgets('textWidthBasis defaults to TextWidthBasis.parent when null', ( + tester, + ) async { + // TextStyler with no textWidthBasis specified + final text = TextStyler(textAlign: TextAlign.left); + + await tester.pumpWidget( + MaterialApp( + home: TextScope( + text: text, + child: Builder( + builder: (context) { + final defaultTextStyle = DefaultTextStyle.of(context); + expect(defaultTextStyle.textWidthBasis, TextWidthBasis.parent); + return const SizedBox(); + }, + ), + ), + ), + ); + }); + + testWidgets('style defaults to empty TextStyle when null', ( + tester, + ) async { + // TextStyler with no style specified + final text = TextStyler(); + + await tester.pumpWidget( + MaterialApp( + home: TextScope( + text: text, + child: Builder( + builder: (context) { + final defaultTextStyle = DefaultTextStyle.of(context); + // The style should be a TextStyle (not null) + expect(defaultTextStyle.style, isA()); + return const SizedBox(); + }, + ), + ), + ), + ); + }); + }); + + group('nested scopes', () { + testWidgets('inner scope overrides outer scope', (tester) async { + final outerText = TextStyler(maxLines: 5); + final innerText = TextStyler(maxLines: 2); + + late TextStyler capturedScope; + + await tester.pumpWidget( + MaterialApp( + home: TextScope( + text: outerText, + child: TextScope( + text: innerText, + child: Builder( + builder: (context) { + capturedScope = TextScope.of(context); + return const SizedBox(); + }, + ), + ), + ), + ), + ); + + // Inner scope should be accessible, not outer + expect(capturedScope, equals(innerText)); + expect(capturedScope, isNot(equals(outerText))); + }); + + testWidgets('inner DefaultTextStyle overrides outer', (tester) async { + final outerText = TextStyler(maxLines: 5); + final innerText = TextStyler(maxLines: 2); + + await tester.pumpWidget( + MaterialApp( + home: TextScope( + text: outerText, + child: TextScope( + text: innerText, + child: Builder( + builder: (context) { + final defaultTextStyle = DefaultTextStyle.of(context); + expect(defaultTextStyle.maxLines, 2); + return const SizedBox(); + }, + ), + ), + ), + ), + ); + }); + + testWidgets('scope is accessible at multiple nesting depths', ( + tester, + ) async { + final text = TextStyler(maxLines: 3); + late TextStyler capturedScope; + + await tester.pumpWidget( + MaterialApp( + home: TextScope( + text: text, + child: Column( + children: [ + Row( + children: [ + Builder( + builder: (context) { + capturedScope = TextScope.of(context); + return const SizedBox(); + }, + ), + ], + ), + ], + ), + ), + ), + ); + + expect(capturedScope, equals(text)); + }); + }); }); } + +class _TextDependencyProbe extends StatefulWidget { + const _TextDependencyProbe({ + required this.onDependenciesChanged, + required this.child, + super.key, + }); + + final VoidCallback onDependenciesChanged; + final Widget child; + + @override + State<_TextDependencyProbe> createState() => _TextDependencyProbeState(); +} + +class _TextDependencyProbeState extends State<_TextDependencyProbe> { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + widget.onDependenciesChanged(); + } + + @override + Widget build(BuildContext context) { + TextScope.of(context); + return widget.child; + } +}