From b1120fc2bb0e4c6c0c66eb9cb921aa43a5d97f42 Mon Sep 17 00:00:00 2001 From: JakesMD <71591438+JakesMD@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:57:49 +0200 Subject: [PATCH 1/4] feat: add gem deletion with confirmation dialog (#76) --- app/lib/localization/arb/intl_de.arb | 3 + app/lib/localization/arb/intl_en.arb | 3 + .../generated/localizations.g.dart | 18 ++ .../generated/localizations_de.g.dart | 10 + .../generated/localizations_en.g.dart | 10 + .../views/collection/dialogs/_dialogs.dart | 1 + .../views/collection/dialogs/delete_gem.dart | 53 ++++ .../shared/views/collection/logic/_logic.dart | 1 + .../logic/collection_view_cubit.dart | 22 +- .../collection/logic/gem_delete_cubit.dart | 24 ++ app/lib/shared/views/collection/view.dart | 229 ++++++++++------- .../views/collection/widgets/app_bar.dart | 60 ++++- app/pubspec.yaml | 2 + .../recents_collection/delete_gem_test.dart | 234 ++++++++++++++++++ .../lib/src/exceptions/_exceptions.dart | 1 + .../lib/src/exceptions/gem_delete.dart | 12 + .../database_client/lib/src/gem_client.dart | 16 +- packages/data/database_client/pubspec.yaml | 1 + .../lib/src/exceptions/_exceptions.dart | 1 + .../lib/src/exceptions/gem_delete.dart | 14 ++ .../lib/src/gem_repository.dart | 7 + packages/domain/gem_repository/pubspec.yaml | 1 + wiki/Testing.md | 12 +- 23 files changed, 640 insertions(+), 95 deletions(-) create mode 100644 app/lib/shared/views/collection/dialogs/_dialogs.dart create mode 100644 app/lib/shared/views/collection/dialogs/delete_gem.dart create mode 100644 app/lib/shared/views/collection/logic/gem_delete_cubit.dart create mode 100644 app/test/pages/recents_collection/delete_gem_test.dart create mode 100644 packages/data/database_client/lib/src/exceptions/gem_delete.dart create mode 100644 packages/domain/gem_repository/lib/src/exceptions/gem_delete.dart diff --git a/app/lib/localization/arb/intl_de.arb b/app/lib/localization/arb/intl_de.arb index fa889c38..c02adec4 100644 --- a/app/lib/localization/arb/intl_de.arb +++ b/app/lib/localization/arb/intl_de.arb @@ -16,6 +16,8 @@ }, "changesPropagationBanner_message": "Änderungen werden beim nächsten Öffnen der App von anderen Benutzern übernommen.", "close": "Schließen", + "collectionView_deleteGemDialog_message": "Diese Gemme und alle ihre Zeilen werden endgültig gelöscht. Dies kann nicht rückgängig gemacht werden.", + "collectionView_deleteGemDialog_title": "Diese Gemme löschen?", "collectionView_noGemsTitle": "Keine Gemme zu zeigen", "collectionView_shareSheet_createLinkButton": "Link erstellen", "collectionView_shareSheet_deleteLinkButton": "Link löschen", @@ -30,6 +32,7 @@ "createChestPage_hint_chestName": "Name der Truhe", "createChestPage_title": "Neue Truhe erstellen", "delete": "Löschen", + "edit": "Bearbeiten", "editGemPage_addNarrationButton": "Neue Erzählung", "editGemPage_addQuoteButton": "Neues Zitat", "editGemPage_dateTile_title": "Datum", diff --git a/app/lib/localization/arb/intl_en.arb b/app/lib/localization/arb/intl_en.arb index 987ee83c..6acac229 100644 --- a/app/lib/localization/arb/intl_en.arb +++ b/app/lib/localization/arb/intl_en.arb @@ -16,6 +16,8 @@ }, "changesPropagationBanner_message": "Changes will propagate to other users next time they open the app.", "close": "Close", + "collectionView_deleteGemDialog_message": "This gem and all its lines will be permanently deleted. This can't be undone.", + "collectionView_deleteGemDialog_title": "Delete this gem?", "collectionView_noGemsTitle": "No gems to show", "collectionView_shareSheet_createLinkButton": "Create a share link", "collectionView_shareSheet_deleteLinkButton": "Delete the link", @@ -30,6 +32,7 @@ "createChestPage_hint_chestName": "Chest name", "createChestPage_title": "Create a new chest", "delete": "Delete", + "edit": "Edit", "editGemPage_addNarrationButton": "Add a narration", "editGemPage_addQuoteButton": "Add a quote", "editGemPage_dateTile_title": "Date", diff --git a/app/lib/localization/generated/localizations.g.dart b/app/lib/localization/generated/localizations.g.dart index bde467b0..7e0a304f 100644 --- a/app/lib/localization/generated/localizations.g.dart +++ b/app/lib/localization/generated/localizations.g.dart @@ -151,6 +151,18 @@ abstract class CAppL10n { /// **'Close'** String get close; + /// No description provided for @collectionView_deleteGemDialog_message. + /// + /// In en, this message translates to: + /// **'This gem and all its lines will be permanently deleted. This can\'t be undone.'** + String get collectionView_deleteGemDialog_message; + + /// No description provided for @collectionView_deleteGemDialog_title. + /// + /// In en, this message translates to: + /// **'Delete this gem?'** + String get collectionView_deleteGemDialog_title; + /// No description provided for @collectionView_noGemsTitle. /// /// In en, this message translates to: @@ -235,6 +247,12 @@ abstract class CAppL10n { /// **'Delete'** String get delete; + /// No description provided for @edit. + /// + /// In en, this message translates to: + /// **'Edit'** + String get edit; + /// No description provided for @editGemPage_addNarrationButton. /// /// In en, this message translates to: diff --git a/app/lib/localization/generated/localizations_de.g.dart b/app/lib/localization/generated/localizations_de.g.dart index 6a4e47c6..18e425e5 100644 --- a/app/lib/localization/generated/localizations_de.g.dart +++ b/app/lib/localization/generated/localizations_de.g.dart @@ -39,6 +39,13 @@ class CAppL10nDe extends CAppL10n { @override String get close => 'Schließen'; + @override + String get collectionView_deleteGemDialog_message => + 'Diese Gemme und alle ihre Zeilen werden endgültig gelöscht. Dies kann nicht rückgängig gemacht werden.'; + + @override + String get collectionView_deleteGemDialog_title => 'Diese Gemme löschen?'; + @override String get collectionView_noGemsTitle => 'Keine Gemme zu zeigen'; @@ -82,6 +89,9 @@ class CAppL10nDe extends CAppL10n { @override String get delete => 'Löschen'; + @override + String get edit => 'Bearbeiten'; + @override String get editGemPage_addNarrationButton => 'Neue Erzählung'; diff --git a/app/lib/localization/generated/localizations_en.g.dart b/app/lib/localization/generated/localizations_en.g.dart index 841a9737..a88bf54c 100644 --- a/app/lib/localization/generated/localizations_en.g.dart +++ b/app/lib/localization/generated/localizations_en.g.dart @@ -39,6 +39,13 @@ class CAppL10nEn extends CAppL10n { @override String get close => 'Close'; + @override + String get collectionView_deleteGemDialog_message => + 'This gem and all its lines will be permanently deleted. This can\'t be undone.'; + + @override + String get collectionView_deleteGemDialog_title => 'Delete this gem?'; + @override String get collectionView_noGemsTitle => 'No gems to show'; @@ -83,6 +90,9 @@ class CAppL10nEn extends CAppL10n { @override String get delete => 'Delete'; + @override + String get edit => 'Edit'; + @override String get editGemPage_addNarrationButton => 'Add a narration'; diff --git a/app/lib/shared/views/collection/dialogs/_dialogs.dart b/app/lib/shared/views/collection/dialogs/_dialogs.dart new file mode 100644 index 00000000..4a945cac --- /dev/null +++ b/app/lib/shared/views/collection/dialogs/_dialogs.dart @@ -0,0 +1 @@ +export 'delete_gem.dart'; diff --git a/app/lib/shared/views/collection/dialogs/delete_gem.dart b/app/lib/shared/views/collection/dialogs/delete_gem.dart new file mode 100644 index 00000000..8734eb7a --- /dev/null +++ b/app/lib/shared/views/collection/dialogs/delete_gem.dart @@ -0,0 +1,53 @@ +import 'package:chuckle_chest/localization/l10n.dart'; +import 'package:chuckle_chest/shared/_shared.dart'; +import 'package:chuckle_chest/shared/views/collection/logic/_logic.dart'; +import 'package:flutter/material.dart'; + +/// {@template CDeleteGemDialog} +/// +/// The dialog that asks the user to confirm the deletion of a gem. +/// +/// {@endtemplate} +class CDeleteGemDialog extends StatelessWidget with CDialogMixin { + /// {@macro CDeleteGemDialog} + const CDeleteGemDialog({ + required this.gemID, + required this.cubit, + super.key, + }); + + /// The ID of the gem to delete. + final String gemID; + + /// The cubit that will delete the gem. + /// + /// Because the dialog is not a part of the page's context, the cubit is + /// passed in as a parameter. + final CGemDeleteCubit cubit; + + void _onDeletePressed(BuildContext context) { + cubit.deleteGem(gemID: gemID); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + key: const Key('delete_gem_dialog'), + title: Text(context.cAppL10n.collectionView_deleteGemDialog_title), + content: Text(context.cAppL10n.collectionView_deleteGemDialog_message), + actions: [ + TextButton( + key: const Key('delete_gem_dialog_cancel_button'), + onPressed: () => Navigator.of(context).pop(), + child: Text(context.cAppL10n.cancel), + ), + TextButton( + key: const Key('delete_gem_dialog_delete_button'), + onPressed: () => _onDeletePressed(context), + child: Text(context.cAppL10n.delete), + ), + ], + ); + } +} diff --git a/app/lib/shared/views/collection/logic/_logic.dart b/app/lib/shared/views/collection/logic/_logic.dart index 756aba2d..c0194b6f 100644 --- a/app/lib/shared/views/collection/logic/_logic.dart +++ b/app/lib/shared/views/collection/logic/_logic.dart @@ -1,3 +1,4 @@ export 'collection_view_cubit.dart'; +export 'gem_delete_cubit.dart'; export 'gem_share_cubit.dart'; export 'gem_share_token_creation_cubit.dart'; diff --git a/app/lib/shared/views/collection/logic/collection_view_cubit.dart b/app/lib/shared/views/collection/logic/collection_view_cubit.dart index 4a71edbc..805a70fa 100644 --- a/app/lib/shared/views/collection/logic/collection_view_cubit.dart +++ b/app/lib/shared/views/collection/logic/collection_view_cubit.dart @@ -40,7 +40,7 @@ class CCollectionViewState { } /// The currently displayed gem. - CGem? get currentGem => gems.elementAt(currentIndex).$2; + CGem? get currentGem => gems.isEmpty ? null : gems.elementAt(currentIndex).$2; /// Whether the current gem is the last gem. bool get isLastGem => currentIndex == gems.length - 1; @@ -112,4 +112,24 @@ class CCollectionViewCubit extends Cubit { emit(state.copyWith(gems: state.gems)); } + + /// Removes the gem with the given [gemID] from the list. + /// + /// Adjusts [CCollectionViewState.currentIndex] so it stays in bounds. + /// If the list becomes empty the index resets to 0. + void removeGem(String gemID) { + final index = state.gems.indexWhere((r) => r.$2?.id == gemID); + if (index == -1) return; + + state.gems.removeAt(index); + gemTokens.removeAt(index); + + if (state.gems.isEmpty) { + emit(state.copyWith(gems: state.gems, currentIndex: 0)); + return; + } + + final newIndex = index.clamp(0, state.gems.length - 1); + emit(state.copyWith(gems: state.gems, currentIndex: newIndex)); + } } diff --git a/app/lib/shared/views/collection/logic/gem_delete_cubit.dart b/app/lib/shared/views/collection/logic/gem_delete_cubit.dart new file mode 100644 index 00000000..afc3ea00 --- /dev/null +++ b/app/lib/shared/views/collection/logic/gem_delete_cubit.dart @@ -0,0 +1,24 @@ +import 'package:bloc/bloc.dart'; +import 'package:cgem_repository/cgem_repository.dart'; +import 'package:mallard_bloc/mallard_bloc.dart'; + +/// The state for the [CGemDeleteCubit]. +typedef CGemDeleteState = TaskBlocState; + +/// {@template CGemDeleteCubit} +/// +/// The cubit that handles deleting gems. +/// +/// {@endtemplate} +class CGemDeleteCubit extends Cubit with TaskCubitMixin { + /// {@macro CGemDeleteCubit} + CGemDeleteCubit({required this.gemRepository}) + : super(TaskBlocState.initial()); + + /// The repository this cubit uses to delete gems. + final CGemRepository gemRepository; + + /// Deletes the gem with the given `gemID`. + Future deleteGem({required String gemID}) => + request(gemRepository.deleteGem(gemID: gemID)); +} diff --git a/app/lib/shared/views/collection/view.dart b/app/lib/shared/views/collection/view.dart index e7756668..9ab8c4aa 100644 --- a/app/lib/shared/views/collection/view.dart +++ b/app/lib/shared/views/collection/view.dart @@ -1,3 +1,4 @@ +import 'package:auto_route/auto_route.dart'; import 'package:ccore/ccore.dart'; import 'package:cgem_repository/cgem_repository.dart'; import 'package:chuckle_chest/localization/l10n.dart'; @@ -6,6 +7,7 @@ import 'package:chuckle_chest/shared/views/collection/logic/_logic.dart'; import 'package:chuckle_chest/shared/views/collection/widgets/_widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mallard_bloc/mallard_bloc.dart'; /// {@template CCollectionView} /// @@ -21,7 +23,7 @@ class CCollectionView< F, O > - extends CWrappedWidget { + extends StatefulWidget { /// {@macro CCollectionView} const CCollectionView({ required this.gemTokens, @@ -55,31 +57,148 @@ class CCollectionView< /// The function to call to fetch the gem with the given token. final void Function(BuildContext context, String token) triggerFetchGem; + @override + State> createState() => + _CCollectionViewState(); +} + +class _CCollectionViewState< + C extends Cubit, + S extends CRequestCubitState, + F, + O +> + extends State> { + late final PageController _pageController; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + void _onPageChanged(BuildContext context, int index) => context.read().onPageChanged(index); + void _onGemDeleted(BuildContext context, String gemID) { + final cubit = context.read()..removeGem(gemID); + if (cubit.state.gems.isEmpty) { + context.router.maybePop(); + } else { + _pageController.jumpToPage(cubit.state.currentIndex); + } + } + + void _updateGemShareToken( + BuildContext context, + String gemID, + String shareToken, + ) { + context.read().onShareTokenCreated(gemID, shareToken); + _shareGem(context, shareToken); + } + + void _shareGem(BuildContext context, String shareToken) { + final box = context.findRenderObject() as RenderBox?; + final sharePositionOrigin = box!.localToGlobal(Offset.zero) & box.size; + + context.read().shareGem( + shareToken: shareToken, + message: context.cAppL10n.gem_share_message, + subject: context.cAppL10n.gem_share_subject, + sharePositionOrigin: sharePositionOrigin, + ); + } + + Widget _buildBody(BuildContext context) { + if (widget.gemTokens.isEmpty) { + return Scaffold( + appBar: CAppBar( + context: context, + title: Text(context.cAppL10n.collectionView_noGemsTitle), + ), + ); + } + return Scaffold( + appBar: CCollectionViewAppBar(userRole: widget.userRole), + body: BlocBuilder( + buildWhen: (prev, curr) => prev.gems.length != curr.gems.length, + builder: (context, collectionState) => PageView.builder( + controller: _pageController, + onPageChanged: (index) => _onPageChanged(context, index), + itemCount: collectionState.gems.length, + itemBuilder: (context, index) => BlocBuilder( + buildWhen: (_, state) => + widget.gemTokenFromState(state) == + collectionState.gems[index].$1, + builder: (context, fetchState) => switch (fetchState.status) { + CRequestCubitStatus.initial => const Center( + child: CCradleLoadingIndicator(), + ), + CRequestCubitStatus.inProgress => const Center( + child: CCradleLoadingIndicator(), + ), + CRequestCubitStatus.failed => const Center( + child: Icon(Icons.error_rounded), + ), + CRequestCubitStatus.succeeded => + BlocBuilder( + buildWhen: (_, state) => + state.currentGem?.id == + widget.gemFromState(fetchState).id || + state.needsRestart, + builder: (context, state) => state.currentGem != null + ? CAnimatedGem( + key: UniqueKey(), + gem: state.currentGem!, + isLastGem: state.isLastGem, + ) + : const SizedBox(), + ), + }, + ), + ), + ), + bottomNavigationBar: widget.userRole != CUserRole.viewer + ? CCollectionViewBottomAppBar( + onShared: (token) => _shareGem(context, token), + ) + : null, + ); + } + @override - Widget wrapper(BuildContext context) { - if (gemTokens.isEmpty) return builder(context); + Widget build(BuildContext context) { + if (widget.gemTokens.isEmpty) return _buildBody(context); return MultiBlocProvider( providers: [ BlocProvider( create: (context) => CCollectionViewCubit( - gemTokens: gemTokens, - onNewGem: (token) => triggerFetchGem(context, token), + gemTokens: widget.gemTokens, + onNewGem: (token) => widget.triggerFetchGem(context, token), ), ), BlocProvider( create: (context) => CGemShareCubit(gemRepository: context.read()), ), - if (userRole != CUserRole.viewer) + if (widget.userRole != CUserRole.viewer) ...[ BlocProvider( create: (context) => CGemShareTokenCreationCubit( gemRepository: context.read(), chestID: context.read().state.id, ), ), + BlocProvider( + create: (context) => CGemDeleteCubit(gemRepository: context.read()), + ), + ], ], child: Builder( builder: (context) => MultiBlocListener( @@ -88,11 +207,13 @@ class CCollectionView< listener: (context, state) => switch (state.status) { CRequestCubitStatus.initial => null, CRequestCubitStatus.inProgress => null, - CRequestCubitStatus.failed => onFetchFailed(state.failure), + CRequestCubitStatus.failed => widget.onFetchFailed( + state.failure, + ), CRequestCubitStatus.succeeded => context.read().onGemFetched( - gemFromState(state), - gemTokenFromState(state), + widget.gemFromState(state), + widget.gemTokenFromState(state), ), }, ), @@ -111,7 +232,7 @@ class CCollectionView< : null, }, ), - if (userRole != CUserRole.viewer) + if (widget.userRole != CUserRole.viewer) ...[ BlocListener< CGemShareTokenCreationCubit, CGemShareTokenCreationState @@ -129,82 +250,24 @@ class CCollectionView< ), }, ), - ], - child: builder(context), - ), - ), - ); - } - - void _updateGemShareToken( - BuildContext context, - String gemID, - String shareToken, - ) { - context.read().onShareTokenCreated(gemID, shareToken); - _shareGem(context, shareToken); - } - - void _shareGem(BuildContext context, String shareToken) { - final box = context.findRenderObject() as RenderBox?; - final sharePositionOrigin = box!.localToGlobal(Offset.zero) & box.size; - - context.read().shareGem( - shareToken: shareToken, - message: context.cAppL10n.gem_share_message, - subject: context.cAppL10n.gem_share_subject, - sharePositionOrigin: sharePositionOrigin, - ); - } - - @override - Widget builder(BuildContext context) { - if (gemTokens.isEmpty) { - return Scaffold( - appBar: CAppBar( - context: context, - title: Text(context.cAppL10n.collectionView_noGemsTitle), - ), - ); - } - return Scaffold( - appBar: CCollectionViewAppBar(userRole: userRole), - body: PageView.builder( - onPageChanged: (index) => _onPageChanged(context, index), - itemCount: gemTokens.length, - itemBuilder: (context, index) => BlocBuilder( - buildWhen: (_, state) => gemTokenFromState(state) == gemTokens[index], - builder: (context, fetchState) => switch (fetchState.status) { - CRequestCubitStatus.initial => const Center( - child: CCradleLoadingIndicator(), - ), - CRequestCubitStatus.inProgress => const Center( - child: CCradleLoadingIndicator(), - ), - CRequestCubitStatus.failed => const Center( - child: Icon(Icons.error_rounded), - ), - CRequestCubitStatus.succeeded => - BlocBuilder( - buildWhen: (_, state) => - state.currentGem?.id == gemFromState(fetchState).id || - state.needsRestart, - builder: (context, state) => state.currentGem != null - ? CAnimatedGem( - key: UniqueKey(), - gem: state.currentGem!, - isLastGem: state.isLastGem, - ) - : const SizedBox(), + BlocListener( + listener: (context, state) => switch (state.status) { + TaskBlocStatus.initial => null, + TaskBlocStatus.inProgress => null, + TaskBlocStatus.failed => const CErrorSnackBar().show( + context, + ), + TaskBlocStatus.succeeded => _onGemDeleted( + context, + state.success!, + ), + }, ), - }, + ], + ], + child: _buildBody(context), ), ), - bottomNavigationBar: userRole != CUserRole.viewer - ? CCollectionViewBottomAppBar( - onShared: (token) => _shareGem(context, token), - ) - : null, ); } } diff --git a/app/lib/shared/views/collection/widgets/app_bar.dart b/app/lib/shared/views/collection/widgets/app_bar.dart index 2e0d2479..55edcbaf 100644 --- a/app/lib/shared/views/collection/widgets/app_bar.dart +++ b/app/lib/shared/views/collection/widgets/app_bar.dart @@ -4,16 +4,26 @@ import 'package:cgem_repository/cgem_repository.dart'; import 'package:chuckle_chest/app/routes.dart'; import 'package:chuckle_chest/localization/l10n.dart'; import 'package:chuckle_chest/shared/_shared.dart'; +import 'package:chuckle_chest/shared/views/collection/dialogs/_dialogs.dart'; import 'package:chuckle_chest/shared/views/collection/logic/_logic.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +/// The actions available in the [CCollectionViewAppBar]'s gem menu. +enum _CGemMenuAction { + /// Edit the gem. + edit, + + /// Delete the gem. + delete, +} + /// {@template CCollectionViewAppBar} /// /// The app bar for the [CCollectionView]. /// -/// It displays the title of the current gem and an edit button if the user -/// has the permission to edit the gem. +/// It displays the title of the current gem and a menu with edit and delete +/// options if the user has the permission to edit the gem. /// /// {@endtemplate} class CCollectionViewAppBar extends StatelessWidget @@ -38,6 +48,25 @@ class CCollectionViewAppBar extends StatelessWidget } } + void _onDeletePressed(BuildContext context) { + final gem = context.read().state.currentGem; + if (gem == null) return; + + CDeleteGemDialog( + gemID: gem.id, + cubit: context.read(), + ).show(context); + } + + void _onMenuSelected(BuildContext context, _CGemMenuAction action) { + switch (action) { + case _CGemMenuAction.edit: + _onEditPressed(context); + case _CGemMenuAction.delete: + _onDeletePressed(context); + } + } + @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); @@ -56,9 +85,30 @@ class CCollectionViewAppBar extends StatelessWidget if (userRole != CUserRole.viewer) BlocBuilder( builder: (context, state) => state.canEdit - ? IconButton( - icon: const Icon(Icons.edit_rounded), - onPressed: () => _onEditPressed(context), + ? PopupMenuButton<_CGemMenuAction>( + key: const Key('collection_view_gem_menu_button'), + icon: const Icon(Icons.more_vert_rounded), + onSelected: (action) => _onMenuSelected(context, action), + itemBuilder: (context) => [ + PopupMenuItem( + key: const Key('collection_view_gem_menu_edit_item'), + value: _CGemMenuAction.edit, + child: ListTile( + leading: const Icon(Icons.edit_rounded), + title: Text(context.cAppL10n.edit), + contentPadding: EdgeInsets.zero, + ), + ), + PopupMenuItem( + key: const Key('collection_view_gem_menu_delete_item'), + value: _CGemMenuAction.delete, + child: ListTile( + leading: const Icon(Icons.delete_rounded), + title: Text(context.cAppL10n.delete), + contentPadding: EdgeInsets.zero, + ), + ), + ], ) : const SizedBox(), ), diff --git a/app/pubspec.yaml b/app/pubspec.yaml index f031e41c..942d26d7 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -32,6 +32,8 @@ dependencies: sdk: flutter formz: ^0.8.0 hydrated_bloc: ^11.0.0 + mallard: any + mallard_bloc: any meta: ^1.15.0 pinput: ^6.0.2 signed_spacing_flex: ^1.1.0 diff --git a/app/test/pages/recents_collection/delete_gem_test.dart b/app/test/pages/recents_collection/delete_gem_test.dart new file mode 100644 index 00000000..6b46a77b --- /dev/null +++ b/app/test/pages/recents_collection/delete_gem_test.dart @@ -0,0 +1,234 @@ +import 'package:bobs_jobs/bobs_jobs.dart'; +import 'package:cauth_client/cauth_client.dart'; +import 'package:ccore/ccore.dart'; +import 'package:cdatabase_client/cdatabase_client.dart'; +import 'package:chuckle_chest/pages/home/page.dart'; +import 'package:chuckle_chest/pages/recents_collection/page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mallard/mallard.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test_beautifier/test_beautifier.dart'; + +import '../../helpers/helpers.dart'; + +const _chestID = 'chest-1'; +const _gemID = 'gem-1'; +const _gem2ID = 'gem-2'; + +const Key _menuButtonKey = Key('collection_view_gem_menu_button'); +const Key _menuEditItemKey = Key('collection_view_gem_menu_edit_item'); +const Key _menuDeleteItemKey = Key('collection_view_gem_menu_delete_item'); +const Key _dialogKey = Key('delete_gem_dialog'); +const Key _dialogDeleteButtonKey = Key('delete_gem_dialog_delete_button'); + +const _fakeRawChest = CRawAuthUserChest( + id: _chestID, + name: 'Test Chest', + userRole: CUserRole.owner, +); + +const _fakeRawUser = CRawAuthUser( + id: 'user-1', + username: 'testuser', + email: 'test@example.com', + chests: [_fakeRawChest], +); + +CRawGem _makeRawGem(String id) => CRawGem( + { + 'id': id, + 'chest_id': _chestID, + 'number': 1, + 'occurred_at': '2024-01-15T10:00:00.000Z', + 'lines': >[], + 'gem_share_tokens': null, + }, + null, +); + +void _setupSignedInOwner(CTestClients clients) { + when(clients.authClient.currentUserStream).thenAnswer( + (_) => BobsStream( + stream: () => Stream.value(bobsSuccess(bobsPresent(_fakeRawUser))), + ), + ); + when(() => clients.authClient.currentUser).thenReturn(_fakeRawUser); + when( + clients.authClient.refreshSession, + ).thenReturn(bobsFakeSuccessJob(bobsNothing)); +} + +void _setupRecentsCollection(CTestClients clients, List gemIDs) { + when( + () => clients.personClient.fetchChestPeople( + chestID: any(named: 'chestID'), + ), + ).thenReturn(bobsFakeSuccessJob([])); + + when( + () => clients.gemClient.fetchRecentGemIDs( + chestID: any(named: 'chestID'), + limit: any(named: 'limit'), + ), + ).thenReturn(bobsFakeSuccessJob(gemIDs)); + + for (final id in gemIDs) { + when( + () => clients.gemClient.fetchGem(gemID: id), + ).thenReturn(bobsFakeSuccessJob(_makeRawGem(id))); + } + + when( + () => clients.gemClient.fetchGemYears(chestID: any(named: 'chestID')), + ).thenReturn(bobsFakeSuccessJob([])); +} + +void main() { + group('Delete Gem Tests', () { + late CTestClients clients; + + setUp(() { + clients = CTestClients(); + _setupSignedInOwner(clients); + }); + + testWidgets( + requirement( + given: 'owner viewing gem in recents collection', + whenever: 'app bar 3-dot menu is tapped', + then: 'shows Edit and Delete options', + why: 'owners must be able to delete gems from the collection view', + ), + (tester) async { + _setupRecentsCollection(clients, [_gemID]); + + await tester.pumpChuckleChestApp( + clients: clients, + startAt: '/chest/collections/recently-added', + ); + + await tester.tap(find.byKey(_menuButtonKey)); + await tester.pumpAndSettle(); + + expect(find.byKey(_menuButtonKey), findsOneWidget); + expect(find.byKey(_menuEditItemKey), findsOneWidget); + expect(find.byKey(_menuDeleteItemKey), findsOneWidget); + }, + ); + + testWidgets( + requirement( + given: 'owner viewing gem in recents collection', + whenever: 'delete is selected from app bar menu', + then: 'shows delete confirmation dialog', + why: 'accidental deletion must be prevented with a confirmation step', + ), + (tester) async { + _setupRecentsCollection(clients, [_gemID]); + + await tester.pumpChuckleChestApp( + clients: clients, + startAt: '/chest/collections/recently-added', + ); + + await tester.tap(find.byKey(_menuButtonKey)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(_menuDeleteItemKey)); + await tester.pumpAndSettle(); + + expect(find.byKey(_dialogKey), findsOneWidget); + }, + ); + + testWidgets( + requirement( + given: 'delete confirmation dialog is shown', + whenever: 'user confirms deletion', + then: 'calls deleteGem with the correct gem ID', + why: 'deletion must be triggered exactly once with the right gem', + ), + (tester) async { + _setupRecentsCollection(clients, [_gemID]); + when( + () => clients.gemClient.deleteGem(gemID: any(named: 'gemID')), + ).thenReturn(Task.succeed(_gemID)); + + await tester.pumpChuckleChestApp( + clients: clients, + startAt: '/chest/collections/recently-added', + ); + + await tester.tap(find.byKey(_menuButtonKey)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(_menuDeleteItemKey)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(_dialogDeleteButtonKey)); + await tester.pumpAndSettle(); + + verify( + () => clients.gemClient.deleteGem(gemID: _gemID), + ).called(1); + }, + ); + + testWidgets( + requirement( + given: 'collection has only one gem', + whenever: 'user confirms deletion of that gem', + then: 'navigates back to home page', + why: 'no gems remain so the collection view is no longer meaningful', + ), + (tester) async { + _setupRecentsCollection(clients, [_gemID]); + when( + () => clients.gemClient.deleteGem(gemID: any(named: 'gemID')), + ).thenReturn(Task.succeed(_gemID)); + + await tester.pumpChuckleChestApp( + clients: clients, + startAt: '/chest/collections/recently-added', + ); + + await tester.tap(find.byKey(_menuButtonKey)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(_menuDeleteItemKey)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(_dialogDeleteButtonKey)); + await tester.pumpAndSettle(); + + expect(find.byType(CRecentsCollectionPage), findsNothing); + expect(find.byType(CHomePage), findsOneWidget); + }, + ); + + testWidgets( + requirement( + given: 'collection has two gems', + whenever: 'user deletes the first gem', + then: 'stays on collection page showing the second gem', + why: 'remaining gems must still be viewable after deletion', + ), + (tester) async { + _setupRecentsCollection(clients, [_gemID, _gem2ID]); + when( + () => clients.gemClient.deleteGem(gemID: any(named: 'gemID')), + ).thenReturn(Task.succeed(_gemID)); + + await tester.pumpChuckleChestApp( + clients: clients, + startAt: '/chest/collections/recently-added', + ); + + await tester.tap(find.byKey(_menuButtonKey)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(_menuDeleteItemKey)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(_dialogDeleteButtonKey)); + await tester.pumpAndSettle(); + + expect(find.byType(CRecentsCollectionPage), findsOneWidget); + }, + ); + }); +} diff --git a/packages/data/database_client/lib/src/exceptions/_exceptions.dart b/packages/data/database_client/lib/src/exceptions/_exceptions.dart index 89b6c12e..b2287fbd 100644 --- a/packages/data/database_client/lib/src/exceptions/_exceptions.dart +++ b/packages/data/database_client/lib/src/exceptions/_exceptions.dart @@ -5,6 +5,7 @@ export 'chest_invitations_fetch.dart'; export 'chest_members_fetch.dart'; export 'chest_people_fetch.dart'; export 'chest_update.dart'; +export 'gem_delete.dart'; export 'gem_fetch.dart'; export 'gem_fetch_from_share_token.dart'; export 'gem_ids_fetch.dart'; diff --git a/packages/data/database_client/lib/src/exceptions/gem_delete.dart b/packages/data/database_client/lib/src/exceptions/gem_delete.dart new file mode 100644 index 00000000..e1fa420d --- /dev/null +++ b/packages/data/database_client/lib/src/exceptions/gem_delete.dart @@ -0,0 +1,12 @@ +// Parameters required for bobs jobs. +// ignore_for_file: avoid_unused_constructor_parameters + +/// Represents an exception that occurs when deleting gem data fails. +enum CRawGemDeleteException { + /// The failure was unitentifiable. + unknown; + + factory CRawGemDeleteException.fromError(Object error) { + return CRawGemDeleteException.unknown; + } +} diff --git a/packages/data/database_client/lib/src/gem_client.dart b/packages/data/database_client/lib/src/gem_client.dart index d5829334..66f2de83 100644 --- a/packages/data/database_client/lib/src/gem_client.dart +++ b/packages/data/database_client/lib/src/gem_client.dart @@ -1,5 +1,6 @@ import 'package:bobs_jobs/bobs_jobs.dart'; import 'package:cdatabase_client/cdatabase_client.dart'; +import 'package:mallard/mallard.dart'; import 'package:supabase/supabase.dart'; import 'package:typesafe_supabase/typesafe_supabase.dart'; @@ -101,8 +102,9 @@ class CGemClient { 'gem_id_param': gem.id, 'occurred_at_param': gem.occurredAt.toIso8601String(), 'chest_id_param': gem.chestID, - 'deleted_line_ids_param': - deletedLineIDs.map((id) => id.toString()).toList(), + 'deleted_line_ids_param': deletedLineIDs + .map((id) => id.toString()) + .toList(), 'lines_param': lines .map( (line) => { @@ -119,6 +121,16 @@ class CGemClient { onError: CRawGemSaveException.fromError, ); + /// Deletes the gem with the given `gemID` from the database. + Task deleteGem({required String gemID}) => + Task.attempt( + run: () async { + await gemsTable.delete(filter: CGemsTable.id.equals(gemID)); + return gemID; + }, + handle: CRawGemDeleteException.fromError, + ); + /// Fetches the `limit` gem IDs by random for the given `chestID` from the /// database. BobsJob> fetchRandomGemIDs({ diff --git a/packages/data/database_client/pubspec.yaml b/packages/data/database_client/pubspec.yaml index 6a3bb91a..9398268c 100644 --- a/packages/data/database_client/pubspec.yaml +++ b/packages/data/database_client/pubspec.yaml @@ -8,6 +8,7 @@ resolution: workspace dependencies: bobs_jobs: any ccore: + mallard: any supabase: ^2.13.0 typesafe_supabase: ^0.2.0 diff --git a/packages/domain/gem_repository/lib/src/exceptions/_exceptions.dart b/packages/domain/gem_repository/lib/src/exceptions/_exceptions.dart index dde25506..60d8e93d 100644 --- a/packages/domain/gem_repository/lib/src/exceptions/_exceptions.dart +++ b/packages/domain/gem_repository/lib/src/exceptions/_exceptions.dart @@ -1,3 +1,4 @@ +export 'gem_delete.dart'; export 'gem_fetch.dart'; export 'gem_fetch_from_share_token.dart'; export 'gem_ids_fetch.dart'; diff --git a/packages/domain/gem_repository/lib/src/exceptions/gem_delete.dart b/packages/domain/gem_repository/lib/src/exceptions/gem_delete.dart new file mode 100644 index 00000000..2121ee05 --- /dev/null +++ b/packages/domain/gem_repository/lib/src/exceptions/gem_delete.dart @@ -0,0 +1,14 @@ +import 'package:cdatabase_client/cdatabase_client.dart'; + +/// Represents an exception that occurs when deleting a gem fails. +enum CGemDeleteException { + /// The failure was unitentifiable. + unknown; + + /// Converts the raw exception to a [CGemDeleteException]. + static CGemDeleteException fromRaw(CRawGemDeleteException e) { + return switch (e) { + CRawGemDeleteException.unknown => CGemDeleteException.unknown, + }; + } +} diff --git a/packages/domain/gem_repository/lib/src/gem_repository.dart b/packages/domain/gem_repository/lib/src/gem_repository.dart index b0c42808..6573087b 100644 --- a/packages/domain/gem_repository/lib/src/gem_repository.dart +++ b/packages/domain/gem_repository/lib/src/gem_repository.dart @@ -4,6 +4,7 @@ import 'package:bobs_jobs/bobs_jobs.dart'; import 'package:cdatabase_client/cdatabase_client.dart'; import 'package:cgem_repository/cgem_repository.dart'; import 'package:cplatform_client/cplatform_client.dart'; +import 'package:mallard/mallard.dart'; /// {@template CGemRepository} /// @@ -67,6 +68,12 @@ class CGemRepository { ) .thenConvertFailure(CGemSaveException.fromRaw); + /// Deletes the gem with the given [gemID]. + Task deleteGem({required String gemID}) => + gemClient + .deleteGem(gemID: gemID) + .convertFailure(CGemDeleteException.fromRaw); + /// Shares the gem with the given [shareToken]. BobsJob shareGem({ required String shareToken, diff --git a/packages/domain/gem_repository/pubspec.yaml b/packages/domain/gem_repository/pubspec.yaml index 690a83ac..db061161 100644 --- a/packages/domain/gem_repository/pubspec.yaml +++ b/packages/domain/gem_repository/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: cperson_repository: # Only used for the CPerson model. cplatform_client: equatable: ^2.0.5 + mallard: any dev_dependencies: flutter_test: diff --git a/wiki/Testing.md b/wiki/Testing.md index 83238168..95483cff 100644 --- a/wiki/Testing.md +++ b/wiki/Testing.md @@ -168,10 +168,10 @@ void main() { await tester.pumpChuckleChestApp(clients: clients); // Interact - await tester.tap(find.byType(SomeButton)); + await tester.tap(find.byKey(const Key('some_button'))); await tester.pumpAndSettle(); - // Assert + // Assert (find.byType() OK only for unique codebase-owned page widgets) expect(find.byType(ExpectedPage), findsOneWidget); }, ); @@ -182,8 +182,12 @@ void main() { ### Guidelines - **Mock at the client level** — repositories, cubits, and routing stay real -- **Use `find.byType()`** over `find.text()` — avoids coupling to localized - strings +- **Use `find.byKey()`** for all widget lookups. Only use `find.byType()` when + the type is unique _and_ defined in this codebase (e.g. page widgets like + `CHomePage`). Never use `find.byText()` or `find.byIcon()` — they couple + tests to localized strings or icon choices. Add `key:` parameters to + production widgets as needed and define the key constants at the top of the + test file with explicit types: `const Key _myKey = Key('my_key');` - **State management agnostic** — tests interact via widgets, not via cubits/blocs directly - **No UI appearance tests** — only verify widget presence and navigation From a6c62ce5b5d354479de674eac506c2cdaadf5dec Mon Sep 17 00:00:00 2001 From: JakesMD <71591438+JakesMD@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:09:13 +0200 Subject: [PATCH 2/4] test: add CGemRepository deleteGem unit tests --- .../test/src/gem_repository_test.dart | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 packages/domain/gem_repository/test/src/gem_repository_test.dart diff --git a/packages/domain/gem_repository/test/src/gem_repository_test.dart b/packages/domain/gem_repository/test/src/gem_repository_test.dart new file mode 100644 index 00000000..1a823232 --- /dev/null +++ b/packages/domain/gem_repository/test/src/gem_repository_test.dart @@ -0,0 +1,65 @@ +import 'package:cdatabase_client/cdatabase_client.dart'; +import 'package:cgem_repository/cgem_repository.dart'; +import 'package:cplatform_client/cplatform_client.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mallard/mallard.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test_beautifier/test_beautifier.dart'; + +class _MockCGemClient extends Mock implements CGemClient {} + +class _MockCPlatformClient extends Mock implements CPlatformClient {} + +void main() { + group('CGemRepository', () { + late _MockCGemClient gemClient; + late CGemRepository repo; + + setUp(() { + gemClient = _MockCGemClient(); + repo = CGemRepository( + gemClient: gemClient, + platformClient: _MockCPlatformClient(), + ); + }); + + group('deleteGem', () { + Task mockDeleteGem() => + gemClient.deleteGem(gemID: any(named: 'gemID')); + + Task deleteGemTask() => + repo.deleteGem(gemID: 'gem-1'); + + test( + requirement( + given: 'gem ID', + whenever: 'deleteGem succeeds', + then: 'returns deleted gem ID', + ), + () async { + when(mockDeleteGem).thenReturn(Task.succeed('gem-1')); + + final result = await deleteGemTask().run(); + + expect(result.asSuccess, 'gem-1'); + }, + ); + + test( + requirement( + given: 'gem ID', + whenever: 'deleteGem fails for unknown reason', + then: 'returns [unknown] exception', + ), + () async { + when(mockDeleteGem) + .thenReturn(Task.fail(CRawGemDeleteException.unknown)); + + final result = await deleteGemTask().run(); + + expect(result.asFailure, CGemDeleteException.unknown); + }, + ); + }); + }); +} From f7e1f4dd38296c663fad802e1b494d2d63d06bcf Mon Sep 17 00:00:00 2001 From: JakesMD <71591438+JakesMD@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:25:08 +0200 Subject: [PATCH 3/4] test: add full CGemRepository unit test coverage --- .../test/src/gem_repository_test.dart | 497 +++++++++++++++++- 1 file changed, 491 insertions(+), 6 deletions(-) diff --git a/packages/domain/gem_repository/test/src/gem_repository_test.dart b/packages/domain/gem_repository/test/src/gem_repository_test.dart index 1a823232..80076523 100644 --- a/packages/domain/gem_repository/test/src/gem_repository_test.dart +++ b/packages/domain/gem_repository/test/src/gem_repository_test.dart @@ -1,3 +1,6 @@ +import 'dart:ui'; + +import 'package:bobs_jobs/bobs_jobs.dart'; import 'package:cdatabase_client/cdatabase_client.dart'; import 'package:cgem_repository/cgem_repository.dart'; import 'package:cplatform_client/cplatform_client.dart'; @@ -10,16 +13,276 @@ class _MockCGemClient extends Mock implements CGemClient {} class _MockCPlatformClient extends Mock implements CPlatformClient {} +const _chestID = 'chest-1'; +const _gemID = 'gem-1'; +const _shareToken = 'share-token-abc'; + +CRawGem _fakeRawGem() => CRawGem( + { + 'id': _gemID, + 'chest_id': _chestID, + 'number': 1, + 'occurred_at': '2024-01-15T10:00:00.000Z', + 'lines': >[], + 'gem_share_tokens': null, + }, + null, +); + void main() { group('CGemRepository', () { late _MockCGemClient gemClient; + late _MockCPlatformClient platformClient; late CGemRepository repo; + setUpAll(() { + registerFallbackValue(Rect.zero); + registerFallbackValue( + CGemShareTokensTableInsert(chestID: _chestID, gemID: _gemID), + ); + registerFallbackValue( + CGemsTableUpsert( + occurredAt: DateTime(2024), + chestID: _chestID, + ), + ); + registerFallbackValue( + CLinesTableInsert(text: '', gemID: _gemID, chestID: _chestID), + ); + }); + setUp(() { gemClient = _MockCGemClient(); + platformClient = _MockCPlatformClient(); repo = CGemRepository( gemClient: gemClient, - platformClient: _MockCPlatformClient(), + platformClient: platformClient, + ); + }); + + group('fetchGemYears', () { + BobsJob> mockFetchGemYears() => + gemClient.fetchGemYears(chestID: any(named: 'chestID')); + + BobsJob> fetchGemYearsJob() => + repo.fetchGemYears(chestID: _chestID); + + test( + requirement( + given: 'chest ID', + whenever: 'fetchGemYears succeeds', + then: 'returns years list', + ), + () async { + when(mockFetchGemYears).thenReturn(bobsFakeSuccessJob([2023, 2024])); + + final result = await fetchGemYearsJob().run(); + + expect(result.asSuccess, equals([2023, 2024])); + }, + ); + + test( + requirement( + given: 'chest ID', + whenever: 'fetchGemYears fails for unknown reason', + then: 'returns [unknown] exception', + ), + () async { + when(mockFetchGemYears).thenReturn( + bobsFakeFailureJob(CRawGemYearsFetchException.unknown), + ); + + final result = await fetchGemYearsJob().run(); + + expectBobsFailure(result, CGemYearsFetchException.unknown); + }, + ); + }); + + group('fetchGemIDsForYear', () { + BobsJob> mockFetch() => + gemClient.fetchGemIDsForYear( + chestID: any(named: 'chestID'), + year: any(named: 'year'), + ); + + BobsJob> fetchJob() => + repo.fetchGemIDsForYear(chestID: _chestID, year: 2024); + + test( + requirement( + given: 'chest ID and year', + whenever: 'fetchGemIDsForYear succeeds', + then: 'returns gem IDs list', + ), + () async { + when(mockFetch).thenReturn(bobsFakeSuccessJob([_gemID])); + + final result = await fetchJob().run(); + + expect(result.asSuccess, equals([_gemID])); + }, + ); + + test( + requirement( + given: 'chest ID and year', + whenever: 'fetchGemIDsForYear fails for unknown reason', + then: 'returns [unknown] exception', + ), + () async { + when(mockFetch).thenReturn( + bobsFakeFailureJob(CRawGemIDsFetchException.unknown), + ); + + final result = await fetchJob().run(); + + expectBobsFailure(result, CGemIDsFetchException.unknown); + }, + ); + }); + + group('fetchRecentGemIDs', () { + BobsJob> mockFetch() => + gemClient.fetchRecentGemIDs( + chestID: any(named: 'chestID'), + limit: any(named: 'limit'), + ); + + BobsJob> fetchJob() => + repo.fetchRecentGemIDs(chestID: _chestID); + + test( + requirement( + given: 'chest ID', + whenever: 'fetchRecentGemIDs succeeds', + then: 'returns gem IDs list', + ), + () async { + when(mockFetch).thenReturn(bobsFakeSuccessJob([_gemID])); + + final result = await fetchJob().run(); + + expect(result.asSuccess, equals([_gemID])); + }, + ); + + test( + requirement( + given: 'chest ID', + whenever: 'fetchRecentGemIDs fails for unknown reason', + then: 'returns [unknown] exception', + ), + () async { + when(mockFetch) + .thenReturn(bobsFakeFailureJob(CRawGemIDsFetchException.unknown)); + + final result = await fetchJob().run(); + + expectBobsFailure(result, CGemIDsFetchException.unknown); + }, + ); + }); + + group('fetchGem', () { + BobsJob mockFetchGem() => + gemClient.fetchGem(gemID: any(named: 'gemID')); + + BobsJob fetchGemJob() => + repo.fetchGem(gemID: _gemID); + + test( + requirement( + given: 'gem ID', + whenever: 'fetchGem succeeds', + then: 'returns converted CGem', + ), + () async { + when(mockFetchGem).thenReturn(bobsFakeSuccessJob(_fakeRawGem())); + + final result = await fetchGemJob().run(); + + expectBobsSuccess(result, CGem.fromRaw(_fakeRawGem())); + }, + ); + + test( + requirement( + given: 'gem ID', + whenever: 'fetchGem fails with not found', + then: 'returns [not found] exception', + ), + () async { + when(mockFetchGem).thenReturn( + bobsFakeFailureJob(CRawGemFetchException.notFound), + ); + + final result = await fetchGemJob().run(); + + expectBobsFailure(result, CGemFetchException.notFound); + }, + ); + + test( + requirement( + given: 'gem ID', + whenever: 'fetchGem fails for unknown reason', + then: 'returns [unknown] exception', + ), + () async { + when(mockFetchGem) + .thenReturn(bobsFakeFailureJob(CRawGemFetchException.unknown)); + + final result = await fetchGemJob().run(); + + expectBobsFailure(result, CGemFetchException.unknown); + }, + ); + }); + + group('saveGem', () { + final fakeGem = CGem.fromRaw(_fakeRawGem()); + + BobsJob mockSaveGem() => + gemClient.saveGem( + gem: any(named: 'gem'), + deletedLineIDs: any(named: 'deletedLineIDs'), + lines: any(named: 'lines'), + ); + + BobsJob saveGemJob() => + repo.saveGem(gem: fakeGem, deletedLines: []); + + test( + requirement( + given: 'gem', + whenever: 'saveGem succeeds', + then: 'returns gem ID', + ), + () async { + when(mockSaveGem).thenReturn(bobsFakeSuccessJob(_gemID)); + + final result = await saveGemJob().run(); + + expectBobsSuccess(result, _gemID); + }, + ); + + test( + requirement( + given: 'gem', + whenever: 'saveGem fails for unknown reason', + then: 'returns [unknown] exception', + ), + () async { + when(mockSaveGem) + .thenReturn(bobsFakeFailureJob(CRawGemSaveException.unknown)); + + final result = await saveGemJob().run(); + + expectBobsFailure(result, CGemSaveException.unknown); + }, ); }); @@ -28,7 +291,7 @@ void main() { gemClient.deleteGem(gemID: any(named: 'gemID')); Task deleteGemTask() => - repo.deleteGem(gemID: 'gem-1'); + repo.deleteGem(gemID: _gemID); test( requirement( @@ -37,11 +300,11 @@ void main() { then: 'returns deleted gem ID', ), () async { - when(mockDeleteGem).thenReturn(Task.succeed('gem-1')); + when(mockDeleteGem).thenReturn(Task.succeed(_gemID)); final result = await deleteGemTask().run(); - expect(result.asSuccess, 'gem-1'); + expect(result.asSuccess, _gemID); }, ); @@ -52,8 +315,9 @@ void main() { then: 'returns [unknown] exception', ), () async { - when(mockDeleteGem) - .thenReturn(Task.fail(CRawGemDeleteException.unknown)); + when( + mockDeleteGem, + ).thenReturn(Task.fail(CRawGemDeleteException.unknown)); final result = await deleteGemTask().run(); @@ -61,5 +325,226 @@ void main() { }, ); }); + + group('shareGem', () { + BobsJob mockShare() => platformClient.share( + text: any(named: 'text'), + subject: any(named: 'subject'), + sharePositionOrigin: any(named: 'sharePositionOrigin'), + ); + + BobsJob mockCopyToClipboard() => + platformClient.copyToClipboard(text: any(named: 'text')); + + BobsJob shareGemJob() => + repo.shareGem( + shareToken: _shareToken, + sharePositionOrigin: Rect.zero, + message: (link) => link, + subject: 'subject', + ); + + test( + requirement( + given: 'mobile device', + whenever: 'shareGem succeeds', + then: 'shares via dialog and returns [dialog] method', + ), + () async { + when(() => platformClient.deviceType).thenReturn(CDeviceType.mobile); + when(mockShare).thenReturn(bobsFakeSuccessJob(bobsNothing)); + + final result = await shareGemJob().run(); + + expectBobsSuccess(result, CGemShareMethod.dialog); + }, + ); + + test( + requirement( + given: 'desktop device', + whenever: 'shareGem succeeds', + then: 'copies to clipboard and returns [clipboard] method', + ), + () async { + when(() => platformClient.deviceType) + .thenReturn(CDeviceType.desktop); + when(mockCopyToClipboard).thenReturn(bobsFakeSuccessJob(bobsNothing)); + + final result = await shareGemJob().run(); + + expectBobsSuccess(result, CGemShareMethod.clipboard); + }, + ); + + test( + requirement( + given: 'mobile device', + whenever: 'shareGem fails for unknown reason', + then: 'returns [unknown] exception', + ), + () async { + when(() => platformClient.deviceType).thenReturn(CDeviceType.mobile); + when(mockShare) + .thenReturn(bobsFakeFailureJob(CShareException.unknown)); + + final result = await shareGemJob().run(); + + expectBobsFailure(result, CGemShareException.unknown); + }, + ); + + test( + requirement( + given: 'desktop device', + whenever: 'shareGem fails for unknown reason', + then: 'returns [unknown] exception', + ), + () async { + when(() => platformClient.deviceType) + .thenReturn(CDeviceType.desktop); + when(mockCopyToClipboard) + .thenReturn(bobsFakeFailureJob(CClipboardCopyException.unknown)); + + final result = await shareGemJob().run(); + + expectBobsFailure(result, CGemShareException.unknown); + }, + ); + }); + + group('fetchRandomGemIDs', () { + BobsJob> mockFetch() => + gemClient.fetchRandomGemIDs( + chestID: any(named: 'chestID'), + limit: any(named: 'limit'), + ); + + BobsJob> fetchJob() => + repo.fetchRandomGemIDs(chestID: _chestID); + + test( + requirement( + given: 'chest ID', + whenever: 'fetchRandomGemIDs succeeds', + then: 'returns gem IDs list', + ), + () async { + when(mockFetch).thenReturn(bobsFakeSuccessJob([_gemID])); + + final result = await fetchJob().run(); + + expect(result.asSuccess, equals([_gemID])); + }, + ); + + test( + requirement( + given: 'chest ID', + whenever: 'fetchRandomGemIDs fails for unknown reason', + then: 'returns [unknown] exception', + ), + () async { + when(mockFetch).thenReturn( + bobsFakeFailureJob(CRawRandomGemIDsFetchException.unknown), + ); + + final result = await fetchJob().run(); + + expectBobsFailure(result, CRandomGemIDsFetchException.unknown); + }, + ); + }); + + group('fetchGemFromShareToken', () { + BobsJob)> + mockFetch() => gemClient.fetchGemFromShareToken( + shareToken: any(named: 'shareToken'), + ); + + BobsJob fetchJob() => + repo.fetchGemFromShareToken(shareToken: _shareToken); + + test( + requirement( + given: 'share token', + whenever: 'fetchGemFromShareToken succeeds', + then: 'returns converted CSharedGem', + ), + () async { + when(mockFetch).thenReturn( + bobsFakeSuccessJob((_fakeRawGem(), [])), + ); + + final result = await fetchJob().run(); + + expectBobsSuccess( + result, + CSharedGem.fromRaw(_fakeRawGem(), []), + ); + }, + ); + + test( + requirement( + given: 'share token', + whenever: 'fetchGemFromShareToken fails for unknown reason', + then: 'returns [unknown] exception', + ), + () async { + when(mockFetch).thenReturn( + bobsFakeFailureJob(CRawGemFetchFromShareTokenException.unknown), + ); + + final result = await fetchJob().run(); + + expectBobsFailure(result, CGemFetchFromShareTokenException.unknown); + }, + ); + }); + + group('createGemShareToken', () { + BobsJob + mockCreate() => gemClient.createGemShareToken( + record: any(named: 'record'), + ); + + BobsJob createJob() => + repo.createGemShareToken(chestID: _chestID, gemID: _gemID); + + test( + requirement( + given: 'chest ID and gem ID', + whenever: 'createGemShareToken succeeds', + then: 'returns share token string', + ), + () async { + when(mockCreate).thenReturn( + bobsFakeSuccessJob(CRawGemShareToken({'token': _shareToken}, null)), + ); + + final result = await createJob().run(); + + expectBobsSuccess(result, _shareToken); + }, + ); + + test( + requirement( + given: 'chest ID and gem ID', + whenever: 'createGemShareToken fails for unknown reason', + then: 'returns [unknown] exception', + ), + () async { + when(mockCreate).thenReturn( + bobsFakeFailureJob(CRawGemShareTokenInsertException.unknown), + ); + + final result = await createJob().run(); + + expectBobsFailure(result, CGemShareTokenCreationException.unknown); + }, + ); + }); }); } From ea092473a722c22b674c7aab5329edd66e836a63 Mon Sep 17 00:00:00 2001 From: JakesMD <71591438+JakesMD@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:43:17 +0200 Subject: [PATCH 4/4] test: add full CGemRepository and model unit test coverage --- .../test/src/gem_repository_test.dart | 42 ++--- .../test/src/models/gem_test.dart | 76 ++++++++++ .../test/src/models/line_test.dart | 143 ++++++++++++++++++ 3 files changed, 241 insertions(+), 20 deletions(-) create mode 100644 packages/domain/gem_repository/test/src/models/gem_test.dart create mode 100644 packages/domain/gem_repository/test/src/models/line_test.dart diff --git a/packages/domain/gem_repository/test/src/gem_repository_test.dart b/packages/domain/gem_repository/test/src/gem_repository_test.dart index 80076523..cda9370e 100644 --- a/packages/domain/gem_repository/test/src/gem_repository_test.dart +++ b/packages/domain/gem_repository/test/src/gem_repository_test.dart @@ -175,8 +175,9 @@ void main() { then: 'returns [unknown] exception', ), () async { - when(mockFetch) - .thenReturn(bobsFakeFailureJob(CRawGemIDsFetchException.unknown)); + when( + mockFetch, + ).thenReturn(bobsFakeFailureJob(CRawGemIDsFetchException.unknown)); final result = await fetchJob().run(); @@ -231,8 +232,9 @@ void main() { then: 'returns [unknown] exception', ), () async { - when(mockFetchGem) - .thenReturn(bobsFakeFailureJob(CRawGemFetchException.unknown)); + when( + mockFetchGem, + ).thenReturn(bobsFakeFailureJob(CRawGemFetchException.unknown)); final result = await fetchGemJob().run(); @@ -244,12 +246,11 @@ void main() { group('saveGem', () { final fakeGem = CGem.fromRaw(_fakeRawGem()); - BobsJob mockSaveGem() => - gemClient.saveGem( - gem: any(named: 'gem'), - deletedLineIDs: any(named: 'deletedLineIDs'), - lines: any(named: 'lines'), - ); + BobsJob mockSaveGem() => gemClient.saveGem( + gem: any(named: 'gem'), + deletedLineIDs: any(named: 'deletedLineIDs'), + lines: any(named: 'lines'), + ); BobsJob saveGemJob() => repo.saveGem(gem: fakeGem, deletedLines: []); @@ -276,8 +277,9 @@ void main() { then: 'returns [unknown] exception', ), () async { - when(mockSaveGem) - .thenReturn(bobsFakeFailureJob(CRawGemSaveException.unknown)); + when( + mockSaveGem, + ).thenReturn(bobsFakeFailureJob(CRawGemSaveException.unknown)); final result = await saveGemJob().run(); @@ -367,8 +369,7 @@ void main() { then: 'copies to clipboard and returns [clipboard] method', ), () async { - when(() => platformClient.deviceType) - .thenReturn(CDeviceType.desktop); + when(() => platformClient.deviceType).thenReturn(CDeviceType.desktop); when(mockCopyToClipboard).thenReturn(bobsFakeSuccessJob(bobsNothing)); final result = await shareGemJob().run(); @@ -385,8 +386,9 @@ void main() { ), () async { when(() => platformClient.deviceType).thenReturn(CDeviceType.mobile); - when(mockShare) - .thenReturn(bobsFakeFailureJob(CShareException.unknown)); + when( + mockShare, + ).thenReturn(bobsFakeFailureJob(CShareException.unknown)); final result = await shareGemJob().run(); @@ -401,10 +403,10 @@ void main() { then: 'returns [unknown] exception', ), () async { - when(() => platformClient.deviceType) - .thenReturn(CDeviceType.desktop); - when(mockCopyToClipboard) - .thenReturn(bobsFakeFailureJob(CClipboardCopyException.unknown)); + when(() => platformClient.deviceType).thenReturn(CDeviceType.desktop); + when( + mockCopyToClipboard, + ).thenReturn(bobsFakeFailureJob(CClipboardCopyException.unknown)); final result = await shareGemJob().run(); diff --git a/packages/domain/gem_repository/test/src/models/gem_test.dart b/packages/domain/gem_repository/test/src/models/gem_test.dart new file mode 100644 index 00000000..f000152d --- /dev/null +++ b/packages/domain/gem_repository/test/src/models/gem_test.dart @@ -0,0 +1,76 @@ +import 'package:bobs_jobs/bobs_jobs.dart'; +import 'package:cgem_repository/cgem_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:test_beautifier/test_beautifier.dart'; + +const _chestID = 'chest-1'; +const _gemID = 'gem-1'; + +CGem _fakeGem({String? shareToken}) => CGem( + id: _gemID, + number: 1, + occurredAt: DateTime(2024), + lines: const [], + chestID: _chestID, + shareToken: shareToken, +); + +void main() { + group('CGem', () { + group('copyWith', () { + test( + requirement( + given: 'gem', + whenever: 'copyWith called with new occurredAt', + then: 'returns gem with updated occurredAt', + ), + () { + final newDate = DateTime(2025); + final result = _fakeGem().copyWith(occurredAt: newDate); + expect(result.occurredAt, newDate); + expect(result.id, _gemID); + }, + ); + + test( + requirement( + given: 'gem without share token', + whenever: 'copyWith called with present share token', + then: 'returns gem with new share token', + ), + () { + final result = _fakeGem().copyWith( + shareToken: bobsPresent('new-token'), + ); + expect(result.shareToken, 'new-token'); + }, + ); + + test( + requirement( + given: 'gem with share token', + whenever: 'copyWith called with absent share token', + then: 'keeps existing share token', + ), + () { + final result = _fakeGem(shareToken: 'old-token').copyWith( + shareToken: bobsAbsent(), + ); + expect(result.shareToken, 'old-token'); + }, + ); + + test( + requirement( + given: 'gem with share token', + whenever: 'copyWith called with no share token argument', + then: 'keeps existing share token', + ), + () { + final result = _fakeGem(shareToken: 'existing-token').copyWith(); + expect(result.shareToken, 'existing-token'); + }, + ); + }); + }); +} diff --git a/packages/domain/gem_repository/test/src/models/line_test.dart b/packages/domain/gem_repository/test/src/models/line_test.dart new file mode 100644 index 00000000..ba60c0a6 --- /dev/null +++ b/packages/domain/gem_repository/test/src/models/line_test.dart @@ -0,0 +1,143 @@ +import 'package:cdatabase_client/cdatabase_client.dart'; +import 'package:cgem_repository/cgem_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:test_beautifier/test_beautifier.dart'; + +const _chestID = 'chest-1'; +const _gemID = 'gem-1'; +final _lineID = BigInt.from(42); +final _personID = BigInt.from(7); + +CRawLine _fakeRawLine({bool withPerson = false}) => CRawLine( + { + 'id': 42, + 'text': 'Hello world', + 'person_id': withPerson ? 7 : null, + 'gem_id': _gemID, + 'chest_id': _chestID, + }, + null, +); + +CLine _fakeLine({BigInt? personID}) => CLine( + id: _lineID, + text: 'Hello world', + personID: personID, + gemID: _gemID, + chestID: _chestID, +); + +void main() { + group('CLine', () { + group('fromRaw', () { + test( + requirement( + given: 'raw line without person', + whenever: 'fromRaw is called', + then: 'creates CLine with correct fields and null personID', + ), + () { + final result = CLine.fromRaw(_fakeRawLine()); + expect(result.id, _lineID); + expect(result.text, 'Hello world'); + expect(result.personID, isNull); + expect(result.gemID, _gemID); + expect(result.chestID, _chestID); + }, + ); + + test( + requirement( + given: 'raw line with person', + whenever: 'fromRaw is called', + then: 'creates CLine with personID set', + ), + () { + final result = CLine.fromRaw(_fakeRawLine(withPerson: true)); + expect(result.personID, _personID); + }, + ); + }); + + group('copyWith', () { + test( + requirement( + given: 'line', + whenever: 'copyWith called with new text', + then: 'returns line with updated text', + ), + () { + final result = _fakeLine().copyWith(text: 'Updated'); + expect(result.text, 'Updated'); + expect(result.id, _lineID); + }, + ); + + test( + requirement( + given: 'line without person', + whenever: 'copyWith called with personID', + then: 'returns line with personID set', + ), + () { + final result = _fakeLine().copyWith(personID: _personID); + expect(result.personID, _personID); + }, + ); + }); + + group('toInsert', () { + test( + requirement( + given: 'line', + whenever: 'toInsert is called', + then: 'returns CLinesTableInsert with correct fields', + ), + () { + final result = _fakeLine(personID: _personID).toInsert(); + expect(result.id, _lineID); + expect(result.text, 'Hello world'); + expect(result.gemID, _gemID); + expect(result.chestID, _chestID); + }, + ); + }); + + group('equality', () { + test( + requirement( + given: 'two lines with same fields', + whenever: 'equality is checked', + then: 'they are equal', + ), + () { + expect(_fakeLine(), _fakeLine()); + }, + ); + }); + + group('isQuote', () { + test( + requirement( + given: 'line with personID', + whenever: 'isQuote is checked', + then: 'returns true', + ), + () { + expect(_fakeLine(personID: _personID).isQuote, isTrue); + }, + ); + + test( + requirement( + given: 'line without personID', + whenever: 'isQuote is checked', + then: 'returns false', + ), + () { + expect(_fakeLine().isQuote, isFalse); + }, + ); + }); + }); +}