diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index a4f1865..f3b6e55 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -38,7 +38,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | 토큰 보관 | `lib/core/storage/` — `token_store.dart` · `secure_store.dart` · `first_run_marker.dart` | 리프레시 토큰만 `flutter_secure_storage`(키 상수 `kRefreshTokenKey`), 액세스 토큰은 메모리. 새 토큰은 받자마자 저장(로그인 저장이 실패하면 그 세션을 끝내고 `SYS_001`, 갱신 저장이 실패하면 이번 실행은 메모리의 새 토큰으로 이어 가고 다음 읽기 · 저장 때 다시 쓴다). 보안 저장소 일은 부른 차례대로 하나씩(로그인 저장과 로그아웃 지우기가 엇갈리지 않게). 토큰 세대 `generation` · 세션 세대 `sessionGeneration`(지울 때만 오른다), 로그아웃은 `signOut()`(메모리를 곧바로 비우고 서버 logout 에 쓸 토큰을 돌려준다). 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각, `core/api/server_time.dart`)로 남은 시간을 재고 받은 때부터 센다. 첫 실행 표시(`shared_preferences` — 참 · 거짓 하나)가 없으면 보안 저장소를 모두 지운다(iOS Keychain 은 앱을 지워도 남는다). Android 는 백업을 껐다(`AndroidManifest` · `res/xml/data_extraction_rules.xml`) | | 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값 | | 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) | -| **새 메일 알림 (`App1Push`)** | `lib/core/push/` — 포트 `push_messaging.dart`(FCM 은 `firebase_push_messaging.dart` — 띄우지 못하면 `UnavailablePushMessaging`) · `notification_system.dart`(채널 · 알림 지우기 · 휴대폰 설정 열기 — 안드로이드 `NewMailNotifications.kt`, 채널 `kr.it.arasystem.app/notifications`) · `push_preferences.dart`(이 휴대폰의 값 — `shared_preferences`, 참 · 거짓과 FID 하나뿐) · `push_state.dart`(상태 · 설정 줄 모습 `pushRowStatusOf` · 권하기 줄 `showsPushOffer`) · `push_controller.dart`(앱에 하나 — `PushRegistrar` 를 구현) · `push_registration.dart`(서버 저장소 · `pushRegistrarProvider`) · `push_events.dart`(앞에서 받음 · 누름 — 라우터 `core/router/push_navigation.dart` 가 누른 알림을 늘 메일 받은편지함으로) · `push_target.dart` · 글 `push_texts.dart`. 화면 `features/mail/presentation/widgets/push_offer_line.dart` · `features/settings/presentation/widgets/settings_notification_row.dart` | **보낼 조건** = 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음(app-config `pushAvailable` — 모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(세션 노티파이어의 `register` · 제한 단계는 `pause`) ∧ 이 휴대폰에서 켬 ∧ 권한 ∧ 새 메일 채널. 맞추기 `_reconcile` 하나가 PUT · DELETE 를 고른다. **PUT** 은 세션이 열릴 때 한 번 · FID 가 바뀔 때 · 켤 때만(앞으로 올 때마다가 아니다), **DELETE** 는 끌 때와 권한 · 채널이 꺼진 것을 알았을 때만 — 로그아웃 · 세션 끝은 FCM `unregister()` 만(서버 줄은 서버가 세션과 함께 지운다, 풀지 못하면 `pendingUnregister` 로 다음 실행 · 앞으로 옴에 다시). **봤음** `DELETE …/unseen` 은 앞으로 올 때 · 앞에서 받은 새 메일 알림 때, 30초 간격 + 끝 한 번 — 앱이 앞에 있을 때만 보내고, 간격 끝을 기다리는 동안 뒤로 가면 그 자리에서 보낸다(끝까지 기다리면 그사이 온 메일까지 본 것으로 센다). 켜기 · 끄기는 서버가 답한 뒤에 바꾼다(실패는 그 코드의 사전 문구 — 등록 소식이 없으면 `SYS_001`). **켜기가 실패하면 이 휴대폰의 값을 끔으로 둔다**(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). 서버 일은 멈춘(`pause`) 바로 그 차례에도 보내지 않는다(`_canReachServer` 가 컨트롤러의 세션 값도 본다). FCM `register` · `unregister` 는 우리가 한 줄로 세운다(플러그인이 스레드 풀에서 돈다), `onRegistered` 는 방송이라 `register` 전에 듣는다. **풀기는 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다** — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 그 뒤에 다시 등록하고(같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다), 풀기 앞의 등록은 끝나도 마친 것으로 치지 않으며 "풀지 못한 등록" 표시도 지우지 않는다. 풀기가 줄에 있는 동안 온 등록 소식은 쓰지 않는다. 앱이 앞에 있으면 FCM 이 알림을 그리지 않는다 — 앞에서 받은 새 메일 알림은 메일 목록이 바로 새 메일을 확인한다(`mail_state_watcher`). 알림 데이터는 **모듈 이름과 종류**(`{ module: 'mail', kind: 'new-mail' }` · `session-ended`). FID · 주소 · 토큰을 로그 · 디버그 출력에 남기지 않는다. 시험 도구 `test/support/push_fakes.dart`(가짜 FCM · 알림창 · 값 · 서버) · `push_screen_harness.dart`(앱 전체 + 진짜 컨트롤러 — `ProviderHarness.recordPush = false`). 다른 시험대는 기기 등록을 기록하는 가짜(`RecordingPushRegistrar`)이고 컨트롤러는 FCM 이 없어 조용하다 | +| **새 메일 알림 (`App1Push`)** | `lib/core/push/` — 포트 `push_messaging.dart`(FCM 은 `firebase_push_messaging.dart` — 띄우지 못하면 `UnavailablePushMessaging`) · `notification_system.dart`(채널 · 알림 지우기 · 휴대폰 설정 열기 — 안드로이드 `NewMailNotifications.kt`, 채널 `kr.it.arasystem.app/notifications`) · `push_preferences.dart`(이 휴대폰의 값 — `shared_preferences`, 참 · 거짓과 FID 하나뿐) · `push_state.dart`(상태 · 설정 줄 모습 `pushRowStatusOf` · 권하기 줄 `showsPushOffer`) · `push_controller.dart`(앱에 하나 — `PushRegistrar` 를 구현) · `push_registration.dart`(서버 저장소 · `pushRegistrarProvider`) · `push_events.dart`(앞에서 받음 · 누름 — 라우터 `core/router/push_navigation.dart` 가 누른 알림을 늘 메일 받은편지함으로) · `push_target.dart` · 글 `push_texts.dart`. 화면 `features/mail/presentation/widgets/push_offer_line.dart` · `features/settings/presentation/widgets/settings_notification_row.dart` | **보낼 조건** = 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음(app-config `pushAvailable` — 모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(세션 노티파이어의 `register` · 제한 단계는 `pause`) ∧ 이 휴대폰에서 켬 ∧ 권한 ∧ 새 메일 채널. 맞추기 `_reconcile` 하나가 PUT · DELETE 를 고른다. **PUT** 은 세션이 열릴 때 한 번 · FID 가 바뀔 때 · 켤 때만(앞으로 올 때마다가 아니다), **DELETE** 는 끌 때와 권한 · 채널이 꺼진 것을 알았을 때만 — 로그아웃 · 세션 끝은 FCM `unregister()` 만(서버 줄은 서버가 세션과 함께 지운다, 풀지 못하면 `pendingUnregister` 로 다음 실행 · 앞으로 옴에 다시). **봤음** `DELETE …/unseen` 은 앞으로 올 때 · 앞에서 받은 새 메일 알림 때, 30초 간격 + 끝 한 번 — 앱이 앞에 있을 때만 보내고, 기다리는 동안 뒤로 가면 그 자리에서 보낸다(끝까지 기다리면 그사이 온 메일까지 본 것으로 센다). 앞에서 받은 알림 뒤의 봤음은 받은 때 + 1.5초(`kPushSeenAfterMessageDelay`)보다 일찍 보내지 않는다 — 서버는 seen_at 의 초부터 같은 초를 세고 받은 시각은 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기). 켜기 · 끄기는 서버가 답한 뒤에 바꾼다(실패는 그 코드의 사전 문구 — 등록 소식이 없으면 `SYS_001`). **켜기가 실패하면 이 휴대폰의 값을 끔으로 둔다**(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). 서버 일은 멈춘(`pause`) 바로 그 차례에도 보내지 않는다(`_canReachServer` 가 컨트롤러의 세션 값도 본다). FCM `register` · `unregister` 는 우리가 한 줄로 세운다(플러그인이 스레드 풀에서 돈다), `onRegistered` 는 방송이라 `register` 전에 듣는다. **풀기는 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다** — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 그 뒤에 다시 등록하고(같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다), 풀기 앞의 등록은 끝나도 마친 것으로 치지 않으며 "풀지 못한 등록" 표시도 지우지 않는다. 풀기가 줄에 있는 동안 온 등록 소식은 쓰지 않는다. 앱이 앞에 있으면 FCM 이 알림을 그리지 않는다 — 앞에서 받은 새 메일 알림은 메일 목록이 바로 새 메일을 확인한다(`mail_state_watcher`). 알림 데이터는 **모듈 이름과 종류**(`{ module: 'mail', kind: 'new-mail' }` · `session-ended`). FID · 주소 · 토큰을 로그 · 디버그 출력에 남기지 않는다. 시험 도구 `test/support/push_fakes.dart`(가짜 FCM · 알림창 · 값 · 서버) · `push_screen_harness.dart`(앱 전체 + 진짜 컨트롤러 — `ProviderHarness.recordPush = false`). 다른 시험대는 기기 등록을 기록하는 가짜(`RecordingPushRegistrar`)이고 컨트롤러는 FCM 이 없어 조용하다 | | **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · `AppStoreLauncher` · 글) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). [Play 스토어 열기]는 `market://details` → 웹 주소, 패키지 이름은 `appPackageNameProvider`(main 이 기기 정보로) | | **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보 · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) | | 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 | diff --git a/docs/design/app/README.md b/docs/design/app/README.md index 20de6a7..297adbc 100644 --- a/docs/design/app/README.md +++ b/docs/design/app/README.md @@ -288,6 +288,7 @@ - 끄는 중의 화면 낭독기 글 "끄는 중"(보드에는 켜는 중만 있다). - 문의 연락처를 정하지 않았거나 받지 못했을 때: 설정 › 문의 "앱이나 계정 문제로 도움이 필요하면 관리자에게 문의해 주세요." · 업데이트 화면 "Play 스토어에 업데이트가 보이지 않나요? 관리자에게 문의해 주세요."(로그인 화면의 문의 줄과 같은 꼴). - 서버가 보낼 수 있는지 아직 모르면(앱 설정을 받기 전 · 받지 못함) 쓸 수 있는 것으로 본다 — 막지 않는다. 앱 설정은 앱을 띄우자마자 세션 되살리기와 나란히 묻는다. + - 알림의 N 은 앱을 마지막으로 본 때부터 센다 — 앱으로 돌아올 때 · 앱을 보는 중 새 메일 알림을 받을 때 "봤음"을 보낸다(30초에 한 번 · 기다리는 동안 뒤로 가면 그 자리에서). 보는 중에 받은 알림 뒤의 봤음은 받은 때부터 1.5초가 지나기 전에는 보내지 않는다(30초 간격 끝이 더 늦으면 그때 — 그사이 뒤로 가면 그 자리에서 보내 같은 초가 될 수 있다, 받아들였다)(2026-11-08 실기기 — 서버는 봤음의 초와 같은 초에 받은 메일도 세는데 받은 시각이 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어져 한 통이 더 많았다). - **실기기 · 실제 Firebase 에서 볼 것**: FID 등록 소식(`onRegistered`)이 자동 초기화를 끈 채 오는지 · 앱 데이터를 지운 뒤 새 FID, 같은 알림을 바꿔 쓰는지(태그 `new-mail`)와 앱으로 돌아올 때 지워지는지, 앱을 보는 중에는 시스템 알림이 뜨지 않는지, 뒤에 있을 때 · 꺼져 있을 때 누른 알림이 받은편지함을 여는지, 두 번 거절한 뒤 스위치가 [설정 열기] 쪽으로 바뀌는지, 카테고리만 끈 상태를 읽는지 · [설정 열기]가 그 카테고리를 여는지(삼성 · Pixel), 상태 표시줄의 작은 아이콘 굵기 · 삼성 알림창의 앱 아이콘 · 테마 아이콘, 절전(Doze) 중 도착 시간, diff --git a/flutter/lib/core/push/push_controller.dart b/flutter/lib/core/push/push_controller.dart index 2571362..5fc13ea 100644 --- a/flutter/lib/core/push/push_controller.dart +++ b/flutter/lib/core/push/push_controller.dart @@ -23,6 +23,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// "봤음"(`DELETE …/unseen`)을 보내는 가장 짧은 간격 — 그 안에 또 오면 건너뛰고 간격 끝에 한 번 보낸다 const Duration kPushSeenInterval = Duration(seconds: 30); +/// 앞에서 받은 새 메일 알림 뒤의 "봤음"은 받은 때부터 이만큼 지나야 보낸다. +/// 서버는 N 을 seen_at 의 초부터 같은 초도 넣어 세고 메일의 받은 시각은 초 단위라, 그 알림을 일으킨 메일과 같은 초에 봤음을 보내면 +/// 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기 — 한 통이 더 왔는데 "새 메일 2통"). 1초 넘게 늦추면 늘 다음 초가 된다 +const Duration kPushSeenAfterMessageDelay = Duration(milliseconds: 1500); + /// 직원이 켤 때 FCM 등록 소식(FID)을 기다리는 가장 긴 시간 — 넘으면 켜지 못한 것으로 알린다 const Duration kPushRegisterWait = Duration(seconds: 15); @@ -43,7 +48,8 @@ final NotifierProvider pushControllerProvider = /// 다음 세션의 등록은 풀기 뒤에 줄을 선다 — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 다시 등록한다. /// - **앞으로 옴**: 알림창의 "새 메일 N통"을 지우고, 권한 · 채널을 다시 읽어 맞추고, "봤음"을 보낸다(30초 간격 — 건너뛰면 끝에 한 번). /// 앞에 있는 동안 온 새 메일 알림도 "봤음"을 보내고 메일 모듈에 알린다([pushEventsProvider] — 새 메일 확인을 바로). -/// 봤음은 앱이 앞에 있을 때만 보낸다 — 간격 끝을 기다리는 동안 뒤로 가면 그 자리에서 보낸다(그때가 마지막으로 본 때다. 끝까지 기다리면 그사이 온 새 메일까지 본 것으로 센다). +/// 그 봤음만은 받은 때 + [kPushSeenAfterMessageDelay] 보다 일찍 보내지 않는다(보내는 때 = 마지막 봤음 + 30초 · 받은 때 + 1.5초 가운데 늦은 쪽). +/// 봤음은 앱이 앞에 있을 때만 보낸다 — 기다리는 동안(간격 끝 · 1.5초) 뒤로 가면 그 자리에서 보낸다(그때가 마지막으로 본 때다. 끝까지 기다리면 그사이 온 새 메일까지 본 것으로 센다). /// - **누른 알림**: [pushEventsProvider] 에 값으로 올린다 — 라우터가 가져간다(`core/router/push_navigation.dart`). /// - Firebase 를 띄우지 못한 휴대폰([PushMessaging.isAvailable] 이 거짓)에서는 아무것도 하지 않는다. /// - FID · 주소 · 토큰은 로그에 남기지 않는다. @@ -94,6 +100,9 @@ class PushController extends Notifier implements PushRegistrar { // ── 봤음 ── bool _seenPending = false; DateTime? _lastSeenAt; + + /// 빚진 봤음을 일으킨, 앞에서 받은 마지막 새 메일 알림의 때 — 그 뒤 [kPushSeenAfterMessageDelay] 가 지나야 보낸다(보내면 지운다) + DateTime? _newMailAt; Timer? _seenTimer; PushRegistrationRepository get _repository => @@ -582,6 +591,8 @@ class PushController extends Notifier implements PushRegistrar { ref.read(pushEventsProvider.notifier).add(message, opened: false); if (message.isNewMail && _sessionReady) { _seenPending = true; + // 그 메일과 같은 초에 봤음을 보내지 않는다 — 받은 때부터 1.5초 뒤에(kPushSeenAfterMessageDelay) + _newMailAt = _clock(); _flushSeen(); } } @@ -591,7 +602,7 @@ class PushController extends Notifier implements PushRegistrar { ref.read(pushEventsProvider.notifier).add(message, opened: true); } - /// 앱이 뒤로 갔다 — 간격 끝을 기다리던 봤음은 그 자리에서 보내고 타이머는 버린다(마지막으로 본 때가 지금이다) + /// 앱이 뒤로 갔다 — 기다리던 봤음(간격 끝 · 받은 알림 뒤 1.5초)은 그 자리에서 보내고 타이머는 버린다(마지막으로 본 때가 지금이다) void _wentToBackground() { final Timer? timer = _seenTimer; if (!ref.mounted || timer == null) return; @@ -604,26 +615,50 @@ class PushController extends Notifier implements PushRegistrar { bool get _canSendSeen => _sessionReady && _serverRow == true && _store.hasSession; - /// 빚진 "봤음"을 보낸다 — 앱이 앞에 있고 이 세션의 서버 줄이 있을 때만. 30초 안이면 간격 끝에 한 번 + /// 빚진 "봤음"을 보낸다 — 앱이 앞에 있고 이 세션의 서버 줄이 있을 때만. 30초 안이면 간격 끝에 한 번, + /// 앞에서 받은 새 메일 알림 뒤면 받은 때 + 1.5초보다 일찍 보내지 않는다([_seenWait]). + /// 타이머는 하나 — 기다리는 사이 더 늦은 때가 생기면(또 받은 알림) 타이머가 끝날 때 다시 재어 그만큼 더 기다린다 void _flushSeen() { if (!_seenPending || !ref.mounted || _seenTimer != null) return; // 뒤에 있으면 보내지 않는다 — 앞으로 올 때 다시 빚진다 if (!_canSendSeen || !ref.read(appForegroundProvider)) return; final DateTime now = _clock(); - final DateTime? last = _lastSeenAt; - final Duration? since = last == null ? null : now.difference(last); - if (since == null || since.isNegative || since >= kPushSeenInterval) { + final Duration wait = _seenWait(now); + if (wait <= Duration.zero) { _sendSeen(now); return; } - _seenTimer = Timer(kPushSeenInterval - since, () { + _seenTimer = Timer(wait, () { _seenTimer = null; _flushSeen(); }); } + /// 지금부터 봤음을 보낼 때까지 — 마지막 봤음 + 30초와 앞에서 받은 새 메일 알림 + 1.5초 가운데 늦은 쪽. + /// 시계가 거꾸로 갔으면 그 기다림은 지난 것으로 본다(거꾸로 간 만큼 늦어지지 않게) + Duration _seenWait(DateTime now) { + Duration wait = Duration.zero; + final DateTime? last = _lastSeenAt; + if (last != null) { + final Duration since = now.difference(last); + if (!since.isNegative && since < kPushSeenInterval) { + wait = kPushSeenInterval - since; + } + } + final DateTime? received = _newMailAt; + if (received != null) { + final Duration since = now.difference(received); + if (!since.isNegative && since < kPushSeenAfterMessageDelay) { + final Duration rest = kPushSeenAfterMessageDelay - since; + if (rest > wait) wait = rest; + } + } + return wait; + } + void _sendSeen(DateTime now) { _seenPending = false; + _newMailAt = null; _lastSeenAt = now; unawaited( _repository.markSeen().then( @@ -636,6 +671,7 @@ class PushController extends Notifier implements PushRegistrar { void _cancelSeen() { _seenPending = false; + _newMailAt = null; _seenTimer?.cancel(); _seenTimer = null; } diff --git a/flutter/test/core/push/push_controller_test.dart b/flutter/test/core/push/push_controller_test.dart index 13772be..2ff223f 100644 --- a/flutter/test/core/push/push_controller_test.dart +++ b/flutter/test/core/push/push_controller_test.dart @@ -71,6 +71,38 @@ void main() { ..set(true); } + // ── 시험 시계(testWidgets)에서 ── + + /// 시험 시계를 넘기며 조건이 될 때까지 — 네트워크(가짜 서버)는 시험 시계 안에서 차례가 넘어가야 끝난다 + Future pumpUntil( + WidgetTester tester, + bool Function() condition, + String reason, + ) async { + for (int turn = 0; turn < 300 && !condition(); turn++) { + await tester.pump(const Duration(milliseconds: 1)); + } + expect(condition(), isTrue, reason: reason); + } + + /// 손 시계와 시험 시계를 함께 넘긴다(간격은 손 시계로 재고 끝은 타이머로 기다린다) + Future elapse(WidgetTester tester, Duration duration) async { + h.clock.advance(duration); + await tester.pump(duration); + } + + /// 앞에 있을 때 알림이 온다 — 손 시계를 넘기기 전에 건넨다(받은 때가 지금이 되게) + Future receive(WidgetTester tester, PushMessage message) async { + h.messaging.receive(message); + await tester.pump(); + } + + /// 앱이 뒤로 간다 · 앞으로 온다(따로) + void foreground(bool value) => + (container.read(appForegroundProvider.notifier) as FakeForeground).set( + value, + ); + setUp(() => h = PushHarness()); group('등록 (PUT — FID 를 받았을 때 · 바뀌었을 때 · 세션이 열릴 때 한 번)', () { @@ -460,23 +492,27 @@ void main() { await until(() => h.push.seen.length == 2, '앞으로 옴'); }); - test('앞에 있을 때 온 새 메일 알림 — 메일 모듈에 알리고 봤음을 보낸다. 세션 끝 알림은 알리기만', () async { - await start(); - await until(() => h.push.seen.isNotEmpty, '처음 봤음'); - h.clock.advance(const Duration(seconds: 31)); + testWidgets( + '앞에 있을 때 온 새 메일 알림 — 메일 모듈에 알리고 1.5초 뒤 봤음을 보낸다. 세션 끝 알림은 알리기만', + (WidgetTester tester) async { + await start(); + await pumpUntil(tester, () => h.push.seen.length == 1, '처음 봤음'); + await elapse(tester, const Duration(seconds: 31)); - h.messaging.receive(kNewMailPush); - await until(() => h.push.seen.length == 2, '받은 알림 봤음'); - final PushEvent? event = container.read(pushEventsProvider); - expect(event?.opened, isFalse); - expect(event?.message.isNewMail, isTrue); + await receive(tester, kNewMailPush); + final PushEvent? event = container.read(pushEventsProvider); + expect(event?.opened, isFalse); + expect(event?.message.isNewMail, isTrue); + await elapse(tester, kPushSeenAfterMessageDelay); + await pumpUntil(tester, () => h.push.seen.length == 2, '받은 알림 봤음'); - h.clock.advance(const Duration(seconds: 31)); - h.messaging.receive(kSessionEndedPush); - await idle(); - expect(h.push.seen, hasLength(2)); - expect(container.read(pushEventsProvider)?.message.isNewMail, isFalse); - }); + await elapse(tester, const Duration(seconds: 31)); + await receive(tester, kSessionEndedPush); + await elapse(tester, const Duration(seconds: 2)); + expect(h.push.seen, hasLength(2)); + expect(container.read(pushEventsProvider)?.message.isNewMail, isFalse); + }, + ); test('누른 알림은 라우터가 가져갈 때까지 값으로 든다 — 꺼져 있다가 뜬 알림 · 뒤에서 누른 알림', () async { h.messaging.initial = kNewMailPush; @@ -498,31 +534,7 @@ void main() { }); }); - group('봤음 — 30초 간격 · 건너뛰면 끝에 한 번 (시험 시계)', () { - /// 시험 시계를 넘기며 조건이 될 때까지 — 네트워크(가짜 서버)는 시험 시계 안에서 차례가 넘어가야 끝난다 - Future pumpUntil( - WidgetTester tester, - bool Function() condition, - String reason, - ) async { - for (int turn = 0; turn < 300 && !condition(); turn++) { - await tester.pump(const Duration(milliseconds: 1)); - } - expect(condition(), isTrue, reason: reason); - } - - /// 손 시계와 시험 시계를 함께 넘긴다(간격은 손 시계로 재고 끝은 타이머로 기다린다) - Future elapse(WidgetTester tester, Duration duration) async { - h.clock.advance(duration); - await tester.pump(duration); - } - - /// 앱이 뒤로 간다 · 앞으로 온다(따로) - void foreground(bool value) => - (container.read(appForegroundProvider.notifier) as FakeForeground).set( - value, - ); - + group('봤음 — 30초 간격 · 건너뛰면 끝에 한 번 · 받은 알림 뒤 1.5초 (시험 시계)', () { testWidgets('앞에 있는 동안 30초 안에 또 오면 건너뛰고 간격 끝에 한 번 보낸다', ( WidgetTester tester, ) async { @@ -533,7 +545,8 @@ void main() { comeBack(); await pumpUntil(tester, () => h.notifications.statusCalls >= 2, '다시 읽기'); await elapse(tester, const Duration(seconds: 5)); - h.messaging.receive(kNewMailPush); + // 받은 때 + 1.5초(16.5초)는 간격 끝(30초)보다 이르다 — 간격 끝 그대로 + await receive(tester, kNewMailPush); await elapse(tester, const Duration(seconds: 14)); expect(h.push.seen, hasLength(1), reason: '30초 안이라 건너뛴다'); @@ -566,6 +579,107 @@ void main() { await pumpUntil(tester, () => h.push.seen.length == 3, '다시 앞으로 옴'); }); + testWidgets('앞에서 받은 새 메일 알림 뒤의 봤음은 1.5초 전에는 보내지 않고 1.5초에 한 번 보낸다', ( + WidgetTester tester, + ) async { + await start(); + await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음'); + await elapse(tester, const Duration(seconds: 31)); + + // 서버는 seen_at 의 초부터 같은 초까지 센다 — 그 알림을 일으킨 메일과 같은 초에 보내지 않는다 + await receive(tester, kNewMailPush); + await elapse( + tester, + kPushSeenAfterMessageDelay - const Duration(milliseconds: 1), + ); + expect(h.push.seen, hasLength(1), reason: '받은 때 + 1.5초 전'); + + await elapse(tester, const Duration(milliseconds: 1)); + await pumpUntil( + tester, + () => h.push.seen.length == 2, + '받은 때 + 1.5초에 한 번', + ); + await elapse(tester, const Duration(seconds: 40)); + expect(h.push.seen, hasLength(2)); + }); + + testWidgets('간격 끝이 받은 때 + 1.5초보다 이르면(기다리는 동안 또 받음) 받은 때 + 1.5초에 한 번 보낸다', ( + WidgetTester tester, + ) async { + await start(); + await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음'); + await elapse(tester, const Duration(seconds: 10)); + await receive(tester, kNewMailPush); + await elapse(tester, const Duration(seconds: 19)); + // 간격 끝(30초) 1초 전에 또 받았다 — 받은 때 + 1.5초(30.5초)가 더 늦다 + await receive(tester, kNewMailPush); + + await elapse(tester, const Duration(seconds: 1)); + expect(h.push.seen, hasLength(1), reason: '간격 끝에서 받은 때 + 1.5초로 미룬다'); + await elapse(tester, const Duration(milliseconds: 499)); + expect(h.push.seen, hasLength(1)); + + await elapse(tester, const Duration(milliseconds: 1)); + await pumpUntil( + tester, + () => h.push.seen.length == 2, + '받은 때 + 1.5초에 한 번', + ); + await elapse(tester, const Duration(seconds: 40)); + expect(h.push.seen, hasLength(2), reason: '두 번 받아도 한 번'); + }); + + testWidgets('간격 끝이 받은 때 + 1.5초보다 늦으면 간격 끝에 한 번 보낸다', ( + WidgetTester tester, + ) async { + await start(); + await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음'); + await elapse(tester, const Duration(seconds: 10)); + + await receive(tester, kNewMailPush); + await elapse(tester, const Duration(seconds: 2)); + expect(h.push.seen, hasLength(1), reason: '1.5초가 지나도 30초 간격 안이다'); + await elapse(tester, const Duration(seconds: 17)); + expect(h.push.seen, hasLength(1)); + + await elapse(tester, const Duration(seconds: 1)); + await pumpUntil(tester, () => h.push.seen.length == 2, '간격 끝에 한 번'); + await elapse(tester, const Duration(seconds: 40)); + expect(h.push.seen, hasLength(2)); + }); + + testWidgets('받은 알림 뒤 1.5초를 기다리는 동안 뒤로 가면 그 자리에서 한 번 보내고, 1.5초에는 보내지 않는다', ( + WidgetTester tester, + ) async { + await start(); + await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음'); + await elapse(tester, const Duration(seconds: 31)); + await receive(tester, kNewMailPush); + await elapse(tester, const Duration(milliseconds: 500)); + expect(h.push.seen, hasLength(1)); + + // 직원이 마지막으로 본 때는 지금이다 — 뒤로 간 뒤에 보내면 그사이 온 새 메일까지 본 것으로 센다 + foreground(false); + await pumpUntil(tester, () => h.push.seen.length == 2, '뒤로 갈 때 한 번'); + await elapse(tester, const Duration(seconds: 40)); + expect(h.push.seen, hasLength(2), reason: '뒤에 있는 동안은 보내지 않는다'); + }); + + testWidgets('앞으로 올 때의 봤음은 1.5초를 기다리지 않고 바로 보낸다', ( + WidgetTester tester, + ) async { + await start(); + await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음'); + await elapse(tester, const Duration(seconds: 31)); + + // 손 시계를 넘기지 않는다 — 기다림이 있으면 보내지 못한다 + comeBack(); + await pumpUntil(tester, () => h.push.seen.length == 2, '앞으로 오면 바로'); + await elapse(tester, const Duration(seconds: 40)); + expect(h.push.seen, hasLength(2)); + }); + testWidgets('로그아웃하면 기다리던 봤음을 보내지 않는다', (WidgetTester tester) async { await start(); await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음');