diff --git a/mobile/test/presentation/pages/drift_slideshow_page_test.dart b/mobile/test/presentation/pages/drift_slideshow_page_test.dart new file mode 100644 index 0000000000..e5f1b842ec --- /dev/null +++ b/mobile/test/presentation/pages/drift_slideshow_page_test.dart @@ -0,0 +1,388 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/config/app_config.dart'; +import 'package:immich_mobile/domain/models/config/slideshow_config.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart'; +import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_controller.dart'; +import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_slide.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; + +import '../../unit/factories/remote_asset_factory.dart'; +import '../../unit/presentation/presentation_context.dart'; + +/// A VideoPlayerNotifier that records play/pause events +class FakeVideoPlayer extends VideoPlayerNotifier { + /// Every call made against this video, in order + final calls = []; + + void emit(VideoPlayerState next) => state = next; + + @override + Future play() async => calls.add('play'); + + @override + Future pause() async => calls.add('pause'); +} + +const slideDuration = Duration(seconds: 3); + +/// A small step to precisely test event timing +const tick = Duration(milliseconds: 1); + +List images(int count) => List.generate(count, (_) => RemoteAssetFactory.create()); + +TimelineService timelineOf(List assets, {TimelineAssetSource? source}) => TimelineService(( + assetSource: source ?? (index, count) async => assets.sublist(index, math.min(index + count, assets.length)), + bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]), + origin: TimelineOrigin.main, +)); + +/// A timeline whose last asset is still loading +TimelineService partlyLoaded(List assets, {Completer? stall}) { + var loadCount = 0; + + return timelineOf( + assets, + source: (index, count) async { + loadCount += 1; + + if (loadCount != 1) { + await stall?.future; + } + + return assets.sublist(index, math.min(index + count, loadCount == 1 ? assets.length - 1 : assets.length)); + }, + ); +} + +void main() { + late PresentationContext ctx; + + setUp(() async => ctx = await PresentationContext.create()); + + tearDown(() async => ctx.dispose()); + + void slideshowTest( + String description, + Future Function(WidgetTester tester) body, { + TimelineService Function()? timeline, + SlideshowDirection direction = SlideshowDirection.forward, + bool repeat = true, + bool disableAnimations = false, + List overrides = const [], + }) { + testWidgets(description, (tester) async { + if (disableAnimations) { + tester.binding.platformDispatcher.accessibilityFeaturesTestValue = const FakeAccessibilityFeatures( + disableAnimations: true, + ); + } + + await tester.pumpTestWidget( + ctx, + DriftSlideshowPage(timeline: (timeline ?? () => timelineOf(images(3)))()), + expectSettle: false, + overrides: [ + appConfigProvider.overrideWithValue( + AppConfig( + slideshow: SlideshowConfig(duration: 3, look: .contain, direction: direction, repeat: repeat), + ), + ), + ...overrides, + ], + ); + + await tester.pump(); + + await body(tester); + }); + } + + /// The delegate for this page + SlideshowDelegate delegateOf(WidgetTester tester) => + tester.state>(find.byType(DriftSlideshowPage)) as SlideshowDelegate; + + /// The currently visible slide index + int visibleIndex(WidgetTester tester) => + tester.widgetList(find.byType(SlideshowSlide)).firstWhere((slide) => slide.isCurrent).index; + + /// The current value of the Ken Burns zoom for a particular slide + double currentZoomOf(WidgetTester tester, int index) => tester + .widget(find.byWidgetPredicate((w) => w is SlideshowSlide && w.index == index && !w.frozen)) + .zoom + .value; + + double? progressIndicatorProgress(WidgetTester tester) => + tester.widget(find.byType(LinearProgressIndicator)).value; + + final fadingSlide = find.byWidgetPredicate((w) => w is SlideshowSlide && w.frozen); + + Future tapScreen(WidgetTester tester) async { + await tester.tap(find.byType(PageView)); + + // The app bar does stuff in post frame, so we need to run twice + await tester.pump(); + await tester.pump(); + } + + group('basic rendering', () { + slideshowTest('should show the first slide only', (tester) async { + expect(visibleIndex(tester), 0); + expect(fadingSlide, findsNothing); + }); + + slideshowTest('should zoom the slide for its duration', (tester) async { + expect(currentZoomOf(tester, 0), 0.0); + + await tester.pump(slideDuration * 0.5); + // 50% through duration we should see 50% of the zoom completed + expect(currentZoomOf(tester, 0), closeTo(0.5, 0.01)); + }); + + slideshowTest('should reverse the zoom direction when moving to the next slide', (tester) async { + await tester.pump(slideDuration + tick); + await tester.pump(); + + // The previous slide zoomed in, so this one starts zoomed in and will zoom out + expect(currentZoomOf(tester, 1), closeTo(1.0, 0.01)); + + await tester.pump(slideDuration * 0.3); + expect(currentZoomOf(tester, 1), closeTo(0.7, 0.01)); + }); + + slideshowTest('should show the next slide once the duration passes', (tester) async { + await tester.pump(slideDuration - tick); + expect(visibleIndex(tester), 0); + + await tester.pump(tick * 2); + expect(visibleIndex(tester), 1); + }); + + slideshowTest('should fade the outgoing slide over the incoming one', (tester) async { + await tester.pump(slideDuration + tick); + await tester.pump(); + + // The old slide covers the new one, then is removed + expect(tester.widget(fadingSlide).index, 0); + expect(visibleIndex(tester), 1); + + final fade = find.ancestor(of: fadingSlide, matching: find.byType(FadeTransition)).first; + expect(tester.widget(fade).opacity.value, 1.0); + + await tester.pump(Durations.extralong2 * 0.5); + expect(tester.widget(fade).opacity.value, lessThan(1.0)); + + await tester.pump(Durations.extralong2); + expect(fadingSlide, findsNothing); + }); + + slideshowTest('should not fade when animations are disabled', (tester) async { + await tester.pump(slideDuration + tick); + await tester.pump(); + + expect(visibleIndex(tester), 1); + expect(fadingSlide, findsNothing); + + expect(currentZoomOf(tester, 1), 0.0); + + await tester.pump(slideDuration * 0.5); + expect(currentZoomOf(tester, 1), 0.0); + }, disableAnimations: true); + + slideshowTest( + 'should show a loading indicator for a slide that isnt ready', + (tester) async { + await tester.drag(find.byType(PageView), const Offset(-800, 0)); + await tester.pump(); + + expect(find.byType(ImmichLoadingIndicator), findsOneWidget); + }, + timeline: () => partlyLoaded(images(2), stall: Completer()), + ); + }); + + group('interactions', () { + slideshowTest('should advance the progress bar', (tester) async { + expect(progressIndicatorProgress(tester), 0.0); + + await tester.pump(slideDuration * 0.45); + expect(progressIndicatorProgress(tester), closeTo(0.45, 0.01)); + }); + + slideshowTest('should show/hide the AppBar', (tester) async { + final appBar = find.byType(AnimatedOpacity).first; + + expect(tester.widget(appBar).opacity, 0.0); + + await tapScreen(tester); + expect(tester.widget(appBar).opacity, 1.0); + + await tapScreen(tester); + expect(tester.widget(appBar).opacity, 0.0); + }); + + slideshowTest('should stop slide movement and progress bar when paused', (tester) async { + await tester.pump(slideDuration * 0.5); + + // Show AppBar and pause the app + await tapScreen(tester); + await tester.tap(find.byIcon(Icons.pause)); + await tester.pump(); + + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + + final held = progressIndicatorProgress(tester); + await tester.pump(slideDuration); + expect(progressIndicatorProgress(tester), held); + expect(visibleIndex(tester), 0); + }); + + slideshowTest('should resume slide from its paused position', (tester) async { + await tester.pump(slideDuration * 0.64); + + await tapScreen(tester); + await tester.tap(find.byIcon(Icons.pause)); + await tester.pump(); + await tester.tap(find.byIcon(Icons.play_arrow)); + + // Resuming restarts the ticker, so this frame is its new baseline and elapses nothing + await tester.pump(); + + // We should have precisely 36% left before transition + await tester.pump(slideDuration * 0.36 - tick); + expect(visibleIndex(tester), 0); + + await tester.pump(tick * 2); + expect(visibleIndex(tester), 1); + }); + + late Completer arrived; + + slideshowTest( + 'should properly handle an async load the user navigated away from', + (tester) async { + await tester.pump(slideDuration + tick); + expect(visibleIndex(tester), 1); + + // This slide will get stuck + await tester.pump(slideDuration + tick); + + // Since the slide is stuck, the user swipes back to the first asset + await tester.drag(find.byType(PageView), const Offset(800, 0)); + await tester.pump(); + expect(visibleIndex(tester), 0); + + // The stuck slide loads, but it doesn't matter + arrived.complete(); + await tester.pump(); + await tester.pump(); + expect(visibleIndex(tester), 0); + }, + timeline: () => partlyLoaded(images(3), stall: arrived = Completer()), + ); + + slideshowTest('should start the slide timer when navigating to a new slide', (tester) async { + await tester.pump(slideDuration * 0.75); + + await tester.drag(find.byType(PageView), const Offset(-800, 0)); + await tester.pump(); + + expect(visibleIndex(tester), 1); + expect(progressIndicatorProgress(tester), 0.0); + + await tester.pump(slideDuration * 0.35); + expect(progressIndicatorProgress(tester), closeTo(0.35, 0.01)); + }); + }); + + group('delegate API', () { + final video = RemoteAssetFactory.create(type: .video); + + late FakeVideoPlayer player; + + final playing = [videoPlayerProvider(video.id).overrideWith((ref) => player)]; + + TimelineService withVideo() => timelineOf([video, ...images(1)]); + + setUp(() => player = FakeVideoPlayer()); + + slideshowTest('should wrap past end', (tester) async { + expect(delegateOf(tester).nextIndexAfter(0), 1); + expect(delegateOf(tester).nextIndexAfter(2), 0); + }); + + slideshowTest('should wrap past start', (tester) async { + expect(delegateOf(tester).nextIndexAfter(2), 1); + expect(delegateOf(tester).nextIndexAfter(0), 2); + }, direction: .backward); + + slideshowTest('should end the slideshow at the last slide when repeat is disabled', (tester) async { + expect(delegateOf(tester).nextIndexAfter(1), 2); + expect(delegateOf(tester).nextIndexAfter(2), null); + }, repeat: false); + + slideshowTest( + 'should end the slideshow at the first slide when running backwards', + (tester) async { + expect(delegateOf(tester).nextIndexAfter(1), 0); + expect(delegateOf(tester).nextIndexAfter(0), null); + }, + repeat: false, + direction: .backward, + ); + + slideshowTest('should end the slideshow when there is nothing to show', (tester) async { + expect(delegateOf(tester).nextIndexAfter(0), null); + }, timeline: () => timelineOf(const [])); + + slideshowTest( + 'should provide video playback position, but not image positions', + (tester) async { + player.emit(const VideoPlayerState(position: Duration(seconds: 2), duration: .zero, status: .playing)); + + expect(delegateOf(tester).videoProgressOf(0), const Duration(seconds: 2)); + expect(delegateOf(tester).videoProgressOf(1), null); + }, + timeline: withVideo, + overrides: playing, + ); + + slideshowTest( + 'should report when videos complete', + (tester) async { + expect(delegateOf(tester).isVideoCompleted(0), false); + + player.emit(const VideoPlayerState(position: Duration(seconds: 2), duration: .zero, status: .completed)); + expect(delegateOf(tester).isVideoCompleted(0), true); + expect(delegateOf(tester).isVideoCompleted(1), false); + }, + timeline: withVideo, + overrides: playing, + ); + + slideshowTest( + 'should pause and play the video along with the slideshow', + (tester) async { + delegateOf(tester).onPlaybackChanged(0, false); + delegateOf(tester).onPlaybackChanged(0, true); + expect(player.calls, ['pause', 'play']); + + // An image does nothing here + delegateOf(tester).onPlaybackChanged(1, true); + expect(player.calls, ['pause', 'play']); + }, + timeline: withVideo, + overrides: playing, + ); + }); +} diff --git a/mobile/test/presentation/widgets/slideshow/slideshow_controller_test.dart b/mobile/test/presentation/widgets/slideshow/slideshow_controller_test.dart new file mode 100644 index 0000000000..9e8d179bb3 --- /dev/null +++ b/mobile/test/presentation/widgets/slideshow/slideshow_controller_test.dart @@ -0,0 +1,198 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/presentation/widgets/slideshow/slideshow_controller.dart'; + +const slideDuration = Duration(seconds: 5); +const tick = Duration(milliseconds: 1); + +class FakeSlideshow implements SlideshowDelegate { + FakeSlideshow({this.nextIndex = _nextInOrder}) { + controller = SlideshowController( + vsync: const TestVSync(), + slideDuration: slideDuration, + initialIndex: 0, + delegate: this, + ); + } + + static int? _nextInOrder(int index) => index + 1; + + int? Function(int index) nextIndex; + + /// Video slide index -> playback position. Image slides do not appear in this map at all + final videos = {}; + final completedVideos = {}; + + /// Test action log + final events = []; + + late final SlideshowController controller; + + @override + int? nextIndexAfter(int index) => nextIndex(index); + + @override + Duration? videoProgressOf(int index) => videos[index]; + + @override + bool isVideoCompleted(int index) => completedVideos.contains(index); + + @override + void onPlaybackChanged(int index, bool playing) => events.add(playing ? 'play' : 'pause'); + + @override + void onShowSlide(int index, int prevIndex) { + events.add('show $index'); + + controller.didCompleteShowSlide(index); + } +} + +/// Runs [body] against a freshly started slideshow +void slideshowTest( + String description, + Future Function(WidgetTester tester, FakeSlideshow show) body, { + int? Function(int index)? nextIndex, +}) { + testWidgets(description, (tester) async { + final show = FakeSlideshow(nextIndex: nextIndex ?? FakeSlideshow._nextInOrder); + + // The slideshow's ticker takes its baseline on this frame, so a test can jump the clock straight away + await tester.pump(); + + try { + await body(tester, show); + } finally { + // The slideshow ticks forever. All tests must stop it, otherwise it will leak into subsequent tests + // This cannot appear in tearDown due to invariants being called outside of "render" + show.controller.dispose(); + } + }); +} + +void main() { + slideshowTest('should advance once the slide duration passes', (tester, show) async { + await tester.pump(slideDuration - tick); + expect(show.events, isEmpty); + + await tester.pump(tick * 2); + expect(show.events, ['show 1']); + }); + + slideshowTest('should show the slide the delegate provides next', (tester, show) async { + await tester.pump(slideDuration + tick); + + expect(show.events, ['show 7']); + }, nextIndex: (_) => 7); + + slideshowTest('should pause when there is no next slide', (tester, show) async { + await tester.pump(slideDuration + tick); + + expect(show.events, isEmpty); + expect(show.controller.paused, isTrue); + }, nextIndex: (_) => null); + + slideshowTest('should stop the clock when paused', (tester, show) async { + show.controller.pause(); + + expect(show.events, ['pause']); + + await tester.pump(slideDuration * 2); + + expect(show.events, ['pause']); + }); + + slideshowTest('should continue the current slide when resumed', (tester, show) async { + await tester.pump(slideDuration * 0.75); + + show.controller.pause(); + show.controller.resume(); + + // Resuming restarts the ticker, so this frame is its new baseline and elapses nothing + await tester.pump(); + + // A quarter of the slide is left + await tester.pump(slideDuration * 0.25 - tick); + expect(show.events, ['pause', 'play']); + + await tester.pump(tick * 2); + expect(show.events, ['pause', 'play', 'show 1']); + }); + + slideshowTest('should not watchdog modify a video that is successfully playing back', (tester, show) async { + show.videos[0] = Duration.zero; + + for (var i = 1; i <= 3; i++) { + // Simulate progress + show.videos[0] = slideDuration * i; + + await tester.pump(slideDuration + tick); + } + + expect(show.events, isEmpty); + }); + + slideshowTest('should advance past a stalled video once the slide duration passes', (tester, show) async { + show.videos[0] = Duration.zero; + + await tester.pump(slideDuration - tick); + expect(show.events, isEmpty); + + await tester.pump(tick * 2); + expect(show.events, ['show 1']); + }); + + slideshowTest('should advance immediately when a video ends', (tester, show) async { + show.videos[0] = Duration.zero; + + show.controller.didCompleteVideo(); + + expect(show.events, ['show 1']); + }); + + slideshowTest('should not move when a video ends while paused', (tester, show) async { + show.videos[0] = Duration.zero; + show.controller.pause(); + + show.controller.didCompleteVideo(); + + expect(show.events, ['pause']); + }); + + slideshowTest('should move to next slide when a finished video is resumed', (tester, show) async { + show.videos[0] = Duration.zero; + show.controller.pause(); + show.completedVideos.add(0); + + expect(show.events, ['pause']); + + show.controller.resume(); + + expect(show.events, ['pause', 'show 1']); + }); + + slideshowTest('should restart the timer on manual navigation to another slide', (tester, show) async { + await tester.pump(slideDuration * 0.60); + + // Widget informs us of a slide change + show.controller.didCompleteShowSlide(2); + + // A new slide restarts the ticker, so this frame is its new baseline and elapses nothing + await tester.pump(); + + // The new slide gets a full slide duration + await tester.pump(slideDuration - tick); + expect(show.events, isEmpty); + + await tester.pump(tick * 2); + expect(show.events, ['show 3']); + }); + + slideshowTest('should pick up a new next slide when recalculating', (tester, show) async { + expect(show.controller.nextIndex, 1); + + show.nextIndex = (_) => 7; + show.controller.recalculateNextIndex(); + + expect(show.controller.nextIndex, 7); + }); +} diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index 754c20aa48..ba220043bf 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -170,6 +170,7 @@ class ServiceMocks { } void _stubAssetService() { + when(asset.getAsset).thenAnswer((_) async => null); when(asset.update).thenAnswer((_) async {}); when(asset.stack).thenAnswer((_) async {}); when(asset.unstack).thenAnswer((_) async {}); @@ -329,6 +330,9 @@ extension type const UserServiceStub(MockUserService service) implements Stub { + Future Function() get getAsset => + () => service.getAsset(any()); + Future Function() get update => () => service.update( any(), diff --git a/mobile/test/unit/presentation/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart index 1d46260435..b465a18f83 100644 --- a/mobile/test/unit/presentation/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/locales.dart'; @@ -89,6 +90,12 @@ class PresentationContext { void setup() { when(service.user.tryGetMyUser).thenReturn(currentUser); + + // Handle system chrome messages + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async => null, + ); } Future dispose() async { @@ -98,7 +105,15 @@ class PresentationContext { } extension PumpPresentationWidget on WidgetTester { - Future pumpTestWidget(PresentationContext context, Widget widget, {List overrides = const []}) async { + /// Renders the UI from the given [widget] + /// + /// Provide [expectSettle] `false` for a component that infinitely animates + Future pumpTestWidget( + PresentationContext context, + Widget widget, { + List overrides = const [], + bool expectSettle = true, + }) async { await pumpWidget( EasyLocalization( supportedLocales: locales.values.toList(), @@ -126,7 +141,12 @@ extension PumpPresentationWidget on WidgetTester { ), ), ); - await pumpAndSettle(); + + if (expectSettle) { + await pumpAndSettle(); + } else { + await pump(); + } } Future pumpTestAction(