diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8c7dd40..9a83305 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,5 +17,7 @@ jobs: - uses: flutter-actions/setup-pubdev-credentials@v1 - name: Install dependencies run: flutter pub get + - name: Run tests + run: flutter test - name: Publish run: flutter pub publish --force diff --git a/example/pubspec.lock b/example/pubspec.lock index 20f72a3..7fc0a50 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -21,26 +21,26 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" cupertino_icons: dependency: "direct main" description: @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" flutter: dependency: "direct main" description: flutter @@ -94,31 +94,31 @@ packages: path: ".." relative: true source: path - version: "1.0.7" + version: "1.0.8" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -131,26 +131,26 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.16.0" nested: dependency: transitive description: @@ -163,10 +163,10 @@ packages: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" provider: dependency: transitive description: @@ -179,7 +179,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: @@ -192,18 +192,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: @@ -224,18 +224,18 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.6" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -245,5 +245,5 @@ packages: source: hosted version: "13.0.0" sdks: - dart: ">=3.2.0-0 <4.0.0" - flutter: ">=1.17.0" + dart: ">=3.8.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/test/viewmodel_test.dart b/test/viewmodel_test.dart new file mode 100644 index 0000000..f7c4417 --- /dev/null +++ b/test/viewmodel_test.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jetpack/viewmodel.dart'; + +class TrackingVM extends ViewModel { + int disposed = 0; + @override + void onDispose() => disposed++; +} + +class TestViewModelFactory extends ViewModelFactory { + final Map _creators; + final Map createCalls = {}; + TestViewModelFactory(this._creators); + + @override + T create() { + createCalls[T] = (createCalls[T] ?? 0) + 1; + final fn = _creators[T]; + if (fn == null) throw StateError('No creator for $T'); + return fn() as T; + } +} + +void main() { + testWidgets('retains same instance across rebuilds', (tester) async { + final factory = TestViewModelFactory({TrackingVM: () => TrackingVM()}); + + TrackingVM? first; + TrackingVM? second; + final tick = ValueNotifier(0); + + Widget app() => ViewModelFactoryProvider( + viewModelFactory: factory, + child: ValueListenableBuilder( + valueListenable: tick, + builder: (_, __, ___) => ViewModelScope( + builder: (context) { + final vm = context.getViewModel(); + if (first == null) { + first = vm; + } else { + second ??= vm; + } + return const SizedBox.shrink(); + }, + ), + ), + ); + + await tester.pumpWidget(app()); + tick.value++; + await tester.pump(); + + expect(first, isNotNull); + expect(second, isNotNull); + expect(identical(first, second), isTrue); + expect(factory.createCalls[TrackingVM], 1); + }); + + testWidgets('disposes when scope unmounts', (tester) async { + final factory = TestViewModelFactory({TrackingVM: () => TrackingVM()}); + + final show = ValueNotifier(true); + TrackingVM? vm; + + Widget app() => ViewModelFactoryProvider( + viewModelFactory: factory, + child: ValueListenableBuilder( + valueListenable: show, + builder: (_, s, __) => s + ? ViewModelScope( + builder: (context) { + vm ??= context.getViewModel(); + return const SizedBox.shrink(); + }, + ) + : const SizedBox.shrink(), + ), + ); + + await tester.pumpWidget(app()); + expect(vm, isNotNull); + expect(vm!.disposed, 0); + + show.value = false; + await tester.pump(); + + expect(vm!.disposed, 1); + }); + + testWidgets('idempotent getViewModel per type/key', (tester) async { + final factory = TestViewModelFactory({TrackingVM: () => TrackingVM()}); + + TrackingVM? a; + TrackingVM? b; + + await tester.pumpWidget( + ViewModelFactoryProvider( + viewModelFactory: factory, + child: ViewModelScope( + builder: (context) { + a = context.getViewModel(); + b = context.getViewModel(); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(a, isNotNull); + expect(identical(a, b), isTrue); + expect(factory.createCalls[TrackingVM], 1); + }); + + testWidgets('different keys => different instances + both disposed', ( + tester, + ) async { + final factory = TestViewModelFactory({TrackingVM: () => TrackingVM()}); + + final show = ValueNotifier(true); + late TrackingVM vmA; + late TrackingVM vmB; + + Widget app() => ViewModelFactoryProvider( + viewModelFactory: factory, + child: ValueListenableBuilder( + valueListenable: show, + builder: (_, s, __) => s + ? ViewModelScope( + builder: (context) { + vmA = context.getViewModel(key: 'a'); + vmB = context.getViewModel(key: 'b'); + return const SizedBox.shrink(); + }, + ) + : const SizedBox.shrink(), + ), + ); + + await tester.pumpWidget(app()); + expect(identical(vmA, vmB), isFalse); + expect(factory.createCalls[TrackingVM], 2); + + show.value = false; + await tester.pump(); + + expect(vmA.disposed, 1); + expect(vmB.disposed, 1); + }); + + testWidgets('new instance after scope remount', (tester) async { + final factory = TestViewModelFactory({TrackingVM: () => TrackingVM()}); + + final show = ValueNotifier(true); + TrackingVM? first; + TrackingVM? second; + + Widget app() => ViewModelFactoryProvider( + viewModelFactory: factory, + child: ValueListenableBuilder( + valueListenable: show, + builder: (_, s, __) => s + ? ViewModelScope( + builder: (context) { + final vm = context.getViewModel(); + if (first == null) { + first = vm; + } else if (!identical(first, vm)) { + second = vm; + } + return const SizedBox.shrink(); + }, + ) + : const SizedBox.shrink(), + ), + ); + + await tester.pumpWidget(app()); + expect(first, isNotNull); + + show.value = false; // unmount + await tester.pump(); + expect(first!.disposed, 1); + + show.value = true; // remount + await tester.pump(); + + expect(second, isNotNull); + expect(identical(first, second), isFalse); + expect(factory.createCalls[TrackingVM], 2); + }); + + testWidgets('nested scopes: disposing child does not dispose parent', ( + tester, + ) async { + final factory = TestViewModelFactory({TrackingVM: () => TrackingVM()}); + + final showChild = ValueNotifier(true); + late TrackingVM parentVM; + TrackingVM? childVM; + + Widget app() => ViewModelFactoryProvider( + viewModelFactory: factory, + child: ViewModelScope( + builder: (context) { + parentVM = context.getViewModel(); + return ValueListenableBuilder( + valueListenable: showChild, + builder: (_, s, __) => s + ? ViewModelScope( + builder: (context) { + childVM = context.getViewModel(); + return const SizedBox.shrink(); + }, + ) + : const SizedBox.shrink(), + ); + }, + ), + ); + + await tester.pumpWidget(app()); + expect(parentVM.disposed, 0); + expect(childVM, isNotNull); + + showChild.value = false; // remove inner scope only + await tester.pump(); + + expect(parentVM.disposed, 0); + expect(childVM!.disposed, 1); + }); + + testWidgets( + 'navigator routes have independent scopes; pop disposes pushed scope only', + (tester) async { + final factory = TestViewModelFactory({TrackingVM: () => TrackingVM()}); + + const pushKey = Key('push'); + const popKey = Key('pop'); + TrackingVM? vmHome; + TrackingVM? vmSecond; + + await tester.pumpWidget( + ViewModelFactoryProvider( + viewModelFactory: factory, + child: MaterialApp( + home: ViewModelScope( + builder: (context) { + vmHome ??= context.getViewModel(); + return Scaffold( + body: Center( + child: ElevatedButton( + key: pushKey, + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) { + return ViewModelScope( + builder: (ctx) { + vmSecond ??= ctx.getViewModel(); + return Scaffold( + body: Center( + child: ElevatedButton( + key: popKey, + onPressed: () => + Navigator.of(ctx).pop(), + child: const Text('Pop'), + ), + ), + ); + }, + ); + }, + ), + ); + }, + child: const Text('Push'), + ), + ), + ); + }, + ), + ), + ), + ); + + expect(vmHome, isNotNull); + + await tester.tap(find.byKey(pushKey)); + await tester.pumpAndSettle(); + + expect(vmSecond, isNotNull); + expect(identical(vmHome, vmSecond), isFalse); + expect(factory.createCalls[TrackingVM], 2); + + await tester.tap(find.byKey(popKey)); + await tester.pumpAndSettle(); + + expect(vmSecond!.disposed, 1); + expect(vmHome!.disposed, 0); + }, + ); +}