diff --git a/.github/workflows/flutter_test.yml b/.github/workflows/flutter_test.yml index c983b9b8..62ba515b 100644 --- a/.github/workflows/flutter_test.yml +++ b/.github/workflows/flutter_test.yml @@ -31,5 +31,14 @@ jobs: run: dart run build_runner build --delete-conflicting-outputs - name: Analyze run: flutter analyze - - name: Run test - run: flutter test + - name: Run test with coverage + run: flutter test --coverage + - name: Check coverage + run: dart run tool/check_coverage.dart --min=80 + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: flutter-coverage-lcov + path: coverage/lcov.info + if-no-files-found: warn diff --git a/README.md b/README.md index 276502f6..5d02928f 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,18 @@ Run tests with coverage: flutter test --coverage ``` +Check the app-owned line coverage gate: + +```sh +dart run tool/check_coverage.dart --min=80 +``` + +The Flutter Testing GitHub Actions workflow runs tests with coverage, enforces +an 80% line coverage gate for app-owned Dart files, and uploads +`coverage/lcov.info` as the `flutter-coverage-lcov` artifact. The gate filters +generated files, localization output, FlutterFire options, and Drift schema +bootstrap definitions so the percentage reflects tested application behavior. + Run the web app locally: ```sh diff --git a/lib/core/services/alarm_scheduler_service.dart b/lib/core/services/alarm_scheduler_service.dart index eba368d1..25ac04de 100644 --- a/lib/core/services/alarm_scheduler_service.dart +++ b/lib/core/services/alarm_scheduler_service.dart @@ -27,7 +27,8 @@ class AlarmSchedulerService { return capabilities; } on MissingPluginException { AppLogger.debug( - '$_logTag getCapabilities -> unsupported: missing plugin'); + '$_logTag getCapabilities -> unsupported: missing plugin', + ); return AlarmSchedulerCapabilities.unsupported; } on PlatformException catch (error) { AppLogger.debug( @@ -50,7 +51,8 @@ class AlarmSchedulerService { return state; } on MissingPluginException { AppLogger.debug( - '$_logTag checkPermission -> unsupported: missing plugin'); + '$_logTag checkPermission -> unsupported: missing plugin', + ); return AlarmPermissionState.unsupported; } on PlatformException catch (error) { AppLogger.debug( @@ -73,7 +75,8 @@ class AlarmSchedulerService { return state; } on MissingPluginException { AppLogger.debug( - '$_logTag requestPermission -> unsupported: missing plugin'); + '$_logTag requestPermission -> unsupported: missing plugin', + ); return AlarmPermissionState.unsupported; } on PlatformException catch (error) { AppLogger.debug( @@ -149,9 +152,7 @@ class AlarmSchedulerService { } } - Future cancelAllNativeAlarms( - List records, - ) async { + Future cancelAllNativeAlarms(List records) async { for (final record in records) { await cancelNativeAlarm(record); } @@ -182,7 +183,8 @@ class AlarmSchedulerService { final launchPayloadHandler = _launchPayloadHandler; if (launchPayloadHandler == null) { AppLogger.debug( - '$_logTag dispatchPendingLaunchPayload skipped: no handler'); + '$_logTag dispatchPendingLaunchPayload skipped: no handler', + ); return; } if (kIsWeb) { @@ -209,6 +211,9 @@ class AlarmSchedulerService { '${error.code} ${error.message}', ); return; + } catch (error) { + AppLogger.debug('$_logTag getLaunchPayload invalid response: $error'); + return; } } @@ -266,8 +271,6 @@ class AlarmSchedulerService { Map? _payloadFromObject(Object? raw) { if (raw is! Map) return null; - return raw.map( - (key, value) => MapEntry(key.toString(), value.toString()), - ); + return raw.map((key, value) => MapEntry(key.toString(), value.toString())); } } diff --git a/lib/core/services/fallback_alarm_notification_service.dart b/lib/core/services/fallback_alarm_notification_service.dart index caad1224..8e7a3d4f 100644 --- a/lib/core/services/fallback_alarm_notification_service.dart +++ b/lib/core/services/fallback_alarm_notification_service.dart @@ -16,32 +16,37 @@ abstract interface class FallbackAlarmNotificationService { @Singleton(as: FallbackAlarmNotificationService) class FallbackAlarmNotificationServiceImpl implements FallbackAlarmNotificationService { + FallbackAlarmNotificationServiceImpl({ + NotificationService? notificationService, + }) : _notificationService = + notificationService ?? NotificationService.instance; + + final NotificationService _notificationService; + @override Future checkPermission() async { return _fromAuthorizationStatus( - await NotificationService.instance.checkNotificationPermission(), + await _notificationService.checkNotificationPermission(), ); } @override Future requestPermission() async { return _fromAuthorizationStatus( - await NotificationService.instance.requestPermission(), + await _notificationService.requestPermission(), ); } @override Future scheduleFallbackAlarm(ScheduledAlarmRecord record) { - return NotificationService.instance.scheduleFallbackAlarm(record); + return _notificationService.scheduleFallbackAlarm(record); } @override Future cancelFallbackAlarm(ScheduledAlarmRecord record) async { final notificationId = record.fallbackNotificationId ?? stableAlarmId(record.scheduleId); - await NotificationService.instance.cancelFallbackNotification( - notificationId, - ); + await _notificationService.cancelFallbackNotification(notificationId); } AlarmPermissionState _fromAuthorizationStatus(AuthorizationStatus status) { diff --git a/lib/core/services/notification_content.dart b/lib/core/services/notification_content.dart new file mode 100644 index 00000000..3f963004 --- /dev/null +++ b/lib/core/services/notification_content.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; + +import 'package:on_time_front/core/services/notification_routing.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; + +class NotificationDisplayContent { + const NotificationDisplayContent({ + required this.title, + required this.body, + required this.payload, + }); + + final String title; + final String body; + final String payload; +} + +NotificationDisplayContent? remoteNotificationDisplayContent({ + required Map data, + String? notificationTitle, + String? notificationBody, +}) { + final title = notificationTitle ?? data['title'] ?? data['Title']; + final body = + notificationBody ?? + data['content'] ?? + data['body'] ?? + data['Content'] ?? + data['Body']; + + if (title == null && body == null) { + return null; + } + + return NotificationDisplayContent( + title: title?.toString() ?? '알림', + body: body?.toString() ?? '', + payload: jsonEncode(data), + ); +} + +String? encodeLocalNotificationPayload(Map? payload) { + return payload == null ? null : jsonEncode(payload); +} + +String preparationStepNotificationTitle({ + required String scheduleName, + required String preparationName, +}) { + return '[$scheduleName] $preparationName'; +} + +String preparationStepNotificationBody({required String languageCode}) { + return localizedNotificationText( + languageCode: languageCode, + ko: '이어서 준비하세요.', + en: 'Continue preparing', + ); +} + +Map preparationStepNotificationPayload({ + required String scheduleId, + required String stepId, +}) { + return { + 'type': 'preparation_step', + 'scheduleId': scheduleId, + 'stepId': stepId, + }; +} + +int fallbackNotificationIdForRecord(ScheduledAlarmRecord record) { + return record.fallbackNotificationId ?? stableAlarmId(record.scheduleId); +} + +String fallbackAlarmNotificationBody({required String languageCode}) { + return localizedNotificationText( + languageCode: languageCode, + ko: '준비를 시작할 시간입니다.', + en: 'It is time to get ready.', + ); +} diff --git a/lib/core/services/notification_routing.dart b/lib/core/services/notification_routing.dart new file mode 100644 index 00000000..9ef957f8 --- /dev/null +++ b/lib/core/services/notification_routing.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; + +class NotificationRouteTarget extends Equatable { + const NotificationRouteTarget(this.path, {this.extra}); + + final String path; + final Object? extra; + + @override + List get props => [path, extra]; +} + +String localizedNotificationText({ + required String languageCode, + required String ko, + required String en, +}) { + return languageCode == 'ko' ? ko : en; +} + +bool isScheduleAlarmPayload(Map? payload) { + if (payload == null) return false; + final type = payload['type']?.toString(); + final promptVariant = payload['promptVariant']?.toString(); + return type == 'schedule_alarm' || + payload['alarmLaunchPayloadVersion'] != null || + (promptVariant == 'alarm' && payload['scheduleId'] != null); +} + +bool isScheduleAlarmMessagePayload({ + required Map data, + String? title, +}) { + return isScheduleAlarmPayload(data) || + title == '약속 알림' || + title == 'Schedule alarm'; +} + +NotificationRouteTarget? notificationRouteForPayloadString(String? payload) { + if (payload == null) return null; + + try { + final decoded = jsonDecode(payload); + if (decoded is! Map) { + return null; + } + return notificationRouteForData(decoded); + } on FormatException { + return null; + } +} + +NotificationRouteTarget? notificationRouteForData(Map data) { + final type = data['type']?.toString(); + final scheduleId = data['scheduleId']?.toString(); + + if (type == 'schedule_alarm' && scheduleId != null) { + return NotificationRouteTarget( + '/scheduleStart', + extra: Map.from(data), + ); + } + + if (type != null && type.contains('5min')) { + return const NotificationRouteTarget( + '/scheduleStart', + extra: {'promptVariant': 'earlyStart'}, + ); + } + + if ((type != null && + (type.startsWith('schedule_') || type.startsWith('preparation_'))) || + scheduleId != null) { + return const NotificationRouteTarget('/alarmScreen'); + } + + return null; +} diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart index ca50ea02..6d34d86f 100644 --- a/lib/core/services/notification_service.dart +++ b/lib/core/services/notification_service.dart @@ -4,7 +4,6 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:firebase_core/firebase_core.dart'; -import 'dart:convert'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -12,6 +11,8 @@ import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/core/logging/app_logger.dart'; import 'package:on_time_front/core/services/js_interop_service.dart'; import 'package:on_time_front/core/services/navigation_service.dart'; +import 'package:on_time_front/core/services/notification_content.dart'; +import 'package:on_time_front/core/services/notification_routing.dart'; import 'package:on_time_front/data/data_sources/notification_remote_data_source.dart'; import 'package:on_time_front/data/models/fcm_token_register_request_model.dart'; import 'package:on_time_front/domain/entities/alarm_entities.dart'; @@ -36,15 +37,42 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { } class NotificationService { - NotificationService._(); + NotificationService._({ + FirebaseMessaging? messaging, + FlutterLocalNotificationsPlugin? localNotifications, + String Function()? localeProvider, + }) : _messaging = messaging ?? FirebaseMessaging.instance, + _localNotifications = + localNotifications ?? FlutterLocalNotificationsPlugin(), + _localeProvider = localeProvider; + + @visibleForTesting + NotificationService.test({ + required FirebaseMessaging messaging, + required FlutterLocalNotificationsPlugin localNotifications, + String Function()? localeProvider, + bool isFlutterLocalNotificationsInitialized = false, + bool isTimezoneInitialized = false, + }) : _messaging = messaging, + _localNotifications = localNotifications, + _localeProvider = localeProvider, + _isFlutterLocalNotificationsInitialized = + isFlutterLocalNotificationsInitialized, + _isTimezoneInitialized = isTimezoneInitialized; + static final NotificationService instance = NotificationService._(); - final _messaging = FirebaseMessaging.instance; - final _localNotifications = FlutterLocalNotificationsPlugin(); + final FirebaseMessaging _messaging; + final FlutterLocalNotificationsPlugin _localNotifications; + final String Function()? _localeProvider; bool _isFlutterLocalNotificationsInitialized = false; bool _isTimezoneInitialized = false; String get _locale { + final localeProvider = _localeProvider; + if (localeProvider != null) { + return localeProvider(); + } try { final locale = ui.PlatformDispatcher.instance.locale; return locale.languageCode; @@ -53,10 +81,6 @@ class NotificationService { } } - String _getLocalizedText(String ko, String en) { - return _locale == 'ko' ? ko : en; - } - Future initialize() async { try { FirebaseMessaging.onBackgroundMessage( @@ -252,31 +276,27 @@ class NotificationService { return; } - final notification = message.notification; - final String? title = - notification?.title ?? message.data['title'] ?? message.data['Title']; - final String? body = - notification?.body ?? - message.data['content'] ?? - message.data['body'] ?? - message.data['Content'] ?? - message.data['Body']; - - if (title == null && body == null) { + final content = remoteNotificationDisplayContent( + data: message.data, + notificationTitle: message.notification?.title, + notificationBody: message.notification?.body, + ); + + if (content == null) { return; } try { final notificationId = - ((title ?? '') + - (body ?? '') + + (content.title + + content.body + DateTime.now().millisecondsSinceEpoch.toString()) .hashCode; await _localNotifications.show( id: notificationId, - title: title ?? '알림', - body: body ?? '', + title: content.title, + body: content.body, notificationDetails: NotificationDetails( android: const AndroidNotificationDetails( 'high_importance_channel', @@ -295,7 +315,7 @@ class NotificationService { presentSound: true, ), ), - payload: jsonEncode(message.data), + payload: content.payload, ); } catch (error) { AppLogger.debug( @@ -310,7 +330,7 @@ class NotificationService { required String body, Map? payload, }) async { - if (_isScheduleAlarmPayload(payload)) { + if (isScheduleAlarmPayload(payload)) { AppLogger.debug( '[FCM] schedule_alarm local notification suppressed; native/system alarm handles alarm UI', ); @@ -350,7 +370,7 @@ class NotificationService { presentSound: true, ), ), - payload: payload != null ? jsonEncode(payload) : null, + payload: encodeLocalNotificationPayload(payload), ); } catch (error) { AppLogger.debug( @@ -374,17 +394,16 @@ class NotificationService { return; } - final title = '[$scheduleName] $preparationName'; - final body = _getLocalizedText('이어서 준비하세요.', 'Continue preparing'); - await showLocalNotification( - title: title, - body: body, - payload: { - 'type': 'preparation_step', - 'scheduleId': scheduleId, - 'stepId': stepId, - }, + title: preparationStepNotificationTitle( + scheduleName: scheduleName, + preparationName: preparationName, + ), + body: preparationStepNotificationBody(languageCode: _locale), + payload: preparationStepNotificationPayload( + scheduleId: scheduleId, + stepId: stepId, + ), ); } @@ -397,18 +416,7 @@ class NotificationService { bool _isScheduleAlarmMessage(RemoteMessage message) { final data = message.data; final title = message.notification?.title ?? data['title'] ?? data['Title']; - return _isScheduleAlarmPayload(data) || - title == '약속 알림' || - title == 'Schedule alarm'; - } - - bool _isScheduleAlarmPayload(Map? payload) { - if (payload == null) return false; - final type = payload['type']?.toString(); - final promptVariant = payload['promptVariant']?.toString(); - return type == 'schedule_alarm' || - payload['alarmLaunchPayloadVersion'] != null || - (promptVariant == 'alarm' && payload['scheduleId'] != null); + return isScheduleAlarmMessagePayload(data: data, title: title?.toString()); } Future scheduleFallbackAlarm(ScheduledAlarmRecord record) async { @@ -423,8 +431,7 @@ class NotificationService { await setupFlutterNotifications(); _ensureTimezoneInitialized(); - final notificationId = - record.fallbackNotificationId ?? stableAlarmId(record.scheduleId); + final notificationId = fallbackNotificationIdForRecord(record); final scheduledAt = tz.TZDateTime.from(record.alarmTime, tz.local); AppLogger.debug( '[FallbackAlarm] schedule notificationId=$notificationId ' @@ -435,7 +442,7 @@ class NotificationService { await _localNotifications.zonedSchedule( id: notificationId, title: record.scheduleTitle, - body: _getLocalizedText('준비를 시작할 시간입니다.', 'It is time to get ready.'), + body: fallbackAlarmNotificationBody(languageCode: _locale), scheduledDate: scheduledAt, notificationDetails: const NotificationDetails( android: AndroidNotificationDetails( @@ -457,7 +464,7 @@ class NotificationService { ), ), androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, - payload: jsonEncode(record.payload), + payload: encodeLocalNotificationPayload(record.payload), ); } @@ -507,60 +514,18 @@ class NotificationService { void _handleLocalNotificationTap(String? payload) { AppLogger.debug('[FCM] 알림 탭'); - if (payload == null) { - return; - } - try { - final data = jsonDecode(payload) as Map; - final type = data['type'] as String?; - final scheduleId = data['scheduleId'] as String?; - // final title = data['title'] as String?; - - if (type == 'schedule_alarm' && scheduleId != null) { - getIt.get().push('/scheduleStart', extra: data); - } else if (type != null && type.contains('5min')) { - getIt.get().push( - '/scheduleStart', - extra: {'promptVariant': 'earlyStart'}, - ); - } else if (type != null && - (type.startsWith('schedule_') || - type.startsWith('preparation_')) || - scheduleId != null) { - getIt.get().push('/alarmScreen'); - } - // else if (title != null && title.contains('약속')) { - // getIt.get().push('/alarmScreen'); - // } - } catch (e) { - AppLogger.debug('[FCM] 페이로드 파싱 오류: $e'); + final target = notificationRouteForPayloadString(payload); + if (target != null) { + getIt.get().push(target.path, extra: target.extra); } } Future _handleBackgroundMessage(RemoteMessage message) async { AppLogger.debug('[FCM] 백그라운드 메시지 처리'); - final type = message.data['type'] as String?; - final scheduleId = message.data['scheduleId'] as String?; - // final title = message.data['title'] as String?; - - if (type == 'schedule_alarm' && scheduleId != null) { - getIt.get().push( - '/scheduleStart', - extra: message.data, - ); - } else if (type != null && type.contains('5min')) { - getIt.get().push( - '/scheduleStart', - extra: {'promptVariant': 'earlyStart'}, - ); - } else if (type != null && - (type.startsWith('schedule_') || type.startsWith('preparation_')) || - scheduleId != null) { - getIt.get().push('/alarmScreen'); + final target = notificationRouteForData(message.data); + if (target != null) { + getIt.get().push(target.path, extra: target.extra); } - // else if (title != null && title.contains('약속')) { - // getIt.get().push('/alarmScreen'); - // } } } diff --git a/lib/presentation/calendar/screens/calendar_screen.dart b/lib/presentation/calendar/screens/calendar_screen.dart index 7864c1b6..c8cfde33 100644 --- a/lib/presentation/calendar/screens/calendar_screen.dart +++ b/lib/presentation/calendar/screens/calendar_screen.dart @@ -19,10 +19,14 @@ import 'package:on_time_front/presentation/shared/theme/calendar_theme.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; import 'package:table_calendar/table_calendar.dart'; +typedef CalendarCreateSheetBuilder = + Widget Function(BuildContext context, DateTime initialDate); + class CalendarScreen extends StatefulWidget { - const CalendarScreen({super.key, this.initialDate}); + const CalendarScreen({super.key, this.initialDate, this.createSheetBuilder}); final DateTime? initialDate; + final CalendarCreateSheetBuilder? createSheetBuilder; @override State createState() => _CalendarScreenState(); @@ -74,11 +78,7 @@ class _CalendarScreenState extends State { ), ), ) - ..add( - MonthlySchedulesVisibleDateChanged( - date: _selectedDate, - ), - ); + ..add(MonthlySchedulesVisibleDateChanged(date: _selectedDate)); } void _onLeftArrowTap() { @@ -126,9 +126,9 @@ class _CalendarScreenState extends State { context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (context) => ScheduleCreateScreen( - initialDate: _selectedDate, - ), + builder: (context) => + widget.createSheetBuilder?.call(context, _selectedDate) ?? + ScheduleCreateScreen(initialDate: _selectedDate), ); _refreshSchedulesIfSaved(saved); @@ -142,9 +142,7 @@ class _CalendarScreenState extends State { context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (context) => ScheduleEditScreen( - scheduleId: scheduleId, - ), + builder: (context) => ScheduleEditScreen(scheduleId: scheduleId), ); _refreshSchedulesIfSaved(saved); @@ -201,13 +199,15 @@ class _CalendarScreenState extends State { ), ), body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0) + + padding: + const EdgeInsets.symmetric(horizontal: 18.0) + EdgeInsets.only(bottom: 12.0), child: LayoutBuilder( builder: (context, constraints) { final detailGap = _calendarDetailGap(constraints.maxHeight); - final selectedDateHeadingGap = - _selectedDateHeadingGap(constraints.maxHeight); + final selectedDateHeadingGap = _selectedDateHeadingGap( + constraints.maxHeight, + ); return Column( children: [ @@ -222,119 +222,145 @@ class _CalendarScreenState extends State { vertical: _calendarVerticalPadding, horizontal: _calendarHorizontalPadding, ), - child: BlocBuilder( - builder: (context, state) { - if (state.status == MonthlySchedulesStatus.error) { - return Text(AppLocalizations.of(context)!.error); - } - - return TableCalendar( - locale: - Localizations.localeOf(context).toString(), - daysOfWeekHeight: _calendarDaysOfWeekHeight, - rowHeight: _calendarRowHeight, - eventLoader: (day) { - day = DateTime(day.year, day.month, day.day); - return state.schedules[day] ?? []; - }, - focusedDay: _selectedDate, - selectedDayPredicate: (day) => - isSameDay(_selectedDate, day), - firstDay: _firstDay, - lastDay: _lastDay, - calendarFormat: CalendarFormat.month, - headerStyle: calendarTheme.headerStyle, - daysOfWeekStyle: calendarTheme.daysOfWeekStyle, - calendarStyle: calendarTheme.calendarStyle, - onDaySelected: (selectedDay, focusedDay) { - setState(() { - _selectedDate = _clampDay( - selectedDay, _firstDay, _lastDay); - }); - _monthlySchedulesBloc.add( - MonthlySchedulesVisibleDateChanged( - date: _selectedDate, - ), - ); - }, - onPageChanged: (focusedDay) { - final clampedFocusedDay = - _clampDay(focusedDay, _firstDay, _lastDay); - - setState(() { - _selectedDate = clampedFocusedDay; - }); - - _monthlySchedulesBloc.add( - MonthlySchedulesVisibleDateChanged( - date: _selectedDate, - ), - ); - - _monthlySchedulesBloc.add( - MonthlySchedulesMonthAdded( - date: DateTime( - clampedFocusedDay.year, - clampedFocusedDay.month, - clampedFocusedDay.day, - ), + child: + BlocBuilder< + MonthlySchedulesBloc, + MonthlySchedulesState + >( + builder: (context, state) { + if (state.status == + MonthlySchedulesStatus.error) { + return Text( + AppLocalizations.of(context)!.error, + ); + } + + return TableCalendar( + locale: Localizations.localeOf( + context, + ).toString(), + daysOfWeekHeight: _calendarDaysOfWeekHeight, + rowHeight: _calendarRowHeight, + eventLoader: (day) { + day = DateTime( + day.year, + day.month, + day.day, + ); + return state.schedules[day] ?? []; + }, + focusedDay: _selectedDate, + selectedDayPredicate: (day) => + isSameDay(_selectedDate, day), + firstDay: _firstDay, + lastDay: _lastDay, + calendarFormat: CalendarFormat.month, + headerStyle: calendarTheme.headerStyle, + daysOfWeekStyle: + calendarTheme.daysOfWeekStyle, + calendarStyle: calendarTheme.calendarStyle, + onDaySelected: (selectedDay, focusedDay) { + setState(() { + _selectedDate = _clampDay( + selectedDay, + _firstDay, + _lastDay, + ); + }); + _monthlySchedulesBloc.add( + MonthlySchedulesVisibleDateChanged( + date: _selectedDate, + ), + ); + }, + onPageChanged: (focusedDay) { + final clampedFocusedDay = _clampDay( + focusedDay, + _firstDay, + _lastDay, + ); + + setState(() { + _selectedDate = clampedFocusedDay; + }); + + _monthlySchedulesBloc.add( + MonthlySchedulesVisibleDateChanged( + date: _selectedDate, + ), + ); + + _monthlySchedulesBloc.add( + MonthlySchedulesMonthAdded( + date: DateTime( + clampedFocusedDay.year, + clampedFocusedDay.month, + clampedFocusedDay.day, + ), + ), + ); + }, + calendarBuilders: CalendarBuilders( + headerTitleBuilder: (context, date) { + return CenteredCalendarHeader( + focusedMonth: date, + onLeftArrowTap: _onLeftArrowTap, + onRightArrowTap: _onRightArrowTap, + titleTextStyle: calendarTheme + .headerStyle + .titleTextStyle, + leftIcon: calendarTheme + .headerStyle + .leftChevronIcon, + rightIcon: calendarTheme + .headerStyle + .rightChevronIcon, + ); + }, + markerBuilder: (context, day, events) { + return selectedDayScheduleMarkerBuilder( + selectedDay: _selectedDate, + day: day, + events: events, + ); + }, + selectedBuilder: + (context, day, focusedDay) { + return Container( + margin: const EdgeInsets.all(2.0), + alignment: Alignment.center, + decoration: calendarTheme + .selectedDayDecoration, + child: Text( + DateFormat.d( + Localizations.localeOf( + context, + ).toString(), + ).format(day), + style: calendarTheme + .selectedDayTextStyle, + ), + ); + }, + todayBuilder: (context, day, focusedDay) => + Container( + margin: const EdgeInsets.all(2.0), + alignment: Alignment.center, + decoration: + calendarTheme.todayDecoration, + child: Text( + DateFormat.d( + Localizations.localeOf( + context, + ).toString(), + ).format(day), + style: calendarTheme.todayTextStyle, + ), + ), ), ); }, - calendarBuilders: CalendarBuilders( - headerTitleBuilder: (context, date) { - return CenteredCalendarHeader( - focusedMonth: date, - onLeftArrowTap: _onLeftArrowTap, - onRightArrowTap: _onRightArrowTap, - titleTextStyle: calendarTheme - .headerStyle.titleTextStyle, - leftIcon: calendarTheme - .headerStyle.leftChevronIcon, - rightIcon: calendarTheme - .headerStyle.rightChevronIcon, - ); - }, - markerBuilder: (context, day, events) { - return selectedDayScheduleMarkerBuilder( - selectedDay: _selectedDate, - day: day, - events: events, - ); - }, - selectedBuilder: (context, day, focusedDay) { - return Container( - margin: const EdgeInsets.all(2.0), - alignment: Alignment.center, - decoration: - calendarTheme.selectedDayDecoration, - child: Text( - DateFormat.d( - Localizations.localeOf(context) - .toString(), - ).format(day), - style: calendarTheme.selectedDayTextStyle, - ), - ); - }, - todayBuilder: (context, day, focusedDay) => - Container( - margin: const EdgeInsets.all(2.0), - alignment: Alignment.center, - decoration: calendarTheme.todayDecoration, - child: Text( - DateFormat.d( - Localizations.localeOf(context) - .toString(), - ).format(day), - style: calendarTheme.todayTextStyle, - ), - ), - ), - ); - }, - ), + ), ), ), SizedBox(height: detailGap), @@ -357,48 +383,52 @@ class _CalendarScreenState extends State { ), SizedBox(height: selectedDateHeadingGap), Expanded( - child: BlocBuilder( - builder: (context, state) { - return _SelectedDateSchedulesContent( - selectedDate: _selectedDate, - state: state, - onAddSchedule: () => - _openCreateScheduleSheet(context), - onEditSchedule: (scheduleId) => - _openEditScheduleSheet( - context, - scheduleId: scheduleId, - ), - onDeleteSchedule: (schedule) { - showTwoButtonDeleteDialog( - context, - title: AppLocalizations.of(context)! - .scheduleDeleteConfirmTitle, - description: AppLocalizations.of( - context)! - .scheduleDeleteConfirmDescription, - cancelText: - AppLocalizations.of(context)! - .cancel, - confirmText: - AppLocalizations.of(context)! - .deleteScheduleConfirmAction, - ).then((confirmed) { - if (confirmed != true || - !context.mounted) { - return; - } - _monthlySchedulesBloc.add( - MonthlySchedulesScheduleDeleted( - schedule: schedule, - ), - ); - }); + child: + BlocBuilder< + MonthlySchedulesBloc, + MonthlySchedulesState + >( + builder: (context, state) { + return _SelectedDateSchedulesContent( + selectedDate: _selectedDate, + state: state, + onAddSchedule: () => + _openCreateScheduleSheet(context), + onEditSchedule: (scheduleId) => + _openEditScheduleSheet( + context, + scheduleId: scheduleId, + ), + onDeleteSchedule: (schedule) { + showTwoButtonDeleteDialog( + context, + title: AppLocalizations.of( + context, + )!.scheduleDeleteConfirmTitle, + description: AppLocalizations.of( + context, + )!.scheduleDeleteConfirmDescription, + cancelText: AppLocalizations.of( + context, + )!.cancel, + confirmText: AppLocalizations.of( + context, + )!.deleteScheduleConfirmAction, + ).then((confirmed) { + if (confirmed != true || + !context.mounted) { + return; + } + _monthlySchedulesBloc.add( + MonthlySchedulesScheduleDeleted( + schedule: schedule, + ), + ); + }); + }, + ); }, - ); - }, - ), + ), ), ], ), @@ -465,7 +495,8 @@ class _SelectedDateSchedulesContent extends StatelessWidget { return BlocBuilder( builder: (context, scheduleState) { - final isEarlyStarted = scheduleState.isEarlyStarted && + final isEarlyStarted = + scheduleState.isEarlyStarted && scheduleState.schedule?.id == schedule.id; return ScheduleDetail( diff --git a/lib/presentation/my_page/my_page_screen.dart b/lib/presentation/my_page/my_page_screen.dart index bca70d43..daac0360 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -24,10 +24,15 @@ import 'package:on_time_front/presentation/shared/components/two_action_dialog.d typedef PrivacyPolicyLauncher = Future Function(Uri uri); class MyPageScreen extends StatelessWidget { - const MyPageScreen({super.key, PrivacyPolicyLauncher? openPrivacyPolicy}) - : _openPrivacyPolicy = openPrivacyPolicy; + const MyPageScreen({ + super.key, + PrivacyPolicyLauncher? openPrivacyPolicy, + NotificationService? notificationService, + }) : _openPrivacyPolicy = openPrivacyPolicy, + _notificationService = notificationService; final PrivacyPolicyLauncher? _openPrivacyPolicy; + final NotificationService? _notificationService; @override Widget build(BuildContext context) { @@ -95,7 +100,10 @@ class MyPageScreen extends StatelessWidget { _SettingTile( title: AppLocalizations.of(context)!.allowAppNotifications, onTap: () async { - await _handleNotificationPermission(context); + await _handleNotificationPermission( + context, + _notificationService ?? NotificationService.instance, + ); }, ), _SettingTile( @@ -418,8 +426,10 @@ class _SettingTile extends StatelessWidget { } } -Future _handleNotificationPermission(BuildContext context) async { - final notificationService = NotificationService.instance; +Future _handleNotificationPermission( + BuildContext context, + NotificationService notificationService, +) async { final currentStatus = await notificationService.checkNotificationPermission(); if (!context.mounted) return; @@ -438,7 +448,7 @@ Future _handleNotificationPermission(BuildContext context) async { if (!context.mounted) return; await _showPermissionGrantedDialog(context); } else if (newStatus == AuthorizationStatus.denied) { - await _showGoToSettingsDialog(context); + await _showGoToSettingsDialog(context, notificationService); } } } else if (currentStatus == AuthorizationStatus.notDetermined) { @@ -453,11 +463,11 @@ Future _handleNotificationPermission(BuildContext context) async { if (!context.mounted) return; await _showPermissionGrantedDialog(context); } else if (newStatus == AuthorizationStatus.denied) { - await _showGoToSettingsDialog(context); + await _showGoToSettingsDialog(context, notificationService); } } } else { - await _showGoToSettingsDialog(context); + await _showGoToSettingsDialog(context, notificationService); } } @@ -537,7 +547,10 @@ Future _showPermissionGrantedDialog(BuildContext context) async { ); } -Future _showGoToSettingsDialog(BuildContext context) async { +Future _showGoToSettingsDialog( + BuildContext context, + NotificationService notificationService, +) async { final l10n = AppLocalizations.of(context)!; final result = await showTwoActionDialog( @@ -557,6 +570,6 @@ Future _showGoToSettingsDialog(BuildContext context) async { ); if (result == DialogActionResult.primary) { - await NotificationService.instance.openNotificationSettings(); + await notificationService.openNotificationSettings(); } } diff --git a/lib/presentation/shared/utils/time_format.dart b/lib/presentation/shared/utils/time_format.dart index 194dc22a..00039d8d 100644 --- a/lib/presentation/shared/utils/time_format.dart +++ b/lib/presentation/shared/utils/time_format.dart @@ -1,4 +1,8 @@ String formatTime(int seconds) { + if (seconds <= 0) { + return '0초'; + } + final int hours = seconds ~/ 3600; final int minutes = (seconds % 3600) ~/ 60; final int remainingSeconds = seconds % 60; @@ -7,8 +11,6 @@ String formatTime(int seconds) { return minutes > 0 ? '$hours시간 $minutes분' : '$hours시간'; } else if (minutes > 0) { return remainingSeconds > 0 ? '$minutes분 $remainingSeconds초' : '$minutes분'; - } else if (seconds <= 0) { - return '0초'; } else { return '$remainingSeconds초'; } diff --git a/test/core/dio/interceptors/logger_interceptor_test.dart b/test/core/dio/interceptors/logger_interceptor_test.dart new file mode 100644 index 00000000..070f6c13 --- /dev/null +++ b/test/core/dio/interceptors/logger_interceptor_test.dart @@ -0,0 +1,84 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/dio/interceptors/logger_interceptor.dart'; + +void main() { + late Dio dio; + late _LoggerAdapter adapter; + + setUp(() { + adapter = _LoggerAdapter(); + dio = Dio( + BaseOptions( + baseUrl: 'https://example.com', + receiveDataWhenStatusError: true, + ), + )..httpClientAdapter = adapter; + dio.interceptors.add(LoggerInterceptor()); + }); + + test('passes successful requests and responses through unchanged', () async { + adapter.nextStatusCode = 200; + + final response = await dio.post>( + '/appointments', + queryParameters: {'token': 'secret-token'}, + data: {'name': 'Design review'}, + options: Options(headers: {'Authorization': 'Bearer secret'}), + ); + + expect(response.statusCode, 200); + expect(response.data, {'message': 'ok'}); + expect(adapter.requestedPaths, ['/appointments']); + expect(adapter.requestedMethods, ['POST']); + }); + + test('passes Dio errors through to the caller', () async { + adapter.nextStatusCode = 500; + + await expectLater( + dio.get>( + '/broken', + options: Options(headers: {'Authorization-refresh': 'refresh'}), + ), + throwsA( + isA().having( + (error) => error.response?.statusCode, + 'statusCode', + 500, + ), + ), + ); + + expect(adapter.requestedPaths, ['/broken']); + expect(adapter.requestedMethods, ['GET']); + }); +} + +class _LoggerAdapter implements HttpClientAdapter { + int nextStatusCode = 200; + final requestedPaths = []; + final requestedMethods = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requestedPaths.add(options.path); + requestedMethods.add(options.method); + return ResponseBody.fromString( + '{"message":"ok"}', + nextStatusCode, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} diff --git a/test/core/dio/interceptors/token_interceptor_test.dart b/test/core/dio/interceptors/token_interceptor_test.dart index 0ffc1776..badb4db8 100644 --- a/test/core/dio/interceptors/token_interceptor_test.dart +++ b/test/core/dio/interceptors/token_interceptor_test.dart @@ -124,6 +124,35 @@ void main() { expect(userRepository.signOutCalled, isTrue); expect(tokenLocalDataSource.deleteTokenCalled, isTrue); }); + + test( + 'continues unauthenticated requests when local token lookup fails', + () async { + tokenLocalDataSource.throwOnGetToken = true; + + final response = await dio.get('/public'); + + expect(response.statusCode, 200); + expect(adapter.protectedAuthorizationHeaders, isEmpty); + }, + ); + + test( + 'missing refresh headers rejects request and signs out locally', + () async { + adapter = _TokenRefreshAdapter(omitRefreshHeaders: true); + dio.httpClientAdapter = adapter; + + await expectLater( + dio.get('/protected'), + throwsA(isA()), + ); + + expect(adapter.refreshRequests, 1); + expect(userRepository.signOutCalled, isTrue); + expect(tokenLocalDataSource.deleteTokenCalled, isTrue); + }, + ); } Future _flushMicrotasks() async { @@ -137,11 +166,13 @@ class _TokenRefreshAdapter implements HttpClientAdapter { this.refreshStatusCode = 200, this.retryStatusCode = 200, this.refreshCompleter, + this.omitRefreshHeaders = false, }); final int refreshStatusCode; final int retryStatusCode; final Completer? refreshCompleter; + final bool omitRefreshHeaders; final requestedPaths = []; final protectedAuthorizationHeaders = []; @@ -168,7 +199,7 @@ class _TokenRefreshAdapter implements HttpClientAdapter { refreshStatusCode, headers: { Headers.contentTypeHeader: [Headers.jsonContentType], - if (refreshStatusCode == 200) ...{ + if (refreshStatusCode == 200 && !omitRefreshHeaders) ...{ 'authorization': ['new-access-token'], 'authorization-refresh': ['new-refresh-token'], }, @@ -215,6 +246,7 @@ class _FakeTokenLocalDataSource implements TokenLocalDataSource { TokenEntity? storedToken; bool deleteTokenCalled = false; int storeTokensCallCount = 0; + bool throwOnGetToken = false; @override Future deleteToken() async { @@ -223,6 +255,9 @@ class _FakeTokenLocalDataSource implements TokenLocalDataSource { @override Future getToken() async { + if (throwOnGetToken) { + throw Exception('token unavailable'); + } return token; } diff --git a/test/core/dio/transformers/logging_transformer_test.dart b/test/core/dio/transformers/logging_transformer_test.dart new file mode 100644 index 00000000..d22469e9 --- /dev/null +++ b/test/core/dio/transformers/logging_transformer_test.dart @@ -0,0 +1,74 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/core/dio/app_dio.dart'; +import 'package:on_time_front/core/dio/interceptors/logger_interceptor.dart'; +import 'package:on_time_front/core/dio/interceptors/token_interceptor.dart'; +import 'package:on_time_front/core/dio/transformers/logging_transformer.dart'; +import 'package:on_time_front/data/data_sources/token_local_data_source.dart'; +import 'package:on_time_front/domain/entities/token_entity.dart'; + +void main() { + tearDown(() async { + await getIt.reset(); + }); + + test( + 'logging transformer preserves serialized request and response body', + () async { + final inner = _FakeTransformer(); + final transformer = LoggingTransformer(inner: inner); + final options = RequestOptions(path: '/test', method: 'POST'); + final responseBody = ResponseBody.fromString('{"ok":true}', 200); + + expect(await transformer.transformRequest(options), '{"name":"meeting"}'); + expect(await transformer.transformResponse(options, responseBody), { + 'ok': true, + }); + }, + ); + + test('AppDio configures JSON defaults, logging, and auth interceptors', () { + getIt.registerSingleton(_FakeTokenLocalDataSource()); + + final dio = AppDio(); + + expect(dio.options.contentType, Headers.jsonContentType); + expect(dio.options.receiveDataWhenStatusError, isTrue); + expect(dio.options.followRedirects, isFalse); + expect(dio.transformer, isA()); + expect(dio.interceptors.whereType(), hasLength(1)); + expect(dio.interceptors.whereType(), hasLength(1)); + }); +} + +class _FakeTransformer implements Transformer { + @override + Future transformRequest(RequestOptions options) async { + return '{"name":"meeting"}'; + } + + @override + Future transformResponse( + RequestOptions options, + ResponseBody responseBody, + ) async { + return {'ok': true}; + } +} + +class _FakeTokenLocalDataSource implements TokenLocalDataSource { + @override + Future storeTokens(TokenEntity token) async {} + + @override + Future storeAuthToken(String token) async {} + + @override + Future getToken() async { + return const TokenEntity(accessToken: 'access', refreshToken: 'refresh'); + } + + @override + Future deleteToken() async {} +} diff --git a/test/core/logging/app_logger_test.dart b/test/core/logging/app_logger_test.dart index 547a468e..1b94617b 100644 --- a/test/core/logging/app_logger_test.dart +++ b/test/core/logging/app_logger_test.dart @@ -86,50 +86,79 @@ void main() { expect(summary, isNot(contains('Leave home'))); expect(summary, isNot(contains('fcm-token'))); }); + + test('handles nulls, plain urls, and empty token values safely', () { + AppLogger.configureFlutterDebugPrint(); + expect(AppLogger.isEnabled, isA()); + expect(AppLogger.redactValue(null), isNull); + expect(AppLogger.redactMap({'accessToken': 'secret'}), { + 'accessToken': AppLogger.redacted, + }); + expect( + AppLogger.redactUri(Uri.parse('https://api.example.test/path')), + 'https://api.example.test/path', + ); + expect(AppLogger.redactToken(null), AppLogger.redacted); + expect(AppLogger.redactToken(''), AppLogger.redacted); + expect(AppLogger.summarizeMap(null), 'null'); + expect( + AppLogger.summarizeMap({'type': null, 'promptVariant': 'alarm'}), + 'keys=2 promptVariant=alarm', + ); + AppLogger.debug( + 'request failed token=secret', + error: Uri.parse('https://api.example.test/path?token=secret'), + stackTrace: StackTrace.current, + ); + }); }); group('logging source scan', () { test('Dart app code uses AppLogger instead of raw debugPrint', () { - final offenders = _sourceFiles( - roots: ['lib'], - extensions: ['.dart'], - excludedPathFragments: [ - '/core/logging/app_logger.dart', - '/l10n/app_localizations', - '.g.dart', - '.freezed.dart', - ], - ) - .where((file) { - final source = file.readAsStringSync(); - return RegExp(r'(? file.path) - .toList(); + final offenders = + _sourceFiles( + roots: ['lib'], + extensions: ['.dart'], + excludedPathFragments: [ + '/core/logging/app_logger.dart', + '/l10n/app_localizations', + '.g.dart', + '.freezed.dart', + ], + ) + .where((file) { + final source = file.readAsStringSync(); + return RegExp( + r'(? file.path) + .toList(); expect(offenders, isEmpty); }); test('native logs do not dump full extras, args, or payload maps', () { - final offenders = _sourceFiles( - roots: ['android/app/src/main/kotlin', 'ios/Runner'], - extensions: ['.kt', '.swift'], - ) - .where((file) { - final source = file.readAsStringSync(); - final isNativeLog = file.path.endsWith('/NativeLog.kt'); - return source.contains('extras=\${intent') || - source.contains('extras=\${intent?') || - source.contains('args=\$args') || - source.contains('payload=\$payload') || - source.contains('-> \$payload') || - source.contains('-> \$launchPayload') || - source.contains('encodedPayload=%@') || - (!isNativeLog && RegExp(r'\bLog\.[diew]\(').hasMatch(source)); - }) - .map((file) => file.path) - .toList(); + final offenders = + _sourceFiles( + roots: ['android/app/src/main/kotlin', 'ios/Runner'], + extensions: ['.kt', '.swift'], + ) + .where((file) { + final source = file.readAsStringSync(); + final isNativeLog = file.path.endsWith('/NativeLog.kt'); + return source.contains('extras=\${intent') || + source.contains('extras=\${intent?') || + source.contains('args=\$args') || + source.contains('payload=\$payload') || + source.contains('-> \$payload') || + source.contains('-> \$launchPayload') || + source.contains('encodedPayload=%@') || + (!isNativeLog && + RegExp(r'\bLog\.[diew]\(').hasMatch(source)); + }) + .map((file) => file.path) + .toList(); expect(offenders, isEmpty); }); diff --git a/test/core/services/alarm_scheduler_service_test.dart b/test/core/services/alarm_scheduler_service_test.dart index 1530b848..e091c9ba 100644 --- a/test/core/services/alarm_scheduler_service_test.dart +++ b/test/core/services/alarm_scheduler_service_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -12,13 +13,13 @@ void main() { pendingPayload = null; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'getLaunchPayload') { - final payload = pendingPayload; - pendingPayload = null; - return payload; - } - return null; - }); + if (call.method == 'getLaunchPayload') { + final payload = pendingPayload; + pendingPayload = null; + return payload; + } + return null; + }); }); tearDown(() { @@ -26,43 +27,411 @@ void main() { .setMockMethodCallHandler(channel, null); }); - test('initializeLaunchHandling dispatches cold-start alarm payload', - () async { - pendingPayload = { - 'type': 'schedule_alarm', - 'scheduleId': 'schedule-1', - }; - final receivedPayloads = >[]; + test( + 'initializeLaunchHandling dispatches cold-start alarm payload', + () async { + pendingPayload = {'type': 'schedule_alarm', 'scheduleId': 'schedule-1'}; + final receivedPayloads = >[]; - await AlarmSchedulerService().initializeLaunchHandling( - receivedPayloads.add, - ); + await AlarmSchedulerService().initializeLaunchHandling( + receivedPayloads.add, + ); + + expect(receivedPayloads, [ + {'type': 'schedule_alarm', 'scheduleId': 'schedule-1'}, + ]); + }, + ); + + test( + 'dispatchPendingLaunchPayload dispatches resumed alarm payload', + () async { + final receivedPayloads = >[]; + final service = AlarmSchedulerService(); + await service.initializeLaunchHandling(receivedPayloads.add); + + pendingPayload = {'type': 'schedule_alarm', 'scheduleId': 'schedule-2'}; + await service.dispatchPendingLaunchPayload(); + + expect(receivedPayloads, [ + {'type': 'schedule_alarm', 'scheduleId': 'schedule-2'}, + ]); + }, + ); + + test('getCapabilities maps native alarm provider capabilities', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getCapabilities') { + return { + 'supportsNativeAlarm': true, + 'nativeAlarmProvider': 'androidAlarmManager', + 'fallbackProvider': 'localNotification', + }; + } + return null; + }); + + final capabilities = await AlarmSchedulerService().getCapabilities(); + + expect(capabilities.supportsNativeAlarm, isTrue); + expect(capabilities.nativeAlarmProvider, AlarmProvider.androidAlarmManager); + expect(capabilities.fallbackProvider, AlarmProvider.localNotification); + }); + + test( + 'getCapabilities treats missing platform implementation as unsupported', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); - expect(receivedPayloads, [ - { - 'type': 'schedule_alarm', - 'scheduleId': 'schedule-1', - }, - ]); + final capabilities = await AlarmSchedulerService().getCapabilities(); + + expect(capabilities, AlarmSchedulerCapabilities.unsupported); + }, + ); + + test( + 'getCapabilities treats native platform errors as unsupported', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getCapabilities') { + throw PlatformException( + code: 'nativeDown', + message: 'alarm service unavailable', + ); + } + return null; + }); + + final capabilities = await AlarmSchedulerService().getCapabilities(); + + expect(capabilities, AlarmSchedulerCapabilities.unsupported); + }, + ); + + test('getCapabilities treats null native response as unsupported', () async { + final capabilities = await AlarmSchedulerService().getCapabilities(); + + expect(capabilities, AlarmSchedulerCapabilities.unsupported); }); - test('dispatchPendingLaunchPayload dispatches resumed alarm payload', - () async { - final receivedPayloads = >[]; - final service = AlarmSchedulerService(); - await service.initializeLaunchHandling(receivedPayloads.add); + test( + 'checkPermission and requestPermission map native wire values', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'checkPermission') { + return 'denied'; + } + if (call.method == 'requestPermission') { + return 'granted'; + } + return null; + }); + + final service = AlarmSchedulerService(); + + expect(await service.checkPermission(), AlarmPermissionState.denied); + expect(await service.requestPermission(), AlarmPermissionState.granted); + }, + ); + + test( + 'permission checks treat missing platform implementation as unsupported', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + final service = AlarmSchedulerService(); + + expect(await service.checkPermission(), AlarmPermissionState.unsupported); + expect( + await service.requestPermission(), + AlarmPermissionState.unsupported, + ); + }, + ); + + test( + 'permission checks treat native platform errors as unsupported', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException( + code: 'nativeDown', + message: 'permission API unavailable', + ); + }); + final service = AlarmSchedulerService(); + + expect(await service.checkPermission(), AlarmPermissionState.unsupported); + expect( + await service.requestPermission(), + AlarmPermissionState.unsupported, + ); + }, + ); + + test('scheduleNativeAlarm sends the native alarm contract payload', () async { + Map? nativeArguments; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'scheduleNativeAlarm') { + nativeArguments = (call.arguments as Map).cast(); + } + return null; + }); + + final alarmTime = DateTime.utc(2026, 5, 15, 8); + final preparationStartTime = DateTime.utc(2026, 5, 15, 8, 5); + await AlarmSchedulerService().scheduleNativeAlarm( + ScheduledAlarmRecord( + scheduleId: 'schedule-1', + alarmTime: alarmTime, + preparationStartTime: preparationStartTime, + scheduleFingerprint: 'fingerprint', + provider: AlarmProvider.androidAlarmManager, + scheduleTitle: 'Morning meeting', + payload: const {'type': 'schedule_alarm', 'scheduleId': 'schedule-1'}, + ), + ); - pendingPayload = { + expect(nativeArguments, isNotNull); + expect(nativeArguments!['scheduleId'], 'schedule-1'); + expect(nativeArguments!['alarmTime'], alarmTime.millisecondsSinceEpoch); + expect( + nativeArguments!['preparationStartTime'], + preparationStartTime.millisecondsSinceEpoch, + ); + expect(nativeArguments!['nativeAlarmId'], stableAlarmId('schedule-1')); + expect(nativeArguments!['provider'], 'androidAlarmManager'); + expect(nativeArguments!['title'], 'Morning meeting'); + expect(nativeArguments!['body'], 'It is time to get ready.'); + expect(nativeArguments!['payload'], { 'type': 'schedule_alarm', - 'scheduleId': 'schedule-2', - }; - await service.dispatchPendingLaunchPayload(); - - expect(receivedPayloads, [ - { - 'type': 'schedule_alarm', - 'scheduleId': 'schedule-2', - }, - ]); + 'scheduleId': 'schedule-1', + }); + }); + + test( + 'scheduleNativeAlarm exposes permission failures as scheduling exception', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException( + code: 'permissionDenied', + message: 'exact alarm permission missing', + ); + }); + + await expectLater( + AlarmSchedulerService().scheduleNativeAlarm(_scheduledAlarmRecord()), + throwsA( + isA() + .having( + (error) => error.reason, + 'reason', + AlarmFailureReason.platformError, + ) + .having( + (error) => error.permissionIssue, + 'permissionIssue', + AlarmPermissionIssue.nativePermissionDenied, + ) + .having( + (error) => error.message, + 'message', + 'exact alarm permission missing', + ), + ), + ); + }, + ); + + test( + 'scheduleNativeAlarm maps unsupported and generic native errors', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'scheduleNativeAlarm') { + throw PlatformException(code: 'unsupported'); + } + return null; + }); + + await expectLater( + AlarmSchedulerService().scheduleNativeAlarm(_scheduledAlarmRecord()), + throwsA( + isA() + .having( + (error) => error.reason, + 'reason', + AlarmFailureReason.platformError, + ) + .having( + (error) => error.message, + 'message', + 'Native alarms are unsupported', + ), + ), + ); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'scheduleNativeAlarm') { + throw PlatformException(code: 'nativeDown'); + } + return null; + }); + + await expectLater( + AlarmSchedulerService().scheduleNativeAlarm(_scheduledAlarmRecord()), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'nativeDown', + ), + ), + ); + }, + ); + + test( + 'cancelNativeAlarm sends cancellation payload for scheduled records', + () async { + Map? nativeArguments; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'cancelNativeAlarm') { + nativeArguments = (call.arguments as Map) + .cast(); + } + return null; + }); + + await AlarmSchedulerService().cancelNativeAlarm( + _scheduledAlarmRecord(nativeAlarmId: 42), + ); + + expect(nativeArguments, isNotNull); + expect(nativeArguments!['scheduleId'], 'schedule-1'); + expect(nativeArguments!['nativeAlarmId'], 42); + }, + ); + + test('cancelNativeAlarm ignores missing native platform', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + + await AlarmSchedulerService().cancelNativeAlarm(_scheduledAlarmRecord()); }); + + test( + 'cancelNativeAlarm maps native platform failures to scheduling exception', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'cancelNativeAlarm') { + throw PlatformException( + code: 'permissionDenied', + message: 'cannot cancel without permission', + ); + } + return null; + }); + + await expectLater( + AlarmSchedulerService().cancelNativeAlarm(_scheduledAlarmRecord()), + throwsA( + isA() + .having( + (error) => error.reason, + 'reason', + AlarmFailureReason.platformError, + ) + .having( + (error) => error.permissionIssue, + 'permissionIssue', + AlarmPermissionIssue.nativePermissionDenied, + ) + .having( + (error) => error.message, + 'message', + 'cannot cancel without permission', + ), + ), + ); + }, + ); + + test( + 'cancelAllNativeAlarms cancels every provided record in order', + () async { + final canceledIds = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'cancelNativeAlarm') { + canceledIds.add( + ((call.arguments as Map)['scheduleId'] + as String), + ); + } + return null; + }); + + await AlarmSchedulerService().cancelAllNativeAlarms([ + _scheduledAlarmRecord(scheduleId: 'schedule-1'), + _scheduledAlarmRecord(scheduleId: 'schedule-2'), + ]); + + expect(canceledIds, ['schedule-1', 'schedule-2']); + }, + ); + + test( + 'dispatchPendingLaunchPayload ignores invalid and failed payload reads', + () async { + final receivedPayloads = >[]; + final service = AlarmSchedulerService(); + await service.initializeLaunchHandling(receivedPayloads.add); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getLaunchPayload') { + return 'not-a-map'; + } + return null; + }); + await service.dispatchPendingLaunchPayload(); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getLaunchPayload') { + throw PlatformException(code: 'boom'); + } + return null; + }); + await service.dispatchPendingLaunchPayload(); + + expect(receivedPayloads, isEmpty); + }, + ); +} + +ScheduledAlarmRecord _scheduledAlarmRecord({ + String scheduleId = 'schedule-1', + int? nativeAlarmId, +}) { + return ScheduledAlarmRecord( + scheduleId: scheduleId, + alarmTime: DateTime.utc(2026, 5, 15, 8), + preparationStartTime: DateTime.utc(2026, 5, 15, 8, 5), + scheduleFingerprint: 'fingerprint', + nativeAlarmId: nativeAlarmId, + provider: AlarmProvider.androidAlarmManager, + scheduleTitle: 'Morning meeting', + payload: {'type': 'schedule_alarm', 'scheduleId': scheduleId}, + ); } diff --git a/test/core/services/fallback_alarm_notification_service_test.dart b/test/core/services/fallback_alarm_notification_service_test.dart new file mode 100644 index 00000000..6d286884 --- /dev/null +++ b/test/core/services/fallback_alarm_notification_service_test.dart @@ -0,0 +1,112 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; +import 'package:on_time_front/core/services/notification_service.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; + +void main() { + test( + 'permission checks map Firebase authorization to alarm permission', + () async { + final notificationService = _FakeNotificationService( + checkStatus: AuthorizationStatus.provisional, + requestStatus: AuthorizationStatus.denied, + ); + final service = FallbackAlarmNotificationServiceImpl( + notificationService: notificationService, + ); + + expect(await service.checkPermission(), AlarmPermissionState.granted); + expect(await service.requestPermission(), AlarmPermissionState.denied); + expect(notificationService.checkCount, 1); + expect(notificationService.requestCount, 1); + }, + ); + + test('not-determined notification status remains recoverable', () async { + final service = FallbackAlarmNotificationServiceImpl( + notificationService: _FakeNotificationService( + checkStatus: AuthorizationStatus.notDetermined, + requestStatus: AuthorizationStatus.notDetermined, + ), + ); + + expect(await service.checkPermission(), AlarmPermissionState.notDetermined); + expect( + await service.requestPermission(), + AlarmPermissionState.notDetermined, + ); + }); + + test( + 'schedules and cancels fallback alarms through notification service', + () async { + final notificationService = _FakeNotificationService( + checkStatus: AuthorizationStatus.authorized, + requestStatus: AuthorizationStatus.authorized, + ); + final service = FallbackAlarmNotificationServiceImpl( + notificationService: notificationService, + ); + final record = _record(fallbackNotificationId: null); + + await service.scheduleFallbackAlarm(record); + await service.cancelFallbackAlarm(record); + + expect(notificationService.scheduledRecords, [record]); + expect(notificationService.cancelledIds, [stableAlarmId('schedule-1')]); + }, + ); +} + +ScheduledAlarmRecord _record({int? fallbackNotificationId}) { + return ScheduledAlarmRecord( + scheduleId: 'schedule-1', + alarmTime: DateTime.utc(2026, 5, 15, 8), + preparationStartTime: DateTime.utc(2026, 5, 15, 8, 5), + scheduleFingerprint: 'fingerprint', + provider: AlarmProvider.localNotification, + scheduleTitle: 'Morning meeting', + payload: const {'type': 'schedule_alarm', 'scheduleId': 'schedule-1'}, + fallbackNotificationId: fallbackNotificationId, + ); +} + +class _FakeNotificationService implements NotificationService { + _FakeNotificationService({ + required this.checkStatus, + required this.requestStatus, + }); + + final AuthorizationStatus checkStatus; + final AuthorizationStatus requestStatus; + final scheduledRecords = []; + final cancelledIds = []; + int checkCount = 0; + int requestCount = 0; + + @override + Future checkNotificationPermission() async { + checkCount += 1; + return checkStatus; + } + + @override + Future requestPermission() async { + requestCount += 1; + return requestStatus; + } + + @override + Future scheduleFallbackAlarm(ScheduledAlarmRecord record) async { + scheduledRecords.add(record); + } + + @override + Future cancelFallbackNotification(int notificationId) async { + cancelledIds.add(notificationId); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/core/services/notification_content_test.dart b/test/core/services/notification_content_test.dart new file mode 100644 index 00000000..24423fc0 --- /dev/null +++ b/test/core/services/notification_content_test.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/services/notification_content.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; + +void main() { + test('remote notification content prefers FCM notification text', () { + final content = remoteNotificationDisplayContent( + notificationTitle: 'Server title', + notificationBody: 'Server body', + data: const { + 'title': 'Data title', + 'body': 'Data body', + 'scheduleId': 'schedule-1', + }, + ); + + expect(content?.title, 'Server title'); + expect(content?.body, 'Server body'); + expect(jsonDecode(content!.payload), { + 'title': 'Data title', + 'body': 'Data body', + 'scheduleId': 'schedule-1', + }); + }); + + test( + 'remote notification content accepts backend title and body variants', + () { + expect( + remoteNotificationDisplayContent( + data: const {'Title': 'Upper title', 'Content': 'Upper content'}, + )?.title, + 'Upper title', + ); + expect( + remoteNotificationDisplayContent( + data: const {'title': 'Lower title', 'content': 'Lower content'}, + )?.body, + 'Lower content', + ); + expect( + remoteNotificationDisplayContent( + data: const {'Body': 'Body only'}, + )?.title, + '알림', + ); + expect(remoteNotificationDisplayContent(data: const {}), isNull); + }, + ); + + test('local notification payloads are encoded only when present', () { + expect(encodeLocalNotificationPayload(null), isNull); + expect( + jsonDecode( + encodeLocalNotificationPayload(const { + 'type': 'preparation_step', + 'scheduleId': 'schedule-1', + })!, + ), + {'type': 'preparation_step', 'scheduleId': 'schedule-1'}, + ); + }); + + test('preparation step notification content carries routing payload', () { + expect( + preparationStepNotificationTitle( + scheduleName: 'Morning meeting', + preparationName: 'Pack bag', + ), + '[Morning meeting] Pack bag', + ); + expect(preparationStepNotificationBody(languageCode: 'ko'), '이어서 준비하세요.'); + expect( + preparationStepNotificationBody(languageCode: 'en'), + 'Continue preparing', + ); + expect( + preparationStepNotificationPayload( + scheduleId: 'schedule-1', + stepId: 'step-2', + ), + { + 'type': 'preparation_step', + 'scheduleId': 'schedule-1', + 'stepId': 'step-2', + }, + ); + }); + + test( + 'fallback alarm notification uses explicit id or stable schedule id', + () { + final explicit = _record(fallbackNotificationId: 42); + final implicit = _record(fallbackNotificationId: null); + + expect(fallbackNotificationIdForRecord(explicit), 42); + expect( + fallbackNotificationIdForRecord(implicit), + stableAlarmId('schedule-1'), + ); + expect( + fallbackAlarmNotificationBody(languageCode: 'ko'), + '준비를 시작할 시간입니다.', + ); + expect( + fallbackAlarmNotificationBody(languageCode: 'en'), + 'It is time to get ready.', + ); + }, + ); +} + +ScheduledAlarmRecord _record({required int? fallbackNotificationId}) { + return ScheduledAlarmRecord( + scheduleId: 'schedule-1', + alarmTime: DateTime.utc(2026, 5, 15, 8), + preparationStartTime: DateTime.utc(2026, 5, 15, 8, 5), + scheduleFingerprint: 'fingerprint', + provider: AlarmProvider.localNotification, + scheduleTitle: 'Morning meeting', + payload: const {'type': 'schedule_alarm'}, + fallbackNotificationId: fallbackNotificationId, + ); +} diff --git a/test/core/services/notification_routing_test.dart b/test/core/services/notification_routing_test.dart new file mode 100644 index 00000000..99ae86ee --- /dev/null +++ b/test/core/services/notification_routing_test.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/services/notification_routing.dart'; + +void main() { + group('localizedNotificationText', () { + test('selects Korean text only for Korean locale', () { + expect( + localizedNotificationText(languageCode: 'ko', ko: '준비', en: 'Ready'), + '준비', + ); + expect( + localizedNotificationText(languageCode: 'en', ko: '준비', en: 'Ready'), + 'Ready', + ); + }); + }); + + group('isScheduleAlarmPayload', () { + test( + 'detects native alarm payloads by type, version, and prompt variant', + () { + expect(isScheduleAlarmPayload(null), isFalse); + expect( + isScheduleAlarmPayload(const {'type': 'schedule_alarm'}), + isTrue, + ); + expect( + isScheduleAlarmPayload(const {'alarmLaunchPayloadVersion': 1}), + isTrue, + ); + expect( + isScheduleAlarmPayload(const { + 'promptVariant': 'alarm', + 'scheduleId': 'schedule-1', + }), + isTrue, + ); + expect( + isScheduleAlarmPayload(const {'promptVariant': 'alarm'}), + isFalse, + ); + expect( + isScheduleAlarmPayload(const {'type': 'preparation_step'}), + isFalse, + ); + }, + ); + }); + + group('isScheduleAlarmMessagePayload', () { + test('detects native alarm push messages from data or known titles', () { + expect( + isScheduleAlarmMessagePayload( + data: const {'type': 'schedule_alarm'}, + title: null, + ), + isTrue, + ); + expect( + isScheduleAlarmMessagePayload(data: const {}, title: '약속 알림'), + isTrue, + ); + expect( + isScheduleAlarmMessagePayload(data: const {}, title: 'Schedule alarm'), + isTrue, + ); + expect( + isScheduleAlarmMessagePayload( + data: const {'type': 'announcement'}, + title: 'General', + ), + isFalse, + ); + }); + }); + + group('notificationRouteForPayloadString', () { + test('routes full schedule alarm payload to the schedule start screen', () { + final payload = jsonEncode({ + 'type': 'schedule_alarm', + 'scheduleId': 'schedule-1', + 'title': 'Morning meeting', + }); + + final target = notificationRouteForPayloadString(payload); + + expect(target, isNotNull); + expect(target!.path, '/scheduleStart'); + expect(target.extra, { + 'type': 'schedule_alarm', + 'scheduleId': 'schedule-1', + 'title': 'Morning meeting', + }); + }); + + test('routes five-minute prompts as early-start schedule starts', () { + final target = notificationRouteForPayloadString( + jsonEncode({ + 'type': 'schedule_5min_before', + 'scheduleId': 'schedule-1', + }), + ); + + expect( + target, + const NotificationRouteTarget( + '/scheduleStart', + extra: {'promptVariant': 'earlyStart'}, + ), + ); + }); + + test('routes schedule and preparation updates to the alarm screen', () { + expect( + notificationRouteForPayloadString( + jsonEncode({'type': 'schedule_changed'}), + ), + const NotificationRouteTarget('/alarmScreen'), + ); + expect( + notificationRouteForPayloadString( + jsonEncode({'type': 'preparation_step'}), + ), + const NotificationRouteTarget('/alarmScreen'), + ); + expect( + notificationRouteForPayloadString(jsonEncode({'scheduleId': 's-1'})), + const NotificationRouteTarget('/alarmScreen'), + ); + }); + + test('ignores null, invalid, and unrelated payloads', () { + expect(notificationRouteForPayloadString(null), isNull); + expect(notificationRouteForPayloadString('{bad json'), isNull); + expect( + notificationRouteForPayloadString(jsonEncode(['not', 'a map'])), + isNull, + ); + expect( + notificationRouteForPayloadString(jsonEncode({'type': 'announcement'})), + isNull, + ); + }); + }); + + group('notificationRouteForData', () { + test('routes background message data with the same notification rules', () { + expect( + notificationRouteForData(const { + 'type': 'schedule_alarm', + 'scheduleId': 'schedule-2', + }), + const NotificationRouteTarget( + '/scheduleStart', + extra: {'type': 'schedule_alarm', 'scheduleId': 'schedule-2'}, + ), + ); + expect( + notificationRouteForData(const {'type': 'preparation_step'}), + const NotificationRouteTarget('/alarmScreen'), + ); + expect(notificationRouteForData(const {'type': 'chat'}), isNull); + }); + }); +} diff --git a/test/core/services/notification_service_test.dart b/test/core/services/notification_service_test.dart new file mode 100644 index 00000000..1f93b9bd --- /dev/null +++ b/test/core/services/notification_service_test.dart @@ -0,0 +1,690 @@ +import 'dart:async'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/core/services/navigation_service.dart'; +import 'package:on_time_front/core/services/notification_service.dart'; +import 'package:on_time_front/data/data_sources/notification_remote_data_source.dart'; +import 'package:on_time_front/data/models/fcm_token_register_request_model.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/repositories/alarm_repository.dart'; +import 'package:timezone/timezone.dart' as tz; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + await getIt.reset(); + }); + + tearDown(() async { + await getIt.reset(); + }); + + test( + 'hasNotificationPermission accepts authorized and provisional states', + () async { + final messaging = _FakeFirebaseMessaging(AuthorizationStatus.authorized); + final localNotifications = _RecordingLocalNotifications(); + + final service = NotificationService.test( + messaging: messaging, + localNotifications: localNotifications, + isFlutterLocalNotificationsInitialized: true, + ); + + expect(await service.hasNotificationPermission(), isTrue); + + messaging.authorizationStatus = AuthorizationStatus.provisional; + expect(await service.hasNotificationPermission(), isTrue); + + messaging.authorizationStatus = AuthorizationStatus.denied; + expect(await service.hasNotificationPermission(), isFalse); + }, + ); + + test('requestPermission delegates to messaging on mobile targets', () async { + final messaging = _FakeFirebaseMessaging(AuthorizationStatus.notDetermined) + ..requestedAuthorizationStatus = AuthorizationStatus.authorized; + final service = NotificationService.test( + messaging: messaging, + localNotifications: _RecordingLocalNotifications(), + isFlutterLocalNotificationsInitialized: true, + ); + + expect(await service.requestPermission(), AuthorizationStatus.authorized); + expect(messaging.requestPermissionCount, 1); + }); + + test( + 'initialize requests permission, sets up local notifications, and routes initial messages', + () async { + final messaging = + _FakeFirebaseMessaging(AuthorizationStatus.notDetermined) + ..requestedAuthorizationStatus = AuthorizationStatus.authorized + ..initialMessage = const RemoteMessage( + data: {'type': 'preparation_step', 'scheduleId': 'schedule-1'}, + ); + final localNotifications = _RecordingLocalNotifications(); + final navigationService = _FakeNavigationService(); + const firebaseMessagingChannel = MethodChannel( + 'plugins.flutter.io/firebase_messaging', + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + firebaseMessagingChannel, + (_) async => null, + ); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(firebaseMessagingChannel, null); + }); + getIt.registerSingleton(navigationService); + final service = NotificationService.test( + messaging: messaging, + localNotifications: localNotifications, + ); + + await service.initialize(); + + expect(messaging.requestPermissionCount, 1); + expect(messaging.getTokenCount, 1); + expect(messaging.getInitialMessageCount, 1); + expect(localNotifications.initializeCount, 1); + expect(navigationService.pushedRoutes, ['/alarmScreen']); + }, + ); + + test( + 'openNotificationSettings returns false when platform launch fails', + () async { + final service = NotificationService.test( + messaging: _FakeFirebaseMessaging(AuthorizationStatus.denied), + localNotifications: _RecordingLocalNotifications(), + isFlutterLocalNotificationsInitialized: true, + ); + + expect(await service.openNotificationSettings(), isFalse); + }, + ); + + test('requestNotificationToken tolerates missing FCM token', () async { + final messaging = _FakeFirebaseMessaging(AuthorizationStatus.authorized); + final service = NotificationService.test( + messaging: messaging, + localNotifications: _RecordingLocalNotifications(), + isFlutterLocalNotificationsInitialized: true, + ); + + await service.requestNotificationToken(); + + expect(messaging.getTokenCount, 1); + expect(messaging.tokenRefreshListened, isTrue); + }); + + test('requestNotificationToken registers FCM token with device id', () async { + final messaging = _FakeFirebaseMessaging(AuthorizationStatus.authorized) + ..token = 'fcm-token'; + final remoteDataSource = _FakeNotificationRemoteDataSource(); + getIt + ..registerSingleton(_FakeAlarmRepository()) + ..registerSingleton(remoteDataSource); + final service = NotificationService.test( + messaging: messaging, + localNotifications: _RecordingLocalNotifications(), + isFlutterLocalNotificationsInitialized: true, + ); + + await service.requestNotificationToken(); + + expect(remoteDataSource.registeredTokens.single.firebaseToken, 'fcm-token'); + expect(remoteDataSource.registeredTokens.single.deviceId, 'device-1'); + }); + + test( + 'token refreshes register the refreshed token for this device', + () async { + final messaging = _FakeFirebaseMessaging(AuthorizationStatus.authorized) + ..token = 'initial-token'; + final remoteDataSource = _FakeNotificationRemoteDataSource(); + getIt + ..registerSingleton(_FakeAlarmRepository()) + ..registerSingleton(remoteDataSource); + final service = NotificationService.test( + messaging: messaging, + localNotifications: _RecordingLocalNotifications(), + isFlutterLocalNotificationsInitialized: true, + ); + + await service.requestNotificationToken(); + messaging.emitTokenRefresh('refreshed-token'); + await pumpEventQueue(); + + expect( + remoteDataSource.registeredTokens.map((token) => token.firebaseToken), + ['initial-token', 'refreshed-token'], + ); + }, + ); + + test( + 'setupFlutterNotifications initializes local notifications once', + () async { + final localNotifications = _RecordingLocalNotifications(); + final service = NotificationService.test( + messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), + localNotifications: localNotifications, + ); + + await service.setupFlutterNotifications(); + await service.setupFlutterNotifications(); + + expect(localNotifications.initializeCount, 1); + }, + ); + + test( + 'local notification taps route decoded payloads through navigation service', + () async { + final localNotifications = _RecordingLocalNotifications(); + final navigationService = _FakeNavigationService(); + getIt.registerSingleton(navigationService); + final service = NotificationService.test( + messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), + localNotifications: localNotifications, + ); + + await service.setupFlutterNotifications(); + localNotifications.tapPayload( + '{"type":"preparation_step","scheduleId":"schedule-1"}', + ); + localNotifications.tapPayload('not-json'); + + expect(navigationService.pushedRoutes, ['/alarmScreen']); + }, + ); + + test('showLocalNotification displays encoded non-alarm payloads', () async { + final localNotifications = _RecordingLocalNotifications(); + final service = NotificationService.test( + messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), + localNotifications: localNotifications, + isFlutterLocalNotificationsInitialized: true, + ); + + await service.showLocalNotification( + title: 'Reminder', + body: 'Leave soon', + payload: const {'type': 'info', 'scheduleId': 'schedule-1'}, + ); + + expect(localNotifications.shown, hasLength(1)); + expect(localNotifications.shown.single.title, 'Reminder'); + expect(localNotifications.shown.single.body, 'Leave soon'); + expect(localNotifications.shown.single.payload, contains('schedule-1')); + }); + + test('showLocalNotification suppresses schedule alarm payloads', () async { + final localNotifications = _RecordingLocalNotifications(); + final service = NotificationService.test( + messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), + localNotifications: localNotifications, + isFlutterLocalNotificationsInitialized: true, + ); + + await service.showLocalNotification( + title: 'Alarm', + body: 'Start preparing', + payload: const {'type': 'schedule_alarm'}, + ); + + expect(localNotifications.shown, isEmpty); + }); + + test('showLocalNotification ignores setup and display failures', () async { + final setupFailureNotifications = _RecordingLocalNotifications() + ..throwOnInitialize = true; + final setupFailureService = NotificationService.test( + messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), + localNotifications: setupFailureNotifications, + ); + + await setupFailureService.showLocalNotification( + title: 'Reminder', + body: 'Leave soon', + ); + + expect(setupFailureNotifications.shown, isEmpty); + + final displayFailureNotifications = _RecordingLocalNotifications() + ..throwOnShow = true; + final displayFailureService = NotificationService.test( + messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), + localNotifications: displayFailureNotifications, + isFlutterLocalNotificationsInitialized: true, + ); + + await displayFailureService.showLocalNotification( + title: 'Reminder', + body: 'Leave soon', + ); + + expect(displayFailureNotifications.showAttempts, 1); + expect(displayFailureNotifications.shown, isEmpty); + }); + + test( + 'preparation step notifications include schedule and step payload', + () async { + final localNotifications = _RecordingLocalNotifications(); + final service = NotificationService.test( + messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), + localNotifications: localNotifications, + isFlutterLocalNotificationsInitialized: true, + ); + + await service.showPreparationStepNotification( + scheduleName: 'Morning meeting', + preparationName: 'Pack', + scheduleId: 'schedule-1', + stepId: 'step-1', + ); + + expect(localNotifications.shown, hasLength(1)); + expect(localNotifications.shown.single.title, contains('Pack')); + expect(localNotifications.shown.single.payload, contains('schedule-1')); + expect(localNotifications.shown.single.payload, contains('step-1')); + }, + ); + + test('preparation step notifications are skipped in foreground', () async { + final localNotifications = _RecordingLocalNotifications(); + final service = NotificationService.test( + messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), + localNotifications: localNotifications, + isFlutterLocalNotificationsInitialized: true, + ); + + TestWidgetsFlutterBinding.instance.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await service.showPreparationStepNotification( + scheduleName: 'Morning meeting', + preparationName: 'Pack', + scheduleId: 'schedule-1', + stepId: 'step-1', + ); + + expect(localNotifications.shown, isEmpty); + }); + + test( + 'remote notifications prefer displayable content and skip alarm pushes', + () async { + final localNotifications = _RecordingLocalNotifications(); + final service = NotificationService.test( + messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), + localNotifications: localNotifications, + isFlutterLocalNotificationsInitialized: true, + ); + + await service.showNotification( + const RemoteMessage( + data: { + 'title': 'Backend title', + 'body': 'Backend body', + 'route': '/calendar', + }, + ), + ); + await service.showNotification( + const RemoteMessage(data: {'type': 'schedule_alarm'}), + ); + await service.showNotification(const RemoteMessage(data: {})); + + expect(localNotifications.shown, hasLength(1)); + expect(localNotifications.shown.single.title, 'Backend title'); + expect(localNotifications.shown.single.body, 'Backend body'); + }, + ); + + test('remote notifications ignore setup and display failures', () async { + final setupFailureNotifications = _RecordingLocalNotifications() + ..throwOnInitialize = true; + final setupFailureService = NotificationService.test( + messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), + localNotifications: setupFailureNotifications, + ); + + await setupFailureService.showNotification( + const RemoteMessage(data: {'title': 'Title', 'body': 'Body'}), + ); + + expect(setupFailureNotifications.shown, isEmpty); + + final displayFailureNotifications = _RecordingLocalNotifications() + ..throwOnShow = true; + final displayFailureService = NotificationService.test( + messaging: _FakeFirebaseMessaging(AuthorizationStatus.authorized), + localNotifications: displayFailureNotifications, + isFlutterLocalNotificationsInitialized: true, + ); + + await displayFailureService.showNotification( + const RemoteMessage(data: {'title': 'Title', 'body': 'Body'}), + ); + + expect(displayFailureNotifications.showAttempts, 1); + expect(displayFailureNotifications.shown, isEmpty); + }); + + test('fallback alarm scheduling requires notification permission', () async { + final messaging = _FakeFirebaseMessaging(AuthorizationStatus.denied); + final service = NotificationService.test( + messaging: messaging, + localNotifications: _RecordingLocalNotifications(), + isFlutterLocalNotificationsInitialized: true, + ); + + await expectLater( + service.scheduleFallbackAlarm(_record()), + throwsA( + isA().having( + (error) => error.permissionIssue, + 'permissionIssue', + AlarmPermissionIssue.notificationPermissionDenied, + ), + ), + ); + }); + + test( + 'fallback alarms schedule and cancel by stable notification id', + () async { + final messaging = _FakeFirebaseMessaging(AuthorizationStatus.authorized); + final localNotifications = _RecordingLocalNotifications(); + final service = NotificationService.test( + messaging: messaging, + localNotifications: localNotifications, + localeProvider: () => 'en', + isFlutterLocalNotificationsInitialized: true, + ); + final record = _record(fallbackNotificationId: null); + + await service.scheduleFallbackAlarm(record); + await service.cancelFallbackNotification( + stableAlarmId(record.scheduleId), + ); + + expect(localNotifications.scheduled, hasLength(1)); + expect( + localNotifications.scheduled.single.id, + stableAlarmId('schedule-1'), + ); + expect(localNotifications.scheduled.single.title, 'Morning meeting'); + expect( + localNotifications.scheduled.single.body, + contains('time to get ready'), + ); + expect(localNotifications.cancelledIds, [stableAlarmId('schedule-1')]); + }, + ); +} + +NotificationSettings _settings(AuthorizationStatus status) { + return NotificationSettings( + alert: AppleNotificationSetting.enabled, + announcement: AppleNotificationSetting.disabled, + authorizationStatus: status, + badge: AppleNotificationSetting.enabled, + carPlay: AppleNotificationSetting.disabled, + lockScreen: AppleNotificationSetting.enabled, + notificationCenter: AppleNotificationSetting.enabled, + showPreviews: AppleShowPreviewSetting.always, + timeSensitive: AppleNotificationSetting.disabled, + criticalAlert: AppleNotificationSetting.disabled, + sound: AppleNotificationSetting.enabled, + providesAppNotificationSettings: AppleNotificationSetting.disabled, + ); +} + +ScheduledAlarmRecord _record({int? fallbackNotificationId = 42}) { + return ScheduledAlarmRecord( + scheduleId: 'schedule-1', + alarmTime: DateTime.utc(2026, 5, 15, 8), + preparationStartTime: DateTime.utc(2026, 5, 15, 8, 5), + scheduleFingerprint: 'fingerprint', + provider: AlarmProvider.localNotification, + scheduleTitle: 'Morning meeting', + payload: const {'type': 'schedule_alarm', 'scheduleId': 'schedule-1'}, + fallbackNotificationId: fallbackNotificationId, + ); +} + +class _FakeFirebaseMessaging implements FirebaseMessaging { + _FakeFirebaseMessaging(this.authorizationStatus); + + final _tokenRefreshController = StreamController.broadcast(); + AuthorizationStatus authorizationStatus; + AuthorizationStatus requestedAuthorizationStatus = + AuthorizationStatus.authorized; + int requestPermissionCount = 0; + int getTokenCount = 0; + int getInitialMessageCount = 0; + bool tokenRefreshListened = false; + String? token; + RemoteMessage? initialMessage; + + @override + Future getNotificationSettings() async { + return _settings(authorizationStatus); + } + + @override + Future requestPermission({ + bool alert = true, + bool announcement = false, + bool badge = true, + bool carPlay = false, + bool criticalAlert = false, + bool provisional = false, + bool sound = true, + bool providesAppNotificationSettings = false, + }) async { + requestPermissionCount += 1; + authorizationStatus = requestedAuthorizationStatus; + return _settings(authorizationStatus); + } + + @override + Future getToken({String? vapidKey}) async { + getTokenCount += 1; + return token; + } + + @override + Future getInitialMessage() async { + getInitialMessageCount += 1; + return initialMessage; + } + + @override + Stream get onTokenRefresh { + tokenRefreshListened = true; + return _tokenRefreshController.stream; + } + + void emitTokenRefresh(String token) { + _tokenRefreshController.add(token); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _ShownNotification { + const _ShownNotification(this.title, this.body, this.payload); + + final String? title; + final String? body; + final String? payload; +} + +class _ScheduledNotification { + const _ScheduledNotification(this.id, this.title, this.body); + + final int id; + final String? title; + final String? body; +} + +class _RecordingLocalNotifications implements FlutterLocalNotificationsPlugin { + final shown = <_ShownNotification>[]; + final scheduled = <_ScheduledNotification>[]; + final cancelledIds = []; + int initializeCount = 0; + int showAttempts = 0; + bool throwOnInitialize = false; + bool throwOnShow = false; + DidReceiveNotificationResponseCallback? notificationResponseCallback; + + @override + T? resolvePlatformSpecificImplementation< + T extends FlutterLocalNotificationsPlatform + >() { + return null; + } + + @override + Future initialize({ + required InitializationSettings settings, + DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, + DidReceiveBackgroundNotificationResponseCallback? + onDidReceiveBackgroundNotificationResponse, + }) async { + if (throwOnInitialize) { + throw Exception('initialize failed'); + } + notificationResponseCallback = onDidReceiveNotificationResponse; + initializeCount += 1; + return true; + } + + void tapPayload(String? payload) { + notificationResponseCallback?.call( + NotificationResponse( + notificationResponseType: NotificationResponseType.selectedNotification, + payload: payload, + ), + ); + } + + @override + Future show({ + required int id, + String? title, + String? body, + NotificationDetails? notificationDetails, + String? payload, + }) async { + showAttempts += 1; + if (throwOnShow) { + throw Exception('show failed'); + } + shown.add(_ShownNotification(title, body, payload)); + } + + @override + Future zonedSchedule({ + required int id, + required tz.TZDateTime scheduledDate, + required NotificationDetails notificationDetails, + required AndroidScheduleMode androidScheduleMode, + String? title, + String? body, + String? payload, + DateTimeComponents? matchDateTimeComponents, + }) async { + scheduled.add(_ScheduledNotification(id, title, body)); + } + + @override + Future cancel({required int id, String? tag}) async { + cancelledIds.add(id); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeNavigationService implements NavigationService { + final pushedRoutes = []; + final pushedExtras = []; + + @override + void push(String routeName, {Object? extra}) { + pushedRoutes.add(routeName); + pushedExtras.add(extra); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeAlarmRepository implements AlarmRepository { + @override + Future getDeviceId() async => 'device-1'; + + @override + Future buildCurrentDeviceInfo() { + throw UnimplementedError(); + } + + @override + Future getAlarmSettings() { + throw UnimplementedError(); + } + + @override + Future updateAlarmSettings({required bool alarmsEnabled}) { + throw UnimplementedError(); + } + + @override + Future registerCurrentDevice(AlarmDeviceInfo deviceInfo) { + throw UnimplementedError(); + } + + @override + Future unregisterCurrentDevice(String deviceId) { + throw UnimplementedError(); + } + + @override + Future> getAlarmWindow( + DateTime startDate, + DateTime endDate, + ) { + throw UnimplementedError(); + } + + @override + Future postAlarmStatus(AlarmStatusReport report) { + throw UnimplementedError(); + } +} + +class _FakeNotificationRemoteDataSource + implements NotificationRemoteDataSource { + final registeredTokens = []; + + @override + Future fcmTokenRegister(FcmTokenRegisterRequestModel model) async { + registeredTokens.add(model); + } +} diff --git a/test/core/validation/backend_constraints_test.dart b/test/core/validation/backend_constraints_test.dart index 484ea642..0ffe5fd5 100644 --- a/test/core/validation/backend_constraints_test.dart +++ b/test/core/validation/backend_constraints_test.dart @@ -5,6 +5,7 @@ void main() { group('PasswordPolicy', () { test('accepts 8-64 chars with letter number and special character', () { expect(PasswordPolicy.validate('Password1!'), isNull); + expect(PasswordPolicy.isValid('Password1!'), isTrue); expect(PasswordPolicy.validate('A1!aaaaa'), isNull); expect(PasswordPolicy.validate('${'A' * 62}1!'), isNull); }); @@ -43,4 +44,9 @@ void main() { isFalse, ); }); + + test('trimToMaxLength trims whitespace before enforcing backend limit', () { + expect(BackendConstraints.trimToMaxLength(' hello ', 10), 'hello'); + expect(BackendConstraints.trimToMaxLength(' hello world ', 5), 'hello'); + }); } diff --git a/test/data/daos/preparation_schedule_dao_test.dart b/test/data/daos/preparation_schedule_dao_test.dart index 644da12c..95e1eb3d 100644 --- a/test/data/daos/preparation_schedule_dao_test.dart +++ b/test/data/daos/preparation_schedule_dao_test.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart' as drift; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/daos/preparation_schedule_dao.dart'; import 'package:on_time_front/data/daos/preparation_user_dao.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; @@ -10,9 +11,12 @@ import 'package:uuid/uuid.dart'; void main() { late AppDatabase appDatabase; late PreparationUserDao userDao; + late PreparationScheduleDao schedulePreparationDao; final uuid = Uuid(); final userId = uuid.v7(); + final placeId = uuid.v7(); + final scheduleId = uuid.v7(); final preparationStep1 = PreparationStepEntity( id: uuid.v7(), @@ -36,9 +40,12 @@ void main() { appDatabase = AppDatabase.forTesting(NativeDatabase.memory()); await appDatabase.customStatement('PRAGMA foreign_keys = ON'); userDao = PreparationUserDao(appDatabase); + schedulePreparationDao = PreparationScheduleDao(appDatabase); // `Users` 테이블에 데이터 삽입 - await appDatabase.into(appDatabase.users).insert( + await appDatabase + .into(appDatabase.users) + .insert( UsersCompanion( id: drift.Value(userId), email: drift.Value('testuser@example.com'), @@ -48,6 +55,32 @@ void main() { score: drift.Value(100), ), ); + + await appDatabase + .into(appDatabase.places) + .insert( + PlacesCompanion( + id: drift.Value(placeId), + placeName: const drift.Value('Office'), + ), + ); + + await appDatabase + .into(appDatabase.schedules) + .insert( + SchedulesCompanion( + id: drift.Value(scheduleId), + placeId: drift.Value(placeId), + scheduleName: const drift.Value('Morning meeting'), + scheduleTime: drift.Value(DateTime(2026, 5, 15, 9)), + moveTime: const drift.Value(Duration(minutes: 20)), + isChanged: const drift.Value(false), + isStarted: const drift.Value(false), + scheduleSpareTime: const drift.Value(Duration(minutes: 10)), + scheduleNote: const drift.Value('Bring notes'), + latenessTime: const drift.Value(0), + ), + ); }); tearDown(() async { @@ -55,20 +88,23 @@ void main() { }); group('createPreparationUser', () { - test('should insert preparation steps and link them as a linked list', - () async { - // Act - await userDao.createPreparationUser(preparationEntity, userId); + test( + 'should insert preparation steps and link them as a linked list', + () async { + // Act + await userDao.createPreparationUser(preparationEntity, userId); - // Assert - final result = - await appDatabase.select(appDatabase.preparationUsers).get(); - expect(result.length, preparationEntity.preparationStepList.length); + // Assert + final result = await appDatabase + .select(appDatabase.preparationUsers) + .get(); + expect(result.length, preparationEntity.preparationStepList.length); - // Linked List 검증 - expect(result.first.nextPreparationId, result[1].id); - expect(result[1].nextPreparationId, isNull); - }); + // Linked List 검증 + expect(result.first.nextPreparationId, result[1].id); + expect(result[1].nextPreparationId, isNull); + }, + ); }); group('getPreparationUsersByUserId', () { @@ -80,12 +116,16 @@ void main() { final result = await userDao.getPreparationUsersByUserId(userId); // Assert - expect(result.preparationStepList.length, - preparationEntity.preparationStepList.length); + expect( + result.preparationStepList.length, + preparationEntity.preparationStepList.length, + ); // Linked List 검증 - expect(result.preparationStepList.first.nextPreparationId, - result.preparationStepList[1].id); + expect( + result.preparationStepList.first.nextPreparationId, + result.preparationStepList[1].id, + ); expect(result.preparationStepList[1].nextPreparationId, isNull); }); }); @@ -124,9 +164,130 @@ void main() { // Assert final result = await userDao.getPreparationUsersByUserId(userId); expect( - result.preparationStepList.first.preparationName, 'Updated Step 1'); - expect(result.preparationStepList.first.preparationTime, - Duration(minutes: 15)); + result.preparationStepList.first.preparationName, + 'Updated Step 1', + ); + expect( + result.preparationStepList.first.preparationTime, + Duration(minutes: 15), + ); }); }); + + group('schedule preparations', () { + test( + 'createPreparationSchedule stores steps as an ordered linked list', + () async { + await schedulePreparationDao.createPreparationSchedule( + preparationEntity, + scheduleId, + ); + + final result = await schedulePreparationDao + .getPreparationSchedulesByScheduleId(scheduleId); + + expect(result.preparationStepList, hasLength(2)); + expect(result.preparationStepList[0].id, preparationStep1.id); + expect( + result.preparationStepList[0].nextPreparationId, + preparationStep2.id, + ); + expect(result.preparationStepList[1].id, preparationStep2.id); + expect(result.preparationStepList[1].nextPreparationId, isNull); + }, + ); + + test( + 'getPreparationSchedulesByScheduleId returns empty for no steps', + () async { + final result = await schedulePreparationDao + .getPreparationSchedulesByScheduleId(scheduleId); + + expect(result.preparationStepList, isEmpty); + }, + ); + + test('getPreparationStepById returns the stored step contract', () async { + await schedulePreparationDao.createPreparationSchedule( + preparationEntity, + scheduleId, + ); + + final result = await schedulePreparationDao.getPreparationStepById( + preparationStep1.id, + ); + + expect(result.id, preparationStep1.id); + expect(result.preparationName, preparationStep1.preparationName); + expect(result.preparationTime, preparationStep1.preparationTime); + expect(result.nextPreparationId, preparationStep2.id); + }); + + test('getPreparationStepById throws for a missing step', () async { + await expectLater( + schedulePreparationDao.getPreparationStepById('missing-step'), + throwsException, + ); + }); + + test( + 'updatePreparationSchedule changes name time and next pointer', + () async { + await schedulePreparationDao.createPreparationSchedule( + preparationEntity, + scheduleId, + ); + + await schedulePreparationDao.updatePreparationSchedule( + preparationStep1.copyWith( + preparationName: 'Updated wake up', + preparationTime: const Duration(minutes: 12), + nextPreparationId: null, + ), + scheduleId, + ); + + final result = await schedulePreparationDao.getPreparationStepById( + preparationStep1.id, + ); + + expect(result.preparationName, 'Updated wake up'); + expect(result.preparationTime, const Duration(minutes: 12)); + expect(result.nextPreparationId, isNull); + }, + ); + + test( + 'deletePreparationSchedule removes middle step and relinks neighbors', + () async { + final middleStep = PreparationStepEntity( + id: uuid.v7(), + preparationName: 'Step 1.5: Coffee', + preparationTime: const Duration(minutes: 7), + nextPreparationId: null, + ); + final threeSteps = PreparationEntity( + preparationStepList: [preparationStep1, middleStep, preparationStep2], + ); + await schedulePreparationDao.createPreparationSchedule( + threeSteps, + scheduleId, + ); + + final result = await schedulePreparationDao.deletePreparationSchedule( + middleStep.id, + ); + + expect(result.preparationStepList.map((step) => step.id), [ + preparationStep1.id, + preparationStep2.id, + ]); + expect( + result.preparationStepList.first.nextPreparationId, + preparationStep2.id, + ); + expect(result.preparationStepList.last.nextPreparationId, isNull); + }, + ); + }); } diff --git a/test/data/daos/preparation_user_dao_test.dart b/test/data/daos/preparation_user_dao_test.dart index cefc03cb..056c06a1 100644 --- a/test/data/daos/preparation_user_dao_test.dart +++ b/test/data/daos/preparation_user_dao_test.dart @@ -39,7 +39,9 @@ void main() { userDao = PreparationUserDao(appDatabase); // `Users` 테이블에 데이터 삽입 - await appDatabase.into(appDatabase.users).insert( + await appDatabase + .into(appDatabase.users) + .insert( UsersCompanion( id: drift.Value(userId), email: drift.Value('testuser@example.com'), @@ -51,7 +53,9 @@ void main() { ); // `Places` 테이블에 데이터 삽입 - await appDatabase.into(appDatabase.places).insert( + await appDatabase + .into(appDatabase.places) + .insert( PlacesCompanion( id: drift.Value(placeId), placeName: drift.Value('Test Place'), @@ -59,7 +63,9 @@ void main() { ); // `Schedules` 테이블에 필수 데이터 삽입 - await appDatabase.into(appDatabase.schedules).insert( + await appDatabase + .into(appDatabase.schedules) + .insert( SchedulesCompanion( id: drift.Value(uuid.v7()), placeId: drift.Value(placeId), @@ -80,23 +86,35 @@ void main() { }); group('createPreparationUser', () { - test('should insert preparation steps and link them as a linked list', - () async { - // Act - await userDao.createPreparationUser(preparationEntity, userId); - - // Assert - final result = - await appDatabase.select(appDatabase.preparationUsers).get(); - expect(result.length, preparationEntity.preparationStepList.length); - - // Linked List 검증 - expect(result.first.nextPreparationId, result[1].id); - expect(result[1].nextPreparationId, isNull); - }); + test( + 'should insert preparation steps and link them as a linked list', + () async { + // Act + await userDao.createPreparationUser(preparationEntity, userId); + + // Assert + final result = await appDatabase + .select(appDatabase.preparationUsers) + .get(); + expect(result.length, preparationEntity.preparationStepList.length); + + // Linked List 검증 + expect(result.first.nextPreparationId, result[1].id); + expect(result[1].nextPreparationId, isNull); + }, + ); }); group('getPreparationUsersByUserId', () { + test( + 'should return an empty preparation list when user has no steps', + () async { + final result = await userDao.getPreparationUsersByUserId(userId); + + expect(result.preparationStepList, isEmpty); + }, + ); + test('should return ordered preparation steps for a given user', () async { // Arrange await userDao.createPreparationUser(preparationEntity, userId); @@ -105,16 +123,40 @@ void main() { final result = await userDao.getPreparationUsersByUserId(userId); // Assert - expect(result.preparationStepList.length, - preparationEntity.preparationStepList.length); + expect( + result.preparationStepList.length, + preparationEntity.preparationStepList.length, + ); // Linked List 검증 - expect(result.preparationStepList.first.nextPreparationId, - result.preparationStepList[1].id); + expect( + result.preparationStepList.first.nextPreparationId, + result.preparationStepList[1].id, + ); expect(result.preparationStepList[1].nextPreparationId, isNull); }); }); + group('getPreparationStepById', () { + test('should return one preparation step by id', () async { + await userDao.createPreparationUser(preparationEntity, userId); + + final result = await userDao.getPreparationStepById(preparationStep1.id); + + expect(result.id, preparationStep1.id); + expect(result.preparationName, preparationStep1.preparationName); + expect(result.preparationTime, preparationStep1.preparationTime); + expect(result.nextPreparationId, preparationStep2.id); + }); + + test('should throw when the preparation step does not exist', () async { + await expectLater( + userDao.getPreparationStepById(uuid.v7()), + throwsException, + ); + }); + }); + group('deletePreparationUser', () { test('should delete a preparation step and relink the list', () async { // Arrange @@ -149,9 +191,13 @@ void main() { // Assert final result = await userDao.getPreparationUsersByUserId(userId); expect( - result.preparationStepList.first.preparationName, 'Updated Step 1'); - expect(result.preparationStepList.first.preparationTime, - Duration(minutes: 15)); + result.preparationStepList.first.preparationName, + 'Updated Step 1', + ); + expect( + result.preparationStepList.first.preparationTime, + Duration(minutes: 15), + ); }); }); } diff --git a/test/data/daos/user_dao_test.dart b/test/data/daos/user_dao_test.dart new file mode 100644 index 00000000..e56861ef --- /dev/null +++ b/test/data/daos/user_dao_test.dart @@ -0,0 +1,59 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/daos/user_dao.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; + +void main() { + late AppDatabase database; + late UserDao dao; + + setUp(() { + database = AppDatabase.forTesting(NativeDatabase.memory()); + dao = UserDao(database); + }); + + tearDown(() async { + await database.close(); + }); + + test('createUser persists the user profile fields', () async { + await dao.createUser(_user); + + final saved = await dao.getUserById('user-1'); + + expect(saved, _user); + }); + + test('getUserById returns null for an unknown user', () async { + expect(await dao.getUserById('missing-user'), isNull); + }); + + test('getAllUsers returns all persisted users as domain entities', () async { + const secondUser = UserEntity( + id: 'user-2', + email: 'second@example.com', + name: 'Second User', + spareTime: Duration(minutes: 20), + note: 'second note', + score: 3.5, + ); + + await dao.createUser(_user); + await dao.createUser(secondUser); + + final users = await dao.getAllUsers(); + + expect(users, containsAll([_user, secondUser])); + expect(users, hasLength(2)); + }); +} + +const _user = UserEntity( + id: 'user-1', + email: 'user@example.com', + name: 'Test User', + spareTime: Duration(minutes: 15), + note: 'note', + score: 4.5, +); diff --git a/test/data/data_sources/alarm_registry_local_data_source_test.dart b/test/data/data_sources/alarm_registry_local_data_source_test.dart new file mode 100644 index 00000000..34ad961b --- /dev/null +++ b/test/data/data_sources/alarm_registry_local_data_source_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/data/data_sources/alarm_registry_local_data_source.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + late AlarmRegistryLocalDataSourceImpl dataSource; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + dataSource = AlarmRegistryLocalDataSourceImpl(); + }); + + test( + 'loadAll returns empty for missing, empty, and corrupt storage', + () async { + expect(await dataSource.loadAll(), isEmpty); + + SharedPreferences.setMockInitialValues({'scheduled_alarm_registry': ''}); + expect(await AlarmRegistryLocalDataSourceImpl().loadAll(), isEmpty); + + SharedPreferences.setMockInitialValues({ + 'scheduled_alarm_registry': 'not json', + }); + expect(await AlarmRegistryLocalDataSourceImpl().loadAll(), isEmpty); + }, + ); + + test( + 'replaceAll persists records and removes the key when cleared', + () async { + final record = _record('schedule-1'); + + await dataSource.replaceAll([record]); + + expect(await dataSource.loadAll(), [record]); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('scheduled_alarm_registry'), isNotNull); + + await dataSource.replaceAll(const []); + + expect(await dataSource.loadAll(), isEmpty); + expect(prefs.getString('scheduled_alarm_registry'), isNull); + }, + ); +} + +ScheduledAlarmRecord _record(String scheduleId) { + return ScheduledAlarmRecord( + scheduleId: scheduleId, + alarmTime: DateTime(2026, 5, 15, 8), + preparationStartTime: DateTime(2026, 5, 15, 8, 5), + scheduleFingerprint: 'fingerprint-$scheduleId', + nativeAlarmId: 42, + fallbackNotificationId: 43, + provider: AlarmProvider.androidAlarmManager, + scheduleTitle: 'Meeting', + payload: {'type': 'schedule_alarm', 'scheduleId': scheduleId}, + ); +} diff --git a/test/data/data_sources/alarm_remote_data_source_test.dart b/test/data/data_sources/alarm_remote_data_source_test.dart index 5fe4fa9a..53293260 100644 --- a/test/data/data_sources/alarm_remote_data_source_test.dart +++ b/test/data/data_sources/alarm_remote_data_source_test.dart @@ -16,91 +16,349 @@ void main() { remoteDataSource = AlarmRemoteDataSourceImpl(dio); }); - group('postAlarmStatus', () { - test('posts lower-camel backend contract without retry on success', - () async { + test('getAlarmSettings maps backend settings response', () async { + when(dio.get(Endpoint.alarmSettings)).thenAnswer( + (_) async => Response( + statusCode: 200, + data: { + 'data': { + 'alarmsEnabled': true, + 'defaultAlarmOffsetMinutes': 8, + 'updatedAt': '2026-05-05T09:00:00.000', + }, + }, + requestOptions: RequestOptions(path: Endpoint.alarmSettings), + ), + ); + + final settings = await remoteDataSource.getAlarmSettings(); + + expect(settings.alarmsEnabled, isTrue); + expect(settings.defaultAlarmOffsetMinutes, 8); + expect(settings.alarmOffset, const Duration(minutes: 8)); + }); + + test( + 'updateAlarmSettings patches the enabled flag and returns settings', + () async { when( - dio.post( - Endpoint.alarmStatus, - data: anyNamed('data'), - options: anyNamed('options'), - ), + dio.patch(Endpoint.alarmSettings, data: anyNamed('data')), ).thenAnswer( (_) async => Response( statusCode: 200, - requestOptions: RequestOptions(path: Endpoint.alarmStatus), + data: { + 'data': {'alarmsEnabled': false, 'defaultAlarmOffsetMinutes': 5}, + }, + requestOptions: RequestOptions(path: Endpoint.alarmSettings), ), ); - await remoteDataSource.postAlarmStatus(_statusReport()); + final settings = await remoteDataSource.updateAlarmSettings( + alarmsEnabled: false, + ); - final verification = verify( - dio.post( - Endpoint.alarmStatus, - data: captureAnyNamed('data'), - options: captureAnyNamed('options'), - ), - )..called(1); - final data = verification.captured[0] as Map; - final options = verification.captured[1] as Options; - - expect(options.validateStatus!(400), isTrue); - expect(data.containsKey('permissionIssue'), isFalse); - expect(data['reconciledAt'], '2026-05-05T09:00:00.000Z'); - expect(data['status'], 'armed'); - expect(data['nativeAlarmProvider'], 'iosAlarmKit'); - expect(data['fallbackProvider'], 'localNotification'); + final data = + verify( + dio.patch( + Endpoint.alarmSettings, + data: captureAnyNamed('data'), + ), + ).captured.single + as Map; + expect(data, {'alarmsEnabled': false}); + expect(settings.alarmsEnabled, isFalse); + }, + ); + + test('registerCurrentDevice posts device capability contract', () async { + when( + dio.put(Endpoint.currentDevice, data: anyNamed('data')), + ).thenAnswer( + (_) async => Response( + statusCode: 200, + requestOptions: RequestOptions(path: Endpoint.currentDevice), + ), + ); + + await remoteDataSource.registerCurrentDevice( + const AlarmDeviceInfo( + deviceId: 'device-1', + platform: 'android', + appVersion: '1.0.0', + osVersion: 'android-35', + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + ), + ); + + final data = + verify( + dio.put( + Endpoint.currentDevice, + data: captureAnyNamed('data'), + ), + ).captured.single + as Map; + expect(data['deviceId'], 'device-1'); + expect(data['nativeAlarmProvider'], 'androidAlarmManager'); + expect(data['fallbackProvider'], 'localNotification'); + }); + + test('unregisterCurrentDevice deletes the current device by id', () async { + when( + dio.delete(Endpoint.currentDevice, data: anyNamed('data')), + ).thenAnswer( + (_) async => Response( + statusCode: 200, + requestOptions: RequestOptions(path: Endpoint.currentDevice), + ), + ); + + await remoteDataSource.unregisterCurrentDevice('device-1'); + + final data = + verify( + dio.delete( + Endpoint.currentDevice, + data: captureAnyNamed('data'), + ), + ).captured.single + as Map; + expect(data, {'deviceId': 'device-1'}); + }); + + test('getAlarmWindow queries ISO range and maps schedules', () async { + final start = DateTime.utc(2026, 5, 5, 9); + final end = start.add(const Duration(days: 7)); + when( + dio.get( + Endpoint.alarmWindow, + queryParameters: anyNamed('queryParameters'), + ), + ).thenAnswer( + (_) async => Response( + statusCode: 200, + data: { + 'data': [ + { + 'scheduleId': 'schedule-1', + 'scheduleName': 'Morning meeting', + 'place': {'placeId': 'place-1', 'placeName': 'Office'}, + 'scheduleTime': '2026-05-06T10:00:00.000', + 'moveTime': 20, + 'scheduleSpareTime': 10, + 'doneStatus': 'NOT_ENDED', + 'preparations': [ + { + 'preparationId': 'prep-1', + 'preparationName': 'Pack', + 'preparationTime': 5, + 'nextPreparationId': null, + }, + ], + }, + ], + }, + requestOptions: RequestOptions(path: Endpoint.alarmWindow), + ), + ); + + final schedules = await remoteDataSource.getAlarmWindow(start, end); + + final query = + verify( + dio.get( + Endpoint.alarmWindow, + queryParameters: captureAnyNamed('queryParameters'), + ), + ).captured.single + as Map; + expect(query, { + 'startDate': start.toIso8601String(), + 'endDate': end.toIso8601String(), }); + expect(schedules.single.id, 'schedule-1'); + expect( + schedules.single.preparation.preparationStepList.single.id, + 'prep-1', + ); + }); - test('falls back to backend enum format after generic bad request', - () async { - var callCount = 0; - when( - dio.post( - Endpoint.alarmStatus, - data: anyNamed('data'), - options: anyNamed('options'), + test( + 'non-200 alarm endpoints throw instead of returning partial data', + () async { + when(dio.get(Endpoint.alarmSettings)).thenAnswer( + (_) async => Response( + statusCode: 500, + requestOptions: RequestOptions(path: Endpoint.alarmSettings), ), - ).thenAnswer((_) async { - callCount += 1; - return Response( - statusCode: callCount == 1 ? 400 : 200, - data: callCount == 1 - ? { - 'status': 'error', - 'code': 400, - 'message': 'bad request', - 'data': null, - } - : null, - requestOptions: RequestOptions(path: Endpoint.alarmStatus), - ); - }); + ); - await remoteDataSource.postAlarmStatus(_statusReport()); + await expectLater(remoteDataSource.getAlarmSettings(), throwsException); + }, + ); - final verification = verify( - dio.post( - Endpoint.alarmStatus, - data: captureAnyNamed('data'), - options: captureAnyNamed('options'), + test('non-200 alarm mutations and window queries surface failures', () async { + when( + dio.patch(Endpoint.alarmSettings, data: anyNamed('data')), + ).thenAnswer( + (_) async => Response( + statusCode: 500, + requestOptions: RequestOptions(path: Endpoint.alarmSettings), + ), + ); + await expectLater( + remoteDataSource.updateAlarmSettings(alarmsEnabled: true), + throwsException, + ); + + when( + dio.put(Endpoint.currentDevice, data: anyNamed('data')), + ).thenAnswer( + (_) async => Response( + statusCode: 500, + requestOptions: RequestOptions(path: Endpoint.currentDevice), + ), + ); + await expectLater( + remoteDataSource.registerCurrentDevice( + const AlarmDeviceInfo( + deviceId: 'device-1', + platform: 'android', + appVersion: '1.0.0', + osVersion: 'android-35', + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, ), - )..called(2); - final firstData = verification.captured[0] as Map; - final firstOptions = verification.captured[1] as Options; - final secondData = verification.captured[2] as Map; - final secondOptions = verification.captured[3] as Options; - - expect(firstOptions.validateStatus!(400), isTrue); - expect(secondOptions.validateStatus!(400), isTrue); - expect(firstData.containsKey('permissionIssue'), isFalse); - expect(firstData['status'], 'armed'); - expect(firstData['nativeAlarmProvider'], 'iosAlarmKit'); - expect(secondData.containsKey('permissionIssue'), isFalse); - expect(secondData['status'], 'ARMED'); - expect(secondData['nativeAlarmProvider'], 'IOS_ALARM_KIT'); - expect(secondData['fallbackProvider'], 'LOCAL_NOTIFICATION'); - }); + ), + throwsException, + ); + + when( + dio.delete(Endpoint.currentDevice, data: anyNamed('data')), + ).thenAnswer( + (_) async => Response( + statusCode: 500, + requestOptions: RequestOptions(path: Endpoint.currentDevice), + ), + ); + await expectLater( + remoteDataSource.unregisterCurrentDevice('device-1'), + throwsException, + ); + + when( + dio.get( + Endpoint.alarmWindow, + queryParameters: anyNamed('queryParameters'), + ), + ).thenAnswer( + (_) async => Response( + statusCode: 500, + requestOptions: RequestOptions(path: Endpoint.alarmWindow), + ), + ); + final start = DateTime.utc(2026, 5, 5, 9); + await expectLater( + remoteDataSource.getAlarmWindow( + start, + start.add(const Duration(days: 7)), + ), + throwsException, + ); + }); + + group('postAlarmStatus', () { + test( + 'posts lower-camel backend contract without retry on success', + () async { + when( + dio.post( + Endpoint.alarmStatus, + data: anyNamed('data'), + options: anyNamed('options'), + ), + ).thenAnswer( + (_) async => Response( + statusCode: 200, + requestOptions: RequestOptions(path: Endpoint.alarmStatus), + ), + ); + + await remoteDataSource.postAlarmStatus(_statusReport()); + + final verification = verify( + dio.post( + Endpoint.alarmStatus, + data: captureAnyNamed('data'), + options: captureAnyNamed('options'), + ), + )..called(1); + final data = verification.captured[0] as Map; + final options = verification.captured[1] as Options; + + expect(options.validateStatus!(400), isTrue); + expect(data.containsKey('permissionIssue'), isFalse); + expect(data['reconciledAt'], '2026-05-05T09:00:00.000Z'); + expect(data['status'], 'armed'); + expect(data['nativeAlarmProvider'], 'iosAlarmKit'); + expect(data['fallbackProvider'], 'localNotification'); + }, + ); + + test( + 'falls back to backend enum format after generic bad request', + () async { + var callCount = 0; + when( + dio.post( + Endpoint.alarmStatus, + data: anyNamed('data'), + options: anyNamed('options'), + ), + ).thenAnswer((_) async { + callCount += 1; + return Response( + statusCode: callCount == 1 ? 400 : 200, + data: callCount == 1 + ? { + 'status': 'error', + 'code': 400, + 'message': 'bad request', + 'data': null, + } + : null, + requestOptions: RequestOptions(path: Endpoint.alarmStatus), + ); + }); + + await remoteDataSource.postAlarmStatus(_statusReport()); + + final verification = verify( + dio.post( + Endpoint.alarmStatus, + data: captureAnyNamed('data'), + options: captureAnyNamed('options'), + ), + )..called(2); + final firstData = verification.captured[0] as Map; + final firstOptions = verification.captured[1] as Options; + final secondData = verification.captured[2] as Map; + final secondOptions = verification.captured[3] as Options; + + expect(firstOptions.validateStatus!(400), isTrue); + expect(secondOptions.validateStatus!(400), isTrue); + expect(firstData.containsKey('permissionIssue'), isFalse); + expect(firstData['status'], 'armed'); + expect(firstData['nativeAlarmProvider'], 'iosAlarmKit'); + expect(secondData.containsKey('permissionIssue'), isFalse); + expect(secondData['status'], 'ARMED'); + expect(secondData['nativeAlarmProvider'], 'IOS_ALARM_KIT'); + expect(secondData['fallbackProvider'], 'LOCAL_NOTIFICATION'); + }, + ); test('does not retry semantic validation errors', () async { when( diff --git a/test/data/data_sources/authentication_remote_data_source_test.dart b/test/data/data_sources/authentication_remote_data_source_test.dart index 34082397..bec64cc1 100644 --- a/test/data/data_sources/authentication_remote_data_source_test.dart +++ b/test/data/data_sources/authentication_remote_data_source_test.dart @@ -3,6 +3,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:on_time_front/core/constants/endpoint.dart'; import 'package:on_time_front/data/data_sources/authentication_remote_data_source.dart'; +import 'package:on_time_front/data/models/sign_in_with_apple_request_model.dart'; +import 'package:on_time_front/data/models/sign_in_with_google_request_model.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; import '../../helpers/mock.mocks.dart'; @@ -26,16 +29,19 @@ void main() { await dataSource.deleteUser(feedbackMessage: ' Not useful anymore. '); - final capturedData = verify( - dio.delete(Endpoint.deleteUser, data: captureAnyNamed('data')), - ).captured.single as Map; + final capturedData = + verify( + dio.delete(Endpoint.deleteUser, data: captureAnyNamed('data')), + ).captured.single + as Map; expect(capturedData['feedbackId'], isA()); expect(capturedData['message'], 'Not useful anymore.'); }); test('sends an empty body when feedback is blank', () async { - when(dio.delete(Endpoint.deleteGoogleMe, data: anyNamed('data'))) - .thenAnswer( + when( + dio.delete(Endpoint.deleteGoogleMe, data: anyNamed('data')), + ).thenAnswer( (_) async => Response( statusCode: 200, requestOptions: RequestOptions(path: Endpoint.deleteGoogleMe), @@ -44,8 +50,338 @@ void main() { await dataSource.deleteGoogleMe(feedbackMessage: ' '); - verify(dio.delete(Endpoint.deleteGoogleMe, data: {})) - .called(1); + verify( + dio.delete(Endpoint.deleteGoogleMe, data: {}), + ).called(1); }); }); + + group('auth contract', () { + test( + 'signIn posts credentials and returns user with response tokens', + () async { + when( + dio.post(Endpoint.signIn, data: anyNamed('data')), + ).thenAnswer((_) async => _authResponse(Endpoint.signIn)); + + final (user, token) = await dataSource.signIn( + 'user@example.com', + 'Password1!', + ); + + final capturedData = + verify( + dio.post(Endpoint.signIn, data: captureAnyNamed('data')), + ).captured.single + as Map; + expect(capturedData, { + 'email': 'user@example.com', + 'password': 'Password1!', + }); + expect(user, _user(isOnboardingCompleted: true)); + expect(token.accessToken, 'access-token'); + expect(token.refreshToken, 'refresh-token'); + }, + ); + + test( + 'signUp posts registration data and maps guest onboarding status', + () async { + when(dio.post(Endpoint.signUp, data: anyNamed('data'))).thenAnswer( + (_) async => _authResponse(Endpoint.signUp, role: 'GUEST'), + ); + + final (user, _) = await dataSource.signUp( + 'new@example.com', + 'Password1!', + 'New User', + ); + + final capturedData = + verify( + dio.post(Endpoint.signUp, data: captureAnyNamed('data')), + ).captured.single + as Map; + expect(capturedData, { + 'email': 'new@example.com', + 'password': 'Password1!', + 'name': 'New User', + }); + expect(user, _user(isOnboardingCompleted: false)); + }, + ); + + test('signInWithGoogle posts provider token payload', () async { + when( + dio.post(Endpoint.signInWithGoogle, data: anyNamed('data')), + ).thenAnswer((_) async => _authResponse(Endpoint.signInWithGoogle)); + + await dataSource.signInWithGoogle( + SignInWithGoogleRequestModel( + idToken: 'google-id-token', + refreshToken: 'google-refresh-token', + ), + ); + + final capturedData = + verify( + dio.post( + Endpoint.signInWithGoogle, + data: captureAnyNamed('data'), + ), + ).captured.single + as Map; + expect(capturedData, { + 'idToken': 'google-id-token', + 'refreshToken': 'google-refresh-token', + }); + }); + + test('signInWithApple omits null email from provider payload', () async { + when( + dio.post(Endpoint.signInWithApple, data: anyNamed('data')), + ).thenAnswer((_) async => _authResponse(Endpoint.signInWithApple)); + + await dataSource.signInWithApple( + SignInWithAppleRequestModel( + idToken: 'apple-id-token', + authCode: 'auth-code', + fullName: 'Apple User', + ), + ); + + final capturedData = + verify( + dio.post( + Endpoint.signInWithApple, + data: captureAnyNamed('data'), + ), + ).captured.single + as Map; + expect(capturedData, { + 'idToken': 'apple-id-token', + 'authCode': 'auth-code', + 'fullName': 'Apple User', + }); + }); + + test('getUser maps backend profile defaults', () async { + when(dio.get(Endpoint.getUser)).thenAnswer( + (_) async => Response( + statusCode: 200, + data: { + 'data': { + 'userId': 2, + 'email': 'profile@example.com', + 'name': 'Profile', + 'spareTime': null, + 'note': null, + 'punctualityScore': null, + 'role': 'GUEST', + }, + }, + requestOptions: RequestOptions(path: Endpoint.getUser), + ), + ); + + final user = await dataSource.getUser(); + + expect( + user, + const UserEntity( + id: '2', + email: 'profile@example.com', + name: 'Profile', + spareTime: Duration.zero, + note: '', + score: -1, + isOnboardingCompleted: false, + ), + ); + }); + + test('getUserSocialType reads social type from profile payload', () async { + when(dio.get(Endpoint.getUser)).thenAnswer( + (_) async => Response( + statusCode: 200, + data: { + 'data': {'socialType': 'GOOGLE'}, + }, + requestOptions: RequestOptions(path: Endpoint.getUser), + ), + ); + + expect(await dataSource.getUserSocialType(), 'GOOGLE'); + }); + + test('postFeedback trims backend long-text payload', () async { + when(dio.post(Endpoint.feedback, data: anyNamed('data'))).thenAnswer( + (_) async => Response( + statusCode: 200, + requestOptions: RequestOptions(path: Endpoint.feedback), + ), + ); + + await dataSource.postFeedback(' useful feedback '); + + final capturedData = + verify( + dio.post(Endpoint.feedback, data: captureAnyNamed('data')), + ).captured.single + as Map; + expect(capturedData['feedbackId'], isA()); + expect(capturedData['message'], 'useful feedback'); + }); + + test( + 'non-200 signIn response throws instead of returning partial data', + () async { + when(dio.post(Endpoint.signIn, data: anyNamed('data'))).thenAnswer( + (_) async => Response( + statusCode: 500, + requestOptions: RequestOptions(path: Endpoint.signIn), + ), + ); + + await expectLater( + dataSource.signIn('user@example.com', 'Password1!'), + throwsException, + ); + }, + ); + + test( + 'non-200 auth and profile responses surface contract failures', + () async { + when(dio.post(Endpoint.signUp, data: anyNamed('data'))).thenAnswer( + (_) async => Response( + statusCode: 409, + requestOptions: RequestOptions(path: Endpoint.signUp), + ), + ); + await expectLater( + dataSource.signUp('new@example.com', 'Password1!', 'New User'), + throwsException, + ); + + when( + dio.post(Endpoint.signInWithGoogle, data: anyNamed('data')), + ).thenAnswer( + (_) async => Response( + statusCode: 400, + requestOptions: RequestOptions(path: Endpoint.signInWithGoogle), + ), + ); + await expectLater( + dataSource.signInWithGoogle( + SignInWithGoogleRequestModel( + idToken: 'google-id-token', + refreshToken: 'google-refresh-token', + ), + ), + throwsException, + ); + + when( + dio.post(Endpoint.signInWithApple, data: anyNamed('data')), + ).thenAnswer( + (_) async => Response( + statusCode: 400, + requestOptions: RequestOptions(path: Endpoint.signInWithApple), + ), + ); + await expectLater( + dataSource.signInWithApple( + SignInWithAppleRequestModel( + idToken: 'apple-id-token', + authCode: 'auth-code', + fullName: 'Apple User', + ), + ), + throwsException, + ); + + when(dio.get(Endpoint.getUser)).thenAnswer( + (_) async => Response( + statusCode: 404, + requestOptions: RequestOptions(path: Endpoint.getUser), + ), + ); + await expectLater(dataSource.getUser(), throwsException); + await expectLater(dataSource.getUserSocialType(), throwsException); + }, + ); + + test('non-200 delete and feedback responses surface failures', () async { + when( + dio.delete(Endpoint.deleteGoogleMe, data: anyNamed('data')), + ).thenAnswer( + (_) async => Response( + statusCode: 500, + requestOptions: RequestOptions(path: Endpoint.deleteGoogleMe), + ), + ); + await expectLater(dataSource.deleteGoogleMe(), throwsException); + + when( + dio.delete(Endpoint.deleteAppleMe, data: anyNamed('data')), + ).thenAnswer( + (_) async => Response( + statusCode: 500, + requestOptions: RequestOptions(path: Endpoint.deleteAppleMe), + ), + ); + await expectLater(dataSource.deleteAppleMe(), throwsException); + + when(dio.delete(Endpoint.deleteUser, data: anyNamed('data'))).thenAnswer( + (_) async => Response( + statusCode: 500, + requestOptions: RequestOptions(path: Endpoint.deleteUser), + ), + ); + await expectLater(dataSource.deleteUser(), throwsException); + + when(dio.post(Endpoint.feedback, data: anyNamed('data'))).thenAnswer( + (_) async => Response( + statusCode: 500, + requestOptions: RequestOptions(path: Endpoint.feedback), + ), + ); + await expectLater(dataSource.postFeedback('not useful'), throwsException); + }); + }); +} + +Response _authResponse(String path, {String role = 'USER'}) { + return Response( + statusCode: 200, + data: { + 'data': { + 'userId': 1, + 'email': 'user@example.com', + 'name': 'User', + 'spareTime': 10, + 'note': 'note', + 'punctualityScore': 4.5, + 'role': role, + }, + }, + headers: Headers.fromMap({ + 'authorization': ['access-token'], + 'authorization-refresh': ['refresh-token'], + }), + requestOptions: RequestOptions(path: path), + ); +} + +UserEntity _user({required bool isOnboardingCompleted}) { + return UserEntity( + id: '1', + email: 'user@example.com', + name: 'User', + spareTime: const Duration(minutes: 10), + note: 'note', + score: 4.5, + isOnboardingCompleted: isOnboardingCompleted, + ); } diff --git a/test/data/data_sources/early_start_session_local_data_source_test.dart b/test/data/data_sources/early_start_session_local_data_source_test.dart new file mode 100644 index 00000000..56d9cfc0 --- /dev/null +++ b/test/data/data_sources/early_start_session_local_data_source_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/data/data_sources/early_start_session_local_data_source.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + late EarlyStartSessionLocalDataSourceImpl dataSource; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + dataSource = EarlyStartSessionLocalDataSourceImpl(); + }); + + test('saves and loads the early start timestamp per schedule', () async { + final startedAt = DateTime(2026, 5, 15, 8, 30); + + await dataSource.saveSession( + scheduleId: 'schedule-1', + startedAt: startedAt, + ); + + expect( + await dataSource.loadSessionStartedAt('schedule-1'), + DateTime.fromMillisecondsSinceEpoch(startedAt.millisecondsSinceEpoch), + ); + expect(await dataSource.loadSessionStartedAt('schedule-2'), isNull); + }); + + test( + 'returns null for missing, corrupt, and incomplete session payloads', + () async { + SharedPreferences.setMockInitialValues({ + 'early_start_session_corrupt': 'not json', + 'early_start_session_incomplete': '{}', + }); + final source = EarlyStartSessionLocalDataSourceImpl(); + + expect(await source.loadSessionStartedAt('missing'), isNull); + expect(await source.loadSessionStartedAt('corrupt'), isNull); + expect(await source.loadSessionStartedAt('incomplete'), isNull); + }, + ); + + test('clearSession removes only the requested schedule session', () async { + final startedAt = DateTime(2026, 5, 15, 8, 30); + await dataSource.saveSession(scheduleId: 'a', startedAt: startedAt); + await dataSource.saveSession(scheduleId: 'b', startedAt: startedAt); + + await dataSource.clearSession('a'); + + expect(await dataSource.loadSessionStartedAt('a'), isNull); + expect(await dataSource.loadSessionStartedAt('b'), isNotNull); + }); +} diff --git a/test/data/data_sources/notification_remote_data_source_test.dart b/test/data/data_sources/notification_remote_data_source_test.dart new file mode 100644 index 00000000..432e2a66 --- /dev/null +++ b/test/data/data_sources/notification_remote_data_source_test.dart @@ -0,0 +1,64 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:on_time_front/core/constants/endpoint.dart'; +import 'package:on_time_front/data/data_sources/notification_remote_data_source.dart'; +import 'package:on_time_front/data/models/fcm_token_register_request_model.dart'; + +import '../../helpers/mock.mocks.dart'; + +void main() { + late Dio dio; + late NotificationRemoteDataSourceImpl dataSource; + + setUp(() { + dio = MockAppDio(); + dataSource = NotificationRemoteDataSourceImpl(dio); + }); + + test('fcmTokenRegister posts the device token payload', () async { + when( + dio.post(Endpoint.fcmTokenRegister, data: anyNamed('data')), + ).thenAnswer( + (_) async => Response( + statusCode: 200, + requestOptions: RequestOptions(path: Endpoint.fcmTokenRegister), + ), + ); + + await dataSource.fcmTokenRegister( + FcmTokenRegisterRequestModel( + firebaseToken: 'fcm-token', + deviceId: 'device-1', + ), + ); + + final data = + verify( + dio.post( + Endpoint.fcmTokenRegister, + data: captureAnyNamed('data'), + ), + ).captured.single + as Map; + expect(data, {'firebaseToken': 'fcm-token', 'deviceId': 'device-1'}); + }); + + test('fcmTokenRegister rejects non-success backend status', () async { + when( + dio.post(Endpoint.fcmTokenRegister, data: anyNamed('data')), + ).thenAnswer( + (_) async => Response( + statusCode: 500, + requestOptions: RequestOptions(path: Endpoint.fcmTokenRegister), + ), + ); + + await expectLater( + dataSource.fcmTokenRegister( + FcmTokenRegisterRequestModel(firebaseToken: 'fcm-token'), + ), + throwsException, + ); + }); +} diff --git a/test/data/data_sources/preparation_local_data_source_test.dart b/test/data/data_sources/preparation_local_data_source_test.dart new file mode 100644 index 00000000..4136bbc0 --- /dev/null +++ b/test/data/data_sources/preparation_local_data_source_test.dart @@ -0,0 +1,151 @@ +import 'package:drift/drift.dart' as drift; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/data_sources/preparation_local_data_source.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; + +void main() { + late AppDatabase database; + late PreparationLocalDataSourceImpl dataSource; + + setUp(() async { + database = AppDatabase.forTesting(NativeDatabase.memory()); + await database.customStatement('PRAGMA foreign_keys = ON'); + dataSource = PreparationLocalDataSourceImpl(appDatabase: database); + + await database + .into(database.users) + .insert( + UsersCompanion( + id: const drift.Value('userId'), + email: const drift.Value('user@example.com'), + name: const drift.Value('User'), + spareTime: drift.Value(const Duration(minutes: 10).inSeconds), + note: const drift.Value('note'), + score: const drift.Value(4.5), + ), + ); + await database + .into(database.places) + .insert( + const PlacesCompanion( + id: drift.Value('place-1'), + placeName: drift.Value('Office'), + ), + ); + await database + .into(database.schedules) + .insert( + SchedulesCompanion( + id: const drift.Value('scheduleId'), + placeId: const drift.Value('place-1'), + scheduleName: const drift.Value('Meeting'), + scheduleTime: drift.Value(DateTime(2026, 5, 15, 9)), + moveTime: const drift.Value(Duration(minutes: 15)), + isChanged: const drift.Value(false), + isStarted: const drift.Value(false), + scheduleSpareTime: const drift.Value(Duration(minutes: 5)), + scheduleNote: const drift.Value('note'), + latenessTime: const drift.Value(0), + ), + ); + }); + + tearDown(() async { + await database.close(); + }); + + test('creates and updates the default user preparation', () async { + await dataSource.createDefaultPreparation(_preparation(userBased: true)); + + final updated = const PreparationStepEntity( + id: 'step-1', + preparationName: 'Updated shower', + preparationTime: Duration(minutes: 12), + ); + await dataSource.updatePreparation(updated); + + final stored = await database.preparationUserDao + .getPreparationUsersByUserId('userId'); + expect(stored.preparationStepList.single, updated); + }); + + test( + 'creates, reads, updates, and deletes custom schedule preparation', + () async { + await dataSource.createCustomPreparation( + _preparation(userBased: false), + 'scheduleId', + ); + + final bySchedule = await dataSource.getPreparationByScheduleId( + 'scheduleId', + ); + expect(bySchedule.preparationStepList.map((step) => step.id), [ + 'step-1', + 'step-2', + ]); + expect( + await dataSource.getPreparationStepById('step-1'), + bySchedule.preparationStepList.first, + ); + + final updated = const PreparationStepEntity( + id: 'step-1', + preparationName: 'Updated schedule prep', + preparationTime: Duration(minutes: 20), + nextPreparationId: 'step-2', + ); + await dataSource.updatePreparation(updated); + expect( + (await dataSource.getPreparationStepById('step-1')).preparationName, + 'Updated schedule prep', + ); + + final afterDelete = await dataSource.deletePreparation( + PreparationEntity(preparationStepList: [updated]), + ); + expect(afterDelete.preparationStepList.single.id, 'step-2'); + }, + ); + + test('deletePreparation rejects empty preparation entities', () async { + await expectLater( + dataSource.deletePreparation( + const PreparationEntity(preparationStepList: []), + ), + throwsException, + ); + }); +} + +PreparationEntity _preparation({required bool userBased}) { + if (!userBased) { + return const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + ), + PreparationStepEntity( + id: 'step-2', + preparationName: 'Pack', + preparationTime: Duration(minutes: 5), + ), + ], + ); + } + + return PreparationEntity( + preparationStepList: [ + const PreparationStepEntity( + id: 'step-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + ), + ], + ); +} diff --git a/test/data/data_sources/preparation_remote_data_source_test.dart b/test/data/data_sources/preparation_remote_data_source_test.dart index aafc9924..5ebb4a8e 100644 --- a/test/data/data_sources/preparation_remote_data_source_test.dart +++ b/test/data/data_sources/preparation_remote_data_source_test.dart @@ -1,301 +1,190 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; import 'package:on_time_front/data/data_sources/preparation_remote_data_source.dart'; -import 'package:on_time_front/data/models/create_preparation_schedule_request_model.dart'; import 'package:on_time_front/data/models/create_defualt_preparation_request_model.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; -import 'package:uuid/uuid.dart'; - -import '../../helpers/mock.mocks.dart'; void main() { - late Dio dio; - late PreparationRemoteDataSourceImpl remoteDataSource; - final uuid = Uuid(); - - final scheduleId = uuid.v7(); - - final preparationStep1 = PreparationStepEntity( - id: uuid.v7(), - preparationName: 'Step 1: Wake up', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ); - - final preparationStep2 = PreparationStepEntity( - id: uuid.v7(), - preparationName: 'Step 2: Brush teeth', - preparationTime: Duration(minutes: 5), - nextPreparationId: null, - ); - - final preparationEntity = PreparationEntity( - preparationStepList: [preparationStep1, preparationStep2], - ); - - final tCreateDefualtPreparationRequestModel = - CreateDefaultPreparationRequestModel.fromEntity( - preparationEntity: preparationEntity, - spareTime: Duration(minutes: 10), - note: 'Wake up'); - - final tCreateScheduleRequestModel = - PreparationScheduleCreateRequestModelListExtension.fromEntityList( - preparationEntity.preparationStepList); + late _PreparationAdapter adapter; + late PreparationRemoteDataSourceImpl dataSource; setUp(() { - dio = MockAppDio(); - remoteDataSource = PreparationRemoteDataSourceImpl(dio); + adapter = _PreparationAdapter(); + final dio = Dio( + BaseOptions(baseUrl: 'https://example.com', validateStatus: (_) => true), + )..httpClientAdapter = adapter; + dataSource = PreparationRemoteDataSourceImpl(dio); }); - group('createCustomPreparation', () { - test( - 'should perform a POST request on the create custom preparation endpoint', - () async { - // arrange - when(dio.post( - Endpoint.getCreateCustomPreparation(scheduleId), - data: - tCreateScheduleRequestModel.map((model) => model.toJson()).toList(), - )).thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: RequestOptions( - path: Endpoint.getCreateCustomPreparation(scheduleId), - ), - ), + test( + 'create and update calls serialize preparation steps for backend', + () async { + final preparation = _preparation(); + + await dataSource.createCustomPreparation(preparation, 'schedule-1'); + await dataSource.updatePreparationByScheduleId(preparation, 'schedule-1'); + await dataSource.updateDefaultPreparation(preparation); + + expect(adapter.requests.map((request) => request.method), [ + 'POST', + 'PUT', + 'PUT', + ]); + expect(adapter.requests[0].body, isA>()); + expect( + (adapter.requests[0].body as List).first['preparationName'], + 'Shower', ); - - // act - await remoteDataSource.createCustomPreparation( - preparationEntity, scheduleId); - - // assert - verify(dio.post( - Endpoint.getCreateCustomPreparation(scheduleId), - data: - tCreateScheduleRequestModel.map((model) => model.toJson()).toList(), - )).called(1); - }); - - test('should throw an exception when the response code is not 200', - () async { - // arrange - when(dio.post( - Endpoint.getCreateCustomPreparation(scheduleId), - data: - tCreateScheduleRequestModel.map((model) => model.toJson()).toList(), - )).thenAnswer( - (_) async => Response( - statusCode: 400, - requestOptions: RequestOptions( - path: Endpoint.getCreateCustomPreparation(scheduleId), - ), - ), + expect( + (adapter.requests[1].body as List).first['preparationId'], + 'step-1', ); - - // act - final call = remoteDataSource.createCustomPreparation; - - // assert - expect(() => call(preparationEntity, scheduleId), throwsException); - }); - }); - - group('createDefaultPreparation', () { - test( - 'should perform a POST request on the create default preparation endpoint', - () async { - // arrange - when(dio.put( - Endpoint.createDefaultPreparation, - data: tCreateDefualtPreparationRequestModel.toJson(), - )).thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: RequestOptions( - path: Endpoint.createDefaultPreparation, - ), - ), + expect( + (adapter.requests[2].body as List).first['preparationId'], + 'step-1', ); + }, + ); - // act - await remoteDataSource - .createDefaultPreparation(tCreateDefualtPreparationRequestModel); - - // assert - verify(dio.put( - Endpoint.createDefaultPreparation, - data: tCreateDefualtPreparationRequestModel.toJson(), - )).called(1); - }); - - test('should throw an exception when the response code is not 200', - () async { - // arrange - when(dio.put( - Endpoint.createDefaultPreparation, - data: tCreateDefualtPreparationRequestModel.toJson(), - )).thenAnswer( - (_) async => Response( - statusCode: 400, - requestOptions: RequestOptions( - path: Endpoint.createDefaultPreparation, - ), + test( + 'default create and spare time update send their request bodies', + () async { + await dataSource.createDefaultPreparation( + CreateDefaultPreparationRequestModel.fromEntity( + preparationEntity: _preparation(), + spareTime: const Duration(minutes: 5), + note: 'note', ), ); + await dataSource.updateSpareTime(const Duration(minutes: 15)); - // act - final call = remoteDataSource.createDefaultPreparation; - - // assert + expect(adapter.requests.first.method, 'PUT'); expect( - () => call(tCreateDefualtPreparationRequestModel), throwsException); - }); - }); - - group('getPreparationByScheduleId', () { - test('should return PreparationEntity ordered by nextPreparationId', - () async { - // arrange - final orderedFirstStep = PreparationStepEntity( - id: uuid.v7(), - preparationName: 'Shower', - preparationTime: const Duration(minutes: 10), - nextPreparationId: null, - ); - final orderedSecondStep = PreparationStepEntity( - id: uuid.v7(), - preparationName: 'Dress', - preparationTime: const Duration(minutes: 5), - nextPreparationId: null, - ); - final orderedThirdStep = PreparationStepEntity( - id: uuid.v7(), - preparationName: 'Pack bag', - preparationTime: const Duration(minutes: 3), - nextPreparationId: null, - ); - final linkedFirstStep = - orderedFirstStep.copyWith(nextPreparationId: orderedSecondStep.id); - final linkedSecondStep = - orderedSecondStep.copyWith(nextPreparationId: orderedThirdStep.id); - - when(dio.get(Endpoint.getPreparationByScheduleId(scheduleId))).thenAnswer( - (_) async => Response( - statusCode: 200, - data: { - 'data': [ - { - 'preparationId': orderedThirdStep.id, - 'preparationName': orderedThirdStep.preparationName, - 'preparationTime': orderedThirdStep.preparationTime.inMinutes, - 'nextPreparationId': orderedThirdStep.nextPreparationId, - }, - { - 'preparationId': linkedFirstStep.id, - 'preparationName': linkedFirstStep.preparationName, - 'preparationTime': linkedFirstStep.preparationTime.inMinutes, - 'nextPreparationId': linkedFirstStep.nextPreparationId, - }, - { - 'preparationId': linkedSecondStep.id, - 'preparationName': linkedSecondStep.preparationName, - 'preparationTime': linkedSecondStep.preparationTime.inMinutes, - 'nextPreparationId': linkedSecondStep.nextPreparationId, - }, - ], - }, - requestOptions: RequestOptions( - path: Endpoint.getPreparationByScheduleId(scheduleId), - ), - ), + (adapter.requests.first.body + as Map)['preparationList'], + isA(), ); - - // act - final result = - await remoteDataSource.getPreparationByScheduleId(scheduleId); - - // assert expect( - result.preparationStepList.map((step) => step.id).toList(), - [ - orderedFirstStep.id, - orderedSecondStep.id, - orderedThirdStep.id, - ], + (adapter.requests.last.body as Map)['newSpareTime'], + 15, ); - }); + }, + ); - test('should throw an exception when the response code is not 200', - () async { - // arrange - when(dio.get(Endpoint.getPreparationByScheduleId(scheduleId))).thenAnswer( - (_) async => Response( - statusCode: 400, - requestOptions: RequestOptions( - path: Endpoint.getPreparationByScheduleId(scheduleId), - ), - ), + test( + 'get preparation calls map ordered backend steps into entities', + () async { + final bySchedule = await dataSource.getPreparationByScheduleId( + 'schedule-1', ); + final defaultPreparation = await dataSource.getDefualtPreparation(); + + expect(bySchedule.preparationStepList.map((step) => step.id), [ + 'step-1', + 'step-2', + ]); + expect(defaultPreparation.preparationStepList.map((step) => step.id), [ + 'step-1', + 'step-2', + ]); + expect(bySchedule.totalDuration, const Duration(minutes: 15)); + }, + ); - // act - final call = remoteDataSource.getPreparationByScheduleId; + test('non-200 responses surface failures', () async { + adapter.statusCode = 500; - // assert - expect(() => call(scheduleId), throwsException); - }); + await expectLater( + dataSource.createCustomPreparation(_preparation(), 'schedule-1'), + throwsException, + ); + await expectLater(dataSource.getDefualtPreparation(), throwsException); }); +} - // group('updatePreparation', () { - // test('should perform a PUT request on the update preparation endpoint', - // () async { - // // arrange - // when(dio.post( - // Endpoint.updateDefaultPreparation, - // data: tUpdateRequestModel.toJson(), - // )).thenAnswer( - // (_) async => Response( - // statusCode: 200, - // requestOptions: RequestOptions( - // path: Endpoint.updateDefaultPreparation, - // ), - // ), - // ); - - // // act - // await remoteDataSource.updateDefaultPreparation(preparationEntity); - - // // assert - // verify(dio.post( - // Endpoint.updateDefaultPreparation, - // data: tUpdateRequestModel.toJson(), - // )).called(1); - // }); +PreparationEntity _preparation() { + return const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + nextPreparationId: 'step-2', + ), + PreparationStepEntity( + id: 'step-2', + preparationName: 'Pack', + preparationTime: Duration(minutes: 5), + ), + ], + ); +} - // test('should throw an exception when the response code is not 200', - // () async { - // // arrange - // when(dio.post( - // Endpoint.updateDefaultPreparation, - // data: tUpdateRequestModel.toJson(), - // )).thenAnswer( - // (_) async => Response( - // statusCode: 400, - // requestOptions: RequestOptions( - // path: Endpoint.updateDefaultPreparation, - // ), - // ), - // ); +class _PreparationRequest { + const _PreparationRequest({ + required this.method, + required this.path, + required this.body, + }); - // // act - // final call = remoteDataSource.updateDefaultPreparation; + final String method; + final String path; + final Object? body; +} - // // assert - // expect(() => call(preparationEntity), throwsException); - // }); - // }); +class _PreparationAdapter implements HttpClientAdapter { + int statusCode = 200; + final requests = <_PreparationRequest>[]; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requests.add( + _PreparationRequest( + method: options.method, + path: options.path, + body: options.data, + ), + ); + + if (options.method == 'GET') { + return _json({ + 'data': [ + { + 'preparationId': 'step-1', + 'preparationName': 'Shower', + 'preparationTime': 10, + 'nextPreparationId': 'step-2', + }, + { + 'preparationId': 'step-2', + 'preparationName': 'Pack', + 'preparationTime': 5, + 'nextPreparationId': null, + }, + ], + }); + } + return _json({'data': null}); + } + + ResponseBody _json(Object body) { + return ResponseBody.fromString( + jsonEncode(body), + statusCode, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} } diff --git a/test/data/data_sources/preparation_with_time_local_data_source_test.dart b/test/data/data_sources/preparation_with_time_local_data_source_test.dart new file mode 100644 index 00000000..54c0a5a2 --- /dev/null +++ b/test/data/data_sources/preparation_with_time_local_data_source_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/data/data_sources/preparation_with_time_local_data_source.dart'; +import 'package:on_time_front/data/repositories/timed_preparation_repository_impl.dart'; +import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + late PreparationWithTimeLocalDataSourceImpl dataSource; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + dataSource = PreparationWithTimeLocalDataSourceImpl(); + }); + + test('savePreparation persists elapsed step state for a schedule', () async { + final snapshot = _snapshot(); + + await dataSource.savePreparation('schedule-1', snapshot); + + final loaded = await dataSource.loadPreparation('schedule-1'); + + expect(loaded, snapshot); + expect(loaded!.preparation.currentStep?.id, 'step-2'); + expect(loaded.preparation.stepElapsedTimesInSeconds, [600, 120]); + }); + + test( + 'loadPreparation returns null for missing or corrupt snapshots', + () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('preparation_with_time_corrupt', '{not json'); + + expect(await dataSource.loadPreparation('missing'), isNull); + expect(await dataSource.loadPreparation('corrupt'), isNull); + }, + ); + + test( + 'loadPreparation supports legacy snapshots without savedAt or fingerprint', + () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('preparation_with_time_legacy', ''' + { + "steps": [ + { + "id": "step-1", + "name": "Pack", + "time": 600000, + "nextId": null, + "elapsed": 600000 + } + ] + } + '''); + + final loaded = await dataSource.loadPreparation('legacy'); + + expect(loaded, isNotNull); + expect(loaded!.scheduleFingerprint, ''); + expect(loaded.preparation.preparationStepList.single.isDone, isFalse); + expect( + loaded.preparation.preparationStepList.single.elapsedTime, + const Duration(minutes: 10), + ); + }, + ); + + test( + 'clearPreparation removes only the requested schedule snapshot', + () async { + await dataSource.savePreparation('schedule-1', _snapshot()); + await dataSource.savePreparation( + 'schedule-2', + _snapshot(scheduleFingerprint: 'other-fingerprint'), + ); + + await dataSource.clearPreparation('schedule-1'); + + expect(await dataSource.loadPreparation('schedule-1'), isNull); + expect( + (await dataSource.loadPreparation('schedule-2'))!.scheduleFingerprint, + 'other-fingerprint', + ); + }, + ); + + test( + 'TimedPreparationRepositoryImpl delegates cache lifecycle operations', + () async { + final repository = TimedPreparationRepositoryImpl( + localDataSource: dataSource, + ); + final snapshot = _snapshot(); + + await repository.saveTimedPreparationSnapshot('schedule-1', snapshot); + expect( + await repository.getTimedPreparationSnapshot('schedule-1'), + snapshot, + ); + + await repository.clearTimedPreparation('schedule-1'); + expect( + await repository.getTimedPreparationSnapshot('schedule-1'), + isNull, + ); + }, + ); +} + +TimedPreparationSnapshotEntity _snapshot({ + String scheduleFingerprint = 'fingerprint', +}) { + return TimedPreparationSnapshotEntity( + savedAt: DateTime.fromMillisecondsSinceEpoch(1778774400000), + scheduleFingerprint: scheduleFingerprint, + preparation: const PreparationWithTimeEntity( + preparationStepList: [ + PreparationStepWithTimeEntity( + id: 'step-1', + preparationName: 'Pack', + preparationTime: Duration(minutes: 10), + nextPreparationId: 'step-2', + elapsedTime: Duration(minutes: 10), + isDone: true, + ), + PreparationStepWithTimeEntity( + id: 'step-2', + preparationName: 'Dress', + preparationTime: Duration(minutes: 5), + nextPreparationId: null, + elapsedTime: Duration(minutes: 2), + isDone: false, + ), + ], + ), + ); +} diff --git a/test/data/data_sources/schedule_local_data_source_test.dart b/test/data/data_sources/schedule_local_data_source_test.dart new file mode 100644 index 00000000..28b255cb --- /dev/null +++ b/test/data/data_sources/schedule_local_data_source_test.dart @@ -0,0 +1,137 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/data_sources/schedule_local_data_source.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; + +void main() { + late AppDatabase database; + late ScheduleLocalDataSourceImpl dataSource; + + setUp(() { + database = AppDatabase.forTesting(NativeDatabase.memory()); + dataSource = ScheduleLocalDataSourceImpl(appDatabase: database); + }); + + tearDown(() async { + await database.close(); + }); + + test('creates and reads a schedule with its place', () async { + final schedule = _schedule( + id: 'schedule-1', + placeId: 'place-1', + placeName: 'Office', + time: DateTime(2026, 5, 15, 9), + ); + + await dataSource.createSchedule(schedule); + + final stored = await dataSource.getScheduleById('schedule-1'); + expect(stored.id, 'schedule-1'); + expect(stored.place, const PlaceEntity(id: 'place-1', placeName: 'Office')); + expect(stored.scheduleName, 'Meeting schedule-1'); + expect(stored.scheduleTime, DateTime(2026, 5, 15, 9)); + expect(stored.moveTime, const Duration(minutes: 20)); + expect(stored.scheduleSpareTime, const Duration(minutes: 5)); + }); + + test('filters schedules by date range and updates schedule fields', () async { + final inside = _schedule( + id: 'inside', + placeId: 'place-1', + placeName: 'Office', + time: DateTime(2026, 5, 15, 9), + ); + final outside = _schedule( + id: 'outside', + placeId: 'place-2', + placeName: 'Cafe', + time: DateTime(2026, 5, 17, 9), + ); + await dataSource.createSchedule(inside); + await dataSource.createSchedule(outside); + + final updated = ScheduleEntity( + id: 'inside', + place: const PlaceEntity(id: 'place-1', placeName: 'Ignored by update'), + scheduleName: 'Updated meeting', + scheduleTime: DateTime(2026, 5, 15, 10), + moveTime: const Duration(minutes: 45), + isChanged: true, + isStarted: true, + scheduleSpareTime: const Duration(minutes: 15), + scheduleNote: 'updated note', + latenessTime: 2, + ); + await dataSource.updateSchedule(updated); + + final schedules = await dataSource.getSchedulesByDate( + DateTime(2026, 5, 15), + DateTime(2026, 5, 16), + ); + + expect(schedules.map((schedule) => schedule.id), ['inside']); + expect(schedules.single.scheduleName, 'Updated meeting'); + expect(schedules.single.scheduleTime, DateTime(2026, 5, 15, 10)); + expect(schedules.single.moveTime, const Duration(minutes: 45)); + expect(schedules.single.isChanged, isTrue); + expect(schedules.single.isStarted, isTrue); + expect(schedules.single.scheduleSpareTime, const Duration(minutes: 15)); + expect(schedules.single.scheduleNote, 'updated note'); + expect(schedules.single.latenessTime, 2); + }); + + test('deleteSchedule removes only the requested schedule', () async { + await dataSource.createSchedule( + _schedule( + id: 'schedule-1', + placeId: 'place-1', + placeName: 'Office', + time: DateTime(2026, 5, 15, 9), + ), + ); + final keep = _schedule( + id: 'schedule-2', + placeId: 'place-2', + placeName: 'Cafe', + time: DateTime(2026, 5, 15, 11), + ); + await dataSource.createSchedule(keep); + + await dataSource.deleteSchedule( + _schedule( + id: 'schedule-1', + placeId: 'place-1', + placeName: 'Office', + time: DateTime(2026, 5, 15, 9), + ), + ); + + final schedules = await dataSource.getSchedulesByDate( + DateTime(2026, 5, 15), + DateTime(2026, 5, 16), + ); + expect(schedules, [keep]); + }); +} + +ScheduleEntity _schedule({ + required String id, + required String placeId, + required String placeName, + required DateTime time, +}) { + return ScheduleEntity( + id: id, + place: PlaceEntity(id: placeId, placeName: placeName), + scheduleName: 'Meeting $id', + scheduleTime: time, + moveTime: const Duration(minutes: 20), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 5), + scheduleNote: 'note', + ); +} diff --git a/test/data/data_sources/schedule_remote_data_source_test.dart b/test/data/data_sources/schedule_remote_data_source_test.dart index 13fdf7db..c0a60f5d 100644 --- a/test/data/data_sources/schedule_remote_data_source_test.dart +++ b/test/data/data_sources/schedule_remote_data_source_test.dart @@ -1,170 +1,184 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:on_time_front/core/constants/endpoint.dart'; import 'package:on_time_front/data/data_sources/schedule_remote_data_source.dart'; -import 'package:on_time_front/data/models/create_schedule_request_model.dart'; -import 'package:on_time_front/data/models/update_schedule_request_model.dart'; import 'package:on_time_front/domain/entities/place_entity.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; -import 'package:uuid/uuid.dart'; - -import '../../helpers/mock.mocks.dart'; void main() { - late Dio dio; - late ScheduleRemoteDataSourceImpl scheduleRemoteDataSourceImpl; - final uuid = Uuid(); - - final scheduleEntityId = uuid.v7(); - - final tPlaceEntity = PlaceEntity( - id: uuid.v7(), - placeName: 'Office', - ); - - final tScheduleEntity = ScheduleEntity( - id: scheduleEntityId, - place: tPlaceEntity, - scheduleName: 'Meeting', - scheduleTime: DateTime.now(), - moveTime: Duration(minutes: 10), - isChanged: false, - isStarted: false, - scheduleSpareTime: Duration(minutes: 5), - scheduleNote: 'Discuss project updates', - ); - - final tCreateScheduleModel = - CreateScheduleRequestModel.fromEntity(tScheduleEntity); - final tUpdateScheduleModel = - UpdateScheduleRequestModel.fromEntity(tScheduleEntity); + late _ScheduleAdapter adapter; + late ScheduleRemoteDataSourceImpl dataSource; setUp(() { - dio = MockAppDio(); - scheduleRemoteDataSourceImpl = ScheduleRemoteDataSourceImpl(dio); + adapter = _ScheduleAdapter(); + final dio = Dio( + BaseOptions(baseUrl: 'https://example.com', validateStatus: (_) => true), + )..httpClientAdapter = adapter; + dataSource = ScheduleRemoteDataSourceImpl(dio); }); - group('createSchedule', () { - test('should perform a POST request on the create schedule endpoint', - () async { - // arrange - when(dio.post(Endpoint.createSchedule, - data: tCreateScheduleModel.toJson())) - .thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: RequestOptions(path: Endpoint.createSchedule), - ), - ); - - // act - await scheduleRemoteDataSourceImpl.createSchedule( - tScheduleEntity, - ); - - // assert - verify(dio.post(Endpoint.createSchedule, - data: tCreateScheduleModel.toJson())) - .called(1); - }); - - test('should throw an exception when the response code is not 200', () { - // arrange - when(dio.post(Endpoint.createSchedule, - data: tCreateScheduleModel.toJson())) - .thenAnswer( - (_) async => Response( - statusCode: 400, - requestOptions: RequestOptions(path: Endpoint.createSchedule), - ), - ); - - // act - final call = scheduleRemoteDataSourceImpl.createSchedule; + test( + 'create, update, delete, and finish send schedule API contracts', + () async { + final schedule = _schedule('schedule-1'); + + await dataSource.createSchedule(schedule); + await dataSource.updateSchedule(schedule); + await dataSource.deleteSchedule(schedule); + await dataSource.finishSchedule('schedule-1', 7); + + expect(adapter.requests.map((request) => request.method), [ + 'POST', + 'PUT', + 'DELETE', + 'PUT', + ]); + expect(adapter.requests[0].body['scheduleName'], 'Meeting schedule-1'); + expect(adapter.requests[1].body['scheduleName'], 'Meeting schedule-1'); + expect(adapter.requests[3].body, { + 'scheduleId': 'schedule-1', + 'latenessTime': 7, + }); + }, + ); - // assert - expect(() => call(tScheduleEntity), throwsException); - }); - }); + test( + 'getScheduleById maps backend response into a schedule entity', + () async { + final schedule = await dataSource.getScheduleById('schedule-1'); - group('updateSchedule', () { - test('should perform a PUT request without completion fields', () async { - final updateJson = tUpdateScheduleModel.toJson(); - expect(updateJson, isNot(contains('latenessTime'))); - - when(dio.put(Endpoint.updateSchedule(scheduleEntityId), data: updateJson)) - .thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: - RequestOptions(path: Endpoint.updateSchedule(scheduleEntityId)), - ), + expect(schedule.id, 'schedule-1'); + expect( + schedule.place, + const PlaceEntity(id: 'place-1', placeName: 'Office'), ); + expect(schedule.scheduleName, 'Morning meeting'); + expect(schedule.moveTime, const Duration(minutes: 20)); + expect(schedule.scheduleSpareTime, const Duration(minutes: 5)); + expect(schedule.doneStatus, ScheduleDoneStatus.normalEnd); + }, + ); - await scheduleRemoteDataSourceImpl.updateSchedule(tScheduleEntity); - - verify(dio.put(Endpoint.updateSchedule(scheduleEntityId), - data: updateJson)) - .called(1); - }); - - test('should throw an exception when the response code is not 200', - () async { - when(dio.put( - Endpoint.updateSchedule(scheduleEntityId), - data: tUpdateScheduleModel.toJson(), - )).thenAnswer( - (_) async => Response( - statusCode: 400, - requestOptions: - RequestOptions(path: Endpoint.updateSchedule(scheduleEntityId)), - ), + test( + 'getSchedulesByDate passes date query parameters and maps list response', + () async { + final start = DateTime(2026, 5, 15); + final end = DateTime(2026, 5, 16); + + final schedules = await dataSource.getSchedulesByDate(start, end); + + expect(schedules.map((schedule) => schedule.id), [ + 'schedule-1', + 'schedule-2', + ]); + expect( + adapter.requests.single.query['startDate'], + start.toIso8601String(), ); + expect(adapter.requests.single.query['endDate'], end.toIso8601String()); + }, + ); - final call = scheduleRemoteDataSourceImpl.updateSchedule(tScheduleEntity); - - expect(call, throwsException); - }); + test('non-200 responses surface failures for callers', () async { + adapter.statusCode = 500; + + await expectLater( + dataSource.createSchedule(_schedule('schedule-1')), + throwsException, + ); + await expectLater( + dataSource.getScheduleById('schedule-1'), + throwsException, + ); }); +} - group('deleteSchedule', () { - test('should perform a DELETE request on the /schedule/delete endpoint', - () async { - // arrange - when(dio.delete(Endpoint.deleteScheduleById(scheduleEntityId))) - .thenAnswer( - (_) async => Response( - statusCode: 200, - requestOptions: RequestOptions( - path: Endpoint.deleteScheduleById(scheduleEntityId)), - ), - ); +ScheduleEntity _schedule(String id) { + return ScheduleEntity( + id: id, + place: const PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: 'Meeting $id', + scheduleTime: DateTime(2026, 5, 15, 9), + moveTime: const Duration(minutes: 20), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 5), + scheduleNote: 'note', + ); +} - // act - await scheduleRemoteDataSourceImpl.deleteSchedule(tScheduleEntity); - - // assert - verify(dio.delete(Endpoint.deleteScheduleById(scheduleEntityId))) - .called(1); - }); - - test('should throw an exception when the response code is not 204', - () async { - when(dio.delete(Endpoint.deleteScheduleById(scheduleEntityId))) - .thenAnswer( - (_) async => Response( - statusCode: 400, - requestOptions: RequestOptions( - path: Endpoint.deleteScheduleById(scheduleEntityId)), - ), - ); +class _ScheduleRequest { + const _ScheduleRequest({ + required this.method, + required this.path, + required this.query, + required this.body, + }); - // act - final call = scheduleRemoteDataSourceImpl.deleteSchedule(tScheduleEntity); + final String method; + final String path; + final Map query; + final Map body; +} - // assert - expect(call, throwsException); - }); - }); +class _ScheduleAdapter implements HttpClientAdapter { + int statusCode = 200; + final requests = <_ScheduleRequest>[]; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requests.add( + _ScheduleRequest( + method: options.method, + path: options.path, + query: Map.from(options.queryParameters), + body: options.data is Map + ? Map.from(options.data as Map) + : const {}, + ), + ); + + if (options.method == 'GET' && options.path.contains('schedule-1')) { + return _json({'data': _scheduleJson('schedule-1')}); + } + if (options.method == 'GET') { + return _json({ + 'data': [_scheduleJson('schedule-1'), _scheduleJson('schedule-2')], + }); + } + return _json({'data': null}); + } + + ResponseBody _json(Object body) { + return ResponseBody.fromString( + jsonEncode(body), + statusCode, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + Map _scheduleJson(String id) { + return { + 'scheduleId': id, + 'place': {'placeId': 'place-1', 'placeName': 'Office'}, + 'scheduleName': id == 'schedule-1' ? 'Morning meeting' : 'Lunch', + 'scheduleTime': DateTime(2026, 5, 15, 9).toIso8601String(), + 'moveTime': 20, + 'scheduleSpareTime': 5, + 'scheduleNote': 'note', + 'latenessTime': 0, + 'doneStatus': 'NORMAL', + }; + } + + @override + void close({bool force = false}) {} } diff --git a/test/data/data_sources/token_local_data_source_test.dart b/test/data/data_sources/token_local_data_source_test.dart new file mode 100644 index 00000000..30b24027 --- /dev/null +++ b/test/data/data_sources/token_local_data_source_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/data/data_sources/token_local_data_source.dart'; +import 'package:on_time_front/domain/entities/token_entity.dart'; + +void main() { + late TokenLocalDataSourceImpl dataSource; + + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + dataSource = TokenLocalDataSourceImpl(); + }); + + test('stores and reads access and refresh tokens together', () async { + const token = TokenEntity( + accessToken: 'access-token', + refreshToken: 'refresh-token', + ); + + await dataSource.storeTokens(token); + + expect(await dataSource.getToken(), token); + }); + + test('auth token write only updates the access token slot', () async { + await dataSource.storeTokens( + const TokenEntity( + accessToken: 'old-access', + refreshToken: 'refresh-token', + ), + ); + + await dataSource.storeAuthToken('new-access'); + + expect( + await dataSource.getToken(), + const TokenEntity( + accessToken: 'new-access', + refreshToken: 'refresh-token', + ), + ); + }); + + test( + 'delete removes both token values and missing tokens fail clearly', + () async { + await dataSource.storeTokens( + const TokenEntity( + accessToken: 'access-token', + refreshToken: 'refresh-token', + ), + ); + + await dataSource.deleteToken(); + + await expectLater( + dataSource.getToken(), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('Token not found'), + ), + ), + ); + }, + ); +} diff --git a/test/data/models/alarm_models_test.dart b/test/data/models/alarm_models_test.dart index 4bac5855..6b5b5e93 100644 --- a/test/data/models/alarm_models_test.dart +++ b/test/data/models/alarm_models_test.dart @@ -5,7 +5,11 @@ import 'package:on_time_front/data/models/alarm_status_report_model.dart'; import 'package:on_time_front/data/models/alarm_window_schedule_model.dart'; import 'package:on_time_front/data/models/scheduled_alarm_record_model.dart'; import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; void main() { test('alarm settings maps backend defaults and update request JSON', () { @@ -16,8 +20,30 @@ void main() { expect(model.toEntity().alarmsEnabled, isFalse); expect(model.toEntity().defaultAlarmOffsetMinutes, 5); - expect(const UpdateAlarmSettingsRequestModel(alarmsEnabled: true).toJson(), - {'alarmsEnabled': true}); + expect( + const UpdateAlarmSettingsRequestModel(alarmsEnabled: true).toJson(), + {'alarmsEnabled': true}, + ); + }); + + test('alarm settings round trip preserves explicit backend values', () { + final updatedAt = DateTime.utc(2026, 5, 5, 9); + final model = AlarmSettingsModel( + alarmsEnabled: true, + defaultAlarmOffsetMinutes: 11, + updatedAt: updatedAt, + ); + + expect(model.toJson(), { + 'alarmsEnabled': true, + 'defaultAlarmOffsetMinutes': 11, + 'updatedAt': updatedAt.toIso8601String(), + }); + + final fromEntity = AlarmSettingsModel.fromEntity(model.toEntity()); + expect(fromEntity.alarmsEnabled, isTrue); + expect(fromEntity.defaultAlarmOffsetMinutes, 11); + expect(fromEntity.updatedAt, updatedAt); }); test('device info serializes provider wire values', () { @@ -42,10 +68,7 @@ void main() { final entity = AlarmWindowScheduleModel.fromJson({ 'scheduleId': 'schedule-1', 'scheduleName': 'Morning meeting', - 'place': { - 'placeId': 'place-1', - 'placeName': 'Office', - }, + 'place': {'placeId': 'place-1', 'placeName': 'Office'}, 'scheduleTime': '2026-05-05T10:00:00.000', 'moveTime': 20, 'scheduleSpareTime': 10, @@ -64,69 +87,70 @@ void main() { expect(entity.place.placeName, 'Office'); expect(entity.doneStatus, ScheduleDoneStatus.notEnded); expect(entity.moveTime, const Duration(minutes: 20)); - expect(entity.preparation.preparationStepList.single.nextPreparationId, - 'prep-2'); + expect( + entity.preparation.preparationStepList.single.nextPreparationId, + 'prep-2', + ); }); - test('status report and registry record serialize alarm contract payloads', - () { - final now = DateTime.utc(2026, 5, 5, 9, 0, 0, 123, 456); - final statusJson = AlarmStatusReportModel( - AlarmStatusReport( - deviceId: 'device-1', - reconciledAt: now, - scheduleWindowStart: now, - scheduleWindowEnd: now.add(const Duration(days: 8)), - alarmCoverageStart: now, - alarmCoverageEnd: now.add(const Duration(days: 7)), - status: AlarmReconciliationStatus.partial, - permissionIssue: AlarmPermissionIssue.notificationPermissionDenied, - nativeAlarmProvider: AlarmProvider.none, - fallbackProvider: AlarmProvider.localNotification, - armedScheduleCount: 1, - armedScheduleIds: const ['schedule-1'], - skippedScheduleCount: 2, - failures: const [ - AlarmFailure( - scheduleId: 'schedule-2', - reason: AlarmFailureReason.platformError, - message: 'failed', - ), - ], - ), - ).toJson(); + test( + 'status report and registry record serialize alarm contract payloads', + () { + final now = DateTime.utc(2026, 5, 5, 9, 0, 0, 123, 456); + final statusJson = AlarmStatusReportModel( + AlarmStatusReport( + deviceId: 'device-1', + reconciledAt: now, + scheduleWindowStart: now, + scheduleWindowEnd: now.add(const Duration(days: 8)), + alarmCoverageStart: now, + alarmCoverageEnd: now.add(const Duration(days: 7)), + status: AlarmReconciliationStatus.partial, + permissionIssue: AlarmPermissionIssue.notificationPermissionDenied, + nativeAlarmProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.localNotification, + armedScheduleCount: 1, + armedScheduleIds: const ['schedule-1'], + skippedScheduleCount: 2, + failures: const [ + AlarmFailure( + scheduleId: 'schedule-2', + reason: AlarmFailureReason.platformError, + message: 'failed', + ), + ], + ), + ).toJson(); - expect(statusJson['status'], 'partial'); - expect(statusJson['permissionIssue'], 'notificationPermissionDenied'); - expect(statusJson['reconciledAt'], '2026-05-05T09:00:00.123Z'); - expect(statusJson['armedScheduleIds'], ['schedule-1']); - expect( - (statusJson['failures'] as List).single['reason'], - 'platformError', - ); + expect(statusJson['status'], 'partial'); + expect(statusJson['permissionIssue'], 'notificationPermissionDenied'); + expect(statusJson['reconciledAt'], '2026-05-05T09:00:00.123Z'); + expect(statusJson['armedScheduleIds'], ['schedule-1']); + expect( + (statusJson['failures'] as List).single['reason'], + 'platformError', + ); - final recordJson = ScheduledAlarmRecordModel( - ScheduledAlarmRecord( - scheduleId: 'schedule-1', - alarmTime: now, - preparationStartTime: now.add(const Duration(minutes: 5)), - scheduleFingerprint: 'fingerprint', - nativeAlarmId: 123, - fallbackNotificationId: 123, - provider: AlarmProvider.localNotification, - scheduleTitle: 'Morning meeting', - payload: const { - 'type': 'schedule_alarm', - 'scheduleId': 'schedule-1', - }, - ), - ).toJson(); + final recordJson = ScheduledAlarmRecordModel( + ScheduledAlarmRecord( + scheduleId: 'schedule-1', + alarmTime: now, + preparationStartTime: now.add(const Duration(minutes: 5)), + scheduleFingerprint: 'fingerprint', + nativeAlarmId: 123, + fallbackNotificationId: 123, + provider: AlarmProvider.localNotification, + scheduleTitle: 'Morning meeting', + payload: const {'type': 'schedule_alarm', 'scheduleId': 'schedule-1'}, + ), + ).toJson(); - final decoded = ScheduledAlarmRecordModel.fromJson(recordJson).record; - expect(decoded.provider, AlarmProvider.localNotification); - expect(decoded.payload['type'], 'schedule_alarm'); - expect(decoded.scheduleFingerprint, 'fingerprint'); - }); + final decoded = ScheduledAlarmRecordModel.fromJson(recordJson).record; + expect(decoded.provider, AlarmProvider.localNotification); + expect(decoded.payload['type'], 'schedule_alarm'); + expect(decoded.scheduleFingerprint, 'fingerprint'); + }, + ); test('status report defaults to lower-camel and supports backend enums', () { final now = DateTime.utc(2026, 5, 5, 9); @@ -162,4 +186,462 @@ void main() { expect(backendJson['status'], 'ARMED'); expect(backendJson['nativeAlarmProvider'], 'IOS_ALARM_KIT'); }); + + test('status report serializes all upper-snake enum branches', () { + final now = DateTime.utc(2026, 5, 5, 9); + + Map reportJson({ + required AlarmReconciliationStatus status, + required AlarmProvider nativeProvider, + required AlarmProvider fallbackProvider, + AlarmPermissionIssue? permissionIssue, + AlarmFailureReason failureReason = AlarmFailureReason.unknown, + }) { + return AlarmStatusReportModel( + AlarmStatusReport( + deviceId: 'device-1', + reconciledAt: now, + scheduleWindowStart: now, + scheduleWindowEnd: now.add(const Duration(days: 8)), + alarmCoverageStart: now, + alarmCoverageEnd: now.add(const Duration(days: 7)), + status: status, + permissionIssue: permissionIssue, + nativeAlarmProvider: nativeProvider, + fallbackProvider: fallbackProvider, + armedScheduleCount: 0, + armedScheduleIds: const [], + skippedScheduleCount: 1, + failures: [AlarmFailure(reason: failureReason)], + ), + ).toJson(wireFormat: AlarmStatusReportWireFormat.upperSnake); + } + + expect( + reportJson( + status: AlarmReconciliationStatus.partial, + nativeProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.none, + permissionIssue: AlarmPermissionIssue.nativePermissionDenied, + failureReason: AlarmFailureReason.preparationLoadFailed, + ), + containsPair('status', 'PARTIAL'), + ); + expect( + reportJson( + status: AlarmReconciliationStatus.disabled, + nativeProvider: AlarmProvider.localNotification, + fallbackProvider: AlarmProvider.iosAlarmKit, + permissionIssue: AlarmPermissionIssue.notificationPermissionDenied, + failureReason: AlarmFailureReason.scheduleInvalid, + ), + containsPair('permissionIssue', 'NOTIFICATION_PERMISSION_DENIED'), + ); + expect( + reportJson( + status: AlarmReconciliationStatus.permissionNeeded, + nativeProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.localNotification, + failureReason: AlarmFailureReason.platformError, + )['failures'], + [ + {'reason': 'PLATFORM_ERROR'}, + ], + ); + expect( + reportJson( + status: AlarmReconciliationStatus.unsupported, + nativeProvider: AlarmProvider.iosAlarmKit, + fallbackProvider: AlarmProvider.none, + ), + containsPair('nativeAlarmProvider', 'IOS_ALARM_KIT'), + ); + expect( + reportJson( + status: AlarmReconciliationStatus.settingsUnavailable, + nativeProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.none, + ), + containsPair('status', 'SETTINGS_UNAVAILABLE'), + ); + }); + + test('alarm enum wire values tolerate backend and unknown values', () { + expect(AlarmProvider.androidAlarmManager.wireValue, 'androidAlarmManager'); + expect(AlarmProvider.iosAlarmKit.wireValue, 'iosAlarmKit'); + expect(AlarmProvider.localNotification.wireValue, 'localNotification'); + expect(AlarmProvider.none.wireValue, 'none'); + expect( + AlarmPermissionStateWireValue.fromWireValue('granted'), + AlarmPermissionState.granted, + ); + expect( + AlarmPermissionStateWireValue.fromWireValue('denied'), + AlarmPermissionState.denied, + ); + expect( + AlarmProviderWireValue.fromWireValue('ANDROID_ALARM_MANAGER'), + AlarmProvider.androidAlarmManager, + ); + expect( + AlarmProviderWireValue.fromWireValue('IOS_ALARM_KIT'), + AlarmProvider.iosAlarmKit, + ); + expect( + AlarmProviderWireValue.fromWireValue('LOCAL_NOTIFICATION'), + AlarmProvider.localNotification, + ); + expect( + AlarmProviderWireValue.fromWireValue('unexpected'), + AlarmProvider.none, + ); + + expect( + AlarmPermissionStateWireValue.fromWireValue('notDetermined'), + AlarmPermissionState.notDetermined, + ); + expect( + AlarmPermissionStateWireValue.fromWireValue('unsupported'), + AlarmPermissionState.unsupported, + ); + expect( + AlarmPermissionIssueWireValue.fromWireValue('nativePermissionDenied'), + AlarmPermissionIssue.nativePermissionDenied, + ); + expect(AlarmPermissionIssueWireValue.fromWireValue('unknown'), isNull); + + expect( + AlarmFailureReasonWireValue.fromWireValue('PREPARATION_LOAD_FAILED'), + AlarmFailureReason.preparationLoadFailed, + ); + expect( + AlarmFailureReasonWireValue.fromWireValue('SCHEDULE_INVALID'), + AlarmFailureReason.scheduleInvalid, + ); + expect( + AlarmFailureReasonWireValue.fromWireValue('UNKNOWN'), + AlarmFailureReason.unknown, + ); + expect( + AlarmFailureReason.preparationLoadFailed.wireValue, + 'preparationLoadFailed', + ); + expect(AlarmFailureReason.scheduleInvalid.wireValue, 'scheduleInvalid'); + expect(AlarmFailureReason.platformError.wireValue, 'platformError'); + expect(AlarmFailureReason.unknown.wireValue, 'unknown'); + + expect(AlarmReconciliationStatus.armed.wireValue, 'armed'); + expect(AlarmReconciliationStatus.partial.wireValue, 'partial'); + expect(AlarmReconciliationStatus.disabled.wireValue, 'disabled'); + expect( + AlarmReconciliationStatus.permissionNeeded.wireValue, + 'permissionNeeded', + ); + expect(AlarmReconciliationStatus.unsupported.wireValue, 'unsupported'); + expect( + AlarmReconciliationStatus.settingsUnavailable.wireValue, + 'settingsUnavailable', + ); + expect( + AlarmReconciliationStatusWireValue.fromWireValue('permissionNeeded'), + AlarmReconciliationStatus.permissionNeeded, + ); + expect( + AlarmReconciliationStatusWireValue.fromWireValue('partial'), + AlarmReconciliationStatus.partial, + ); + expect( + AlarmReconciliationStatusWireValue.fromWireValue('disabled'), + AlarmReconciliationStatus.disabled, + ); + expect( + AlarmReconciliationStatusWireValue.fromWireValue('unsupported'), + AlarmReconciliationStatus.unsupported, + ); + expect( + AlarmReconciliationStatusWireValue.fromWireValue('anything-else'), + AlarmReconciliationStatus.settingsUnavailable, + ); + }); + + test( + 'alarm scheduling helpers derive stable alarm records from schedules', + () { + final schedule = _scheduleWithPreparation( + doneStatus: ScheduleDoneStatus.notEnded, + ); + + final record = buildScheduledAlarmRecord( + schedule, + alarmOffset: const Duration(minutes: 7), + provider: AlarmProvider.androidAlarmManager, + ); + + expect(isAlarmEligibleSchedule(schedule), isTrue); + expect(record.scheduleId, schedule.id); + expect( + record.alarmTime, + schedule.preparationStartTime.subtract(const Duration(minutes: 7)), + ); + expect(record.preparationStartTime, schedule.preparationStartTime); + expect(record.nativeAlarmId, stableAlarmId(schedule.id)); + expect(record.fallbackNotificationId, stableAlarmId(schedule.id)); + expect(record.scheduleFingerprint, schedule.cacheFingerprint); + expect( + record.payload['alarmLaunchPayloadVersion'], + alarmLaunchPayloadVersion, + ); + expect(record.payload['promptVariant'], 'alarm'); + expect(record.payload['placeName'], 'Office'); + }, + ); + + test('ended schedules are not eligible for alarm scheduling', () { + expect( + isAlarmEligibleSchedule( + _scheduleWithPreparation(doneStatus: ScheduleDoneStatus.normalEnd), + ), + isFalse, + ); + }); + + test('scheduled alarm records copy mutable scheduling fields only', () { + final original = ScheduledAlarmRecord( + scheduleId: 'schedule-1', + alarmTime: DateTime.utc(2026, 5, 15, 8), + preparationStartTime: DateTime.utc(2026, 5, 15, 8, 5), + scheduleFingerprint: 'fingerprint', + nativeAlarmId: 1, + fallbackNotificationId: 2, + provider: AlarmProvider.androidAlarmManager, + scheduleTitle: 'Morning meeting', + payload: const {'type': 'schedule_alarm'}, + ); + + final updated = original.copyWith( + nativeAlarmId: 3, + fallbackNotificationId: 4, + provider: AlarmProvider.localNotification, + payload: const {'type': 'fallback_alarm'}, + ); + + expect(updated.scheduleId, original.scheduleId); + expect(updated.nativeAlarmId, 3); + expect(updated.fallbackNotificationId, 4); + expect(updated.provider, AlarmProvider.localNotification); + expect(updated.payload['type'], 'fallback_alarm'); + }); + + test('alarm exceptions and result summaries expose user-visible context', () { + final exception = const AlarmSchedulingException( + reason: AlarmFailureReason.platformError, + permissionIssue: AlarmPermissionIssue.nativePermissionDenied, + message: 'permission missing', + ); + final now = DateTime.utc(2026, 5, 15); + final result = AlarmReconciliationResult( + status: AlarmReconciliationStatus.partial, + permissionIssue: AlarmPermissionIssue.notificationPermissionDenied, + nativeAlarmProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.localNotification, + armedScheduleIds: const ['schedule-1', 'schedule-2'], + skippedScheduleCount: 1, + failures: const [ + AlarmFailure( + scheduleId: 'schedule-3', + reason: AlarmFailureReason.scheduleInvalid, + message: 'ended', + ), + ], + scheduleWindowStart: now, + scheduleWindowEnd: now.add(const Duration(days: 8)), + alarmCoverageStart: now, + alarmCoverageEnd: now.add(const Duration(days: 7)), + ); + + expect(exception.toString(), contains('permission missing')); + expect( + const DeviceSessionNotActiveException().toString(), + 'DeviceSessionNotActiveException', + ); + expect(result.armedScheduleCount, 2); + expect(result.failures.single.reason, AlarmFailureReason.scheduleInvalid); + }); + + test( + 'alarm value objects compare the fields used by alarm sync contracts', + () { + final now = DateTime.utc(2026, 5, 15); + const device = AlarmDeviceInfo( + deviceId: 'device-1', + platform: 'android', + appVersion: '1.0.0', + osVersion: '35', + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + ); + const failure = AlarmFailure( + scheduleId: 'schedule-1', + reason: AlarmFailureReason.platformError, + message: 'permission', + ); + final result = AlarmReconciliationResult( + status: AlarmReconciliationStatus.partial, + permissionIssue: AlarmPermissionIssue.nativePermissionDenied, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + armedScheduleIds: const ['schedule-1'], + skippedScheduleCount: 2, + failures: const [failure], + scheduleWindowStart: now, + scheduleWindowEnd: now.add(const Duration(days: 8)), + alarmCoverageStart: now, + alarmCoverageEnd: now.add(const Duration(days: 7)), + ); + final sameResult = AlarmReconciliationResult( + status: AlarmReconciliationStatus.partial, + permissionIssue: AlarmPermissionIssue.nativePermissionDenied, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + armedScheduleIds: const ['schedule-1'], + skippedScheduleCount: 2, + failures: const [failure], + scheduleWindowStart: now, + scheduleWindowEnd: now.add(const Duration(days: 8)), + alarmCoverageStart: now, + alarmCoverageEnd: now.add(const Duration(days: 7)), + ); + final report = AlarmStatusReport( + deviceId: device.deviceId, + reconciledAt: now, + scheduleWindowStart: result.scheduleWindowStart, + scheduleWindowEnd: result.scheduleWindowEnd, + alarmCoverageStart: result.alarmCoverageStart, + alarmCoverageEnd: result.alarmCoverageEnd, + status: result.status, + permissionIssue: result.permissionIssue, + nativeAlarmProvider: result.nativeAlarmProvider, + fallbackProvider: result.fallbackProvider, + armedScheduleCount: result.armedScheduleCount, + armedScheduleIds: result.armedScheduleIds, + skippedScheduleCount: result.skippedScheduleCount, + failures: result.failures, + ); + final settings = AlarmSettings( + alarmsEnabled: true, + defaultAlarmOffsetMinutes: 7, + updatedAt: now, + ); + const capabilities = AlarmSchedulerCapabilities( + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + ); + final record = ScheduledAlarmRecord( + scheduleId: 'schedule-1', + alarmTime: now, + preparationStartTime: now.add(const Duration(minutes: 5)), + scheduleFingerprint: 'fingerprint', + nativeAlarmId: 10, + fallbackNotificationId: 11, + provider: AlarmProvider.androidAlarmManager, + scheduleTitle: 'Morning meeting', + payload: const {'type': 'schedule_alarm'}, + ); + + expect(settings.alarmOffset, const Duration(minutes: 7)); + expect(settings.props, [true, 7, now]); + expect(device.props, [ + 'device-1', + 'android', + '1.0.0', + '35', + true, + AlarmProvider.androidAlarmManager, + AlarmProvider.localNotification, + ]); + expect(capabilities.props, [ + true, + AlarmProvider.androidAlarmManager, + AlarmProvider.localNotification, + ]); + expect(record.props, [ + 'schedule-1', + now, + now.add(const Duration(minutes: 5)), + 'fingerprint', + 10, + 11, + AlarmProvider.androidAlarmManager, + 'Morning meeting', + const {'type': 'schedule_alarm'}, + ]); + expect(failure.props, [ + 'schedule-1', + AlarmFailureReason.platformError, + 'permission', + ]); + expect(result, equals(sameResult)); + expect(result.props, [ + AlarmReconciliationStatus.partial, + AlarmPermissionIssue.nativePermissionDenied, + AlarmProvider.androidAlarmManager, + AlarmProvider.localNotification, + const ['schedule-1'], + 2, + const [failure], + now, + now.add(const Duration(days: 8)), + now, + now.add(const Duration(days: 7)), + ]); + expect(report, equals(report)); + expect( + report.props, + containsAll([ + 'device-1', + AlarmReconciliationStatus.partial, + AlarmPermissionIssue.nativePermissionDenied, + AlarmProvider.androidAlarmManager, + AlarmProvider.localNotification, + 1, + 2, + const [failure], + ]), + ); + }, + ); +} + +ScheduleWithPreparationEntity _scheduleWithPreparation({ + required ScheduleDoneStatus doneStatus, +}) { + return ScheduleWithPreparationEntity( + id: 'schedule-1', + place: const PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: 'Morning meeting', + scheduleTime: DateTime.utc(2026, 5, 15, 9), + moveTime: const Duration(minutes: 20), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 5), + scheduleNote: 'Bring notes', + doneStatus: doneStatus, + preparation: const PreparationWithTimeEntity( + preparationStepList: [ + PreparationStepWithTimeEntity( + id: 'prep-1', + preparationName: 'Pack', + preparationTime: Duration(minutes: 10), + nextPreparationId: 'prep-2', + ), + PreparationStepWithTimeEntity( + id: 'prep-2', + preparationName: 'Dress', + preparationTime: Duration(minutes: 15), + nextPreparationId: null, + ), + ], + ), + ); } diff --git a/test/data/models/get_preparation_step_response_model_test.dart b/test/data/models/get_preparation_step_response_model_test.dart index d98c0d8a..d96e17d4 100644 --- a/test/data/models/get_preparation_step_response_model_test.dart +++ b/test/data/models/get_preparation_step_response_model_test.dart @@ -1,7 +1,40 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/data/models/get_preparation_step_response_model.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; void main() { + test('maps preparation step JSON and entity durations in minutes', () { + final model = GetPreparationStepResponseModel.fromJson({ + 'preparationId': 'prep-1', + 'preparationName': 'Shower', + 'preparationTime': 12, + 'nextPreparationId': 'prep-2', + }); + + expect(model.id, 'prep-1'); + expect(model.toJson(), { + 'preparationId': 'prep-1', + 'preparationName': 'Shower', + 'preparationTime': 12, + 'nextPreparationId': 'prep-2', + }); + + final entity = model.toEntity(); + expect(entity.preparationTime, const Duration(minutes: 12)); + + final fromEntity = GetPreparationStepResponseModel.fromEntity( + const PreparationStepEntity( + id: 'prep-3', + preparationName: 'Pack bag', + preparationTime: Duration(minutes: 7), + nextPreparationId: null, + ), + ); + expect(fromEntity.id, 'prep-3'); + expect(fromEntity.preparationTime, 7); + expect(fromEntity.nextPreparationId, isNull); + }); + group('PreparationResponseModelListExtension', () { test('orders preparation steps by nextPreparationId chain', () { final models = [ @@ -27,10 +60,11 @@ void main() { final preparation = models.toPreparationEntity(); - expect( - preparation.preparationStepList.map((step) => step.id), - ['first', 'second', 'third'], - ); + expect(preparation.preparationStepList.map((step) => step.id), [ + 'first', + 'second', + 'third', + ]); }); test('keeps unlinked steps instead of dropping them', () { @@ -57,10 +91,11 @@ void main() { final preparation = models.toPreparationEntity(); - expect( - preparation.preparationStepList.map((step) => step.id), - ['first', 'second', 'unlinked'], - ); + expect(preparation.preparationStepList.map((step) => step.id), [ + 'first', + 'second', + 'unlinked', + ]); }); }); } diff --git a/test/data/models/get_schedule_response_model_test.dart b/test/data/models/get_schedule_response_model_test.dart new file mode 100644 index 00000000..0ed1f974 --- /dev/null +++ b/test/data/models/get_schedule_response_model_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/data/models/create_schedule_request_model.dart'; +import 'package:on_time_front/data/models/fcm_token_register_request_model.dart'; +import 'package:on_time_front/data/models/get_place_response_model.dart'; +import 'package:on_time_front/data/models/get_schedule_response_model.dart'; +import 'package:on_time_front/data/models/sign_in_with_apple_request_model.dart'; +import 'package:on_time_front/data/models/sign_in_with_google_request_model.dart'; +import 'package:on_time_front/data/models/update_schedule_request_model.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; + +void main() { + test('toEntity maps schedule response fields and durations', () { + final scheduleTime = DateTime(2026, 5, 15, 9, 30); + final model = GetScheduleResponseModel( + scheduleId: 'schedule-1', + place: const GetPlaceResponseModel( + placeId: 'place-1', + placeName: 'Office', + ), + scheduleName: 'Morning standup', + scheduleTime: scheduleTime, + moveTime: 20, + scheduleSpareTime: 5, + scheduleNote: 'Bring laptop', + latenessTime: 3, + doneStatus: 'LATE', + ); + + final entity = model.toEntity(); + + expect(entity.id, 'schedule-1'); + expect(entity.place.id, 'place-1'); + expect(entity.place.placeName, 'Office'); + expect(entity.scheduleName, 'Morning standup'); + expect(entity.scheduleTime, scheduleTime); + expect(entity.moveTime, const Duration(minutes: 20)); + expect(entity.scheduleSpareTime, const Duration(minutes: 5)); + expect(entity.scheduleNote, 'Bring laptop'); + expect(entity.latenessTime, 3); + expect(entity.doneStatus, ScheduleDoneStatus.lateEnd); + expect(entity.isChanged, isFalse); + expect(entity.isStarted, isFalse); + }); + + test( + 'toEntity maps server done status values and null lateness fallback', + () { + ScheduleDoneStatus statusFor(String? doneStatus) { + return GetScheduleResponseModel( + scheduleId: 'schedule-1', + place: const GetPlaceResponseModel( + placeId: 'place-1', + placeName: 'Office', + ), + scheduleName: 'Meeting', + scheduleTime: DateTime(2026, 5, 15), + moveTime: 10, + scheduleSpareTime: 0, + scheduleNote: '', + latenessTime: null, + doneStatus: doneStatus, + ).toEntity().doneStatus; + } + + expect(statusFor('NORMAL'), ScheduleDoneStatus.normalEnd); + expect(statusFor('ABNORMAL'), ScheduleDoneStatus.abnormalEnd); + expect(statusFor('NOT_ENDED'), ScheduleDoneStatus.notEnded); + expect(statusFor('unexpected'), ScheduleDoneStatus.notEnded); + expect(statusFor(null), ScheduleDoneStatus.notEnded); + }, + ); + + test('place response model maps entity and json representations', () { + const place = PlaceEntity(id: 'place-1', placeName: 'Office'); + + final model = GetPlaceResponseModel.fromEntity(place); + + expect(model.placeId, 'place-1'); + expect(model.placeName, 'Office'); + expect(model.toEntity(), place); + expect(model.toJson(), {'placeId': 'place-1', 'placeName': 'Office'}); + expect(GetPlaceResponseModel.fromJson(model.toJson()).toEntity(), place); + }); + + test('schedule request models trim backend constrained text fields', () { + final entity = ScheduleEntity( + id: 'schedule-1', + place: const PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: ' ${'Long schedule name ' * 3}', + scheduleTime: DateTime(2026, 5, 15, 9), + moveTime: const Duration(minutes: 20), + isChanged: true, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 5), + scheduleNote: ' ${'note ' * 300}', + ); + + final create = CreateScheduleRequestModel.fromEntity(entity); + final update = UpdateScheduleRequestModel.fromEntity(entity); + + expect(create.scheduleId, 'schedule-1'); + expect(create.placeId, 'place-1'); + expect(create.moveTime, 20); + expect(create.isChange, isTrue); + expect(create.scheduleSpareTime, 5); + expect(create.scheduleName.length, 30); + expect(create.scheduleNote.length, 1000); + expect(update.scheduleName, create.scheduleName); + expect(update.scheduleNote, create.scheduleNote); + expect(update.toJson()['scheduleId'], 'schedule-1'); + }); + + test('auth and notification request models serialize backend payloads', () { + final google = SignInWithGoogleRequestModel( + idToken: 'google-id-token', + refreshToken: '', + ); + final appleWithoutEmail = SignInWithAppleRequestModel( + idToken: 'apple-id-token', + authCode: 'auth-code', + fullName: 'Apple User', + ); + final fcm = FcmTokenRegisterRequestModel( + firebaseToken: 'fcm-token', + deviceId: 'device-1', + ); + + expect(google.toJson(), {'idToken': 'google-id-token', 'refreshToken': ''}); + expect( + SignInWithGoogleRequestModel.fromJson(google.toJson()).idToken, + 'google-id-token', + ); + expect(appleWithoutEmail.toJson().containsKey('email'), isFalse); + expect( + SignInWithAppleRequestModel.fromJson({ + ...appleWithoutEmail.toJson(), + 'email': 'apple@example.com', + }).email, + 'apple@example.com', + ); + expect(fcm.toJson(), { + 'firebaseToken': 'fcm-token', + 'deviceId': 'device-1', + }); + expect( + FcmTokenRegisterRequestModel.fromJson(fcm.toJson()).deviceId, + 'device-1', + ); + }); +} diff --git a/test/data/models/preparation_create_models_test.dart b/test/data/models/preparation_create_models_test.dart new file mode 100644 index 00000000..eeb1a4a8 --- /dev/null +++ b/test/data/models/preparation_create_models_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/data/models/create_defualt_preparation_request_model.dart'; +import 'package:on_time_front/data/models/create_preparation_schedule_request_model.dart'; +import 'package:on_time_front/data/models/create_preparation_step_request_model.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; + +void main() { + const step = PreparationStepEntity( + id: 'step-1', + preparationName: 'Pack bag', + preparationTime: Duration(minutes: 7), + nextPreparationId: 'step-2', + ); + + test('schedule create model serializes and restores preparation steps', () { + final model = PreparationScheduleCreateRequestModel.fromEntity(step); + + expect(model.toJson(), { + 'preparationId': 'step-1', + 'preparationName': 'Pack bag', + 'preparationTime': 7, + 'nextPreparationId': 'step-2', + }); + expect( + PreparationScheduleCreateRequestModel.fromJson(model.toJson()).toEntity(), + step, + ); + }); + + test('schedule create list extension maps ordered steps', () { + final models = + PreparationScheduleCreateRequestModelListExtension.fromEntityList([ + step, + const PreparationStepEntity( + id: 'step-2', + preparationName: 'Shoes', + preparationTime: Duration(minutes: 3), + ), + ]); + + expect(models.map((model) => model.id), ['step-1', 'step-2']); + expect(models.toEntityList().map((entity) => entity.nextPreparationId), [ + 'step-2', + null, + ]); + }); + + test( + 'default preparation create model serializes backend request fields', + () { + final model = CreatePreparationStepRequestModel.fromEntity(step); + + expect(model.toJson(), { + 'preparationId': 'step-1', + 'preparationName': 'Pack bag', + 'preparationTime': 7, + 'nextPreparationId': 'step-2', + }); + expect( + CreatePreparationStepRequestModel.fromJson(model.toJson()).toEntity(), + step, + ); + }, + ); + + test('default preparation request maps spare time note and step list', () { + const preparation = PreparationEntity(preparationStepList: [step]); + + final model = CreateDefaultPreparationRequestModel.fromEntity( + preparationEntity: preparation, + spareTime: const Duration(minutes: 12), + note: 'Bring umbrella', + ); + + expect(model.spareTime, '12'); + expect(model.note, 'Bring umbrella'); + expect(model.preparationList.single.id, 'step-1'); + expect(model.toJson()['spareTime'], '12'); + }); +} diff --git a/test/data/models/preparation_update_models_test.dart b/test/data/models/preparation_update_models_test.dart new file mode 100644 index 00000000..0d428dfe --- /dev/null +++ b/test/data/models/preparation_update_models_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/data/models/update_preparation_schedule_request_model.dart'; +import 'package:on_time_front/data/models/update_preparation_user_request_model.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; + +void main() { + const step = PreparationStepEntity( + id: 'step-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 15), + nextPreparationId: 'step-2', + ); + + test('schedule modify model round-trips preparation steps', () { + final model = PreparationScheduleModifyRequestModel.fromEntity(step); + + expect(model.id, 'step-1'); + expect(model.preparationName, 'Shower'); + expect(model.preparationTime, 15); + expect(model.nextPreparationId, 'step-2'); + expect(model.toJson(), { + 'preparationId': 'step-1', + 'preparationName': 'Shower', + 'preparationTime': 15, + 'nextPreparationId': 'step-2', + }); + expect(model.toEntity(), step); + }); + + test('schedule modify list extension maps every step', () { + final models = + PreparationScheduleModifyRequestModelListExtension.fromEntityList([ + step, + step.copyWith(id: 'step-2', nextPreparationId: null), + ]); + + expect(models.map((model) => model.id), ['step-1', 'step-2']); + expect(models.toEntityList().map((entity) => entity.id), [ + 'step-1', + 'step-2', + ]); + }); + + test('user modify model round-trips preparation steps', () { + final model = PreparationUserModifyRequestModel.fromEntity(step); + + expect(model.id, 'step-1'); + expect(model.preparationName, 'Shower'); + expect(model.preparationTime, 15); + expect(model.nextPreparationId, 'step-2'); + expect(model.toJson(), { + 'preparationId': 'step-1', + 'preparationName': 'Shower', + 'preparationTime': 15, + 'nextPreparationId': 'step-2', + }); + expect(model.toEntity(), step); + }); + + test('user modify list extension maps every step', () { + final models = + PreparationUserModifyRequestModelListExtension.fromEntityList([ + step, + step.copyWith(id: 'step-2', nextPreparationId: null), + ]); + + expect(models.map((model) => model.id), ['step-1', 'step-2']); + expect(models.toEntityList().map((entity) => entity.id), [ + 'step-1', + 'step-2', + ]); + }); +} diff --git a/test/data/repositories/alarm_registry_repository_impl_test.dart b/test/data/repositories/alarm_registry_repository_impl_test.dart new file mode 100644 index 00000000..034c6b36 --- /dev/null +++ b/test/data/repositories/alarm_registry_repository_impl_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/data/data_sources/alarm_registry_local_data_source.dart'; +import 'package:on_time_front/data/repositories/alarm_registry_repository_impl.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; + +void main() { + late _FakeAlarmRegistryLocalDataSource localDataSource; + late AlarmRegistryRepositoryImpl repository; + + setUp(() { + localDataSource = _FakeAlarmRegistryLocalDataSource(); + repository = AlarmRegistryRepositoryImpl(localDataSource: localDataSource); + }); + + test('loadAll returns the current local registry', () async { + localDataSource.records = [_record('schedule-1')]; + + expect(await repository.loadAll(), [_record('schedule-1')]); + }); + + test( + 'upsert replaces an existing schedule record and keeps others', + () async { + localDataSource.records = [ + _record('schedule-1', title: 'Old'), + _record('schedule-2'), + ]; + + await repository.upsert(_record('schedule-1', title: 'New')); + + expect(localDataSource.records, [ + _record('schedule-2'), + _record('schedule-1', title: 'New'), + ]); + }, + ); + + test('deleteByScheduleId removes only the requested schedule', () async { + localDataSource.records = [_record('schedule-1'), _record('schedule-2')]; + + await repository.deleteByScheduleId('schedule-1'); + + expect(localDataSource.records, [_record('schedule-2')]); + }); + + test( + 'replaceAll deduplicates by schedule id and deleteAll clears storage', + () async { + await repository.replaceAll([ + _record('schedule-1', title: 'Old'), + _record('schedule-1', title: 'New'), + _record('schedule-2'), + ]); + + expect(localDataSource.records, [ + _record('schedule-1', title: 'New'), + _record('schedule-2'), + ]); + + await repository.deleteAll(); + + expect(localDataSource.records, isEmpty); + }, + ); +} + +ScheduledAlarmRecord _record(String scheduleId, {String title = 'Meeting'}) { + return ScheduledAlarmRecord( + scheduleId: scheduleId, + alarmTime: DateTime(2026, 5, 15, 8), + preparationStartTime: DateTime(2026, 5, 15, 8, 5), + scheduleFingerprint: 'fingerprint-$scheduleId', + provider: AlarmProvider.androidAlarmManager, + scheduleTitle: title, + payload: {'type': 'schedule_alarm', 'scheduleId': scheduleId}, + ); +} + +class _FakeAlarmRegistryLocalDataSource + implements AlarmRegistryLocalDataSource { + List records = const []; + + @override + Future> loadAll() async => records; + + @override + Future replaceAll(List records) async { + this.records = List.of(records); + } +} diff --git a/test/data/repositories/alarm_repository_impl_test.dart b/test/data/repositories/alarm_repository_impl_test.dart new file mode 100644 index 00000000..44cb6dff --- /dev/null +++ b/test/data/repositories/alarm_repository_impl_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; +import 'package:on_time_front/data/data_sources/alarm_remote_data_source.dart'; +import 'package:on_time_front/data/repositories/alarm_repository_impl.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + late _FakeAlarmRemoteDataSource remoteDataSource; + late _FakeAlarmSchedulerService schedulerService; + late AlarmRepositoryImpl repository; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + remoteDataSource = _FakeAlarmRemoteDataSource(); + schedulerService = _FakeAlarmSchedulerService(); + repository = AlarmRepositoryImpl( + remoteDataSource: remoteDataSource, + schedulerService: schedulerService, + ); + }); + + test('getDeviceId reuses a valid stored device id', () async { + SharedPreferences.setMockInitialValues({ + 'alarm_device_id': '123e4567-e89b-12d3-a456-426614174000', + }); + + expect( + await repository.getDeviceId(), + '123e4567-e89b-12d3-a456-426614174000', + ); + }); + + test( + 'getDeviceId replaces an invalid stored device id with a UUID', + () async { + SharedPreferences.setMockInitialValues({'alarm_device_id': 'bad'}); + + final deviceId = await repository.getDeviceId(); + + expect(deviceId, isNot('bad')); + expect(deviceId, matches(RegExp(r'^[0-9a-f-]{36}$'))); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('alarm_device_id'), deviceId); + }, + ); + + test( + 'buildCurrentDeviceInfo combines persisted id and scheduler capabilities', + () async { + SharedPreferences.setMockInitialValues({ + 'alarm_device_id': '123e4567-e89b-12d3-a456-426614174000', + }); + schedulerService.capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + ); + + final info = await repository.buildCurrentDeviceInfo(); + + expect(info.deviceId, '123e4567-e89b-12d3-a456-426614174000'); + expect(info.appVersion, '1.0.0'); + expect(info.supportsNativeAlarm, isTrue); + expect(info.nativeAlarmProvider, AlarmProvider.androidAlarmManager); + expect(info.fallbackProvider, AlarmProvider.localNotification); + expect(info.platform, isNotEmpty); + expect(info.osVersion, isNotEmpty); + }, + ); + + test('alarm settings calls delegate to the remote data source', () async { + remoteDataSource.settings = const AlarmSettings( + alarmsEnabled: false, + defaultAlarmOffsetMinutes: 10, + ); + + expect(await repository.getAlarmSettings(), remoteDataSource.settings); + expect( + await repository.updateAlarmSettings(alarmsEnabled: true), + const AlarmSettings(alarmsEnabled: true), + ); + expect(remoteDataSource.updatedValues, [true]); + }); + + test('device, window, and status calls forward their payloads', () async { + const deviceInfo = AlarmDeviceInfo( + deviceId: 'device-1', + platform: 'android', + appVersion: '1.0.0', + osVersion: 'android', + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + ); + final start = DateTime(2026, 5, 15); + final end = DateTime(2026, 5, 16); + final report = _alarmStatusReport(); + + await repository.registerCurrentDevice(deviceInfo); + await repository.unregisterCurrentDevice('device-1'); + expect(await repository.getAlarmWindow(start, end), isEmpty); + await repository.postAlarmStatus(report); + + expect(remoteDataSource.registeredDevices, [deviceInfo]); + expect(remoteDataSource.unregisteredDeviceIds, ['device-1']); + expect(remoteDataSource.alarmWindowRanges, [(start, end)]); + expect(remoteDataSource.statusReports, [report]); + }); +} + +AlarmStatusReport _alarmStatusReport() { + final now = DateTime(2026, 5, 15, 9); + return AlarmStatusReport( + deviceId: 'device-1', + reconciledAt: now, + scheduleWindowStart: now, + scheduleWindowEnd: now.add(const Duration(days: 1)), + alarmCoverageStart: now, + alarmCoverageEnd: now.add(const Duration(hours: 1)), + status: AlarmReconciliationStatus.armed, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + armedScheduleCount: 1, + armedScheduleIds: const ['schedule-1'], + skippedScheduleCount: 0, + failures: const [], + ); +} + +class _FakeAlarmSchedulerService extends AlarmSchedulerService { + AlarmSchedulerCapabilities capabilities = + AlarmSchedulerCapabilities.unsupported; + + @override + Future getCapabilities() async => capabilities; +} + +class _FakeAlarmRemoteDataSource implements AlarmRemoteDataSource { + AlarmSettings settings = const AlarmSettings(alarmsEnabled: true); + final updatedValues = []; + final registeredDevices = []; + final unregisteredDeviceIds = []; + final alarmWindowRanges = <(DateTime, DateTime)>[]; + final statusReports = []; + + @override + Future getAlarmSettings() async => settings; + + @override + Future updateAlarmSettings({ + required bool alarmsEnabled, + }) async { + updatedValues.add(alarmsEnabled); + return AlarmSettings(alarmsEnabled: alarmsEnabled); + } + + @override + Future registerCurrentDevice(AlarmDeviceInfo deviceInfo) async { + registeredDevices.add(deviceInfo); + } + + @override + Future unregisterCurrentDevice(String deviceId) async { + unregisteredDeviceIds.add(deviceId); + } + + @override + Future> getAlarmWindow( + DateTime startDate, + DateTime endDate, + ) async { + alarmWindowRanges.add((startDate, endDate)); + return const []; + } + + @override + Future postAlarmStatus(AlarmStatusReport report) async { + statusReports.add(report); + } +} diff --git a/test/data/repositories/early_start_session_repository_impl_test.dart b/test/data/repositories/early_start_session_repository_impl_test.dart new file mode 100644 index 00000000..79a03bdc --- /dev/null +++ b/test/data/repositories/early_start_session_repository_impl_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/data/data_sources/early_start_session_local_data_source.dart'; +import 'package:on_time_front/data/repositories/early_start_session_repository_impl.dart'; + +void main() { + test( + 'markStarted saves and getSession restores early-start timestamp', + () async { + final localDataSource = _FakeEarlyStartSessionLocalDataSource(); + final repository = EarlyStartSessionRepositoryImpl( + localDataSource: localDataSource, + ); + final startedAt = DateTime.utc(2026, 5, 15, 8); + + await repository.markStarted( + scheduleId: 'schedule-1', + startedAt: startedAt, + ); + + final session = await repository.getSession('schedule-1'); + expect(session?.scheduleId, 'schedule-1'); + expect(session?.startedAt, startedAt); + }, + ); + + test('getSession returns null and clear removes saved session', () async { + final localDataSource = _FakeEarlyStartSessionLocalDataSource(); + final repository = EarlyStartSessionRepositoryImpl( + localDataSource: localDataSource, + ); + final startedAt = DateTime.utc(2026, 5, 15, 8); + + expect(await repository.getSession('missing'), isNull); + + await repository.markStarted( + scheduleId: 'schedule-1', + startedAt: startedAt, + ); + await repository.clear('schedule-1'); + + expect(await repository.getSession('schedule-1'), isNull); + }); +} + +class _FakeEarlyStartSessionLocalDataSource + implements EarlyStartSessionLocalDataSource { + final sessions = {}; + + @override + Future saveSession({ + required String scheduleId, + required DateTime startedAt, + }) async { + sessions[scheduleId] = startedAt; + } + + @override + Future loadSessionStartedAt(String scheduleId) async { + return sessions[scheduleId]; + } + + @override + Future clearSession(String scheduleId) async { + sessions.remove(scheduleId); + } +} diff --git a/test/data/repositories/preparation_repository_impl_test.dart b/test/data/repositories/preparation_repository_impl_test.dart index eb36f7aa..491e51dd 100644 --- a/test/data/repositories/preparation_repository_impl_test.dart +++ b/test/data/repositories/preparation_repository_impl_test.dart @@ -1,217 +1,268 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; +import 'package:on_time_front/data/data_sources/preparation_local_data_source.dart'; +import 'package:on_time_front/data/data_sources/preparation_remote_data_source.dart'; +import 'package:on_time_front/data/models/create_defualt_preparation_request_model.dart'; +import 'package:on_time_front/data/repositories/preparation_repository_impl.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; import 'package:uuid/uuid.dart'; -import '../../helpers/mock.mocks.dart'; +void main() { + group('stream-backed behavior', () { + late _FakePreparationRemoteDataSource remoteDataSource; + late PreparationRepositoryImpl repository; -import 'package:on_time_front/data/repositories/preparation_repository_impl.dart'; + setUp(() { + remoteDataSource = _FakePreparationRemoteDataSource(); + repository = PreparationRepositoryImpl( + preparationRemoteDataSource: remoteDataSource, + preparationLocalDataSource: _FakePreparationLocalDataSource(), + ); + }); -void main() { - late PreparationRepositoryImpl preparationRepository; - late MockPreparationRemoteDataSource mockPreparationRemoteDataSource; - late MockPreparationLocalDataSource mockPreparationLocalDataSource; + test( + 'preparation stream starts empty and updates after custom create', + () async { + expect(await repository.preparationStream.first, isEmpty); - final uuid = Uuid(); + await repository.createCustomPreparation( + _preparation('step-1'), + 'schedule-1', + ); - final tPreparationStepList = [ - PreparationStepEntity( - id: uuid.v7(), - preparationName: 'Meeting A Friend', - preparationTime: Duration(minutes: 30), - nextPreparationId: null, - ), - PreparationStepEntity( - id: uuid.v7(), - preparationName: 'Museum Tour', - preparationTime: Duration(minutes: 40), - nextPreparationId: null, - ), - ]; - - tPreparationStepList[0] = PreparationStepEntity( - id: tPreparationStepList[0].id, - preparationName: tPreparationStepList[0].preparationName, - preparationTime: tPreparationStepList[0].preparationTime, - nextPreparationId: tPreparationStepList[1].id, - ); + expect(remoteDataSource.createdCustomSchedules, ['schedule-1']); + expect(await repository.preparationStream.first, { + 'schedule-1': _preparation('step-1'), + }); + }, + ); - final tPreparationStep = PreparationStepEntity( - id: uuid.v7(), - preparationName: 'Dress Up', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ); + test( + 'remote schedule preparation load publishes the fetched preparation', + () async { + remoteDataSource.preparationsByScheduleId['schedule-1'] = _preparation( + 'remote-step', + ); - final tPreparationEntity = PreparationEntity( - preparationStepList: [tPreparationStep], - ); + await repository.getPreparationByScheduleId('schedule-1'); - setUp(() { - mockPreparationRemoteDataSource = MockPreparationRemoteDataSource(); - mockPreparationLocalDataSource = MockPreparationLocalDataSource(); - preparationRepository = PreparationRepositoryImpl( - preparationRemoteDataSource: mockPreparationRemoteDataSource, - preparationLocalDataSource: mockPreparationLocalDataSource, + expect(await repository.preparationStream.first, { + 'schedule-1': _preparation('remote-step'), + }); + }, ); - }); - // group('getPreparationByScheduleId', () { - // test( - // 'should emit local data first and then update local data if remote differs', - // () async { - // // Arrange - // when(mockPreparationLocalDataSource - // .getPreparationByScheduleId(scheduleEntityId)) - // .thenAnswer((_) async => tLocalPreparationEntity); - // when(mockPreparationRemoteDataSource - // .getPreparationByScheduleId(scheduleEntityId)) - // .thenAnswer((_) async => tPreparationEntity); - - // // Act - // final result = - // preparationRepository.getPreparationByScheduleId(scheduleEntityId); - - // // Assert - // await expectLater( - // result, - // emitsInOrder([ - // tLocalPreparationEntity, - // tPreparationEntity, - // ]), - // ); - - // verify(mockPreparationLocalDataSource - // .getPreparationByScheduleId(scheduleEntityId)) - // .called(1); - // verify(mockPreparationRemoteDataSource - // .getPreparationByScheduleId(scheduleEntityId)) - // .called(1); - // }); - // }); - - // group('getPreparationStepById', () { - // test( - // 'should return PreparationStepEntity from local data source if available', - // () async { - // // Arrange - // when(mockPreparationLocalDataSource - // .getPreparationStepById(preparationStepEntityId)) - // .thenAnswer((_) async => tLocalPreparationStep); - // when(mockPreparationRemoteDataSource - // .getPreparationStepById(preparationStepEntityId)) - // .thenAnswer((_) async => tPreparationStep); - - // // Act - // final result = - // preparationRepository.getPreparationStepById(preparationStepEntityId); - - // // Assert - // await expectLater( - // result, - // emitsInOrder([ - // tLocalPreparationStep, // Local 데이터 방출 - // tPreparationStep, // Remote 데이터 방출 - // ]), - // ); - - // verify(mockPreparationLocalDataSource - // .getPreparationStepById(preparationStepEntityId)) - // .called(1); - // verify(mockPreparationRemoteDataSource - // .getPreparationStepById(preparationStepEntityId)) - // .called(1); - // }); - // }); - - // group('createDefaultPreparation', () { - // test('should call createDefaultPreparation on remote data source', - // () async { - // // Arrange - - // when(mockPreparationRemoteDataSource - // .createDefaultPreparation(tCreateDefaultPreparationRequestModel)) - // .thenAnswer((_) async {}); - - // // Act - // await preparationRepository.createDefaultPreparation(tCreateDefaultPreparationRequestModel); - - // // Assert - // verify(mockPreparationRemoteDataSource - // .createDefaultPreparation(tCreateDefaultPreparationRequestModel)) - // .called(1); - // verifyNoMoreInteractions(mockPreparationRemoteDataSource); - // }); - // }); - - group('updatePreparation', () { - test('should call updatePreparation on remote data source', () async { - // Arrange - when( - mockPreparationRemoteDataSource.updateDefaultPreparation( - tPreparationEntity, - ), - ).thenAnswer((_) async {}); - when( - mockPreparationRemoteDataSource.getDefualtPreparation(), - ).thenAnswer((_) async => tPreparationEntity); - - // Act - await preparationRepository.updateDefaultPreparation(tPreparationEntity); + test( + 'schedule preparation update publishes the edited preparation', + () async { + await repository.updatePreparationByScheduleId( + _preparation('updated-step'), + 'schedule-1', + ); - // Assert - verify( - mockPreparationRemoteDataSource.updateDefaultPreparation( - tPreparationEntity, - ), - ).called(1); - verify(mockPreparationRemoteDataSource.getDefualtPreparation()).called(1); - verifyNoMoreInteractions(mockPreparationRemoteDataSource); - }); + expect(remoteDataSource.updatedScheduleIds, ['schedule-1']); + expect(await repository.preparationStream.first, { + 'schedule-1': _preparation('updated-step'), + }); + }, + ); test( - 'should throw when backend does not persist updated preparation', + 'default preparation and spare time calls delegate to remote source', () async { - final persistedPreparation = PreparationEntity( - preparationStepList: [ - tPreparationStep.copyWith( - preparationTime: const Duration(minutes: 5), - ), - ], + remoteDataSource.defaultPreparation = _preparation('default-step'); + + await repository.createDefaultPreparation( + preparationEntity: _preparation('default-step'), + spareTime: const Duration(minutes: 5), + note: 'note', ); - when( - mockPreparationRemoteDataSource.updateDefaultPreparation( - tPreparationEntity, - ), - ).thenAnswer((_) async {}); - when( - mockPreparationRemoteDataSource.getDefualtPreparation(), - ).thenAnswer((_) async => persistedPreparation); + final defaultPreparation = await repository.getDefualtPreparation(); + await repository.updateDefaultPreparation(_preparation('default-step')); + await repository.updateSpareTime(const Duration(minutes: 15)); + + expect(defaultPreparation, _preparation('default-step')); + expect(remoteDataSource.createdDefaultModels, hasLength(1)); + expect(remoteDataSource.updatedDefaultPreparations, [ + _preparation('default-step'), + ]); + expect(remoteDataSource.updatedSpareTimes, [ + const Duration(minutes: 15), + ]); + }, + ); - final call = preparationRepository.updateDefaultPreparation( - tPreparationEntity, + test( + 'remote failures are surfaced to callers without stream mutation', + () async { + remoteDataSource.throwOnNext = true; + + await expectLater( + repository.createCustomPreparation( + _preparation('step-1'), + 'schedule-1', + ), + throwsException, ); - expect(call, throwsA(isA())); + expect(await repository.preparationStream.first, isEmpty); }, ); + }); - test('should throw an exception if remote data source fails', () async { - // Arrange - when( - mockPreparationRemoteDataSource.updateDefaultPreparation( - tPreparationEntity, - ), - ).thenThrow(Exception()); + group('updateDefaultPreparation persistence checks', () { + late PreparationRepositoryImpl preparationRepository; + late _FakePreparationRemoteDataSource remoteDataSource; + + final uuid = Uuid(); + + final tPreparationStep = PreparationStepEntity( + id: uuid.v7(), + preparationName: 'Dress Up', + preparationTime: const Duration(minutes: 10), + nextPreparationId: null, + ); + + final tPreparationEntity = PreparationEntity( + preparationStepList: [tPreparationStep], + ); + + setUp(() { + remoteDataSource = _FakePreparationRemoteDataSource(); + preparationRepository = PreparationRepositoryImpl( + preparationRemoteDataSource: remoteDataSource, + preparationLocalDataSource: _FakePreparationLocalDataSource(), + ); + }); + + test('calls update and reloads persisted default preparation', () async { + await preparationRepository.updateDefaultPreparation(tPreparationEntity); + + expect(remoteDataSource.updatedDefaultPreparations, [tPreparationEntity]); + expect(remoteDataSource.getDefaultCallCount, 1); + }); + + test('throws when backend does not persist updated preparation', () async { + final persistedPreparation = PreparationEntity( + preparationStepList: [ + tPreparationStep.copyWith( + preparationTime: const Duration(minutes: 5), + ), + ], + ); + remoteDataSource.persistUpdatedDefault = false; + remoteDataSource.defaultPreparation = persistedPreparation; + + final call = preparationRepository.updateDefaultPreparation( + tPreparationEntity, + ); + + expect(call, throwsA(isA())); + }); + + test('throws an exception if remote data source fails', () async { + remoteDataSource.throwOnNext = true; - // Act final call = preparationRepository.updateDefaultPreparation( tPreparationEntity, ); - // Assert expect(call, throwsException); }); }); } + +PreparationEntity _preparation(String stepId) { + return PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: stepId, + preparationName: stepId, + preparationTime: const Duration(minutes: 10), + ), + ], + ); +} + +class _FakePreparationRemoteDataSource implements PreparationRemoteDataSource { + final createdDefaultModels = []; + final createdCustomSchedules = []; + final updatedDefaultPreparations = []; + final updatedScheduleIds = []; + final updatedSpareTimes = []; + final preparationsByScheduleId = {}; + PreparationEntity defaultPreparation = _preparation('default'); + int getDefaultCallCount = 0; + bool persistUpdatedDefault = true; + bool throwOnNext = false; + + void _maybeThrow() { + if (throwOnNext) { + throwOnNext = false; + throw Exception('remote failed'); + } + } + + @override + Future createDefaultPreparation( + CreateDefaultPreparationRequestModel model, + ) async { + _maybeThrow(); + createdDefaultModels.add(model); + } + + @override + Future createCustomPreparation( + PreparationEntity preparationEntity, + String scheduleId, + ) async { + _maybeThrow(); + createdCustomSchedules.add(scheduleId); + } + + @override + Future getPreparationByScheduleId( + String scheduleId, + ) async { + _maybeThrow(); + return preparationsByScheduleId[scheduleId] ?? _preparation('missing'); + } + + @override + Future getDefualtPreparation() async { + _maybeThrow(); + getDefaultCallCount++; + return defaultPreparation; + } + + @override + Future updateDefaultPreparation( + PreparationEntity preparationEntity, + ) async { + _maybeThrow(); + updatedDefaultPreparations.add(preparationEntity); + if (persistUpdatedDefault) { + defaultPreparation = preparationEntity; + } + } + + @override + Future updatePreparationByScheduleId( + PreparationEntity preparationEntity, + String scheduleId, + ) async { + _maybeThrow(); + updatedScheduleIds.add(scheduleId); + } + + @override + Future updateSpareTime(Duration newSpareTime) async { + _maybeThrow(); + updatedSpareTimes.add(newSpareTime); + } +} + +class _FakePreparationLocalDataSource implements PreparationLocalDataSource { + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/data/repositories/user_repository_impl_test.dart b/test/data/repositories/user_repository_impl_test.dart new file mode 100644 index 00000000..2e0c6932 --- /dev/null +++ b/test/data/repositories/user_repository_impl_test.dart @@ -0,0 +1,440 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:on_time_front/data/data_sources/authentication_remote_data_source.dart'; +import 'package:on_time_front/data/data_sources/token_local_data_source.dart'; +import 'package:on_time_front/data/models/sign_in_with_apple_request_model.dart'; +import 'package:on_time_front/data/models/sign_in_with_google_request_model.dart'; +import 'package:on_time_front/data/repositories/user_repository_impl.dart'; +import 'package:on_time_front/domain/entities/token_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; + +void main() { + late _FakeAuthenticationRemoteDataSource remoteDataSource; + late _FakeTokenLocalDataSource tokenLocalDataSource; + late UserRepositoryImpl repository; + + setUp(() { + remoteDataSource = _FakeAuthenticationRemoteDataSource(); + tokenLocalDataSource = _FakeTokenLocalDataSource(); + repository = UserRepositoryImpl(remoteDataSource, tokenLocalDataSource); + }); + + test('signIn stores backend tokens and publishes signed-in user', () async { + final emittedUsers = []; + final subscription = repository.userStream.listen(emittedUsers.add); + addTearDown(subscription.cancel); + + await repository.signIn(email: 'user@example.com', password: 'Password1!'); + await pumpEventQueue(); + + expect(remoteDataSource.signInCalls, [('user@example.com', 'Password1!')]); + expect(tokenLocalDataSource.storedTokens, [_token]); + expect(emittedUsers, [const UserEntity.empty(), _user]); + }); + + test('signUp validates password before calling backend', () async { + await expectLater( + repository.signUp( + email: 'user@example.com', + password: 'weak', + name: 'User', + ), + throwsA(isA()), + ); + + expect(remoteDataSource.signUpCalls, isEmpty); + expect(tokenLocalDataSource.storedTokens, isEmpty); + }); + + test('signUp stores tokens and publishes newly created user', () async { + const nextUser = UserEntity( + id: 'new-user', + email: 'new@example.com', + name: 'New User', + spareTime: Duration(minutes: 10), + note: 'note', + score: 4.5, + ); + remoteDataSource.authResult = (nextUser, _token); + + await repository.signUp( + email: 'new@example.com', + password: 'Password1!', + name: 'New User', + ); + + expect(remoteDataSource.signUpCalls, [ + ('new@example.com', 'Password1!', 'New User'), + ]); + expect(tokenLocalDataSource.storedTokens, [_token]); + expect(await repository.userStream.first, nextUser); + }); + + test( + 'getUser clears tokens and publishes empty user on unauthorized response', + () async { + remoteDataSource.getUserHandler = () async { + throw DioException( + requestOptions: RequestOptions(path: '/users/me'), + response: Response( + statusCode: 401, + requestOptions: RequestOptions(path: '/users/me'), + ), + ); + }; + + final user = await repository.getUser(); + + expect(user, const UserEntity.empty()); + expect(tokenLocalDataSource.deleteCount, 1); + expect(await repository.userStream.first, const UserEntity.empty()); + }, + ); + + test('signOut deletes local tokens and publishes empty user', () async { + await repository.signIn(email: 'user@example.com', password: 'Password1!'); + + await repository.signOut(); + + expect(tokenLocalDataSource.deleteCount, 1); + expect(await repository.userStream.first, const UserEntity.empty()); + }); + + test( + 'delete and feedback methods forward optional feedback to backend', + () async { + await repository.deleteUser(feedbackMessage: 'Not useful'); + await repository.deleteGoogleUser(feedbackMessage: 'Google feedback'); + await repository.deleteAppleUser(feedbackMessage: 'Apple feedback'); + await repository.postFeedback('General feedback'); + + expect(remoteDataSource.deleteUserFeedback, 'Not useful'); + expect(remoteDataSource.deleteGoogleFeedback, 'Google feedback'); + expect(remoteDataSource.deleteAppleFeedback, 'Apple feedback'); + expect(remoteDataSource.feedbackMessages, ['General feedback']); + }, + ); + + test('getUserSocialType returns null when backend lookup fails', () async { + remoteDataSource.socialTypeHandler = () async { + throw Exception('session expired'); + }; + + expect(await repository.getUserSocialType(), isNull); + }); + + test('getUser publishes backend user on successful lookup', () async { + final user = await repository.getUser(); + + expect(user, _user); + expect(await repository.userStream.first, _user); + }); + + test( + 'signInWithApple replaces local tokens and publishes backend user', + () async { + const appleUser = UserEntity( + id: 'apple-user', + email: 'apple@example.com', + name: 'Apple User', + spareTime: Duration(minutes: 5), + note: '', + score: 4.0, + ); + remoteDataSource.authResult = (appleUser, _token); + + await repository.signInWithApple( + idToken: 'id-token', + authCode: 'auth-code', + fullName: 'Apple User', + email: 'apple@example.com', + ); + + expect(tokenLocalDataSource.deleteCount, 1); + expect(tokenLocalDataSource.storedTokens, [_token]); + expect(remoteDataSource.appleRequests.single.idToken, 'id-token'); + expect(remoteDataSource.appleRequests.single.authCode, 'auth-code'); + expect(remoteDataSource.appleRequests.single.fullName, 'Apple User'); + expect(remoteDataSource.appleRequests.single.email, 'apple@example.com'); + expect(await repository.userStream.first, appleUser); + }, + ); + + test( + 'signInWithApple rethrows backend failures without publishing user', + () async { + remoteDataSource.signInWithAppleHandler = (_) async { + throw Exception('apple backend failed'); + }; + + await expectLater( + repository.signInWithApple( + idToken: 'id-token', + authCode: 'auth-code', + fullName: 'Apple User', + ), + throwsException, + ); + + expect(tokenLocalDataSource.deleteCount, 1); + expect(tokenLocalDataSource.storedTokens, isEmpty); + expect(await repository.userStream.first, const UserEntity.empty()); + }, + ); + + test( + 'signInWithGoogle replaces local tokens and publishes backend user', + () async { + const googleUser = UserEntity( + id: 'google-user', + email: 'google@example.com', + name: 'Google User', + spareTime: Duration(minutes: 15), + note: '', + score: 4.0, + ); + remoteDataSource.authResult = (googleUser, _token); + + await repository.signInWithGoogle( + _FakeGoogleSignInAccount(idToken: 'google-id-token'), + ); + + expect(tokenLocalDataSource.deleteCount, 1); + expect(tokenLocalDataSource.storedTokens, [_token]); + expect(remoteDataSource.googleRequests.single.idToken, 'google-id-token'); + expect(remoteDataSource.googleRequests.single.refreshToken, isEmpty); + expect(await repository.userStream.first, googleUser); + }, + ); + + test('signInWithGoogle rejects accounts without an ID token', () async { + await expectLater( + repository.signInWithGoogle(_FakeGoogleSignInAccount()), + throwsException, + ); + + expect(remoteDataSource.googleRequests, isEmpty); + expect(tokenLocalDataSource.deleteCount, 0); + expect(await repository.userStream.first, const UserEntity.empty()); + }); + + test( + 'backend failures are surfaced without publishing a signed-in user', + () async { + remoteDataSource.signInHandler = (_, __) async { + throw Exception('sign in failed'); + }; + + await expectLater( + repository.signIn(email: 'user@example.com', password: 'Password1!'), + throwsException, + ); + + expect(tokenLocalDataSource.storedTokens, isEmpty); + expect(await repository.userStream.first, const UserEntity.empty()); + }, + ); + + test('non-unauthorized getUser failures are rethrown', () async { + remoteDataSource.getUserHandler = () async { + throw DioException( + requestOptions: RequestOptions(path: '/users/me'), + response: Response( + statusCode: 500, + requestOptions: RequestOptions(path: '/users/me'), + ), + ); + }; + + await expectLater(repository.getUser(), throwsA(isA())); + + expect(tokenLocalDataSource.deleteCount, 0); + expect(await repository.userStream.first, const UserEntity.empty()); + }); + + test( + 'getUserSocialType returns backend social type when available', + () async { + expect(await repository.getUserSocialType(), 'GOOGLE'); + }, + ); + + test('delete operations surface backend failures', () async { + remoteDataSource.deleteUserHandler = () async { + throw Exception('delete failed'); + }; + await expectLater(repository.deleteUser(), throwsException); + + remoteDataSource.deleteGoogleHandler = () async { + throw Exception('delete google failed'); + }; + await expectLater(repository.deleteGoogleUser(), throwsException); + + remoteDataSource.deleteAppleHandler = () async { + throw Exception('delete apple failed'); + }; + await expectLater(repository.deleteAppleUser(), throwsException); + }); + + test('disconnectGoogleSignIn absorbs plugin failures', () async { + await repository.disconnectGoogleSignIn(); + }); +} + +const _user = UserEntity( + id: 'user-1', + email: 'user@example.com', + name: 'User', + spareTime: Duration(minutes: 10), + note: 'note', + score: 4.5, +); + +const _token = TokenEntity( + accessToken: 'access-token', + refreshToken: 'refresh-token', +); + +class _FakeAuthenticationRemoteDataSource + implements AuthenticationRemoteDataSource { + (UserEntity, TokenEntity) authResult = (_user, _token); + Future Function() getUserHandler = () async => _user; + Future Function() socialTypeHandler = () async => 'GOOGLE'; + Future<(UserEntity, TokenEntity)> Function(String, String)? signInHandler; + + final signInCalls = <(String, String)>[]; + final signUpCalls = <(String, String, String)>[]; + final appleRequests = []; + final googleRequests = []; + final feedbackMessages = []; + Future<(UserEntity, TokenEntity)> Function(SignInWithAppleRequestModel)? + signInWithAppleHandler; + Future Function()? deleteUserHandler; + Future Function()? deleteGoogleHandler; + Future Function()? deleteAppleHandler; + String? deleteUserFeedback; + String? deleteGoogleFeedback; + String? deleteAppleFeedback; + + @override + Future<(UserEntity, TokenEntity)> signIn( + String email, + String password, + ) async { + signInCalls.add((email, password)); + final handler = signInHandler; + if (handler != null) { + return handler(email, password); + } + return authResult; + } + + @override + Future<(UserEntity, TokenEntity)> signUp( + String email, + String password, + String name, + ) async { + signUpCalls.add((email, password, name)); + return authResult; + } + + @override + Future getUser() => getUserHandler(); + + @override + Future deleteUser({String? feedbackMessage}) async { + final handler = deleteUserHandler; + if (handler != null) { + await handler(); + } + deleteUserFeedback = feedbackMessage; + } + + @override + Future deleteGoogleMe({String? feedbackMessage}) async { + final handler = deleteGoogleHandler; + if (handler != null) { + await handler(); + } + deleteGoogleFeedback = feedbackMessage; + } + + @override + Future deleteAppleMe({String? feedbackMessage}) async { + final handler = deleteAppleHandler; + if (handler != null) { + await handler(); + } + deleteAppleFeedback = feedbackMessage; + } + + @override + Future postFeedback(String message) async { + feedbackMessages.add(message); + } + + @override + Future getUserSocialType() => socialTypeHandler(); + + @override + Future<(UserEntity, TokenEntity)> signInWithApple( + SignInWithAppleRequestModel signInWithAppleRequestModel, + ) async { + appleRequests.add(signInWithAppleRequestModel); + final handler = signInWithAppleHandler; + if (handler != null) { + return handler(signInWithAppleRequestModel); + } + return authResult; + } + + @override + Future<(UserEntity, TokenEntity)> signInWithGoogle( + SignInWithGoogleRequestModel signInWithGoogleRequestModel, + ) async { + googleRequests.add(signInWithGoogleRequestModel); + return authResult; + } +} + +class _FakeGoogleSignInAccount implements GoogleSignInAccount { + _FakeGoogleSignInAccount({this.idToken}); + + final String? idToken; + + @override + GoogleSignInAuthentication get authentication { + return GoogleSignInAuthentication(idToken: idToken); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeTokenLocalDataSource implements TokenLocalDataSource { + final storedTokens = []; + final storedAuthTokens = []; + int deleteCount = 0; + + @override + Future storeTokens(TokenEntity token) async { + storedTokens.add(token); + } + + @override + Future storeAuthToken(String token) async { + storedAuthTokens.add(token); + } + + @override + Future getToken() async { + return storedTokens.last; + } + + @override + Future deleteToken() async { + deleteCount += 1; + } +} diff --git a/test/domain/entities/preparation_timing_entity_test.dart b/test/domain/entities/preparation_timing_entity_test.dart index 785fe35c..7abe0f7b 100644 --- a/test/domain/entities/preparation_timing_entity_test.dart +++ b/test/domain/entities/preparation_timing_entity_test.dart @@ -1,11 +1,15 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/data/tables/schedule_with_place_model.dart'; import 'package:on_time_front/domain/entities/place_entity.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; +import 'package:on_time_front/presentation/shared/constants/constants.dart'; void main() { group('Preparation timing entities', () { @@ -35,10 +39,11 @@ void main() { final preparation = PreparationWithTimeEntity.fromPreparation(unordered); - expect( - preparation.preparationStepList.map((step) => step.id).toList(), - ['s1', 's2', 's3'], - ); + expect(preparation.preparationStepList.map((step) => step.id).toList(), [ + 's1', + 's2', + 's3', + ]); expect(preparation.currentStep?.id, 's1'); }); @@ -94,8 +99,38 @@ void main() { expect(progressed.preparationStepList[0].isDone, isTrue); expect(progressed.preparationStepList[1].isDone, isFalse); expect(progressed.currentStep?.id, 's2'); - expect(progressed.preparationStepList[1].elapsedTime, - const Duration(minutes: 5)); + expect( + progressed.preparationStepList[1].elapsedTime, + const Duration(minutes: 5), + ); + }); + + test('single preparation step tracks elapsed time and diagnostics', () { + const step = PreparationStepWithTimeEntity( + id: 's1', + preparationName: 'wash', + preparationTime: Duration(minutes: 10), + nextPreparationId: 's2', + ); + + final partial = step.timeElapsed(const Duration(minutes: 4)); + final completed = partial.timeElapsed(const Duration(minutes: 6)); + final renamed = step.copyWith(preparationName: 'shower'); + + expect(partial.elapsedTime, const Duration(minutes: 4)); + expect(partial.isDone, isFalse); + expect(completed.elapsedTime, const Duration(minutes: 10)); + expect(completed.isDone, isTrue); + expect(renamed.preparationName, 'shower'); + expect(step.toString(), contains('PreparationStepWithTimeEntity')); + expect(step.props, [ + 's1', + 'wash', + const Duration(minutes: 10), + 's2', + Duration.zero, + false, + ]); }); test('marks all steps done for very late entry inside schedule window', () { @@ -105,6 +140,67 @@ void main() { expect(progressed.progress, 1.0); }); + test('reports display helpers for current and completed steps', () { + final progressed = preparation.timeElapsed(const Duration(minutes: 12)); + + expect(progressed.elapsedTime, const Duration(minutes: 12)); + expect(progressed.currentStepIndex, 1); + expect(progressed.resolvedCurrentStepIndex, 1); + expect(progressed.currentStepRemainingTime, const Duration(minutes: 8)); + expect(progressed.currentStepName, 'dress'); + expect(progressed.stepElapsedTimesInSeconds, [600, 120]); + expect(progressed.preparationStepStates, [ + PreparationStateEnum.done, + PreparationStateEnum.now, + ]); + expect(progressed.progress, 0.6); + }); + + test('skipCurrentStep marks only the active step done', () { + final skipped = preparation.skipCurrentStep(); + + expect(skipped.preparationStepList[0].isDone, isTrue); + expect(skipped.preparationStepList[1].isDone, isFalse); + expect(skipped.currentStep?.id, 's2'); + expect(skipped.preparationStepStates, [ + PreparationStateEnum.done, + PreparationStateEnum.now, + ]); + }); + + test('completed and empty preparations expose safe display fallbacks', () { + final completed = preparation.timeElapsed(const Duration(minutes: 20)); + const empty = PreparationWithTimeEntity(preparationStepList: []); + const zeroDuration = PreparationWithTimeEntity( + preparationStepList: [ + PreparationStepWithTimeEntity( + id: 'zero', + preparationName: 'No-op', + preparationTime: Duration.zero, + nextPreparationId: null, + ), + ], + ); + + expect(completed.currentStepIndex, -1); + expect(completed.resolvedCurrentStepIndex, 1); + expect(completed.currentStepName, 'dress'); + expect(completed.preparationStepStates, [ + PreparationStateEnum.done, + PreparationStateEnum.done, + ]); + expect(completed.skipCurrentStep(), same(completed)); + expect( + completed.timeElapsed(const Duration(minutes: 1)), + same(completed), + ); + + expect(empty.currentStepName, ''); + expect(empty.progress, 0); + expect(empty.preparationStepStates, isEmpty); + expect(zeroDuration.progress, 0); + }); + test('cache fingerprint changes when preparation step name changes', () { final baseline = ScheduleWithPreparationEntity( id: 'schedule-cache', @@ -150,42 +246,152 @@ void main() { ); expect( - baseline.cacheFingerprint, isNot(equals(renamed.cacheFingerprint))); + baseline.cacheFingerprint, + isNot(equals(renamed.cacheFingerprint)), + ); }); + + test('schedule with preparation derives start time and total duration', () { + expect(schedule.totalDuration, const Duration(minutes: 50)); + expect(schedule.preparationStartTime, DateTime(2026, 3, 20, 9, 10)); + expect(schedule.timeRemainingBeforeLeaving.inMinutes, isA()); + expect(schedule.isLate, isA()); + expect(schedule.cacheFingerprint, contains('s1:wash:600000:s2|')); + expect(schedule.cacheFingerprint, contains('s2:dress:600000:|')); + }); + + test( + 'combines a schedule entity with timed preparation preserving fields', + () { + final base = ScheduleEntity( + id: 'schedule-combine', + place: const PlaceEntity(id: 'p2', placeName: 'Gym'), + scheduleName: 'Workout', + scheduleTime: DateTime(2026, 3, 21, 8), + moveTime: const Duration(minutes: 15), + isChanged: true, + isStarted: true, + scheduleSpareTime: null, + scheduleNote: 'shoes', + latenessTime: 3, + doneStatus: ScheduleDoneStatus.lateEnd, + ); + + final combined = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + base, + preparation, + ); + + expect(combined.id, base.id); + expect(combined.place, base.place); + expect(combined.scheduleName, base.scheduleName); + expect(combined.scheduleTime, base.scheduleTime); + expect(combined.moveTime, base.moveTime); + expect(combined.isChanged, isTrue); + expect(combined.isStarted, isTrue); + expect(combined.scheduleSpareTime, isNull); + expect(combined.scheduleNote, 'shoes'); + expect(combined.latenessTime, 3); + expect(combined.doneStatus, ScheduleDoneStatus.lateEnd); + expect(combined.preparation, preparation); + }, + ); }); - group('ScheduleState timing helper', () { - test('durationUntilPreparationStartAt uses injected now deterministically', - () { - final preparation = PreparationWithTimeEntity( - preparationStepList: const [ - PreparationStepWithTimeEntity( - id: 's1', - preparationName: 'prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, + group('ScheduleEntity model mapping', () { + test('maps to and from database models preserving user-visible fields', () { + final entity = ScheduleEntity.fromScheduleWithPlaceModel( + ScheduleWithPlace( + schedule: Schedule( + id: 'schedule-model', + placeId: 'place-model', + scheduleName: 'Doctor', + scheduleTime: DateTime(2026, 4, 1, 15), + moveTime: const Duration(minutes: 30), + isChanged: true, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 5), + scheduleNote: null, + latenessTime: 7, ), - ], - ); - final schedule = ScheduleWithPreparationEntity( - id: 'schedule-2', - place: PlaceEntity(id: 'p1', placeName: 'Office'), - scheduleName: 'Meeting', - scheduleTime: DateTime(2026, 3, 20, 10, 0), - moveTime: const Duration(minutes: 20), - isChanged: false, - isStarted: false, - scheduleSpareTime: const Duration(minutes: 10), - scheduleNote: '', - preparation: preparation, + place: const Place(id: 'place-model', placeName: 'Clinic'), + ), ); - final state = ScheduleState.upcoming(schedule); - final now = DateTime(2026, 3, 20, 9, 15); - expect( - state.durationUntilPreparationStartAt(now), - const Duration(minutes: 5), - ); + expect(entity.id, 'schedule-model'); + expect(entity.place.placeName, 'Clinic'); + expect(entity.scheduleNote, ''); + expect(entity.doneStatus, ScheduleDoneStatus.notEnded); + + final model = entity + .copyWith(doneStatus: ScheduleDoneStatus.normalEnd) + .toScheduleWithPlaceModel(); + + expect(model.schedule.id, 'schedule-model'); + expect(model.schedule.placeId, 'place-model'); + expect(model.schedule.scheduleName, 'Doctor'); + expect(model.schedule.moveTime, const Duration(minutes: 30)); + expect(model.schedule.scheduleNote, ''); + expect(model.schedule.latenessTime, 7); + expect(model.place.placeName, 'Clinic'); }); + + test( + 'string representation includes schedule identity for diagnostics', + () { + final entity = ScheduleEntity( + id: 'schedule-log', + place: const PlaceEntity(id: 'p1', placeName: 'Office'), + scheduleName: 'Meeting', + scheduleTime: DateTime(2026, 5, 1, 9), + moveTime: const Duration(minutes: 10), + isChanged: false, + isStarted: false, + scheduleSpareTime: null, + scheduleNote: '', + ); + + expect(entity.toString(), contains('schedule-log')); + expect(entity.toString(), contains('Meeting')); + }, + ); + }); + + group('ScheduleState timing helper', () { + test( + 'durationUntilPreparationStartAt uses injected now deterministically', + () { + final preparation = PreparationWithTimeEntity( + preparationStepList: const [ + PreparationStepWithTimeEntity( + id: 's1', + preparationName: 'prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + final schedule = ScheduleWithPreparationEntity( + id: 'schedule-2', + place: PlaceEntity(id: 'p1', placeName: 'Office'), + scheduleName: 'Meeting', + scheduleTime: DateTime(2026, 3, 20, 10, 0), + moveTime: const Duration(minutes: 20), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 10), + scheduleNote: '', + preparation: preparation, + ); + final state = ScheduleState.upcoming(schedule); + + final now = DateTime(2026, 3, 20, 9, 15); + expect( + state.durationUntilPreparationStartAt(now), + const Duration(minutes: 5), + ); + }, + ); }); } diff --git a/test/domain/entities/user_entity_test.dart b/test/domain/entities/user_entity_test.dart new file mode 100644 index 00000000..e1ff2a68 --- /dev/null +++ b/test/domain/entities/user_entity_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/database/database.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; + +void main() { + test('fromModel and toModel preserve user profile values', () { + const model = User( + id: 'user-1', + email: 'user@example.com', + name: 'User', + spareTime: 12, + note: 'note', + score: 4.5, + ); + + final entity = UserEntity.fromModel(model); + final roundTrip = entity.toModel(); + + expect(entity.valueOrNull, entity); + expect(entity.spareTimeOrNull, const Duration(minutes: 12)); + expect(entity.scoreOrNull, 4.5); + expect(entity.nameOrNull, 'User'); + expect(entity.emailOrNull, 'user@example.com'); + expect(roundTrip.id, model.id); + expect(roundTrip.email, model.email); + expect(roundTrip.name, model.name); + expect(roundTrip.spareTime, model.spareTime); + expect(roundTrip.note, model.note); + expect(roundTrip.score, model.score); + }); + + test( + 'empty user exposes null convenience values and cannot become a model', + () { + const entity = UserEntity.empty(); + + expect(entity.valueOrNull, isNull); + expect(entity.spareTimeOrNull, isNull); + expect(entity.scoreOrNull, isNull); + expect(entity.nameOrNull, isNull); + expect(entity.emailOrNull, isNull); + expect(entity.toModel, throwsException); + }, + ); +} diff --git a/test/domain/use-cases/cancel_alarms_use_cases_test.dart b/test/domain/use-cases/cancel_alarms_use_cases_test.dart new file mode 100644 index 00000000..492e518f --- /dev/null +++ b/test/domain/use-cases/cancel_alarms_use_cases_test.dart @@ -0,0 +1,187 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; +import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/repositories/alarm_registry_repository.dart'; +import 'package:on_time_front/domain/repositories/alarm_repository.dart'; +import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; +import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart'; + +void main() { + test( + 'CancelScheduleAlarmUseCase cancels matching native and fallback records', + () async { + final registry = _FakeAlarmRegistryRepository([ + _record('schedule-1', AlarmProvider.androidAlarmManager), + _record('schedule-1', AlarmProvider.localNotification), + _record('schedule-2', AlarmProvider.androidAlarmManager), + _record('schedule-1', AlarmProvider.none), + ]); + final scheduler = _FakeAlarmSchedulerService(); + final fallback = _FakeFallbackAlarmNotificationService(); + final useCase = CancelScheduleAlarmUseCase(registry, scheduler, fallback); + + await useCase('schedule-1'); + + expect(scheduler.canceledNative.map((record) => record.scheduleId), [ + 'schedule-1', + ]); + expect(fallback.canceledFallback.map((record) => record.scheduleId), [ + 'schedule-1', + ]); + expect(registry.deletedScheduleIds, ['schedule-1']); + }, + ); + + test( + 'CancelScheduleAlarmUseCase still deletes registry when platform cancel fails', + () async { + final registry = _FakeAlarmRegistryRepository([ + _record('schedule-1', AlarmProvider.androidAlarmManager), + ]); + final scheduler = _FakeAlarmSchedulerService()..throwOnCancel = true; + final useCase = CancelScheduleAlarmUseCase( + registry, + scheduler, + _FakeFallbackAlarmNotificationService(), + ); + + await useCase('schedule-1'); + + expect(registry.deletedScheduleIds, ['schedule-1']); + }, + ); + + test( + 'CancelAllAlarmsUseCase clears registry and unregisters device on logout', + () async { + final registry = _FakeAlarmRegistryRepository([ + _record('native', AlarmProvider.androidAlarmManager), + _record('fallback', AlarmProvider.localNotification), + _record('none', AlarmProvider.none), + ]); + final alarmRepository = _FakeAlarmRepository(); + final scheduler = _FakeAlarmSchedulerService(); + final fallback = _FakeFallbackAlarmNotificationService(); + final useCase = CancelAllAlarmsUseCase( + alarmRepository, + registry, + scheduler, + fallback, + ); + + await useCase(unregisterDevice: true); + + expect(scheduler.canceledNative.map((record) => record.scheduleId), [ + 'native', + ]); + expect(fallback.canceledFallback.map((record) => record.scheduleId), [ + 'fallback', + ]); + expect(registry.deleteAllCount, 1); + expect(alarmRepository.unregisteredDeviceIds, ['device-1']); + }, + ); + + test( + 'CancelAllAlarmsUseCase tolerates unregister failures during cleanup', + () async { + final registry = _FakeAlarmRegistryRepository(const []); + final alarmRepository = _FakeAlarmRepository()..throwOnUnregister = true; + final useCase = CancelAllAlarmsUseCase( + alarmRepository, + registry, + _FakeAlarmSchedulerService(), + _FakeFallbackAlarmNotificationService(), + ); + + await useCase(unregisterDevice: true); + + expect(registry.deleteAllCount, 1); + }, + ); +} + +ScheduledAlarmRecord _record(String scheduleId, AlarmProvider provider) { + return ScheduledAlarmRecord( + scheduleId: scheduleId, + alarmTime: DateTime(2026, 5, 15, 8), + preparationStartTime: DateTime(2026, 5, 15, 8, 5), + scheduleFingerprint: 'fingerprint-$scheduleId', + provider: provider, + scheduleTitle: scheduleId, + payload: {'type': 'schedule_alarm', 'scheduleId': scheduleId}, + ); +} + +class _FakeAlarmRegistryRepository implements AlarmRegistryRepository { + _FakeAlarmRegistryRepository(this.records); + + final List records; + final deletedScheduleIds = []; + int deleteAllCount = 0; + + @override + Future> loadAll() async => records; + + @override + Future deleteByScheduleId(String scheduleId) async { + deletedScheduleIds.add(scheduleId); + } + + @override + Future deleteAll() async { + deleteAllCount += 1; + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeAlarmSchedulerService implements AlarmSchedulerService { + final canceledNative = []; + bool throwOnCancel = false; + + @override + Future cancelNativeAlarm(ScheduledAlarmRecord record) async { + if (throwOnCancel) { + throw Exception('native failure'); + } + canceledNative.add(record); + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeFallbackAlarmNotificationService + implements FallbackAlarmNotificationService { + final canceledFallback = []; + + @override + Future cancelFallbackAlarm(ScheduledAlarmRecord record) async { + canceledFallback.add(record); + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeAlarmRepository implements AlarmRepository { + final unregisteredDeviceIds = []; + bool throwOnUnregister = false; + + @override + Future getDeviceId() async => 'device-1'; + + @override + Future unregisterCurrentDevice(String deviceId) async { + if (throwOnUnregister) { + throw Exception('backend unavailable'); + } + unregisteredDeviceIds.add(deviceId); + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/domain/use-cases/reconcile_alarms_use_case_test.dart b/test/domain/use-cases/reconcile_alarms_use_case_test.dart index b8def180..140edaba 100644 --- a/test/domain/use-cases/reconcile_alarms_use_case_test.dart +++ b/test/domain/use-cases/reconcile_alarms_use_case_test.dart @@ -19,6 +19,8 @@ class FakeAlarmRepository implements AlarmRepository { AlarmSettings settings = const AlarmSettings(alarmsEnabled: true); bool throwSettings = false; bool throwAlarmWindow = false; + bool throwRegisterCurrentDevice = false; + bool throwGenericOnStatus = false; List schedules = []; DateTime? requestedWindowStart; DateTime? requestedWindowEnd; @@ -63,6 +65,9 @@ class FakeAlarmRepository implements AlarmRepository { @override Future registerCurrentDevice(AlarmDeviceInfo deviceInfo) async { + if (throwRegisterCurrentDevice) { + throw Exception('registration failed'); + } registeredDevices.add(deviceInfo); } @@ -88,6 +93,9 @@ class FakeAlarmRepository implements AlarmRepository { if (throwDeviceSessionNotActiveOnStatus) { throw const DeviceSessionNotActiveException(); } + if (throwGenericOnStatus) { + throw Exception('status failed'); + } statusReports.add(report); } } @@ -131,21 +139,32 @@ class FakeAlarmSchedulerService implements AlarmSchedulerService { nativeAlarmProvider: AlarmProvider.androidAlarmManager, ); AlarmPermissionState nativePermission = AlarmPermissionState.granted; + bool throwOnCheckPermission = false; final scheduledNative = []; final canceledNative = []; final throwOnScheduleIds = {}; + final throwGenericOnScheduleIds = {}; + final throwOnCancelIds = {}; @override Future getCapabilities() async => capabilities; @override - Future checkPermission() async => nativePermission; + Future checkPermission() async { + if (throwOnCheckPermission) { + throw Exception('native permission unavailable'); + } + return nativePermission; + } @override Future requestPermission() async => nativePermission; @override Future scheduleNativeAlarm(ScheduledAlarmRecord record) async { + if (throwGenericOnScheduleIds.contains(record.scheduleId)) { + throw Exception('native channel failed'); + } if (throwOnScheduleIds.contains(record.scheduleId)) { throw const AlarmSchedulingException( reason: AlarmFailureReason.platformError, @@ -157,6 +176,9 @@ class FakeAlarmSchedulerService implements AlarmSchedulerService { @override Future cancelNativeAlarm(ScheduledAlarmRecord record) async { + if (throwOnCancelIds.contains(record.scheduleId)) { + throw Exception('cancel failed'); + } canceledNative.add(record); } @@ -177,22 +199,51 @@ class FakeAlarmSchedulerService implements AlarmSchedulerService { class FakeFallbackAlarmNotificationService implements FallbackAlarmNotificationService { AlarmPermissionState permission = AlarmPermissionState.denied; + bool throwOnCheckPermission = false; final scheduledFallback = []; final canceledFallback = []; + final throwOnScheduleIds = {}; + final throwPermissionOnScheduleIds = {}; + final throwGenericOnScheduleIds = {}; + final throwOnCancelIds = {}; @override - Future checkPermission() async => permission; + Future checkPermission() async { + if (throwOnCheckPermission) { + throw Exception('fallback permission unavailable'); + } + return permission; + } @override Future requestPermission() async => permission; @override Future scheduleFallbackAlarm(ScheduledAlarmRecord record) async { + if (throwPermissionOnScheduleIds.contains(record.scheduleId)) { + throw const AlarmSchedulingException( + reason: AlarmFailureReason.platformError, + permissionIssue: AlarmPermissionIssue.notificationPermissionDenied, + message: 'notification denied', + ); + } + if (throwOnScheduleIds.contains(record.scheduleId)) { + throw const AlarmSchedulingException( + reason: AlarmFailureReason.platformError, + message: 'fallback failed', + ); + } + if (throwGenericOnScheduleIds.contains(record.scheduleId)) { + throw Exception('fallback channel failed'); + } scheduledFallback.add(record); } @override Future cancelFallbackAlarm(ScheduledAlarmRecord record) async { + if (throwOnCancelIds.contains(record.scheduleId)) { + throw Exception('fallback cancel failed'); + } canceledFallback.add(record); } } @@ -446,6 +497,61 @@ void main() { }, ); + test('keeps matching native registry record without rescheduling', () async { + final schedule = scheduleWithAlarmAt( + id: 'already-armed', + alarmTime: now.add(const Duration(hours: 1)), + ); + final existing = buildScheduledAlarmRecord( + schedule, + alarmOffset: const Duration(minutes: 5), + provider: AlarmProvider.androidAlarmManager, + ); + registryRepository.records = [existing]; + alarmRepository.schedules = [schedule]; + + final result = await useCase(); + + expect(schedulerService.canceledNative, isEmpty); + expect(schedulerService.scheduledNative, isEmpty); + expect(registryRepository.records, [existing]); + expect(result.status, AlarmReconciliationStatus.armed); + expect(result.armedScheduleIds, ['already-armed']); + expect(result.nativeAlarmProvider, AlarmProvider.androidAlarmManager); + }); + + test( + 'keeps matching fallback registry record when fallback provider is available', + () async { + schedulerService.capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: false, + nativeAlarmProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.localNotification, + ); + schedulerService.nativePermission = AlarmPermissionState.unsupported; + fallbackService.permission = AlarmPermissionState.granted; + final schedule = scheduleWithAlarmAt( + id: 'already-fallback', + alarmTime: now.add(const Duration(hours: 1)), + ); + final existing = buildScheduledAlarmRecord( + schedule, + alarmOffset: const Duration(minutes: 5), + provider: AlarmProvider.localNotification, + ); + registryRepository.records = [existing]; + alarmRepository.schedules = [schedule]; + + final result = await useCase(); + + expect(fallbackService.canceledFallback, isEmpty); + expect(fallbackService.scheduledFallback, isEmpty); + expect(registryRepository.records, [existing]); + expect(result.status, AlarmReconciliationStatus.armed); + expect(result.fallbackProvider, AlarmProvider.localNotification); + }, + ); + test( 'reschedules stale record with old alarm launch payload version', () async { @@ -544,6 +650,60 @@ void main() { }, ); + test( + 'falls back to local notification when native scheduling fails', + () async { + schedulerService.throwOnScheduleIds.add('native-fails'); + fallbackService.permission = AlarmPermissionState.granted; + alarmRepository.schedules = [ + scheduleWithAlarmAt( + id: 'native-fails', + alarmTime: now.add(const Duration(hours: 1)), + ), + ]; + + final result = await useCase(); + + expect(schedulerService.scheduledNative, isEmpty); + expect( + fallbackService.scheduledFallback.single.scheduleId, + 'native-fails', + ); + expect( + registryRepository.records.single.provider, + AlarmProvider.localNotification, + ); + expect(result.status, AlarmReconciliationStatus.armed); + }, + ); + + test( + 'reports notification permission when only fallback delivery is denied', + () async { + schedulerService.capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: false, + nativeAlarmProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.localNotification, + ); + fallbackService.permission = AlarmPermissionState.denied; + alarmRepository.schedules = [ + scheduleWithAlarmAt( + id: 'fallback-denied', + alarmTime: now.add(const Duration(hours: 1)), + ), + ]; + + final result = await useCase(); + + expect(result.status, AlarmReconciliationStatus.permissionNeeded); + expect( + result.permissionIssue, + AlarmPermissionIssue.notificationPermissionDenied, + ); + expect(registryRepository.records, isEmpty); + }, + ); + test( 'reports permissionNeeded when exact alarm and fallback permissions are denied', () async { @@ -698,6 +858,174 @@ void main() { expect(registryRepository.records, isEmpty); }); + test( + 'generic platform failures are reported when no fallback is available', + () async { + final failing = scheduleWithAlarmAt( + id: 'generic-native-failure', + alarmTime: now.add(const Duration(hours: 1)), + ); + alarmRepository.schedules = [failing]; + schedulerService.throwGenericOnScheduleIds.add('generic-native-failure'); + fallbackService.permission = AlarmPermissionState.denied; + + final result = await useCase(); + + expect(result.status, AlarmReconciliationStatus.partial); + expect(result.failures.single.scheduleId, 'generic-native-failure'); + expect(result.failures.single.reason, AlarmFailureReason.platformError); + expect(result.failures.single.message, contains('native channel failed')); + }, + ); + + test( + 'fallback scheduling failures become permission or partial reports', + () async { + schedulerService.capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: false, + nativeAlarmProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.localNotification, + ); + fallbackService.permission = AlarmPermissionState.granted; + alarmRepository.schedules = [ + scheduleWithAlarmAt( + id: 'fallback-permission-fails', + alarmTime: now.add(const Duration(hours: 1)), + ), + ]; + fallbackService.throwPermissionOnScheduleIds.add( + 'fallback-permission-fails', + ); + + final permissionResult = await useCase(); + + expect( + permissionResult.status, + AlarmReconciliationStatus.permissionNeeded, + ); + expect( + permissionResult.permissionIssue, + AlarmPermissionIssue.notificationPermissionDenied, + ); + + fallbackService.throwPermissionOnScheduleIds.clear(); + fallbackService.throwGenericOnScheduleIds.add( + 'fallback-permission-fails', + ); + final partialResult = await useCase(); + + expect(partialResult.status, AlarmReconciliationStatus.partial); + expect( + partialResult.failures.single.message, + contains('fallback channel'), + ); + }, + ); + + test( + 'fallback alarm scheduling exceptions without permission issue are partial failures', + () async { + schedulerService.capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: false, + nativeAlarmProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.localNotification, + ); + fallbackService.permission = AlarmPermissionState.granted; + alarmRepository.schedules = [ + scheduleWithAlarmAt( + id: 'fallback-platform-fails', + alarmTime: now.add(const Duration(hours: 1)), + ), + ]; + fallbackService.throwOnScheduleIds.add('fallback-platform-fails'); + + final result = await useCase(); + + expect(result.status, AlarmReconciliationStatus.partial); + expect(result.permissionIssue, isNull); + expect(result.failures.single.scheduleId, 'fallback-platform-fails'); + expect(result.failures.single.reason, AlarmFailureReason.platformError); + expect(result.failures.single.message, 'fallback failed'); + expect(registryRepository.records, isEmpty); + }, + ); + + test('unsupported providers and status post failures do not throw', () async { + schedulerService.capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: false, + nativeAlarmProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.none, + ); + schedulerService.nativePermission = AlarmPermissionState.unsupported; + fallbackService.permission = AlarmPermissionState.unsupported; + alarmRepository + ..throwRegisterCurrentDevice = true + ..throwGenericOnStatus = true + ..schedules = [ + scheduleWithAlarmAt( + id: 'unsupported', + alarmTime: now.add(const Duration(hours: 1)), + ), + ]; + + final result = await useCase(); + + expect(result.status, AlarmReconciliationStatus.unsupported); + expect(alarmRepository.statusReports, isEmpty); + expect(registryRepository.records, isEmpty); + }); + + test( + 'permission check failures degrade to denied or unsupported states', + () async { + schedulerService.throwOnCheckPermission = true; + fallbackService + ..permission = AlarmPermissionState.granted + ..throwOnCheckPermission = true; + alarmRepository.schedules = [ + scheduleWithAlarmAt( + id: 'permission-check-fails', + alarmTime: now.add(const Duration(hours: 1)), + ), + ]; + + final result = await useCase(); + + expect(result.status, AlarmReconciliationStatus.permissionNeeded); + expect( + result.permissionIssue, + AlarmPermissionIssue.notificationPermissionDenied, + ); + }, + ); + + test('cancel failures do not block registry replacement', () async { + final staleNative = buildScheduledAlarmRecord( + scheduleWithAlarmAt( + id: 'stale-native', + alarmTime: now.add(const Duration(hours: 1)), + ), + alarmOffset: const Duration(minutes: 5), + provider: AlarmProvider.androidAlarmManager, + ); + final staleFallback = buildScheduledAlarmRecord( + scheduleWithAlarmAt( + id: 'stale-fallback', + alarmTime: now.add(const Duration(hours: 2)), + ), + alarmOffset: const Duration(minutes: 5), + provider: AlarmProvider.localNotification, + ); + schedulerService.throwOnCancelIds.add('stale-native'); + fallbackService.throwOnCancelIds.add('stale-fallback'); + registryRepository.records = [staleNative, staleFallback]; + + final result = await useCase(); + + expect(result.status, AlarmReconciliationStatus.armed); + expect(registryRepository.records, isEmpty); + }); + test( 'session invalidation cancels alarms, clears registry, and signs out', () async { diff --git a/test/domain/use-cases/schedule_date_use_cases_test.dart b/test/domain/use-cases/schedule_date_use_cases_test.dart new file mode 100644 index 00000000..baef4ac0 --- /dev/null +++ b/test/domain/use-cases/schedule_date_use_cases_test.dart @@ -0,0 +1,259 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/adjacent_schedules_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/repositories/schedule_repository.dart'; +import 'package:on_time_front/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_schedules_by_date_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_schedules_by_date_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_schedules_for_month_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_schedules_for_week_use_case.dart'; + +void main() { + test( + 'GetSchedulesByDateUseCase filters inclusive start exclusive end sorted', + () async { + final repository = _FakeScheduleRepository(); + final useCase = GetSchedulesByDateUseCase(repository); + final start = DateTime(2026, 5, 10); + final end = DateTime(2026, 5, 17); + final resultFuture = useCase(start, end).first; + await pumpEventQueue(); + + repository.emit([ + _schedule('late', DateTime(2026, 5, 18)), + _schedule('inside-later', DateTime(2026, 5, 12, 9)), + _schedule('inside-start', DateTime(2026, 5, 10)), + _schedule('before', DateTime(2026, 5, 9, 23, 59)), + _schedule('exclusive-end', end), + ]); + + final result = await resultFuture; + + expect(result.map((schedule) => schedule.id), [ + 'inside-start', + 'inside-later', + ]); + }, + ); + + test( + 'LoadSchedulesByDateUseCase forwards requested range to repository', + () async { + final repository = _FakeScheduleRepository(); + final useCase = LoadSchedulesByDateUseCase(repository); + final start = DateTime(2026, 5, 10); + final end = DateTime(2026, 5, 17); + + await useCase(start, end); + + expect(repository.loadedRanges, [(start, end)]); + }, + ); + + test( + 'LoadSchedulesForMonthUseCase loads the selected calendar month', + () async { + final recorder = _RecordingLoadSchedulesByDateUseCase(); + final useCase = LoadSchedulesForMonthUseCase(recorder); + + await useCase(DateTime(2026, 2, 14)); + + expect(recorder.calls, [(DateTime(2026, 2), DateTime(2026, 2, 28))]); + }, + ); + + test( + 'LoadSchedulesForWeekUseCase loads Monday through next Monday', + () async { + final recorder = _RecordingLoadSchedulesByDateUseCase(); + final useCase = LoadSchedulesForWeekUseCase(recorder); + + await useCase(DateTime(2026, 5, 14)); + + expect(recorder.calls, [(DateTime(2026, 5, 11), DateTime(2026, 5, 18))]); + }, + ); + + test( + 'GetAdjacentSchedulesWithPreparationUseCase returns closest previous and next schedules', + () async { + final selected = DateTime(2026, 5, 15, 12); + final current = _schedule('current', DateTime(2026, 5, 15, 13)); + final previousClosest = _schedule( + 'previous-close', + DateTime(2026, 5, 15, 11, 30), + ); + final previousFar = _schedule('previous-far', DateTime(2026, 5, 15, 9)); + final nextClosest = _schedule( + 'next-close', + DateTime(2026, 5, 15, 12, 15), + ); + final nextFar = _schedule('next-far', DateTime(2026, 5, 15, 16)); + final useCase = GetAdjacentSchedulesWithPreparationUseCase( + _FakeGetSchedulesByDateUseCase([ + nextFar, + previousFar, + current, + nextClosest, + previousClosest, + ]), + _FakeGetPreparationByScheduleIdUseCase({ + previousClosest.id: _preparation('prev-step'), + nextClosest.id: _preparation('next-step'), + nextFar.id: _preparation('next-far-step'), + previousFar.id: _preparation('previous-far-step'), + }), + ); + + final result = await useCase( + selectedDateTime: selected, + currentScheduleId: current.id, + startDate: DateTime(2026, 5, 15), + endDate: DateTime(2026, 5, 16), + ); + + expect(result.hasPrevious, isTrue); + expect(result.hasNext, isTrue); + expect(result.previousSchedule!.id, 'previous-close'); + expect(result.nextSchedule!.id, 'next-close'); + expect( + result.previousSchedule!.preparation.preparationStepList.single.id, + 'prev-step', + ); + expect( + result.nextSchedule!.preparation.preparationStepList.single.id, + 'next-step', + ); + }, + ); + + test( + 'GetAdjacentSchedulesWithPreparationUseCase omits schedules whose preparation is unavailable', + () async { + final selected = DateTime(2026, 5, 15, 12); + final useCase = GetAdjacentSchedulesWithPreparationUseCase( + _FakeGetSchedulesByDateUseCase([ + _schedule('previous', DateTime(2026, 5, 15, 11)), + _schedule('next', DateTime(2026, 5, 15, 13)), + ]), + _FakeGetPreparationByScheduleIdUseCase(const {}), + ); + + final result = await useCase( + selectedDateTime: selected, + startDate: DateTime(2026, 5, 15), + endDate: DateTime(2026, 5, 16), + ); + + expect(result, isA()); + expect(result.isEmpty, isTrue); + }, + ); +} + +ScheduleEntity _schedule(String id, DateTime scheduleTime) { + return ScheduleEntity( + id: id, + place: const PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: id, + scheduleTime: scheduleTime, + moveTime: const Duration(minutes: 10), + isChanged: false, + isStarted: false, + scheduleSpareTime: null, + scheduleNote: '', + ); +} + +class _RecordingLoadSchedulesByDateUseCase + implements LoadSchedulesByDateUseCase { + final calls = <(DateTime, DateTime?)>[]; + + @override + Future call(DateTime startDate, DateTime? endDate) async { + calls.add((startDate, endDate)); + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeScheduleRepository implements ScheduleRepository { + final _controller = StreamController>.broadcast(); + final loadedRanges = <(DateTime, DateTime?)>[]; + + void emit(List schedules) { + _controller.add(schedules.toSet()); + } + + @override + Stream> get scheduleStream => _controller.stream; + + @override + Future> getSchedulesByDate( + DateTime startDate, + DateTime? endDate, + ) async { + loadedRanges.add((startDate, endDate)); + return const []; + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +PreparationEntity _preparation(String stepId) { + return PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: stepId, + preparationName: stepId, + preparationTime: const Duration(minutes: 5), + ), + ], + ); +} + +class _FakeGetSchedulesByDateUseCase extends GetSchedulesByDateUseCase { + _FakeGetSchedulesByDateUseCase(this.schedules) + : super(_FakeScheduleRepository()); + + final List schedules; + + @override + Stream> call( + DateTime startDate, + DateTime endDate, + ) async* { + yield schedules; + } +} + +class _FakeGetPreparationByScheduleIdUseCase + extends GetPreparationByScheduleIdUseCase { + _FakeGetPreparationByScheduleIdUseCase(this.preparations) + : super(_FakePreparationRepository()); + + final Map preparations; + + @override + Future call(String scheduleId) async { + final preparation = preparations[scheduleId]; + if (preparation == null) { + throw StateError('Missing preparation for $scheduleId'); + } + return preparation; + } +} + +class _FakePreparationRepository implements PreparationRepository { + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/domain/use-cases/schedule_mutation_use_cases_test.dart b/test/domain/use-cases/schedule_mutation_use_cases_test.dart new file mode 100644 index 00000000..bfab079d --- /dev/null +++ b/test/domain/use-cases/schedule_mutation_use_cases_test.dart @@ -0,0 +1,281 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/schedule_repository.dart'; +import 'package:on_time_front/domain/repositories/user_repository.dart'; +import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; +import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart'; +import 'package:on_time_front/domain/use-cases/create_schedule_with_place_use_case.dart'; +import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart'; +import 'package:on_time_front/domain/use-cases/finish_schedule_use_case.dart'; +import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; +import 'package:on_time_front/domain/use-cases/sign_out_use_case.dart'; +import 'package:on_time_front/domain/use-cases/update_schedule_use_case.dart'; + +void main() { + test( + 'create and update schedule use cases persist then reconcile alarms', + () async { + final scheduleRepository = _FakeScheduleRepository(); + final reconcile = _FakeReconcileAlarmsUseCase(); + final createUseCase = CreateScheduleWithPlaceUseCase( + scheduleRepository, + reconcile, + ); + final updateUseCase = UpdateScheduleUseCase( + scheduleRepository, + reconcile, + ); + final schedule = _schedule('schedule-1'); + + await createUseCase(schedule); + await updateUseCase( + schedule.copyWith(doneStatus: ScheduleDoneStatus.lateEnd), + ); + await pumpEventQueue(); + + expect(scheduleRepository.createdSchedules, [schedule]); + expect( + scheduleRepository.updatedSchedules.single.doneStatus, + ScheduleDoneStatus.lateEnd, + ); + expect(reconcile.callCount, 2); + }, + ); + + test( + 'delete schedule removes schedule, cancels alarm, then reconciles', + () async { + final scheduleRepository = _FakeScheduleRepository(); + final cancel = _FakeCancelScheduleAlarmUseCase(); + final reconcile = _FakeReconcileAlarmsUseCase(); + final useCase = DeleteScheduleUseCase( + scheduleRepository, + cancel, + reconcile, + ); + final schedule = _schedule('schedule-1'); + + await useCase(schedule); + await pumpEventQueue(); + + expect(scheduleRepository.deletedSchedules, [schedule]); + expect(cancel.cancelledScheduleIds, ['schedule-1']); + expect(reconcile.callCount, 1); + }, + ); + + test( + 'finish schedule records lateness, cancels alarm, then reconciles', + () async { + final scheduleRepository = _FakeScheduleRepository(); + final cancel = _FakeCancelScheduleAlarmUseCase(); + final reconcile = _FakeReconcileAlarmsUseCase(); + final useCase = FinishScheduleUseCase( + scheduleRepository, + cancel, + reconcile, + ); + + await useCase('schedule-1', 12); + await pumpEventQueue(); + + expect(scheduleRepository.finishedSchedules, [('schedule-1', 12)]); + expect(cancel.cancelledScheduleIds, ['schedule-1']); + expect(reconcile.callCount, 1); + }, + ); + + test( + 'sign out clears registered alarms before clearing user session', + () async { + final userRepository = _FakeUserRepository(); + final cancelAll = _FakeCancelAllAlarmsUseCase(); + final useCase = SignOutUseCase(userRepository, cancelAll); + + await useCase(); + + expect(cancelAll.unregisterDeviceRequests, [true]); + expect(userRepository.signOutCount, 1); + }, + ); +} + +ScheduleEntity _schedule(String id) { + return ScheduleEntity( + id: id, + place: const PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: 'Meeting', + scheduleTime: DateTime(2026, 5, 15, 9), + moveTime: const Duration(minutes: 10), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 5), + scheduleNote: '', + ); +} + +class _FakeScheduleRepository implements ScheduleRepository { + final createdSchedules = []; + final updatedSchedules = []; + final deletedSchedules = []; + final finishedSchedules = <(String, int)>[]; + + @override + Stream> get scheduleStream => const Stream.empty(); + + @override + Future createSchedule(ScheduleEntity schedule) async { + createdSchedules.add(schedule); + } + + @override + Future deleteSchedule(ScheduleEntity schedule) async { + deletedSchedules.add(schedule); + } + + @override + Future finishSchedule(String scheduleId, int latenessTime) async { + finishedSchedules.add((scheduleId, latenessTime)); + } + + @override + Future getScheduleById(String id) async => _schedule(id); + + @override + Future> getSchedulesByDate( + DateTime startDate, + DateTime? endDate, + ) async => const []; + + @override + Future updateSchedule( + ScheduleEntity schedule, { + bool includePreparationSource = false, + }) async { + updatedSchedules.add(schedule); + } +} + +class _FakeCancelScheduleAlarmUseCase implements CancelScheduleAlarmUseCase { + final cancelledScheduleIds = []; + + @override + Future call(String scheduleId) async { + cancelledScheduleIds.add(scheduleId); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeCancelAllAlarmsUseCase implements CancelAllAlarmsUseCase { + final unregisterDeviceRequests = []; + + @override + Future call({bool unregisterDevice = false}) async { + unregisterDeviceRequests.add(unregisterDevice); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeReconcileAlarmsUseCase implements ReconcileAlarmsUseCase { + int callCount = 0; + + @override + Future call() async { + callCount += 1; + return AlarmReconciliationResult( + status: AlarmReconciliationStatus.armed, + nativeAlarmProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.localNotification, + armedScheduleIds: const [], + skippedScheduleCount: 0, + failures: const [], + scheduleWindowStart: DateTime.utc(2026, 5, 15), + scheduleWindowEnd: DateTime.utc(2026, 5, 23), + alarmCoverageStart: DateTime.utc(2026, 5, 15), + alarmCoverageEnd: DateTime.utc(2026, 5, 22), + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeUserRepository implements UserRepository { + int signOutCount = 0; + + @override + Stream get userStream => const Stream.empty(); + + @override + Future signOut() async { + signOutCount += 1; + } + + @override + Future deleteAppleUser({String? feedbackMessage}) async {} + + @override + Future deleteGoogleUser({String? feedbackMessage}) async {} + + @override + Future deleteUser({String? feedbackMessage}) async {} + + @override + Future disconnectGoogleSignIn() async {} + + @override + Future getUser() async {} + + @override + Future getUserSocialType() async => null; + + @override + Future postFeedback(String message) async {} + + @override + Future signIn({ + required String email, + required String password, + }) async {} + + @override + Future signInWithApple({ + required String idToken, + required String authCode, + required String fullName, + String? email, + }) async {} + + @override + Future signUp({ + required String email, + required String password, + required String name, + }) async {} + + @override + Future signInWithGoogle(GoogleSignInAccount account) async {} + + @override + Future initializeGoogleSignIn() async {} + + @override + bool get supportsGoogleAuthenticate => false; + + @override + Stream get googleAuthenticationEvents => + const Stream.empty(); + + @override + Future authenticateWithGoogle() async { + throw UnimplementedError(); + } +} diff --git a/test/domain/use-cases/timed_preparation_use_cases_test.dart b/test/domain/use-cases/timed_preparation_use_cases_test.dart new file mode 100644 index 00000000..044e4dad --- /dev/null +++ b/test/domain/use-cases/timed_preparation_use_cases_test.dart @@ -0,0 +1,498 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/early_start_session_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/repositories/schedule_repository.dart'; +import 'package:on_time_front/domain/repositories/early_start_session_repository.dart'; +import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; +import 'package:on_time_front/domain/use-cases/clear_early_start_session_use_case.dart'; +import 'package:on_time_front/domain/use-cases/clear_timed_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_early_start_session_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_schedules_by_date_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_timed_preparation_snapshot_use_case.dart'; +import 'package:on_time_front/domain/use-cases/create_custom_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_schedules_by_date_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_schedules_for_week_use_case.dart'; +import 'package:on_time_front/domain/use-cases/mark_early_start_session_use_case.dart'; +import 'package:on_time_front/domain/use-cases/save_timed_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/stream_preparations_use_case.dart'; +import 'package:on_time_front/domain/use-cases/update_default_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/update_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/update_spare_time_use_case.dart'; + +void main() { + test('SaveTimedPreparationUseCase stores a fingerprinted snapshot', () async { + final repository = _FakeTimedPreparationRepository(); + final useCase = SaveTimedPreparationUseCase(repository); + final schedule = _scheduleWithPreparation('schedule-1'); + final preparation = schedule.preparation.timeElapsed( + const Duration(minutes: 3), + ); + final savedAt = DateTime.utc(2026, 5, 15, 8); + + await useCase(schedule, preparation, savedAt: savedAt); + + expect(repository.savedScheduleId, 'schedule-1'); + expect(repository.savedSnapshot!.preparation, preparation); + expect(repository.savedSnapshot!.savedAt, savedAt); + expect( + repository.savedSnapshot!.scheduleFingerprint, + schedule.cacheFingerprint, + ); + }); + + test( + 'timed preparation read and clear use cases delegate by schedule id', + () async { + final repository = _FakeTimedPreparationRepository(); + final snapshot = TimedPreparationSnapshotEntity( + preparation: _scheduleWithPreparation('schedule-1').preparation, + savedAt: DateTime.utc(2026, 5, 15, 8), + scheduleFingerprint: 'fingerprint-1', + ); + repository.savedSnapshot = snapshot; + + expect( + await GetTimedPreparationSnapshotUseCase(repository)('schedule-1'), + snapshot, + ); + await ClearTimedPreparationUseCase(repository)('schedule-1'); + + expect(repository.loadedScheduleIds, ['schedule-1']); + expect(repository.clearedScheduleIds, ['schedule-1']); + }, + ); + + test( + 'early start session use cases mark read and clear one schedule', + () async { + final repository = _FakeEarlyStartSessionRepository(); + final startedAt = DateTime.utc(2026, 5, 15, 8); + + await MarkEarlyStartSessionUseCase(repository)( + scheduleId: 'schedule-1', + startedAt: startedAt, + ); + final session = await GetEarlyStartSessionUseCase(repository)( + 'schedule-1', + ); + await ClearEarlyStartSessionUseCase(repository)('schedule-1'); + + expect( + session, + EarlyStartSessionEntity(scheduleId: 'schedule-1', startedAt: startedAt), + ); + expect(repository.markedSessions, [('schedule-1', startedAt)]); + expect(repository.loadedScheduleIds, ['schedule-1']); + expect(repository.clearedScheduleIds, ['schedule-1']); + }, + ); + + test('timed preparation snapshots copy changed cache fields', () { + final original = TimedPreparationSnapshotEntity( + preparation: _scheduleWithPreparation('schedule-1').preparation, + savedAt: DateTime.utc(2026, 5, 15, 8), + scheduleFingerprint: 'fingerprint-1', + ); + final replacementPreparation = _scheduleWithPreparation( + 'schedule-2', + ).preparation.timeElapsed(const Duration(minutes: 10)); + final replacementSavedAt = DateTime.utc(2026, 5, 15, 9); + + final copied = original.copyWith( + preparation: replacementPreparation, + savedAt: replacementSavedAt, + scheduleFingerprint: 'fingerprint-2', + ); + + expect(copied.preparation, replacementPreparation); + expect(copied.savedAt, replacementSavedAt); + expect(copied.scheduleFingerprint, 'fingerprint-2'); + expect(original.copyWith().props, [ + original.preparation, + original.savedAt, + 'fingerprint-1', + ]); + }); + + test( + 'LoadAdjacentScheduleWithPreparationUseCase loads each schedule prep', + () async { + final scheduleRepository = _FakeScheduleRepository({ + _schedule('outside-before', DateTime.utc(2026, 5, 14, 23)), + _schedule('schedule-b', DateTime.utc(2026, 5, 15, 11)), + _schedule('schedule-a', DateTime.utc(2026, 5, 15, 9)), + _schedule('outside-after', DateTime.utc(2026, 5, 16)), + }); + final preparationRepository = _FakePreparationRepository(); + final useCase = LoadAdjacentScheduleWithPreparationUseCase( + LoadSchedulesByDateUseCase(scheduleRepository), + GetSchedulesByDateUseCase(scheduleRepository), + LoadPreparationByScheduleIdUseCase(preparationRepository), + ); + final start = DateTime.utc(2026, 5, 15); + final end = DateTime.utc(2026, 5, 16); + + await useCase(startDate: start, endDate: end); + + expect(scheduleRepository.requestedRanges.single, (start, end)); + expect(preparationRepository.loadedScheduleIds, [ + 'schedule-a', + 'schedule-b', + ]); + }, + ); + + test( + 'GetPreparationByScheduleIdUseCase waits for matching cache entry', + () async { + final preparationRepository = _FakePreparationRepository(); + final useCase = GetPreparationByScheduleIdUseCase(preparationRepository); + + final future = useCase('schedule-1'); + preparationRepository.emit({ + 'other': const PreparationEntity(preparationStepList: []), + }); + preparationRepository.emit({ + 'schedule-1': const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'prep-1', + preparationName: 'Pack', + preparationTime: Duration(minutes: 5), + ), + ], + ), + }); + + final preparation = await future; + + expect(preparation.preparationStepList.single.id, 'prep-1'); + }, + ); + + test( + 'preparation use cases delegate to repository with explicit arguments', + () async { + final repository = _FakePreparationRepository(); + final preparation = _preparation('prep-1'); + + await CreateCustomPreparationUseCase(repository)( + preparation, + 'schedule-1', + ); + await UpdateDefaultPreparationUseCase(repository)(preparation); + await UpdatePreparationByScheduleIdUseCase(repository)( + preparation, + 'schedule-2', + ); + await UpdateSpareTimeUseCase(repository)(const Duration(minutes: 20)); + + expect(repository.customPreparationCalls, [(preparation, 'schedule-1')]); + expect(repository.updatedDefaultPreparations, [preparation]); + expect(repository.updatedSchedulePreparations, [ + (preparation, 'schedule-2'), + ]); + expect(repository.updatedSpareTimes, [const Duration(minutes: 20)]); + }, + ); + + test( + 'default and stream preparation use cases expose repository values', + () async { + final repository = _FakePreparationRepository(); + final defaultPreparation = _preparation('default-prep'); + final schedulePreparation = _preparation('schedule-prep'); + repository.defaultPreparation = defaultPreparation; + repository.emit({'schedule-1': schedulePreparation}); + + expect( + await GetDefaultPreparationUseCase(repository)(), + defaultPreparation, + ); + expect(await StreamPreparationsUseCase(repository)().first, { + 'schedule-1': schedulePreparation, + }); + }, + ); + + test( + 'GetNearestUpcomingScheduleUseCase loads prep for nearest active schedule', + () async { + final now = DateTime.now(); + final nearest = _schedule('nearest', now.add(const Duration(hours: 1))); + final ended = _schedule( + 'ended', + now.add(const Duration(minutes: 30)), + ).copyWith(doneStatus: ScheduleDoneStatus.normalEnd); + final later = _schedule('later', now.add(const Duration(hours: 2))); + final scheduleRepository = _FakeScheduleRepository({ + later, + ended, + nearest, + }); + final preparationRepository = _FakePreparationRepository() + ..emit({ + 'nearest': const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'prep-1', + preparationName: 'Pack', + preparationTime: Duration(minutes: 5), + ), + ], + ), + }); + final loadSchedulesByDate = LoadSchedulesByDateUseCase( + scheduleRepository, + ); + final useCase = GetNearestUpcomingScheduleUseCase( + GetSchedulesByDateUseCase(scheduleRepository), + LoadPreparationByScheduleIdUseCase(preparationRepository), + GetPreparationByScheduleIdUseCase(preparationRepository), + LoadSchedulesForWeekUseCase(loadSchedulesByDate), + ); + + final schedule = await useCase().first; + + expect(schedule!.id, 'nearest'); + expect(preparationRepository.loadedScheduleIds, ['nearest']); + expect(schedule.preparation.preparationStepList.single.id, 'prep-1'); + expect( + scheduleRepository.requestedRanges.length, + greaterThanOrEqualTo(1), + ); + }, + ); +} + +class _FakeTimedPreparationRepository implements TimedPreparationRepository { + String? savedScheduleId; + TimedPreparationSnapshotEntity? savedSnapshot; + final loadedScheduleIds = []; + final clearedScheduleIds = []; + + @override + Future clearTimedPreparation(String scheduleId) async { + clearedScheduleIds.add(scheduleId); + } + + @override + Future getTimedPreparationSnapshot( + String scheduleId, + ) async { + loadedScheduleIds.add(scheduleId); + return savedSnapshot; + } + + @override + Future saveTimedPreparationSnapshot( + String scheduleId, + TimedPreparationSnapshotEntity snapshot, + ) async { + savedScheduleId = scheduleId; + savedSnapshot = snapshot; + } +} + +class _FakeEarlyStartSessionRepository implements EarlyStartSessionRepository { + final markedSessions = <(String, DateTime)>[]; + final loadedScheduleIds = []; + final clearedScheduleIds = []; + final sessions = {}; + + @override + Future markStarted({ + required String scheduleId, + required DateTime startedAt, + }) async { + markedSessions.add((scheduleId, startedAt)); + sessions[scheduleId] = EarlyStartSessionEntity( + scheduleId: scheduleId, + startedAt: startedAt, + ); + } + + @override + Future getSession(String scheduleId) async { + loadedScheduleIds.add(scheduleId); + return sessions[scheduleId]; + } + + @override + Future clear(String scheduleId) async { + clearedScheduleIds.add(scheduleId); + sessions.remove(scheduleId); + } +} + +class _FakeScheduleRepository implements ScheduleRepository { + _FakeScheduleRepository(this._schedules); + + final Set _schedules; + final requestedRanges = <(DateTime, DateTime?)>[]; + + @override + Stream> get scheduleStream => Stream.value(_schedules); + + @override + Future createSchedule(ScheduleEntity schedule) async {} + + @override + Future deleteSchedule(ScheduleEntity schedule) async {} + + @override + Future finishSchedule(String scheduleId, int latenessTime) async {} + + @override + Future getScheduleById(String id) async => + _schedules.firstWhere((schedule) => schedule.id == id); + + @override + Future> getSchedulesByDate( + DateTime startDate, + DateTime? endDate, + ) async { + requestedRanges.add((startDate, endDate)); + return _schedules.toList(); + } + + @override + Future updateSchedule( + ScheduleEntity schedule, { + bool includePreparationSource = false, + }) async {} +} + +class _FakePreparationRepository implements PreparationRepository { + final _controller = + StreamController>.broadcast(); + Map _currentPreparations = {}; + PreparationEntity defaultPreparation = const PreparationEntity( + preparationStepList: [], + ); + final loadedScheduleIds = []; + final customPreparationCalls = <(PreparationEntity, String)>[]; + final updatedDefaultPreparations = []; + final updatedSchedulePreparations = <(PreparationEntity, String)>[]; + final updatedSpareTimes = []; + + @override + Stream> get preparationStream async* { + yield _currentPreparations; + yield* _controller.stream; + } + + void emit(Map preparations) { + _currentPreparations = preparations; + _controller.add(preparations); + } + + @override + Future createCustomPreparation( + PreparationEntity preparationEntity, + String scheduleId, + ) async { + customPreparationCalls.add((preparationEntity, scheduleId)); + } + + @override + Future createDefaultPreparation({ + required PreparationEntity preparationEntity, + required Duration spareTime, + required String note, + }) async {} + + @override + Future getDefualtPreparation() async => defaultPreparation; + + @override + Future getPreparationByScheduleId(String scheduleId) async { + loadedScheduleIds.add(scheduleId); + } + + @override + Future updateDefaultPreparation( + PreparationEntity preparationEntity, + ) async { + updatedDefaultPreparations.add(preparationEntity); + } + + @override + Future updatePreparationByScheduleId( + PreparationEntity preparationEntity, + String scheduleId, + ) async { + updatedSchedulePreparations.add((preparationEntity, scheduleId)); + } + + @override + Future updateSpareTime(Duration newSpareTime) async { + updatedSpareTimes.add(newSpareTime); + } +} + +PreparationEntity _preparation(String id) { + return PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: id, + preparationName: 'Pack', + preparationTime: const Duration(minutes: 5), + ), + ], + ); +} + +ScheduleEntity _schedule(String id, DateTime scheduleTime) { + return ScheduleEntity( + id: id, + place: const PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: 'Meeting', + scheduleTime: scheduleTime, + moveTime: const Duration(minutes: 10), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 5), + scheduleNote: '', + ); +} + +ScheduleWithPreparationEntity _scheduleWithPreparation(String id) { + final schedule = _schedule(id, DateTime.utc(2026, 5, 15, 9)); + return ScheduleWithPreparationEntity( + id: schedule.id, + place: schedule.place, + scheduleName: schedule.scheduleName, + scheduleTime: schedule.scheduleTime, + moveTime: schedule.moveTime, + isChanged: schedule.isChanged, + isStarted: schedule.isStarted, + scheduleSpareTime: schedule.scheduleSpareTime, + scheduleNote: schedule.scheduleNote, + preparation: const PreparationWithTimeEntity( + preparationStepList: [ + PreparationStepWithTimeEntity( + id: 'prep-1', + preparationName: 'Pack', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ), + ); +} diff --git a/test/domain/use-cases/user_use_cases_test.dart b/test/domain/use-cases/user_use_cases_test.dart new file mode 100644 index 00000000..82b3d3d6 --- /dev/null +++ b/test/domain/use-cases/user_use_cases_test.dart @@ -0,0 +1,201 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/repositories/user_repository.dart'; +import 'package:on_time_front/domain/use-cases/load_user_use_case.dart'; +import 'package:on_time_front/domain/use-cases/onboard_use_case.dart'; +import 'package:on_time_front/domain/use-cases/stream_user_use_case.dart'; + +void main() { + test('LoadUserUseCase refreshes the current user', () async { + final repository = _FakeUserRepository(); + + await LoadUserUseCase(repository)(); + + expect(repository.getUserCount, 1); + }); + + test('StreamUserUseCase exposes the repository user stream', () async { + final repository = _FakeUserRepository(); + final user = _user('user-1'); + + repository.emit(user); + + expect(await StreamUserUseCase(repository)().first, user); + }); + + test('OnboardUseCase creates defaults before refreshing the user', () async { + final preparationRepository = _FakePreparationRepository(); + final userRepository = _FakeUserRepository(); + final preparation = _preparation('prep-1'); + + await OnboardUseCase(preparationRepository, userRepository)( + preparationEntity: preparation, + spareTime: const Duration(minutes: 15), + note: 'Need shoes', + ); + + expect(preparationRepository.createdDefaults, [ + (preparation, const Duration(minutes: 15), 'Need shoes'), + ]); + expect(userRepository.getUserCount, 1); + expect(userRepository.events, ['getUser']); + }); +} + +class _FakeUserRepository implements UserRepository { + final _controller = Stream.empty().asBroadcastStream(); + final emittedUsers = []; + final events = []; + int getUserCount = 0; + + @override + Stream get userStream async* { + for (final user in emittedUsers) { + yield user; + } + yield* _controller; + } + + void emit(UserEntity user) { + emittedUsers.add(user); + } + + @override + Stream get googleAuthenticationEvents => + const Stream.empty(); + + @override + bool get supportsGoogleAuthenticate => false; + + @override + Future authenticateWithGoogle() => + throw UnimplementedError(); + + @override + Future getUser() async { + getUserCount += 1; + events.add('getUser'); + } + + @override + Future initializeGoogleSignIn() async {} + + @override + Future deleteAppleUser({String? feedbackMessage}) => + throw UnimplementedError(); + + @override + Future deleteGoogleUser({String? feedbackMessage}) => + throw UnimplementedError(); + + @override + Future deleteUser({String? feedbackMessage}) => + throw UnimplementedError(); + + @override + Future disconnectGoogleSignIn() => throw UnimplementedError(); + + @override + Future getUserSocialType() => throw UnimplementedError(); + + @override + Future postFeedback(String message) => throw UnimplementedError(); + + @override + Future signIn({required String email, required String password}) => + throw UnimplementedError(); + + @override + Future signInWithApple({ + required String idToken, + required String authCode, + required String fullName, + String? email, + }) => throw UnimplementedError(); + + @override + Future signInWithGoogle(GoogleSignInAccount account) => + throw UnimplementedError(); + + @override + Future signOut() => throw UnimplementedError(); + + @override + Future signUp({ + required String email, + required String password, + required String name, + }) => throw UnimplementedError(); +} + +class _FakePreparationRepository implements PreparationRepository { + final createdDefaults = <(PreparationEntity, Duration, String)>[]; + + @override + Stream> get preparationStream => + const Stream.empty(); + + @override + Future createDefaultPreparation({ + required PreparationEntity preparationEntity, + required Duration spareTime, + required String note, + }) async { + createdDefaults.add((preparationEntity, spareTime, note)); + } + + @override + Future createCustomPreparation( + PreparationEntity preparationEntity, + String scheduleId, + ) => throw UnimplementedError(); + + @override + Future getDefualtPreparation() => + throw UnimplementedError(); + + @override + Future getPreparationByScheduleId(String scheduleId) => + throw UnimplementedError(); + + @override + Future updateDefaultPreparation(PreparationEntity preparationEntity) => + throw UnimplementedError(); + + @override + Future updatePreparationByScheduleId( + PreparationEntity preparationEntity, + String scheduleId, + ) => throw UnimplementedError(); + + @override + Future updateSpareTime(Duration newSpareTime) => + throw UnimplementedError(); +} + +PreparationEntity _preparation(String id) { + return PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: id, + preparationName: 'Pack', + preparationTime: const Duration(minutes: 5), + ), + ], + ); +} + +UserEntity _user(String id) { + return UserEntity( + id: id, + email: '$id@example.com', + name: 'Test User', + spareTime: const Duration(minutes: 10), + note: 'note', + score: 1, + ); +} diff --git a/test/presentation/alarm/components/preparation_step_list_widget_test.dart b/test/presentation/alarm/components/preparation_step_list_widget_test.dart new file mode 100644 index 00000000..fb62c548 --- /dev/null +++ b/test/presentation/alarm/components/preparation_step_list_widget_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/presentation/alarm/components/preparation_step_list_widget.dart'; +import 'package:on_time_front/presentation/shared/constants/constants.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +void main() { + testWidgets('renders ordered preparation steps and wires skip action', ( + tester, + ) async { + var skipCount = 0; + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: PreparationStepListWidget( + preparationSteps: _steps, + currentStepIndex: 1, + stepElapsedTimes: const [0, 90, 120], + preparationStepStates: const [ + PreparationStateEnum.done, + PreparationStateEnum.now, + PreparationStateEnum.yet, + ], + onSkip: () => skipCount += 1, + ), + ), + ), + ); + + expect(find.text('Wake up'), findsOneWidget); + expect(find.text('Shower'), findsOneWidget); + expect(find.text('Leave home'), findsOneWidget); + expect(find.byIcon(Icons.check), findsOneWidget); + expect(find.text('1분 30초'), findsOneWidget); + expect(find.text('이 단계 건너 뛰기'), findsOneWidget); + + await tester.tap(find.text('이 단계 건너 뛰기')); + await tester.pump(); + + expect(skipCount, 1); + }); + + testWidgets('scrolls toward the previous step when current step advances', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: PreparationStepListWidget( + preparationSteps: _steps, + currentStepIndex: 1, + stepElapsedTimes: const [0, 90, 120], + preparationStepStates: const [ + PreparationStateEnum.done, + PreparationStateEnum.done, + PreparationStateEnum.now, + ], + onSkip: () {}, + ), + ), + ), + ); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: PreparationStepListWidget( + preparationSteps: _steps, + currentStepIndex: 2, + stepElapsedTimes: const [0, 90, 120], + preparationStepStates: const [ + PreparationStateEnum.done, + PreparationStateEnum.done, + PreparationStateEnum.now, + ], + onSkip: () {}, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Leave home'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +const _steps = [ + PreparationStepEntity( + id: 'step-1', + preparationName: 'Wake up', + preparationTime: Duration(minutes: 5), + nextPreparationId: 'step-2', + ), + PreparationStepEntity( + id: 'step-2', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + nextPreparationId: 'step-3', + ), + PreparationStepEntity( + id: 'step-3', + preparationName: 'Leave home', + preparationTime: Duration(minutes: 15), + ), +]; diff --git a/test/presentation/alarm/screens/preparation_flow_widget_test.dart b/test/presentation/alarm/screens/preparation_flow_widget_test.dart index 9db771ea..aa4fee69 100644 --- a/test/presentation/alarm/screens/preparation_flow_widget_test.dart +++ b/test/presentation/alarm/screens/preparation_flow_widget_test.dart @@ -119,7 +119,9 @@ class InMemoryGetEarlyStartSessionUseCase final startedAt = store.sessions[scheduleId]; if (startedAt == null) return null; return EarlyStartSessionEntity( - scheduleId: scheduleId, startedAt: startedAt); + scheduleId: scheduleId, + startedAt: startedAt, + ); } } @@ -200,6 +202,38 @@ Future pumpWithRouter( await tester.pump(); } +class StaticScheduleBloc implements ScheduleBloc { + StaticScheduleBloc(this._state); + + final ScheduleState _state; + final addedEvents = []; + + @override + ScheduleState get state => _state; + + @override + Stream get stream => const Stream.empty(); + + @override + bool get isClosed => false; + + @override + void add(ScheduleEvent event) { + addedEvents.add(event); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Future pumpWithStaticScheduleBloc( + WidgetTester tester, { + required StaticScheduleBloc bloc, + required GoRouter router, +}) { + return pumpWithRouter(tester, bloc: bloc, router: router); +} + class _TestAssetBundle extends CachingAssetBundle { static const _minimalSvg = ''; @@ -304,12 +338,15 @@ void main() { getSnapshotUseCase = StubGetTimedPreparationSnapshotUseCase({}); clearTimedUseCase = NoopClearTimedPreparationUseCase(); earlySessionStore = EarlyStartSessionStore(); - markEarlyStartUseCase = - InMemoryMarkEarlyStartSessionUseCase(earlySessionStore); - getEarlyStartUseCase = - InMemoryGetEarlyStartSessionUseCase(earlySessionStore); - clearEarlyStartUseCase = - InMemoryClearEarlyStartSessionUseCase(earlySessionStore); + markEarlyStartUseCase = InMemoryMarkEarlyStartSessionUseCase( + earlySessionStore, + ); + getEarlyStartUseCase = InMemoryGetEarlyStartSessionUseCase( + earlySessionStore, + ); + clearEarlyStartUseCase = InMemoryClearEarlyStartSessionUseCase( + earlySessionStore, + ); now = DateTime(2026, 3, 20, 9, 0, 0); bloc = ScheduleBloc.test( StubGetNearestUpcomingScheduleUseCase(() => controller.stream), @@ -348,21 +385,21 @@ void main() { ScheduleStartPromptVariant.officialStart, ); expect( - scheduleStartPromptVariantFromRouteExtra( - const {'promptVariant': 'earlyStart'}, - ), + scheduleStartPromptVariantFromRouteExtra(const { + 'promptVariant': 'earlyStart', + }), ScheduleStartPromptVariant.earlyStart, ); expect( - scheduleStartPromptVariantFromRouteExtra( - const {'promptVariant': 'fiveMinutes'}, - ), + scheduleStartPromptVariantFromRouteExtra(const { + 'promptVariant': 'fiveMinutes', + }), ScheduleStartPromptVariant.earlyStart, ); expect( - scheduleStartPromptVariantFromRouteExtra( - const {'isFiveMinutesBefore': true}, - ), + scheduleStartPromptVariantFromRouteExtra(const { + 'isFiveMinutesBefore': true, + }), ScheduleStartPromptVariant.earlyStart, ); expect( @@ -370,15 +407,15 @@ void main() { ScheduleStartPromptVariant.officialStart, ); expect( - scheduleStartLaunchActionFromRouteExtra( - const {'alarmLaunchAction': 'startPreparation'}, - ), + scheduleStartLaunchActionFromRouteExtra(const { + 'alarmLaunchAction': 'startPreparation', + }), ScheduleStartLaunchAction.startPreparation, ); expect( - scheduleStartLaunchActionFromRouteExtra( - const {'alarmLaunchAction': 'startPreparing'}, - ), + scheduleStartLaunchActionFromRouteExtra(const { + 'alarmLaunchAction': 'startPreparing', + }), ScheduleStartLaunchAction.startPreparation, ); expect( @@ -387,981 +424,1276 @@ void main() { ); }); - testWidgets('early-start screen variant is shown with two choices', - (tester) async { - await setLargeTestViewport(tester); - - final router = GoRouter( - initialLocation: '/scheduleStart', - routes: [ - GoRoute( - path: '/scheduleStart', - builder: (_, __) => const ScheduleStartScreen( - promptVariant: ScheduleStartPromptVariant.earlyStart, - ), - ), - GoRoute( - path: '/alarmScreen', builder: (_, __) => const Text('ALARM')), - GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), - ], - ); - - await pumpWithRouter(tester, bloc: bloc, router: router); - await tester.pump(const Duration(milliseconds: 100)); - - expect(find.byType(ScheduleStartScreen), findsOneWidget); - expect(find.byType(ElevatedButton), findsNWidgets(2)); - expect(find.text('Start preparing now'), findsOneWidget); - expect(find.text('Not now'), findsOneWidget); - }, timeout: const Timeout(Duration(seconds: 15))); - - testWidgets('early-start start-now button navigates to alarm', - (tester) async { - await setLargeTestViewport(tester); - - final schedule = buildSchedule( - id: 's2', - scheduleTime: now.add(const Duration(minutes: 40)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); - bloc.add(ScheduleUpcomingReceived(schedule)); + testWidgets( + 'official start prompt uses one action and opens alarm', + (tester) async { + await setLargeTestViewport(tester); - final router = GoRouter( - initialLocation: '/scheduleStart', - routes: [ - GoRoute( - path: '/scheduleStart', - builder: (_, __) => const ScheduleStartScreen( - promptVariant: ScheduleStartPromptVariant.earlyStart, + final router = GoRouter( + initialLocation: '/scheduleStart', + routes: [ + GoRoute( + path: '/scheduleStart', + builder: (_, __) => const ScheduleStartScreen(), ), - ), - GoRoute( + GoRoute( path: '/alarmScreen', - builder: (_, __) => const Text('ALARM_ROUTE')), - GoRoute(path: '/home', builder: (_, __) => const Text('HOME_ROUTE')), - ], - ); - - await pumpWithRouter(tester, bloc: bloc, router: router); - await tapAndPump(tester, find.text('Start preparing now')); - await pumpUntilRouteText(tester, 'ALARM_ROUTE'); - expect(find.text('ALARM_ROUTE'), findsOneWidget); - }, timeout: const Timeout(Duration(seconds: 15))); - - testWidgets('early-start not-now button navigates home', (tester) async { - await setLargeTestViewport(tester); - - final schedule = buildSchedule( - id: 's2b', - scheduleTime: now.add(const Duration(minutes: 40)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); - bloc.add(ScheduleUpcomingReceived(schedule)); - - final router = GoRouter( - initialLocation: '/scheduleStart', - routes: [ - GoRoute( - path: '/scheduleStart', - builder: (_, __) => const ScheduleStartScreen( - promptVariant: ScheduleStartPromptVariant.earlyStart, + builder: (_, __) => const Text('ALARM_ROUTE'), ), - ), - GoRoute( - path: '/alarmScreen', - builder: (_, __) => const Text('ALARM_ROUTE')), - GoRoute(path: '/home', builder: (_, __) => const Text('HOME_ROUTE')), - ], - ); - - await pumpWithRouter(tester, bloc: bloc, router: router); - await tapAndPump(tester, find.text('Not now')); - await pumpUntilRouteText(tester, 'HOME_ROUTE'); - expect(find.text('HOME_ROUTE'), findsOneWidget); - }, timeout: const Timeout(Duration(seconds: 15))); - - testWidgets('deprecated five-minute flag resolves to early-start choices', - (tester) async { - await setLargeTestViewport(tester); - - final router = GoRouter( - initialLocation: '/scheduleStart', - routes: [ - GoRoute( - path: '/scheduleStart', - builder: (_, __) => const ScheduleStartScreen( - // ignore: deprecated_member_use_from_same_package - isFiveMinutesBefore: true, + GoRoute( + path: '/home', + builder: (_, __) => const Text('HOME_ROUTE'), ), - ), - GoRoute( - path: '/alarmScreen', builder: (_, __) => const Text('ALARM')), - GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), - ], - ); + ], + ); - await pumpWithRouter(tester, bloc: bloc, router: router); - await tester.pump(const Duration(milliseconds: 100)); - - expect(find.text('Start preparing now'), findsOneWidget); - expect(find.text('Not now'), findsOneWidget); - }, timeout: const Timeout(Duration(seconds: 15))); - - testWidgets('early-start variant is shown with dedicated prompt', - (tester) async { - await setLargeTestViewport(tester); - now = DateTime.now(); - - final schedule = buildSchedule( - id: 'early-prompt', - scheduleTime: now.add(const Duration(minutes: 63, seconds: 30)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); - bloc.emit(ScheduleState.upcoming(schedule)); - final router = GoRouter( - initialLocation: '/scheduleStart', - routes: [ - GoRoute( - path: '/scheduleStart', - builder: (_, __) => const ScheduleStartScreen( - promptVariant: ScheduleStartPromptVariant.earlyStart, - ), - ), - GoRoute( - path: '/alarmScreen', builder: (_, __) => const Text('ALARM')), - GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), - ], - ); + await pumpWithRouter(tester, bloc: bloc, router: router); + await tester.pump(const Duration(milliseconds: 100)); - await pumpWithRouter(tester, bloc: bloc, router: router); - await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Start Preparing'), findsOneWidget); + expect(find.text('Start preparing now'), findsNothing); + expect(find.byType(ElevatedButton), findsOneWidget); - expect( - findTextMatching(RegExp(r"You're starting \d+ minutes early\.")), - findsOneWidget, - ); - expect( - find.textContaining('Would you like to start preparing early now?'), - findsOneWidget, - ); - expect(find.byType(ElevatedButton), findsNWidgets(2)); - expect(find.text('Home'), findsNothing); - expect(find.text('Start preparing now'), findsOneWidget); - expect(find.text('Not now'), findsOneWidget); - }, timeout: const Timeout(Duration(seconds: 15))); - - testWidgets('early-start variant formats hour and minute lead time', - (tester) async { - await setLargeTestViewport(tester); - now = DateTime.now(); - - final schedule = buildSchedule( - id: 'early-hour-minute', - scheduleTime: - now.add(const Duration(hours: 1, minutes: 55, seconds: 30)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); - bloc.emit(ScheduleState.upcoming(schedule)); - final router = GoRouter( - initialLocation: '/scheduleStart', - routes: [ - GoRoute( - path: '/scheduleStart', - builder: (_, __) => const ScheduleStartScreen( - promptVariant: ScheduleStartPromptVariant.earlyStart, - ), - ), - GoRoute( - path: '/alarmScreen', builder: (_, __) => const Text('ALARM')), - GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), - ], - ); + await tapAndPump(tester, find.text('Start Preparing')); + await pumpUntilRouteText(tester, 'ALARM_ROUTE'); - await pumpWithRouter(tester, bloc: bloc, router: router); - await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('ALARM_ROUTE'), findsOneWidget); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); - expect( - findTextMatching(RegExp(r"You're starting 1 hour 15 minutes early\.")), - findsOneWidget, - ); - }, timeout: const Timeout(Duration(seconds: 15))); - - testWidgets('early-start primary action starts preparation and navigates', - (tester) async { - await setLargeTestViewport(tester); - - final schedule = buildSchedule( - id: 'early-primary', - scheduleTime: now.add(const Duration(minutes: 41)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); - bloc.add(ScheduleUpcomingReceived(schedule)); + testWidgets( + 'alarm prompt without cached schedule still offers early start', + (tester) async { + await setLargeTestViewport(tester); - final router = GoRouter( - initialLocation: '/scheduleStart', - routes: [ - GoRoute( - path: '/scheduleStart', - builder: (_, __) => const ScheduleStartScreen( - promptVariant: ScheduleStartPromptVariant.earlyStart, + final router = GoRouter( + initialLocation: '/scheduleStart', + routes: [ + GoRoute( + path: '/scheduleStart', + builder: (_, __) => const ScheduleStartScreen( + promptVariant: ScheduleStartPromptVariant.alarm, + ), ), - ), - GoRoute( + GoRoute( path: '/alarmScreen', - builder: (_, __) => const Text('ALARM_ROUTE')), - GoRoute(path: '/home', builder: (_, __) => const Text('HOME_ROUTE')), - ], - ); + builder: (_, __) => const Text('ALARM_ROUTE'), + ), + GoRoute( + path: '/home', + builder: (_, __) => const Text('HOME_ROUTE'), + ), + ], + ); - await pumpWithRouter(tester, bloc: bloc, router: router); - await tapAndPump(tester, find.text('Start preparing now')); - await pumpUntilRouteText(tester, 'ALARM_ROUTE'); - - expect(find.text('ALARM_ROUTE'), findsOneWidget); - }, timeout: const Timeout(Duration(seconds: 15))); - - testWidgets('early-start has explicit not-now instead of home button', - (tester) async { - await setLargeTestViewport(tester); - - final schedule = buildSchedule( - id: 'early-secondary', - scheduleTime: now.add(const Duration(minutes: 41)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); - bloc.add(ScheduleUpcomingReceived(schedule)); + await pumpWithRouter(tester, bloc: bloc, router: router); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + find.textContaining('start preparing early now'), + findsOneWidget, + ); + expect(find.text('Start preparing now'), findsOneWidget); + expect(find.text('Not now'), findsOneWidget); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + testWidgets( + 'close button confirms before leaving start prompt', + (tester) async { + await setLargeTestViewport(tester); - final router = GoRouter( - initialLocation: '/scheduleStart', - routes: [ - GoRoute( - path: '/scheduleStart', - builder: (_, __) => const ScheduleStartScreen( - promptVariant: ScheduleStartPromptVariant.earlyStart, + final router = GoRouter( + initialLocation: '/scheduleStart', + routes: [ + GoRoute( + path: '/scheduleStart', + builder: (_, __) => const ScheduleStartScreen(), ), - ), - GoRoute( + GoRoute( path: '/alarmScreen', - builder: (_, __) => const Text('ALARM_ROUTE')), - GoRoute(path: '/home', builder: (_, __) => const Text('HOME_ROUTE')), - ], - ); - - await pumpWithRouter(tester, bloc: bloc, router: router); - expect(find.text('Home'), findsNothing); - expect(find.text('Not now'), findsOneWidget); - expect(find.byIcon(Icons.close), findsOneWidget); - }, timeout: const Timeout(Duration(seconds: 15))); - - testWidgets('active alarm screen renders top-corner close button', - (tester) async { - await setLargeTestViewport(tester); - now = DateTime.now(); - - final schedule = buildSchedule( - id: 's-active-close', - scheduleTime: now.add(const Duration(minutes: 35)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); + builder: (_, __) => const Text('ALARM_ROUTE'), + ), + GoRoute( + path: '/home', + builder: (_, __) => const Text('HOME_ROUTE'), + ), + ], + ); - final router = GoRouter( - initialLocation: '/alarmScreen', - routes: [ - GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), - GoRoute( - path: '/alarmScreen', - builder: (_, __) => const AlarmScreen(), - ), - ], - ); + await pumpWithRouter(tester, bloc: bloc, router: router); + await tapAndPump(tester, find.byIcon(Icons.close)); - final earlyBundle = createEarlyStartUseCaseBundle(); - final alarmBloc = ScheduleBloc.test( - StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), - navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, - nowProvider: () => now, - ); - addTearDown(alarmBloc.close); + expect(find.text('Are you sure you want to leave?'), findsOneWidget); - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilFound(tester, find.byKey(const Key('alarm_close_button'))); + await tapAndPump(tester, find.text("I'm leaving")); + await pumpUntilRouteText(tester, 'HOME_ROUTE'); - expect(find.byKey(const Key('alarm_close_button')), findsOneWidget); - alarmBloc.add(const ScheduleFinished(0)); - await tester.pump(); - }, timeout: const Timeout(Duration(seconds: 15))); + expect(find.text('HOME_ROUTE'), findsOneWidget); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); testWidgets( - 'active alarm close button shows confirm dialog and stay keeps alarm', - (tester) async { - await setLargeTestViewport(tester); - now = DateTime.now(); - - final schedule = buildSchedule( - id: 's-active-stay', - scheduleTime: now.add(const Duration(minutes: 35)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); - - final router = GoRouter( - initialLocation: '/alarmScreen', - routes: [ - GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), - GoRoute( - path: '/alarmScreen', - builder: (_, __) => const AlarmScreen(), - ), - ], - ); + 'early-start screen variant is shown with two choices', + (tester) async { + await setLargeTestViewport(tester); - final earlyBundle = createEarlyStartUseCaseBundle(); - final alarmBloc = ScheduleBloc.test( - StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), - navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, - nowProvider: () => now, - ); - addTearDown(alarmBloc.close); - - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await tapAndPump(tester, find.byKey(const Key('alarm_close_button'))); - await pumpUntilFound(tester, find.byType(TwoActionDialog)); - - await tapAndPump(tester, find.text("I'll stay")); - await tester.pump(const Duration(milliseconds: 150)); - - expect(find.text('HOME'), findsNothing); - expect(find.byType(AlarmScreen), findsOneWidget); - alarmBloc.add(const ScheduleFinished(0)); - await tester.pump(); - }, timeout: const Timeout(Duration(seconds: 15))); - - testWidgets('active alarm close button leave action navigates home', - (tester) async { - await setLargeTestViewport(tester); - now = DateTime.now(); - - final schedule = buildSchedule( - id: 's-active-leave', - scheduleTime: now.add(const Duration(minutes: 35)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); + final router = GoRouter( + initialLocation: '/scheduleStart', + routes: [ + GoRoute( + path: '/scheduleStart', + builder: (_, __) => const ScheduleStartScreen( + promptVariant: ScheduleStartPromptVariant.earlyStart, + ), + ), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const Text('ALARM'), + ), + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + ], + ); - final router = GoRouter( - initialLocation: '/alarmScreen', - routes: [ - GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), - GoRoute( - path: '/alarmScreen', - builder: (_, __) => const AlarmScreen(), - ), - ], - ); + await pumpWithRouter(tester, bloc: bloc, router: router); + await tester.pump(const Duration(milliseconds: 100)); - final earlyBundle = createEarlyStartUseCaseBundle(); - final alarmBloc = ScheduleBloc.test( - StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), - navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, - nowProvider: () => now, - ); - addTearDown(alarmBloc.close); + expect(find.byType(ScheduleStartScreen), findsOneWidget); + expect(find.byType(ElevatedButton), findsNWidgets(2)); + expect(find.text('Start preparing now'), findsOneWidget); + expect(find.text('Not now'), findsOneWidget); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await tapAndPump(tester, find.byKey(const Key('alarm_close_button'))); - await pumpUntilFound(tester, find.byType(TwoActionDialog)); + testWidgets( + 'early-start start-now button navigates to alarm', + (tester) async { + await setLargeTestViewport(tester); - await tapAndPump(tester, find.text("I'm leaving")); - await pumpUntilRouteText(tester, 'HOME'); + final router = GoRouter( + initialLocation: '/scheduleStart', + routes: [ + GoRoute( + path: '/scheduleStart', + builder: (_, __) => const ScheduleStartScreen( + promptVariant: ScheduleStartPromptVariant.earlyStart, + ), + ), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const Text('ALARM_ROUTE'), + ), + GoRoute( + path: '/home', + builder: (_, __) => const Text('HOME_ROUTE'), + ), + ], + ); - expect(find.text('HOME'), findsOneWidget); - alarmBloc.add(const ScheduleFinished(0)); - await tester.pump(); - }, timeout: const Timeout(Duration(seconds: 15))); + await pumpWithRouter(tester, bloc: bloc, router: router); + await tapAndPump(tester, find.text('Start preparing now')); + await pumpUntilRouteText(tester, 'ALARM_ROUTE'); + expect(find.text('ALARM_ROUTE'), findsOneWidget); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); testWidgets( - 'manual finish before leave time sends lateness 0 and navigates', - (tester) async { - await setLargeTestViewport(tester); - now = DateTime.now(); - - final schedule = buildSchedule( - id: 's3', - scheduleTime: now.add(const Duration(minutes: 35)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); - - final router = GoRouter( - initialLocation: '/alarmScreen', - routes: [ - GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), - GoRoute( - path: '/alarmScreen', builder: (_, __) => const AlarmScreen()), - GoRoute( - path: '/earlyLate', - builder: (_, state) { - final extra = state.extra as Map; - return Text( - 'EARLYLATE:${extra['isLate']}:${extra['earlyLateTime']}'); - }, - ), - ], - ); - - final earlyBundle = createEarlyStartUseCaseBundle(); - final alarmBloc = ScheduleBloc.test( - StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), - navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, - nowProvider: () => now, - ); - addTearDown(alarmBloc.close); - - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilFound(tester, find.byType(ElevatedButton)); - - await tapAndPump(tester, find.byType(ElevatedButton).first); - await pumpUntilFound(tester, find.textContaining('EARLYLATE:false')); - - expect(finishUseCase.calls.single.$2, 0); - expect(find.textContaining('EARLYLATE:false'), findsOneWidget); - }, timeout: const Timeout(Duration(seconds: 15))); - - testWidgets('manual finish after leave threshold sends positive lateness', - (tester) async { - await setLargeTestViewport(tester); - now = DateTime.now(); - - final schedule = buildSchedule( - id: 's4', - scheduleTime: now.add(const Duration(minutes: 25)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); - // make user already late relative to leave time - now = now.add(const Duration(minutes: 2)); - - final router = GoRouter( - initialLocation: '/alarmScreen', - routes: [ - GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), - GoRoute( - path: '/alarmScreen', builder: (_, __) => const AlarmScreen()), - GoRoute( - path: '/earlyLate', - builder: (_, state) { - final extra = state.extra as Map; - return Text( - 'EARLYLATE:${extra['isLate']}:${extra['earlyLateTime']}'); - }, - ), - ], - ); + 'early-start not-now button navigates home', + (tester) async { + await setLargeTestViewport(tester); - final earlyBundle = createEarlyStartUseCaseBundle(); - final alarmBloc = ScheduleBloc.test( - StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), - navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, - nowProvider: () => now, - ); - addTearDown(alarmBloc.close); + final schedule = buildSchedule( + id: 's2b', + scheduleTime: now.add(const Duration(minutes: 40)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + bloc.add(ScheduleUpcomingReceived(schedule)); - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - // This case auto-completes the only step immediately, so finish via dialog. - await pumpUntilFound(tester, find.byType(TwoActionDialog)); - await tapAndPump(tester, find.byType(ModalWideButton).last); - await pumpUntilFound(tester, find.textContaining('EARLYLATE:true')); + final router = GoRouter( + initialLocation: '/scheduleStart', + routes: [ + GoRoute( + path: '/scheduleStart', + builder: (_, __) => const ScheduleStartScreen( + promptVariant: ScheduleStartPromptVariant.earlyStart, + ), + ), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const Text('ALARM_ROUTE'), + ), + GoRoute( + path: '/home', + builder: (_, __) => const Text('HOME_ROUTE'), + ), + ], + ); - expect(finishUseCase.calls.single.$2, greaterThan(0)); - expect(find.textContaining('EARLYLATE:true'), findsOneWidget); - }, timeout: const Timeout(Duration(seconds: 15))); + await pumpWithRouter(tester, bloc: bloc, router: router); + await tapAndPump(tester, find.text('Not now')); + await pumpUntilRouteText(tester, 'HOME_ROUTE'); + expect(find.text('HOME_ROUTE'), findsOneWidget); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); testWidgets( - 'completion dialog continue shows live leave countdown for ongoing flow', - (tester) async { - await setLargeTestViewport(tester); - now = DateTime(2026, 3, 20, 9, 25); - - final schedule = buildSchedule( - id: 's5', - scheduleTime: DateTime(2026, 3, 20, 10, 0), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - isDone: true, - elapsedTime: Duration(minutes: 10), - ), - ], - ); - - final router = GoRouter( - initialLocation: '/alarmScreen', - routes: [ - GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), - GoRoute( - path: '/alarmScreen', - builder: (_, __) => AlarmScreen(nowProvider: () => now), - ), - GoRoute( - path: '/earlyLate', builder: (_, __) => const Text('EARLYLATE')), - ], - ); + 'deprecated five-minute flag resolves to early-start choices', + (tester) async { + await setLargeTestViewport(tester); - final earlyBundle = createEarlyStartUseCaseBundle(); - final alarmBloc = ScheduleBloc.test( - StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), - navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, - nowProvider: () => now, - ); - addTearDown(alarmBloc.close); + final router = GoRouter( + initialLocation: '/scheduleStart', + routes: [ + GoRoute( + path: '/scheduleStart', + builder: (_, __) => const ScheduleStartScreen( + // ignore: deprecated_member_use_from_same_package + isFiveMinutesBefore: true, + ), + ), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const Text('ALARM'), + ), + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + ], + ); - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilFound(tester, find.byType(TwoActionDialog)); + await pumpWithRouter(tester, bloc: bloc, router: router); + await tester.pump(const Duration(milliseconds: 100)); - await tapAndPump(tester, find.byType(ModalWideButton).first); + expect(find.text('Start preparing now'), findsOneWidget); + expect(find.text('Not now'), findsOneWidget); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); - final continuingTheme = tester - .widget(find.byKey(const ValueKey('alarm_screen_theme'))) - .data; - final continuingScaffold = tester.widget( - find.descendant( - of: find.byKey(const ValueKey('alarm_screen_theme')), - matching: find.byType(Scaffold), - ), - ); + testWidgets( + 'early-start variant is shown with dedicated prompt', + (tester) async { + await setLargeTestViewport(tester); + now = DateTime.now(); - expect( - continuingTheme.colorScheme.primary.toARGB32(), - const Color(0xFF5C79FB).toARGB32(), - ); - expect( - continuingTheme.colorScheme.primaryContainer.toARGB32(), - const Color(0xFFDCE3FF).toARGB32(), - ); - expect( - continuingTheme.colorScheme.onPrimaryContainer.toARGB32(), - const Color(0xFF212F6F).toARGB32(), - ); - expect( - continuingScaffold.backgroundColor!.toARGB32(), - const Color(0xFF5C79FB).toARGB32(), - ); - expect(find.text('EARLYLATE'), findsNothing); - expect(find.text('Ready to go'), findsOneWidget); - expect(find.text('5분 뒤에 나가야 해요'), findsOneWidget); - expect(find.text('05 : 00'), findsOneWidget); - expect(finishUseCase.calls, isEmpty); + final schedule = buildSchedule( + id: 'early-prompt', + scheduleTime: now.add(const Duration(minutes: 63, seconds: 30)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + bloc.emit(ScheduleState.upcoming(schedule)); + final router = GoRouter( + initialLocation: '/scheduleStart', + routes: [ + GoRoute( + path: '/scheduleStart', + builder: (_, __) => const ScheduleStartScreen( + promptVariant: ScheduleStartPromptVariant.earlyStart, + ), + ), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const Text('ALARM'), + ), + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + ], + ); + + await pumpWithRouter(tester, bloc: bloc, router: router); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + findTextMatching(RegExp(r"You're starting \d+ minutes early\.")), + findsOneWidget, + ); + expect( + find.textContaining('Would you like to start preparing early now?'), + findsOneWidget, + ); + expect(find.byType(ElevatedButton), findsNWidgets(2)); + expect(find.text('Home'), findsNothing); + expect(find.text('Start preparing now'), findsOneWidget); + expect(find.text('Not now'), findsOneWidget); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + testWidgets( + 'early-start variant formats hour and minute lead time', + (tester) async { + await setLargeTestViewport(tester); + now = DateTime.now(); + + final schedule = buildSchedule( + id: 'early-hour-minute', + scheduleTime: now.add( + const Duration(hours: 1, minutes: 55, seconds: 30), + ), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + bloc.emit(ScheduleState.upcoming(schedule)); + final router = GoRouter( + initialLocation: '/scheduleStart', + routes: [ + GoRoute( + path: '/scheduleStart', + builder: (_, __) => const ScheduleStartScreen( + promptVariant: ScheduleStartPromptVariant.earlyStart, + ), + ), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const Text('ALARM'), + ), + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + ], + ); + + await pumpWithRouter(tester, bloc: bloc, router: router); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + findTextMatching( + RegExp(r"You're starting 1 hour 15 minutes early\."), + ), + findsOneWidget, + ); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + testWidgets( + 'early-start primary action starts preparation and navigates', + (tester) async { + await setLargeTestViewport(tester); + + final schedule = buildSchedule( + id: 'early-primary', + scheduleTime: now.add(const Duration(minutes: 41)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + bloc.add(ScheduleUpcomingReceived(schedule)); + + final router = GoRouter( + initialLocation: '/scheduleStart', + routes: [ + GoRoute( + path: '/scheduleStart', + builder: (_, __) => const ScheduleStartScreen( + promptVariant: ScheduleStartPromptVariant.earlyStart, + ), + ), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const Text('ALARM_ROUTE'), + ), + GoRoute( + path: '/home', + builder: (_, __) => const Text('HOME_ROUTE'), + ), + ], + ); + + await pumpWithRouter(tester, bloc: bloc, router: router); + await tapAndPump(tester, find.text('Start preparing now')); + await pumpUntilRouteText(tester, 'ALARM_ROUTE'); + + expect(find.text('ALARM_ROUTE'), findsOneWidget); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + testWidgets( + 'early-start has explicit not-now instead of home button', + (tester) async { + await setLargeTestViewport(tester); + + final schedule = buildSchedule( + id: 'early-secondary', + scheduleTime: now.add(const Duration(minutes: 41)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + bloc.add(ScheduleUpcomingReceived(schedule)); + + final router = GoRouter( + initialLocation: '/scheduleStart', + routes: [ + GoRoute( + path: '/scheduleStart', + builder: (_, __) => const ScheduleStartScreen( + promptVariant: ScheduleStartPromptVariant.earlyStart, + ), + ), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const Text('ALARM_ROUTE'), + ), + GoRoute( + path: '/home', + builder: (_, __) => const Text('HOME_ROUTE'), + ), + ], + ); + + await pumpWithRouter(tester, bloc: bloc, router: router); + expect(find.text('Home'), findsNothing); + expect(find.text('Not now'), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + testWidgets( + 'active alarm screen renders top-corner close button', + (tester) async { + await setLargeTestViewport(tester); + now = DateTime.now(); + + final schedule = buildSchedule( + id: 's-active-close', + scheduleTime: now.add(const Duration(minutes: 35)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const AlarmScreen(), + ), + ], + ); + + final earlyBundle = createEarlyStartUseCaseBundle(); + final alarmBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), + navigationService, + NoopSaveTimedPreparationUseCase(), + StubGetTimedPreparationSnapshotUseCase({}), + NoopClearTimedPreparationUseCase(), + finishUseCase, + markEarlyStartSessionUseCase: earlyBundle.markUseCase, + getEarlyStartSessionUseCase: earlyBundle.getUseCase, + clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + nowProvider: () => now, + ); + addTearDown(alarmBloc.close); + + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilFound( + tester, + find.byKey(const Key('alarm_close_button')), + ); + + expect(find.byKey(const Key('alarm_close_button')), findsOneWidget); + alarmBloc.add(const ScheduleFinished(0)); + await tester.pump(); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + testWidgets( + 'upcoming alarm screen can start preparing immediately', + (tester) async { + await setLargeTestViewport(tester); + final schedule = buildSchedule( + id: 's-upcoming-alarm-start', + scheduleTime: now.add(const Duration(minutes: 50)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Pack', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + final staticBloc = StaticScheduleBloc(ScheduleState.upcoming(schedule)); + + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => AlarmScreen(nowProvider: () => now), + ), + ], + ); + + await pumpWithStaticScheduleBloc( + tester, + bloc: staticBloc, + router: router, + ); + await pumpUntilFound(tester, find.text('Start Preparing')); + + expect(find.text('Meeting'), findsOneWidget); + expect( + find.textContaining('Preparation starts in 5 minutes'), + findsOneWidget, + ); + + await tapAndPump(tester, find.text('Start Preparing')); + + expect( + staticBloc.addedEvents, + contains(isA()), + ); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + testWidgets( + 'upcoming alarm screen home button returns home without starting', + (tester) async { + await setLargeTestViewport(tester); + final schedule = buildSchedule( + id: 's-upcoming-alarm-home', + scheduleTime: now.add(const Duration(minutes: 50)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Pack', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + final staticBloc = StaticScheduleBloc(ScheduleState.upcoming(schedule)); + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => AlarmScreen(nowProvider: () => now), + ), + ], + ); + + await pumpWithStaticScheduleBloc( + tester, + bloc: staticBloc, + router: router, + ); + await pumpUntilFound(tester, find.text('Home')); + await tapAndPump(tester, find.text('Home')); + + expect(find.text('HOME'), findsOneWidget); + expect( + staticBloc.addedEvents, + isNot(contains(isA())), + ); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + testWidgets( + 'active alarm close button shows confirm dialog and stay keeps alarm', + (tester) async { + await setLargeTestViewport(tester); + now = DateTime.now(); + + final schedule = buildSchedule( + id: 's-active-stay', + scheduleTime: now.add(const Duration(minutes: 35)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const AlarmScreen(), + ), + ], + ); + + final earlyBundle = createEarlyStartUseCaseBundle(); + final alarmBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), + navigationService, + NoopSaveTimedPreparationUseCase(), + StubGetTimedPreparationSnapshotUseCase({}), + NoopClearTimedPreparationUseCase(), + finishUseCase, + markEarlyStartSessionUseCase: earlyBundle.markUseCase, + getEarlyStartSessionUseCase: earlyBundle.getUseCase, + clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + nowProvider: () => now, + ); + addTearDown(alarmBloc.close); + + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await tapAndPump(tester, find.byKey(const Key('alarm_close_button'))); + await pumpUntilFound(tester, find.byType(TwoActionDialog)); + + await tapAndPump(tester, find.text("I'll stay")); + await tester.pump(const Duration(milliseconds: 150)); + + expect(find.text('HOME'), findsNothing); + expect(find.byType(AlarmScreen), findsOneWidget); + alarmBloc.add(const ScheduleFinished(0)); + await tester.pump(); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + testWidgets( + 'active alarm close button leave action navigates home', + (tester) async { + await setLargeTestViewport(tester); + now = DateTime.now(); + + final schedule = buildSchedule( + id: 's-active-leave', + scheduleTime: now.add(const Duration(minutes: 35)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const AlarmScreen(), + ), + ], + ); + + final earlyBundle = createEarlyStartUseCaseBundle(); + final alarmBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), + navigationService, + NoopSaveTimedPreparationUseCase(), + StubGetTimedPreparationSnapshotUseCase({}), + NoopClearTimedPreparationUseCase(), + finishUseCase, + markEarlyStartSessionUseCase: earlyBundle.markUseCase, + getEarlyStartSessionUseCase: earlyBundle.getUseCase, + clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + nowProvider: () => now, + ); + addTearDown(alarmBloc.close); + + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await tapAndPump(tester, find.byKey(const Key('alarm_close_button'))); + await pumpUntilFound(tester, find.byType(TwoActionDialog)); + + await tapAndPump(tester, find.text("I'm leaving")); + await pumpUntilRouteText(tester, 'HOME'); + + expect(find.text('HOME'), findsOneWidget); + alarmBloc.add(const ScheduleFinished(0)); + await tester.pump(); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + testWidgets( + 'manual finish before leave time sends lateness 0 and navigates', + (tester) async { + await setLargeTestViewport(tester); + now = DateTime.now(); + + final schedule = buildSchedule( + id: 's3', + scheduleTime: now.add(const Duration(minutes: 35)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const AlarmScreen(), + ), + GoRoute( + path: '/earlyLate', + builder: (_, state) { + final extra = state.extra as Map; + return Text( + 'EARLYLATE:${extra['isLate']}:${extra['earlyLateTime']}', + ); + }, + ), + ], + ); + + final earlyBundle = createEarlyStartUseCaseBundle(); + final alarmBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), + navigationService, + NoopSaveTimedPreparationUseCase(), + StubGetTimedPreparationSnapshotUseCase({}), + NoopClearTimedPreparationUseCase(), + finishUseCase, + markEarlyStartSessionUseCase: earlyBundle.markUseCase, + getEarlyStartSessionUseCase: earlyBundle.getUseCase, + clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + nowProvider: () => now, + ); + addTearDown(alarmBloc.close); + + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilFound(tester, find.byType(ElevatedButton)); + + await tapAndPump(tester, find.byType(ElevatedButton).first); + await pumpUntilFound(tester, find.textContaining('EARLYLATE:false')); + + expect(finishUseCase.calls.single.$2, 0); + expect(find.textContaining('EARLYLATE:false'), findsOneWidget); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + testWidgets( + 'manual finish after leave threshold sends positive lateness', + (tester) async { + await setLargeTestViewport(tester); + now = DateTime.now(); + + final schedule = buildSchedule( + id: 's4', + scheduleTime: now.add(const Duration(minutes: 25)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + // make user already late relative to leave time + now = now.add(const Duration(minutes: 2)); + + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const AlarmScreen(), + ), + GoRoute( + path: '/earlyLate', + builder: (_, state) { + final extra = state.extra as Map; + return Text( + 'EARLYLATE:${extra['isLate']}:${extra['earlyLateTime']}', + ); + }, + ), + ], + ); + + final earlyBundle = createEarlyStartUseCaseBundle(); + final alarmBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), + navigationService, + NoopSaveTimedPreparationUseCase(), + StubGetTimedPreparationSnapshotUseCase({}), + NoopClearTimedPreparationUseCase(), + finishUseCase, + markEarlyStartSessionUseCase: earlyBundle.markUseCase, + getEarlyStartSessionUseCase: earlyBundle.getUseCase, + clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + nowProvider: () => now, + ); + addTearDown(alarmBloc.close); + + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + // This case auto-completes the only step immediately, so finish via dialog. + await pumpUntilFound(tester, find.byType(TwoActionDialog)); + await tapAndPump(tester, find.byType(ModalWideButton).last); + await pumpUntilFound(tester, find.textContaining('EARLYLATE:true')); + + expect(finishUseCase.calls.single.$2, greaterThan(0)); + expect(find.textContaining('EARLYLATE:true'), findsOneWidget); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + testWidgets( + 'completion dialog continue shows live leave countdown for ongoing flow', + (tester) async { + await setLargeTestViewport(tester); + now = DateTime(2026, 3, 20, 9, 25); + + final schedule = buildSchedule( + id: 's5', + scheduleTime: DateTime(2026, 3, 20, 10, 0), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + isDone: true, + elapsedTime: Duration(minutes: 10), + ), + ], + ); + + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => AlarmScreen(nowProvider: () => now), + ), + GoRoute( + path: '/earlyLate', + builder: (_, __) => const Text('EARLYLATE'), + ), + ], + ); - now = now.add(const Duration(minutes: 1)); - await tester.pump(const Duration(seconds: 1)); + final earlyBundle = createEarlyStartUseCaseBundle(); + final alarmBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), + navigationService, + NoopSaveTimedPreparationUseCase(), + StubGetTimedPreparationSnapshotUseCase({}), + NoopClearTimedPreparationUseCase(), + finishUseCase, + markEarlyStartSessionUseCase: earlyBundle.markUseCase, + getEarlyStartSessionUseCase: earlyBundle.getUseCase, + clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + nowProvider: () => now, + ); + addTearDown(alarmBloc.close); - expect(find.text('4분 뒤에 나가야 해요'), findsOneWidget); - expect(find.text('Ready to go'), findsOneWidget); - expect(find.text('04 : 00'), findsOneWidget); + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilFound(tester, find.byType(TwoActionDialog)); - alarmBloc.add(const ScheduleFinished(0)); - await tester.pump(); - }, timeout: const Timeout(Duration(seconds: 15))); + await tapAndPump(tester, find.byType(ModalWideButton).first); - testWidgets( - 'completion dialog continue shows live leave countdown for early-start flow', - (tester) async { - await setLargeTestViewport(tester); - now = DateTime(2026, 3, 20, 8, 30); - - final schedule = buildSchedule( - id: 's5-early', - scheduleTime: DateTime(2026, 3, 20, 10, 0), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - isDone: true, - elapsedTime: Duration(minutes: 10), + final continuingTheme = tester + .widget(find.byKey(const ValueKey('alarm_screen_theme'))) + .data; + final continuingScaffold = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('alarm_screen_theme')), + matching: find.byType(Scaffold), ), - ], - ); + ); - final router = GoRouter( - initialLocation: '/alarmScreen', - routes: [ - GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), - GoRoute( - path: '/alarmScreen', - builder: (_, __) => AlarmScreen(nowProvider: () => now), - ), - GoRoute( - path: '/earlyLate', builder: (_, __) => const Text('EARLYLATE')), - ], - ); + expect( + continuingTheme.colorScheme.primary.toARGB32(), + const Color(0xFF5C79FB).toARGB32(), + ); + expect( + continuingTheme.colorScheme.primaryContainer.toARGB32(), + const Color(0xFFDCE3FF).toARGB32(), + ); + expect( + continuingTheme.colorScheme.onPrimaryContainer.toARGB32(), + const Color(0xFF212F6F).toARGB32(), + ); + expect( + continuingScaffold.backgroundColor!.toARGB32(), + const Color(0xFF5C79FB).toARGB32(), + ); + expect(find.text('EARLYLATE'), findsNothing); + expect(find.text('Ready to go'), findsOneWidget); + expect(find.text('5분 뒤에 나가야 해요'), findsOneWidget); + expect(find.text('05 : 00'), findsOneWidget); + expect(finishUseCase.calls, isEmpty); - final earlyBundle = createEarlyStartUseCaseBundle(); - await earlyBundle.markUseCase( - scheduleId: schedule.id, - startedAt: now.subtract(const Duration(minutes: 1)), - ); + now = now.add(const Duration(minutes: 1)); + await tester.pump(const Duration(seconds: 1)); - final alarmBloc = ScheduleBloc.test( - StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), - navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, - nowProvider: () => now, - ); - addTearDown(alarmBloc.close); + expect(find.text('4분 뒤에 나가야 해요'), findsOneWidget); + expect(find.text('Ready to go'), findsOneWidget); + expect(find.text('04 : 00'), findsOneWidget); + + alarmBloc.add(const ScheduleFinished(0)); + await tester.pump(); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + testWidgets( + 'completion dialog continue shows live leave countdown for early-start flow', + (tester) async { + await setLargeTestViewport(tester); + now = DateTime(2026, 3, 20, 8, 30); + + final schedule = buildSchedule( + id: 's5-early', + scheduleTime: DateTime(2026, 3, 20, 10, 0), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + isDone: true, + elapsedTime: Duration(minutes: 10), + ), + ], + ); + + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => AlarmScreen(nowProvider: () => now), + ), + GoRoute( + path: '/earlyLate', + builder: (_, __) => const Text('EARLYLATE'), + ), + ], + ); + + final earlyBundle = createEarlyStartUseCaseBundle(); + await earlyBundle.markUseCase( + scheduleId: schedule.id, + startedAt: now.subtract(const Duration(minutes: 1)), + ); + + final alarmBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), + navigationService, + NoopSaveTimedPreparationUseCase(), + StubGetTimedPreparationSnapshotUseCase({}), + NoopClearTimedPreparationUseCase(), + finishUseCase, + markEarlyStartSessionUseCase: earlyBundle.markUseCase, + getEarlyStartSessionUseCase: earlyBundle.getUseCase, + clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + nowProvider: () => now, + ); + addTearDown(alarmBloc.close); - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilFound(tester, find.byType(TwoActionDialog)); + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilFound(tester, find.byType(TwoActionDialog)); - await tapAndPump(tester, find.byType(ModalWideButton).first); + await tapAndPump(tester, find.byType(ModalWideButton).first); - expect(find.text('1시간 뒤에 나가야 해요'), findsOneWidget); - expect(find.text('01 : 00 : 00'), findsOneWidget); + expect(find.text('1시간 뒤에 나가야 해요'), findsOneWidget); + expect(find.text('01 : 00 : 00'), findsOneWidget); - now = now.add(const Duration(minutes: 5)); - await tester.pump(const Duration(seconds: 1)); + now = now.add(const Duration(minutes: 5)); + await tester.pump(const Duration(seconds: 1)); - expect(find.text('55분 뒤에 나가야 해요'), findsOneWidget); - expect(find.text('55 : 00'), findsOneWidget); + expect(find.text('55분 뒤에 나가야 해요'), findsOneWidget); + expect(find.text('55 : 00'), findsOneWidget); - alarmBloc.add(const ScheduleFinished(0)); - await tester.pump(); - }, timeout: const Timeout(Duration(seconds: 15))); + alarmBloc.add(const ScheduleFinished(0)); + await tester.pump(); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); testWidgets( - 'completion dialog continue keeps overdue timer running after leave time', - (tester) async { - await setLargeTestViewport(tester); - now = DateTime(2026, 3, 20, 9, 29); - - final schedule = buildSchedule( - id: 's5-late', - scheduleTime: DateTime(2026, 3, 20, 10, 0), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - isDone: true, - elapsedTime: Duration(minutes: 10), - ), - ], - ); + 'completion dialog continue keeps overdue timer running after leave time', + (tester) async { + await setLargeTestViewport(tester); + now = DateTime(2026, 3, 20, 9, 29); - final router = GoRouter( - initialLocation: '/alarmScreen', - routes: [ - GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), - GoRoute( - path: '/alarmScreen', - builder: (_, __) => AlarmScreen(nowProvider: () => now), - ), - GoRoute( - path: '/earlyLate', builder: (_, __) => const Text('EARLYLATE')), - ], - ); + final schedule = buildSchedule( + id: 's5-late', + scheduleTime: DateTime(2026, 3, 20, 10, 0), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + isDone: true, + elapsedTime: Duration(minutes: 10), + ), + ], + ); - final earlyBundle = createEarlyStartUseCaseBundle(); - final alarmBloc = ScheduleBloc.test( - StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), - navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, - nowProvider: () => now, - ); - addTearDown(alarmBloc.close); + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => AlarmScreen(nowProvider: () => now), + ), + GoRoute( + path: '/earlyLate', + builder: (_, __) => const Text('EARLYLATE'), + ), + ], + ); - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilFound(tester, find.byType(TwoActionDialog)); + final earlyBundle = createEarlyStartUseCaseBundle(); + final alarmBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), + navigationService, + NoopSaveTimedPreparationUseCase(), + StubGetTimedPreparationSnapshotUseCase({}), + NoopClearTimedPreparationUseCase(), + finishUseCase, + markEarlyStartSessionUseCase: earlyBundle.markUseCase, + getEarlyStartSessionUseCase: earlyBundle.getUseCase, + clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + nowProvider: () => now, + ); + addTearDown(alarmBloc.close); - await tapAndPump(tester, find.byType(ModalWideButton).first); + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilFound(tester, find.byType(TwoActionDialog)); - expect(find.text('1분 뒤에 나가야 해요'), findsOneWidget); - expect(find.text('01 : 00'), findsOneWidget); + await tapAndPump(tester, find.byType(ModalWideButton).first); - now = now.add(const Duration(minutes: 2)); - await tester.pump(const Duration(seconds: 1)); + expect(find.text('1분 뒤에 나가야 해요'), findsOneWidget); + expect(find.text('01 : 00'), findsOneWidget); - final lateTheme = tester - .widget(find.byKey(const ValueKey('alarm_screen_theme'))) - .data; - final lateScaffold = tester.widget( - find.descendant( - of: find.byKey(const ValueKey('alarm_screen_theme')), - matching: find.byType(Scaffold), - ), - ); + now = now.add(const Duration(minutes: 2)); + await tester.pump(const Duration(seconds: 1)); - expect( - lateTheme.colorScheme.primary.toARGB32(), - const Color(0xFFFF6953).toARGB32(), - ); - expect( - lateTheme.colorScheme.primaryContainer.toARGB32(), - const Color(0xFFFFEAE7).toARGB32(), - ); - expect( - lateTheme.colorScheme.onPrimaryContainer.toARGB32(), - const Color(0xFFFF6953).toARGB32(), - ); - expect( - lateScaffold.backgroundColor!.toARGB32(), - const Color(0xFFFF6953).toARGB32(), - ); - expect( - tester - .widget(find.byType(AlarmGraphAnimator)) - .progress, - 0.0, - ); - expect( - tester - .widget(find.byType(AlarmGraphAnimator)) - .backgroundColor - .toARGB32(), - const Color(0xFFFFEAE7).toARGB32(), - ); - expect( - tester - .widget(find.byType(AlarmGraphAnimator)) - .progressColor - .toARGB32(), - const Color(0xFFFFEAE7).toARGB32(), - ); - expect(find.text('준비시간을 1분 초과했어요'), findsOneWidget); - expect(find.text('지각이에요'), findsOneWidget); - expect(find.text('Ready to go'), findsNothing); - expect(find.text('01 : 00'), findsOneWidget); - expect(find.text('Prep'), findsOneWidget); - - alarmBloc.add(const ScheduleFinished(0)); - await tester.pump(); - }, timeout: const Timeout(Duration(seconds: 15))); - - testWidgets('completion dialog finish triggers finish flow', - (tester) async { - await setLargeTestViewport(tester); - now = DateTime.now(); - - final schedule = buildSchedule( - id: 's6', - scheduleTime: now.add(const Duration(minutes: 35)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'p1', - preparationName: 'Prep', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - isDone: true, - elapsedTime: Duration(minutes: 10), + final lateTheme = tester + .widget(find.byKey(const ValueKey('alarm_screen_theme'))) + .data; + final lateScaffold = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('alarm_screen_theme')), + matching: find.byType(Scaffold), ), - ], - ); + ); - final router = GoRouter( - initialLocation: '/alarmScreen', - routes: [ - GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), - GoRoute( - path: '/alarmScreen', builder: (_, __) => const AlarmScreen()), - GoRoute( - path: '/earlyLate', builder: (_, __) => const Text('EARLYLATE')), - ], - ); + expect( + lateTheme.colorScheme.primary.toARGB32(), + const Color(0xFFFF6953).toARGB32(), + ); + expect( + lateTheme.colorScheme.primaryContainer.toARGB32(), + const Color(0xFFFFEAE7).toARGB32(), + ); + expect( + lateTheme.colorScheme.onPrimaryContainer.toARGB32(), + const Color(0xFFFF6953).toARGB32(), + ); + expect( + lateScaffold.backgroundColor!.toARGB32(), + const Color(0xFFFF6953).toARGB32(), + ); + expect( + tester + .widget(find.byType(AlarmGraphAnimator)) + .progress, + 0.0, + ); + expect( + tester + .widget(find.byType(AlarmGraphAnimator)) + .backgroundColor + .toARGB32(), + const Color(0xFFFFEAE7).toARGB32(), + ); + expect( + tester + .widget(find.byType(AlarmGraphAnimator)) + .progressColor + .toARGB32(), + const Color(0xFFFFEAE7).toARGB32(), + ); + expect(find.text('준비시간을 1분 초과했어요'), findsOneWidget); + expect(find.text('지각이에요'), findsOneWidget); + expect(find.text('Ready to go'), findsNothing); + expect(find.text('01 : 00'), findsOneWidget); + expect(find.text('Prep'), findsOneWidget); + + alarmBloc.add(const ScheduleFinished(0)); + await tester.pump(); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); - final earlyBundle = createEarlyStartUseCaseBundle(); - final alarmBloc = ScheduleBloc.test( - StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), - navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, - nowProvider: () => now, - ); - addTearDown(alarmBloc.close); - - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilFound(tester, find.byType(TwoActionDialog)); - - await tapAndPump(tester, find.byType(ModalWideButton).last); - await pumpUntilRouteText(tester, 'EARLYLATE'); - - expect(find.text('EARLYLATE'), findsOneWidget); - expect(finishUseCase.calls.length, 1); - }, timeout: const Timeout(Duration(seconds: 15))); - - testWidgets('already missing schedule on alarm entry navigates home', - (tester) async { - await setLargeTestViewport(tester); - - final router = GoRouter( - initialLocation: '/alarmScreen', - routes: [ - GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), - GoRoute( - path: '/alarmScreen', builder: (_, __) => const AlarmScreen()), - GoRoute( - path: '/earlyLate', builder: (_, __) => const Text('EARLYLATE')), - ], - ); + testWidgets( + 'completion dialog finish triggers finish flow', + (tester) async { + await setLargeTestViewport(tester); + now = DateTime.now(); - final earlyBundle = createEarlyStartUseCaseBundle(); - final alarmBloc = ScheduleBloc.test( - StubGetNearestUpcomingScheduleUseCase(() => const Stream.empty()), - navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, - nowProvider: () => now, - )..emit(const ScheduleState.notExists()); - addTearDown(alarmBloc.close); + final schedule = buildSchedule( + id: 's6', + scheduleTime: now.add(const Duration(minutes: 35)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'p1', + preparationName: 'Prep', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + isDone: true, + elapsedTime: Duration(minutes: 10), + ), + ], + ); + + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const AlarmScreen(), + ), + GoRoute( + path: '/earlyLate', + builder: (_, __) => const Text('EARLYLATE'), + ), + ], + ); + + final earlyBundle = createEarlyStartUseCaseBundle(); + final alarmBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), + navigationService, + NoopSaveTimedPreparationUseCase(), + StubGetTimedPreparationSnapshotUseCase({}), + NoopClearTimedPreparationUseCase(), + finishUseCase, + markEarlyStartSessionUseCase: earlyBundle.markUseCase, + getEarlyStartSessionUseCase: earlyBundle.getUseCase, + clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + nowProvider: () => now, + ); + addTearDown(alarmBloc.close); + + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilFound(tester, find.byType(TwoActionDialog)); + + await tapAndPump(tester, find.byType(ModalWideButton).last); + await pumpUntilRouteText(tester, 'EARLYLATE'); + + expect(find.text('EARLYLATE'), findsOneWidget); + expect(finishUseCase.calls.length, 1); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + testWidgets( + 'already missing schedule on alarm entry navigates home', + (tester) async { + await setLargeTestViewport(tester); + + final router = GoRouter( + initialLocation: '/alarmScreen', + routes: [ + GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), + GoRoute( + path: '/alarmScreen', + builder: (_, __) => const AlarmScreen(), + ), + GoRoute( + path: '/earlyLate', + builder: (_, __) => const Text('EARLYLATE'), + ), + ], + ); + + final earlyBundle = createEarlyStartUseCaseBundle(); + final alarmBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => const Stream.empty()), + navigationService, + NoopSaveTimedPreparationUseCase(), + StubGetTimedPreparationSnapshotUseCase({}), + NoopClearTimedPreparationUseCase(), + finishUseCase, + markEarlyStartSessionUseCase: earlyBundle.markUseCase, + getEarlyStartSessionUseCase: earlyBundle.getUseCase, + clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + nowProvider: () => now, + )..emit(const ScheduleState.notExists()); + addTearDown(alarmBloc.close); - await pumpWithRouter(tester, bloc: alarmBloc, router: router); - await pumpUntilRouteText(tester, 'HOME'); + await pumpWithRouter(tester, bloc: alarmBloc, router: router); + await pumpUntilRouteText(tester, 'HOME'); - expect(find.text('HOME'), findsOneWidget); - expect(find.text('EARLYLATE'), findsNothing); - expect(finishUseCase.calls, isEmpty); - }, timeout: const Timeout(Duration(seconds: 15))); + expect(find.text('HOME'), findsOneWidget); + expect(find.text('EARLYLATE'), findsNothing); + expect(finishUseCase.calls, isEmpty); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); testWidgets( 'null schedule emission while already notExists navigates home', @@ -1373,10 +1705,13 @@ void main() { routes: [ GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), GoRoute( - path: '/alarmScreen', builder: (_, __) => const AlarmScreen()), + path: '/alarmScreen', + builder: (_, __) => const AlarmScreen(), + ), GoRoute( - path: '/earlyLate', - builder: (_, __) => const Text('EARLYLATE')), + path: '/earlyLate', + builder: (_, __) => const Text('EARLYLATE'), + ), ], ); @@ -1428,10 +1763,13 @@ void main() { routes: [ GoRoute(path: '/home', builder: (_, __) => const Text('HOME')), GoRoute( - path: '/alarmScreen', builder: (_, __) => const AlarmScreen()), + path: '/alarmScreen', + builder: (_, __) => const AlarmScreen(), + ), GoRoute( - path: '/earlyLate', - builder: (_, __) => const Text('EARLYLATE')), + path: '/earlyLate', + builder: (_, __) => const Text('EARLYLATE'), + ), ], ); diff --git a/test/presentation/app/bloc/auth/auth_bloc_test.dart b/test/presentation/app/bloc/auth/auth_bloc_test.dart new file mode 100644 index 00000000..f05ee5ea --- /dev/null +++ b/test/presentation/app/bloc/auth/auth_bloc_test.dart @@ -0,0 +1,216 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/use-cases/load_user_use_case.dart'; +import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; +import 'package:on_time_front/domain/use-cases/sign_out_use_case.dart'; +import 'package:on_time_front/domain/use-cases/stream_user_use_case.dart'; +import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; + +void main() { + late StreamController userController; + late _FakeStreamUserUseCase streamUserUseCase; + late _FakeLoadUserUseCase loadUserUseCase; + late _FakeSignOutUseCase signOutUseCase; + late _FakeScheduleBloc scheduleBloc; + late _FakeReconcileAlarmsUseCase reconcileAlarmsUseCase; + + AuthBloc buildBloc() { + return AuthBloc( + streamUserUseCase, + signOutUseCase, + loadUserUseCase, + scheduleBloc, + reconcileAlarmsUseCase, + ); + } + + setUp(() { + userController = StreamController.broadcast(); + streamUserUseCase = _FakeStreamUserUseCase(userController.stream); + loadUserUseCase = _FakeLoadUserUseCase(); + signOutUseCase = _FakeSignOutUseCase(); + scheduleBloc = _FakeScheduleBloc(); + reconcileAlarmsUseCase = _FakeReconcileAlarmsUseCase(); + }); + + tearDown(() async { + await userController.close(); + }); + + test( + 'authenticated users subscribe schedules and reconcile alarms', + () async { + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(const AuthUserSubscriptionRequested()); + await pumpEventQueue(); + userController.add(_user(isOnboardingCompleted: true)); + + final authenticated = await bloc.stream.firstWhere( + (state) => state.status == AuthStatus.authenticated, + ); + await pumpEventQueue(); + + expect(loadUserUseCase.callCount, 1); + expect(authenticated.user, _user(isOnboardingCompleted: true)); + expect(scheduleBloc.addedEvents, [const ScheduleSubscriptionRequested()]); + expect(reconcileAlarmsUseCase.callCount, 1); + }, + ); + + test('non-onboarded and empty users map to their auth statuses', () async { + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(const AuthUserSubscriptionRequested()); + await pumpEventQueue(); + userController.add(_user(isOnboardingCompleted: false)); + + final onboardingState = await bloc.stream.firstWhere( + (state) => state.status == AuthStatus.onboardingNotCompleted, + ); + userController.add(const UserEntity.empty()); + final unauthenticatedState = await bloc.stream.firstWhere( + (state) => state.status == AuthStatus.unauthenticated, + ); + + expect(onboardingState.user, _user(isOnboardingCompleted: false)); + expect(unauthenticatedState.user, const UserEntity.empty()); + expect(scheduleBloc.addedEvents, isEmpty); + expect(reconcileAlarmsUseCase.callCount, 0); + }); + + test( + 'load failure emits empty unauthenticated user before listening', + () async { + loadUserUseCase.error = Exception('session expired'); + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(const AuthUserSubscriptionRequested()); + + final state = await bloc.stream.firstWhere( + (state) => state.status == AuthStatus.unauthenticated, + ); + + expect(state.user, const UserEntity.empty()); + }, + ); + + test('sign out event delegates to sign out use case', () async { + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(const AuthSignOutPressed()); + await pumpEventQueue(); + + expect(signOutUseCase.callCount, 1); + }); + + test('AuthState value helpers derive status from the user', () { + final authenticated = AuthState(user: _user(isOnboardingCompleted: true)); + final onboarding = AuthState(user: _user(isOnboardingCompleted: false)); + final copied = authenticated.copyWith(status: AuthStatus.loading); + + expect(authenticated.status, AuthStatus.authenticated); + expect(onboarding.status, AuthStatus.onboardingNotCompleted); + expect(const AuthState.loading().status, AuthStatus.loading); + expect(copied.status, AuthStatus.loading); + expect(copied.user, authenticated.user); + }); +} + +UserEntity _user({required bool isOnboardingCompleted}) { + return UserEntity( + id: 'user-1', + email: 'user@example.com', + name: 'User', + spareTime: const Duration(minutes: 10), + note: 'note', + score: 4.5, + isOnboardingCompleted: isOnboardingCompleted, + ); +} + +class _FakeStreamUserUseCase implements StreamUserUseCase { + _FakeStreamUserUseCase(this.stream); + + final Stream stream; + + @override + Stream call() => stream; + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeLoadUserUseCase implements LoadUserUseCase { + int callCount = 0; + Object? error; + + @override + Future call() async { + callCount += 1; + final nextError = error; + if (nextError != null) { + throw nextError; + } + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSignOutUseCase implements SignOutUseCase { + int callCount = 0; + + @override + Future call() async { + callCount += 1; + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeScheduleBloc implements ScheduleBloc { + final addedEvents = []; + + @override + void add(ScheduleEvent event) { + addedEvents.add(event); + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeReconcileAlarmsUseCase implements ReconcileAlarmsUseCase { + int callCount = 0; + + @override + Future call() async { + callCount += 1; + final now = DateTime(2026, 5, 15); + return AlarmReconciliationResult( + status: AlarmReconciliationStatus.armed, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + armedScheduleIds: const [], + skippedScheduleCount: 0, + failures: const [], + scheduleWindowStart: now, + scheduleWindowEnd: now.add(const Duration(days: 1)), + alarmCoverageStart: now, + alarmCoverageEnd: now.add(const Duration(hours: 1)), + ); + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/presentation/app/bloc/schedule/schedule_bloc_test.dart b/test/presentation/app/bloc/schedule/schedule_bloc_test.dart index 4440d065..511b6332 100644 --- a/test/presentation/app/bloc/schedule/schedule_bloc_test.dart +++ b/test/presentation/app/bloc/schedule/schedule_bloc_test.dart @@ -3,17 +3,24 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/services/navigation_service.dart'; import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart'; import 'package:on_time_front/domain/entities/early_start_session_entity.dart'; +import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart'; import 'package:on_time_front/domain/use-cases/clear_early_start_session_use_case.dart'; import 'package:on_time_front/domain/use-cases/clear_timed_preparation_use_case.dart'; import 'package:on_time_front/domain/use-cases/finish_schedule_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; import 'package:on_time_front/domain/use-cases/get_early_start_session_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_schedule_by_id_use_case.dart'; import 'package:on_time_front/domain/use-cases/get_timed_preparation_snapshot_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_preparation_by_schedule_id_use_case.dart'; import 'package:on_time_front/domain/use-cases/mark_early_start_session_use_case.dart'; import 'package:on_time_front/domain/use-cases/save_timed_preparation_use_case.dart'; import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; @@ -58,10 +65,56 @@ class SpySaveTimedPreparationUseCase implements SaveTimedPreparationUseCase { class SpyFinishScheduleUseCase implements FinishScheduleUseCase { final List<(String, int)> calls = []; + bool throwOnCall = false; @override Future call(String scheduleId, int latenessTime) async { calls.add((scheduleId, latenessTime)); + if (throwOnCall) { + throw Exception('finish failed'); + } + } +} + +class SpyCancelScheduleAlarmUseCase implements CancelScheduleAlarmUseCase { + final List calls = []; + + @override + Future call(String scheduleId) async { + calls.add(scheduleId); + } +} + +class StubGetScheduleByIdUseCase implements GetScheduleByIdUseCase { + final Map schedules = {}; + final Set throwingIds = {}; + + @override + Future call(String id) async { + if (throwingIds.contains(id)) { + throw Exception('schedule unavailable'); + } + return schedules[id]!; + } +} + +class SpyLoadPreparationByScheduleIdUseCase + implements LoadPreparationByScheduleIdUseCase { + final List calls = []; + + @override + Future call(String scheduleId) async { + calls.add(scheduleId); + } +} + +class StubGetPreparationByScheduleIdUseCase + implements GetPreparationByScheduleIdUseCase { + final Map preparations = {}; + + @override + Future call(String scheduleId) async { + return preparations[scheduleId]!; } } @@ -106,7 +159,9 @@ class StubGetEarlyStartSessionUseCase implements GetEarlyStartSessionUseCase { final startedAt = marker.sessions[scheduleId]; if (startedAt == null) return null; return EarlyStartSessionEntity( - scheduleId: scheduleId, startedAt: startedAt); + scheduleId: scheduleId, + startedAt: startedAt, + ); } } @@ -168,6 +223,12 @@ void main() { late SpyMarkEarlyStartSessionUseCase markEarlySessionUseCase; late StubGetEarlyStartSessionUseCase getEarlySessionUseCase; late SpyClearEarlyStartSessionUseCase clearEarlySessionUseCase; + late SpyCancelScheduleAlarmUseCase cancelScheduleAlarmUseCase; + late StubGetScheduleByIdUseCase getScheduleByIdUseCase; + late SpyLoadPreparationByScheduleIdUseCase + loadPreparationByScheduleIdUseCase; + late StubGetPreparationByScheduleIdUseCase + getPreparationByScheduleIdUseCase; late DateTime now; late List notifiedStepIds; @@ -181,10 +242,18 @@ void main() { getSnapshotUseCase = StubGetTimedPreparationSnapshotUseCase(); clearTimedUseCase = SpyClearTimedPreparationUseCase(); markEarlySessionUseCase = SpyMarkEarlyStartSessionUseCase(); - getEarlySessionUseCase = - StubGetEarlyStartSessionUseCase(markEarlySessionUseCase); - clearEarlySessionUseCase = - SpyClearEarlyStartSessionUseCase(markEarlySessionUseCase); + getEarlySessionUseCase = StubGetEarlyStartSessionUseCase( + markEarlySessionUseCase, + ); + clearEarlySessionUseCase = SpyClearEarlyStartSessionUseCase( + markEarlySessionUseCase, + ); + cancelScheduleAlarmUseCase = SpyCancelScheduleAlarmUseCase(); + getScheduleByIdUseCase = StubGetScheduleByIdUseCase(); + loadPreparationByScheduleIdUseCase = + SpyLoadPreparationByScheduleIdUseCase(); + getPreparationByScheduleIdUseCase = + StubGetPreparationByScheduleIdUseCase(); now = DateTime(2026, 3, 20, 9, 0, 0); notifiedStepIds = []; bloc = ScheduleBloc.test( @@ -197,15 +266,20 @@ void main() { markEarlyStartSessionUseCase: markEarlySessionUseCase, getEarlyStartSessionUseCase: getEarlySessionUseCase, clearEarlyStartSessionUseCase: clearEarlySessionUseCase, + cancelScheduleAlarmUseCase: cancelScheduleAlarmUseCase, + getScheduleByIdUseCase: getScheduleByIdUseCase, + loadPreparationByScheduleIdUseCase: loadPreparationByScheduleIdUseCase, + getPreparationByScheduleIdUseCase: getPreparationByScheduleIdUseCase, nowProvider: () => now, - notifyPreparationStep: ({ - required scheduleName, - required preparationName, - required scheduleId, - required stepId, - }) { - notifiedStepIds.add(stepId); - }, + notifyPreparationStep: + ({ + required scheduleName, + required preparationName, + required scheduleId, + required stepId, + }) { + notifiedStepIds.add(stepId); + }, ); }); @@ -220,25 +294,82 @@ void main() { expect(bloc.state.status, ScheduleStatus.notExists); }); - test('emits notExists when upcoming schedule time is already passed', - () async { - final schedule = buildSchedule( - id: 'past', - scheduleTime: now.subtract(const Duration(minutes: 1)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'a', - preparationName: 'a', - preparationTime: Duration(minutes: 5), - nextPreparationId: null, - ), - ], - ); + test( + 'alarm prompt is ignored when validation use cases are unavailable', + () async { + final minimalBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => const Stream.empty()), + navigationService, + saveUseCase, + getSnapshotUseCase, + clearTimedUseCase, + finishUseCase, + markEarlyStartSessionUseCase: markEarlySessionUseCase, + getEarlyStartSessionUseCase: getEarlySessionUseCase, + clearEarlyStartSessionUseCase: clearEarlySessionUseCase, + nowProvider: () => now, + ); + addTearDown(minimalBloc.close); + + minimalBloc.add( + const ScheduleAlarmPromptRequested(scheduleId: 'missing-validation'), + ); + await Future.delayed(Duration.zero); + + expect(minimalBloc.state.status, ScheduleStatus.initial); + expect(navigationService.goRoutes, isEmpty); + }, + ); - bloc.add(ScheduleUpcomingReceived(schedule)); - await Future.delayed(Duration.zero); - expect(bloc.state.status, ScheduleStatus.notExists); - }); + test( + 'default constructor registers handlers with injected dependencies', + () async { + final constructedBloc = ScheduleBloc( + StubGetNearestUpcomingScheduleUseCase(() => const Stream.empty()), + navigationService, + saveUseCase, + getSnapshotUseCase, + clearTimedUseCase, + finishUseCase, + markEarlyStartSessionUseCase: markEarlySessionUseCase, + getEarlyStartSessionUseCase: getEarlySessionUseCase, + clearEarlyStartSessionUseCase: clearEarlySessionUseCase, + cancelScheduleAlarmUseCase: cancelScheduleAlarmUseCase, + getScheduleByIdUseCase: getScheduleByIdUseCase, + loadPreparationByScheduleIdUseCase: + loadPreparationByScheduleIdUseCase, + getPreparationByScheduleIdUseCase: getPreparationByScheduleIdUseCase, + ); + addTearDown(constructedBloc.close); + + constructedBloc.add(const ScheduleSubscriptionRequested()); + await Future.delayed(Duration.zero); + + expect(constructedBloc.state.status, ScheduleStatus.initial); + }, + ); + + test( + 'emits notExists when upcoming schedule time is already passed', + () async { + final schedule = buildSchedule( + id: 'past', + scheduleTime: now.subtract(const Duration(minutes: 1)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(minutes: 5), + nextPreparationId: null, + ), + ], + ); + + bloc.add(ScheduleUpcomingReceived(schedule)); + await Future.delayed(Duration.zero); + expect(bloc.state.status, ScheduleStatus.notExists); + }, + ); test('emits upcoming when now is before preparationStartTime', () async { final schedule = buildSchedule( @@ -281,184 +412,195 @@ void main() { }); test( - 'at preparation start boundary it transitions to started and navigates once', - () async { - final schedule = buildSchedule( - id: 'boundary', - scheduleTime: now.add(const Duration(milliseconds: 200)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'a', - preparationName: 'a', - preparationTime: Duration(milliseconds: 100), - nextPreparationId: null, - ), - ], - moveTime: Duration.zero, - scheduleSpareTime: Duration.zero, - ); - bloc.add(ScheduleUpcomingReceived(schedule)); - await Future.delayed(Duration.zero); - expect(bloc.state.status, ScheduleStatus.upcoming); - - final started = await bloc.stream - .firstWhere((s) => s.status == ScheduleStatus.started); - expect(started.status, ScheduleStatus.started); - expect(navigationService.pushedRoutes, ['/scheduleStart']); - }); + 'at preparation start boundary it transitions to started and navigates once', + () async { + final schedule = buildSchedule( + id: 'boundary', + scheduleTime: now.add(const Duration(milliseconds: 200)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(milliseconds: 100), + nextPreparationId: null, + ), + ], + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + ); + bloc.add(ScheduleUpcomingReceived(schedule)); + await Future.delayed(Duration.zero); + expect(bloc.state.status, ScheduleStatus.upcoming); + + final started = await bloc.stream.firstWhere( + (s) => s.status == ScheduleStatus.started, + ); + expect(started.status, ScheduleStatus.started); + expect(navigationService.pushedRoutes, ['/scheduleStart']); + }, + ); test( - 'when received exactly at preparationStartTime it starts and navigates once', - () async { - final schedule = buildSchedule( - id: 'exact-boundary', - scheduleTime: now.add(const Duration(minutes: 40)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'a', - preparationName: 'a', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); - expect(schedule.preparationStartTime, now); + 'when received exactly at preparationStartTime it starts and navigates once', + () async { + final schedule = buildSchedule( + id: 'exact-boundary', + scheduleTime: now.add(const Duration(minutes: 40)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + expect(schedule.preparationStartTime, now); - bloc.add(ScheduleUpcomingReceived(schedule)); - await Future.delayed(Duration.zero); - await Future.delayed(Duration.zero); + bloc.add(ScheduleUpcomingReceived(schedule)); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); - expect(bloc.state.status, ScheduleStatus.started); - expect(bloc.state.schedule?.id, 'exact-boundary'); - expect(bloc.state.isEarlyStarted, isFalse); - expect(navigationService.pushedRoutes, ['/scheduleStart']); - }); + expect(bloc.state.status, ScheduleStatus.started); + expect(bloc.state.schedule?.id, 'exact-boundary'); + expect(bloc.state.isEarlyStarted, isFalse); + expect(navigationService.pushedRoutes, ['/scheduleStart']); + }, + ); test( - 'when early-started schedule is received at boundary it does not navigate again', - () async { - final schedule = buildSchedule( - id: 'early-boundary', - scheduleTime: now.add(const Duration(minutes: 40)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'a', - preparationName: 'a', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); - expect(schedule.preparationStartTime, now); - markEarlySessionUseCase.sessions['early-boundary'] = - now.subtract(const Duration(minutes: 1)); - - bloc.add(ScheduleUpcomingReceived(schedule)); - await Future.delayed(Duration.zero); - await Future.delayed(Duration.zero); - - expect(bloc.state.status, ScheduleStatus.started); - expect(bloc.state.schedule?.id, 'early-boundary'); - expect(bloc.state.isEarlyStarted, isTrue); - expect(navigationService.pushedRoutes, isEmpty); - }); - - test('late entry fast-forwards elapsed preparation to current step', - () async { - final schedule = buildSchedule( - id: 'late-entry', - scheduleTime: now.add(const Duration(minutes: 35)), - steps: const [ - PreparationStepWithTimeEntity( - id: 's1', - preparationName: 'wash', - preparationTime: Duration(minutes: 10), - nextPreparationId: 's2', - ), - PreparationStepWithTimeEntity( - id: 's2', - preparationName: 'dress', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); - - now = schedule.preparationStartTime.add(const Duration(minutes: 15)); - bloc.add(ScheduleUpcomingReceived(schedule)); + 'when early-started schedule is received at boundary it does not navigate again', + () async { + final schedule = buildSchedule( + id: 'early-boundary', + scheduleTime: now.add(const Duration(minutes: 40)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + expect(schedule.preparationStartTime, now); + markEarlySessionUseCase.sessions['early-boundary'] = now.subtract( + const Duration(minutes: 1), + ); + + bloc.add(ScheduleUpcomingReceived(schedule)); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(bloc.state.status, ScheduleStatus.started); + expect(bloc.state.schedule?.id, 'early-boundary'); + expect(bloc.state.isEarlyStarted, isTrue); + expect(navigationService.pushedRoutes, isEmpty); + }, + ); - await Future.delayed(Duration.zero); - await Future.delayed(Duration.zero); - expect(bloc.state.status, ScheduleStatus.ongoing); - expect(bloc.state.schedule!.preparation.currentStep?.id, 's2'); - expect( - bloc.state.schedule!.preparation.preparationStepList[1].elapsedTime, - const Duration(minutes: 5), - ); - }); + test( + 'late entry fast-forwards elapsed preparation to current step', + () async { + final schedule = buildSchedule( + id: 'late-entry', + scheduleTime: now.add(const Duration(minutes: 35)), + steps: const [ + PreparationStepWithTimeEntity( + id: 's1', + preparationName: 'wash', + preparationTime: Duration(minutes: 10), + nextPreparationId: 's2', + ), + PreparationStepWithTimeEntity( + id: 's2', + preparationName: 'dress', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + + now = schedule.preparationStartTime.add(const Duration(minutes: 15)); + bloc.add(ScheduleUpcomingReceived(schedule)); + + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + expect(bloc.state.status, ScheduleStatus.ongoing); + expect(bloc.state.schedule!.preparation.currentStep?.id, 's2'); + expect( + bloc.state.schedule!.preparation.preparationStepList[1].elapsedTime, + const Duration(minutes: 5), + ); + }, + ); - test('entering ongoing applies catch-up tick and accepts later ticks', - () async { - final schedule = buildSchedule( - id: 'tick', - scheduleTime: now.add(const Duration(minutes: 30)), - steps: const [ - PreparationStepWithTimeEntity( - id: 's1', - preparationName: 'wash', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); + test( + 'entering ongoing applies catch-up tick and accepts later ticks', + () async { + final schedule = buildSchedule( + id: 'tick', + scheduleTime: now.add(const Duration(minutes: 30)), + steps: const [ + PreparationStepWithTimeEntity( + id: 's1', + preparationName: 'wash', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); - now = schedule.preparationStartTime.add(const Duration(seconds: 2)); - bloc.add(ScheduleUpcomingReceived(schedule)); - await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); - await Future.delayed(Duration.zero); + now = schedule.preparationStartTime.add(const Duration(seconds: 2)); + bloc.add(ScheduleUpcomingReceived(schedule)); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + await Future.delayed(Duration.zero); - final caughtUpElapsed = bloc.state.schedule!.preparation.elapsedTime; - expect(caughtUpElapsed, const Duration(seconds: 2)); + final caughtUpElapsed = bloc.state.schedule!.preparation.elapsedTime; + expect(caughtUpElapsed, const Duration(seconds: 2)); - bloc.add(const ScheduleTick(Duration(seconds: 1))); - await Future.delayed(Duration.zero); - expect( - bloc.state.schedule!.preparation.elapsedTime, - const Duration(seconds: 3), - ); - }); + bloc.add(const ScheduleTick(Duration(seconds: 1))); + await Future.delayed(Duration.zero); + expect( + bloc.state.schedule!.preparation.elapsedTime, + const Duration(seconds: 3), + ); + }, + ); - test('skip current step advances to next and persists timed preparation', - () async { - final schedule = buildSchedule( - id: 'skip', - scheduleTime: now.add(const Duration(minutes: 35)), - steps: const [ - PreparationStepWithTimeEntity( - id: 's1', - preparationName: 'wash', - preparationTime: Duration(minutes: 10), - nextPreparationId: 's2', - ), - PreparationStepWithTimeEntity( - id: 's2', - preparationName: 'dress', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); - now = schedule.preparationStartTime.add(const Duration(minutes: 1)); - bloc.add(ScheduleUpcomingReceived(schedule)); - await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + test( + 'skip current step advances to next and persists timed preparation', + () async { + final schedule = buildSchedule( + id: 'skip', + scheduleTime: now.add(const Duration(minutes: 35)), + steps: const [ + PreparationStepWithTimeEntity( + id: 's1', + preparationName: 'wash', + preparationTime: Duration(minutes: 10), + nextPreparationId: 's2', + ), + PreparationStepWithTimeEntity( + id: 's2', + preparationName: 'dress', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + now = schedule.preparationStartTime.add(const Duration(minutes: 1)); + bloc.add(ScheduleUpcomingReceived(schedule)); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); - bloc.add(const ScheduleStepSkipped()); - await Future.delayed(Duration.zero); + bloc.add(const ScheduleStepSkipped()); + await Future.delayed(Duration.zero); - expect(bloc.state.schedule!.preparation.currentStep?.id, 's2'); - expect(saveUseCase.calls, isNotEmpty); - expect(saveUseCase.calls.last.$1, 'skip'); - }); + expect(bloc.state.schedule!.preparation.currentStep?.id, 's2'); + expect(saveUseCase.calls, isNotEmpty); + expect(saveUseCase.calls.last.$1, 'skip'); + }, + ); test('skip on last remaining step marks all steps done', () async { final schedule = buildSchedule( @@ -526,197 +668,210 @@ void main() { await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); bloc.add(const ScheduleFinished(7)); - final finished = await bloc.stream - .firstWhere((s) => s.status == ScheduleStatus.notExists); + final finished = await bloc.stream.firstWhere( + (s) => s.status == ScheduleStatus.notExists, + ); expect(finished.status, ScheduleStatus.notExists); expect(finishUseCase.calls.single, ('finish', 7)); }); - test('step change notification fires for non-first transitions only once', - () async { - final schedule = buildSchedule( - id: 'notify', - scheduleTime: now.add(const Duration(minutes: 50)), - steps: const [ - PreparationStepWithTimeEntity( - id: 's1', - preparationName: 'step1', - preparationTime: Duration(minutes: 10), - nextPreparationId: 's2', - ), - PreparationStepWithTimeEntity( - id: 's2', - preparationName: 'step2', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); - now = schedule.preparationStartTime.add(const Duration(minutes: 1)); - bloc.add(ScheduleUpcomingReceived(schedule)); - await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); - - expect(notifiedStepIds, isEmpty); - bloc.add(const ScheduleTick(Duration(minutes: 10))); - await Future.delayed(Duration.zero); - expect(notifiedStepIds, ['s2']); - - bloc.add(const ScheduleTick(Duration(minutes: 1))); - await Future.delayed(Duration.zero); - expect(notifiedStepIds, ['s2']); - }); - - test('new upcoming schedule cancels old timer and tracks latest schedule', - () async { - final scheduleA = buildSchedule( - id: 'A', - scheduleTime: now.add(const Duration(milliseconds: 250)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'a1', - preparationName: 'a', - preparationTime: Duration(milliseconds: 100), - nextPreparationId: null, - ), - ], - moveTime: Duration.zero, - scheduleSpareTime: Duration.zero, - ); - final scheduleB = buildSchedule( - id: 'B', - scheduleTime: now.add(const Duration(milliseconds: 500)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'b1', - preparationName: 'b', - preparationTime: Duration(milliseconds: 100), - nextPreparationId: null, - ), - ], - moveTime: Duration.zero, - scheduleSpareTime: Duration.zero, - ); + test( + 'step change notification fires for non-first transitions only once', + () async { + final schedule = buildSchedule( + id: 'notify', + scheduleTime: now.add(const Duration(minutes: 50)), + steps: const [ + PreparationStepWithTimeEntity( + id: 's1', + preparationName: 'step1', + preparationTime: Duration(minutes: 10), + nextPreparationId: 's2', + ), + PreparationStepWithTimeEntity( + id: 's2', + preparationName: 'step2', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + now = schedule.preparationStartTime.add(const Duration(minutes: 1)); + bloc.add(ScheduleUpcomingReceived(schedule)); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + + expect(notifiedStepIds, isEmpty); + bloc.add(const ScheduleTick(Duration(minutes: 10))); + await Future.delayed(Duration.zero); + expect(notifiedStepIds, ['s2']); + + bloc.add(const ScheduleTick(Duration(minutes: 1))); + await Future.delayed(Duration.zero); + expect(notifiedStepIds, ['s2']); + }, + ); - bloc.add(ScheduleUpcomingReceived(scheduleA)); - await Future.delayed(const Duration(milliseconds: 20)); - bloc.add(ScheduleUpcomingReceived(scheduleB)); - await Future.delayed(const Duration(milliseconds: 220)); + test( + 'new upcoming schedule cancels old timer and tracks latest schedule', + () async { + final scheduleA = buildSchedule( + id: 'A', + scheduleTime: now.add(const Duration(milliseconds: 250)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a1', + preparationName: 'a', + preparationTime: Duration(milliseconds: 100), + nextPreparationId: null, + ), + ], + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + ); + final scheduleB = buildSchedule( + id: 'B', + scheduleTime: now.add(const Duration(milliseconds: 500)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'b1', + preparationName: 'b', + preparationTime: Duration(milliseconds: 100), + nextPreparationId: null, + ), + ], + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + ); + + bloc.add(ScheduleUpcomingReceived(scheduleA)); + await Future.delayed(const Duration(milliseconds: 20)); + bloc.add(ScheduleUpcomingReceived(scheduleB)); + await Future.delayed(const Duration(milliseconds: 220)); + + expect(bloc.state.schedule?.id, 'B'); + expect(bloc.state.status, ScheduleStatus.upcoming); + + final started = await bloc.stream.firstWhere( + (s) => s.status == ScheduleStatus.started, + ); + expect(started.schedule?.id, 'B'); + expect(navigationService.pushedRoutes, ['/scheduleStart']); + }, + ); - expect(bloc.state.schedule?.id, 'B'); - expect(bloc.state.status, ScheduleStatus.upcoming); + test( + 'early start from upcoming emits started and marks early session', + () async { + final schedule = buildSchedule( + id: 'early-start', + scheduleTime: now.add(const Duration(hours: 1)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); - final started = await bloc.stream - .firstWhere((s) => s.status == ScheduleStatus.started); - expect(started.schedule?.id, 'B'); - expect(navigationService.pushedRoutes, ['/scheduleStart']); - }); + bloc.add(ScheduleUpcomingReceived(schedule)); + await Future.delayed(Duration.zero); + expect(bloc.state.status, ScheduleStatus.upcoming); - test('early start from upcoming emits started and marks early session', - () async { - final schedule = buildSchedule( - id: 'early-start', - scheduleTime: now.add(const Duration(hours: 1)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'a', - preparationName: 'a', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); + bloc.add(const SchedulePreparationStarted()); + await Future.delayed(Duration.zero); - bloc.add(ScheduleUpcomingReceived(schedule)); - await Future.delayed(Duration.zero); - expect(bloc.state.status, ScheduleStatus.upcoming); + expect(bloc.state.status, ScheduleStatus.started); + expect(bloc.state.isEarlyStarted, isTrue); + expect( + markEarlySessionUseCase.sessions.containsKey('early-start'), + isTrue, + ); + }, + ); - bloc.add(const SchedulePreparationStarted()); - await Future.delayed(Duration.zero); + test( + 'alarm launch start action keeps cached schedule on start prompt', + () async { + final schedule = buildSchedule( + id: 'alarm-start', + scheduleTime: now.add(const Duration(hours: 1)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); - expect(bloc.state.status, ScheduleStatus.started); - expect(bloc.state.isEarlyStarted, isTrue); - expect( - markEarlySessionUseCase.sessions.containsKey('early-start'), isTrue); - }); + bloc.add(ScheduleUpcomingReceived(schedule)); + await Future.delayed(Duration.zero); - test('alarm launch start action keeps cached schedule on start prompt', - () async { - final schedule = buildSchedule( - id: 'alarm-start', - scheduleTime: now.add(const Duration(hours: 1)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'a', - preparationName: 'a', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, + bloc.add( + ScheduleAlarmPromptRequested( + scheduleId: 'alarm-start', + scheduleFingerprint: schedule.cacheFingerprint, + startPreparation: true, ), - ], - ); - - bloc.add(ScheduleUpcomingReceived(schedule)); - await Future.delayed(Duration.zero); - - bloc.add( - ScheduleAlarmPromptRequested( - scheduleId: 'alarm-start', - scheduleFingerprint: schedule.cacheFingerprint, - startPreparation: true, - ), - ); - await Future.delayed(Duration.zero); - await Future.delayed(Duration.zero); - - expect(bloc.state.status, ScheduleStatus.upcoming); - expect(bloc.state.schedule?.id, 'alarm-start'); - expect(bloc.state.isEarlyStarted, isFalse); - expect( - markEarlySessionUseCase.sessions.containsKey('alarm-start'), - isFalse, - ); - expect(navigationService.goRoutes, isEmpty); - }); + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(bloc.state.status, ScheduleStatus.upcoming); + expect(bloc.state.schedule?.id, 'alarm-start'); + expect(bloc.state.isEarlyStarted, isFalse); + expect( + markEarlySessionUseCase.sessions.containsKey('alarm-start'), + isFalse, + ); + expect(navigationService.goRoutes, isEmpty); + }, + ); test( - 'alarm launch start action keeps stale-fingerprint cached schedule on start prompt', - () async { - final schedule = buildSchedule( - id: 'alarm-stale-fingerprint', - scheduleTime: now.add(const Duration(hours: 1)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'a', - preparationName: 'a', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); + 'alarm launch start action keeps stale-fingerprint cached schedule on start prompt', + () async { + final schedule = buildSchedule( + id: 'alarm-stale-fingerprint', + scheduleTime: now.add(const Duration(hours: 1)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); - bloc.add(ScheduleUpcomingReceived(schedule)); - await Future.delayed(Duration.zero); + bloc.add(ScheduleUpcomingReceived(schedule)); + await Future.delayed(Duration.zero); - bloc.add( - const ScheduleAlarmPromptRequested( - scheduleId: 'alarm-stale-fingerprint', - scheduleFingerprint: 'stale-fingerprint', - startPreparation: true, - ), - ); - await Future.delayed(Duration.zero); - await Future.delayed(Duration.zero); - - expect(bloc.state.status, ScheduleStatus.upcoming); - expect(bloc.state.schedule?.id, 'alarm-stale-fingerprint'); - expect(bloc.state.isEarlyStarted, isFalse); - expect( - markEarlySessionUseCase.sessions.containsKey( - 'alarm-stale-fingerprint', - ), - isFalse, - ); - expect(navigationService.goRoutes, isEmpty); - }); + bloc.add( + const ScheduleAlarmPromptRequested( + scheduleId: 'alarm-stale-fingerprint', + scheduleFingerprint: 'stale-fingerprint', + startPreparation: true, + ), + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(bloc.state.status, ScheduleStatus.upcoming); + expect(bloc.state.schedule?.id, 'alarm-stale-fingerprint'); + expect(bloc.state.isEarlyStarted, isFalse); + expect( + markEarlySessionUseCase.sessions.containsKey( + 'alarm-stale-fingerprint', + ), + isFalse, + ); + expect(navigationService.goRoutes, isEmpty); + }, + ); test('future upcoming schedule stops active preparation timer', () async { final active = buildSchedule( @@ -764,57 +919,60 @@ void main() { }); test( - 'official start timer does not push scheduleStart when already early-started', - () async { - final schedule = buildSchedule( - id: 'early-no-push', - scheduleTime: now.add(const Duration(milliseconds: 220)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'a', - preparationName: 'a', - preparationTime: Duration(milliseconds: 100), - nextPreparationId: null, - ), - ], - moveTime: Duration.zero, - scheduleSpareTime: Duration.zero, - ); + 'official start timer does not push scheduleStart when already early-started', + () async { + final schedule = buildSchedule( + id: 'early-no-push', + scheduleTime: now.add(const Duration(milliseconds: 220)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(milliseconds: 100), + nextPreparationId: null, + ), + ], + moveTime: Duration.zero, + scheduleSpareTime: Duration.zero, + ); - bloc.add(ScheduleUpcomingReceived(schedule)); - await Future.delayed(Duration.zero); - bloc.add(const SchedulePreparationStarted()); - await Future.delayed(Duration.zero); - await Future.delayed(const Duration(milliseconds: 300)); + bloc.add(ScheduleUpcomingReceived(schedule)); + await Future.delayed(Duration.zero); + bloc.add(const SchedulePreparationStarted()); + await Future.delayed(Duration.zero); + await Future.delayed(const Duration(milliseconds: 300)); - expect(navigationService.pushedRoutes, isEmpty); - }); + expect(navigationService.pushedRoutes, isEmpty); + }, + ); - test('same schedule re-emission preserves started state when early-started', - () async { - final schedule = buildSchedule( - id: 'same-reemit', - scheduleTime: now.add(const Duration(hours: 1)), - steps: const [ - PreparationStepWithTimeEntity( - id: 'a', - preparationName: 'a', - preparationTime: Duration(minutes: 10), - nextPreparationId: null, - ), - ], - ); + test( + 'same schedule re-emission preserves started state when early-started', + () async { + final schedule = buildSchedule( + id: 'same-reemit', + scheduleTime: now.add(const Duration(hours: 1)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); - bloc.add(ScheduleUpcomingReceived(schedule)); - await Future.delayed(Duration.zero); - bloc.add(const SchedulePreparationStarted()); - await Future.delayed(Duration.zero); + bloc.add(ScheduleUpcomingReceived(schedule)); + await Future.delayed(Duration.zero); + bloc.add(const SchedulePreparationStarted()); + await Future.delayed(Duration.zero); - bloc.add(ScheduleUpcomingReceived(schedule)); - await Future.delayed(Duration.zero); - expect(bloc.state.status, ScheduleStatus.started); - expect(bloc.state.isEarlyStarted, isTrue); - }); + bloc.add(ScheduleUpcomingReceived(schedule)); + await Future.delayed(Duration.zero); + expect(bloc.state.status, ScheduleStatus.started); + expect(bloc.state.isEarlyStarted, isTrue); + }, + ); test('finish clears timed preparation and early session state', () async { final schedule = buildSchedule( @@ -882,39 +1040,273 @@ void main() { ); }); - test('resume before official start when early session and snapshot exist', - () async { + test( + 'resume before official start when early session and snapshot exist', + () async { + final schedule = buildSchedule( + id: 'resume-early', + scheduleTime: now.add(const Duration(hours: 1)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(minutes: 20), + nextPreparationId: null, + elapsedTime: Duration(minutes: 2), + ), + ], + ); + + markEarlySessionUseCase.sessions['resume-early'] = now.subtract( + const Duration(minutes: 3), + ); + getSnapshotUseCase.snapshots['resume-early'] = buildSnapshot( + preparation: schedule.preparation, + savedAt: now.subtract(const Duration(minutes: 1)), + fingerprint: schedule.cacheFingerprint, + ); + + bloc.add(ScheduleUpcomingReceived(schedule)); + await Future.delayed(Duration.zero); + + expect(bloc.state.status, ScheduleStatus.started); + expect(bloc.state.isEarlyStarted, isTrue); + expect( + bloc.state.schedule!.preparation.elapsedTime, + greaterThan(const Duration(minutes: 2)), + ); + }, + ); + + test( + 'remote alarm prompt loads schedule details and arms the prompt', + () async { + final schedule = buildSchedule( + id: 'remote-alarm', + scheduleTime: now.add(const Duration(hours: 1)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'stale-local', + preparationName: 'stale', + preparationTime: Duration(minutes: 1), + nextPreparationId: null, + ), + ], + ); + getScheduleByIdUseCase.schedules['remote-alarm'] = schedule; + getPreparationByScheduleIdUseCase.preparations['remote-alarm'] = + const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'remote-step', + preparationName: 'remote prep', + preparationTime: Duration(minutes: 7), + nextPreparationId: null, + ), + ], + ); + + bloc.add( + const ScheduleAlarmPromptRequested(scheduleId: 'remote-alarm'), + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(loadPreparationByScheduleIdUseCase.calls, ['remote-alarm']); + expect(bloc.state.status, ScheduleStatus.upcoming); + expect(bloc.state.schedule?.id, 'remote-alarm'); + expect( + bloc.state.schedule?.preparation.preparationStepList.single.id, + 'remote-step', + ); + expect(cancelScheduleAlarmUseCase.calls, isEmpty); + expect(navigationService.goRoutes, isEmpty); + }, + ); + + test( + 'remote alarm prompt rejects ended schedules and clears their alarm', + () async { + final schedule = buildSchedule( + id: 'ended-alarm', + scheduleTime: now.add(const Duration(hours: 1)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(minutes: 5), + nextPreparationId: null, + ), + ], + ).copyWith(doneStatus: ScheduleDoneStatus.normalEnd); + getScheduleByIdUseCase.schedules['ended-alarm'] = schedule; + + bloc.add(const ScheduleAlarmPromptRequested(scheduleId: 'ended-alarm')); + await Future.delayed(Duration.zero); + + expect(cancelScheduleAlarmUseCase.calls, ['ended-alarm']); + expect(navigationService.goRoutes, ['/home']); + expect(bloc.state.status, ScheduleStatus.initial); + }, + ); + + test( + 'remote alarm prompt rejects stale fingerprint unless it starts prep', + () async { + final schedule = buildSchedule( + id: 'remote-stale', + scheduleTime: now.add(const Duration(hours: 1)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(minutes: 5), + nextPreparationId: null, + ), + ], + ); + getScheduleByIdUseCase.schedules['remote-stale'] = schedule; + getPreparationByScheduleIdUseCase.preparations['remote-stale'] = + const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(minutes: 5), + nextPreparationId: null, + ), + ], + ); + + bloc.add( + const ScheduleAlarmPromptRequested( + scheduleId: 'remote-stale', + scheduleFingerprint: 'old-fingerprint', + ), + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(cancelScheduleAlarmUseCase.calls, ['remote-stale']); + expect(navigationService.goRoutes, ['/home']); + + cancelScheduleAlarmUseCase.calls.clear(); + navigationService.goRoutes.clear(); + bloc.add( + const ScheduleAlarmPromptRequested( + scheduleId: 'remote-stale', + scheduleFingerprint: 'old-fingerprint', + startPreparation: true, + ), + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(bloc.state.status, ScheduleStatus.upcoming); + expect(bloc.state.schedule?.id, 'remote-stale'); + expect(cancelScheduleAlarmUseCase.calls, isEmpty); + expect(navigationService.goRoutes, isEmpty); + }, + ); + + test( + 'alarm prompt validation failure clears only non-start prompts', + () async { + getScheduleByIdUseCase.throwingIds.add('missing-alarm'); + + bloc.add( + const ScheduleAlarmPromptRequested(scheduleId: 'missing-alarm'), + ); + await Future.delayed(Duration.zero); + + expect(cancelScheduleAlarmUseCase.calls, ['missing-alarm']); + expect(navigationService.goRoutes, ['/home']); + + cancelScheduleAlarmUseCase.calls.clear(); + navigationService.goRoutes.clear(); + bloc.add( + const ScheduleAlarmPromptRequested( + scheduleId: 'missing-alarm', + startPreparation: true, + ), + ); + await Future.delayed(Duration.zero); + + expect(cancelScheduleAlarmUseCase.calls, isEmpty); + expect(navigationService.goRoutes, isEmpty); + }, + ); + + test('finish failure keeps the active schedule visible', () async { final schedule = buildSchedule( - id: 'resume-early', - scheduleTime: now.add(const Duration(hours: 1)), + id: 'finish-fails', + scheduleTime: now.add(const Duration(minutes: 35)), steps: const [ PreparationStepWithTimeEntity( id: 'a', preparationName: 'a', - preparationTime: Duration(minutes: 20), + preparationTime: Duration(minutes: 10), nextPreparationId: null, - elapsedTime: Duration(minutes: 2), ), ], ); - - markEarlySessionUseCase.sessions['resume-early'] = - now.subtract(const Duration(minutes: 3)); - getSnapshotUseCase.snapshots['resume-early'] = buildSnapshot( - preparation: schedule.preparation, - savedAt: now.subtract(const Duration(minutes: 1)), - fingerprint: schedule.cacheFingerprint, - ); - + finishUseCase.throwOnCall = true; + now = schedule.preparationStartTime.add(const Duration(minutes: 1)); bloc.add(ScheduleUpcomingReceived(schedule)); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + + bloc.add(const ScheduleFinished(11)); await Future.delayed(Duration.zero); - expect(bloc.state.status, ScheduleStatus.started); - expect(bloc.state.isEarlyStarted, isTrue); - expect( - bloc.state.schedule!.preparation.elapsedTime, - greaterThan(const Duration(minutes: 2)), - ); + expect(finishUseCase.calls, [('finish-fails', 11)]); + expect(bloc.state.status, ScheduleStatus.ongoing); + expect(clearTimedUseCase.calls, isNot(contains('finish-fails'))); }); }); + + test('schedule events expose equality props for bloc deduping', () { + final schedule = buildSchedule( + id: 'props', + scheduleTime: DateTime(2026, 3, 20, 10), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(minutes: 5), + nextPreparationId: null, + ), + ], + ); + + expect( + const ScheduleAlarmPromptRequested( + scheduleId: 's1', + scheduleFingerprint: 'fingerprint', + startPreparation: true, + ), + const ScheduleAlarmPromptRequested( + scheduleId: 's1', + scheduleFingerprint: 'fingerprint', + startPreparation: true, + ), + ); + expect(ScheduleUpcomingReceived(schedule).props, [schedule]); + expect( + const ScheduleAlarmPromptRequested( + scheduleId: 's1', + scheduleFingerprint: 'fingerprint', + startPreparation: true, + ).props, + ['s1', 'fingerprint', true], + ); + expect(const ScheduleTick(Duration(seconds: 3)).props, [ + const Duration(seconds: 3), + ]); + expect(const ScheduleFinished(4).props, [4]); + expect(const ScheduleEvent().props, isEmpty); + expect(const ScheduleSubscriptionRequested().props, isEmpty); + expect(const ScheduleStarted().props, isEmpty); + expect(const SchedulePreparationStarted().props, isEmpty); + expect(const ScheduleStepSkipped().props, isEmpty); + }); } diff --git a/test/presentation/app/cubit/alarm_gate_cubit_test.dart b/test/presentation/app/cubit/alarm_gate_cubit_test.dart new file mode 100644 index 00000000..9baefe4d --- /dev/null +++ b/test/presentation/app/cubit/alarm_gate_cubit_test.dart @@ -0,0 +1,315 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/repositories/alarm_repository.dart'; +import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; +import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; +import 'package:on_time_front/presentation/app/cubit/alarm_gate_cubit.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test( + 'refreshPermission marks unsupported devices as resolved without prompt', + () async { + final cubit = _buildCubit( + scheduler: _FakeAlarmSchedulerService( + checkPermissionState: AlarmPermissionState.unsupported, + ), + ); + addTearDown(cubit.close); + + await cubit.refreshPermission(); + + expect(cubit.state, const AlarmGateState.unsupported()); + expect(cubit.state.isResolved, isTrue); + expect(cubit.state.shouldPrompt, isFalse); + }, + ); + + test( + 'refreshPermission clears dismissal and enables alarms when granted', + () async { + SharedPreferences.setMockInitialValues({'alarm_prompt_dismissed': true}); + final repository = _FakeAlarmRepository(); + final reconcile = _FakeReconcileAlarmsUseCase(); + final cubit = _buildCubit( + scheduler: _FakeAlarmSchedulerService( + checkPermissionState: AlarmPermissionState.granted, + ), + repository: repository, + reconcile: reconcile, + ); + addTearDown(cubit.close); + + await cubit.refreshPermission(enableAlarmsOnGrant: true); + + final prefs = await SharedPreferences.getInstance(); + expect(cubit.state, const AlarmGateState.allowed()); + expect(prefs.getBool('alarm_prompt_dismissed'), isNull); + expect(repository.updatedAlarmSettings, [true]); + expect(reconcile.callCount, 1); + }, + ); + + test( + 'refreshPermission prompts and disables alarms when permission is denied', + () async { + final repository = _FakeAlarmRepository(); + final cancelAll = _FakeCancelAllAlarmsUseCase(); + final cubit = _buildCubit( + scheduler: _FakeAlarmSchedulerService( + checkPermissionState: AlarmPermissionState.denied, + ), + repository: repository, + cancelAll: cancelAll, + ); + addTearDown(cubit.close); + + await cubit.refreshPermission(disableAlarmsWhenPermissionMissing: true); + + expect(cubit.state, const AlarmGateState.required()); + expect(cubit.state.shouldPrompt, isTrue); + expect(repository.updatedAlarmSettings, [false]); + expect(cancelAll.callCount, 1); + }, + ); + + test('refreshPermission keeps a dismissed denied prompt dismissed', () async { + SharedPreferences.setMockInitialValues({'alarm_prompt_dismissed': true}); + final cubit = _buildCubit( + scheduler: _FakeAlarmSchedulerService( + checkPermissionState: AlarmPermissionState.denied, + ), + ); + addTearDown(cubit.close); + + await cubit.refreshPermission(); + + expect(cubit.state, const AlarmGateState.dismissed()); + expect(cubit.state.shouldPrompt, isFalse); + }); + + test( + 'requestPermission grants access, clears dismissal, and enables alarms', + () async { + SharedPreferences.setMockInitialValues({'alarm_prompt_dismissed': true}); + final repository = _FakeAlarmRepository(); + final reconcile = _FakeReconcileAlarmsUseCase(); + final cubit = _buildCubit( + scheduler: _FakeAlarmSchedulerService( + requestPermissionState: AlarmPermissionState.granted, + ), + repository: repository, + reconcile: reconcile, + ); + addTearDown(cubit.close); + + final permission = await cubit.requestPermission(); + + final prefs = await SharedPreferences.getInstance(); + expect(permission, AlarmPermissionState.granted); + expect(cubit.state, const AlarmGateState.allowed()); + expect(prefs.getBool('alarm_prompt_dismissed'), isNull); + expect(repository.updatedAlarmSettings, [true]); + expect(reconcile.callCount, 1); + }, + ); + + test( + 'requestPermission denial disables alarms and keeps prompt required', + () async { + final repository = _FakeAlarmRepository(); + final cancelAll = _FakeCancelAllAlarmsUseCase(); + final cubit = _buildCubit( + scheduler: _FakeAlarmSchedulerService( + requestPermissionState: AlarmPermissionState.denied, + ), + repository: repository, + cancelAll: cancelAll, + ); + addTearDown(cubit.close); + + final permission = await cubit.requestPermission(); + + expect(permission, AlarmPermissionState.denied); + expect(cubit.state, const AlarmGateState.required()); + expect(repository.updatedAlarmSettings, [false]); + expect(cancelAll.callCount, 1); + }, + ); + + test('dismissPrompt stores dismissal and disables alarms', () async { + final repository = _FakeAlarmRepository(); + final cancelAll = _FakeCancelAllAlarmsUseCase(); + final cubit = _buildCubit(repository: repository, cancelAll: cancelAll); + addTearDown(cubit.close); + + await cubit.dismissPrompt(); + + final prefs = await SharedPreferences.getInstance(); + expect(cubit.state, const AlarmGateState.dismissed()); + expect(prefs.getBool('alarm_prompt_dismissed'), isTrue); + expect(repository.updatedAlarmSettings, [false]); + expect(cancelAll.callCount, 1); + }); + + test( + 'best-effort alarm enable still allows permission state to resolve', + () async { + final cubit = _buildCubit( + scheduler: _FakeAlarmSchedulerService( + checkPermissionState: AlarmPermissionState.granted, + ), + repository: _FakeAlarmRepository(throwOnUpdate: true), + ); + addTearDown(cubit.close); + + await cubit.refreshPermission(enableAlarmsOnGrant: true); + + expect(cubit.state, const AlarmGateState.allowed()); + }, + ); + + test( + 'best-effort alarm disable still records dismissal when cleanup fails', + () async { + final cubit = _buildCubit( + repository: _FakeAlarmRepository(throwOnUpdate: true), + ); + addTearDown(cubit.close); + + await cubit.dismissPrompt(); + + final prefs = await SharedPreferences.getInstance(); + expect(cubit.state, const AlarmGateState.dismissed()); + expect(prefs.getBool('alarm_prompt_dismissed'), isTrue); + }, + ); +} + +AlarmGateCubit _buildCubit({ + _FakeAlarmSchedulerService? scheduler, + _FakeAlarmRepository? repository, + _FakeReconcileAlarmsUseCase? reconcile, + _FakeCancelAllAlarmsUseCase? cancelAll, +}) { + return AlarmGateCubit( + alarmSchedulerService: scheduler ?? _FakeAlarmSchedulerService(), + alarmRepository: repository ?? _FakeAlarmRepository(), + reconcileAlarmsUseCase: reconcile ?? _FakeReconcileAlarmsUseCase(), + cancelAllAlarmsUseCase: cancelAll ?? _FakeCancelAllAlarmsUseCase(), + ); +} + +class _FakeAlarmSchedulerService extends AlarmSchedulerService { + _FakeAlarmSchedulerService({ + this.checkPermissionState = AlarmPermissionState.denied, + this.requestPermissionState = AlarmPermissionState.denied, + }); + + final AlarmPermissionState checkPermissionState; + final AlarmPermissionState requestPermissionState; + + @override + Future checkPermission() async => checkPermissionState; + + @override + Future requestPermission() async => + requestPermissionState; +} + +class _FakeAlarmRepository implements AlarmRepository { + _FakeAlarmRepository({this.throwOnUpdate = false}); + + final bool throwOnUpdate; + final updatedAlarmSettings = []; + + @override + Future updateAlarmSettings({ + required bool alarmsEnabled, + }) async { + if (throwOnUpdate) { + throw Exception('settings unavailable'); + } + updatedAlarmSettings.add(alarmsEnabled); + return AlarmSettings(alarmsEnabled: alarmsEnabled); + } + + @override + Future getDeviceId() async => 'device-1'; + + @override + Future buildCurrentDeviceInfo() { + throw UnimplementedError(); + } + + @override + Future getAlarmSettings() { + throw UnimplementedError(); + } + + @override + Future> getAlarmWindow( + DateTime startDate, + DateTime endDate, + ) { + throw UnimplementedError(); + } + + @override + Future postAlarmStatus(AlarmStatusReport report) { + throw UnimplementedError(); + } + + @override + Future registerCurrentDevice(AlarmDeviceInfo deviceInfo) { + throw UnimplementedError(); + } + + @override + Future unregisterCurrentDevice(String deviceId) { + throw UnimplementedError(); + } +} + +class _FakeReconcileAlarmsUseCase implements ReconcileAlarmsUseCase { + int callCount = 0; + + @override + Future call() async { + callCount += 1; + final now = DateTime(2026, 5, 15); + return AlarmReconciliationResult( + status: AlarmReconciliationStatus.armed, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + armedScheduleIds: const [], + skippedScheduleCount: 0, + failures: const [], + scheduleWindowStart: now, + scheduleWindowEnd: now, + alarmCoverageStart: now, + alarmCoverageEnd: now, + ); + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeCancelAllAlarmsUseCase implements CancelAllAlarmsUseCase { + int callCount = 0; + + @override + Future call({bool unregisterDevice = false}) async { + callCount += 1; + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/presentation/app/cubit/notification_gate_cubit_test.dart b/test/presentation/app/cubit/notification_gate_cubit_test.dart new file mode 100644 index 00000000..a13f9918 --- /dev/null +++ b/test/presentation/app/cubit/notification_gate_cubit_test.dart @@ -0,0 +1,161 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/core/services/notification_service.dart'; +import 'package:on_time_front/presentation/app/cubit/notification_gate_cubit.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test( + 'authorized permission initializes notifications and allows app entry', + () async { + final service = _FakeNotificationService( + permission: AuthorizationStatus.authorized, + ); + final cubit = NotificationGateCubit(notificationService: service); + addTearDown(cubit.close); + + await expectLater( + cubit.stream, + emits(const NotificationGateState.allowed()), + ); + + expect(cubit.state.isResolved, isTrue); + expect(cubit.state.shouldPrompt, isFalse); + expect(service.initializeCount, 1); + }, + ); + + test( + 'missing permission requires prompt when the prompt was not dismissed', + () async { + final service = _FakeNotificationService( + permission: AuthorizationStatus.denied, + ); + final cubit = NotificationGateCubit(notificationService: service); + addTearDown(cubit.close); + + await expectLater( + cubit.stream, + emits(const NotificationGateState.required()), + ); + + expect(cubit.state.shouldPrompt, isTrue); + expect(service.initializeCount, 0); + }, + ); + + test( + 'dismissed prompt remains dismissed without checking permission', + () async { + SharedPreferences.setMockInitialValues({ + 'notification_prompt_dismissed': true, + }); + final service = _FakeNotificationService( + permission: AuthorizationStatus.authorized, + ); + final cubit = NotificationGateCubit(notificationService: service); + addTearDown(cubit.close); + + await expectLater( + cubit.stream, + emits(const NotificationGateState.dismissed()), + ); + + expect(service.checkCount, 0); + expect(service.initializeCount, 0); + }, + ); + + test( + 'markPermissionAllowed clears dismissal and initializes notifications', + () async { + SharedPreferences.setMockInitialValues({ + 'notification_prompt_dismissed': true, + }); + final service = _FakeNotificationService(); + final cubit = NotificationGateCubit(notificationService: service); + addTearDown(cubit.close); + await expectLater( + cubit.stream, + emits(const NotificationGateState.dismissed()), + ); + + await cubit.markPermissionAllowed(); + + final prefs = await SharedPreferences.getInstance(); + expect(cubit.state, const NotificationGateState.allowed()); + expect(prefs.getBool('notification_prompt_dismissed'), isNull); + expect(service.initializeCount, 1); + }, + ); + + test( + 'dismissPrompt stores dismissal and resolves without prompting', + () async { + final service = _FakeNotificationService( + permission: AuthorizationStatus.denied, + ); + final cubit = NotificationGateCubit(notificationService: service); + addTearDown(cubit.close); + await expectLater( + cubit.stream, + emits(const NotificationGateState.required()), + ); + + await cubit.dismissPrompt(); + + final prefs = await SharedPreferences.getInstance(); + expect(cubit.state, const NotificationGateState.dismissed()); + expect(prefs.getBool('notification_prompt_dismissed'), isTrue); + }, + ); + + test('initialization failure does not block an allowed gate state', () async { + final service = _FakeNotificationService( + permission: AuthorizationStatus.authorized, + throwOnInitialize: true, + ); + final cubit = NotificationGateCubit(notificationService: service); + addTearDown(cubit.close); + + await expectLater( + cubit.stream, + emits(const NotificationGateState.allowed()), + ); + + expect(service.initializeCount, 1); + }); +} + +class _FakeNotificationService implements NotificationService { + _FakeNotificationService({ + this.permission = AuthorizationStatus.denied, + this.throwOnInitialize = false, + }); + + final AuthorizationStatus permission; + final bool throwOnInitialize; + int checkCount = 0; + int initializeCount = 0; + + @override + Future checkNotificationPermission() async { + checkCount += 1; + return permission; + } + + @override + Future initialize() async { + initializeCount += 1; + if (throwOnInitialize) { + throw Exception('notification setup failed'); + } + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart b/test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart index 16548594..a2ad2919 100644 --- a/test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart +++ b/test/presentation/calendar/bloc/monthly_schedules_bloc_test.dart @@ -94,7 +94,7 @@ void main() { late StubGetSchedulesByDateUseCase getSchedulesByDateUseCase; late StubDeleteScheduleUseCase deleteScheduleUseCase; late StubLoadPreparationByScheduleIdUseCase - loadPreparationByScheduleIdUseCase; + loadPreparationByScheduleIdUseCase; late StubGetPreparationByScheduleIdUseCase getPreparationByScheduleIdUseCase; late StubStreamPreparationsUseCase streamPreparationsUseCase; @@ -134,55 +134,57 @@ void main() { (loadCallsByScheduleId[scheduleId] ?? 0) + 1; }, ); - getPreparationByScheduleIdUseCase = StubGetPreparationByScheduleIdUseCase( - (scheduleId) async { - if (scheduleId == scheduleA.id) { - return const PreparationEntity( - preparationStepList: [ - PreparationStepEntity( - id: 'prep-a', - preparationName: 'Shower', - preparationTime: Duration(minutes: 20), - ), - ], - ); - } + getPreparationByScheduleIdUseCase = StubGetPreparationByScheduleIdUseCase(( + scheduleId, + ) async { + if (scheduleId == scheduleA.id) { return const PreparationEntity( preparationStepList: [ PreparationStepEntity( - id: 'prep-b', - preparationName: 'Dress', - preparationTime: Duration(minutes: 15), + id: 'prep-a', + preparationName: 'Shower', + preparationTime: Duration(minutes: 20), ), ], ); - }, - ); + } + return const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'prep-b', + preparationName: 'Dress', + preparationTime: Duration(minutes: 15), + ), + ], + ); + }); }); - test('visible date + schedules prefetches preparations for visible schedules', - () async { - final bloc = buildBloc(); - addTearDown(bloc.close); - - bloc.add(MonthlySchedulesVisibleDateChanged(date: selectedDate)); - bloc.add(MonthlySchedulesSubscriptionRequested(date: selectedDate)); - - final loadedState = await bloc.stream.firstWhere( - (state) => - state.preparationDurationByScheduleId.containsKey(scheduleA.id) && - state.preparationDurationByScheduleId.containsKey(scheduleB.id), - ); - - expect( - loadedState.preparationDurationByScheduleId[scheduleA.id], - const Duration(minutes: 20), - ); - expect( - loadedState.preparationDurationByScheduleId[scheduleB.id], - const Duration(minutes: 15), - ); - }); + test( + 'visible date + schedules prefetches preparations for visible schedules', + () async { + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(MonthlySchedulesVisibleDateChanged(date: selectedDate)); + bloc.add(MonthlySchedulesSubscriptionRequested(date: selectedDate)); + + final loadedState = await bloc.stream.firstWhere( + (state) => + state.preparationDurationByScheduleId.containsKey(scheduleA.id) && + state.preparationDurationByScheduleId.containsKey(scheduleB.id), + ); + + expect( + loadedState.preparationDurationByScheduleId[scheduleA.id], + const Duration(minutes: 20), + ); + expect( + loadedState.preparationDurationByScheduleId[scheduleB.id], + const Duration(minutes: 15), + ); + }, + ); test('cached schedule preparations are not fetched again', () async { getSchedulesByDateUseCase = StubGetSchedulesByDateUseCase( @@ -282,8 +284,10 @@ void main() { }); await Future.delayed(const Duration(milliseconds: 20)); - expect(bloc.state.preparationDurationByScheduleId, - loadedState.preparationDurationByScheduleId); + expect( + bloc.state.preparationDurationByScheduleId, + loadedState.preparationDurationByScheduleId, + ); }); test('deleting a schedule removes its cached preparation duration', () async { @@ -310,79 +314,79 @@ void main() { ); }); - test('schedule stream update refreshes schedule fields in monthly state', - () async { - final controller = StreamController>(); - addTearDown(controller.close); - getSchedulesByDateUseCase = StubGetSchedulesByDateUseCase( - (_, __) => controller.stream, - ); - - final bloc = buildBloc(); - addTearDown(bloc.close); - - bloc.add(MonthlySchedulesVisibleDateChanged(date: selectedDate)); - bloc.add(MonthlySchedulesSubscriptionRequested(date: selectedDate)); - - controller.add([scheduleA, scheduleB]); - await bloc.stream.firstWhere( - (state) => - state.schedules[selectedDate]?.any( - (schedule) => schedule.scheduleName == 'Meeting', - ) ?? - false, - ); - - final updatedScheduleA = ScheduleEntity( - id: scheduleA.id, - place: PlaceEntity(id: scheduleA.place.id, placeName: 'New Office'), - scheduleName: 'Edited Meeting', - scheduleTime: DateTime(2026, 3, 20, 10, 30), - moveTime: const Duration(minutes: 45), - isChanged: false, - isStarted: false, - scheduleSpareTime: const Duration(minutes: 20), - scheduleNote: '', - ); - - final updatedStateFuture = bloc.stream.firstWhere( - (state) => - state.schedules[selectedDate]?.any( - (schedule) => - schedule.id == scheduleA.id && - schedule.scheduleName == 'Edited Meeting' && - schedule.place.placeName == 'New Office' && - schedule.scheduleTime == DateTime(2026, 3, 20, 10, 30), - ) ?? - false, - ); - controller.add([updatedScheduleA, scheduleB]); - final updatedState = await updatedStateFuture; - - final updatedSchedule = updatedState.schedules[selectedDate]!.firstWhere( - (schedule) => schedule.id == scheduleA.id, - ); - expect(updatedSchedule.scheduleName, 'Edited Meeting'); - expect(updatedSchedule.place.placeName, 'New Office'); - expect(updatedSchedule.scheduleTime, DateTime(2026, 3, 20, 10, 30)); - expect(updatedSchedule.moveTime, const Duration(minutes: 45)); - expect(updatedSchedule.scheduleSpareTime, const Duration(minutes: 20)); - }); + test( + 'schedule stream update refreshes schedule fields in monthly state', + () async { + final controller = StreamController>(); + addTearDown(controller.close); + getSchedulesByDateUseCase = StubGetSchedulesByDateUseCase( + (_, __) => controller.stream, + ); + + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(MonthlySchedulesVisibleDateChanged(date: selectedDate)); + bloc.add(MonthlySchedulesSubscriptionRequested(date: selectedDate)); + + controller.add([scheduleA, scheduleB]); + await bloc.stream.firstWhere( + (state) => + state.schedules[selectedDate]?.any( + (schedule) => schedule.scheduleName == 'Meeting', + ) ?? + false, + ); + + final updatedScheduleA = ScheduleEntity( + id: scheduleA.id, + place: PlaceEntity(id: scheduleA.place.id, placeName: 'New Office'), + scheduleName: 'Edited Meeting', + scheduleTime: DateTime(2026, 3, 20, 10, 30), + moveTime: const Duration(minutes: 45), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 20), + scheduleNote: '', + ); + + final updatedStateFuture = bloc.stream.firstWhere( + (state) => + state.schedules[selectedDate]?.any( + (schedule) => + schedule.id == scheduleA.id && + schedule.scheduleName == 'Edited Meeting' && + schedule.place.placeName == 'New Office' && + schedule.scheduleTime == DateTime(2026, 3, 20, 10, 30), + ) ?? + false, + ); + controller.add([updatedScheduleA, scheduleB]); + final updatedState = await updatedStateFuture; + + final updatedSchedule = updatedState.schedules[selectedDate]!.firstWhere( + (schedule) => schedule.id == scheduleA.id, + ); + expect(updatedSchedule.scheduleName, 'Edited Meeting'); + expect(updatedSchedule.place.placeName, 'New Office'); + expect(updatedSchedule.scheduleTime, DateTime(2026, 3, 20, 10, 30)); + expect(updatedSchedule.moveTime, const Duration(minutes: 45)); + expect(updatedSchedule.scheduleSpareTime, const Duration(minutes: 20)); + }, + ); test('refresh requested reloads schedules for current month', () async { var loadedDate = DateTime(2000); - loadSchedulesForMonthUseCase = StubLoadSchedulesForMonthUseCase( - (date) async { - loadedDate = date; - }, - ); + loadSchedulesForMonthUseCase = StubLoadSchedulesForMonthUseCase(( + date, + ) async { + loadedDate = date; + }); final bloc = buildBloc(); addTearDown(bloc.close); - bloc.add( - MonthlySchedulesRefreshRequested(date: DateTime(2026, 3, 20)), - ); + bloc.add(MonthlySchedulesRefreshRequested(date: DateTime(2026, 3, 20))); await Future.delayed(const Duration(milliseconds: 20)); expect(loadedDate, DateTime(2026, 3, 20)); @@ -404,4 +408,274 @@ void main() { expect(errorState.status, MonthlySchedulesStatus.error); }); + + test( + 'month already in loaded range reuses cached range without reloading', + () async { + final loadedDates = []; + loadSchedulesForMonthUseCase = StubLoadSchedulesForMonthUseCase(( + date, + ) async { + loadedDates.add(date); + }); + + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(MonthlySchedulesSubscriptionRequested(date: selectedDate)); + await bloc.stream.firstWhere( + (state) => state.status == MonthlySchedulesStatus.success, + ); + + bloc.add(MonthlySchedulesMonthAdded(date: DateTime(2026, 3, 1))); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(loadedDates, [selectedDate]); + expect(bloc.state.startDate, DateTime(2026, 3, 1)); + expect(bloc.state.endDate, DateTime(2026, 4, 1)); + }, + ); + + test( + 'adjacent month extends loaded range and groups returned schedules', + () async { + final loadedDates = []; + loadSchedulesForMonthUseCase = StubLoadSchedulesForMonthUseCase(( + date, + ) async { + loadedDates.add(date); + }); + + final aprilSchedule = ScheduleEntity( + id: 'schedule-april', + place: PlaceEntity(id: 'place-3', placeName: 'Library'), + scheduleName: 'April review', + scheduleTime: DateTime(2026, 4, 2, 9), + moveTime: const Duration(minutes: 10), + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + ); + getSchedulesByDateUseCase = StubGetSchedulesByDateUseCase( + (_, __) => Stream.value([scheduleA, aprilSchedule]), + ); + + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(MonthlySchedulesSubscriptionRequested(date: selectedDate)); + await bloc.stream.firstWhere( + (state) => state.status == MonthlySchedulesStatus.success, + ); + + bloc.add(MonthlySchedulesMonthAdded(date: DateTime(2026, 4, 1))); + final extendedState = await bloc.stream.firstWhere( + (state) => + state.endDate == DateTime(2026, 5, 1) && + (state.schedules[DateTime(2026, 4, 2)]?.contains(aprilSchedule) ?? + false), + ); + + expect(loadedDates, [selectedDate, DateTime(2026, 4, 1)]); + expect(extendedState.startDate, DateTime(2026, 3, 1)); + expect(extendedState.endDate, DateTime(2026, 5, 1)); + }, + ); + + test('non-consecutive month replaces the subscription range', () async { + final loadedDates = []; + loadSchedulesForMonthUseCase = StubLoadSchedulesForMonthUseCase(( + date, + ) async { + loadedDates.add(date); + }); + + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(MonthlySchedulesSubscriptionRequested(date: selectedDate)); + await bloc.stream.firstWhere( + (state) => state.status == MonthlySchedulesStatus.success, + ); + + bloc.add(MonthlySchedulesMonthAdded(date: DateTime(2026, 7, 1))); + final reloadedState = await bloc.stream.firstWhere( + (state) => state.startDate == DateTime(2026, 7, 1), + ); + + expect(loadedDates, [selectedDate, DateTime(2026, 7, 1)]); + expect(reloadedState.endDate, DateTime(2026, 8, 1)); + }); + + test( + 'adjacent month load failure emits error without dropping schedules', + () async { + var shouldFail = false; + loadSchedulesForMonthUseCase = StubLoadSchedulesForMonthUseCase(( + date, + ) async { + if (shouldFail) { + throw Exception('network down'); + } + }); + + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(MonthlySchedulesSubscriptionRequested(date: selectedDate)); + final loadedState = await bloc.stream.firstWhere( + (state) => state.status == MonthlySchedulesStatus.success, + ); + + shouldFail = true; + bloc.add(MonthlySchedulesMonthAdded(date: DateTime(2026, 4, 1))); + final errorState = await bloc.stream.firstWhere( + (state) => state.status == MonthlySchedulesStatus.error, + ); + + expect(errorState.schedules, loadedState.schedules); + }, + ); + + test( + 'delete failure emits error state and keeps deleted schedule visible', + () async { + deleteScheduleUseCase = StubDeleteScheduleUseCase( + (_) async => throw Exception('cannot delete'), + ); + + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add(MonthlySchedulesVisibleDateChanged(date: selectedDate)); + bloc.add(MonthlySchedulesSubscriptionRequested(date: selectedDate)); + await bloc.stream.firstWhere( + (state) => + state.preparationDurationByScheduleId.containsKey(scheduleA.id) && + state.preparationDurationByScheduleId.containsKey(scheduleB.id), + ); + + bloc.add(MonthlySchedulesScheduleDeleted(schedule: scheduleA)); + final errorState = await bloc.stream.firstWhere( + (state) => state.status == MonthlySchedulesStatus.error, + ); + + expect(errorState.lastDeletedSchedule, scheduleA); + expect( + errorState.preparationDurationByScheduleId.containsKey(scheduleA.id), + isFalse, + ); + }, + ); + + test( + 'preparation prefetch ignores failed schedule preparation loads', + () async { + loadPreparationByScheduleIdUseCase = + StubLoadPreparationByScheduleIdUseCase((scheduleId) async { + if (scheduleId == scheduleA.id) { + throw Exception('missing preparation'); + } + }); + + final bloc = buildBloc(); + addTearDown(bloc.close); + + bloc.add( + MonthlySchedulesPreparationsPrefetchRequested( + scheduleIds: [scheduleA.id, scheduleB.id], + ), + ); + + final prefetchedState = await bloc.stream.firstWhere( + (state) => + state.preparationDurationByScheduleId.containsKey(scheduleB.id), + ); + + expect( + prefetchedState.preparationDurationByScheduleId[scheduleA.id], + isNull, + ); + expect( + prefetchedState.preparationDurationByScheduleId[scheduleB.id], + const Duration(minutes: 15), + ); + }, + ); + + test('events and state expose value semantics used by the UI', () { + final refresh = MonthlySchedulesRefreshRequested( + date: DateTime(2026, 3, 20, 12), + ); + final visible = MonthlySchedulesVisibleDateChanged( + date: DateTime(2026, 3, 20, 12), + ); + + expect( + MonthlySchedulesSubscriptionRequested(date: DateTime(2026, 3, 20)).props, + [DateTime(2026, 3, 1), DateTime(2026, 4, 1)], + ); + expect(MonthlySchedulesMonthAdded(date: DateTime(2026, 3, 20)).props, [ + DateTime(2026, 3, 20), + ]); + expect( + MonthlySchedulesMonthAdded(date: DateTime(2026, 12, 31)).startDate, + DateTime(2026, 12, 1), + ); + expect( + MonthlySchedulesMonthAdded(date: DateTime(2026, 12, 31)).endDate, + DateTime(2027, 1, 1), + ); + expect(refresh.props, [2026, 3]); + expect(visible.props, [2026, 3, 20]); + expect(MonthlySchedulesScheduleDeleted(schedule: scheduleA).props, [ + scheduleA, + ]); + expect( + MonthlySchedulesPreparationsPrefetchRequested( + scheduleIds: [scheduleA.id], + ).props, + [ + [scheduleA.id], + ], + ); + expect( + MonthlySchedulesPreparationsStreamChanged( + preparations: const { + 'schedule-a': PreparationEntity(preparationStepList: []), + }, + ).props, + [ + const {'schedule-a': PreparationEntity(preparationStepList: [])}, + ], + ); + + final state = MonthlySchedulesState( + status: MonthlySchedulesStatus.loading, + schedules: { + selectedDate: [scheduleA], + }, + preparationDurationByScheduleId: { + scheduleA.id: const Duration(minutes: 10), + }, + lastDeletedSchedule: scheduleB, + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 4, 1), + visibleDate: selectedDate, + ); + final copied = state.copyWith( + status: () => MonthlySchedulesStatus.success, + preparationDurationByScheduleId: () => const {}, + lastDeletedSchedule: () => null, + ); + + expect(copied.status, MonthlySchedulesStatus.success); + expect(copied.schedules, state.schedules); + expect(copied.preparationDurationByScheduleId, isEmpty); + expect(copied.lastDeletedSchedule, isNull); + expect(copied.startDate, state.startDate); + expect(copied.endDate, state.endDate); + expect(copied.visibleDate, state.visibleDate); + }); } diff --git a/test/presentation/calendar/screens/calendar_screen_test.dart b/test/presentation/calendar/screens/calendar_screen_test.dart index 1ec1ade9..d177db68 100644 --- a/test/presentation/calendar/screens/calendar_screen_test.dart +++ b/test/presentation/calendar/screens/calendar_screen_test.dart @@ -21,7 +21,9 @@ import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; import 'package:on_time_front/presentation/calendar/bloc/monthly_schedules_bloc.dart'; import 'package:on_time_front/presentation/calendar/screens/calendar_screen.dart'; +import 'package:on_time_front/presentation/shared/components/calendar/centered_calendar_header.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import 'package:table_calendar/table_calendar.dart'; class _FakeSvgAssetBundle extends CachingAssetBundle { static const _svg = @@ -36,8 +38,18 @@ class _FakeSvgAssetBundle extends CachingAssetBundle { class _StubLoadSchedulesForMonthUseCase implements LoadSchedulesForMonthUseCase { + _StubLoadSchedulesForMonthUseCase({this.throwOnCall = false}); + + final bool throwOnCall; + final calls = []; + @override - Future call(DateTime date) async {} + Future call(DateTime date) async { + calls.add(date); + if (throwOnCall) { + throw Exception('month unavailable'); + } + } } class _StubGetSchedulesByDateUseCase implements GetSchedulesByDateUseCase { @@ -52,8 +64,12 @@ class _StubGetSchedulesByDateUseCase implements GetSchedulesByDateUseCase { } class _StubDeleteScheduleUseCase implements DeleteScheduleUseCase { + final deletedSchedules = []; + @override - Future call(ScheduleEntity schedule) async {} + Future call(ScheduleEntity schedule) async { + deletedSchedules.add(schedule); + } } class _StubLoadPreparationByScheduleIdUseCase @@ -105,12 +121,17 @@ void main() { required DateTime initialDate, List schedules = const [], double textScale = 1.0, + _StubLoadSchedulesForMonthUseCase? loadSchedulesForMonthUseCase, + _StubDeleteScheduleUseCase? deleteScheduleUseCase, + CalendarCreateSheetBuilder? createSheetBuilder, }) async { + final loadUseCase = + loadSchedulesForMonthUseCase ?? _StubLoadSchedulesForMonthUseCase(); getIt.registerFactory( () => MonthlySchedulesBloc( - _StubLoadSchedulesForMonthUseCase(), + loadUseCase, _StubGetSchedulesByDateUseCase(schedules), - _StubDeleteScheduleUseCase(), + deleteScheduleUseCase ?? _StubDeleteScheduleUseCase(), _StubLoadPreparationByScheduleIdUseCase(), _StubGetPreparationByScheduleIdUseCase(), _StubStreamPreparationsUseCase(), @@ -137,7 +158,10 @@ void main() { ), child: BlocProvider.value( value: _StubScheduleBloc(), - child: CalendarScreen(initialDate: initialDate), + child: CalendarScreen( + initialDate: initialDate, + createSheetBuilder: createSheetBuilder, + ), ), ), ), @@ -146,8 +170,9 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('compact future empty state fits with add button', - (tester) async { + testWidgets('compact future empty state fits with add button', ( + tester, + ) async { final futureDate = DateTime.now().add(const Duration(days: 7)); await pumpCalendarScreen( @@ -177,8 +202,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('compact selected day with schedules scrolls without overflow', - (tester) async { + testWidgets('compact selected day with schedules scrolls without overflow', ( + tester, + ) async { final selectedDate = DateTime.now().add(const Duration(days: 7)); final schedule = ScheduleEntity( id: 'schedule-1', @@ -210,8 +236,136 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('android system back from calendar returns to home', - (tester) async { + testWidgets('initial date before supported range is clamped to first month', ( + tester, + ) async { + final loadUseCase = _StubLoadSchedulesForMonthUseCase(); + + await pumpCalendarScreen( + tester, + size: const Size(390, 844), + initialDate: DateTime(2024, 1, 15), + loadSchedulesForMonthUseCase: loadUseCase, + ); + + expect(find.text('December 2024'), findsOneWidget); + expect(loadUseCase.calls.single, DateTime(2024, 12, 1)); + }); + + testWidgets('calendar header arrows move the visible month', (tester) async { + await pumpCalendarScreen( + tester, + size: const Size(390, 844), + initialDate: DateTime(2026, 1, 15), + ); + + expect(find.text('January 2026'), findsOneWidget); + + final headerButtons = find.descendant( + of: find.byType(CenteredCalendarHeader), + matching: find.byType(IconButton), + ); + + await tester.tap(headerButtons.at(1)); + await tester.pumpAndSettle(); + + expect(find.text('February 2026'), findsOneWidget); + expect(find.text('February 1'), findsOneWidget); + + await tester.tap(headerButtons.at(0)); + await tester.pumpAndSettle(); + + expect(find.text('January 2026'), findsOneWidget); + expect(find.text('January 1'), findsOneWidget); + }); + + testWidgets('selecting another day updates the detail list for that day', ( + tester, + ) async { + final firstDay = DateTime(2026, 1, 15); + final secondDay = DateTime(2026, 1, 20); + final schedules = [ + _schedule(id: 'first', name: 'Initial day appointment', date: firstDay), + _schedule( + id: 'second', + name: 'Selected day appointment', + date: secondDay, + ), + ]; + + await pumpCalendarScreen( + tester, + size: const Size(390, 844), + initialDate: firstDay, + schedules: schedules, + ); + + expect(find.text('Initial day appointment'), findsOneWidget); + expect(find.text('Selected day appointment'), findsNothing); + + await tester.tap(find.text('20').first); + await tester.pumpAndSettle(); + + expect(find.text('January 20'), findsOneWidget); + expect(find.text('Initial day appointment'), findsNothing); + expect(find.text('Selected day appointment'), findsOneWidget); + }); + + testWidgets('saved create sheet refreshes the selected calendar date', ( + tester, + ) async { + final selectedDate = DateTime.now().add(const Duration(days: 7)); + final loadUseCase = _StubLoadSchedulesForMonthUseCase(); + DateTime? sheetInitialDate; + + await pumpCalendarScreen( + tester, + size: const Size(390, 844), + initialDate: selectedDate, + loadSchedulesForMonthUseCase: loadUseCase, + createSheetBuilder: (context, initialDate) { + sheetInitialDate = initialDate; + return Material( + child: TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Save new appointment'), + ), + ); + }, + ); + + await tester.tap(find.text('Add appointment')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save new appointment')); + await tester.pumpAndSettle(); + + expect( + sheetInitialDate, + DateTime(selectedDate.year, selectedDate.month, selectedDate.day), + ); + expect( + loadUseCase.calls.last, + DateTime(selectedDate.year, selectedDate.month, selectedDate.day), + ); + }); + + testWidgets('month load failures show calendar error state', (tester) async { + await pumpCalendarScreen( + tester, + size: const Size(390, 844), + initialDate: DateTime(2026, 1, 15), + loadSchedulesForMonthUseCase: _StubLoadSchedulesForMonthUseCase( + throwOnCall: true, + ), + ); + + expect(find.text('Error'), findsOneWidget); + expect(find.byType(TableCalendar), findsNothing); + }); + + testWidgets('android system back from calendar returns to home', ( + tester, + ) async { getIt.registerFactory( () => MonthlySchedulesBloc( _StubLoadSchedulesForMonthUseCase(), @@ -233,9 +387,8 @@ void main() { routes: [ GoRoute( path: '/home', - builder: (context, state) => const Scaffold( - body: Text('Home Screen'), - ), + builder: (context, state) => + const Scaffold(body: Text('Home Screen')), ), GoRoute( path: '/calendar', @@ -263,4 +416,72 @@ void main() { expect(find.byType(CalendarScreen), findsNothing); expect(find.text('Home Screen'), findsOneWidget); }); + + testWidgets('app bar back button returns to home', (tester) async { + getIt.registerFactory( + () => MonthlySchedulesBloc( + _StubLoadSchedulesForMonthUseCase(), + _StubGetSchedulesByDateUseCase(const []), + _StubDeleteScheduleUseCase(), + _StubLoadPreparationByScheduleIdUseCase(), + _StubGetPreparationByScheduleIdUseCase(), + _StubStreamPreparationsUseCase(), + ), + ); + + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final router = GoRouter( + initialLocation: '/calendar', + routes: [ + GoRoute( + path: '/home', + builder: (context, state) => + const Scaffold(body: Text('Home Screen')), + ), + GoRoute( + path: '/calendar', + builder: (context, state) => const CalendarScreen(), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + MaterialApp.router( + theme: themeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + + expect(find.byType(CalendarScreen), findsNothing); + expect(find.text('Home Screen'), findsOneWidget); + }); +} + +ScheduleEntity _schedule({ + required String id, + required String name, + required DateTime date, +}) { + return ScheduleEntity( + id: id, + place: const PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: name, + scheduleTime: DateTime(date.year, date.month, date.day, 9), + moveTime: const Duration(minutes: 30), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 10), + scheduleNote: '', + ); } diff --git a/test/presentation/early_late/bloc/early_late_screen_bloc_test.dart b/test/presentation/early_late/bloc/early_late_screen_bloc_test.dart index f35f9e93..3dd2a1a7 100644 --- a/test/presentation/early_late/bloc/early_late_screen_bloc_test.dart +++ b/test/presentation/early_late/bloc/early_late_screen_bloc_test.dart @@ -30,5 +30,81 @@ void main() { expect(state.earlylateMessage, isNotEmpty); expect(state.earlylateImage, isNotEmpty); }); + + test( + 'loaded checklist replaces only checklist while preserving message', + () async { + final bloc = EarlyLateScreenBloc(); + addTearDown(bloc.close); + + bloc.add(const LoadEarlyLateInfo(earlyLateTime: 5 * 60)); + await Future.delayed(Duration.zero); + final loaded = bloc.state as EarlyLateScreenLoadSuccess; + + bloc.add(const ChecklistLoaded(checklist: [true, false, true])); + await Future.delayed(Duration.zero); + + final state = bloc.state as EarlyLateScreenLoadSuccess; + expect(state.checklist, [true, false, true]); + expect(state.isLate, loaded.isLate); + expect(state.earlylateMessage, loaded.earlylateMessage); + expect(state.earlylateImage, loaded.earlylateImage); + }, + ); + + test('toggle checklist item flips one item and keeps the others', () async { + final bloc = EarlyLateScreenBloc(); + addTearDown(bloc.close); + + bloc.add(const LoadEarlyLateInfo(earlyLateTime: 5 * 60)); + await Future.delayed(Duration.zero); + bloc.add(const ChecklistLoaded(checklist: [false, false, true])); + await Future.delayed(Duration.zero); + + bloc.add(const ChecklistItemToggled(1)); + await Future.delayed(Duration.zero); + + final state = bloc.state as EarlyLateScreenLoadSuccess; + expect(state.checklist, [false, true, true]); + }); + + test( + 'checklist events are ignored until early-late info is loaded', + () async { + final bloc = EarlyLateScreenBloc(); + addTearDown(bloc.close); + + bloc.add(const ChecklistLoaded(checklist: [true, true, true])); + bloc.add(const ChecklistItemToggled(0)); + await Future.delayed(Duration.zero); + + expect(bloc.state, isA()); + }, + ); + + test('events and states compare by their public fields', () { + expect( + const LoadEarlyLateInfo(earlyLateTime: 60), + const LoadEarlyLateInfo(earlyLateTime: 60), + ); + expect(const ChecklistLoaded(checklist: [true, false]).props, [ + [true, false], + ]); + expect(const ChecklistItemToggled(2).props, [2]); + expect( + const EarlyLateScreenLoadSuccess( + checklist: [false, true], + isLate: false, + earlylateMessage: 'Ready', + earlylateImage: 'character.svg', + ).props, + [ + [false, true], + false, + 'Ready', + 'character.svg', + ], + ); + }); }); } diff --git a/test/presentation/event_value_objects_test.dart b/test/presentation/event_value_objects_test.dart new file mode 100644 index 00000000..45e4895d --- /dev/null +++ b/test/presentation/event_value_objects_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/presentation/home/bloc/schedule_timer_bloc.dart'; +import 'package:on_time_front/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart'; + +void main() { + test('default preparation edit events compare user-edited fields', () { + const preparation = PreparationEntity(preparationStepList: []); + + expect(const DefaultPreparationSpareTimeFormEvent().props, isEmpty); + expect(const FormEditRequested(spareTime: Duration(minutes: 7)).props, [ + const Duration(minutes: 7), + ]); + expect(const SpareTimeIncreased().props, isEmpty); + expect(const SpareTimeDecreased().props, isEmpty); + expect( + const FormSubmitted(note: 'Bring bag', preparation: preparation).props, + ['Bring bag'], + ); + }); + + test('schedule timer events compare schedule and tick times', () { + final scheduleTime = DateTime.utc(2026, 5, 15, 9); + final currentTime = DateTime.utc(2026, 5, 15, 8, 30); + + expect(ScheduleTimerStarted(scheduleTime).props, [scheduleTime]); + expect(ScheduleTimerTicked(currentTime).props, [currentTime]); + expect(const ScheduleTimerStopped().props, isEmpty); + expect(ScheduleTimerUpdated(scheduleTime).props, [scheduleTime]); + expect(const ScheduleTimerUpdated(null).props, [null]); + }); +} diff --git a/test/presentation/home/bloc/schedule_timer_bloc_test.dart b/test/presentation/home/bloc/schedule_timer_bloc_test.dart new file mode 100644 index 00000000..8ed6b9f6 --- /dev/null +++ b/test/presentation/home/bloc/schedule_timer_bloc_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/presentation/home/bloc/schedule_timer_bloc.dart'; + +void main() { + test('started with a past schedule immediately finishes', () async { + final bloc = ScheduleTimerBloc(); + addTearDown(bloc.close); + final scheduleTime = DateTime.now().subtract(const Duration(minutes: 1)); + + bloc.add(ScheduleTimerStarted(scheduleTime)); + + await expectLater( + bloc.stream, + emits( + isA().having( + (state) => state.scheduleTime, + 'scheduleTime', + scheduleTime, + ), + ), + ); + }); + + test( + 'tick before the schedule keeps timer running with remaining duration', + () async { + final bloc = ScheduleTimerBloc(); + addTearDown(bloc.close); + final scheduleTime = DateTime.now().add(const Duration(hours: 1)); + final tickTime = scheduleTime.subtract(const Duration(minutes: 10)); + + bloc.add(ScheduleTimerStarted(scheduleTime)); + await expectLater(bloc.stream, emits(isA())); + + bloc.add(ScheduleTimerTicked(tickTime)); + + await expectLater( + bloc.stream, + emits( + isA() + .having( + (state) => state.scheduleTime, + 'scheduleTime', + scheduleTime, + ) + .having((state) => state.currentTime, 'currentTime', tickTime) + .having( + (state) => state.remainingDuration, + 'remainingDuration', + const Duration(minutes: 10), + ), + ), + ); + }, + ); + + test( + 'tick at the schedule time finishes and preserves target schedule', + () async { + final bloc = ScheduleTimerBloc(); + addTearDown(bloc.close); + final scheduleTime = DateTime.now().add(const Duration(hours: 1)); + + bloc.add(ScheduleTimerStarted(scheduleTime)); + await expectLater(bloc.stream, emits(isA())); + + bloc.add(ScheduleTimerTicked(scheduleTime)); + + await expectLater( + bloc.stream, + emits( + isA().having( + (state) => state.scheduleTime, + 'scheduleTime', + scheduleTime, + ), + ), + ); + }, + ); + + test('stopped returns to initial and ignores later ticks', () async { + final bloc = ScheduleTimerBloc(); + addTearDown(bloc.close); + final scheduleTime = DateTime.now().add(const Duration(hours: 1)); + + bloc.add(ScheduleTimerStarted(scheduleTime)); + await expectLater(bloc.stream, emits(isA())); + + bloc.add(const ScheduleTimerStopped()); + + await expectLater(bloc.stream, emits(const ScheduleTimerInitial())); + + bloc.add(ScheduleTimerTicked(scheduleTime)); + await pumpEventQueue(); + + expect(bloc.state, const ScheduleTimerInitial()); + }); + + test('updated with null clears the active timer', () async { + final bloc = ScheduleTimerBloc(); + addTearDown(bloc.close); + final scheduleTime = DateTime.now().add(const Duration(hours: 1)); + + bloc.add(ScheduleTimerStarted(scheduleTime)); + await expectLater(bloc.stream, emits(isA())); + + bloc.add(const ScheduleTimerUpdated(null)); + + await expectLater(bloc.stream, emits(const ScheduleTimerInitial())); + }); + + test('updated with a schedule starts a new timer', () async { + final bloc = ScheduleTimerBloc(); + addTearDown(bloc.close); + final scheduleTime = DateTime.now().add(const Duration(hours: 1)); + + bloc.add(ScheduleTimerUpdated(scheduleTime)); + + await expectLater( + bloc.stream, + emits( + isA().having( + (state) => state.scheduleTime, + 'scheduleTime', + scheduleTime, + ), + ), + ); + }); + +} diff --git a/test/presentation/home/bloc/weekly_schedules_bloc_test.dart b/test/presentation/home/bloc/weekly_schedules_bloc_test.dart index 6e53b01c..38b483b6 100644 --- a/test/presentation/home/bloc/weekly_schedules_bloc_test.dart +++ b/test/presentation/home/bloc/weekly_schedules_bloc_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/use-cases/get_schedules_by_date_use_case.dart'; import 'package:on_time_front/domain/use-cases/load_schedules_for_week_use_case.dart'; @@ -13,13 +14,94 @@ class StubLoadSchedulesForWeekUseCase implements LoadSchedulesForWeekUseCase { } class StubGetSchedulesByDateUseCase implements GetSchedulesByDateUseCase { + StubGetSchedulesByDateUseCase([this.schedules = const []]); + + final List schedules; + final calls = <(DateTime, DateTime)>[]; + @override Stream> call(DateTime startDate, DateTime endDate) { - return const Stream.empty(); + calls.add((startDate, endDate)); + return Stream.value(schedules); } } void main() { + test('state exposes schedule dates, today schedule, and copy updates', () { + final now = DateTime.now(); + final laterToday = _schedule( + id: 'later', + scheduleTime: DateTime(now.year, now.month, now.day, 18), + ); + final earlierToday = _schedule( + id: 'earlier', + scheduleTime: DateTime(now.year, now.month, now.day, 9), + ); + final endedToday = _schedule( + id: 'ended', + scheduleTime: DateTime(now.year, now.month, now.day, 8), + doneStatus: ScheduleDoneStatus.normalEnd, + ); + final tomorrow = _schedule( + id: 'tomorrow', + scheduleTime: DateTime(now.year, now.month, now.day + 1, 9), + ); + + final state = WeeklySchedulesState( + status: WeeklySchedulesStatus.success, + schedules: [laterToday, endedToday, tomorrow, earlierToday], + ); + + expect(state.dates, [ + laterToday.scheduleTime, + endedToday.scheduleTime, + tomorrow.scheduleTime, + earlierToday.scheduleTime, + ]); + expect(state.todaySchedule, earlierToday); + expect( + state.copyWith(status: () => WeeklySchedulesStatus.loading).status, + WeeklySchedulesStatus.loading, + ); + expect(state.copyWith(schedules: () => [tomorrow]).schedules, [tomorrow]); + expect(state.props, [ + WeeklySchedulesStatus.success, + laterToday, + endedToday, + tomorrow, + earlierToday, + ]); + }); + + test('weekly subscription event derives Monday-to-Monday range', () { + final event = WeeklySchedulesSubscriptionRequested( + date: DateTime(2026, 5, 15, 18), + ); + + expect(event.startDate, DateTime(2026, 5, 11, 18)); + expect(event.endDate, DateTime(2026, 5, 18, 18)); + expect(event.props, [DateTime(2026, 5, 11, 18), DateTime(2026, 5, 18, 18)]); + }); + + test('today schedule is null when no not-ended schedule is today', () { + final now = DateTime.now(); + final state = WeeklySchedulesState( + schedules: [ + _schedule( + id: 'ended', + scheduleTime: DateTime(now.year, now.month, now.day, 8), + doneStatus: ScheduleDoneStatus.normalEnd, + ), + _schedule( + id: 'tomorrow', + scheduleTime: DateTime(now.year, now.month, now.day + 1, 9), + ), + ], + ); + + expect(state.todaySchedule, isNull); + }); + test('load failure emits error state instead of throwing', () async { final bloc = WeeklySchedulesBloc( StubLoadSchedulesForWeekUseCase( @@ -29,9 +111,7 @@ void main() { ); addTearDown(bloc.close); - bloc.add( - WeeklySchedulesSubscriptionRequested(date: DateTime(2026, 5, 5)), - ); + bloc.add(WeeklySchedulesSubscriptionRequested(date: DateTime(2026, 5, 5))); await expectLater( bloc.stream, @@ -44,4 +124,59 @@ void main() { ), ); }); + + test( + 'successful subscription loads week then emits streamed schedules', + () async { + final schedule = _schedule( + id: 'meeting', + scheduleTime: DateTime(2026, 5, 12, 9), + ); + final loadedWeeks = []; + final getSchedulesByDateUseCase = StubGetSchedulesByDateUseCase([ + schedule, + ]); + final bloc = WeeklySchedulesBloc( + StubLoadSchedulesForWeekUseCase((date) async { + loadedWeeks.add(date); + }), + getSchedulesByDateUseCase, + ); + addTearDown(bloc.close); + + bloc.add( + WeeklySchedulesSubscriptionRequested(date: DateTime(2026, 5, 15)), + ); + + final state = await bloc.stream.firstWhere( + (state) => state.status == WeeklySchedulesStatus.success, + ); + + expect(loadedWeeks, [DateTime(2026, 5, 15)]); + expect(getSchedulesByDateUseCase.calls.single, ( + DateTime(2026, 5, 11), + DateTime(2026, 5, 18), + )); + expect(state.schedules, [schedule]); + }, + ); +} + +ScheduleEntity _schedule({ + required String id, + required DateTime scheduleTime, + ScheduleDoneStatus doneStatus = ScheduleDoneStatus.notEnded, +}) { + return ScheduleEntity( + id: id, + place: const PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: id, + scheduleTime: scheduleTime, + moveTime: const Duration(minutes: 10), + isChanged: false, + isStarted: false, + scheduleSpareTime: null, + scheduleNote: '', + doneStatus: doneStatus, + ); } diff --git a/test/presentation/home/components/month_calendar_test.dart b/test/presentation/home/components/month_calendar_test.dart new file mode 100644 index 00000000..68c5c45e --- /dev/null +++ b/test/presentation/home/components/month_calendar_test.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/calendar/bloc/monthly_schedules_bloc.dart'; +import 'package:on_time_front/presentation/home/components/month_calendar.dart'; +import 'package:on_time_front/presentation/shared/components/calendar/centered_calendar_header.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +void main() { + testWidgets('selecting a day reports the selected calendar date', ( + tester, + ) async { + DateTime? selected; + final targetDate = DateTime.now().add(const Duration(days: 2)); + + await tester.pumpWidget( + _TestApp( + child: MonthCalendar( + dispatchBlocEvents: false, + monthlySchedulesState: const MonthlySchedulesState( + status: MonthlySchedulesStatus.success, + ), + onDateSelected: (date) => selected = date, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text(targetDate.day.toString()).last); + await tester.pumpAndSettle(); + + expect( + selected, + DateTime(targetDate.year, targetDate.month, targetDate.day), + ); + }); + + testWidgets('scheduled days render without hiding the selected day', ( + tester, + ) async { + final scheduledDay = DateTime.now().add(const Duration(days: 3)); + + await tester.pumpWidget( + _TestApp( + child: MonthCalendar( + dispatchBlocEvents: false, + monthlySchedulesState: MonthlySchedulesState( + status: MonthlySchedulesStatus.success, + schedules: { + DateTime(scheduledDay.year, scheduledDay.month, scheduledDay.day): + [_schedule(scheduledDay)], + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text(scheduledDay.day.toString()), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets('compact height clamps calendar rows without overflow', ( + tester, + ) async { + await tester.pumpWidget( + _TestApp( + child: SizedBox( + height: 260, + child: MonthCalendar( + dispatchBlocEvents: false, + rowHeight: 80, + monthlySchedulesState: const MonthlySchedulesState( + status: MonthlySchedulesStatus.success, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(MonthCalendar), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('header arrows move between months and request month loads', ( + tester, + ) async { + final bloc = _RecordingMonthlySchedulesBloc(); + final now = DateTime.now(); + final previousMonth = DateTime(now.year, now.month - 1, 1); + final nextMonth = DateTime(now.year, now.month, 1); + + await tester.pumpWidget( + _TestApp( + bloc: bloc, + child: MonthCalendar( + monthlySchedulesState: const MonthlySchedulesState( + status: MonthlySchedulesStatus.success, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final header = find.byType(CenteredCalendarHeader); + expect(header, findsOneWidget); + + await tester.tap( + find.descendant(of: header, matching: find.byType(IconButton)).first, + ); + await tester.pumpAndSettle(); + + expect( + bloc.addedEvents, + contains( + isA().having( + (event) => event.date, + 'date', + previousMonth, + ), + ), + ); + + await tester.tap( + find.descendant(of: header, matching: find.byType(IconButton)).last, + ); + await tester.pumpAndSettle(); + + expect( + bloc.addedEvents, + contains( + isA().having( + (event) => event.date, + 'date', + nextMonth, + ), + ), + ); + }); + + testWidgets('unbounded vertical layout keeps the configured row height', ( + tester, + ) async { + await tester.pumpWidget( + _TestApp( + child: SingleChildScrollView( + child: MonthCalendar( + dispatchBlocEvents: false, + rowHeight: 42, + monthlySchedulesState: const MonthlySchedulesState( + status: MonthlySchedulesStatus.success, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(MonthCalendar), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +class _TestApp extends StatelessWidget { + const _TestApp({required this.child, this.bloc}); + + final Widget child; + final MonthlySchedulesBloc? bloc; + + @override + Widget build(BuildContext context) { + final app = MaterialApp( + theme: themeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ); + + final bloc = this.bloc; + if (bloc == null) { + return app; + } + + return MaterialApp( + theme: themeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: BlocProvider.value( + value: bloc, + child: Scaffold(body: child), + ), + ); + } +} + +class _RecordingMonthlySchedulesBloc extends Mock + implements MonthlySchedulesBloc { + final addedEvents = []; + + @override + void add(MonthlySchedulesEvent event) { + addedEvents.add(event); + } + + @override + MonthlySchedulesState get state => + const MonthlySchedulesState(status: MonthlySchedulesStatus.success); + + @override + Stream get stream => const Stream.empty(); + + @override + bool get isClosed => false; +} + +ScheduleEntity _schedule(DateTime date) { + return ScheduleEntity( + id: 'schedule-1', + place: const PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: 'Meeting', + scheduleTime: date, + moveTime: const Duration(minutes: 10), + isChanged: false, + isStarted: false, + scheduleSpareTime: null, + scheduleNote: '', + ); +} diff --git a/test/presentation/home/components/week_calendar_test.dart b/test/presentation/home/components/week_calendar_test.dart new file mode 100644 index 00000000..303a698b --- /dev/null +++ b/test/presentation/home/components/week_calendar_test.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/presentation/home/components/week_calendar.dart'; + +void main() { + test('firstDayOfWeek resolves to Monday for the selected week', () { + final calendar = WeekCalendar( + date: DateTime(2026, 5, 15), + highlightedDates: const [], + onDateSelected: (_) {}, + ); + + expect(calendar.firstDayOfWeek, DateTime.utc(2026, 5, 11)); + }); + + testWidgets('renders one week and reports the tapped date', (tester) async { + DateTime? selectedDate; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: [DateTileThemeData(style: _dateTileStyle())], + ), + home: Scaffold( + body: WeekCalendar( + date: DateTime(2026, 5, 15), + highlightedDates: [DateTime(2026, 5, 13)], + onDateSelected: (date) => selectedDate = date, + ), + ), + ), + ); + + expect(find.text('월'), findsOneWidget); + expect(find.text('일'), findsOneWidget); + expect(find.text('11'), findsOneWidget); + expect(find.text('17'), findsOneWidget); + + await tester.tap(find.text('13')); + await tester.pump(); + + expect(selectedDate, DateTime.utc(2026, 5, 13)); + }); + + testWidgets('disabled date tile does not invoke tap callback', ( + tester, + ) async { + var tapCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DateTile( + date: DateTime(2026, 5, 15), + onTap: null, + style: _dateTileStyle(), + ), + ), + ), + ); + + await tester.tap(find.text('15')); + await tester.pump(); + + expect(tapCount, 0); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DateTile( + date: DateTime(2026, 5, 15), + onTap: () => tapCount += 1, + style: _dateTileStyle(), + ), + ), + ), + ); + + await tester.tap(find.text('15')); + await tester.pump(); + + expect(tapCount, 1); + }); + + testWidgets('date tile theme supplies visual style defaults', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: [DateTileThemeData(style: _dateTileStyle())], + ), + home: Scaffold( + body: DateTile.outlined(date: DateTime(2026, 5, 16), onTap: () {}), + ), + ), + ); + + final material = tester.widget(find.byType(Material).last); + expect(material.color, Colors.yellow); + expect(material.textStyle!.color, Colors.black); + }); + + testWidgets('filled date tile uses built-in selected-day colors', ( + tester, + ) async { + final colorScheme = ColorScheme.fromSeed(seedColor: Colors.indigo); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: colorScheme, + extensions: [ + DateTileThemeData( + style: DateTileStyle( + textStyle: WidgetStateProperty.all( + const TextStyle(fontSize: 16), + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + home: Scaffold( + body: DateTile.filled(date: DateTime(2026, 5, 15), onTap: () {}), + ), + ), + ); + + final material = tester.widget(find.byType(Material).last); + + expect(material.color, colorScheme.primary); + expect(material.textStyle!.color, colorScheme.onPrimary); + }); + + test('date tile style copy, merge, equality, and lerp are stable', () { + final base = _dateTileStyle(backgroundColor: Colors.white); + final override = _dateTileStyle( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + sideColor: Colors.red, + ); + + final copied = base.copyWith( + backgroundColor: WidgetStateProperty.all(Colors.green), + ); + final merged = base.merge(override); + final lerped = DateTileStyle.lerp(base, override, 0.5)!; + final themeData = DateTileThemeData(style: base); + + expect(copied.backgroundColor!.resolve({}), Colors.green); + expect(merged.backgroundColor!.resolve({}), Colors.white); + expect(merged.forgroundColor!.resolve({}), Colors.black); + expect(lerped.backgroundColor!.resolve({}), isA()); + expect(DateTileStyle.lerp(base, base, 0.5), same(base)); + expect( + DateTileStyle.lerp(const DateTileStyle(), const DateTileStyle(), 0.5), + isA(), + ); + expect(themeData.copyWith(), DateTileThemeData(style: base)); + expect( + themeData.lerp(DateTileThemeData(style: override), 0.5), + isA(), + ); + expect(themeData.hashCode, DateTileThemeData(style: base).hashCode); + }); +} + +DateTileStyle _dateTileStyle({ + Color backgroundColor = Colors.yellow, + Color foregroundColor = Colors.black, + Color sideColor = Colors.blue, +}) { + return DateTileStyle( + textStyle: WidgetStateProperty.all(const TextStyle(fontSize: 16)), + backgroundColor: WidgetStateProperty.all(backgroundColor), + forgroundColor: WidgetStateProperty.all(foregroundColor), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + side: WidgetStateProperty.all(BorderSide(color: sideColor)), + ); +} diff --git a/test/presentation/home/screens/home_screen_tmp_test.dart b/test/presentation/home/screens/home_screen_tmp_test.dart index 1db7473a..bf3a8f59 100644 --- a/test/presentation/home/screens/home_screen_tmp_test.dart +++ b/test/presentation/home/screens/home_screen_tmp_test.dart @@ -5,17 +5,27 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:mockito/mockito.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/get_schedules_by_date_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_schedules_for_month_use_case.dart'; +import 'package:on_time_front/domain/use-cases/stream_preparations_use_case.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; import 'package:on_time_front/presentation/home/components/todays_schedule_tile.dart'; import 'package:on_time_front/presentation/calendar/bloc/monthly_schedules_bloc.dart'; import 'package:on_time_front/presentation/home/screens/home_screen_tmp.dart'; +import 'package:on_time_front/presentation/shared/components/arc_indicator.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; class StubAuthBloc extends Mock implements AuthBloc { @@ -77,6 +87,14 @@ class StreamingScheduleBloc extends Mock implements ScheduleBloc { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() async { + await getIt.reset(); + }); + + tearDown(() async { + await getIt.reset(); + }); + Widget buildSubject({ required Size size, required ScheduleState scheduleState, @@ -128,8 +146,9 @@ void main() { ); } - testWidgets('compact portrait home fits without scroll at 1.3 text scale', - (tester) async { + testWidgets('compact portrait home fits without scroll at 1.3 text scale', ( + tester, + ) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(360, 640); addTearDown(tester.view.resetPhysicalSize); @@ -146,20 +165,83 @@ void main() { expect(find.byType(SingleChildScrollView), findsNothing); expect(tester.getSize(find.byKey(const Key('home_banner'))).width, 360); - expect(tester.getSize(find.byKey(const Key('today_schedule_card'))).width, - 328); + expect( + tester.getSize(find.byKey(const Key('today_schedule_card'))).width, + 328, + ); expect( tester.getSize(find.byKey(const Key('home_banner'))).height, closeTo(116, 1), ); expect(_verticalGap(tester, 'home_banner', 'today_schedule_card'), 0); expect( - _bottomGap(tester, 'home_month_calendar', 640), lessThanOrEqualTo(6)); + _bottomGap(tester, 'home_month_calendar', 640), + lessThanOrEqualTo(6), + ); expect(tester.takeException(), isNull); }); - testWidgets('compact portrait home fits when today schedule exists', - (tester) async { + testWidgets('HomeScreenTmp subscribes monthly bloc for today', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(360, 640); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final loadUseCase = _StubLoadSchedulesForMonthUseCase(); + getIt.registerFactory( + () => MonthlySchedulesBloc( + loadUseCase, + _StubGetSchedulesByDateUseCase(), + _StubDeleteScheduleUseCase(), + _StubLoadPreparationByScheduleIdUseCase(), + _StubGetPreparationByScheduleIdUseCase(), + _StubStreamPreparationsUseCase(), + ), + ); + final authBloc = StubAuthBloc( + AuthState( + user: UserEntity( + id: 'user-1', + name: 'Test User', + email: 'test@example.com', + spareTime: Duration.zero, + note: '', + score: 80, + isOnboardingCompleted: true, + ), + ), + ); + final scheduleBloc = StubScheduleBloc(const ScheduleState.notExists()); + + await tester.pumpWidget( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: scheduleBloc), + ], + child: MaterialApp( + theme: themeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const HomeScreenTmp(), + ), + ), + ); + await tester.pumpAndSettle(); + + final today = DateTime.now(); + expect(loadUseCase.calls, hasLength(1)); + expect( + loadUseCase.calls.single, + DateTime(today.year, today.month, today.day), + ); + expect(find.byKey(const Key('today_schedule_card')), findsOneWidget); + }); + + testWidgets('compact portrait home fits when today schedule exists', ( + tester, + ) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(360, 640); addTearDown(tester.view.resetPhysicalSize); @@ -176,12 +258,15 @@ void main() { expect(_verticalGap(tester, 'home_banner', 'today_schedule_card'), 0); expect( - _bottomGap(tester, 'home_month_calendar', 640), lessThanOrEqualTo(6)); + _bottomGap(tester, 'home_month_calendar', 640), + lessThanOrEqualTo(6), + ); expect(tester.takeException(), isNull); }); - testWidgets('regular portrait home matches Figma hero and card geometry', - (tester) async { + testWidgets('regular portrait home matches Figma hero and card geometry', ( + tester, + ) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(390, 844); addTearDown(tester.view.resetPhysicalSize); @@ -196,18 +281,23 @@ void main() { await tester.pump(); expect(tester.getSize(find.byKey(const Key('home_banner'))).width, 390); - expect(tester.getSize(find.byKey(const Key('today_schedule_card'))).width, - 358); - expect(tester.getSize(find.byKey(const Key('today_schedule_card'))).height, - 137); + expect( + tester.getSize(find.byKey(const Key('today_schedule_card'))).width, + 358, + ); + expect( + tester.getSize(find.byKey(const Key('today_schedule_card'))).height, + 137, + ); expect(_top(tester, 'home_banner'), closeTo(51, 1)); expect(_top(tester, 'today_schedule_card'), closeTo(177, 1)); expect(_top(tester, 'today_background_surface'), closeTo(230, 1)); expect(tester.takeException(), isNull); }); - testWidgets('banner clears the device safe area before rendering', - (tester) async { + testWidgets('banner clears the device safe area before rendering', ( + tester, + ) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(390, 844); addTearDown(tester.view.resetPhysicalSize); @@ -224,12 +314,15 @@ void main() { expect(_top(tester, 'home_banner'), greaterThanOrEqualTo(71)); expect( - _bottomGap(tester, 'home_month_calendar', 844), lessThanOrEqualTo(6)); + _bottomGap(tester, 'home_month_calendar', 844), + lessThanOrEqualTo(6), + ); expect(tester.takeException(), isNull); }); - testWidgets('today schedule tile truncates long schedule names', - (tester) async { + testWidgets('today schedule tile truncates long schedule names', ( + tester, + ) async { await tester.pumpWidget( MaterialApp( theme: themeData, @@ -257,15 +350,17 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('does not reopen schedule start on started-state ticks', - (tester) async { + testWidgets('does not reopen schedule start on started-state ticks', ( + tester, + ) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(360, 640); addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final scheduleBloc = - StreamingScheduleBloc(ScheduleState.started(_scheduleWithLongName())); + final scheduleBloc = StreamingScheduleBloc( + ScheduleState.started(_scheduleWithLongName()), + ); addTearDown(scheduleBloc.close); await tester.pumpWidget( @@ -286,6 +381,153 @@ void main() { expect(find.byKey(const Key('today_schedule_card')), findsOneWidget); expect(find.text('Schedule Start'), findsNothing); }); + + testWidgets('view calendar button navigates to calendar screen', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(360, 640); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final scheduleBloc = StreamingScheduleBloc(const ScheduleState.notExists()); + addTearDown(scheduleBloc.close); + + await tester.pumpWidget( + _buildRoutedSubject( + size: const Size(360, 640), + scheduleBloc: scheduleBloc, + ), + ); + await tester.pump(); + + await tester.tap(find.text('View calendar')); + await tester.pumpAndSettle(); + + expect(find.text('Calendar Route'), findsOneWidget); + }); + + testWidgets('today upcoming tile opens early-start schedule start route', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(360, 640); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final scheduleBloc = StreamingScheduleBloc( + ScheduleState.upcoming(_shortSchedule()), + ); + addTearDown(scheduleBloc.close); + + await tester.pumpWidget( + _buildRoutedSubject( + size: const Size(360, 640), + scheduleBloc: scheduleBloc, + ), + ); + await tester.pump(); + + await tester.tap(find.byKey(const Key('today_schedule_tile'))); + await tester.pumpAndSettle(); + + expect(find.text('Schedule Start:earlyStart'), findsOneWidget); + }); + + testWidgets('today ongoing tile opens active alarm route', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(360, 640); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final scheduleBloc = StreamingScheduleBloc( + ScheduleState.ongoing(_shortSchedule()), + ); + addTearDown(scheduleBloc.close); + + await tester.pumpWidget( + _buildRoutedSubject( + size: const Size(360, 640), + scheduleBloc: scheduleBloc, + ), + ); + await tester.pump(); + + await tester.tap(find.byKey(const Key('today_schedule_tile'))); + await tester.pumpAndSettle(); + + expect(find.text('Alarm Route'), findsOneWidget); + }); + + testWidgets('animated arc indicator reaches requested score', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: AnimatedArcIndicator( + score: 80, + child: const SizedBox(width: 100, height: 100), + ), + ), + ); + + await tester.pump(const Duration(seconds: 1)); + + final painter = + tester + .widget( + find + .descendant( + of: find.byType(AnimatedArcIndicator), + matching: find.byType(CustomPaint), + ) + .first, + ) + .painter + as ArcIndicator; + expect(painter.progress, closeTo(0.8, 0.01)); + }); +} + +class _StubLoadSchedulesForMonthUseCase + implements LoadSchedulesForMonthUseCase { + final calls = []; + + @override + Future call(DateTime date) async { + calls.add(date); + } +} + +class _StubGetSchedulesByDateUseCase implements GetSchedulesByDateUseCase { + @override + Stream> call(DateTime startDate, DateTime endDate) { + return Stream.value(const []); + } +} + +class _StubDeleteScheduleUseCase implements DeleteScheduleUseCase { + @override + Future call(ScheduleEntity schedule) async {} +} + +class _StubLoadPreparationByScheduleIdUseCase + implements LoadPreparationByScheduleIdUseCase { + @override + Future call(String scheduleId) async {} +} + +class _StubGetPreparationByScheduleIdUseCase + implements GetPreparationByScheduleIdUseCase { + @override + Future call(String scheduleId) async { + return const PreparationEntity(preparationStepList: []); + } +} + +class _StubStreamPreparationsUseCase implements StreamPreparationsUseCase { + @override + Stream> call() { + return const Stream.empty(); + } } Widget _buildRoutedSubject({ @@ -330,7 +572,22 @@ Widget _buildRoutedSubject({ GoRoute( path: '/scheduleStart', builder: (context, state) { - return const Scaffold(body: Text('Schedule Start')); + final extra = state.extra as Map?; + return Scaffold( + body: Text('Schedule Start:${extra?['promptVariant'] ?? ''}'), + ); + }, + ), + GoRoute( + path: '/calendar', + builder: (context, state) { + return const Scaffold(body: Text('Calendar Route')); + }, + ), + GoRoute( + path: '/alarmScreen', + builder: (context, state) { + return const Scaffold(body: Text('Alarm Route')); }, ), ], @@ -350,11 +607,7 @@ Widget _buildRoutedSubject({ ); } -double _verticalGap( - WidgetTester tester, - String upperKey, - String lowerKey, -) { +double _verticalGap(WidgetTester tester, String upperKey, String lowerKey) { final upperBox = tester.renderObject(find.byKey(Key(upperKey))); final lowerBox = tester.renderObject(find.byKey(Key(lowerKey))); final upperBottom = @@ -364,11 +617,7 @@ double _verticalGap( return lowerTop - upperBottom; } -double _bottomGap( - WidgetTester tester, - String key, - double screenHeight, -) { +double _bottomGap(WidgetTester tester, String key, double screenHeight) { final box = tester.renderObject(find.byKey(Key(key))); return screenHeight - (box.localToGlobal(Offset.zero).dy + box.size.height); } @@ -402,3 +651,27 @@ ScheduleWithPreparationEntity _scheduleWithLongName() { ), ); } + +ScheduleWithPreparationEntity _shortSchedule() { + return ScheduleWithPreparationEntity( + id: 'schedule-short', + place: PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: 'Standup', + scheduleTime: DateTime.now().add(const Duration(hours: 3)), + moveTime: const Duration(minutes: 20), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 10), + scheduleNote: '', + preparation: const PreparationWithTimeEntity( + preparationStepList: [ + PreparationStepWithTimeEntity( + id: 'prep-1', + preparationName: 'Get ready', + preparationTime: Duration(minutes: 15), + nextPreparationId: null, + ), + ], + ), + ); +} diff --git a/test/presentation/my_page/logout_modal_test.dart b/test/presentation/my_page/logout_modal_test.dart new file mode 100644 index 00000000..d083e9f2 --- /dev/null +++ b/test/presentation/my_page/logout_modal_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; +import 'package:on_time_front/presentation/my_page/my_page_modal/logout_modal.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +void main() { + testWidgets('confirming logout dispatches sign-out event', (tester) async { + final authBloc = _RecordingAuthBloc(); + + await _pumpSubject(tester, authBloc); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Do you want to log out?'), findsOneWidget); + + await tester.tap(find.text('Log out')); + await tester.pumpAndSettle(); + + expect(authBloc.events, [const AuthSignOutPressed()]); + }); + + testWidgets('canceling logout keeps auth bloc untouched', (tester) async { + final authBloc = _RecordingAuthBloc(); + + await _pumpSubject(tester, authBloc); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(authBloc.events, isEmpty); + expect(find.text('Do you want to log out?'), findsNothing); + }); +} + +Future _pumpSubject(WidgetTester tester, AuthBloc authBloc) async { + await tester.pumpWidget( + BlocProvider.value( + value: authBloc, + child: MaterialApp( + theme: themeData, + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + return Scaffold( + body: TextButton( + onPressed: () => showLogoutModal(context), + child: const Text('Open'), + ), + ); + }, + ), + ), + ), + ); +} + +class _RecordingAuthBloc extends Mock implements AuthBloc { + final events = []; + + @override + AuthState get state => AuthState( + user: const UserEntity( + id: 'user-1', + email: 'user@example.com', + name: 'User', + spareTime: Duration(minutes: 10), + note: '', + score: 4, + isOnboardingCompleted: true, + ), + ); + + @override + Stream get stream => const Stream.empty(); + + @override + bool get isClosed => false; + + @override + void add(AuthEvent event) { + events.add(event); + } +} diff --git a/test/presentation/my_page/my_page_screen_test.dart b/test/presentation/my_page/my_page_screen_test.dart index 9df16783..c8fe63e0 100644 --- a/test/presentation/my_page/my_page_screen_test.dart +++ b/test/presentation/my_page/my_page_screen_test.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:mockito/mockito.dart'; import 'package:on_time_front/core/constants/external_links.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; +import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/domain/entities/alarm_entities.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; import 'package:on_time_front/domain/repositories/alarm_registry_repository.dart'; import 'package:on_time_front/domain/repositories/alarm_repository.dart'; import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; @@ -87,6 +90,150 @@ void main() { expect(openedUris, [ExternalLinks.privacyPolicyUri]); }); + testWidgets('shows an error dialog when privacy policy cannot open', ( + tester, + ) async { + await _pumpMyPage( + tester, + locale: const Locale('en'), + openPrivacyPolicy: (_) async => false, + ); + + await tester.ensureVisible(find.text('Privacy Policy')); + await tester.tap(find.text('Privacy Policy')); + await tester.pumpAndSettle(); + + expect(find.text('Error'), findsOneWidget); + expect(find.textContaining('privacy policy'), findsOneWidget); + }); + + testWidgets('shows already-enabled dialog when notifications are allowed', ( + tester, + ) async { + final notificationService = _FakeNotificationService( + currentStatus: AuthorizationStatus.authorized, + ); + + await _pumpMyPage( + tester, + locale: const Locale('en'), + notificationService: notificationService, + ); + + await tester.ensureVisible(find.text('Allow App Notifications')); + await tester.tap(find.text('Allow App Notifications')); + await tester.pumpAndSettle(); + + expect(find.text('Notification Already Enabled'), findsOneWidget); + expect(notificationService.requestCount, 0); + expect(notificationService.initializeCount, 0); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(find.text('Notification Already Enabled'), findsNothing); + }); + + testWidgets('cancels notification permission request from rationale dialog', ( + tester, + ) async { + final notificationService = _FakeNotificationService( + currentStatus: AuthorizationStatus.denied, + ); + + await _pumpMyPage( + tester, + locale: const Locale('en'), + notificationService: notificationService, + ); + + await tester.ensureVisible(find.text('Allow App Notifications')); + await tester.tap(find.text('Allow App Notifications')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(notificationService.requestCount, 0); + expect(notificationService.initializeCount, 0); + expect(notificationService.openSettingsCount, 0); + }); + + testWidgets( + 'granting notification permission initializes notifications and confirms', + (tester) async { + final notificationService = _FakeNotificationService( + currentStatus: AuthorizationStatus.notDetermined, + requestedStatus: AuthorizationStatus.authorized, + ); + + await _pumpMyPage( + tester, + locale: const Locale('en'), + notificationService: notificationService, + ); + + await tester.ensureVisible(find.text('Allow App Notifications')); + await tester.tap(find.text('Allow App Notifications')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Allow')); + await tester.pumpAndSettle(); + + expect(notificationService.requestCount, 1); + expect(notificationService.initializeCount, 1); + expect(find.text('Notification Permission Granted'), findsOneWidget); + }, + ); + + testWidgets('denied notification permission can open app settings', ( + tester, + ) async { + final notificationService = _FakeNotificationService( + currentStatus: AuthorizationStatus.denied, + requestedStatus: AuthorizationStatus.denied, + ); + + await _pumpMyPage( + tester, + locale: const Locale('en'), + notificationService: notificationService, + ); + + await tester.ensureVisible(find.text('Allow App Notifications')); + await tester.tap(find.text('Allow App Notifications')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Allow')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open Settings')); + await tester.pumpAndSettle(); + + expect(notificationService.requestCount, 1); + expect(notificationService.initializeCount, 0); + expect(notificationService.openSettingsCount, 1); + }); + + testWidgets('provisional notification permission opens settings dialog', ( + tester, + ) async { + final notificationService = _FakeNotificationService( + currentStatus: AuthorizationStatus.provisional, + ); + + await _pumpMyPage( + tester, + locale: const Locale('en'), + notificationService: notificationService, + ); + + await tester.ensureVisible(find.text('Allow App Notifications')); + await tester.tap(find.text('Allow App Notifications')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open Settings')); + await tester.pumpAndSettle(); + + expect(notificationService.requestCount, 0); + expect(notificationService.openSettingsCount, 1); + }); + testWidgets('keeps alarms disabled when exact alarm permission is missing', ( tester, ) async { @@ -115,13 +262,242 @@ void main() { expect(cancelAllUseCase.callCount, 1); expect(tester.widget(find.byType(Switch)).value, isFalse); }); + + testWidgets( + 'enabling alarms can recover exact alarm permission through settings', + (tester) async { + final alarmRepository = + getIt.get() as _FakeAlarmRepository; + final alarmScheduler = + getIt.get() as _FakeAlarmSchedulerService; + final fallbackService = + getIt.get() + as _FakeFallbackAlarmNotificationService; + final reconcileUseCase = + getIt.get() as _FakeReconcileAlarmsUseCase; + alarmRepository.settings = const AlarmSettings(alarmsEnabled: false); + alarmScheduler + ..capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + ) + ..permission = AlarmPermissionState.denied + ..permissionAfterRequest = AlarmPermissionState.granted; + fallbackService.permission = AlarmPermissionState.granted; + + await _pumpMyPage(tester, locale: const Locale('en')); + + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open Settings')); + await tester.pumpAndSettle(); + + expect(alarmScheduler.requestCount, 1); + expect(alarmRepository.updatedSettings, [true]); + expect(fallbackService.requestCount, 1); + expect(reconcileUseCase.callCount, 1); + expect(tester.widget(find.byType(Switch)).value, isTrue); + }, + ); + + testWidgets('shows authenticated user account information', (tester) async { + await _pumpMyPage( + tester, + locale: const Locale('en'), + authState: AuthState( + user: const UserEntity( + id: 'user-1', + email: 'user@example.com', + name: 'User Name', + spareTime: Duration(minutes: 10), + note: '', + score: 4.5, + isOnboardingCompleted: true, + ), + ), + ); + + expect(find.text('User Name'), findsOneWidget); + expect(find.text('user@example.com'), findsOneWidget); + }); + + testWidgets('logout setting shows confirmation and dispatches sign out', ( + tester, + ) async { + final authBloc = _StubAuthBloc(AuthState()); + + await _pumpMyPage(tester, locale: const Locale('en'), authBloc: authBloc); + + await tester.ensureVisible(find.text('Log out')); + await tester.tap(find.text('Log out')); + await tester.pumpAndSettle(); + + expect(find.text('Do you want to log out?'), findsOneWidget); + + await tester.tap(find.text('Log out').last); + await tester.pumpAndSettle(); + + expect(authBloc.addedEvents.single, isA()); + }); + + testWidgets('shows native alarm status when native records exist', ( + tester, + ) async { + final alarmRepository = + getIt.get() as _FakeAlarmRepository; + final alarmRegistry = + getIt.get() as _FakeAlarmRegistry; + alarmRepository.settings = const AlarmSettings(alarmsEnabled: true); + alarmRegistry.records = [ + _alarmRecord(provider: AlarmProvider.androidAlarmManager), + ]; + + await _pumpMyPage(tester, locale: const Locale('ko')); + + expect(find.text('네이티브 알람'), findsOneWidget); + expect(tester.widget(find.byType(Switch)).value, isTrue); + }); + + testWidgets( + 'shows fallback notification status when fallback records exist', + (tester) async { + final alarmRepository = + getIt.get() as _FakeAlarmRepository; + final alarmRegistry = + getIt.get() as _FakeAlarmRegistry; + alarmRepository.settings = const AlarmSettings(alarmsEnabled: true); + alarmRegistry.records = [ + _alarmRecord(provider: AlarmProvider.localNotification), + ]; + + await _pumpMyPage(tester, locale: const Locale('ko')); + + expect(find.text('알림 대체'), findsOneWidget); + }, + ); + + testWidgets('shows unsupported status when no alarm provider can be used', ( + tester, + ) async { + final alarmRepository = + getIt.get() as _FakeAlarmRepository; + final fallbackService = + getIt.get() + as _FakeFallbackAlarmNotificationService; + alarmRepository.settings = const AlarmSettings(alarmsEnabled: true); + fallbackService.permission = AlarmPermissionState.denied; + + await _pumpMyPage(tester, locale: const Locale('ko')); + + expect(find.text('지원 안 됨'), findsOneWidget); + }); + + testWidgets( + 'shows permission-needed status when all alarm permissions fail', + (tester) async { + final alarmRepository = + getIt.get() as _FakeAlarmRepository; + final alarmScheduler = + getIt.get() as _FakeAlarmSchedulerService; + final fallbackService = + getIt.get() + as _FakeFallbackAlarmNotificationService; + alarmRepository.settings = const AlarmSettings(alarmsEnabled: true); + alarmScheduler + ..capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + ) + ..permission = AlarmPermissionState.denied; + fallbackService.permission = AlarmPermissionState.denied; + + await _pumpMyPage(tester, locale: const Locale('ko')); + + expect(find.text('권한 필요'), findsOneWidget); + }, + ); + + testWidgets('unauthenticated users do not render account identity', ( + tester, + ) async { + await _pumpMyPage(tester, locale: const Locale('en')); + + expect(find.text('User Name'), findsNothing); + expect(find.text('user@example.com'), findsNothing); + }); + + testWidgets('shows load error when alarm settings cannot be read', ( + tester, + ) async { + final alarmRepository = + getIt.get() as _FakeAlarmRepository; + alarmRepository.throwSettings = true; + + await _pumpMyPage(tester, locale: const Locale('ko')); + + expect(find.text('상태를 불러올 수 없음'), findsOneWidget); + }); + + testWidgets('enabling alarms with permission reconciles alarm schedule', ( + tester, + ) async { + final alarmRepository = + getIt.get() as _FakeAlarmRepository; + final alarmScheduler = + getIt.get() as _FakeAlarmSchedulerService; + final fallbackService = + getIt.get() + as _FakeFallbackAlarmNotificationService; + final reconcileUseCase = + getIt.get() as _FakeReconcileAlarmsUseCase; + alarmRepository.settings = const AlarmSettings(alarmsEnabled: false); + alarmScheduler + ..capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + ) + ..permission = AlarmPermissionState.granted; + fallbackService.permission = AlarmPermissionState.granted; + + await _pumpMyPage(tester, locale: const Locale('ko')); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect(alarmRepository.updatedSettings, [true]); + expect(fallbackService.requestCount, 1); + expect(reconcileUseCase.callCount, 1); + expect(tester.widget(find.byType(Switch)).value, isTrue); + }); + + testWidgets( + 'disabling alarms updates settings and cancels registered alarms', + (tester) async { + final alarmRepository = + getIt.get() as _FakeAlarmRepository; + final cancelAllUseCase = + getIt.get() as _FakeCancelAllAlarmsUseCase; + alarmRepository.settings = const AlarmSettings(alarmsEnabled: true); + + await _pumpMyPage(tester, locale: const Locale('ko')); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect(alarmRepository.updatedSettings, [false]); + expect(cancelAllUseCase.callCount, 1); + expect(tester.widget(find.byType(Switch)).value, isFalse); + }, + ); } Future _pumpMyPage( WidgetTester tester, { required Locale locale, PrivacyPolicyLauncher? openPrivacyPolicy, + NotificationService? notificationService, + AuthState authState = const AuthState.loading(), + _StubAuthBloc? authBloc, }) async { + final bloc = authBloc ?? _StubAuthBloc(authState); await tester.pumpWidget( MaterialApp( theme: themeData, @@ -129,8 +505,11 @@ Future _pumpMyPage( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: BlocProvider.value( - value: _StubAuthBloc(), - child: MyPageScreen(openPrivacyPolicy: openPrivacyPolicy), + value: bloc, + child: MyPageScreen( + openPrivacyPolicy: openPrivacyPolicy, + notificationService: notificationService, + ), ), ), ); @@ -138,19 +517,69 @@ Future _pumpMyPage( } class _StubAuthBloc extends Mock implements AuthBloc { + _StubAuthBloc(this._state); + + final AuthState _state; + final addedEvents = []; + @override - AuthState get state => const AuthState.loading(); + AuthState get state => _state; @override Stream get stream => const Stream.empty(); @override bool get isClosed => false; + + @override + void add(AuthEvent event) { + addedEvents.add(event); + } +} + +class _FakeNotificationService implements NotificationService { + _FakeNotificationService({ + required this.currentStatus, + this.requestedStatus = AuthorizationStatus.denied, + }); + + AuthorizationStatus currentStatus; + final AuthorizationStatus requestedStatus; + int requestCount = 0; + int initializeCount = 0; + int openSettingsCount = 0; + + @override + Future checkNotificationPermission() async { + return currentStatus; + } + + @override + Future requestPermission() async { + requestCount += 1; + currentStatus = requestedStatus; + return requestedStatus; + } + + @override + Future initialize() async { + initializeCount += 1; + } + + @override + Future openNotificationSettings() async { + openSettingsCount += 1; + return true; + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } class _FakeAlarmRepository implements AlarmRepository { AlarmSettings settings = const AlarmSettings(alarmsEnabled: false); final updatedSettings = []; + bool throwSettings = false; @override Future getDeviceId() => throw UnimplementedError(); @@ -161,6 +590,9 @@ class _FakeAlarmRepository implements AlarmRepository { @override Future getAlarmSettings() async { + if (throwSettings) { + throw Exception('settings unavailable'); + } return settings; } @@ -198,8 +630,10 @@ class _FakeAlarmRepository implements AlarmRepository { } class _FakeAlarmRegistry implements AlarmRegistryRepository { + List records = const []; + @override - Future> loadAll() async => const []; + Future> loadAll() async => records; @override Future upsert(ScheduledAlarmRecord record) { @@ -226,6 +660,7 @@ class _FakeAlarmSchedulerService extends AlarmSchedulerService { AlarmSchedulerCapabilities capabilities = AlarmSchedulerCapabilities.unsupported; AlarmPermissionState permission = AlarmPermissionState.unsupported; + AlarmPermissionState? permissionAfterRequest; int requestCount = 0; @override @@ -241,20 +676,28 @@ class _FakeAlarmSchedulerService extends AlarmSchedulerService { @override Future requestPermission() async { requestCount += 1; + final nextPermission = permissionAfterRequest; + if (nextPermission != null) { + permission = nextPermission; + } return permission; } } class _FakeFallbackAlarmNotificationService implements FallbackAlarmNotificationService { + AlarmPermissionState permission = AlarmPermissionState.unsupported; + int requestCount = 0; + @override Future checkPermission() async { - return AlarmPermissionState.unsupported; + return permission; } @override - Future requestPermission() { - throw UnimplementedError(); + Future requestPermission() async { + requestCount += 1; + return permission; } @override @@ -268,6 +711,20 @@ class _FakeFallbackAlarmNotificationService } } +ScheduledAlarmRecord _alarmRecord({required AlarmProvider provider}) { + return ScheduledAlarmRecord( + scheduleId: 'schedule-1', + alarmTime: DateTime(2026, 5, 15, 8), + preparationStartTime: DateTime(2026, 5, 15, 8, 5), + scheduleFingerprint: 'fingerprint', + nativeAlarmId: 1, + fallbackNotificationId: 1, + provider: provider, + scheduleTitle: 'Morning meeting', + payload: const {'type': 'schedule_alarm'}, + ); +} + class _FakeCancelAllAlarmsUseCase extends CancelAllAlarmsUseCase { // ignore: use_super_parameters _FakeCancelAllAlarmsUseCase( @@ -305,8 +762,11 @@ class _FakeReconcileAlarmsUseCase extends ReconcileAlarmsUseCase { nowProvider: () => DateTime(2026), ); + int callCount = 0; + @override Future call() async { + callCount += 1; return AlarmReconciliationResult( status: AlarmReconciliationStatus.armed, nativeAlarmProvider: AlarmProvider.androidAlarmManager, diff --git a/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart b/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart index ce345d6f..c42c2ad1 100644 --- a/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart +++ b/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart @@ -137,6 +137,139 @@ void main() { const Duration(minutes: 15), ); }); + + test( + 'form bloc loads defaults and edits spare time in five-minute steps', + () async { + final bloc = _buildBloc(preparationStore); + addTearDown(bloc.close); + + bloc.add(const FormEditRequested(spareTime: Duration(minutes: 15))); + await expectLater( + bloc.stream, + emitsInOrder([ + isA().having( + (state) => state.status, + 'status', + DefaultPreparationSpareTimeStatus.loading, + ), + isA() + .having( + (state) => state.status, + 'status', + DefaultPreparationSpareTimeStatus.success, + ) + .having( + (state) => state.spareTime, + 'spareTime', + const Duration(minutes: 15), + ), + ]), + ); + + bloc + ..add(const SpareTimeIncreased()) + ..add(const SpareTimeDecreased()) + ..add(const SpareTimeDecreased()) + ..add(const SpareTimeDecreased()); + await testerPumpEventQueue(); + + expect(bloc.state.spareTime, const Duration(minutes: 10)); + }, + ); + + test( + 'form bloc persists preparation and spare time before reloading user', + () async { + final bloc = _buildBloc(preparationStore); + addTearDown(bloc.close); + const editedPreparation = PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step-2', + preparationName: 'Pack bag', + preparationTime: Duration(minutes: 8), + ), + ], + ); + + bloc.add(const FormEditRequested(spareTime: Duration(minutes: 20))); + await bloc.stream.firstWhere( + (state) => state.status == DefaultPreparationSpareTimeStatus.success, + ); + bloc.add( + const FormSubmitted( + note: 'Updated note', + preparation: editedPreparation, + ), + ); + await bloc.stream.firstWhere( + (state) => state.status == DefaultPreparationSpareTimeStatus.submitted, + ); + + expect(preparationStore.updatedPreparation, editedPreparation); + expect(preparationStore.updatedSpareTime, const Duration(minutes: 20)); + expect(preparationStore.loadUserCount, 1); + }, + ); + + test( + 'form bloc reports errors when spare time is absent or update fails', + () async { + final missingSpareBloc = _buildBloc(preparationStore); + addTearDown(missingSpareBloc.close); + + missingSpareBloc.add( + const FormSubmitted( + note: '', + preparation: PreparationEntity(preparationStepList: []), + ), + ); + await missingSpareBloc.stream.firstWhere( + (state) => state.status == DefaultPreparationSpareTimeStatus.error, + ); + expect(preparationStore.updatedPreparation, isNull); + + final failingStore = + _FakePreparationStore(preparationStore.defaultPreparation) + ..updateDefaultHandler = (_) async { + throw Exception('update failed'); + }; + final failingBloc = _buildBloc(failingStore); + addTearDown(failingBloc.close); + + failingBloc.add( + const FormEditRequested(spareTime: Duration(minutes: 20)), + ); + await failingBloc.stream.firstWhere( + (state) => state.status == DefaultPreparationSpareTimeStatus.success, + ); + failingBloc.add( + const FormSubmitted( + note: '', + preparation: PreparationEntity(preparationStepList: []), + ), + ); + + await failingBloc.stream.firstWhere( + (state) => state.status == DefaultPreparationSpareTimeStatus.error, + ); + }, + ); +} + +Future testerPumpEventQueue() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +DefaultPreparationSpareTimeFormBloc _buildBloc(_FakePreparationStore store) { + return DefaultPreparationSpareTimeFormBloc( + _FakeGetDefaultPreparationUseCase(store), + _FakeUpdateDefaultPreparationUseCase(store), + _FakeUpdateSpareTimeUseCase(store), + _FakeLoadUserUseCase(store), + ); } Future _pumpScreen(WidgetTester tester) async { @@ -233,8 +366,14 @@ class _FakeUpdateSpareTimeUseCase extends Mock } class _FakeLoadUserUseCase extends Mock implements LoadUserUseCase { + _FakeLoadUserUseCase([this.store]); + + final _FakePreparationStore? store; + @override - Future call() async {} + Future call() async { + store?.loadUserCount++; + } } class _StubAuthBloc extends Mock implements AuthBloc { diff --git a/test/presentation/notification_allow/notification_allow_screen_test.dart b/test/presentation/notification_allow/notification_allow_screen_test.dart index ebde1868..b9af91a4 100644 --- a/test/presentation/notification_allow/notification_allow_screen_test.dart +++ b/test/presentation/notification_allow/notification_allow_screen_test.dart @@ -161,6 +161,26 @@ void main() { expect(find.text('home'), findsOneWidget); expect(harness.gateCubit.state.status, NotificationGateStatus.dismissed); }); + + testWidgets('do it later dismisses prompt without requesting permission', ( + tester, + ) async { + final permissionGateway = _FakePermissionGateway( + currentStatus: AuthorizationStatus.notDetermined, + ); + final harness = await _pumpNotificationAllowScreen( + tester, + permissionGateway: permissionGateway, + ); + addTearDown(harness.dispose); + + await tester.tap(find.text("I'll do it later.")); + await tester.pumpAndSettle(); + + expect(permissionGateway.requestCount, 0); + expect(find.text('home'), findsOneWidget); + expect(harness.gateCubit.state.status, NotificationGateStatus.dismissed); + }); } Future<_NotificationAllowHarness> _pumpNotificationAllowScreen( diff --git a/test/presentation/onboarding/cubit/onboarding_cubit_test.dart b/test/presentation/onboarding/cubit/onboarding_cubit_test.dart new file mode 100644 index 00000000..cef0954e --- /dev/null +++ b/test/presentation/onboarding/cubit/onboarding_cubit_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/repositories/user_repository.dart'; +import 'package:on_time_front/domain/use-cases/onboard_use_case.dart'; +import 'package:on_time_front/presentation/onboarding/cubit/onboarding_cubit.dart'; + +void main() { + test( + 'OnboardingPreparationStepState copyWith updates and clears linkage', + () { + const step = OnboardingPreparationStepState( + id: 'step-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + nextPreparationId: 'step-2', + ); + + expect(step.copyWith(preparationName: 'Pack').preparationName, 'Pack'); + expect(step.copyWith(nextPreparationId: '').nextPreparationId, isNull); + }, + ); + + test('OnboardingState copies form values and converts steps to entity', () { + const step = OnboardingPreparationStepState( + id: 'step-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + nextPreparationId: 'step-2', + ); + + final state = const OnboardingState().copyWith( + preparationStepList: [step], + spareTime: const Duration(minutes: 5), + note: 'Leave early', + isValid: true, + ); + final entity = state.toEntity(); + + expect(state.preparationStepList, [step]); + expect(state.spareTime, const Duration(minutes: 5)); + expect(state.note, 'Leave early'); + expect(state.isValid, isTrue); + expect(entity.preparationStepList.single.id, 'step-1'); + expect(entity.preparationStepList.single.preparationName, 'Shower'); + expect( + entity.preparationStepList.single.preparationTime, + const Duration(minutes: 10), + ); + expect(entity.preparationStepList.single.nextPreparationId, 'step-2'); + }); + + test( + 'OnboardingCubit emits form changes and submits onboarding payload', + () async { + final useCase = _FakeOnboardUseCase(); + final cubit = OnboardingCubit(useCase); + addTearDown(cubit.close); + const step = OnboardingPreparationStepState( + id: 'step-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + ); + + cubit.onboardingFormChanged( + preparationStepList: [step], + spareTime: const Duration(minutes: 5), + ); + cubit.onboardingFormValidated(isValid: true); + await cubit.onboardingFormSubmitted(); + + expect(cubit.state.preparationStepList, [step]); + expect(cubit.state.spareTime, const Duration(minutes: 5)); + expect(cubit.state.isValid, isTrue); + expect(useCase.submissions.single.spareTime, const Duration(minutes: 5)); + expect(useCase.submissions.single.note, ''); + expect( + useCase + .submissions + .single + .preparationEntity + .preparationStepList + .single + .id, + 'step-1', + ); + }, + ); +} + +class _OnboardingSubmission { + const _OnboardingSubmission({ + required this.preparationEntity, + required this.spareTime, + required this.note, + }); + + final PreparationEntity preparationEntity; + final Duration spareTime; + final String note; +} + +class _FakeOnboardUseCase extends OnboardUseCase { + _FakeOnboardUseCase() + : super(_FakePreparationRepository(), _FakeUserRepository()); + + final submissions = <_OnboardingSubmission>[]; + + @override + Future call({ + required PreparationEntity preparationEntity, + required Duration spareTime, + required String note, + }) async { + submissions.add( + _OnboardingSubmission( + preparationEntity: preparationEntity, + spareTime: spareTime, + note: note, + ), + ); + } +} + +class _FakePreparationRepository implements PreparationRepository { + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeUserRepository implements UserRepository { + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart b/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart index 93b57b47..d04d46f3 100644 --- a/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart +++ b/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart @@ -364,6 +364,121 @@ void main() { ); }); + test('ScheduleFormCreateRequested seeds a provided future date', () async { + final bloc = buildBloc(); + addTearDown(bloc.close); + + final createReady = bloc.stream.firstWhere( + (state) => state.status == ScheduleFormStatus.success, + ); + bloc.add(ScheduleFormCreateRequested(initialDate: DateTime(2027, 4, 5))); + final state = await createReady; + + expect(state.scheduleTime, isNotNull); + expect(state.scheduleTime!.year, 2027); + expect(state.scheduleTime!.month, 4); + expect(state.scheduleTime!.day, 5); + expect(state.scheduleSpareTime, const Duration(minutes: 5)); + }); + + test('ScheduleFormPreparationChanged marks unchanged preparations', () async { + final bloc = buildBloc(); + addTearDown(bloc.close); + + final editReady = bloc.stream.firstWhere( + (state) => state.status == ScheduleFormStatus.success, + ); + bloc.add(const ScheduleFormEditRequested(scheduleId: 'schedule-1')); + await editReady; + + bloc.add(ScheduleFormPreparationChanged(preparation: preparation)); + await pumpEventQueue(); + + expect(bloc.state.isChanged, IsPreparationChanged.unchanged); + }); + + test('ScheduleFormUpdated skips preparation update when unchanged', () async { + var preparationUpdateCount = 0; + updatePreparationByScheduleIdUseCase = + StubUpdatePreparationByScheduleIdUseCase((_, __) async { + preparationUpdateCount += 1; + }); + final bloc = buildBloc(); + addTearDown(bloc.close); + + final editReady = bloc.stream.firstWhere( + (state) => state.status == ScheduleFormStatus.success, + ); + bloc.add(const ScheduleFormEditRequested(scheduleId: 'schedule-1')); + await editReady; + + final submitDone = bloc.stream.firstWhere( + (state) => state.submissionStatus == ScheduleFormSubmissionStatus.success, + ); + bloc.add(const ScheduleFormUpdated()); + await submitDone; + + expect(preparationUpdateCount, 0); + }); + + test( + 'ScheduleFormCreated persists custom preparation when changed', + () async { + var customPreparationCount = 0; + createCustomPreparationUseCase = StubCreateCustomPreparationUseCase(( + _, + __, + ) async { + customPreparationCount += 1; + }); + final bloc = buildBloc(); + addTearDown(bloc.close); + + final createReady = bloc.stream.firstWhere( + (state) => state.status == ScheduleFormStatus.success, + ); + bloc.add(const ScheduleFormCreateRequested()); + await createReady; + + final changedPreparation = PreparationEntity( + preparationStepList: const [ + PreparationStepEntity( + id: 'prep-2', + preparationName: 'Pack', + preparationTime: Duration(minutes: 15), + ), + ], + ); + bloc + ..add(const ScheduleFormScheduleNameChanged(scheduleName: 'Meeting')) + ..add( + ScheduleFormScheduleDateTimeChanged( + scheduleDate: DateTime(2027, 3, 20), + scheduleTime: DateTime(2027, 3, 20, 9), + ), + ) + ..add(const ScheduleFormPlaceNameChanged(placeName: 'Office')) + ..add( + const ScheduleFormMoveTimeChanged(moveTime: Duration(minutes: 30)), + ) + ..add( + const ScheduleFormScheduleSpareTimeChanged( + scheduleSpareTime: Duration(minutes: 10), + ), + ) + ..add(ScheduleFormPreparationChanged(preparation: changedPreparation)); + + final submitDone = bloc.stream.firstWhere( + (state) => + state.submissionStatus == ScheduleFormSubmissionStatus.success, + ); + bloc.add(const ScheduleFormCreated()); + await submitDone; + + expect(customPreparationCount, 1); + }, + ); + test('ScheduleFormCreated emits submitting then failure on error', () async { createScheduleWithPlaceUseCase = StubCreateScheduleWithPlaceUseCase( (_) => Future.error(Exception()), diff --git a/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart b/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart new file mode 100644 index 00000000..56c3bfee --- /dev/null +++ b/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; + +void main() { + test('ScheduleFormEvent props capture user input payloads', () { + final date = DateTime(2026, 5, 15); + final time = DateTime(2026, 5, 15, 9); + final preparation = _preparation(); + + expect(const ScheduleFormEditRequested(scheduleId: 's-1').props, ['s-1']); + expect(ScheduleFormCreateRequested(initialDate: date).props, [date]); + expect(const ScheduleFormCreateRequested().props.single, isA()); + expect( + const ScheduleFormScheduleNameChanged(scheduleName: 'Meeting').props, + ['Meeting'], + ); + expect( + ScheduleFormScheduleDateTimeChanged( + scheduleDate: date, + scheduleTime: time, + maxAvailableTime: const Duration(minutes: 20), + previousScheduleName: 'Previous', + ).props, + [date, time, const Duration(minutes: 20), 'Previous'], + ); + expect(const ScheduleFormPlaceNameChanged(placeName: 'Office').props, [ + 'Office', + ]); + expect( + const ScheduleFormMoveTimeChanged(moveTime: Duration(minutes: 10)).props, + [const Duration(minutes: 10)], + ); + expect( + const ScheduleFormScheduleSpareTimeChanged( + scheduleSpareTime: Duration(minutes: 5), + ).props, + [const Duration(minutes: 5)], + ); + expect(ScheduleFormPreparationChanged(preparation: preparation).props, [ + preparation, + ]); + expect(const ScheduleFormValidated(isValid: true).props, [true]); + }); + + test('ScheduleFormState copyWith updates fields and can clear error', () { + final initial = ScheduleFormState( + id: 'schedule-1', + submissionError: 'backend down', + ); + final updated = initial.copyWith( + status: ScheduleFormStatus.loading, + submissionStatus: ScheduleFormSubmissionStatus.submitting, + submissionError: null, + placeId: 'place-1', + placeName: 'Office', + scheduleName: 'Meeting', + scheduleTime: DateTime(2026, 5, 15, 9), + moveTime: const Duration(minutes: 10), + isChanged: IsPreparationChanged.changed, + scheduleSpareTime: const Duration(minutes: 5), + scheduleNote: 'Bring notes', + preparation: _preparation(), + isValid: true, + maxAvailableTime: const Duration(minutes: 30), + previousScheduleName: 'Previous', + ); + + expect(updated.id, 'schedule-1'); + expect(updated.status, ScheduleFormStatus.loading); + expect(updated.submissionStatus, ScheduleFormSubmissionStatus.submitting); + expect(updated.submissionError, isNull); + expect(updated.placeName, 'Office'); + expect(updated.totalPreparationTime, const Duration(minutes: 15)); + expect(updated.isValid, isTrue); + }); + + test('ScheduleFormState creates schedule entity from valid form fields', () { + final state = ScheduleFormState( + id: 'schedule-1', + placeId: 'place-1', + placeName: 'Office', + scheduleName: 'Meeting', + scheduleTime: DateTime(2026, 5, 15, 9), + moveTime: const Duration(minutes: 10), + isChanged: IsPreparationChanged.changed, + scheduleSpareTime: const Duration(minutes: 5), + scheduleNote: 'Bring notes', + ); + + final entity = state.createEntity(state); + + expect(entity.id, 'schedule-1'); + expect(entity.place.id, 'place-1'); + expect(entity.place.placeName, 'Office'); + expect(entity.scheduleName, 'Meeting'); + expect(entity.isChanged, isTrue); + expect(entity.isStarted, isFalse); + expect(entity.scheduleNote, 'Bring notes'); + }); +} + +PreparationEntity _preparation() { + return const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + nextPreparationId: 'step-2', + ), + PreparationStepEntity( + id: 'step-2', + preparationName: 'Pack', + preparationTime: Duration(minutes: 5), + ), + ], + ); +} diff --git a/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart b/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart index 01e9826b..872a1976 100644 --- a/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart +++ b/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart @@ -29,6 +29,8 @@ import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bl import 'package:on_time_front/presentation/schedule_create/components/schedule_multi_page_form.dart'; import 'package:on_time_front/presentation/schedule_create/components/top_bar.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart'; +import 'package:on_time_front/presentation/schedule_create/screens/schedule_create_screen.dart'; +import 'package:on_time_front/presentation/schedule_create/screens/schedule_edit_screen.dart'; class StubAuthBloc extends Mock implements AuthBloc { StubAuthBloc(this._state); @@ -37,6 +39,12 @@ class StubAuthBloc extends Mock implements AuthBloc { @override AuthState get state => _state; + + @override + Stream get stream => const Stream.empty(); + + @override + bool get isClosed => false; } class StubLoadPreparationByScheduleIdUseCase @@ -274,6 +282,27 @@ void main() { await tester.pumpAndSettle(); } + Future pumpScheduleScreen( + WidgetTester tester, { + required Widget screen, + }) async { + getIt.registerFactoryParam( + (_, __) => buildBloc(), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: BlocProvider.value( + value: authBloc, + child: Scaffold(body: screen), + ), + ), + ); + await tester.pumpAndSettle(); + } + Future goToFinalStepAndSubmit(WidgetTester tester) async { Finder nextButton() => find.descendant( of: find.byType(TopBar), @@ -382,6 +411,30 @@ void main() { }, ); + testWidgets('create screen initializes form bloc with selected date', ( + tester, + ) async { + await pumpScheduleScreen( + tester, + screen: ScheduleCreateScreen(initialDate: DateTime(2027, 3, 21)), + ); + + expect(find.byType(ScheduleMultiPageForm), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('edit screen initializes form bloc with requested schedule', ( + tester, + ) async { + await pumpScheduleScreen( + tester, + screen: const ScheduleEditScreen(scheduleId: 'schedule-1'), + ); + + expect(find.byType(ScheduleMultiPageForm), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('sheet closes after successful submit', (tester) async { final bloc = buildBloc(); addTearDown(bloc.close); diff --git a/test/presentation/schedule_create/preparation_form/preparation_form_create_list_test.dart b/test/presentation/schedule_create/preparation_form/preparation_form_create_list_test.dart new file mode 100644 index 00000000..7d431de6 --- /dev/null +++ b/test/presentation/schedule_create/preparation_form/preparation_form_create_list_test.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_name_select/input_models/preparation_name_input_model.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_time/input_models/preparation_time_input_model.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_create_list.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +void main() { + testWidgets( + 'create list forwards edits, reorder changes, and create request', + (tester) async { + final bloc = PreparationFormBloc(); + addTearDown(bloc.close); + final nameChanges = <({int index, String value})>[]; + var createCount = 0; + + await tester.pumpWidget( + DefaultAssetBundle( + bundle: _SvgAssetBundle(), + child: MaterialApp( + theme: themeData, + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: BlocProvider.value( + value: bloc, + child: Scaffold( + body: PreparationFormCreateList( + preparationNameState: PreparationFormState( + preparationStepList: [_step('step-1', 'Shower', 10)], + ), + onNameChanged: ({required index, required value}) { + nameChanges.add((index: index, value: value)); + }, + onCreationRequested: () => createCount += 1, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextFormField), 'Pack'); + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + expect(nameChanges, [(index: 0, value: 'Pack')]); + expect(createCount, 1); + }, + ); +} + +PreparationStepFormState _step(String id, String name, int minutes) { + return PreparationStepFormState( + id: id, + preparationName: PreparationNameInputModel.pure(name), + preparationTime: PreparationTimeInputModel.pure(Duration(minutes: minutes)), + isValid: true, + ); +} + +class _SvgAssetBundle extends CachingAssetBundle { + static const _svg = + ''; + + @override + Future load(String key) async { + final bytes = Uint8List.fromList(utf8.encode(_svg)); + return ByteData.view(bytes.buffer); + } +} diff --git a/test/presentation/schedule_create/preparation_form/preparation_form_list_field_test.dart b/test/presentation/schedule_create/preparation_form/preparation_form_list_field_test.dart new file mode 100644 index 00000000..ac3fd619 --- /dev/null +++ b/test/presentation/schedule_create/preparation_form/preparation_form_list_field_test.dart @@ -0,0 +1,247 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_name_select/input_models/preparation_name_input_model.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_time/input_models/preparation_time_input_model.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'editing a saved preparation step reports name changes and save', + (tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final changedNames = []; + final focusLostNames = []; + var savedCount = 0; + + await _pumpField( + tester, + focusNode: focusNode, + step: _step( + name: const PreparationNameInputModel.pure('Shower'), + time: const PreparationTimeInputModel.pure(Duration(minutes: 10)), + ), + onNameChanged: changedNames.add, + onNameFocusLost: focusLostNames.add, + onNameSaved: () => savedCount += 1, + ); + + await tester.enterText(find.byType(TextFormField), 'Pack bag'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(changedNames, ['Pack bag']); + expect(focusLostNames, contains('Pack bag')); + expect(savedCount, greaterThanOrEqualTo(1)); + }, + ); + + testWidgets('adding row delays interaction-ended while time picker is open', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final endedNames = []; + final changedTimes = []; + + await _pumpField( + tester, + focusNode: focusNode, + isAdding: true, + step: _step( + name: const PreparationNameInputModel.pure(''), + time: const PreparationTimeInputModel.pure(Duration.zero), + ), + onInteractionEnded: endedNames.add, + onPreparationTimeChanged: changedTimes.add, + ); + + await tester.enterText(find.byType(TextFormField), 'Coffee'); + await tester.tap(find.text('00')); + await tester.pumpAndSettle(); + focusNode.unfocus(); + await tester.pump(); + + expect(endedNames, isEmpty); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(changedTimes, isNotEmpty); + }); + + testWidgets('validation errors explain invalid name and preparation time', ( + tester, + ) async { + await _pumpField( + tester, + showValidationErrors: true, + step: _step( + name: const PreparationNameInputModel.dirty(''), + time: const PreparationTimeInputModel.dirty(Duration.zero), + ), + ); + + expect(find.text('Please enter a preparation name.'), findsOneWidget); + expect( + find.text('Set preparation time to at least 1 minute.'), + findsOneWidget, + ); + }); + + testWidgets('validation reports too-large preparation time', (tester) async { + await _pumpField( + tester, + showValidationErrors: true, + step: _step( + name: const PreparationNameInputModel.dirty('Pack bag'), + time: const PreparationTimeInputModel.dirty(Duration(days: 400)), + ), + ); + + expect( + find.textContaining('Preparation time can be up to'), + findsOneWidget, + ); + }); + + testWidgets( + 'updates focus listener and field value when row identity changes', + (tester) async { + final firstFocusNode = FocusNode(); + final secondFocusNode = FocusNode(); + addTearDown(firstFocusNode.dispose); + addTearDown(secondFocusNode.dispose); + final endedNames = []; + + await _pumpField( + tester, + focusNode: firstFocusNode, + step: _step( + id: 'step-1', + name: const PreparationNameInputModel.pure('Shower'), + time: const PreparationTimeInputModel.pure(Duration(minutes: 10)), + ), + onInteractionEnded: endedNames.add, + ); + + await _pumpField( + tester, + focusNode: secondFocusNode, + isAdding: true, + step: _step( + id: 'step-2', + name: const PreparationNameInputModel.pure('Coffee'), + time: const PreparationTimeInputModel.pure(Duration(minutes: 5)), + ), + onInteractionEnded: endedNames.add, + ); + await tester.pump(); + + secondFocusNode.unfocus(); + await tester.pump(); + + expect(find.text('Coffee'), findsOneWidget); + expect(endedNames, ['Coffee']); + }, + ); + + testWidgets('row without remove callback renders as a plain editable tile', ( + tester, + ) async { + var removeCount = 0; + + await _pumpField( + tester, + step: _step( + name: const PreparationNameInputModel.pure('Shower'), + time: const PreparationTimeInputModel.pure(Duration(minutes: 10)), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'Shower and pack'); + + expect(removeCount, 0); + expect(find.text('10'), findsOneWidget); + }); +} + +Future _pumpField( + WidgetTester tester, { + required PreparationStepFormState step, + int? index, + bool canRemove = true, + bool isAdding = false, + bool showValidationErrors = false, + FocusNode? focusNode, + ValueChanged? onNameChanged, + ValueChanged? onNameFocusLost, + ValueChanged? onInteractionEnded, + ValueChanged? onPreparationTimeChanged, + VoidCallback? onNameSaved, + VoidCallback? onRemove, +}) async { + await tester.pumpWidget( + DefaultAssetBundle( + bundle: _SvgAssetBundle(), + child: MaterialApp( + theme: themeData, + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: PreparationFormListField( + preparationStep: step, + index: index, + focusNode: focusNode, + canRemove: canRemove, + isAdding: isAdding, + showValidationErrors: showValidationErrors, + onNameChanged: onNameChanged, + onNameFocusLost: onNameFocusLost, + onInteractionEnded: onInteractionEnded, + onPreparationTimeChanged: onPreparationTimeChanged, + onNameSaved: onNameSaved, + onRemove: onRemove, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +PreparationStepFormState _step({ + String id = 'step-1', + required PreparationNameInputModel name, + required PreparationTimeInputModel time, +}) { + return PreparationStepFormState( + id: id, + preparationName: name, + preparationTime: time, + isValid: name.isValid && time.isValid, + ); +} + +class _SvgAssetBundle extends CachingAssetBundle { + static const _svg = + ''; + + @override + Future load(String key) async { + final bytes = Uint8List.fromList(utf8.encode(_svg)); + return ByteData.view(bytes.buffer); + } +} diff --git a/test/presentation/schedule_create/preparation_form/preparation_form_reorderable_list_test.dart b/test/presentation/schedule_create/preparation_form/preparation_form_reorderable_list_test.dart new file mode 100644 index 00000000..0f4f02c2 --- /dev/null +++ b/test/presentation/schedule_create/preparation_form/preparation_form_reorderable_list_test.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_name_select/input_models/preparation_name_input_model.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_time/input_models/preparation_time_input_model.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_reorderable_list.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +void main() { + testWidgets('reorderable preparation list wires edits to row callbacks', ( + tester, + ) async { + final bloc = PreparationFormBloc(); + addTearDown(bloc.close); + final changedNames = <(int, String)>[]; + final changedTimes = <(int, Duration)>[]; + final reorders = <(int, int)>[]; + + await tester.pumpWidget( + DefaultAssetBundle( + bundle: _SvgAssetBundle(), + child: MaterialApp( + theme: themeData, + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: BlocProvider.value( + value: bloc, + child: Scaffold( + body: PreparationFormReorderableList( + preparationStepList: [ + _step('step-1', 'Shower', 10), + _step('step-2', 'Pack', 5), + ], + addingStepId: null, + showValidationErrors: false, + stepKeyFor: (id) => ValueKey('row-$id'), + nameFocusNodeFor: (_) => FocusNode(), + onNameChanged: (index, value) { + changedNames.add((index, value)); + }, + onTimeChanged: (index, value) { + changedTimes.add((index, value)); + }, + onReorder: (oldIndex, newIndex) { + reorders.add((oldIndex, newIndex)); + }, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextFormField).first, 'Morning shower'); + await tester.tap(find.text('10')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(changedNames, [(0, 'Morning shower')]); + expect(changedTimes, isNotEmpty); + expect(find.byKey(const ValueKey('row-step-1')), findsOneWidget); + expect(reorders, isEmpty); + }); +} + +PreparationStepFormState _step(String id, String name, int minutes) { + return PreparationStepFormState( + id: id, + preparationName: PreparationNameInputModel.pure(name), + preparationTime: PreparationTimeInputModel.pure(Duration(minutes: minutes)), + isValid: true, + ); +} + +class _SvgAssetBundle extends CachingAssetBundle { + static const _svg = + ''; + + @override + Future load(String key) async { + final bytes = Uint8List.fromList(utf8.encode(_svg)); + return ByteData.view(bytes.buffer); + } +} diff --git a/test/presentation/schedule_create/preparation_form/preparation_form_state_event_test.dart b/test/presentation/schedule_create/preparation_form/preparation_form_state_event_test.dart new file mode 100644 index 00000000..d1b71388 --- /dev/null +++ b/test/presentation/schedule_create/preparation_form/preparation_form_state_event_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_name_select/input_models/preparation_name_input_model.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_time/input_models/preparation_time_input_model.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart'; + +void main() { + test('PreparationFormEvent props capture step edits', () { + final preparation = _preparation(); + final stepState = PreparationStepFormState( + id: 'step-1', + preparationName: const PreparationNameInputModel.pure('Shower'), + preparationTime: const PreparationTimeInputModel.pure( + Duration(minutes: 10), + ), + ); + + expect( + PreparationFormEditRequested( + preparationEntity: preparation, + ).preparationEntity, + preparation, + ); + expect( + PreparationFormPreparationStepCreated(preparationStep: stepState).props, + [stepState], + ); + expect( + const PreparationFormPreparationStepRemoved( + preparationStepId: 'step-1', + ).props, + ['step-1'], + ); + expect( + const PreparationFormPreparationStepNameChanged( + index: 1, + preparationStepName: 'Pack', + ).props, + [1, 'Pack'], + ); + expect( + const PreparationFormPreparationStepNameFocusLost( + index: 1, + preparationStepName: 'Pack', + ).props, + [1, 'Pack'], + ); + expect( + const PreparationFormPreparationStepInteractionEnded( + index: 1, + preparationStepName: 'Pack', + ).props, + [1, 'Pack'], + ); + expect( + const PreparationFormPreparationStepTimeChanged( + index: 1, + preparationStepTime: Duration(minutes: 15), + ).props, + [1, const Duration(minutes: 15)], + ); + expect( + const PreparationFormDraftStepNameChanged( + preparationStepName: 'Draft', + ).props, + ['Draft'], + ); + expect( + const PreparationFormDraftStepTimeChanged( + preparationStepTime: Duration(minutes: 3), + ).props, + [const Duration(minutes: 3)], + ); + expect( + const PreparationFormPreparationStepOrderChanged( + oldIndex: 0, + newIndex: 1, + ).props, + [0, 1], + ); + expect(const PreparationFormPreparationStepCreationRequested().props, []); + expect(const PreparationFormValidationRequested().props, []); + }); + + test('PreparationFormState orders linked entity steps for editing', () { + final state = PreparationFormState.fromEntity(_preparation()); + + expect(state.status, PreparationFormStatus.success); + expect(state.preparationStepList.map((step) => step.id), [ + 'step-1', + 'step-2', + ]); + }); + + test('PreparationFormState validates fields and converts visible steps', () { + final validStep = PreparationStepFormState( + id: 'step-1', + preparationName: const PreparationNameInputModel.dirty('Shower'), + preparationTime: const PreparationTimeInputModel.dirty( + Duration(minutes: 10), + ), + ); + final invalidNameStep = PreparationStepFormState( + id: 'step-2', + preparationName: const PreparationNameInputModel.dirty(''), + preparationTime: const PreparationTimeInputModel.dirty( + Duration(minutes: 5), + ), + ); + final state = PreparationFormState( + preparationStepList: [validStep, invalidNameStep], + addingStepId: 'step-2', + showValidationErrors: true, + ); + + expect(state.visiblePreparationStepList, [validStep, invalidNameStep]); + expect(state.firstInvalidStep, invalidNameStep); + expect( + state.invalidFieldFor(invalidNameStep), + PreparationFormInvalidField.name, + ); + expect(state.invalidFieldFor(validStep), isNull); + + final cleared = state.copyWith(clearAddingStepId: true, isValid: true); + expect(cleared.addingStepId, isNull); + expect(cleared.isValid, isTrue); + + final entity = PreparationFormState( + preparationStepList: [validStep], + ).toPreparationEntity(); + expect(entity.preparationStepList.single.id, 'step-1'); + expect(entity.preparationStepList.single.nextPreparationId, isNull); + }); +} + +PreparationEntity _preparation() { + return const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step-2', + preparationName: 'Pack', + preparationTime: Duration(minutes: 5), + ), + PreparationStepEntity( + id: 'step-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + nextPreparationId: 'step-2', + ), + ], + ); +} diff --git a/test/presentation/schedule_create/preparation_form/preparation_step_form_cubit_test.dart b/test/presentation/schedule_create/preparation_form/preparation_step_form_cubit_test.dart new file mode 100644 index 00000000..bee4023e --- /dev/null +++ b/test/presentation/schedule_create/preparation_form/preparation_step_form_cubit_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart'; + +void main() { + test('name and time edits update validity for a preparation step draft', () { + final preparationFormBloc = _FakePreparationFormBloc(); + final cubit = PreparationStepFormCubit( + PreparationStepFormState(id: 'step-1'), + preparationFormBloc: preparationFormBloc, + ); + addTearDown(cubit.close); + + cubit.nameChanged('Shower'); + expect(cubit.state.preparationName.value, 'Shower'); + expect(cubit.state.isValid, isTrue); + + cubit.timeChanged(const Duration(minutes: 10)); + expect(cubit.state.preparationTime.value, const Duration(minutes: 10)); + expect(cubit.state.isValid, isTrue); + }); + + test( + 'saving a step sends the current draft to the preparation form bloc', + () { + final preparationFormBloc = _FakePreparationFormBloc(); + final cubit = PreparationStepFormCubit( + PreparationStepFormState(id: 'step-1'), + preparationFormBloc: preparationFormBloc, + ); + addTearDown(cubit.close); + + cubit.nameChanged('Pack'); + cubit.timeChanged(const Duration(minutes: 5)); + cubit.preparationStepSaved(); + + final event = preparationFormBloc.addedEvents + .whereType() + .single; + expect(event.preparationStep.id, 'step-1'); + expect(event.preparationStep.preparationName.value, 'Pack'); + expect( + event.preparationStep.preparationTime.value, + const Duration(minutes: 5), + ); + }, + ); +} + +class _FakePreparationFormBloc implements PreparationFormBloc { + final addedEvents = []; + + @override + void add(PreparationFormEvent event) { + addedEvents.add(event); + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/presentation/schedule_create/schedule_date_time/schedule_date_time_cubit_test.dart b/test/presentation/schedule_create/schedule_date_time/schedule_date_time_cubit_test.dart new file mode 100644 index 00000000..93338617 --- /dev/null +++ b/test/presentation/schedule_create/schedule_date_time/schedule_date_time_cubit_test.dart @@ -0,0 +1,447 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/adjacent_schedules_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_date_time/input_models/schedule_date_input_model.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_date_time/input_models/schedule_time_input_model.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +void main() { + test('state combines selected date and time and clears overlap fields', () { + final date = DateTime.now().add(const Duration(days: 3)); + final time = DateTime(2026, 1, 1, 9, 30); + final state = ScheduleDateTimeState( + scheduleDate: ScheduleDateInputModel.dirty(date), + scheduleTime: ScheduleTimeInputModel.dirty(time), + isOverlapping: true, + nextScheduleName: 'Next', + nextPreparationStartTime: DateTime(2026, 1, 1, 8), + previousOverlapDuration: const Duration(minutes: 20), + previousScheduleName: 'Previous', + ); + + expect( + state.selectedScheduleDateTime, + DateTime(date.year, date.month, date.day, 9, 30), + ); + expect(state.hasAnyOverlapMessage, isTrue); + + final cleared = state.copyWith( + clearOverlap: true, + clearPreviousOverlap: true, + ); + expect(cleared.isOverlapping, isFalse); + expect(cleared.nextScheduleName, isNull); + expect(cleared.nextPreparationStartTime, isNull); + expect(cleared.previousOverlapDuration, isNull); + expect(cleared.previousScheduleName, isNull); + }); + + testWidgets('state returns localized overlap and past-time messages', ( + tester, + ) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + theme: themeData, + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + capturedContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final past = DateTime.now().subtract(const Duration(days: 1)); + final state = ScheduleDateTimeState( + scheduleDate: ScheduleDateInputModel.dirty(past), + scheduleTime: ScheduleTimeInputModel.dirty(past), + isOverlapping: true, + nextScheduleName: 'Next meeting', + nextPreparationStartTime: DateTime(2026, 1, 1, 8), + previousOverlapDuration: const Duration(minutes: 15), + previousScheduleName: 'Previous meeting', + ); + + expect(state.getOverlapMessage(capturedContext), contains('Next meeting')); + expect( + state.getPreviousOverlapMessage(capturedContext), + contains('Previous meeting'), + ); + expect(state.getPastScheduleTimeMessage(capturedContext), isNotNull); + }); + + test( + 'date and time changes load adjacent schedules and validate form', + () async { + final formBloc = _FakeScheduleFormBloc(); + final loader = _FakeLoadAdjacentScheduleWithPreparationUseCase(); + final adjacent = _FakeGetAdjacentSchedulesWithPreparationUseCase(); + final cubit = ScheduleDateTimeCubit(formBloc, loader, adjacent); + addTearDown(cubit.close); + final date = DateTime.now().add(const Duration(days: 2)); + final time = DateTime(date.year, date.month, date.day, 9, 30); + + await cubit.scheduleDateChanged(date); + await cubit.scheduleTimeChanged(time); + + expect(loader.calls, [ + ( + DateTime( + date.year, + date.month, + date.day, + ).subtract(const Duration(days: 1)), + DateTime( + date.year, + date.month, + date.day, + ).add(const Duration(days: 2)), + ), + ]); + expect( + adjacent.calls.single.selectedDateTime, + DateTime(date.year, date.month, date.day, 9, 30), + ); + expect( + formBloc.addedEvents.whereType().map( + (event) => event.isValid, + ), + containsAll([false, true]), + ); + }, + ); + + test('initialize validates invalid form state without loading schedules', () { + final formBloc = _FakeScheduleFormBloc(state: ScheduleFormState()); + final loader = _FakeLoadAdjacentScheduleWithPreparationUseCase(); + final adjacent = _FakeGetAdjacentSchedulesWithPreparationUseCase(); + final cubit = ScheduleDateTimeCubit(formBloc, loader, adjacent); + addTearDown(cubit.close); + + cubit.initialize(); + cubit.validateCurrentSelection(); + + expect(loader.calls, isEmpty); + expect(adjacent.calls, isEmpty); + expect( + formBloc.addedEvents.whereType().map( + (event) => event.isValid, + ), + [false, false], + ); + }); + + test( + 'initialize loads adjacent schedules for an editable scheduled time', + () async { + final scheduledAt = DateTime.now().add(const Duration(days: 2)); + final formBloc = _FakeScheduleFormBloc( + state: ScheduleFormState( + id: 'editing-schedule', + scheduleTime: scheduledAt, + ), + ); + final loader = _FakeLoadAdjacentScheduleWithPreparationUseCase(); + final adjacent = _FakeGetAdjacentSchedulesWithPreparationUseCase(); + final cubit = ScheduleDateTimeCubit(formBloc, loader, adjacent); + addTearDown(cubit.close); + + cubit.initialize(); + await pumpEventQueue(); + + expect(loader.calls, [ + ( + DateTime( + scheduledAt.year, + scheduledAt.month, + scheduledAt.day, + ).subtract(const Duration(days: 1)), + DateTime( + scheduledAt.year, + scheduledAt.month, + scheduledAt.day, + ).add(const Duration(days: 2)), + ), + ]); + expect(adjacent.calls.single.currentScheduleId, 'editing-schedule'); + }, + ); + + test('overlap check marks next schedule conflicts as invalid', () async { + final formBloc = _FakeScheduleFormBloc(); + final loader = _FakeLoadAdjacentScheduleWithPreparationUseCase(); + final adjacent = _FakeGetAdjacentSchedulesWithPreparationUseCase(); + final selected = DateTime.now().add(const Duration(days: 2)); + adjacent.result = AdjacentSchedulesWithPreparationEntity( + nextSchedule: _scheduleWithPreparation( + id: 'next', + name: 'Next meeting', + scheduleTime: DateTime(selected.year, selected.month, selected.day, 9), + preparationMinutes: 30, + ), + ); + final cubit = ScheduleDateTimeCubit(formBloc, loader, adjacent); + addTearDown(cubit.close); + + await cubit.scheduleDateChanged(selected); + await cubit.scheduleTimeChanged( + DateTime(selected.year, selected.month, selected.day, 8, 45), + ); + + expect(cubit.state.isOverlapping, isTrue); + expect(cubit.state.nextScheduleName, 'Next meeting'); + expect(cubit.scheduleDateTimeSubmitted(), isFalse); + }); + + test( + 'overlap check clears next conflict when next preparation starts later', + () async { + final formBloc = _FakeScheduleFormBloc(); + final loader = _FakeLoadAdjacentScheduleWithPreparationUseCase(); + final adjacent = _FakeGetAdjacentSchedulesWithPreparationUseCase(); + final selected = DateTime.now().add(const Duration(days: 2)); + adjacent.result = AdjacentSchedulesWithPreparationEntity( + nextSchedule: _scheduleWithPreparation( + id: 'next', + name: 'Later meeting', + scheduleTime: DateTime( + selected.year, + selected.month, + selected.day, + 10, + ), + preparationMinutes: 15, + ), + ); + final cubit = ScheduleDateTimeCubit(formBloc, loader, adjacent); + addTearDown(cubit.close); + + await cubit.scheduleDateChanged(selected); + await cubit.scheduleTimeChanged( + DateTime(selected.year, selected.month, selected.day, 9), + ); + + expect(cubit.state.isOverlapping, isFalse); + expect(cubit.state.nextScheduleName, isNull); + }, + ); + + test( + 'previous schedule branches retain warning context for small and large gaps', + () async { + final selected = DateTime.now().add(const Duration(days: 2)); + final formBloc = _FakeScheduleFormBloc(); + final loader = _FakeLoadAdjacentScheduleWithPreparationUseCase(); + final adjacent = _FakeGetAdjacentSchedulesWithPreparationUseCase(); + adjacent.result = AdjacentSchedulesWithPreparationEntity( + previousSchedule: _scheduleWithPreparation( + id: 'previous', + name: 'Previous meeting', + scheduleTime: DateTime( + selected.year, + selected.month, + selected.day, + 5, + ), + preparationMinutes: 10, + ), + ); + final cubit = ScheduleDateTimeCubit(formBloc, loader, adjacent); + addTearDown(cubit.close); + + await cubit.scheduleDateChanged(selected); + await cubit.scheduleTimeChanged( + DateTime(selected.year, selected.month, selected.day, 9), + ); + + expect(cubit.state.previousScheduleName, 'Previous meeting'); + expect(cubit.state.previousOverlapDuration, const Duration(hours: 4)); + expect(cubit.state.hasPreviousOverlapMessage, isFalse); + }, + ); + + test('overlap errors clear stale overlap state and mark invalid', () async { + final formBloc = _FakeScheduleFormBloc(); + final loader = _FakeLoadAdjacentScheduleWithPreparationUseCase(); + final adjacent = _FakeGetAdjacentSchedulesWithPreparationUseCase() + ..throwsOnCall = true; + final selected = DateTime.now().add(const Duration(days: 2)); + final cubit = ScheduleDateTimeCubit(formBloc, loader, adjacent); + addTearDown(cubit.close); + + await cubit.scheduleDateChanged(selected); + await cubit.scheduleTimeChanged( + DateTime(selected.year, selected.month, selected.day, 9), + ); + + expect(cubit.state.isOverlapping, isFalse); + expect(cubit.state.previousOverlapDuration, isNull); + expect( + formBloc.addedEvents.whereType().last.isValid, + isTrue, + ); + }); + + test( + 'submission forwards selected time and previous available gap', + () async { + final formBloc = _FakeScheduleFormBloc(); + final loader = _FakeLoadAdjacentScheduleWithPreparationUseCase(); + final adjacent = _FakeGetAdjacentSchedulesWithPreparationUseCase(); + final selected = DateTime.now().add(const Duration(days: 2)); + adjacent.result = AdjacentSchedulesWithPreparationEntity( + previousSchedule: _scheduleWithPreparation( + id: 'previous', + name: 'Previous meeting', + scheduleTime: DateTime( + selected.year, + selected.month, + selected.day, + 8, + ), + preparationMinutes: 10, + ), + ); + final cubit = ScheduleDateTimeCubit(formBloc, loader, adjacent); + addTearDown(cubit.close); + + await cubit.scheduleDateChanged(selected); + await cubit.scheduleTimeChanged( + DateTime(selected.year, selected.month, selected.day, 8, 30), + ); + + expect(cubit.scheduleDateTimeSubmitted(), isTrue); + final submitted = formBloc.addedEvents + .whereType() + .single; + expect(submitted.scheduleDate, selected); + expect(submitted.scheduleTime.hour, 8); + expect(submitted.scheduleTime.minute, 30); + expect(submitted.maxAvailableTime, const Duration(minutes: 30)); + expect(submitted.previousScheduleName, 'Previous meeting'); + }, + ); +} + +ScheduleWithPreparationEntity _scheduleWithPreparation({ + required String id, + required String name, + required DateTime scheduleTime, + required int preparationMinutes, +}) { + return ScheduleWithPreparationEntity( + id: id, + place: const PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: name, + scheduleTime: scheduleTime, + moveTime: Duration.zero, + isChanged: false, + isStarted: false, + scheduleSpareTime: Duration.zero, + scheduleNote: '', + preparation: PreparationWithTimeEntity( + preparationStepList: [ + PreparationStepWithTimeEntity( + id: 'prep-$id', + preparationName: 'Prepare', + preparationTime: Duration(minutes: preparationMinutes), + nextPreparationId: null, + ), + ], + ), + ); +} + +class _FakeScheduleFormBloc implements ScheduleFormBloc { + _FakeScheduleFormBloc({ScheduleFormState? state}) + : _state = state ?? ScheduleFormState(id: 'current-schedule'); + + final addedEvents = []; + final ScheduleFormState _state; + + @override + ScheduleFormState get state => _state; + + @override + void add(ScheduleFormEvent event) { + addedEvents.add(event); + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeLoadAdjacentScheduleWithPreparationUseCase + implements LoadAdjacentScheduleWithPreparationUseCase { + final calls = <(DateTime, DateTime)>[]; + + @override + Future call({ + required DateTime startDate, + required DateTime endDate, + }) async { + calls.add((startDate, endDate)); + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _AdjacentCall { + const _AdjacentCall({ + required this.selectedDateTime, + required this.currentScheduleId, + required this.startDate, + required this.endDate, + }); + + final DateTime selectedDateTime; + final String? currentScheduleId; + final DateTime startDate; + final DateTime endDate; +} + +class _FakeGetAdjacentSchedulesWithPreparationUseCase + implements GetAdjacentSchedulesWithPreparationUseCase { + AdjacentSchedulesWithPreparationEntity result = + const AdjacentSchedulesWithPreparationEntity(); + final calls = <_AdjacentCall>[]; + bool throwsOnCall = false; + + @override + Future call({ + required DateTime selectedDateTime, + String? currentScheduleId, + required DateTime startDate, + required DateTime endDate, + }) async { + calls.add( + _AdjacentCall( + selectedDateTime: selectedDateTime, + currentScheduleId: currentScheduleId, + startDate: startDate, + endDate: endDate, + ), + ); + if (throwsOnCall) { + throw Exception('adjacent schedules unavailable'); + } + return result; + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/presentation/schedule_create/schedule_date_time/schedule_date_time_form_test.dart b/test/presentation/schedule_create/schedule_date_time/schedule_date_time_form_test.dart new file mode 100644 index 00000000..49f03e8a --- /dev/null +++ b/test/presentation/schedule_create/schedule_date_time/schedule_date_time_form_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/adjacent_schedules_with_preparation_entity.dart'; +import 'package:on_time_front/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_adjacent_schedule_with_preparation_use_case.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_date_time/cubit/schedule_date_time_cubit.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_date_time/screens/schedule_date_time_form.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +void main() { + testWidgets('saves date and time picker selections through the cubit', ( + tester, + ) async { + final scheduledAt = DateTime.now().add(const Duration(days: 2)); + final formBloc = _FakeScheduleFormBloc( + ScheduleFormState(id: 'schedule-1', scheduleTime: scheduledAt), + ); + final loader = _FakeLoadAdjacentSchedulesWithPreparationUseCase(); + final adjacent = _FakeGetAdjacentSchedulesWithPreparationUseCase(); + final cubit = ScheduleDateTimeCubit(formBloc, loader, adjacent) + ..initialize(); + addTearDown(cubit.close); + + await _pumpForm(tester, cubit: cubit); + + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TextField).last); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(loader.calls, isNotEmpty); + expect(adjacent.calls, isNotEmpty); + expect(formBloc.addedEvents.whereType(), isNotEmpty); + }); + + testWidgets('formats selected date with Korean locale', (tester) async { + final scheduledAt = DateTime(2026, 5, 15, 9, 30); + final formBloc = _FakeScheduleFormBloc( + ScheduleFormState(id: 'schedule-1', scheduleTime: scheduledAt), + ); + final cubit = ScheduleDateTimeCubit( + formBloc, + _FakeLoadAdjacentSchedulesWithPreparationUseCase(), + _FakeGetAdjacentSchedulesWithPreparationUseCase(), + )..initialize(); + addTearDown(cubit.close); + + await _pumpForm(tester, cubit: cubit, locale: const Locale('ko')); + + expect(find.text('2026년 05월 15일'), findsWidgets); + }); +} + +Future _pumpForm( + WidgetTester tester, { + required ScheduleDateTimeCubit cubit, + Locale locale = const Locale('en'), +}) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData, + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BlocProvider.value( + value: cubit, + child: const ScheduleDateTimeForm(), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +class _FakeScheduleFormBloc implements ScheduleFormBloc { + _FakeScheduleFormBloc(this._state); + + final ScheduleFormState _state; + final addedEvents = []; + + @override + ScheduleFormState get state => _state; + + @override + Stream get stream => const Stream.empty(); + + @override + bool get isClosed => false; + + @override + void add(ScheduleFormEvent event) { + addedEvents.add(event); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeLoadAdjacentSchedulesWithPreparationUseCase + implements LoadAdjacentScheduleWithPreparationUseCase { + final calls = <(DateTime, DateTime)>[]; + + @override + Future call({ + required DateTime startDate, + required DateTime endDate, + }) async { + calls.add((startDate, endDate)); + } +} + +class _FakeAdjacentCall { + const _FakeAdjacentCall(this.selectedDateTime); + + final DateTime selectedDateTime; +} + +class _FakeGetAdjacentSchedulesWithPreparationUseCase + implements GetAdjacentSchedulesWithPreparationUseCase { + final calls = <_FakeAdjacentCall>[]; + + @override + Future call({ + required DateTime selectedDateTime, + String? currentScheduleId, + required DateTime startDate, + required DateTime endDate, + }) async { + calls.add(_FakeAdjacentCall(selectedDateTime)); + return const AdjacentSchedulesWithPreparationEntity(); + } +} diff --git a/test/presentation/schedule_create/schedule_place_moving_time/schedule_place_moving_time_form_test.dart b/test/presentation/schedule_create/schedule_place_moving_time/schedule_place_moving_time_form_test.dart new file mode 100644 index 00000000..c7217015 --- /dev/null +++ b/test/presentation/schedule_create/schedule_place_moving_time/schedule_place_moving_time_form_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_place_moving_time.dart/cubit/schedule_place_moving_time_cubit.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_place_moving_time.dart/screens/schedule_place_moving_time_form.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +void main() { + testWidgets('edits place name and saves travel time picker selection', ( + tester, + ) async { + final formBloc = _FakeScheduleFormBloc( + ScheduleFormState( + placeName: 'Office', + moveTime: const Duration(minutes: 20), + scheduleTime: DateTime(2026, 5, 15, 9), + maxAvailableTime: const Duration(minutes: 45), + ), + ); + final cubit = SchedulePlaceMovingTimeCubit(scheduleFormBloc: formBloc) + ..initialize(); + addTearDown(cubit.close); + + await _pumpForm(tester, cubit: cubit); + + await tester.enterText(find.byType(TextFormField), 'Client site'); + await tester.tap(find.byType(TextField).last); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(cubit.state.placeName.value, 'Client site'); + expect(formBloc.addedEvents.whereType(), isNotEmpty); + }); + + testWidgets('shows overlap warning from available travel window', ( + tester, + ) async { + final formBloc = _FakeScheduleFormBloc( + ScheduleFormState( + placeName: 'Office', + moveTime: const Duration(minutes: 20), + scheduleTime: DateTime(2026, 5, 15, 9), + maxAvailableTime: const Duration(minutes: 25), + previousScheduleName: 'Previous meeting', + ), + ); + final cubit = SchedulePlaceMovingTimeCubit(scheduleFormBloc: formBloc) + ..initialize(); + addTearDown(cubit.close); + + await _pumpForm(tester, cubit: cubit); + + expect(find.textContaining('Previous meeting'), findsOneWidget); + }); +} + +Future _pumpForm( + WidgetTester tester, { + required SchedulePlaceMovingTimeCubit cubit, +}) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData, + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BlocProvider.value( + value: cubit.scheduleFormBloc, + child: BlocProvider.value( + value: cubit, + child: const SchedulePlaceMovingTimeForm(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +class _FakeScheduleFormBloc implements ScheduleFormBloc { + _FakeScheduleFormBloc(this._state); + + final ScheduleFormState _state; + final addedEvents = []; + + @override + ScheduleFormState get state => _state; + + @override + Stream get stream => const Stream.empty(); + + @override + bool get isClosed => false; + + @override + void add(ScheduleFormEvent event) { + addedEvents.add(event); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_state_test.dart b/test/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_state_test.dart new file mode 100644 index 00000000..88391565 --- /dev/null +++ b/test/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_state_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/input_models/schedule_spare_time_input_model.dart'; + +void main() { + test('validity follows spare-time input and overlap error state', () { + const valid = ScheduleFormSpareTimeState( + spareTime: ScheduleSpareTimeInputModel.dirty(Duration(minutes: 10)), + ); + const overlapping = ScheduleFormSpareTimeState( + spareTime: ScheduleSpareTimeInputModel.dirty(Duration(minutes: 10)), + overlapDuration: Duration(minutes: 2), + isOverlapping: true, + ); + + expect(valid.isValid, isTrue); + expect(valid.hasOverlapMessage, isFalse); + expect(valid.isOverlapError, isFalse); + expect(overlapping.isValid, isFalse); + expect(overlapping.hasOverlapMessage, isTrue); + expect(overlapping.isOverlapError, isTrue); + }); + + test('copyWith can update or clear overlap state', () { + const state = ScheduleFormSpareTimeState( + spareTime: ScheduleSpareTimeInputModel.dirty(Duration(minutes: 5)), + overlapDuration: Duration(minutes: 3), + isOverlapping: true, + ); + + final updated = state.copyWith( + totalPreparationTime: const Duration(minutes: 20), + overlapDuration: const Duration(minutes: 1), + isOverlapping: false, + ); + final cleared = updated.copyWith(clearOverlap: true); + + expect(updated.totalPreparationTime, const Duration(minutes: 20)); + expect(updated.overlapDuration, const Duration(minutes: 1)); + expect(updated.isOverlapping, isFalse); + expect(cleared.overlapDuration, isNull); + expect(cleared.isOverlapping, isFalse); + }); + + test('fromScheduleFormState carries spare time and preparation duration', () { + final preparation = _preparation(); + final formState = ScheduleFormState( + scheduleSpareTime: const Duration(minutes: 8), + preparation: preparation, + ); + + final state = ScheduleFormSpareTimeState.fromScheduleFormState(formState); + + expect(state.spareTime.value, const Duration(minutes: 8)); + expect(state.preparation, preparation); + expect(state.totalPreparationTime, const Duration(minutes: 15)); + expect(state.props, [ + const ScheduleSpareTimeInputModel.pure(Duration(minutes: 8)), + preparation, + const Duration(minutes: 15), + const Duration(), + false, + ]); + }); +} + +PreparationEntity _preparation() { + return const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + nextPreparationId: 'step-2', + ), + PreparationStepEntity( + id: 'step-2', + preparationName: 'Pack', + preparationTime: Duration(minutes: 5), + ), + ], + ); +} diff --git a/test/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc_test.dart b/test/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc_test.dart index 1148cbf9..3bcb20e3 100644 --- a/test/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc_test.dart +++ b/test/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart'; void main() { late PreparationFormBloc bloc; @@ -460,4 +461,146 @@ void main() { PreparationFormInvalidField.name, ); }); + + test('does not remove the last remaining preparation step', () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + final originalState = await editState; + + bloc.add( + const PreparationFormPreparationStepRemoved(preparationStepId: 'step-1'), + ); + await pumpEventQueue(); + + expect(bloc.state, originalState); + }); + + test('ignores duplicate add requests while a draft row is active', () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + await editState; + + final addingState = waitForState( + (state) => + state.status == PreparationFormStatus.adding && + state.preparationStepList.length == 2, + ); + bloc.add(const PreparationFormPreparationStepCreationRequested()); + final stateAfterFirstAdd = await addingState; + + bloc.add(const PreparationFormPreparationStepCreationRequested()); + await pumpEventQueue(); + + expect( + bloc.state.preparationStepList, + stateAfterFirstAdd.preparationStepList, + ); + expect(bloc.state.addingStepId, stateAfterFirstAdd.addingStepId); + }); + + test( + 'created event exits adding mode without appending invalid step', + () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + await editState; + + final addingState = waitForState( + (state) => + state.status == PreparationFormStatus.adding && + state.preparationStepList.length == 2, + ); + bloc.add(const PreparationFormPreparationStepCreationRequested()); + await addingState; + + final createdState = waitForState( + (state) => + state.status == PreparationFormStatus.initial && + state.addingStepId == null, + ); + bloc.add( + PreparationFormPreparationStepCreated( + preparationStep: PreparationStepFormState(), + ), + ); + + final state = await createdState; + + expect(state.preparationStepList, hasLength(2)); + expect(state.preparationStepList.first.id, 'step-1'); + expect(state.preparationStepList.last.preparationName.isValid, isFalse); + expect(state.isValid, isFalse); + }, + ); + + test( + 'draft name and time changes are ignored without active draft', + () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + final originalState = await editState; + + bloc + ..add( + const PreparationFormDraftStepNameChanged( + preparationStepName: 'Pack', + ), + ) + ..add( + const PreparationFormDraftStepTimeChanged( + preparationStepTime: Duration(minutes: 5), + ), + ); + await pumpEventQueue(); + + expect(bloc.state, originalState); + }, + ); + + test('invalid reorder indices leave preparation order unchanged', () async { + final twoStepPreparation = PreparationEntity( + preparationStepList: [ + ...preparation.preparationStepList, + const PreparationStepEntity( + id: 'step-2', + preparationName: 'Pack', + preparationTime: Duration(minutes: 5), + nextPreparationId: null, + ), + ], + ); + bloc.add( + PreparationFormEditRequested(preparationEntity: twoStepPreparation), + ); + await pumpEventQueue(); + final originalState = bloc.state; + + bloc + ..add( + const PreparationFormPreparationStepOrderChanged( + oldIndex: -1, + newIndex: 0, + ), + ) + ..add( + const PreparationFormPreparationStepOrderChanged( + oldIndex: 0, + newIndex: 3, + ), + ); + await pumpEventQueue(); + + expect( + bloc.state.preparationStepList.map((step) => step.id), + originalState.preparationStepList.map((step) => step.id), + ); + }); } diff --git a/test/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form_test.dart b/test/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form_test.dart new file mode 100644 index 00000000..a475d3b6 --- /dev/null +++ b/test/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form_test.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mockito/mockito.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_edit_draft_cubit.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +void main() { + setUp(() async { + await getIt.reset(); + getIt.registerSingleton( + PreparationEditDraftCubit(), + ); + }); + + tearDown(() async { + await getIt.reset(); + }); + + testWidgets('renders preparation and user default spare time', ( + tester, + ) async { + final formBloc = _FakeScheduleFormBloc( + ScheduleFormState( + preparation: _preparation(minutes: 12), + scheduleSpareTime: const Duration(minutes: 8), + ), + ); + final cubit = ScheduleFormSpareTimeCubit(scheduleFormBloc: formBloc) + ..initialize(); + addTearDown(cubit.close); + + await _pumpForm(tester, cubit: cubit); + + expect( + find.text('Please tell us the time required for each step.'), + findsOneWidget, + ); + expect(find.text('12 minutes'), findsOneWidget); + expect(find.text('Spare Time'), findsOneWidget); + expect(find.text('8 minutes'), findsOneWidget); + }); + + testWidgets('warning overlap message names the previous schedule', ( + tester, + ) async { + final formBloc = _FakeScheduleFormBloc( + ScheduleFormState( + scheduleTime: DateTime(2026, 5, 15, 9), + moveTime: const Duration(minutes: 10), + scheduleSpareTime: const Duration(minutes: 5), + preparation: _preparation(minutes: 20), + maxAvailableTime: const Duration(minutes: 40), + previousScheduleName: 'Previous meeting', + ), + ); + final cubit = ScheduleFormSpareTimeCubit(scheduleFormBloc: formBloc) + ..initialize(); + addTearDown(cubit.close); + + await _pumpForm(tester, cubit: cubit); + + expect(find.textContaining('To avoid overlapping'), findsOneWidget); + expect(find.textContaining('Previous meeting'), findsOneWidget); + }); + + testWidgets('error overlap message names the previous schedule', ( + tester, + ) async { + final formBloc = _FakeScheduleFormBloc( + ScheduleFormState( + scheduleTime: DateTime(2026, 5, 15, 9), + moveTime: const Duration(minutes: 10), + scheduleSpareTime: const Duration(minutes: 5), + preparation: _preparation(minutes: 20), + maxAvailableTime: const Duration(minutes: 35), + previousScheduleName: 'Previous meeting', + ), + ); + final cubit = ScheduleFormSpareTimeCubit(scheduleFormBloc: formBloc) + ..initialize(); + addTearDown(cubit.close); + + await _pumpForm(tester, cubit: cubit); + + expect(find.textContaining('Overlapped'), findsOneWidget); + expect(find.textContaining('Previous meeting'), findsOneWidget); + }); + + testWidgets('saving spare-time picker submits chosen duration', ( + tester, + ) async { + final formBloc = _FakeScheduleFormBloc( + ScheduleFormState( + scheduleSpareTime: const Duration(minutes: 8), + preparation: _preparation(minutes: 12), + ), + ); + final cubit = ScheduleFormSpareTimeCubit(scheduleFormBloc: formBloc) + ..initialize(); + addTearDown(cubit.close); + + await _pumpForm(tester, cubit: cubit); + await tester.tap(find.text('8 minutes')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(cubit.state.spareTime.value, const Duration(minutes: 8)); + expect(formBloc.addedEvents.whereType(), isNotEmpty); + }); + + testWidgets('preparation edit route updates preparation from draft', ( + tester, + ) async { + final formBloc = _FakeScheduleFormBloc( + ScheduleFormState( + scheduleSpareTime: const Duration(minutes: 8), + preparation: _preparation(minutes: 12), + ), + ); + final cubit = ScheduleFormSpareTimeCubit(scheduleFormBloc: formBloc) + ..initialize(); + addTearDown(cubit.close); + final edited = _preparation(minutes: 20); + + await _pumpForm( + tester, + cubit: cubit, + preparationEditBuilder: (context, state) { + return Scaffold( + body: ElevatedButton( + onPressed: () { + getIt.get().setDraft(edited); + context.pop(); + }, + child: const Text('save preparation'), + ), + ); + }, + ); + + await tester.tap(find.text('12 minutes')); + await tester.pumpAndSettle(); + await tester.tap(find.text('save preparation')); + await tester.pumpAndSettle(); + + expect(cubit.state.preparation, edited); + expect(cubit.state.totalPreparationTime, const Duration(minutes: 20)); + expect(getIt.get().state, isNull); + expect( + formBloc.addedEvents + .whereType() + .single + .preparation, + edited, + ); + }); +} + +Future _pumpForm( + WidgetTester tester, { + required ScheduleFormSpareTimeCubit cubit, + GoRouterWidgetBuilder? preparationEditBuilder, +}) async { + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold( + body: BlocProvider.value( + value: cubit.scheduleFormBloc, + child: BlocProvider.value( + value: _StubAuthBloc( + AuthState( + user: const UserEntity( + id: 'user-1', + email: 'user@example.com', + name: 'User', + spareTime: Duration(minutes: 8), + note: '', + score: 4.0, + isOnboardingCompleted: true, + ), + ), + ), + child: BlocProvider.value( + value: cubit, + child: const ScheduleSpareAndPreparingTimeForm(), + ), + ), + ), + ), + ), + GoRoute( + path: '/preparationEdit', + builder: + preparationEditBuilder ?? + (context, state) => const Scaffold(body: Text('edit preparation')), + ), + ], + ); + + await tester.pumpWidget( + MaterialApp.router( + theme: themeData, + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ); + await tester.pumpAndSettle(); +} + +PreparationEntity _preparation({required int minutes}) { + return PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step-$minutes', + preparationName: 'Prepare', + preparationTime: Duration(minutes: minutes), + ), + ], + ); +} + +class _StubAuthBloc extends Mock implements AuthBloc { + _StubAuthBloc(this._state); + + final AuthState _state; + + @override + AuthState get state => _state; + + @override + Stream get stream => const Stream.empty(); + + @override + bool get isClosed => false; +} + +class _FakeScheduleFormBloc implements ScheduleFormBloc { + _FakeScheduleFormBloc(this._state); + + final ScheduleFormState _state; + final addedEvents = []; + + @override + ScheduleFormState get state => _state; + + @override + Stream get stream => const Stream.empty(); + + @override + bool get isClosed => false; + + @override + void add(ScheduleFormEvent event) { + addedEvents.add(event); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/presentation/schedule_create/schedule_time_inputs_cubit_test.dart b/test/presentation/schedule_create/schedule_time_inputs_cubit_test.dart new file mode 100644 index 00000000..9c6c6091 --- /dev/null +++ b/test/presentation/schedule_create/schedule_time_inputs_cubit_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_place_moving_time.dart/cubit/schedule_place_moving_time_cubit.dart'; +import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart'; + +void main() { + test( + 'place and moving time cubit validates fields and submits to form bloc', + () { + final formBloc = _FakeScheduleFormBloc( + ScheduleFormState( + placeName: 'Office', + moveTime: const Duration(minutes: 10), + scheduleTime: DateTime(2026, 5, 15, 9), + maxAvailableTime: const Duration(minutes: 20), + previousScheduleName: 'Previous', + ), + ); + final cubit = SchedulePlaceMovingTimeCubit(scheduleFormBloc: formBloc); + addTearDown(cubit.close); + + cubit.initialize(); + cubit.placeNameChanged('Cafe'); + cubit.moveTimeChanged(const Duration(minutes: 25)); + + expect(cubit.state.placeName.value, 'Cafe'); + expect(cubit.state.moveTime.value, const Duration(minutes: 25)); + expect(cubit.state.isOverlapping, isFalse); + expect(cubit.state.hasOverlapMessage, isTrue); + + cubit.moveTimeChanged(const Duration(minutes: 5)); + expect(cubit.state.isOverlapping, isFalse); + expect(cubit.state.hasOverlapMessage, isTrue); + + cubit.schedulePlaceMovingTimeSubmitted(); + expect( + formBloc.addedEvents + .whereType() + .last + .moveTime, + const Duration(minutes: 5), + ); + expect( + formBloc.addedEvents + .whereType() + .last + .placeName, + 'Cafe', + ); + }, + ); + + test('spare time cubit detects overlap and submits spare time', () { + final preparation = _preparation(minutes: 20); + final formBloc = _FakeScheduleFormBloc( + ScheduleFormState( + scheduleTime: DateTime(2026, 5, 15, 9), + moveTime: const Duration(minutes: 10), + scheduleSpareTime: const Duration(minutes: 5), + preparation: preparation, + maxAvailableTime: const Duration(minutes: 45), + previousScheduleName: 'Previous', + ), + ); + final cubit = ScheduleFormSpareTimeCubit(scheduleFormBloc: formBloc); + addTearDown(cubit.close); + + cubit.initialize(); + expect(cubit.state.totalPreparationTime, const Duration(minutes: 20)); + expect(cubit.state.overlapDuration, const Duration(minutes: 10)); + expect(cubit.state.isOverlapping, isFalse); + + cubit.spareTimeChanged(const Duration(minutes: 30)); + expect(cubit.state.isOverlapping, isTrue); + expect(cubit.state.isValid, isFalse); + + cubit.spareTimeChanged(const Duration(minutes: 5)); + cubit.scheduleSpareTimeSubmitted(); + expect( + formBloc.addedEvents + .whereType() + .last + .scheduleSpareTime, + const Duration(minutes: 5), + ); + }); + + test('preparation changes update total time and notify the form bloc', () { + final formBloc = _FakeScheduleFormBloc( + ScheduleFormState( + scheduleTime: DateTime(2026, 5, 15, 9), + moveTime: const Duration(minutes: 10), + scheduleSpareTime: const Duration(minutes: 5), + maxAvailableTime: const Duration(minutes: 60), + ), + ); + final cubit = ScheduleFormSpareTimeCubit(scheduleFormBloc: formBloc); + addTearDown(cubit.close); + final preparation = _preparation(minutes: 25); + + cubit.preparationChanged(preparation); + + expect(cubit.state.preparation, preparation); + expect(cubit.state.totalPreparationTime, const Duration(minutes: 25)); + expect( + formBloc.addedEvents + .whereType() + .single + .preparation, + preparation, + ); + expect(formBloc.addedEvents.whereType(), isNotEmpty); + }); +} + +PreparationEntity _preparation({required int minutes}) { + return PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step-$minutes', + preparationName: 'Prepare', + preparationTime: Duration(minutes: minutes), + ), + ], + ); +} + +class _FakeScheduleFormBloc implements ScheduleFormBloc { + _FakeScheduleFormBloc(this._state); + + final ScheduleFormState _state; + final addedEvents = []; + + @override + ScheduleFormState get state => _state; + + @override + void add(ScheduleFormEvent event) { + addedEvents.add(event); + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/presentation/shared/arc_indicator_test.dart b/test/presentation/shared/arc_indicator_test.dart new file mode 100644 index 00000000..cd17c054 --- /dev/null +++ b/test/presentation/shared/arc_indicator_test.dart @@ -0,0 +1,21 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/presentation/shared/components/arc_indicator.dart'; + +void main() { + test('ArcIndicator paints background and progress arcs without throwing', () { + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + final painter = ArcIndicator(progress: 0.5, strokeWidth: 8); + + painter.paint(canvas, const Size(120, 80)); + final picture = recorder.endRecording(); + addTearDown(picture.dispose); + + expect( + painter.shouldRepaint(ArcIndicator(progress: 0.5, strokeWidth: 8)), + isTrue, + ); + }); +} diff --git a/test/presentation/shared/components/cupertino_picker_modal_test.dart b/test/presentation/shared/components/cupertino_picker_modal_test.dart new file mode 100644 index 00000000..55c9d973 --- /dev/null +++ b/test/presentation/shared/components/cupertino_picker_modal_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/shared/components/cupertino_picker_modal.dart'; + +void main() { + testWidgets('minute picker saves the selected duration and disposes', ( + tester, + ) async { + Duration? saved; + var disposedCount = 0; + + await tester.pumpWidget( + _TestApp( + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => context.showCupertinoMinutePickerModal( + title: 'Preparation minutes', + initialValue: const Duration(minutes: 7), + onSaved: (value) => saved = value, + onDisposed: () => disposedCount += 1, + ), + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('Preparation minutes'), findsOneWidget); + expect(find.text('07'), findsOneWidget); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(saved, const Duration(minutes: 7)); + expect(disposedCount, 1); + expect(find.text('Preparation minutes'), findsNothing); + }); + + testWidgets('minute picker cancel disposes without saving', (tester) async { + Duration? saved; + var disposedCount = 0; + + await tester.pumpWidget( + _TestApp( + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => context.showCupertinoMinutePickerModal( + title: 'Preparation minutes', + initialValue: const Duration(minutes: 3), + onSaved: (value) => saved = value, + onDisposed: () => disposedCount += 1, + ), + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(saved, isNull); + expect(disposedCount, 1); + }); + + testWidgets('timer picker saves the initial timer duration', (tester) async { + Duration? saved; + + await tester.pumpWidget( + _TestApp( + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => context.showCupertinoTimerPickerModal( + title: 'Move time', + initialValue: const Duration(minutes: 20), + mode: CupertinoTimerPickerMode.hm, + onSaved: (value) => saved = value, + ), + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(saved, const Duration(minutes: 20)); + }); + + testWidgets('date picker saves the initial date value', (tester) async { + DateTime? saved; + final initial = DateTime(2026, 5, 15, 9, 30); + + await tester.pumpWidget( + _TestApp( + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => context.showCupertinoDatePickerModal( + title: 'Schedule date', + initialValue: initial, + mode: CupertinoDatePickerMode.dateAndTime, + onSaved: (value) => saved = value, + ), + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(saved, initial); + }); +} + +class _TestApp extends StatelessWidget { + const _TestApp({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: Center(child: child)), + ); + } +} diff --git a/test/presentation/shared/components/custom_alert_dialog_test.dart b/test/presentation/shared/components/custom_alert_dialog_test.dart new file mode 100644 index 00000000..41369a4a --- /dev/null +++ b/test/presentation/shared/components/custom_alert_dialog_test.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/presentation/shared/components/custom_alert_dialog.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +void main() { + testWidgets('renders title, content, and actions with configured spacing', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const Scaffold( + body: CustomAlertDialog( + title: Text('Delete schedule?'), + content: Text('This cannot be undone.'), + actions: [ + TextButton(onPressed: null, child: Text('Cancel')), + TextButton(onPressed: null, child: Text('Delete')), + ], + titleContentSpacing: 12, + contentActionsSpacing: 24, + innerPadding: EdgeInsets.all(10), + ), + ), + ), + ); + + expect(find.text('Delete schedule?'), findsOneWidget); + expect(find.text('This cannot be undone.'), findsOneWidget); + expect(find.byType(OverflowBar), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('supports content-only dialog with explicit semantic label', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const Scaffold( + body: CustomAlertDialog( + semanticLabel: 'Info dialog', + content: Text('Saved'), + contentTextAlign: TextAlign.center, + actionsAlignment: MainAxisAlignment.center, + ), + ), + ), + ); + + expect(find.text('Saved'), findsOneWidget); + expect(find.bySemanticsLabel('Info dialog'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('title-only dialog scales padding for large text', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(2.5)), + child: Scaffold( + body: CustomAlertDialog( + title: Text('Large title'), + titleTextAlign: TextAlign.center, + ), + ), + ), + ), + ); + + expect(find.text('Large title'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('iOS dialog omits default alert route label', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData.copyWith(platform: TargetPlatform.iOS), + home: const Scaffold( + body: CustomAlertDialog( + title: Text('Cupertino title'), + content: Text('No default Android alert label'), + ), + ), + ), + ); + + expect(find.text('Cupertino title'), findsOneWidget); + expect(find.bySemanticsLabel('Alert'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('custom theme text styles are applied to title and content', ( + tester, + ) async { + const titleStyle = TextStyle(fontSize: 23, color: Colors.red); + const contentStyle = TextStyle(fontSize: 17, color: Colors.green); + + await tester.pumpWidget( + MaterialApp( + theme: themeData.copyWith( + dialogTheme: const DialogThemeData( + titleTextStyle: titleStyle, + contentTextStyle: contentStyle, + ), + ), + home: const Scaffold( + body: CustomAlertDialog( + title: Text('Styled title'), + content: Text('Styled content'), + ), + ), + ), + ); + + final titleDefaultTextStyle = tester.widget( + find + .ancestor( + of: find.text('Styled title'), + matching: find.byType(DefaultTextStyle), + ) + .first, + ); + final contentDefaultTextStyle = tester.widget( + find + .ancestor( + of: find.text('Styled content'), + matching: find.byType(DefaultTextStyle), + ) + .first, + ); + + expect(titleDefaultTextStyle.style, titleStyle); + expect(contentDefaultTextStyle.style, contentStyle); + }); + + testWidgets('android dialogs use default alert semantics and action layout', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData.copyWith(platform: TargetPlatform.android), + home: const Scaffold( + body: CustomAlertDialog( + title: Text('Android title'), + content: Text('Android content'), + buttonPadding: EdgeInsets.symmetric(horizontal: 24), + actionsAlignment: MainAxisAlignment.spaceBetween, + actionsOverflowAlignment: OverflowBarAlignment.center, + actionsOverflowDirection: VerticalDirection.up, + actionsOverflowButtonSpacing: 6, + actions: [ + TextButton(onPressed: null, child: Text('Later')), + TextButton(onPressed: null, child: Text('OK')), + ], + ), + ), + ), + ); + + final overflowBar = tester.widget(find.byType(OverflowBar)); + + expect(find.bySemanticsLabel('Alert'), findsOneWidget); + expect(overflowBar.alignment, MainAxisAlignment.spaceBetween); + expect(overflowBar.spacing, 24); + expect(overflowBar.overflowAlignment, OverflowBarAlignment.center); + expect(overflowBar.overflowDirection, VerticalDirection.up); + expect(overflowBar.overflowSpacing, 6); + }); + + testWidgets('default dialog styles come from the active material theme', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const Scaffold( + body: CustomAlertDialog( + title: Text('Default title'), + content: Text('Default content'), + ), + ), + ), + ); + + final titleDefaultTextStyle = tester.widget( + find + .ancestor( + of: find.text('Default title'), + matching: find.byType(DefaultTextStyle), + ) + .first, + ); + final contentDefaultTextStyle = tester.widget( + find + .ancestor( + of: find.text('Default content'), + matching: find.byType(DefaultTextStyle), + ) + .first, + ); + + expect(titleDefaultTextStyle.style.fontWeight, FontWeight.w600); + expect(contentDefaultTextStyle.style.fontSize, 14); + expect(contentDefaultTextStyle.style.fontWeight, FontWeight.w400); + }); +} diff --git a/test/presentation/shared/constants/app_colors_test.dart b/test/presentation/shared/constants/app_colors_test.dart new file mode 100644 index 00000000..1ae47937 --- /dev/null +++ b/test/presentation/shared/constants/app_colors_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; +import 'package:on_time_front/presentation/shared/constants/constants.dart'; + +void main() { + test('white opacity colors keep the documented alpha ladder', () { + expect(AppColors.white, const Color(0xFFFFFFFF)); + expect(AppColors.white90.a, closeTo(0.9, 0.001)); + expect(AppColors.white80.a, closeTo(0.8, 0.001)); + expect(AppColors.white70.a, closeTo(0.7, 0.001)); + expect(AppColors.white60.a, closeTo(0.6, 0.001)); + expect(AppColors.white50.a, closeTo(0.5, 0.001)); + expect(AppColors.white40.a, closeTo(0.4, 0.001)); + expect(AppColors.white30.a, closeTo(0.3, 0.001)); + expect(AppColors.white20.a, closeTo(0.2, 0.001)); + expect(AppColors.white10.a, closeTo(0.1, 0.001)); + }); + + test('brand swatches expose stable primary shades', () { + expect(AppColors.blue, isA()); + expect(AppColors.blue.shade500, const Color(0xFF5C79FB)); + expect(AppColors.green.shade500, const Color(0xFF00CA78)); + expect(AppColors.yellow.shade500, const Color(0xFFFFD956)); + expect(AppColors.red.shade400, const Color(0xFFFF6953)); + expect(AppColors.grey.shade500, const Color(0xFF949494)); + }); + + test('social type string conversion is normalized for persistence', () { + expect(socialTypeFromString(null), SocialType.normal); + expect(socialTypeFromString(' GOOGLE '), SocialType.google); + expect(socialTypeFromString('apple'), SocialType.apple); + expect(socialTypeFromString('unknown'), SocialType.normal); + + expect(socialTypeToString(SocialType.normal), 'normal'); + expect(socialTypeToString(SocialType.google), 'google'); + expect(socialTypeToString(SocialType.apple), 'apple'); + }); +} diff --git a/test/presentation/shared/router/route_arguments_test.dart b/test/presentation/shared/router/route_arguments_test.dart index 3ab12388..8cf02f17 100644 --- a/test/presentation/shared/router/route_arguments_test.dart +++ b/test/presentation/shared/router/route_arguments_test.dart @@ -26,8 +26,9 @@ void main() { expect(parseCalendarInitialDate(extra: 'not-a-date'), isNull); }); - testWidgets('router does not throw for malformed calendar extras', - (tester) async { + testWidgets('router does not throw for malformed calendar extras', ( + tester, + ) async { final router = GoRouter( initialLocation: '/calendar', routes: [ @@ -53,8 +54,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('router reads durable calendar query parameter', - (tester) async { + testWidgets('router reads durable calendar query parameter', ( + tester, + ) async { final router = GoRouter( initialLocation: calendarRouteLocation(DateTime(2026, 5, 7)), routes: [ @@ -79,36 +81,81 @@ void main() { group('scheduleStart route arguments', () { test('rejects non-map extras and accepts string-keyed legacy maps', () { expect(routeExtraMap('bad-extra'), isNull); - expect( - routeExtraMap({'scheduleId': 'schedule-1'}), - {'scheduleId': 'schedule-1'}, - ); + expect(routeExtraMap({'scheduleId': 'schedule-1'}), { + 'scheduleId': 'schedule-1', + }); + expect(routeExtraMap({'scheduleId': 'schedule-1'}), { + 'scheduleId': 'schedule-1', + }); expect(routeExtraMap({1: 'bad-key'}), isNull); }); + testWidgets('scheduleStart query parameters merge with explicit extras', ( + tester, + ) async { + Map? capturedExtra; + final router = GoRouter( + initialLocation: + '/scheduleStart?scheduleId=query-id' + '&scheduleFingerprint=fingerprint' + '&promptVariant=officialStart' + '&alarmLaunchAction=startPreparation' + '&isFiveMinutesBefore=1', + routes: [ + GoRoute( + path: '/scheduleStart', + builder: (context, state) { + capturedExtra = scheduleStartRouteExtraFromState(state); + return const Material(child: Text('schedule start')); + }, + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + router.go( + '/scheduleStart?scheduleId=query-id' + '&scheduleFingerprint=fingerprint' + '&promptVariant=officialStart' + '&alarmLaunchAction=startPreparation' + '&isFiveMinutesBefore=1', + extra: const {'scheduleId': 'extra-id'}, + ); + await tester.pumpAndSettle(); + + expect(capturedExtra, { + 'scheduleId': 'extra-id', + 'scheduleFingerprint': 'fingerprint', + 'promptVariant': 'officialStart', + 'alarmLaunchAction': 'startPreparation', + 'isFiveMinutesBefore': true, + }); + }); + test('prompt helpers ignore malformed optional fields', () { expect( - scheduleStartPromptVariantFromRouteExtra( - const {'isFiveMinutesBefore': 'definitely'}, - ), + scheduleStartPromptVariantFromRouteExtra(const { + 'isFiveMinutesBefore': 'definitely', + }), ScheduleStartPromptVariant.officialStart, ); expect( - scheduleStartPromptVariantFromRouteExtra( - const {'promptVariant': 1}, - ), + scheduleStartPromptVariantFromRouteExtra(const {'promptVariant': 1}), ScheduleStartPromptVariant.officialStart, ); expect( - scheduleStartLaunchActionFromRouteExtra( - const {'alarmLaunchAction': false}, - ), + scheduleStartLaunchActionFromRouteExtra(const { + 'alarmLaunchAction': false, + }), ScheduleStartLaunchAction.prompt, ); }); - testWidgets('router treats malformed scheduleStart extra as absent', - (tester) async { + testWidgets('router treats malformed scheduleStart extra as absent', ( + tester, + ) async { final router = GoRouter( initialLocation: '/scheduleStart', routes: [ @@ -148,6 +195,8 @@ void main() { expect(extraArguments?.isLate, isFalse); expect(queryArguments?.earlyLateTime, -30); expect(queryArguments?.isLate, isTrue); + expect(routeBoolValue('FALSE'), isFalse); + expect(routeBoolValue(0), isFalse); }); test('rejects missing and wrong-type required values', () { @@ -156,7 +205,7 @@ void main() { parseEarlyLateRouteArguments( extra: const { 'earlyLateTime': [1], - 'isLate': false + 'isLate': false, }, ), isNull, @@ -165,15 +214,16 @@ void main() { parseEarlyLateRouteArguments( extra: const { 'earlyLateTime': 1, - 'isLate': [true] + 'isLate': [true], }, ), isNull, ); }); - testWidgets('router redirects missing and malformed earlyLate args home', - (tester) async { + testWidgets('router redirects missing and malformed earlyLate args home', ( + tester, + ) async { final router = GoRouter( initialLocation: '/earlyLate', routes: [ @@ -206,8 +256,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('router accepts valid earlyLate query arguments', - (tester) async { + testWidgets('router accepts valid earlyLate query arguments', ( + tester, + ) async { final router = GoRouter( initialLocation: earlyLateRouteLocation( earlyLateTime: 90, diff --git a/test/presentation/shared/theme/button_and_calendar_theme_test.dart b/test/presentation/shared/theme/button_and_calendar_theme_test.dart new file mode 100644 index 00000000..0bca9929 --- /dev/null +++ b/test/presentation/shared/theme/button_and_calendar_theme_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:on_time_front/presentation/shared/theme/button_styles.dart'; +import 'package:on_time_front/presentation/shared/theme/calendar_theme.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; + +void main() { + setUpAll(() async { + await initializeDateFormatting('en'); + }); + + test('button styles resolve enabled and disabled visual contracts', () { + final colorScheme = themeData.colorScheme; + final textTheme = themeData.textTheme; + + final primary = AppButtonStyles.elevatedPrimary(colorScheme, textTheme); + final secondary = AppButtonStyles.elevatedSecondary(colorScheme, textTheme); + final text = AppButtonStyles.textPrimary(colorScheme, textTheme); + + expect(primary.backgroundColor!.resolve({}), colorScheme.primary); + expect( + primary.backgroundColor!.resolve({WidgetState.disabled}), + colorScheme.surfaceDim, + ); + expect(primary.foregroundColor!.resolve({}), colorScheme.onPrimary); + + expect( + secondary.backgroundColor!.resolve({}), + colorScheme.primaryContainer, + ); + expect( + secondary.backgroundColor!.resolve({WidgetState.disabled}), + colorScheme.surfaceDim, + ); + expect( + secondary.foregroundColor!.resolve({}), + colorScheme.onPrimaryContainer, + ); + expect( + secondary.foregroundColor!.resolve({WidgetState.disabled}), + colorScheme.onSurface.withValues(alpha: 0.38), + ); + + expect(text.textStyle!.resolve({}), textTheme.titleLarge); + expect(text.padding!.resolve({}), EdgeInsets.zero); + expect(text.foregroundColor!.resolve({}), colorScheme.primary); + expect( + text.foregroundColor!.resolve({WidgetState.disabled}), + colorScheme.outlineVariant.withValues(alpha: 0.38), + ); + }); + + test('calendar theme builds app calendar tokens and copies overrides', () { + final colorScheme = themeData.colorScheme; + final textTheme = themeData.textTheme; + final calendarTheme = CalendarTheme.from(colorScheme, textTheme); + const overrideDecoration = BoxDecoration(color: Colors.red); + + final copied = + calendarTheme.copyWith(selectedDayDecoration: overrideDecoration) + as CalendarTheme; + + expect(calendarTheme.headerStyle.formatButtonVisible, isFalse); + expect(calendarTheme.headerStyle.titleCentered, isTrue); + expect(calendarTheme.calendarStyle.outsideDaysVisible, isFalse); + expect( + calendarTheme.calendarStyle.markerDecoration, + BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle), + ); + expect( + calendarTheme.daysOfWeekStyle.dowTextFormatter!.call( + DateTime(2026), + 'en', + ), + 'Thu', + ); + expect(copied.selectedDayDecoration, overrideDecoration); + expect(copied.todayDecoration, calendarTheme.todayDecoration); + expect( + calendarTheme.lerp( + CalendarTheme.from( + colorScheme.copyWith(primary: Colors.red), + textTheme, + ), + 0.25, + ), + calendarTheme, + ); + expect( + calendarTheme.lerp( + CalendarTheme.from( + colorScheme.copyWith(primary: Colors.red), + textTheme, + ), + 0.75, + ), + isA(), + ); + }); + + testWidgets('calendar theme can be derived from build context', ( + tester, + ) async { + late CalendarTheme resolved; + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Builder( + builder: (context) { + resolved = CalendarTheme.fromTheme(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect( + resolved.headerStyle.titleTextStyle.color, + themeData.textTheme.titleMedium!.color, + ); + expect( + resolved.headerStyle.titleTextStyle.fontSize, + themeData.textTheme.titleMedium!.fontSize, + ); + }); +} diff --git a/test/presentation/shared/theme/tile_style_test.dart b/test/presentation/shared/theme/tile_style_test.dart new file mode 100644 index 00000000..ddff11d2 --- /dev/null +++ b/test/presentation/shared/theme/tile_style_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/presentation/shared/theme/tile_style.dart'; + +void main() { + test('copyWith keeps existing tile style values when not overridden', () { + const style = TileStyle( + backgroundColor: Colors.red, + borderRadius: BorderRadius.all(Radius.circular(8)), + minimumSize: Size(10, 20), + maximumSize: Size(100, 200), + padding: EdgeInsets.all(4), + margin: EdgeInsets.all(2), + ); + + final copied = style.copyWith(backgroundColor: Colors.blue); + + expect(copied.backgroundColor, Colors.blue); + expect(copied.borderRadius, style.borderRadius); + expect(copied.minimumSize, style.minimumSize); + expect(copied.maximumSize, style.maximumSize); + expect(copied.padding, style.padding); + expect(copied.margin, style.margin); + }); + + test('lerp interpolates all tile style dimensions', () { + const start = TileStyle( + backgroundColor: Colors.red, + borderRadius: BorderRadius.all(Radius.circular(4)), + minimumSize: Size(10, 20), + maximumSize: Size(100, 200), + padding: EdgeInsets.all(4), + margin: EdgeInsets.all(2), + ); + const end = TileStyle( + backgroundColor: Colors.blue, + borderRadius: BorderRadius.all(Radius.circular(12)), + minimumSize: Size(20, 40), + maximumSize: Size(200, 400), + padding: EdgeInsets.all(8), + margin: EdgeInsets.all(6), + ); + + final mid = start.lerp(end, 0.5); + + expect(mid.backgroundColor, Color.lerp(Colors.red, Colors.blue, 0.5)); + expect( + mid.borderRadius, + BorderRadius.lerp(start.borderRadius, end.borderRadius, 0.5), + ); + expect(mid.minimumSize, const Size(15, 30)); + expect(mid.maximumSize, const Size(150, 300)); + expect( + mid.padding, + EdgeInsetsGeometry.lerp(start.padding, end.padding, 0.5), + ); + expect(mid.margin, EdgeInsetsGeometry.lerp(start.margin, end.margin, 0.5)); + expect(start.lerp(null, 0.5), start); + expect(mid.toString(), contains('TileStyle')); + }); +} diff --git a/test/presentation/shared/utils/time_format_test.dart b/test/presentation/shared/utils/time_format_test.dart new file mode 100644 index 00000000..897d170e --- /dev/null +++ b/test/presentation/shared/utils/time_format_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/presentation/shared/utils/time_format.dart'; + +void main() { + test('formatTime describes elapsed seconds in Korean units', () { + expect(formatTime(0), '0초'); + expect(formatTime(-5), '0초'); + expect(formatTime(45), '45초'); + expect(formatTime(120), '2분'); + expect(formatTime(125), '2분 5초'); + expect(formatTime(3600), '1시간'); + expect(formatTime(3660), '1시간 1분'); + }); + + test('formatTimeTimer uses timer display with hours when needed', () { + expect(formatTimeTimer(65), '01 : 05'); + expect(formatTimeTimer(3665), '01 : 01 : 05'); + }); + + test('formatEalyLateTime reports absolute early or late minutes', () { + expect(formatEalyLateTime(-90), '1분'); + expect(formatEalyLateTime(3900), '1시간 5분'); + }); + + test('formatElapsedTime keeps seconds two-digit padded', () { + expect(formatElapsedTime(65), '1분 05초'); + }); +} diff --git a/tool/check_coverage.dart b/tool/check_coverage.dart new file mode 100644 index 00000000..2c4a5706 --- /dev/null +++ b/tool/check_coverage.dart @@ -0,0 +1,201 @@ +import 'dart:io'; + +const _defaultCoveragePath = 'coverage/lcov.info'; +const _defaultMinimum = 80.0; + +void main(List arguments) { + final options = _CoverageOptions.parse(arguments); + final file = File(options.coveragePath); + + if (!file.existsSync()) { + stderr.writeln('Coverage file not found: ${options.coveragePath}'); + stderr.writeln('Run `flutter test --coverage` before checking coverage.'); + exitCode = 1; + return; + } + + final report = _LcovReport.parse(file.readAsLinesSync()); + + if (report.totalLines == 0) { + stderr.writeln( + 'No app-owned coverage data found in ${options.coveragePath}.', + ); + exitCode = 1; + return; + } + + final percentage = report.percentage; + final formattedPercentage = percentage.toStringAsFixed(2); + final formattedMinimum = options.minimum.toStringAsFixed(2); + + stdout.writeln( + 'Coverage: $formattedPercentage% ' + '(${report.coveredLines}/${report.totalLines} lines)', + ); + + if (percentage < options.minimum) { + stderr.writeln( + 'Coverage check failed: $formattedPercentage% is below ' + 'the required $formattedMinimum%.', + ); + exitCode = 1; + } +} + +class _CoverageOptions { + const _CoverageOptions({required this.coveragePath, required this.minimum}); + + final String coveragePath; + final double minimum; + + static _CoverageOptions parse(List arguments) { + var coveragePath = _defaultCoveragePath; + var minimum = _defaultMinimum; + + for (var index = 0; index < arguments.length; index += 1) { + final argument = arguments[index]; + + if (argument == '--coverage') { + coveragePath = _requiredValue(arguments, index, argument); + index += 1; + } else if (argument.startsWith('--coverage=')) { + coveragePath = argument.substring('--coverage='.length); + } else if (argument == '--min') { + minimum = _parseMinimum(_requiredValue(arguments, index, argument)); + index += 1; + } else if (argument.startsWith('--min=')) { + minimum = _parseMinimum(argument.substring('--min='.length)); + } else { + stderr.writeln('Unknown argument: $argument'); + _printUsageAndExit(); + } + } + + return _CoverageOptions(coveragePath: coveragePath, minimum: minimum); + } + + static String _requiredValue( + List arguments, + int index, + String option, + ) { + final valueIndex = index + 1; + if (valueIndex >= arguments.length || + arguments[valueIndex].startsWith('--')) { + stderr.writeln('Missing value for $option.'); + _printUsageAndExit(); + } + return arguments[valueIndex]; + } + + static double _parseMinimum(String value) { + final minimum = double.tryParse(value); + if (minimum == null || minimum < 0 || minimum > 100) { + stderr.writeln('Invalid coverage minimum: $value'); + _printUsageAndExit(); + } + return minimum; + } + + static Never _printUsageAndExit() { + stderr.writeln( + 'Usage: dart run tool/check_coverage.dart ' + '[--coverage coverage/lcov.info] [--min 80]', + ); + exit(64); + } +} + +class _LcovReport { + const _LcovReport({required this.coveredLines, required this.totalLines}); + + final int coveredLines; + final int totalLines; + + double get percentage => coveredLines / totalLines * 100; + + static _LcovReport parse(List lines) { + var currentSourceFile = ''; + var currentCoveredLines = 0; + var currentTotalLines = 0; + var coveredLines = 0; + var totalLines = 0; + + void flushRecord() { + if (_isIncludedSource(currentSourceFile)) { + coveredLines += currentCoveredLines; + totalLines += currentTotalLines; + } + + currentSourceFile = ''; + currentCoveredLines = 0; + currentTotalLines = 0; + } + + for (final line in lines) { + if (line.startsWith('SF:')) { + flushRecord(); + currentSourceFile = _normalizePath(line.substring(3)); + } else if (line.startsWith('LH:')) { + currentCoveredLines = _parseCount(line, 'LH'); + } else if (line.startsWith('LF:')) { + currentTotalLines = _parseCount(line, 'LF'); + } else if (line == 'end_of_record') { + flushRecord(); + } + } + + flushRecord(); + + return _LcovReport(coveredLines: coveredLines, totalLines: totalLines); + } + + static int _parseCount(String line, String label) { + final value = int.tryParse(line.substring(label.length + 1)); + if (value == null) { + stderr.writeln('Invalid LCOV $label value: $line'); + exit(1); + } + return value; + } +} + +String _normalizePath(String path) { + final normalizedPath = path.replaceAll('\\', '/'); + final libIndex = normalizedPath.indexOf('/lib/'); + + if (libIndex == -1) { + return normalizedPath; + } + + return normalizedPath.substring(libIndex + 1); +} + +bool _isIncludedSource(String sourceFile) { + if (!sourceFile.startsWith('lib/')) { + return false; + } + + if (sourceFile.endsWith('.g.dart') || + sourceFile.endsWith('.freezed.dart') || + sourceFile.endsWith('.config.dart') || + sourceFile.endsWith('.mocks.dart')) { + return false; + } + + if (sourceFile == 'lib/firebase_options.dart') { + return false; + } + + if (sourceFile == 'lib/core/database/database.dart' || + sourceFile.startsWith('lib/data/tables/')) { + return false; + } + + if (sourceFile.startsWith('lib/l10n/app_localizations') && + sourceFile.endsWith('.dart')) { + return false; + } + + return true; +}