Added controller and widget tests

This commit is contained in:
Adam Gastineau 2026-08-19 14:49:42 -07:00
parent d5f9141b15
commit 16e9aad0a4
4 changed files with 612 additions and 2 deletions

View file

@ -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 = <String>[];
void emit(VideoPlayerState next) => state = next;
@override
Future<void> play() async => calls.add('play');
@override
Future<void> pause() async => calls.add('pause');
}
const slideDuration = Duration(seconds: 3);
/// A small step to precisely test event timing
const tick = Duration(milliseconds: 1);
List<BaseAsset> images(int count) => List.generate(count, (_) => RemoteAssetFactory.create());
TimelineService timelineOf(List<BaseAsset> 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<BaseAsset> assets, {Completer<void>? 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<void> Function(WidgetTester tester) body, {
TimelineService Function()? timeline,
SlideshowDirection direction = SlideshowDirection.forward,
bool repeat = true,
bool disableAnimations = false,
List<Override> 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<ConsumerState<DriftSlideshowPage>>(find.byType(DriftSlideshowPage)) as SlideshowDelegate;
/// The currently visible slide index
int visibleIndex(WidgetTester tester) =>
tester.widgetList<SlideshowSlide>(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<SlideshowSlide>(find.byWidgetPredicate((w) => w is SlideshowSlide && w.index == index && !w.frozen))
.zoom
.value;
double? progressIndicatorProgress(WidgetTester tester) =>
tester.widget<LinearProgressIndicator>(find.byType(LinearProgressIndicator)).value;
final fadingSlide = find.byWidgetPredicate((w) => w is SlideshowSlide && w.frozen);
Future<void> 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<SlideshowSlide>(fadingSlide).index, 0);
expect(visibleIndex(tester), 1);
final fade = find.ancestor(of: fadingSlide, matching: find.byType(FadeTransition)).first;
expect(tester.widget<FadeTransition>(fade).opacity.value, 1.0);
await tester.pump(Durations.extralong2 * 0.5);
expect(tester.widget<FadeTransition>(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<void>()),
);
});
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<AnimatedOpacity>(appBar).opacity, 0.0);
await tapScreen(tester);
expect(tester.widget<AnimatedOpacity>(appBar).opacity, 1.0);
await tapScreen(tester);
expect(tester.widget<AnimatedOpacity>(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<void> 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<void>()),
);
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,
);
});
}

View file

@ -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 = <int, Duration>{};
final completedVideos = <int>{};
/// Test action log
final events = <String>[];
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<void> 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);
});
}

View file

@ -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<Mo
}
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
Future<BaseAsset?> Function() get getAsset =>
() => service.getAsset(any());
Future<void> Function() get update =>
() => service.update(
any(),

View file

@ -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<void> dispose() async {
@ -98,7 +105,15 @@ class PresentationContext {
}
extension PumpPresentationWidget on WidgetTester {
Future<void> pumpTestWidget(PresentationContext context, Widget widget, {List<Override> overrides = const []}) async {
/// Renders the UI from the given [widget]
///
/// Provide [expectSettle] `false` for a component that infinitely animates
Future<void> pumpTestWidget(
PresentationContext context,
Widget widget, {
List<Override> 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<void> pumpTestAction(