Compare commits

..

2 Commits

Author SHA1 Message Date
shenlong-tanwen 7f30ac3219 feat: stack action 2026-06-28 19:56:26 +05:30
shenlong-tanwen b2e3702cf4 chore: cleanup unit tests 2026-06-28 02:02:09 +05:30
21 changed files with 336 additions and 252 deletions
@@ -77,4 +77,22 @@ class AssetService {
await _apiRepository.updateFavorite(remoteIds, isFavorite);
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
}
Future<void> stack(String userId, List<String> remoteIds) async {
if (remoteIds.isEmpty) {
return;
}
final stack = await _apiRepository.stack(remoteIds);
await _remoteRepository.stack(userId, stack);
}
Future<void> unStack(List<String> stackIds) async {
if (stackIds.isEmpty) {
return;
}
await _remoteRepository.unStack(stackIds);
await _apiRepository.unStack(stackIds);
}
}
@@ -17,11 +17,9 @@ class FavoriteAction extends AssetAction<RemoteAsset> {
String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite;
@override
Iterable<RemoteAsset> filter(ActionScope scope) => assets
.where(
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
)
.cast<RemoteAsset>();
Iterable<RemoteAsset> filter(ActionScope scope) => assets.whereType<RemoteAsset>().where(
(asset) => asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
);
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_ui/immich_ui.dart';
class StackAction extends AssetAction<RemoteAsset> {
final bool shouldStack;
StackAction({required super.assets})
: shouldStack = assets.any((asset) => asset is RemoteAsset && asset.stackId == null);
@override
IconData get icon => shouldStack ? Icons.filter_none_rounded : Icons.layers_clear_outlined;
@override
String label(ActionScope scope) => shouldStack ? scope.context.t.stack : scope.context.t.unstack;
@override
Iterable<RemoteAsset> filter(ActionScope scope) =>
assets.whereType<RemoteAsset>().where((asset) => asset.ownerId == scope.authUser.id);
@override
bool isVisible(ActionScope scope) => shouldStack ? filter(scope).length > 1 : filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:ref) = scope;
final assets = filter(scope).toList(growable: false);
final service = ref.read(assetServiceProvider);
if (shouldStack) {
await service.stack(scope.authUser.id, assets.map((asset) => asset.id).toList(growable: false));
} else {
await service.unStack(assets.map((asset) => asset.stackId).nonNulls.toList(growable: false));
}
final message = shouldStack
? StaticTranslations.instance.stacked_assets_count(count: assets.length)
: StaticTranslations.instance.unstacked_assets_count(count: assets.length);
snackbar.success(message);
}
}
@@ -1,50 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class StackActionButton extends ConsumerWidget {
final ActionSource source;
const StackActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access stack action');
}
final result = await ref.read(actionProvider.notifier).stack(user.id, source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'stack_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.filter_none_rounded,
label: "stack".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,48 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnStackActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnStackActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).unStack(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unstack_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.layers_clear_outlined,
label: "unstack".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -5,6 +5,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
@@ -14,10 +15,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -77,7 +76,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
final actions = [FavoriteAction(assets: assets), StackAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
@@ -97,8 +96,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
],
@@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
@@ -16,9 +17,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -68,7 +67,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
final actions = [FavoriteAction(assets: assets), StackAction(assets: assets)];
return BaseBottomSheet(
initialChildSize: 0.4,
@@ -87,8 +86,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
],
@@ -5,6 +5,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
@@ -18,9 +19,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_actio
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
@@ -84,7 +83,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [AssetDebugAction(assets: assets)];
final actions = [AssetDebugAction(assets: assets), StackAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
@@ -107,8 +106,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.onlyLocal || multiselect.hasMerged)
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
@@ -17,9 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_al
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -86,7 +85,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
final actions = [FavoriteAction(assets: assets), StackAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
@@ -111,8 +110,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
@@ -22,8 +22,6 @@ class MultiSelectState {
bool get hasRemote =>
selectedAssets.any((asset) => asset.storage == AssetState.remote || asset.storage == AssetState.merged);
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
+2 -2
View File
@@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
@@ -30,7 +31,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -248,7 +248,7 @@ enum ActionButtonType {
menuItem: menuItem,
),
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => ActionMenuItemWidget(action: StackAction(assets: [context.asset])),
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
remoteId: context.asset.remoteId!,
origin: context.timelineOrigin,
@@ -5,7 +5,7 @@ import '../../utils.dart';
class RemoteAssetFactory {
const RemoteAssetFactory();
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) {
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false, String? stackId}) {
id = TestUtils.uuid(id);
return RemoteAsset(
@@ -17,6 +17,7 @@ class RemoteAssetFactory {
createdAt: TestUtils.yesterday(),
updatedAt: TestUtils.now(),
isFavorite: isFavorite,
stackId: stackId,
isEdited: false,
);
}
+64 -14
View File
@@ -1,7 +1,10 @@
import 'dart:typed_data';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:mocktail/mocktail.dart' as mock;
import 'package:mocktail/mocktail.dart';
@@ -12,11 +15,11 @@ import 'factories/local_asset_factory.dart';
import 'factories/user_factory.dart';
class RepositoryMocks {
final localAlbum = MockLocalAlbumRepository();
final localAsset = MockDriftLocalAssetRepository();
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
final trashedAsset = MockTrashedLocalAssetRepository();
final nativeApi = MockNativeSyncApi();
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
RepositoryMocks() {
resetAll();
@@ -24,17 +27,34 @@ class RepositoryMocks {
void resetAll() {
_registerFallbacks();
reset(localAlbum);
reset(localAsset);
localAlbum.reset();
localAsset.reset();
reset(trashedAsset);
reset(nativeApi);
nativeApi.reset();
_stubLocalAlbumRepository();
_stubLocalAssetRepository();
_stubNativeSyncApi();
}
void _stubLocalAlbumRepository() {
when(localAlbum.getBackupAlbums).thenAnswer((_) async => []);
when(localAlbum.getAssetsToHash).thenAnswer((_) async => []);
}
void _stubLocalAssetRepository() {
when(localAsset.reconcileHashesFromCloudId).thenAnswer((_) async => {});
when(localAsset.updateHashes).thenAnswer((_) async => {});
}
void _stubNativeSyncApi() {
when(nativeApi.hashAssets).thenAnswer((_) async => []);
}
}
class ServiceMocks {
final PartnerStub partner = PartnerStub(MockPartnerService());
final UserStub user = UserStub(MockUserService());
final asset = AssetStub(MockAssetService());
final partner = PartnerServiceStub(MockPartnerService());
final user = UserServiceStub(MockUserService());
final asset = AssetServiceStub(MockAssetService());
ServiceMocks() {
resetAll();
@@ -69,6 +89,8 @@ class ServiceMocks {
void _stubAssetService() {
when(asset.updateFavorite).thenAnswer((_) async {});
when(asset.stack).thenAnswer((_) async {});
when(asset.unStack).thenAnswer((_) async {});
}
}
@@ -78,11 +100,28 @@ void _registerFallbacks() {
registerFallbackValue(Uint8List(0));
}
extension type const Stub<T extends Mock>(T mockedService) {
void reset() => mock.reset(mockedService);
extension type const Stub<T extends Mock>(T mockedClass) {
void reset() => mock.reset(mockedClass);
}
extension type const PartnerStub(MockPartnerService service) implements Stub<MockPartnerService> {
extension type const LocalAlbumRepositoryStub(MockLocalAlbumRepository repo) implements Stub<MockLocalAlbumRepository> {
Future<List<LocalAlbum>> Function() get getBackupAlbums =>
() => repo.getBackupAlbums();
Future<List<LocalAsset>> Function() get getAssetsToHash =>
() => repo.getAssetsToHash(any());
}
extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo)
implements Stub<MockDriftLocalAssetRepository> {
Future<void> Function() get reconcileHashesFromCloudId =>
() => repo.reconcileHashesFromCloudId();
Future<void> Function() get updateHashes =>
() => repo.updateHashes(any());
}
extension type const PartnerServiceStub(MockPartnerService service) implements Stub<MockPartnerService> {
Stream<Iterable<User>> Function() get getCandidates =>
() => service.getCandidates(any());
@@ -110,7 +149,7 @@ extension type const PartnerStub(MockPartnerService service) implements Stub<Moc
);
}
extension type const UserStub(MockUserService service) implements Stub<MockUserService> {
extension type const UserServiceStub(MockUserService service) implements Stub<MockUserService> {
UserDto Function() get getMyUser =>
() => service.getMyUser();
@@ -127,7 +166,18 @@ extension type const UserStub(MockUserService service) implements Stub<MockUserS
() => service.createProfileImage(any(), any());
}
extension type const AssetStub(MockAssetService service) implements Stub<MockAssetService> {
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
Future<void> Function() get updateFavorite =>
() => service.updateFavorite(any(), any());
Future<void> Function() get stack =>
() => service.stack(any(), any());
Future<void> Function() get unStack =>
() => service.unStack(any());
}
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
Future<List<HashResult>> Function() get hashAssets =>
() => api.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'));
}
@@ -6,7 +6,7 @@ import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_ui/immich_ui.dart';
import '../../factories/remote_asset_factory.dart';
import '../../presentation_context.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
@@ -23,8 +23,8 @@ void main() {
group('AssetDebugAction', () {
testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
overrides: context.overrides,
);
expect(find.byType(ImmichIconButton), findsOneWidget);
@@ -32,10 +32,10 @@ void main() {
testWidgets('hidden for multiple assets', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(
action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]),
),
overrides: context.overrides,
);
expect(find.byType(ImmichIconButton), findsNothing);
@@ -44,8 +44,8 @@ void main() {
testWidgets('hidden when advanced troubleshooting is off', (tester) async {
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
overrides: context.overrides,
);
expect(find.byType(ImmichIconButton), findsNothing);
@@ -1,30 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/remote_asset_factory.dart';
import '../../presentation_context.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetService assetService;
setUp(() async {
context = await PresentationContext.create();
assetService = context.service.asset.service;
});
tearDown(() {
context.dispose();
});
List<Override> overrides() => [
...context.overrides,
assetServiceProvider.overrideWithValue(context.mocks.asset.service),
];
RemoteAsset owned({bool isFavorite = false}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
@@ -32,48 +28,48 @@ void main() {
testWidgets('favorites the eligible owned assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
verify(() => context.mocks.asset.service.updateFavorite([asset.id], true)).called(1);
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
});
testWidgets('unfavorite the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
verify(() => context.mocks.asset.service.updateFavorite([asset.id], false)).called(1);
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(FavoriteAction(assets: [mine, theirs]), overrides: overrides());
await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
verify(() => context.mocks.asset.service.updateFavorite([mine.id], true)).called(1);
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(FavoriteAction(assets: [first, second]), overrides: overrides());
await tester.pumpTestAction(context, FavoriteAction(assets: [first, second]));
verify(() => context.mocks.asset.service.updateFavorite([first.id, second.id], true)).called(1);
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
final stale = owned();
final alreadyFavorite = owned(isFavorite: true);
await tester.pumpTestAction(FavoriteAction(assets: [stale, alreadyFavorite]), overrides: overrides());
await tester.pumpTestAction(context, FavoriteAction(assets: [stale, alreadyFavorite]));
verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1);
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(FavoriteAction(assets: [owned()]), overrides: overrides());
await tester.pumpTestAction(context, FavoriteAction(assets: [owned()]));
await tester.pumpUntilFound(find.byType(SnackBar));
expect(find.byType(SnackBar), findsOneWidget);
@@ -4,17 +4,19 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/presentation/actions/partner.action.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/user_factory.dart';
import '../../presentation_context.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockPartnerService partnerService;
setUp(() async {
context = await PresentationContext.create();
partnerService = context.service.partner.service;
});
tearDown(() {
@@ -22,8 +24,6 @@ void main() {
});
List<Override> overrides({List<User> candidates = const []}) => [
...context.overrides,
partnerServiceProvider.overrideWithValue(context.mocks.partner.service),
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
];
@@ -31,22 +31,24 @@ void main() {
testWidgets('creates a partner for the selected candidate', (tester) async {
final candidate = UserFactory.create();
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
await tester.pumpTestAction(context, const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
await tester.pumpUntilFound(find.text(candidate.name));
await tester.tap(find.text(candidate.name));
await tester.pumpAndSettle();
verify(
() => context.mocks.partner.service.create(sharedById: context.currentUser.id, sharedWithId: candidate.id),
).called(1);
verify(() => partnerService.create(sharedById: context.currentUser.id, sharedWithId: candidate.id)).called(1);
});
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [UserFactory.create()]));
await tester.pumpTestAction(
context,
const PartnerAddAction(),
overrides: overrides(candidates: [UserFactory.create()]),
);
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
await tester.pumpAndSettle();
verifyNever(context.mocks.partner.create);
verifyNever(context.service.partner.create);
});
});
@@ -54,27 +56,27 @@ void main() {
testWidgets('deletes the partner after confirmation', (tester) async {
final partner = UserFactory.create();
await tester.pumpTestAction(
context,
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
overrides: overrides(),
);
await tester.tap(find.byType(TextButton).last); // confirm
await tester.pumpAndSettle();
verify(
() => context.mocks.partner.service.delete(sharedById: context.currentUser.id, sharedWithId: partner.id),
).called(1);
verify(() => partnerService.delete(sharedById: context.currentUser.id, sharedWithId: partner.id)).called(1);
});
testWidgets('deletes nothing when the confirmation is cancelled', (tester) async {
final partner = UserFactory.create();
await tester.pumpTestAction(
context,
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
overrides: overrides(),
);
await tester.tap(find.byType(TextButton).first); // cancel
await tester.pumpAndSettle();
verifyNever(context.mocks.partner.delete);
verifyNever(context.service.partner.delete);
});
});
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetService assetService;
setUp(() async {
context = await PresentationContext.create();
assetService = context.service.asset.service;
});
tearDown(() {
context.dispose();
});
RemoteAsset owned({String? stackId}) => RemoteAssetFactory.create(ownerId: context.currentUser.id, stackId: stackId);
group('StackAction', () {
testWidgets('stacks the eligible owned assets', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
});
testWidgets('unstacks the eligible owned assets', (tester) async {
final asset = owned(stackId: 'stack');
await tester.pumpTestAction(context, StackAction(assets: [asset]));
verify(() => assetService.unStack(['stack'])).called(1);
});
testWidgets('prioritizes stack when mixed state', (tester) async {
final first = owned();
final second = owned(stackId: 'stack');
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final other = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(context, StackAction(assets: [mine, other, theirs]));
verify(() => assetService.stack(context.currentUser.id, [mine.id, other.id])).called(1);
});
testWidgets('unstacks every selected stack in a single call', (tester) async {
final first = owned(stackId: 'stack-1');
final second = owned(stackId: 'stack-2');
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
verify(() => assetService.unStack(['stack-1', 'stack-2'])).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(context, StackAction(assets: [owned(), owned()]));
await tester.pumpUntilFound(find.byType(SnackBar));
expect(find.byType(SnackBar), findsOneWidget);
});
});
}
@@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import '../../factories/remote_asset_factory.dart';
import '../../presentation_context.dart';
import '../presentation_context.dart';
class _FakeAction extends BaseAction {
_FakeAction({this.visible = true, this.error});
@@ -48,8 +48,7 @@ void main() {
context.dispose();
});
List<Override> seededOverrides() => [
...context.overrides,
List<Override> overrides() => [
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
@@ -61,6 +60,7 @@ void main() {
late ActionScope scope;
late ProviderContainer container;
await tester.pumpTestWidget(
context,
Consumer(
builder: (innerContext, ref, _) {
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
@@ -68,7 +68,7 @@ void main() {
return const SizedBox.shrink();
},
),
overrides: seededOverrides(),
overrides: overrides(),
);
return (scope, container);
}
@@ -97,8 +97,8 @@ void main() {
testWidgets('delegates visibility to the wrapped action', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))),
overrides: context.overrides,
);
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
@@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/partner.action.dart';
import '../factories/partner_user_factory.dart';
import '../factories/user_factory.dart';
import '../presentation_context.dart';
import 'presentation_context.dart';
void main() {
late PresentationContext context;
@@ -19,7 +19,7 @@ void main() {
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
final action = const PartnerAddAction();
await tester.pumpTestWidget(const PartnerSharedByList(partners: []), overrides: context.overrides);
await tester.pumpTestWidget(context, const PartnerSharedByList(partners: []));
expect(find.byType(ListView), findsNothing);
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
@@ -28,8 +28,7 @@ void main() {
testWidgets('renders a tile per partner with name and email', (tester) async {
final partner1 = PartnerFactory.create();
final partner2 = PartnerFactory.create();
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
expect(find.byType(ListTile), findsNWidgets(2));
expect(find.text(partner1.name), findsOneWidget);
expect(find.text(partner1.email), findsOneWidget);
@@ -41,7 +40,7 @@ void main() {
final partner1 = PartnerFactory.create(inTimeline: true);
final partner2 = PartnerFactory.create();
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
expect(find.byIcon(action.icon), findsNWidgets(2));
});
});
@@ -62,13 +61,12 @@ void main() {
}
List<Override> withCandidates(List<User> candidates) => [
...context.overrides,
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
];
testWidgets('renders an option per candidate fetched from the provider', (tester) async {
final user = UserFactory.create();
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates([user]));
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates([user]));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -78,7 +76,7 @@ void main() {
});
testWidgets('shows no options when the provider returns no candidates', (tester) async {
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates(const []));
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates(const []));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -89,7 +87,11 @@ void main() {
testWidgets('pops the selected candidate when an option is tapped', (tester) async {
final user = UserFactory.create();
User? selected;
await tester.pumpTestWidget(dialogWidget(onClosed: (user) => selected = user), overrides: withCandidates([user]));
await tester.pumpTestWidget(
context,
dialogWidget(onClosed: (user) => selected = user),
overrides: withCandidates([user]),
);
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -13,16 +13,21 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
import '../test_utils.dart';
import 'factories/user_factory.dart';
import 'mocks.dart';
import '../../test_utils.dart';
import '../factories/user_factory.dart';
import '../mocks.dart';
class PresentationContext {
PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() {
PresentationContext._({required UserDto user})
: currentUser = user,
service = ServiceMocks(),
repository = RepositoryMocks() {
setup();
}
@@ -31,9 +36,14 @@ class PresentationContext {
static Drift? _db;
final UserDto currentUser;
final ServiceMocks mocks;
final ServiceMocks service;
final RepositoryMocks repository;
List<Override> get overrides => [currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service))];
List<Override> get overrides => [
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
assetServiceProvider.overrideWithValue(service.asset.service),
partnerServiceProvider.overrideWithValue(service.partner.service),
];
static Future<PresentationContext> create() async {
TestUtils.init();
@@ -47,18 +57,18 @@ class PresentationContext {
}
void setup() {
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
when(service.user.tryGetMyUser).thenReturn(currentUser);
}
void dispose() {
addTearDown(() {
mocks.resetAll();
service.resetAll();
});
}
}
extension PumpPresentationWidget on WidgetTester {
Future<void> pumpTestWidget(Widget widget, {List<Override> overrides = const []}) async {
Future<void> pumpTestWidget(PresentationContext context, Widget widget, {List<Override> overrides = const []}) async {
await pumpWidget(
EasyLocalization(
supportedLocales: locales.values.toList(),
@@ -69,7 +79,7 @@ extension PumpPresentationWidget on WidgetTester {
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: ProviderScope(
overrides: overrides,
overrides: [...context.overrides, ...overrides],
child: Builder(
builder: (context) => MaterialApp(
debugShowCheckedModeBanner: false,
@@ -86,8 +96,12 @@ extension PumpPresentationWidget on WidgetTester {
await pumpAndSettle();
}
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides);
Future<void> pumpTestAction(
PresentationContext context,
BaseAction action, {
List<Override> overrides = const [],
}) async {
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
await tap(find.byType(ImmichIconButton));
await pump();
}
@@ -14,14 +14,11 @@ void main() {
setUp(() {
sut = HashService(
localAlbumRepository: mocks.localAlbum,
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
localAlbumRepository: mocks.localAlbum.repo,
localAssetRepository: mocks.localAsset.repo,
nativeSyncApi: mocks.nativeApi.api,
trashedLocalAssetRepository: mocks.trashedAsset,
);
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
});
tearDown(() {
@@ -32,22 +29,20 @@ void main() {
group('hashAssets', () {
test('skips albums with no assets to hash', () async {
final album = LocalAlbumFactory.create(assetCount: 0);
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
await sut.hashAssets();
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
verifyNever(mocks.nativeApi.hashAssets);
});
test('skips empty batches', () async {
final album = LocalAlbumFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
await sut.hashAssets();
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
verifyNever(mocks.nativeApi.hashAssets);
});
test('processes assets when available', () async {
@@ -55,15 +50,17 @@ void main() {
final asset = LocalAssetFactory.create();
final result = HashResult(assetId: asset.id, hash: 'test-hash');
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).thenAnswer((_) async => [result]);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [result]);
await sut.hashAssets();
verify(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
verify(() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
final captured =
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1);
expect(captured[asset.id], result.hash);
});
@@ -72,16 +69,16 @@ void main() {
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 0);
});
@@ -89,25 +86,25 @@ void main() {
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 0);
});
test('batches by size limit', () async {
const batchSize = 2;
final sut = HashService(
localAlbumRepository: mocks.localAlbum,
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
localAlbumRepository: mocks.localAlbum.repo,
localAssetRepository: mocks.localAsset.repo,
nativeSyncApi: mocks.nativeApi.api,
batchSize: batchSize,
trashedLocalAssetRepository: mocks.trashedAsset,
);
@@ -119,12 +116,9 @@ void main() {
final capturedCalls = <List<String>>[];
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
invocation,
) async {
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async {
final assetIds = invocation.positionalArguments[0] as List<String>;
capturedCalls.add(List<String>.from(assetIds));
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
@@ -136,7 +130,7 @@ void main() {
expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets');
expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset');
verify(() => mocks.localAsset.updateHashes(any())).called(2);
verify(() => mocks.localAsset.repo.updateHashes(any())).called(2);
});
test('handles mixed success and failure in batch', () async {
@@ -144,9 +138,9 @@ void main() {
final asset1 = LocalAssetFactory.create();
final asset2 = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
when(() => mocks.nativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
when(() => mocks.nativeApi.api.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
(_) async => [
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
@@ -156,7 +150,7 @@ void main() {
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1);
expect(captured[asset1.id], 'asset1-hash');
});
@@ -167,20 +161,18 @@ void main() {
final asset1 = LocalAssetFactory.create();
final asset2 = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
when(() => mocks.localAlbum.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
when(() => mocks.localAlbum.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
invocation,
) async {
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
when(() => mocks.localAlbum.repo.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
when(() => mocks.localAlbum.repo.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async {
final assetIds = invocation.positionalArguments[0] as List<String>;
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
});
await sut.hashAssets();
verify(() => mocks.nativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
verify(() => mocks.nativeApi.api.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
verify(() => mocks.nativeApi.api.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
});
});
});