From 8cef5195f161c94df915dadfbfc737b452552163 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sun, 23 Aug 2026 11:51:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20AtchaV2=20Post-12=20=EA=B0=AD=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=C2=B7=EB=A1=9C=EB=93=9C=EB=A7=B5=20+=20Phase?= =?UTF-8?q?=2013=C2=B714=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20+=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EA=B2=80=EC=88=98=20=EA=B7=9C=EC=95=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/planning/atcha-v2-post12-roadmap.md: 전수 갭 분석 정본, Phase 13~18 로드맵, 서버 트랙 S1~S4(질문 리스트 포함), 미확정 입력 원장 현재 상태 + #11·#12 신설, 제품 결정 트랙(보류·반대 판정), 신뢰 UX 원칙, 수용 리스크 - docs/prompts/atcha-v2-session-lifecycle-prompt.md: Phase 13(알람 이후 세션 수명 완결 — stopIntent·departed·자동 소멸·클라 만료·배너 3단계·isStale) + Phase 14(재실행 정합성 — 스냅샷 영속화·고아 LA 재부착·카드 복원·도보 시간 클라 반영·정합성 일괄) 구현 프롬프트 - docs/prompts/atcha-v2-auto-verification.md: 자동 검수 규약 — 사람 검수를 computer use로 대체(에이전트 직접 수행·스크린샷 증적), 실기기 잔여만 이관. 실측 제약(AXPress·ScreenIsLocked·simctl push 거부) 내장 - CLAUDE.md: 미완 상태 절 정정(base URL 실주소·plist 번들 ID 불일치·LA 구축 완료), 아키텍처 다이어그램에 CoreAlarm·CoreLiveActivity·AtchaWidget 반영, 새 문서 포인터 추가 Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 16 +- docs/planning/atcha-v2-post12-roadmap.md | 204 ++++++++++++++++++ docs/prompts/atcha-v2-auto-verification.md | 60 ++++++ .../atcha-v2-session-lifecycle-prompt.md | 180 ++++++++++++++++ 4 files changed, 455 insertions(+), 5 deletions(-) create mode 100644 docs/planning/atcha-v2-post12-roadmap.md create mode 100644 docs/prompts/atcha-v2-auto-verification.md create mode 100644 docs/prompts/atcha-v2-session-lifecycle-prompt.md diff --git a/CLAUDE.md b/CLAUDE.md index c36de38..9a9f206 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,10 +47,13 @@ xcodebuild -workspace Atcha.xcworkspace -scheme Atcha-Dev -configuration Debug \ ## 아키텍처 (AtchaV2 — uFeatures + 클린아키텍처) ``` -AtchaV2(앱, 조합 루트: 어댑터·스플래시) ─► HomeFeature ─► {HomeFeatureInterface, SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} +AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► HomeFeature ─► {HomeFeatureInterface, SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} ├─► SearchFeature ─► {SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} ├─► AtchaData ─► {Domain, CoreNetwork, CoreStorage} - └─► CoreAuth ─► {CoreNetwork, CoreStorage} + ├─► CoreAuth ─► {CoreNetwork, CoreStorage} + ├─► CoreAlarm (무의존 — AlarmKit 유일 import 지점, App만 import) + ├─► CoreLiveActivity (무의존 — ActivityAttributes 계약, 앱·위젯 공유) + └─► AtchaWidget (위젯 익스텐션, 앱에 임베드 — Live Activity UI, UIKit 규약의 유일한 SwiftUI 예외) ``` 의존 규칙(위반 금지, `tuist graph`로 검증 가능): @@ -76,6 +79,9 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시) ─► HomeFeature ─► { ### 미완 상태 (작업 시 참고) -- `AppEnvironment`의 API base URL은 플레이스홀더 — 실서버 주소 미정. -- `com.atcha.iOS.v2`용 GoogleService-Info.plist 미발급 — `AppDelegate`가 파일 존재를 가드한 뒤에만 `FirebaseApp.configure()` 호출. plist를 `Projects/App/Resources/`에 넣으면 자동 활성화. -- AtchaV2는 iOS 26 전용(AlarmKit 사용 예정). AlarmKit의 커스텀 알람 UI(Live Activity)는 추후 위젯 익스텐션 타겟이 별도로 필요. +- **익명 인증 발급 엔드포인트 미확정** — `UnconfiguredAnonymousSessionIssuer` 스텁이 주입돼 있어 실서버에서는 토큰 없이 동작한다(알람 서버 기능 전부 불가). Debug(DEV)는 `DevDemoFallbacks`가 데모 데이터로 가린다 — 실서버 스펙 확정 시 이 폴백과 `AppDIContainer`의 `#if DEV` 주입을 제거할 것. +- `AppEnvironment`의 base URL은 dev/live 실주소 반영 완료. **Stage는 dev 호스트를 공유 중** — 전용 호스트만 미정. +- `Projects/App/Resources/GoogleService-Info.plist`는 **레거시 번들 ID(`com.atcha.iOS`)용 파일**이라 존재 가드만 통과할 뿐 V2(`com.atcha.iOS.v2`)로의 사일런트 푸시가 성립하지 않는다 — V2용 재발급·교체 필요. FCM 토큰 서버 전달도 미구현(로깅만)이라 갱신 채널은 현재 폴링(앱 시작·포그라운드 복귀)뿐. +- AtchaV2는 iOS 26 전용. AlarmKit(CoreAlarm)·Live Activity(CoreLiveActivity + AtchaWidget 익스텐션)는 Phase 9~12에서 구축 완료. +- Phase 12 이후의 갭 분석·후속 로드맵: `docs/planning/atcha-v2-post12-roadmap.md` / Phase 13·14(알람 이후 + 재실행 정합성) 구현 프롬프트: `docs/prompts/atcha-v2-session-lifecycle-prompt.md`. +- Phase 검수는 사람 검수 대신 **자동 검수 규약**(`docs/prompts/atcha-v2-auto-verification.md`)을 따른다 — 에이전트가 computer use로 시뮬레이터 검수를 직접 수행·증적 보고하고, 실기기 잔여 항목만 사용자에게 이관. diff --git a/docs/planning/atcha-v2-post12-roadmap.md b/docs/planning/atcha-v2-post12-roadmap.md new file mode 100644 index 0000000..969170d --- /dev/null +++ b/docs/planning/atcha-v2-post12-roadmap.md @@ -0,0 +1,204 @@ +# AtchaV2 Post-12 로드맵 — 갭 분석 정본 + Phase 13~18 + +> **성격**: Phase 12(폴백·하드닝)로 기존 기획 스코프가 마감된 시점의 **전수 갭 분석 정본**과 후속 로드맵. +> 구현 지시는 이 문서가 아니라 각 Phase의 구현 프롬프트가 한다 — Phase 13·14는 [세션 수명주기 프롬프트](../prompts/atcha-v2-session-lifecycle-prompt.md). +> 충돌 시 **CLAUDE.md > [마스터 프롬프트](../prompts/atcha-v2-master-prompt.md) > [LA 프롬프트](../prompts/atcha-v2-live-activity-prompt.md) > 이 문서** 순. +> 작성일: 2026-08-23. 분석 기준: `feat/v2-phase12-fallback-hardening` (Phase 12 완료 커밋, 당시 Phase 10~12는 `env/dev` 미머지). + +--- + +## 현황 요약 + +Phase 1~12로 "검색 → 알람 등록 → 변경 인지"의 전반부는 완성됐고, **안전망(마지막 값 기준 AlarmKit 발화)은 성립**해 있다. 전수 조사(화면·상태 / 알람·LA 파이프라인 / 기획 문서·Domain·Data)로 확인된 남은 공백은 세 덩어리다: + +1. **실서버에서 앱이 동작하지 않는다** — 익명 인증 미구현 + plist 번들 ID 불일치 + FCM 토큰 미전달. DEV 데모 폴백이 이를 가려 "되는 것처럼 보이는" 상태. → [서버 트랙](#서버-트랙-클라-로드맵과-병렬) +2. **알람이 울린 이후와 시간이 경과한 이후의 세계가 비어 있다** — 발화 감지 0, 알림 탭 라우팅 0, 배너 "출발까지 0분" 무한, 강제 종료 후 고아 LA, 다음날 유령 알람. 지나간 막차를 "탈 수 있다"고 계속 표시하는 것은 "놓치지 않게 깨워주는 앱"의 신뢰를 직접 훼손한다. → **Phase 13·14 (클라 단독 해결, 최우선 확정)** +3. **"간편 그 자체"와의 구조적 간극** — 반복 사용자(매일 같은 귀갓길)에게 홈이 매번 백지(3탭 + 화면 전환 2회 + 대기). → Phase 17·18 + +확정된 방향 (2026-08-23 논의): + +| 항목 | 결정 | +|---|---| +| 최우선 트랙 | **세션 수명주기 완결 (Phase 13·14)** — 서버 협의는 병렬 | +| 알람 이후 UX | **조용한 원상복귀** — 새 화면·버튼 없음. 발화 감지 → LA "지금 출발하세요" → 자동 소멸 → 홈 정리 | +| 홈 간편성 | **최근 경로 원탭 칩** (Phase 18). 집 설정 부활·밤 시간 자동 갱신은 제품 결정 트랙에 후보로만 | +| 산출물 | 이 문서 + Phase 13·14 구현 프롬프트 + CLAUDE.md 미완 상태 절 갱신 | + +--- + +## 서버 트랙 (클라 로드맵과 병렬) + +블로킹 해소 순. 클라 코드는 전부 준비돼 있고 스펙·자산만 기다린다. + +| # | 항목 | 클라 측 잔여 작업 | +|---|---|---| +| **S1** | **익명 인증 발급 엔드포인트 (#2)** — 전 기능의 전제. 현재 `UnconfiguredAnonymousSessionIssuer` 스텁이 항상 throw하고 `bootstrap()`이 non-fatal로 삼켜 **토큰 없이 홈 진입 → 알람 서버 기능 전부 실서버에서 불가** | 구현체 1개 작성 + `AppDIContainer` 주입 교체 + `DevDemoFallbacks` 제거 (제거 조건이 파일에 명시돼 있음) | +| **S2** | **`com.atcha.iOS.v2`용 GoogleService-Info.plist 재발급 (#6·#10)** — 현재 커밋된 plist는 **레거시 번들 ID(`com.atcha.iOS`)**라 존재 가드만 무력화된 채 사일런트 푸시가 성립하지 않는다. **레거시 plist는 제거 대상.** + **FCM 토큰 전달 API (#5)** — 현재 로깅만 | plist 교체, 토큰 전달 호출 1개 (`AppDelegate` TODO 지점) | +| **S3** | **responseCode 실측 (#3)** — `normalizedResult(code:)`가 빈 매핑이라 `.noRoute`가 도달 불가 상태 (빈 목록 = 무조건 `serviceEnded`). 레거시 단서: `URT_001, LRT_001, LRT_003, REQ_004` (의미 미확인). + **"등록된 알람 없음" 표현 (#11, 신규)** — 미정이면 서버에서 알람이 사라져도 홈의 배너·해제 버튼이 정리되지 않는 유령 알람 잔존 | 매핑 테이블 채우기, `refresh()`가 "없음"을 에러가 아닌 상태로 반환하게 수정 + 홈 정리 이벤트 | +| **S4** | 알람 기준 시각 서버 필드 (#7 — Phase 14의 클라 임시안으로 급함 하락), 단일 알람 규약 (#4), Stage 전용 호스트 (#1 잔여), **"다음 운행 안내" 필드 (#12, 신규)** — 기획서가 "막차 종료 시 다음 운행 안내"를 약속했으나 어떤 DTO에도 필드가 없음, 대안(심야버스) 데이터 (#9) | 각 TODO 지점에 반영 | + +### 서버에 전달할 질문 리스트 + +1. 익명 세션 **발급** 엔드포인트 스펙 (경로·요청·응답 — 레거시엔 소셜 로그인뿐, `/auth/reissue`만 실측됨) +2. FCM 토큰을 익명 체계에서 어떻게 등록하나 (엔드포인트·토큰 로테이션 처리) +3. `GET /routes/last-routes`가 "오늘 막차 종료"와 "경로 없음"을 각각 어떤 responseCode로 주나 (실측값) +4. 등록된 알람이 없을 때 `GET /routes/user-routes/refresh`가 무엇을 반환하나 (에러 코드? 빈 성공?) +5. `POST /routes/user-routes`는 기존 알람이 있으면 교체하나, 클라가 삭제 후 등록해야 하나 +6. refresh 응답에 "서버 계산 알람 시각" 또는 "첫 도보 구간 시간" 필드를 추가할 수 있나 (없으면 클라 계산 확정 — Phase 14 임시안이 정식화됨) +7. "다음 운행 안내"(내일 첫차 등) 데이터를 줄 수 있나 / 심야버스 대안 경로는? +8. Stage 전용 호스트 계획 (현재 dev 호스트 공유) +9. (확인용) 익명 세션은 기기 단위인가 — 다중 기기 동시 알람 케이스가 실존하나 + +--- + +## 미확정 입력 원장 — 현재 상태 (#1~10) + 신규 (#11·#12) + +번호는 마스터 프롬프트 #1~6, LA 프롬프트 #7~10에 이어 **#11부터 이 문서가 잇는다.** + +| # | 항목 | 상태 (2026-08-23) | +|---|---|---| +| 1 | 실서버 base URL | **부분 해결** — dev/live 실주소 반영(`AppEnvironment.swift`, 레거시 trust-evaluator에서 복원·사용자 승인). Stage는 dev 호스트 공유 — 전용 호스트만 미정 | +| 2 | 익명 인증 발급 엔드포인트 | **미해결 — 최치명 (S1)** | +| 3 | responseCode 실측 | **미해결 (S3)** — `.noRoute` 도달 불가 상태 | +| 4 | 단일 알람 규약 | 미해결 — 클라 refresh→cancel→register 우회 동작 중 | +| 5 | FCM 토큰 전달 | **미해결 (S2)** — 로깅만 | +| 6·10 | GoogleService-Info.plist | **잘못 해결 (S2)** — 레거시 번들 ID plist가 커밋돼 가드 무력화. 재발급·교체 필요 | +| 7 | 알람 기준 시각 서버 필드 | 미해결·타협(균일 −3분, 도보 미반영) → **Phase 14 클라 임시안이 해소** (등록 시점 walk leg 저장). 서버 필드 확정 시 대체 | +| 8 | 알림 권한 요청 시점 | 임시 동작 유지 (알람 등록 성공 직후) — 확정만 남음 | +| 9 | 대안 제시(심야버스) 데이터 | 미해결 — 실패 문구만 | +| **11** | **서버의 "등록된 알람 없음" 표현** | **신규 (S3)** — `HomeViewModel` TODO로만 존재하던 항목을 원장에 승격. 미정 시 유령 알람 | +| **12** | **"다음 운행 안내" 필드** | **신규 (S4)** — 기획서 약속("오늘 막차 종료 → 다음 운행 안내") 대비 DTO 필드 부재. 명시적 스코프 결정 필요 | + +--- + +## 클라 로드맵 — Phase 13~18 + +Phase 13·14가 확정 최우선. 15~18은 상호 독립성이 높아 재배열 가능하며, 각각 착수 시점에 구현 프롬프트를 이 스타일로 작성한다. 공수: S ≤ 0.5일 / M 1~2일 / L 3일+. + +**검수는 사람 검수 대신 [자동 검수 규약](../prompts/atcha-v2-auto-verification.md)을 따른다** — 실행 에이전트가 computer use로 시뮬레이터 검수를 직접 수행·증적 보고하고, 시뮬레이터 재현이 불가한 항목만 "실기기 잔여"로 사용자에게 이관한다. Phase 15 이후의 신규 프롬프트도 검수 절을 이 규약 기준으로 작성한다. + +### Phase 13 — 알람 이후: 세션 수명 완결 (L) · [구현 프롬프트](../prompts/atcha-v2-session-lifecycle-prompt.md) + +지나간 막차를 "탈 수 있다"고 표시하는 경로를 전부 제거한다. + +- AlarmKit **stopIntent**로 알람 "확인" 탭 감지 (LiveActivityIntent — 앱 프로세스 실행, 백그라운드 깨움. **강제 종료 상태 실행 여부만 실기기 잔여** — 그 외 검수는 자동) +- LA phase **`departed`** 신설("지금 출발하세요") + `end(dismissalPolicy: .after(출발+10분))` — 앱이 다시 깨지 않아도 잠금화면에서 자동 소멸 +- `AlarmSyncService.expireLocallyIfNeeded(now:)` — 모든 wake 지점에서 클라 자체 만료 판정 (서버가 미래값을 주면 서버 우선) +- 홈 배너 3단계 전이: "출발까지 N분" → "지금 출발하세요" → 정리 + 카드 "지난 막차" 상태 (기존 60초 틱 재사용) +- 위젯 `context.isStale` 분기 렌더 — 갱신 끊긴 LA의 마지막 방어선 + +해소하는 갭: 배너 "출발까지 0분" 무한 / LA 0:00 잔존 / 발화 후 상태 정리 전무 / 서버 `sessionEnded`에만 의존하는 종료. + +### Phase 14 — 재실행 정합성: 스냅샷 + 고아 LA + 도보 시간 (M~L) · [구현 프롬프트](../prompts/atcha-v2-session-lifecycle-prompt.md) + +앱 프로세스 수명과 알람 세션 수명을 분리한다. + +- **세션 스냅샷 영속화** (`AlarmSessionSnapshotStore` — Domain 포트 + App 어댑터, `KeyValueStore+Codable` 재사용): AlarmInfo + 첫 도보 초 + 노선 표시명·수단 → `AlarmSyncService.lastInfo` 휘발 해소 (재실행 후 첫 sync가 진짜 diff를 산출) +- **고아 LA 재부착**: 부트스트랩 직후 `Activity.activities` 스캔 — 스냅샷과 일치·미만료면 adopt(+dismiss 관찰 재개), 아니면 즉시 end. 같은 경로로 8시간 한도·시작 실패로 죽은 세션도 sync 시 재시작 (dismiss 기록 없을 때만 — push-to-start 금지 정책과 무관한 로컬 재생성) +- **카드 복원**: 미사용 자산 `RouteEndpoint.detail` / `lastRoute(id:)` 재활용 — "무슨 경로인지 모르는 해제 버튼" 해소 +- **도보 시간 클라 반영 (미확정 #7 임시안 실현)**: 등록 시 `route.legs` 첫 walk leg `sectionTime`을 스냅샷에 저장, `AlarmTiming` 확장으로 등록/refresh/LA/배너 4곳 동일 기준 +- 소소 정합성 일괄: 등록 시 과거 fireDate 사전 가드(+`AlarmError.tooLate`), "당겨짐" 배지 조기 소멸 수정, 최초 LA 긴급도 기준 통일(alarmTime), 분 반올림 `.up` 통일, DI compact 수단 아이콘(버스 고정 해소) + +### Phase 15 — 인지 채널 방어선 (M) + +Phase 12 폴백이 실전에서 뚫리는 지점을 막는다. + +- 로컬 노티 폴백 조건을 `isDismissedByUser` 단일 → **"LA alert 도달 불가"**(dismissed ∨ 활성 activity 없음 ∨ `areActivitiesEnabled` false)로 확대 — LA 꺼둔 유저의 인지 채널 0 해소 +- 폴백 노티에 `.timeSensitive` interruptionLevel + entitlement — **집중 모드(심야에 흔함)에서 억제되는 현재 상태 해소** +- `UNUserNotificationCenterDelegate` 도입 — 노티 탭 → 홈 랜딩, 포그라운드 표시 정책 명시 +- 알림 권한 거부 시 1회 안내 토스트("막차 변경 알림을 받으려면 설정에서 허용") — 재요청 스팸 금지는 기존 가드 유지 +- 최후통첩 경로의 포그라운드 이중 알림(LA alert 소리 + 인앱 토스트 동시) 제거 +- 위치 권한 `didBecomeActive` 재확인 — 설정 다녀오면 출발지 재조회 (현재 viewDidLoad 1회뿐) + +### Phase 16 — 갱신 신뢰성 (M) + +- 홈 pull-to-refresh + `AlarmSyncService.syncNow` 노출 — 수동 갱신 수단 전무 해소 +- Stage/Release URLSession 타임아웃 60초 기본 → 10~15초 + 멱등 GET 1회 재시도 +- `NetworkError` 오프라인 구분 + 스플래시 실패 문구 분기 (현재 원인 불문 "네트워크 연결을 확인해주세요" 고정) +- 배너·카드에 "HH:mm 확인 기준" 신선도 스탬프 — sync 무음 실패의 조용한 표면화 (실패 토스트는 소음이라 지양) +- **App 타겟 테스트 타겟 신설** — `AlarmSyncService`(Phase 11·12 오케스트레이션의 심장, 현재 무테스트)의 `UIApplication` 직접 참조를 주입으로 바꿔 판정·폴백 분기 회귀 방어 + +### Phase 17 — 검색·홈 마찰 팩 (M) + +첫 90초의 이탈 요인 제거. 권한 전부 거부한 "조회 전용 사용자"의 최소 동작 보장이기도 하다. + +- 검색 결과 0건·최근 검색 0건 → `DSEmptyState` 표시 (현재 완전한 빈 화면) + 장소 검색 로딩 상태 +- 홈 `arrivalField` 상태 바인딩 (현재 어떤 상태와도 연결되지 않아 경로 카드 옆에 placeholder 영구 공존) +- 홈 필드 구분 진입 — 도착지 탭 시 도착지 슬롯에서 시작 (`SearchCoordinatorBuildable`에 initialField) +- 스와이프 백 시 `SearchCoordinator` 누수 수정 (`UINavigationControllerDelegate`) +- `LocationError` 세분화 (denied/restricted/전역 OFF — restricted엔 "설정으로 이동"이 무의미) +- **오늘/내일 라벨** — 심야 앱에서 "도착 00:29"의 날짜 모호 해소 +- `serviceEnded`/`noRoute`의 "다시 검색하기" 실동작 (현재 최근 검색 화면 복귀만), 최근 검색 스와이프 삭제 (기획서 요구 누락분) + +### Phase 18 — 최근 경로 원탭 칩 (M) + +"간편 그 자체"를 반복 사용자에게 체감시키는 최소 공수의 구조 개선. 3탭 → 1탭. + +- 홈 검색 필드 아래 마지막 도착지 칩 1개("→ 신림동") — 탭 시 현재 위치 기준 즉시 재검색 → 결과 카드를 홈에 바로 표출 (검색 화면 생략) +- `RecentSearchRepository` 재사용 + 홈에 `SearchLastRoutesUseCase` 주입 +- **"최근 검색만, 즐겨찾기 없음" 확정 결정과 충돌하지 않는다** — 별도 저장·관리 UI가 없는 최근 검색의 표면 확장이다. 알람 자동 등록은 하지 않는다 (알람은 명시적 의사) + +--- + +## 제품 결정 트랙 (별도 논의 후 착수) + +| 항목 | 상태 | +|---|---| +| 홈 위치 기반 자동 막차 (도착지 입력 전 막차 0 — 앱 컨셉과 최대 간극) | 최대 스코프 — Phase 18의 사용 데이터 확인 후 별도 기획 | +| 밤 시간대(21시+) 홈 진입 시 최근 경로 자동 재검색 | v1.1 후보 — 칩(18) 데이터로 검증 후. **알람 자동 등록은 반대 확정** (제안 카드까지만) | +| "집" 배지 승격 (레거시 온보딩 "우리집 설정"의 부활) | 보류 — 칩의 최근 1위 고착률이 입증하면 1슬롯만. 일반 즐겨찾기 N개 관리는 반대 유지 | +| 알람에 "경로 보기" secondaryButton | 보류 — 심야 버튼 2개는 인지 부하. 조용한 원상복귀 원칙 우선 | +| "잘 탔어요?" 세션 회고 / 알람 히스토리 | v1 반대 — 응답 데이터를 쓸 곳이 없음 | +| 홈스크린·잠금화면 위젯 | v1.1 후보 — **"세션 밖 진입 런처"로 역할 한정** (세션 중 카운트다운은 LA 담당, 미니 앱화 반대) | +| 택시비 절약 (레거시 대표 기능) | 누적 통계·상시 표시 반대. **놓침/임박 카피 한 줄만 부활 후보** ("지금 놓치면 택시비 약 N원") | +| 공유 ("나 00:12 막차야") | 여유 시 — 술자리 맥락 바이럴, 공수 하 | +| 온보딩·위치 권한 프라이머 | 후보 — 현재 사전 설명 없이 시스템 팝업 즉발, 거부 시 회복 경로 없음(15가 일부 해소) | +| AlarmKit 거부 시 "조회 전용(LA·배너만)" 모드 | 보류 — 안전망 없는 카운트다운은 앱 사명과 충돌 소지 | +| 접근성 (Dynamic Type·VoiceOver 전무, 긴급도 색상만 구분, 토스트 announce 없음) / i18n (전 문구 하드코딩) | 착수 시점 결정 필요 — 현재 0% | +| **반대 판정 기록** | 지도 SDK 부활 (텍스트 "OO정류장 · 도보 N분"으로 대체 — 같은 효용, 1/20 공수), Apple Watch 전용 앱 (LA alert가 이미 워치 미러링), 게이미피케이션, 탑승 체크인·아침 리포트, Siri/App Intents 선행 투자 (위젯의 부산물로만) | + +--- + +## 신뢰 UX 원칙 (전 Phase 공통) + +**"거짓 숫자를 보여주느니 낡았다고 말한다."** 이 앱의 리텐션은 기능이 아니라 신뢰에서 나온다 (알람 실패 1회 = 삭제). + +1. **자기모순 제거가 전제**: 카드·배너·LA의 시각은 단일 소스 — 카드만 낡은 값으로 남는 현재 상태(막차 변경 시 한 화면에 두 시각)가 신뢰의 최대 적 +2. **자연 만료**: "출발까지 0분" 무한 표시는 "이 앱 숫자는 믿으면 안 된다"의 학습 장치 — Phase 13이 제거 +3. **신선도 스탬프**: 갱신 실패를 토스트로 소음화하지 않고 "HH:mm 확인 기준"으로 조용히 정직하게 +4. **안전망 약속의 문장화**: 오프라인·서버 다운 시 "지금은 연결이 안 돼요. 알람은 마지막 확인(23:40) 기준으로 유지돼요" — 시스템이 뒤에서 지키는 약속을 사용자에게 말한다 +5. **변경의 흔적**: 방향 비대칭·행동 중심 문구·10분 배지는 기존 정책 유지. 취소선 상시 표시 금지 + +## 수용 리스크 (명시적 결정) + +- 알람 확인도, 앱 재진입도 없는 최악 케이스에서 LA가 시스템 수명 한도까지 잔존할 수 있다 — `staleDate` + `isStale` 렌더로 "낡음"이 표시되므로 거짓 정보는 아님 (Phase 13 이후) +- 저전력 모드의 사일런트 푸시 스로틀 — 기존 수용 리스크(알람은 로컬)와 동일 구조. stopIntent가 유일하게 이 영향 밖의 깨움 지점 +- 시간대 변경 여행자의 표시 시각 혼란 — 계산은 KST 절대시각이라 안전, 실사용자(서울 심야) 기준 사소함으로 강등 +- 등록 후 사용자가 크게 이동한 경우의 도보 시간 어긋남 — v1 수용, "위치 기반 재평가"는 제품 결정 트랙의 논거로만 + +--- + +## 부록 — 갭 근거 목록 (분석 시점 검증 완료) + +파일 참조는 심볼 기준 (라인 번호는 부패하므로 생략). 전부 소스에서 직접 확인된 사실. + +**알람·LA 파이프라인** +- 발화 감지 수단 전무: `AlarmKitEngine`은 schedule/cancel만, `stopIntent`·`UNUserNotificationCenterDelegate`·딥링크·`widgetURL` 0건. 예약 로컬 알림 0건 (즉시 발송 `post()`뿐) +- `HomeViewModel.minutesUntil`의 `max(0,…)` 클램프 + 종료 조건 없는 배너 틱 → "출발까지 0분" 무한. 카운트다운은 알람 시각(출발−3분) 기준이라 실제 출발 3분 전부터 0분 +- `LastTrainLiveActivityAdapter.activity` 인메모리 전용 — 재실행 시 update/end 전부 no-op guard (고아 LA). `AlarmSyncService.lastInfo`도 인메모리 (diff 휘발) +- 폴백 분기 조건이 `isDismissedByUser` 단일 — LA 비활성·시작 실패는 미커버. `LocalNotificationAdapter`는 interruptionLevel 미설정, time-sensitive entitlement 없음, 권한 거부 무안내 +- `RegisterAlarmUseCase`에 과거 fireDate 가드 없음 (서버 등록 성공 후 로컬 스케줄 실패 → 서버/로컬 불일치. DEV 데모 경로가 이 문제로 출발 시각 3분→8분 조정됨(`18b60f7`) — 근본 가드는 Phase 14) +- 최후통첩 경로가 applicationState 미검사 → 포그라운드 이중 알림 / unchanged·delayed 갱신이 `changeBadgeExpiry: nil` 덮어씀 → 배지 조기 소멸 / 최초 LA 긴급도만 출발 시각 기준(이후는 알람 시각) / 반올림 `.up` vs `.rounded()` 불일치 / DI compact 아이콘 버스 고정 (수단 필드 부재) +- App 타겟 테스트 타겟 부재 — `AlarmSyncService` 전 분기 무테스트. LA 8시간 한도는 주석 인정만 + +**화면·상태** +- `HomeViewController.render()`는 departureField만 갱신 — arrivalField 미바인딩. `alarmSynced()`는 배너·버튼만 — 카드 시각 영구 미갱신 +- `searchFieldTapped()` 필드 구분 없음 (주석으로 의도 명시된 v1 단순화) +- 검색: 결과 0건·최근 0건 빈 화면, 장소 검색 로딩 없음, `.idle` dead state, "다시 검색하기" 미동작, `RouteResultsViewData`가 `entities[0]` 강제 인덱싱 (호출부 가드에만 의존) +- 위치: 권한 회복 경로 없음(viewDidLoad 1회), `LocationError` 2케이스로 denied/restricted/전역 OFF 뭉개짐, reduced accuracy 미처리, 역지오코딩 실패는 무토스트 +- 스플래시: 원인 불문 고정 문구, Stage/Release 타임아웃 60초 기본. 스와이프 백 Coordinator 누수. 오늘/내일 라벨 없음. 온보딩·프라이머 0건. 접근성·i18n 0건. `RouteCardViewData`/`RouteViewData`/`TransportBadgeMapper` 중복 구현 + +**기획·Data** +- `LastRouteSearchResult.noRoute` 도달 불가 (`normalizedResult` 빈 매핑). "다음 운행 안내" 필드 부재 (#12). `RouteEndpoint.detail` 구현만 되고 미사용 (→ Phase 14가 재활용). `AlarmError` 케이스 1개 — 실패 전부 단일 토스트 +- 네트워크 회복 정책 전무 (재시도·백오프·도달성 감지·오프라인 전용 에러 없음 — 기획서에도 없던 영역) +- 문서-코드 불일치: CLAUDE.md 미완 상태 절 3건 낡음 (이 로드맵과 함께 갱신), README는 여전히 레거시 1.x 설명 diff --git a/docs/prompts/atcha-v2-auto-verification.md b/docs/prompts/atcha-v2-auto-verification.md new file mode 100644 index 0000000..61769c9 --- /dev/null +++ b/docs/prompts/atcha-v2-auto-verification.md @@ -0,0 +1,60 @@ +# AtchaV2 자동 검수 규약 — 사람 검수의 computer use 대체 + +> **성격**: 모든 Phase 프롬프트의 "사람 검수"를 이 규약의 **자동 검수**로 대체한다. 검수의 실행 주체는 사용자가 아니라 **실행 에이전트**다 — 에이전트가 computer use로 시뮬레이터를 직접 조작하고 스크린샷 증적으로 보고하며, 사용자는 조작이 아니라 증적을 검토한다. +> 적용 범위: Phase 13 이후 신규 프롬프트는 검수 절을 이 규약 기준("자동 검수" + "실기기 잔여")으로 작성한다. 기존 문서(마스터·LA 프롬프트)의 "사람 검수"도 재검수·잔여 검수(예: Phase 10~12 머지 전 검수)가 필요해지면 이 규약으로 수행한다. +> 충돌 시 **CLAUDE.md > [마스터 프롬프트](atcha-v2-master-prompt.md) > 개별 Phase 프롬프트 > 이 문서** 순. 작성일: 2026-08-23. + +--- + +## 원칙 + +1. **시뮬레이터에서 재현 가능한 항목은 전부 에이전트가 직접 수행한다.** "사용자가 직접 확인해야 다음 진행" 대신 "에이전트가 수행·증적 보고해야 다음 진행"이 블로킹 게이트다. +2. **원리적으로 시뮬레이터 재현이 불가한 항목만 "실기기 잔여"로 분리**해 사용자에게 이관한다. 각 프롬프트의 검수 절은 `### 자동 검수 (블로킹)`과 `**실기기 잔여**` 두 부분으로 나눠 작성한다. +3. **실패를 숨기지 않는다** (마스터 진행 프로토콜 3 상속) — 기대와 다른 화면, 자동화 자체의 실패(플레이키·조작 불가)도 판정과 함께 그대로 보고한다. 자동화가 안 된다고 항목을 조용히 건너뛰지 않는다. +4. 검수는 **Debug(DEV) 구성**으로 수행한다 — DEV 전용 훅(데모 폴백·변경 시뮬레이터)이 검수의 재료다. + +## 도구 스택 + 실측 제약 (이 레포에서 확인된 사실) + +| 도구 | 용도 | 실측 제약 | +|---|---|---| +| `xcrun simctl` | 부팅·설치·실행·종료·스크린샷 | `simctl push`의 content-available 주입은 **iOS 26 시뮬레이터가 거부**(RequestDenied) → 사일런트 푸시 수신은 항상 실기기 잔여 | +| computer use (orca CLI) | Simulator.app 화면 내 UI 조작 | **좌표 클릭은 불신뢰(실측) — AX 액션(AXPress) 기반으로만 조작**할 것. 검수 시작 전 `computer-use` 스킬을 로드해 현재 CLI 사용법을 확인 | +| 시스템 메뉴 조작 (System Events) | Simulator 메뉴 동작 — Device ▸ Lock(잠금화면 LA 확인의 표준 수단), Home 등 | **Mac 화면이 잠겨 있으면(ScreenIsLocked) AX 기반 조작이 전면 불가** — simctl 스크린샷만 가능. 검수 시작 전 잠금 상태를 확인하고, 잠겨 있으면 진행하지 말고 보고 후 대기 | + +추가 실측 규약: + +- **강제 종료 재현은 `xcrun simctl terminate`** — 앱 스위처 스와이프 자동화 불필요. +- **LA dismiss 검수는 DEV dismiss 토글 사용** — 잠금화면 스와이프 삭제 자동화가 불안정해서 그 용도로 만들어진 수단(플로팅 디버그 메뉴의 dismiss 기록 토글, `devToggleDismissedByUser`)을 쓴다. +- **막차 변경 주입은 DEV 플로팅 "변경" 버튼** (SceneDelegate) — 앞당김/늦춤/운행 종료. actionable 경로가 필요하면 늦춤 먼저 → 앞당김 순서로 주입한다(실측 관례). +- **시간 경과는 실대기(sleep)로 수행.** 대기가 과도해지면(예: 자동 소멸 +10분) 데모 출발 시각·유예 상수를 **DEV 한정으로 단축**하는 것을 허용한다 — `DevDemoFallbacks` 관례(검수용 임시 우회 파일)와 동일한 취급. +- 다크/라이트, 시각 고정이 필요하면 `simctl ui appearance`·status bar override 사용 가능. + +## 표준 시나리오 빌딩 블록 (조합해서 검수 시나리오를 구성) + +| 블록 | 수행 | +|---|---| +| 준비 | Mac 화면 잠금 상태 확인 → DEV 빌드 설치·실행 (`simctl install`/`launch`) | +| 알람 등록 | 홈 → 검색 → 데모 경로 선택 → "알람 등록하기" (AXPress 시퀀스, 권한 팝업 허용 포함) | +| 변경 주입 | DEV 플로팅 "변경" 버튼 → 앞당김/늦춤/종료/dismiss 토글 | +| 잠금화면 확인 | Device ▸ Lock → `simctl io booted screenshot` (LA 상태 증적) | +| 백/포그라운드 전환 | Home 메뉴 → 재실행 (포그라운드 복귀 sync 트리거) | +| 강제 종료·재실행 | `simctl terminate` → `simctl launch` | +| 발화 대기 | 알람 시각까지 실대기 → 발화 화면 스크린샷 → "확인" AXPress | + +## 증적 규약 + +- 스크린샷은 단계마다 저장 — 레포에 커밋하지 않는 위치(세션 스크래치 디렉터리 등)에 `p{phase}-{step}-{설명}.png` 형식으로. 보고에 경로를 명시한다. +- 최종 보고는 표로: **검수 항목 / 기대 / 관찰 / 판정(통과·실패·자동화 불가) / 증적 파일**. +- "자동화 불가" 판정은 사유(위 실측 제약 중 무엇에 걸렸는지)와 함께 실기기 잔여로 이관한다. + +## 게이트 의미 + +- **자동 검수 (블로킹)**: 증적 보고까지 완료해야 다음 Phase 진행. 판정에 "실패"가 있으면 수정 후 재검수. +- **실기기 잔여**: 사용자 확인 대기 목록에 기록하고 진행한다 — 단, 프롬프트에 "불가 시 분기"가 명시된 항목(예: stopIntent의 강제 종료 시 실행 여부)은 **보수적 가정 쪽으로 진행**하고 실기기 결과로 확정하며, 결과를 해당 프롬프트에 기록한다. 명시가 없는 잔여 항목은 머지 전 사용자 확인을 요청한다. + +## 항상 실기기 잔여인 항목 (시뮬레이터 재현 불가 실측·원리) + +- 사일런트 푸시(content-available) 수신 — simctl 주입 거부, 실서버+실기기로만 +- 강제 종료 상태에서의 stopIntent(LiveActivityIntent) 실행 여부 — 시뮬레이터의 프로세스 깨움 semantics는 참고값 +- AlarmKit 발화의 무음 모드·집중 모드 관통 +- Dynamic Island 실물 표시 품질(시뮬레이터 렌더는 참고값), Apple Watch 미러링 diff --git a/docs/prompts/atcha-v2-session-lifecycle-prompt.md b/docs/prompts/atcha-v2-session-lifecycle-prompt.md new file mode 100644 index 0000000..e9424cb --- /dev/null +++ b/docs/prompts/atcha-v2-session-lifecycle-prompt.md @@ -0,0 +1,180 @@ +# AtchaV2 세션 수명주기 구현 프롬프트 — 알람 이후 + 재실행 정합성 + +> **사용법**: 이 문서 전체를 Claude Code에 컨텍스트로 전달하고 `"Phase N을 진행해"`라고 지시한다. +> 실행 에이전트는 [마스터 프롬프트](atcha-v2-master-prompt.md)의 **진행 프로토콜·공통 규칙·공통 acceptance를 그대로 상속**하며, 한 번에 한 Phase만 수행한다. +> 갭 분석·우선순위 정본은 [Post-12 로드맵](../planning/atcha-v2-post12-roadmap.md). 충돌 시 **CLAUDE.md > 마스터 프롬프트 > [LA 프롬프트](atcha-v2-live-activity-prompt.md) > 이 문서** 순. +> **검수는 사람 검수가 아니라 [자동 검수 규약](atcha-v2-auto-verification.md)을 따른다** — 실행 에이전트가 computer use로 직접 수행·증적 보고하고, 실기기 잔여 항목만 사용자에게 이관한다. +> 작성일: 2026-08-23. + +--- + +## Goal (최상위) + +**사명의 후반부를 완성한다: 알람이 울린 이후에도, 앱이 죽었다 살아난 이후에도, 앱은 거짓말하지 않는다.** + +Phase 12까지의 인지 계층은 "알람이 울리기 전"의 세계다. 울린 이후는 비어 있다 — 발화를 감지하지 못하고, 막차 시각이 지나도 배너는 "출발까지 0분"을 무한 표시하며, 강제 종료 후 재실행하면 잠금화면의 LA는 갱신도 종료도 불가능한 고아가 되고, 다음날 홈에는 "무슨 경로인지 모르는 해제 버튼"만 남는다. 이 문서는 그 후반부를 닫는다. Phase 번호는 LA 프롬프트의 9~12에 이어 **13~14**. + +세션의 전체 수명 (이 문서가 완성하는 상태 기계): + +``` +등록 ──► active ("출발까지 N분") + │ 알람 발화(출발−버퍼) + "확인" 탭 ← stopIntent가 감지 (Phase 13) + ▼ + departed ("지금 출발하세요") ← 신설 phase (Phase 13) + │ 출발 시각 + 10분 + ▼ + 자동 소멸 (LA 예약 dismissal + 홈 정리) ← 앱이 깨지 않아도 (Phase 13) + + [어느 시점이든] 서버 sessionEnded ──► serviceEnded 종료 (기존) + [어느 시점이든] 못 타는 앞당김 ──► missed 고정 (기존) + [wake 시점마다] 출발+유예 경과 ──► 클라 자체 만료 = 로컬 sessionEnded (Phase 13) + [재실행 시] 스냅샷 복원 + LA 재부착 + 카드 복원 (Phase 14) +``` + +확정된 제품 결정사항 (변경하려면 사용자에게 먼저 물을 것 — 2026-08-23 논의 확정): + +| 항목 | 결정 | +|---|---| +| 알람 이후 UX | **조용한 원상복귀.** 새 화면·새 버튼 없음 — 기존 서피스(LA·배너·카드)의 상태 전이로만. 알람 "확인"은 앱을 열지 않는 것이 기본값 | +| 발화 감지 | AlarmKit **stopIntent** (LiveActivityIntent — 앱 프로세스에서 실행, 필요 시 백그라운드 깨움). secondaryButton("경로 보기")은 붙이지 않는다 — 심야 버튼 2개는 인지 부하 (제품 결정 트랙에 기록됨) | +| 만료 판정 | **이중 구조**: 깨어 있을 때는 홈 배너 틱이, 깨어날 때는 `AlarmSyncService` 진입점이 판정. 서버 refresh가 미래 시각을 주면 **서버 우선**(만료 취소) | +| 만료 유예 | 출발 시각 + 60초 (wake 시점 판정) / LA 예약 소멸은 출발 + 10분 (`.after`) — 잠금화면에 "지금 출발" 상태가 잠시 남는 것이 취지 | +| 세션 영속화 | 스냅샷(AlarmInfo + 첫 도보 초 + 노선 표시명·수단)을 Domain 포트 + App 어댑터로. 알람 세션의 정본은 여전히 서버 — 스냅샷은 재실행 브리지 | +| 도보 시간 | **클라 임시안 확정 (미확정 #7 해소)**: 알람 기준 시각 = `departureTime − 첫 도보 구간 시간 − 3분 버퍼`. 도보 초는 등록 시점 경로에서 취득해 스냅샷에 저장. 서버 필드가 생기면 대체 | +| 고아 LA | 재실행 시 `Activity.activities` 재부착. dismiss 기록이 없는 죽은 세션(8시간 한도·시작 실패 포함)은 sync 시점 **로컬 재시작** — push-to-start 금지 정책과 무관 (그 정책은 유저가 지운 LA의 재생성 금지) | + +--- + +## 이 문서가 다시 정의하지 않는 것 (중복 금지) + +아래는 기존 Phase 산출물이다. **계약을 바꾸지 않고 덧붙이거나, 명시된 지점만 확장한다.** 이 목록의 계약을 깨고 싶어지면 멈추고 사용자에게 물을 것. + +| 산출물 | 소속 | 이 문서에서의 취급 | +|---|---|---| +| `AlarmSyncService` 3경로 일원화 + 판정 훅 | Phase 8·11 | 진입점에 만료 판정을 **선행 삽입**, `lastInfo`에 초기 로드를 추가 — 판정·채널 분기 로직 자체는 불변 | +| `EvaluateAlarmChangeUseCase` / `AlarmChangeVerdict` | Phase 11 | 그대로 사용. 케이스 추가 금지 (만료는 verdict가 아니라 로컬 sessionEnded 처리) | +| `LastTrainActivityPort` (Domain 문서 고정 계약) | Phase 10 | **시그니처 불변.** `departed`는 `LastTrainSessionPhase` 케이스 추가일 뿐 포트는 그대로 | +| dismiss 감지·폴백, 방향 비대칭, 배지 10분 | Phase 10~12 | 그대로 사용. 폴백 조건 확대는 Phase 15 몫 — 선취 금지 | +| `AlarmKitScheduling` / `AlarmKitEngine` | Phase 7 | stopIntent 주입 지점만 확장 (아래 명세) | +| 서버 계약 전체 | 마스터 공통 규칙 | **서버 변경 0.** 이 문서의 전 작업은 클라 단독 — 새 엔드포인트·payload 제안 금지 | + +## 전제 조건 + +1. **Phase 12 완료가 전제** (`feat/v2-phase12-fallback-hardening` 기준). 13 → 14 순서 고정 — 14의 스냅샷·재부착이 13의 `departed`·만료 개념에 의존한다. +2. 서버 트랙(로드맵 S1~S4)과 **완전 독립** — 미확정 입력을 새로 요구하지 않는다. 실서버 미인증 상태이므로 검수는 [자동 검수 규약](atcha-v2-auto-verification.md)에 따라 DEV 데모 폴백·변경 시뮬레이터를 재료로 에이전트가 직접 수행한다. +3. AlarmKit `stopIntent`의 정확한 이니셜라이저 형태(iOS 26 SDK)는 구현 시점에 **SDK에서 실검증**한다 — 이 문서의 요구는 행동 명세("stop 버튼 탭 시 LiveActivityIntent 실행")이며, 편의 이니셜라이저 `.alarm(schedule:attributes:)`를 full configuration으로 교체하는 방향만 고정한다. + +--- + +## Phase 13 — 알람 이후: 세션 수명 완결 + +### Goal +알람 발화("확인" 탭)를 감지하고, 시간 경과를 클라가 스스로 판정하며, 세션이 자연 만료되게 한다. 지나간 막차를 "탈 수 있다"고 표시하는 경로를 전부 제거한다. + +### Requirements +- **`departed` phase 신설 (wire 계약 확장)**: Domain `LastTrainSessionPhase`와 CoreLiveActivity `LastTrainSessionStatus`에 `departed` 추가 (rawValue 동일 규약 유지 — 앱·익스텐션 동일 바이너리 배포라 wire 안전. 기존 방어값 정책상 미지 rawValue는 `.active`로 떨어지므로 추가도 안전). 위젯: `departed`는 카운트다운 대신 상태 문구 **"지금 출발하세요"** + 출발 시각, glance 색은 imminent와 동일 척도. +- **stopIntent 연결 (CoreAlarm 확장)**: `AlarmKitEngine.schedule`을 full `AlarmConfiguration`으로 교체하고 stop 버튼에 인텐트를 싣는다. 인텐트 **타입**은 App의 `Projects/App/Sources/Intents/`에 둔다 (`LiveActivityIntent` 채택, `perform()`에서 조합 루트의 세션 수명 서비스 호출). CoreAlarm은 인텐트 **인스턴스를 주입받는 전달 수단**만 가진다 — AlarmKit·AppIntents는 시스템 프레임워크라 무의존 원칙 위배가 아니지만, App 타입이 CoreAlarm으로 새어 들어가면 안 된다 (주입 방향은 App → CoreAlarm 한 방향). +- **`alarmAcknowledged()` (App 세션 수명 로직)**: 인텐트 실행 시 ① 스냅샷에 확인 기록(Phase 14에서 영속화 — 13에서는 인메모리) ② LA를 `departed` 상태로 갱신 ③ `end(dismissalPolicy: .after(departureTime + 10분))` 예약 — **앱이 다시 깨지 않아도 잠금화면에서 자동 소멸**된다. end 이후 남은 시간 창(최대 3분+10분)의 재변경 인지는 알람 재스케줄(기존 경로)이 담당 — LA 재생성은 하지 않는다. +- **`AlarmSyncService.expireLocallyIfNeeded(now:)`**: `sync()` 파이프라인 **진입 시** 선행 판정 — 보유 세션의 `departureTime + 60초 < now`면 refresh 결과와 무관하게 **로컬 sessionEnded 처리**(알람 레코드 정리 → LA `end(final: .serviceEnded)` → changes 스트림 yield → 홈 정리는 기존 `sessionEnded` 소비 경로 재사용). 단 refresh가 **성공해 미래 출발 시각을 반환하면 서버 우선** — 만료를 취소하고 정상 갱신 경로로. 만료 처리 여부를 기록해 이후 refresh가 같은 과거 세션으로 배너를 되살리지 못하게 한다 (`alarmSynced`의 미래 시각 가드가 1차 방어, 기록이 2차). +- **홈 배너 3단계 전이** (`HomeViewModel` — 기존 60초 틱 재사용, 신규 인프라 없음): + 1. `now < alarmTime`: "출발까지 N분" (기존) + 2. `alarmTime ≤ now < departureTime + 유예`: **"지금 출발하세요"** (imminent 고정) — "출발까지 0분" 문구 제거 + 3. 유예 경과: 배너 제거 + 카드를 **"지난 막차" 상태**(비활성 톤 + "HH:mm 출발이었어요")로 전환 + 알람 버튼 hidden. 틱 루프 종료. + `makeBanner`류 순수 함수로 구현해 경계 테스트 (자정 경계 포함 — 기존 테스트 패턴 재사용). +- **위젯 `context.isStale` 분기**: 갱신이 끊긴 채 `staleDate`(=출발 시각)가 지난 LA는 카운트다운 대신 **"시간이 지났어요 — 앱에서 확인하세요"** 렌더. 앱 깨움 없이 동작하는 마지막 방어선 (강제 종료·고아 케이스의 UI 완충 — 근본 해소는 Phase 14). + +### Constraints +- 알람 재스케줄·안전망 경로에 회귀 금지 — stopIntent 추가로 알람 등록이 실패하게 되면 안 된다 (인텐트 주입 실패 시 인텐트 없이 스케줄되는 fallback 유지). +- AppIntents·ActivityKit 심볼의 Domain·Feature 유출 금지 (`tuist graph` + import 검사 — 기존 규약). +- 만료 판정·배너 전이는 순수 함수로 분리해 시각 주입 테스트 (실 `Date()` 의존 금지 — 기존 `now` 주입 패턴 재사용). +- `AlarmChangeVerdict`에 케이스를 추가하지 않는다 — 만료는 기존 `sessionEnded` 소비 경로를 로컬에서 트리거하는 방식. + +### Acceptance +공통 acceptance + `-scheme Domain test` + `-scheme HomeFeature test` + `-scheme CoreLiveActivity test` + `-scheme CoreAlarm test`. + +### 자동 검수 (블로킹 — [자동 검수 규약](atcha-v2-auto-verification.md)) +iOS 26 시뮬레이터에서 에이전트가 직접 수행하고 단계별 스크린샷 증적으로 보고: +① 알람 등록 → 발화 대기 → **"확인" AXPress → 잠금(Device ▸ Lock) 스크린샷으로 LA "지금 출발하세요" 전환 확인 → 출발+10분 자동 소멸 확인** (대기가 과도하면 데모 출발 시각·소멸 유예를 DEV 한정 단축 — 규약의 `DevDemoFallbacks` 관례) +② `simctl terminate`로 종료한 상태에서 발화 → "확인" → 재실행 후 확인 기록(스냅샷·로그)으로 인텐트 실행 여부 판정 — **시뮬레이터 결과는 참고값** (최종 판정은 실기기 잔여) +③ 확인도 앱 재진입도 없이 방치 → `staleDate` 경과 후 잠금화면 isStale 렌더 스크린샷 +④ DEV 플로팅 "변경" 버튼으로 만료 직전 "미래로 늦춰짐" 주입 → 서버 우선(만료 취소) — 배너·LA 갱신 스크린샷. + +**실기기 잔여**: 강제 종료 상태의 stopIntent 실행 여부 **최종 판정** — 실행 안 되면(시스템 제약 판명) wake 시점 리컨실(`expireLocallyIfNeeded`)만으로 커버됨을 확정하고 **판명 결과를 이 문서에 기록** (확정 전까지는 리컨실이 커버한다는 보수적 가정으로 진행). AlarmKit 발화의 무음·집중 모드 관통. + +--- + +## Phase 14 — 재실행 정합성: 스냅샷 + 고아 LA 재부착 + 도보 시간 + +### Goal +앱 프로세스 수명과 알람 세션 수명을 분리한다 — 강제 종료·재실행이 정보를 잃지 않고, 잠금화면의 고아 LA가 사라지고, 알람 시각에 도보 시간이 반영된다. + +### Requirements +- **세션 스냅샷 (Domain 포트 + App 어댑터 — 기존 포트+어댑터 패턴)**: + ```swift + public struct AlarmSessionSnapshot: Sendable, Equatable, Codable { + public let info: AlarmInfo // AlarmInfo에 Codable 채택 추가 + public let firstWalkSeconds: Int? // 등록 시점 경로의 첫 도보 구간 (없으면 nil) + public let routeDisplayName: String // LA·카드 복원용 표시명 + public let acknowledged: Bool // stopIntent 확인 기록 (Phase 13 연동) + public let expired: Bool // 로컬 만료 기록 (Phase 13 연동) + } + public protocol AlarmSessionSnapshotStore: Sendable { + func load() async -> AlarmSessionSnapshot? + func save(_ snapshot: AlarmSessionSnapshot) async + func clear() async + } + ``` + App 어댑터는 `KeyValueStore+Codable` 재사용 (UserDefaults 백엔드). 필드 추가에 대비해 디코딩 실패는 nil로 무해화 (최근 검색 저장소의 자가치유 패턴 재사용). 기록 시점: 등록 성공·sync 성공 시 save, 취소·sessionEnded·만료 확정 시 clear. +- **diff 휘발 해소**: `AlarmSyncService.lastInfo`의 초기값을 스냅샷에서 로드 — 재실행 후 첫 sync가 `previous == nil → unchanged`로 끝나지 않고 **종료 중 발생한 변경을 실제로 판정**한다. +- **고아 LA 재부착**: 부트스트랩 직후(어댑터 초기화 시점) `Activity.activities` 스캔 — + - 스냅샷과 `routeId` 일치 + 미만료 → **adopt**: 어댑터가 보관하고 `activityStateUpdates` 관찰 재개 (이후 update/end가 정상 동작) + - 불일치·만료·스냅샷 없음 → `end(nil, dismissalPolicy: .immediate)` 정리 +- **죽은 세션 재시작**: sync 성공 시 "스냅샷은 살아 있는데(미만료) 활성 activity가 없고 dismiss 기록도 없으면" LA를 로컬 재시작 — 8시간 한도로 시스템이 내린 세션·시작 실패 세션 커버. dismiss 기록이 있으면 재시작 금지 (유저 의도 존중 — 기존 정책 그대로). +- **카드 복원**: Domain에 `GetLastRouteDetailUseCase` 신설 — 기존 미사용 자산 `LastRouteRepository.lastRoute(id:)` / `RouteEndpoint.detail` 재활용. 홈은 `alarmSynced` 수신 시 `routeCard == nil`이면 상세를 재조회해 카드 복원 (실패 시 현행 폴백 — 카드 없이 해제 버튼, 단 이제 만료 정리가 있어 유령이 오래가지 않는다). "알람 있는데 무슨 경로인지 모름" 상태 해소. +- **도보 시간 반영 (미확정 #7 임시안 실현)**: `AlarmTiming.alarmFireDate(departureTime:firstWalkSeconds:)` 확장 — 기준 시각 = `departureTime − firstWalkSeconds − 180초`. 등록 시 `route.legs`의 첫 `.walk` leg `sectionTime`을 스냅샷에 저장하고, **등록/refresh 재스케줄/LA alarmTime/홈 배너 4곳이 같은 값을 쓴다** (이중 시각 금지 — 기존 원칙). 도보 데이터가 없으면 기존 −180초로 폴백. LA 3행의 공석("정류장 도보 N분")도 이 값으로 채운다. +- **소소 정합성 일괄** (개별 이슈로 격상하지 않고 이 Phase에 포함): + - `RegisterAlarmUseCase`에 과거 fireDate **사전 가드** + `AlarmError.tooLate` 케이스 추가 — 서버 등록 성공 후 로컬 스케줄이 실패하는 서버/로컬 불일치 차단. 홈 토스트 "이미 출발 시간이 지난 경로예요". DEV 데모 경로 출발 시각은 이미 now+8분으로 조정됨(`18b60f7`) — 도보 시간 반영 후에도 가드에 걸리지 않는지 확인 + - `unchanged`/`delayed` 조용한 갱신이 `changeBadgeExpiry: nil`로 덮어써 **"당겨짐" 배지가 10분을 못 채우고 소멸**하는 문제 — 직전 만료 시각을 보존 + - 최초 LA start의 긴급도만 출발 시각 기준(이후 갱신은 알람 시각 기준)인 불일치 — **알람 시각 기준으로 통일** + - 변경 분 표기 반올림 불일치(LA `.up` vs 홈 `.rounded()`) — `.up`으로 통일 + - DI compact 아이콘 버스 고정 — `LastTrainActivityAttributes`에 수단 필드 추가(고정 정보라 Attributes가 맞는 자리, 동일 배포라 wire 안전)해 지하철/버스 아이콘 분기 + +### Constraints +- 스냅샷은 **재실행 브리지이지 정본이 아니다** — 서버 refresh 결과와 충돌하면 항상 서버 우선. 스냅샷만으로 알람을 새로 만들지 않는다. +- `AlarmRepository`·서버 계약 불변. `LastTrainActivityPort` 시그니처 불변 (재부착은 어댑터 내부 동작). +- Domain 테스트는 스텁 스토어로 — 실 UserDefaults 금지 (기존 규약). +- 도보 시간 확장 시 기존 `AlarmTiming` 테스트·`RefreshAlarmUseCase`의 기대 발화 시각 계산이 전부 새 시그니처를 타는지 확인 — 한 곳이라도 구 시그니처가 남으면 이중 시각 재발. + +### Acceptance +공통 acceptance + `-scheme Domain test`(스냅샷·도보 반영·tooLate 가드) + `-scheme HomeFeature test`(카드 복원·배너 기준) + `-scheme CoreLiveActivity test`(Attributes 수단 필드) + `-scheme CoreAlarm test`. + +### 자동 검수 (블로킹 — [자동 검수 규약](atcha-v2-auto-verification.md)) +iOS 26 시뮬레이터에서 에이전트가 직접 수행하고 단계별 스크린샷 증적으로 보고: +① 알람 등록 → **`simctl terminate` → `simctl launch`** → 카드·배너·LA가 일관 복원되고, 변경 주입 시 LA가 실제 갱신되는지 (고아 아님 확인) +② **재실행 직후 DEV 플로팅 버튼으로 변경 주입** → 이전 스냅샷 대비 diff가 판정되는지 (재실행 후 첫 sync가 unchanged로 뭉개지지 않는지) +③ 도보 구간이 있는 경로로 등록 → 알람 발화 시각·배너·LA가 전부 "출발 − 도보 − 3분" 기준으로 일치하는지 (데모 경로에 도보 구간이 없으면 `DevDemoFallbacks`에 walk leg 추가) +④ DEV dismiss 토글 on → 재실행 → LA가 재시작되지 **않는지** (dismiss 존중 유지 — 잠금화면 스와이프 자동화가 불안정하므로 규약대로 토글 사용). + +**실기기 잔여**: 없음 — Phase 14의 검수는 전부 시뮬레이터에서 재현 가능하다. + +--- + +## 진행 프로토콜 + +[마스터 프롬프트의 진행 프로토콜](atcha-v2-master-prompt.md#진행-프로토콜) 1~6을 그대로 상속한다. 추가 규칙: + +1. **13 → 14 순서 고정** (14가 13의 `departed`·만료 개념에 의존). 이 문서 내부의 병렬 없음. +2. ["이 문서가 다시 정의하지 않는 것"](#이-문서가-다시-정의하지-않는-것-중복-금지) 표의 계약을 바꾸고 싶어지면 멈추고 사용자에게 물을 것. +3. Phase 15 이후 산출물(폴백 조건 확대, time-sensitive, 노티 탭 라우팅, pull-to-refresh 등)을 **선취하지 않는다** — [로드맵](../planning/atcha-v2-post12-roadmap.md)의 몫. +4. 검수는 [자동 검수 규약](atcha-v2-auto-verification.md)을 따른다 — "자동 검수 (블로킹)"는 에이전트가 computer use로 수행·증적 보고를 마쳐야 다음 진행, "실기기 잔여"만 사용자에게 이관한다. +5. stopIntent의 강제 종료 시 실행 여부가 실기기 잔여 검수에서 판명되면 결과를 **이 문서 Phase 13 절에 기록**하고, 불가 시 "wake 시점 리컨실만으로 커버"를 확정 사실로 남긴다. 확정 전까지는 보수적 가정(리컨실이 커버)으로 진행한다. + +## 미확정 입력 (참조) + +이 문서는 새 미확정 입력을 만들지 않는다. 관련 기존 항목의 취급: + +| # | 항목 | 이 문서에서의 취급 | +|---|---|---| +| 7 | 알람 기준 시각 서버 필드 | **Phase 14 클라 임시안으로 해소** — 서버 필드 확정 시 스냅샷의 도보 초를 서버 값으로 대체 | +| 11 | 서버 "등록된 알람 없음" 표현 | 이 문서 밖 (S3) — 단 Phase 13의 로컬 만료가 유령 알람의 체감을 크게 줄인다 | +| 원장 전체 | [Post-12 로드맵](../planning/atcha-v2-post12-roadmap.md)의 "미확정 입력 원장" 절 참조 | | From 0d1d4694d295c26f296083a8ce3b73402140cd82 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sun, 23 Aug 2026 12:22:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20AtchaV2=20Phase=2013=20=E2=80=94=20?= =?UTF-8?q?=EC=95=8C=EB=9E=8C=20=EC=9D=B4=ED=9B=84=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=88=98=EB=AA=85=20=EC=99=84=EA=B2=B0=20(departed=20+=20stopI?= =?UTF-8?q?ntent=20+=20=ED=81=B4=EB=9D=BC=20=EC=9E=90=EC=B2=B4=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Domain: LastTrainSessionPhase.departed 신설, AlarmTiming 만료 유예(출발+60초)·isSessionExpired 순수 함수 - CoreLiveActivity: LastTrainSessionStatus.departed (wire 안전 — 동일 바이너리 배포·방어값 정책) - CoreAlarm: stop 버튼("확인")에 LiveActivityIntent 주입 지점 확장 — 주입 실패 시 인텐트 없이 재시도 폴백 - App: AlarmAcknowledgeIntent(발화 확인 훅) + AlarmSessionLifecycleService.alarmAcknowledged() → LA departed 전환 + 출발+10분 자동 소멸 예약(.after) — 앱이 깨지 않아도 잠금화면에서 소멸 - AlarmSyncService: sync 진입 시 expireLocallyIfNeeded — 유예 경과 세션은 refresh 결과와 무관하게 로컬 sessionEnded 처리, 단 미래 출발 시각 반환 시 서버 우선(만료 취소), 만료 기록으로 2차 방어 - 홈 배너 3단계 전이: 카운트다운 → "지금 출발하세요"(0분 문구 제거) → 유예 경과 시 배너 제거 + "지난 막차" 카드(비활성 톤, DSRouteCard muted tone 신설) + 알람 버튼 숨김 - 위젯: departed 렌더("지금 출발하세요", imminent 척도) + isStale 최신성 경고("시간이 지났어요") Co-Authored-By: Claude Fable 5 --- .../LastTrainLiveActivityAdapter.swift | 57 ++++++++++++- .../AlarmSessionLifecycleService.swift | 33 ++++++++ Projects/App/Sources/AlarmSyncService.swift | 75 +++++++++++++++-- Projects/App/Sources/AppDIContainer.swift | 15 +++- .../Intents/AlarmAcknowledgeIntent.swift | 26 ++++++ .../Sources/LastTrainLiveActivityWidget.swift | 46 ++++++++-- .../Core/Alarm/Sources/AlarmKitEngine.swift | 14 +++- .../Alarm/Sources/AlarmKitScheduler.swift | 36 ++++++-- .../Alarm/Tests/AlarmKitSchedulerTests.swift | 65 ++++++++++++++- .../Sources/LastTrainActivityAttributes.swift | 6 +- .../LastTrainActivityAttributesTests.swift | 19 +++++ .../Sources/Components/DSRouteCard.swift | 35 +++++++- .../DesignSystem/Tests/DSRouteCardTests.swift | 24 ++++++ .../Domain/Sources/Entities/AlarmTiming.swift | 11 +++ .../Entities/LastTrainActivityState.swift | 7 +- Projects/Domain/Tests/AlarmTimingTests.swift | 47 +++++++++++ .../Tests/LastTrainSessionPhaseTests.swift | 14 ++++ .../Feature/Home/Sources/HomeViewData.swift | 40 ++++++++- .../Feature/Home/Sources/HomeViewModel.swift | 46 ++++++++-- .../Home/Tests/HomeViewModelTests.swift | 83 +++++++++++++++++-- 20 files changed, 651 insertions(+), 48 deletions(-) create mode 100644 Projects/App/Sources/AlarmSessionLifecycleService.swift create mode 100644 Projects/App/Sources/Intents/AlarmAcknowledgeIntent.swift create mode 100644 Projects/Domain/Tests/LastTrainSessionPhaseTests.swift diff --git a/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift b/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift index a51d043..8223944 100644 --- a/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift +++ b/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift @@ -18,6 +18,16 @@ nonisolated protocol LastTrainChangeAlerting: Sendable { func end(final state: LastTrainActivityState) async } +/// Phase 13 발화 확인 경로 — stopIntent가 조합 루트(세션 수명 서비스)를 거쳐 LA를 +/// departed로 내리는 통로. Domain 포트(`LastTrainActivityPort`)는 문서 고정 계약이라 +/// 손대지 않고 App 내부 확장 포트로 둔다(Phase 11의 LastTrainChangeAlerting과 같은 방식). +nonisolated protocol LastTrainDepartureEnding: Sendable { + /// 현재 세션을 departed("지금 출발하세요") 최종 상태로 전환하고, 출발+유예 시점에 + /// 잠금화면에서 자동 소멸하도록 예약한다 — 앱이 다시 깨지 않아도 시스템이 내린다. + /// 실패·세션 부재는 흡수한다(포트 계약과 동일 — LA 실패가 확인 기록을 막으면 안 된다). + func endAsDeparted() async +} + /// ActivityKit → Domain `LastTrainActivityPort` 어댑터. ActivityKit을 import하는 곳은 App에서 이 파일뿐. /// 단일 알람 정책과 동일하게 Live Activity도 단일 세션만 유지한다(새 start가 기존 세션을 교체). /// 포트 계약대로 어떤 실패도 밖으로 던지지 않는다 — LA 실패가 알람 등록·취소를 실패시키면 안 된다. @@ -25,7 +35,8 @@ nonisolated protocol LastTrainChangeAlerting: Sendable { /// actor인 이유: 포트는 nonisolated async 요구사항을 가진 Sendable 프로토콜이라 /// MainActor 클래스의 격리 멤버로는 적합성이 성립하지 않는다(Sendable 경계를 넘는 격리 적합성 불가). /// ActivityKit의 `Activity`는 Sendable 미표기이나 스레드 안전 설계라 `@preconcurrency`로 완화한다. -actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerting { +actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerting, + LastTrainDepartureEnding { /// 유저 스와이프 dismiss 기록 키 — 앱 재실행 후에도 남아야 Phase 12 폴백 트리거 재료가 된다. private static let dismissedDefaultsKey = "la.dismissedByUser" @@ -129,6 +140,50 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti var isDismissedByUser: Bool { dismissedByUser } + // MARK: - LastTrainDepartureEnding (Phase 13) + + /// 정책: departed 상태는 잠금화면에 남았다가 출발 + 10분에 자동 소멸한다(.after) — + /// "지금 출발" 상태가 잠시 남는 것이 취지. end 이후 남은 시간 창(최대 3분+10분)의 + /// 재변경 인지는 알람 재스케줄(기존 경로)이 담당하고, LA 재생성은 하지 않는다(계약). + private var departedDismissalGraceSeconds: TimeInterval { + #if DEV + // 자동 검수 규약: 자동 소멸 대기가 과도할 때 DEV 한정 단축 — + // `simctl spawn booted defaults write com.atcha.iOS.v2 dev.la.departedDismissalGrace -int 60` + let override = userDefaults.double(forKey: "dev.la.departedDismissalGrace") + if override > 0 { return override } + #endif + return 600 + } + + func endAsDeparted() async { + // 프로그램적 end — 예약 소멸 시점에 도착하는 .dismissed를 유저 스와이프로 + // 오인하지 않도록 관찰을 먼저 끊는다(end(final:)과 동일 순서). + stateObservationTask?.cancel() + stateObservationTask = nil + // 세션 부재(강제 종료 후 고아, LA 비활성 등)는 조용히 no-op — + // 확인 기록은 이미 남았고, 고아 재부착은 Phase 14 몫이다. + guard let activity else { return } + self.activity = nil + + // departed 상태는 현재 콘텐츠의 세션 사실(출발·알람 시각)을 그대로 잇는다 — + // glance 색은 imminent와 동일 척도(정책), 배지는 접는다. + let current = activity.content.state + let departed = LastTrainActivityAttributes.ContentState( + departureTime: current.departureTime, + alarmTime: current.alarmTime, + urgency: .imminent, + changeBadgeExpiry: nil, + status: .departed + ) + await activity.end( + // staleDate nil: departed는 시각 최신성 경고 대상이 아니다 — 소멸은 .after가 맡는다. + ActivityContent(state: departed, staleDate: nil), + dismissalPolicy: .after( + current.departureTime.addingTimeInterval(departedDismissalGraceSeconds) + ) + ) + } + // MARK: - LastTrainChangeAlerting func update(state: LastTrainActivityState, alert: (title: String, body: String)?) async { diff --git a/Projects/App/Sources/AlarmSessionLifecycleService.swift b/Projects/App/Sources/AlarmSessionLifecycleService.swift new file mode 100644 index 0000000..e9c4dac --- /dev/null +++ b/Projects/App/Sources/AlarmSessionLifecycleService.swift @@ -0,0 +1,33 @@ +import Foundation +import os + +/// 알람 발화 이후의 세션 수명 로직 (Phase 13) — stopIntent("확인" 탭)가 조합 루트를 +/// 거쳐 도달하는 유일한 지점. 시간 경과 판정(만료)은 AlarmSyncService의 wake 시점 +/// 리컨실 몫이고, 여기는 "확인" 사건의 기록과 표출 전이만 담당한다. +@MainActor +final class AlarmSessionLifecycleService { + /// departed 전환·예약 소멸 경로 — LA 어댑터가 구현한다(실패 전부 흡수, non-throwing). + private let liveActivity: any LastTrainDepartureEnding + private static let logger = Logger( + subsystem: "com.atcha.iOS.v2", category: "SessionLifecycle" + ) + + /// stopIntent 확인 기록 — Phase 14 스냅샷 `acknowledged` 필드의 인메모리 선행. + /// 프로세스가 죽으면 사라진다(강제 종료 케이스의 영속화는 Phase 14 몫). + private(set) var isAcknowledged = false + + init(liveActivity: any LastTrainDepartureEnding) { + self.liveActivity = liveActivity + } + + /// 알람 "확인" 탭(stopIntent 실행) — ① 확인 기록 ② LA departed 전환 + /// ③ 출발+10분 자동 소멸 예약. ②③은 어댑터의 end 한 번으로 구현된다 + /// (final content = departed, dismissalPolicy = .after) — 앱이 다시 깨지 + /// 않아도 잠금화면에서 시스템이 내린다. + func alarmAcknowledged() async { + // 로그는 자동 검수 ②(강제 종료 후 인텐트 실행 여부 판정)의 증적 채널이다. + Self.logger.info("알람 확인(stopIntent) 수신 — departed 전환 + 자동 소멸 예약") + isAcknowledged = true + await liveActivity.endAsDeparted() + } +} diff --git a/Projects/App/Sources/AlarmSyncService.swift b/Projects/App/Sources/AlarmSyncService.swift index 8174dfb..a5bc009 100644 --- a/Projects/App/Sources/AlarmSyncService.swift +++ b/Projects/App/Sources/AlarmSyncService.swift @@ -18,6 +18,12 @@ import os /// 서버 무관여로 가능하고, push-to-start 재생성은 하지 않는다(지운 의사 존중). /// ④ advanced(actionable: false)는 LA를 missed 상태로, sessionEnded는 serviceEnded 최종 /// 상태로 내리고 로컬 알람을 취소한다. 배너 정리는 changes 스트림을 받은 홈의 몫. +/// +/// Phase 13 클라 자체 만료: sync 진입 시 보유 세션이 유예(출발+60초)를 넘겼으면 만료 +/// 후보로 잡고, refresh 결과와 무관하게 로컬 sessionEnded 처리한다 — 단 refresh가 +/// 성공해 **미래 출발 시각**을 반환하면 서버 우선(만료 취소, 정상 갱신 경로). +/// 만료 확정 세션은 기록해 이후 refresh가 같은 과거 세션으로 배너를 되살리지 못하게 +/// 한다(홈의 미래 시각 가드가 1차 방어, 이 기록이 2차). // Sendable 프로토콜(AlarmSyncEvents 등) 채택이 기본 MainActor 격리를 nonisolated로 // 추론시키므로 명시한다 — 상태(subscribers 등)는 전부 메인 액터에서만 만진다. @MainActor @@ -42,6 +48,9 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { /// 구독 전에 끝난 동기화를 놓치지 않기 위한 replay-1. 홈은 앱 시작 동기화와 /// 거의 동시에 구독하므로 순서에 기대지 않는다. 변경 판정의 "이전 값"이기도 하다. private var lastInfo: AlarmInfo? + /// Phase 13 만료 2차 방어 — 로컬 만료를 확정한 세션. 이후 refresh가 같은 routeId의 + /// 과거 세션을 반환해도 무시한다(미래 출발이 오면 서버 우선으로 해제). + private var locallyExpiredSession: AlarmInfo? /// 진행 중 동기화 — 트리거가 겹치면(예: 앱 시작 직후 포그라운드 노티) 합류한다. private var inFlight: Task? private var foregroundObserver: (any NSObjectProtocol)? @@ -89,6 +98,8 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { return await inFlight.value } Self.logger.info("알람 동기화 시작") + // Phase 13 선행 판정 — 확정은 refresh 결과를 본 뒤(미래 출발이면 서버 우선 취소). + let expiryCandidate = expireLocallyIfNeeded(now: Date()) let task = Task { [refreshAlarmUseCase] () -> AlarmInfo? in do { return try await refreshAlarmUseCase.execute() @@ -100,20 +111,66 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { inFlight = task let info = await task.value inFlight = nil - if let info { - Self.logger.info("알람 동기화 성공: route=\(info.lastRouteId, privacy: .public)") - let previous = lastInfo - lastInfo = info - for continuation in subscribers.values { - continuation.yield(info) + + guard let info else { + // refresh 실패여도 만료는 확정한다 — "refresh 결과와 무관하게"가 정책이다. + if let expiryCandidate { + await finalizeLocalExpiry(of: expiryCandidate) } - // Phase 11 피기백 — 이 시점에 알람 재스케줄은 이미 완료돼 있다 - // (RefreshAlarmUseCase.execute 반환 = 재스케줄 포함). 표출은 그 뒤에만 덧붙는다. - await propagateChange(previous: previous, latest: info) + return nil + } + + if let departure = info.departureTime, departure > Date() { + // 서버 우선 — 미래 출발 시각이 오면 만료 후보·확정 기록 모두 해제하고 정상 경로. + locallyExpiredSession = nil + } else if let expiryCandidate { + // 성공했지만 여전히 과거 세션(또는 출발 시각 없음) — 만료 확정. + // 이 결과는 구독자에게 흘리지 않는다(죽은 세션으로 배너·버튼 복원 금지). + await finalizeLocalExpiry(of: expiryCandidate) + return info + } else if let expired = locallyExpiredSession, expired.lastRouteId == info.lastRouteId { + // 만료 확정 후 같은 과거 세션의 재수신 — 무시(2차 방어). + Self.logger.info("만료 확정 세션 재수신 → 무시: route=\(info.lastRouteId, privacy: .public)") + return info + } + + Self.logger.info("알람 동기화 성공: route=\(info.lastRouteId, privacy: .public)") + let previous = lastInfo + lastInfo = info + for continuation in subscribers.values { + continuation.yield(info) } + // Phase 11 피기백 — 이 시점에 알람 재스케줄은 이미 완료돼 있다 + // (RefreshAlarmUseCase.execute 반환 = 재스케줄 포함). 표출은 그 뒤에만 덧붙는다. + await propagateChange(previous: previous, latest: info) return info } + // MARK: - Phase 13 클라 자체 만료 (wake 시점 판정) + + /// sync 진입 선행 판정 — 보유 세션이 만료 유예(출발+60초, AlarmTiming 단일 기준)를 + /// 넘겼으면 만료 후보를 반환한다. 판정 자체는 Domain 순수 함수(시각 주입 테스트 대상). + private func expireLocallyIfNeeded(now: Date) -> AlarmInfo? { + guard let lastInfo, + let departure = lastInfo.departureTime, + AlarmTiming.isSessionExpired(departureTime: departure, now: now) + else { return nil } + return lastInfo + } + + /// 만료 확정 = 로컬 sessionEnded 처리: 알람 레코드 정리 → LA 최종 종료 → + /// changes yield(홈 정리는 기존 sessionEnded 소비 경로 재사용). 서버 계약 무관여. + private func finalizeLocalExpiry(of session: AlarmInfo) async { + Self.logger.info( + "클라 자체 만료 확정(로컬 sessionEnded): route=\(session.lastRouteId, privacy: .public)" + ) + locallyExpiredSession = session + // replay-1이 죽은 세션을 재구독자에게 되살리지 않도록 비운다. + lastInfo = nil + await presentSessionEnded(previous: session, now: Date()) + yieldChange(.sessionEnded) + } + // MARK: - Phase 11·12 변경 표출 (판정 → LA/로컬 노티/인앱 채널) /// 판정 → 채널 분기. LA 호출은 전부 실패 무해(포트가 non-throwing) — 알람에 영향 없음. diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 1925b27..dd143bf 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -28,6 +28,9 @@ final class AppDIContainer { // 같은 요청 이력을 봐야 한다. UNUserNotificationCenter를 아는 곳은 이 어댑터뿐. private let localNotificationPort: any LocalNotificationPort let alarmSyncService: AlarmSyncService + /// Phase 13 발화 이후 세션 수명 — stopIntent(AlarmAcknowledgeIntent)가 조합 루트를 + /// 거쳐 도달하는 지점. AppDelegate 경유로 인텐트 perform()이 접근한다. + let alarmSessionLifecycle: AlarmSessionLifecycleService #if DEV /// DEV 플로팅 디버그 메뉴가 dismiss 기록 강제 토글에 접근하는 유일한 통로 (Phase 12 검수). let devLiveActivityAdapter: LastTrainLiveActivityAdapter @@ -84,12 +87,18 @@ final class AppDIContainer { #endif // 디바이스 포트 어댑터 — CoreLocation/AlarmKit/ActivityKit을 아는 곳은 App의 어댑터뿐. - let alarmScheduler = CoreAlarmSchedulerAdapter() + // Phase 13: stop 버튼("확인")에 발화 확인 인텐트를 싣는다 — 인텐트 타입은 App 소유, + // CoreAlarm은 인스턴스를 전달만 한다(주입 실패 시 인텐트 없이 스케줄되는 폴백 유지). + let alarmScheduler = CoreAlarmSchedulerAdapter( + scheduling: AlarmKitScheduler(stopIntent: AlarmAcknowledgeIntent()) + ) self.alarmScheduler = alarmScheduler - // 구체 어댑터로 들고 있다가 두 얼굴로 나눠 준다 — Domain 포트(등록/해제 UseCase)와 - // App 내부 변경 표출 경로(LastTrainChangeAlerting, Phase 11 훅). + // 구체 어댑터로 들고 있다가 세 얼굴로 나눠 준다 — Domain 포트(등록/해제 UseCase), + // App 내부 변경 표출 경로(LastTrainChangeAlerting, Phase 11 훅), + // 발화 확인 경로(LastTrainDepartureEnding, Phase 13). let liveActivityAdapter = LastTrainLiveActivityAdapter() self.liveActivityPort = liveActivityAdapter + self.alarmSessionLifecycle = AlarmSessionLifecycleService(liveActivity: liveActivityAdapter) #if DEV self.devLiveActivityAdapter = liveActivityAdapter #endif diff --git a/Projects/App/Sources/Intents/AlarmAcknowledgeIntent.swift b/Projects/App/Sources/Intents/AlarmAcknowledgeIntent.swift new file mode 100644 index 0000000..350738e --- /dev/null +++ b/Projects/App/Sources/Intents/AlarmAcknowledgeIntent.swift @@ -0,0 +1,26 @@ +import AppIntents +import UIKit + +/// AlarmKit stop 버튼("확인")에 실리는 인텐트 — 알람 발화 확인 감지의 유일한 훅 (Phase 13). +/// LiveActivityIntent는 앱 프로세스에서 실행된다(필요 시 시스템이 백그라운드로 깨운다). +/// 인텐트 타입은 App이 소유하고, CoreAlarm에는 인스턴스만 주입된다(App → CoreAlarm 한 방향). +/// +/// 강제 종료 상태에서의 실행 여부는 실기기 잔여 검수로 판명한다 — 판명 전까지는 +/// "wake 시점 리컨실(expireLocallyIfNeeded)이 커버한다"는 보수적 가정으로 진행(규약). +nonisolated struct AlarmAcknowledgeIntent: LiveActivityIntent { + static let title: LocalizedStringResource = "막차 알람 확인" + /// 제품 결정: 심야의 "확인"은 앱을 열지 않는 것이 기본값 — 조용한 원상복귀. + static let openAppWhenRun: Bool = false + /// 단축어·스포트라이트 노출 불필요 — 알람 stop 버튼 전용. + static let isDiscoverable: Bool = false + + func perform() async throws -> some IntentResult { + // 조합 루트(AppDelegate 소유)의 세션 수명 서비스로 위임한다. 델리게이트 부재 + // (이론상 초기화 경합)면 조용히 no-op — 만료 리컨실이 최종 안전망이다. + let lifecycle = await MainActor.run { + (UIApplication.shared.delegate as? AppDelegate)?.container.alarmSessionLifecycle + } + await lifecycle?.alarmAcknowledged() + return .result() + } +} diff --git a/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift b/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift index 7ae5c2b..afe9f53 100644 --- a/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift +++ b/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift @@ -22,7 +22,8 @@ struct LastTrainLiveActivityWidget: Widget { ActivityConfiguration(for: LastTrainActivityAttributes.self) { context in LastTrainLockScreenView( routeName: context.attributes.routeName, - state: context.state + state: context.state, + isStale: context.isStale ) // 시스템 변형(항상 켜진 화면·밝기 감소·알림 센터 스택)에서도 // 무난하도록 배경/시스템 액션 색을 DS 토큰으로 고정. @@ -30,6 +31,7 @@ struct LastTrainLiveActivityWidget: Widget { .activitySystemActionForegroundColor(Color(ds: DSColor.Text.primary)) } dynamicIsland: { context in let state = context.state + let isStale = context.isStale return DynamicIsland { DynamicIslandExpandedRegion(.leading) { HStack(spacing: DSSpacing.xs) { @@ -53,6 +55,12 @@ struct LastTrainLiveActivityWidget: Widget { // ContentState에 도보 필드가 없어 생략 — 아래 // LastTrainLockScreenView.departureRow 주석 참고. switch state.status { + case .active where isStale: + // 갱신이 끊긴 채 staleDate(=출발 시각)가 지났다 — 얼어붙은 + // 카운트다운 대신 최신성 경고 (Phase 13, 앱 깨움 없는 마지막 방어선). + Text(LastTrainStaleCopy.message) + .font(.headline) + .foregroundStyle(Color(ds: DSColor.Text.secondary)) case .active: HStack(alignment: .firstTextBaseline, spacing: DSSpacing.sm) { Text("출발까지") @@ -68,7 +76,7 @@ struct LastTrainLiveActivityWidget: Widget { } .foregroundStyle(state.urgencyColor) } - case .missed, .serviceEnded: + case .departed, .missed, .serviceEnded: Text(state.finalStatusMessage ?? "") .font(.headline) .foregroundStyle(state.glanceColor) @@ -80,6 +88,10 @@ struct LastTrainLiveActivityWidget: Widget { .foregroundStyle(state.glanceColor) } compactTrailing: { switch state.status { + case .active where isStale: + Text("지남") + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color(ds: DSColor.Text.secondary)) case .active: Text(timerInterval: state.countdownRange, countsDown: true) .font(.caption2.weight(.semibold)) @@ -91,6 +103,10 @@ struct LastTrainLiveActivityWidget: Widget { // Text(timerInterval:)는 가용 폭을 전부 차지하려 하므로 // 컴팩트 영역에서는 폭을 제한한다. .frame(maxWidth: 56) + case .departed: + Text("출발") + .font(.caption2.weight(.semibold)) + .foregroundStyle(state.glanceColor) case .missed: Text("놓침") .font(.caption2.weight(.semibold)) @@ -124,6 +140,9 @@ struct LastTrainLiveActivityWidget: Widget { private struct LastTrainLockScreenView: View { let routeName: String let state: LastTrainActivityAttributes.ContentState + /// staleDate(=출발 시각)가 지나도록 갱신이 없었다 — 강제 종료·고아 케이스의 UI 완충 + /// (Phase 13). 근본 해소(재부착)는 Phase 14 몫. + let isStale: Bool var body: some View { VStack(alignment: .leading, spacing: DSSpacing.sm) { @@ -151,11 +170,17 @@ private struct LastTrainLockScreenView: View { } } - /// 2행: glance 핵심. active면 카운트다운(긴급도 색), - /// final state면 카운트다운을 숨기고 상태 문구만 표시. + /// 2행: glance 핵심. active면 카운트다운(긴급도 색), stale이면 최신성 경고, + /// departed/final state면 카운트다운을 숨기고 상태 문구만 표시. @ViewBuilder private var mainRow: some View { switch state.status { + case .active where isStale: + // 갱신이 끊긴 채 staleDate(=출발 시각) 경과 — 얼어붙은 카운트다운을 + // 신선한 정보처럼 보여주지 않는다 (Phase 13 마지막 방어선). + Text(LastTrainStaleCopy.message) + .font(.title3.weight(.bold)) + .foregroundStyle(Color(ds: DSColor.Text.secondary)) case .active: HStack(alignment: .firstTextBaseline, spacing: DSSpacing.sm) { Text("출발까지") @@ -173,7 +198,7 @@ private struct LastTrainLockScreenView: View { } .foregroundStyle(state.urgencyColor) } - case .missed, .serviceEnded: + case .departed, .missed, .serviceEnded: Text(state.finalStatusMessage ?? "") .font(.title3.weight(.bold)) .foregroundStyle(state.glanceColor) @@ -220,12 +245,13 @@ private extension LastTrainActivityAttributes.ContentState { } } - /// glance용 대표 색: active면 긴급도 색, missed는 긴급 색(놓침 경고), + /// glance용 대표 색: active면 긴급도 색, departed는 imminent와 동일 척도(정책 — + /// "지금 출발"은 가장 긴박한 행동 신호), missed는 긴급 색(놓침 경고), /// serviceEnded는 보조 색(더 이상 행동 불가 — 시각적 소음 억제). var glanceColor: Color { switch status { case .active: urgencyColor - case .missed: Color(ds: DSColor.State.urgent) + case .departed, .missed: Color(ds: DSColor.State.urgent) case .serviceEnded: Color(ds: DSColor.Text.secondary) } } @@ -249,12 +275,18 @@ private extension LastTrainActivityAttributes.ContentState { var finalStatusMessage: String? { switch status { case .active: nil + case .departed: "지금 출발하세요" case .missed: "막차가 지나갔어요" case .serviceEnded: "오늘 운행이 끝났어요" } } } +/// isStale 최신성 경고 문구 — 잠금화면·DI가 같은 카피를 쓴다. +private enum LastTrainStaleCopy { + static let message = "시간이 지났어요 — 앱에서 확인하세요" +} + // MARK: - Formatting /// "HH:mm" 출발 시각 포맷. departureTime은 틱하지 않는 값이라 diff --git a/Projects/Core/Alarm/Sources/AlarmKitEngine.swift b/Projects/Core/Alarm/Sources/AlarmKitEngine.swift index 791184e..8e9ae7f 100644 --- a/Projects/Core/Alarm/Sources/AlarmKitEngine.swift +++ b/Projects/Core/Alarm/Sources/AlarmKitEngine.swift @@ -1,4 +1,5 @@ import AlarmKit +import AppIntents import Foundation import SwiftUI @@ -21,11 +22,14 @@ struct AlarmKitEngine: AlarmEngine { } } - func schedule(id: UUID, fireDate: Date, title: String) async throws { + func schedule( + id: UUID, fireDate: Date, title: String, stopIntent: (any LiveActivityIntent)? + ) async throws { // Alert의 non-deprecated init은 iOS 26.1+라 배포 타겟 26.0에서는 stopButton // 버전을 쓴다 (26.1 미만 타겟에서는 deprecation 경고가 나지 않는다). // 반복(스누즈) 버튼은 넣지 않는다 — "마지노선까지만 미루기" 클램프 검증(Phase 11) // 전까지는 단발 알람이 보수 기본값이다. + // secondaryButton("경로 보기")도 붙이지 않는다 — 심야 버튼 2개는 인지 부하(제품 결정). let alert = AlarmPresentation.Alert( title: "\(title)", stopButton: AlarmButton(text: "확인", textColor: .white, systemImageName: "checkmark") @@ -35,9 +39,15 @@ struct AlarmKitEngine: AlarmEngine { presentation: AlarmPresentation(alert: alert), tintColor: .accentColor ) + // stopIntent: stop("확인") 탭 시 앱 프로세스에서 실행되는 LiveActivityIntent — + // iOS 26 SDK 실검증 결과 .alarm 팩토리가 stopIntent 파라미터를 직접 받는다. _ = try await AlarmManager.shared.schedule( id: id, - configuration: .alarm(schedule: .fixed(fireDate), attributes: attributes) + configuration: .alarm( + schedule: .fixed(fireDate), + attributes: attributes, + stopIntent: stopIntent + ) ) } diff --git a/Projects/Core/Alarm/Sources/AlarmKitScheduler.swift b/Projects/Core/Alarm/Sources/AlarmKitScheduler.swift index 7c02eb5..7698b68 100644 --- a/Projects/Core/Alarm/Sources/AlarmKitScheduler.swift +++ b/Projects/Core/Alarm/Sources/AlarmKitScheduler.swift @@ -1,3 +1,4 @@ +import AppIntents import Foundation /// AlarmKit 위에서 단일 알람 교체 정책을 구현하는 기본 스케줄러. @@ -8,14 +9,27 @@ import Foundation public actor AlarmKitScheduler: AlarmKitScheduling { private let engine: any AlarmEngine private let recordStore: any AlarmRecordStoring + /// stop 버튼("확인") 탭 시 앱 프로세스에서 실행할 인텐트 (Phase 13 발화 감지). + /// 인텐트 **타입**은 App이 소유하고 여기엔 인스턴스만 주입된다(App → CoreAlarm 한 방향) — + /// AppIntents는 시스템 프레임워크라 무의존 원칙 위배가 아니다. + private let stopIntent: (any LiveActivityIntent)? - public init() { - self.init(engine: AlarmKitEngine(), recordStore: UserDefaultsAlarmRecordStore()) + public init(stopIntent: (any LiveActivityIntent)? = nil) { + self.init( + engine: AlarmKitEngine(), + recordStore: UserDefaultsAlarmRecordStore(), + stopIntent: stopIntent + ) } - init(engine: any AlarmEngine, recordStore: any AlarmRecordStoring) { + init( + engine: any AlarmEngine, + recordStore: any AlarmRecordStoring, + stopIntent: (any LiveActivityIntent)? = nil + ) { self.engine = engine self.recordStore = recordStore + self.stopIntent = stopIntent } public func requestAuthorization() async -> Bool { @@ -26,7 +40,17 @@ public actor AlarmKitScheduler: AlarmKitScheduling { await cancelEngineAlarms() recordStore.clear() let uuid = UUID() - try await engine.schedule(id: uuid, fireDate: spec.fireDate, title: spec.title) + do { + try await engine.schedule( + id: uuid, fireDate: spec.fireDate, title: spec.title, stopIntent: stopIntent + ) + } catch where stopIntent != nil { + // 회귀 금지: 인텐트 주입이 알람 등록을 실패시키면 안 된다 — + // 인텐트 없이 재시도한다(발화 감지만 포기, 알람은 그대로 성립). + try await engine.schedule( + id: uuid, fireDate: spec.fireDate, title: spec.title, stopIntent: nil + ) + } recordStore.save(ScheduledAlarmRecord(uuid: uuid, spec: spec)) } @@ -54,7 +78,9 @@ public actor AlarmKitScheduler: AlarmKitScheduling { /// AlarmKit 호출 시임 — 테스트는 스텁 엔진으로 대체한다 (AlarmKit 직접 호출 금지 규약). protocol AlarmEngine: Sendable { func requestAuthorization() async throws -> Bool - func schedule(id: UUID, fireDate: Date, title: String) async throws + func schedule( + id: UUID, fireDate: Date, title: String, stopIntent: (any LiveActivityIntent)? + ) async throws func cancel(id: UUID) async func alarmIDs() async -> [UUID] } diff --git a/Projects/Core/Alarm/Tests/AlarmKitSchedulerTests.swift b/Projects/Core/Alarm/Tests/AlarmKitSchedulerTests.swift index f71c46e..49da0a0 100644 --- a/Projects/Core/Alarm/Tests/AlarmKitSchedulerTests.swift +++ b/Projects/Core/Alarm/Tests/AlarmKitSchedulerTests.swift @@ -1,3 +1,4 @@ +import AppIntents @testable import CoreAlarm import Foundation import Synchronization @@ -5,21 +6,31 @@ import Testing private struct StubError: Error {} +/// stopIntent 주입 검증용 대역 — perform은 테스트에서 호출되지 않는다. +private struct StubStopIntent: LiveActivityIntent { + static let title: LocalizedStringResource = "stub" + func perform() async throws -> some IntentResult { .result() } +} + /// AlarmKit 대역 — 호출 순서와 시스템에 남아 있는 알람 ID 집합을 흉내 낸다. private actor SpyEngine: AlarmEngine { private(set) var events: [String] = [] private var alarmIDsInSystem: [UUID] private let authorizationResult: Result private let scheduleError: (any Error)? + /// true면 stopIntent가 실린 schedule만 실패시킨다 — 인텐트 폴백 경로 검증용. + private let failsOnlyWithIntent: Bool init( existingIDs: [UUID] = [], authorization: Result = .success(true), - scheduleError: (any Error)? = nil + scheduleError: (any Error)? = nil, + failsOnlyWithIntent: Bool = false ) { self.alarmIDsInSystem = existingIDs self.authorizationResult = authorization self.scheduleError = scheduleError + self.failsOnlyWithIntent = failsOnlyWithIntent } func requestAuthorization() async throws -> Bool { @@ -27,8 +38,11 @@ private actor SpyEngine: AlarmEngine { return try authorizationResult.get() } - func schedule(id: UUID, fireDate: Date, title: String) async throws { - events.append("schedule:\(title)") + func schedule( + id: UUID, fireDate: Date, title: String, stopIntent: (any LiveActivityIntent)? + ) async throws { + events.append("schedule:\(title)\(stopIntent == nil ? "" : ":intent")") + if failsOnlyWithIntent, stopIntent != nil { throw StubError() } if let scheduleError { throw scheduleError } alarmIDsInSystem.append(id) } @@ -118,6 +132,51 @@ struct AlarmKitSchedulerTests { #expect(store.load() == nil) } + // MARK: - stopIntent (Phase 13) + + @Test + func replaceAlarm_withStopIntent_schedulesIntentCarryingAlarm() async throws { + let engine = SpyEngine() + let sut = AlarmKitScheduler( + engine: engine, recordStore: InMemoryRecordStore(), stopIntent: StubStopIntent() + ) + + try await sut.replaceAlarm(makeSpec()) + + #expect(await engine.events == ["schedule:막차 출발 알림:intent"]) + #expect(await sut.scheduledAlarm() == makeSpec()) + } + + @Test + func replaceAlarm_intentScheduleFails_retriesWithoutIntent() async throws { + // 회귀 금지 계약: 인텐트 주입 실패가 알람 등록을 실패시키면 안 된다. + let engine = SpyEngine(failsOnlyWithIntent: true) + let store = InMemoryRecordStore() + let sut = AlarmKitScheduler( + engine: engine, recordStore: store, stopIntent: StubStopIntent() + ) + + try await sut.replaceAlarm(makeSpec()) + + #expect(await engine.events == ["schedule:막차 출발 알림:intent", "schedule:막차 출발 알림"]) + #expect(store.load() != nil) + #expect(await sut.scheduledAlarm() == makeSpec()) + } + + @Test + func replaceAlarm_bothAttemptsFail_throwsAndKeepsNoRecord() async { + let engine = SpyEngine(scheduleError: StubError()) + let store = InMemoryRecordStore() + let sut = AlarmKitScheduler( + engine: engine, recordStore: store, stopIntent: StubStopIntent() + ) + + await #expect(throws: StubError.self) { + try await sut.replaceAlarm(makeSpec()) + } + #expect(store.load() == nil) + } + @Test func requestAuthorization_granted_returnsTrue() async { let sut = AlarmKitScheduler( diff --git a/Projects/Core/LiveActivity/Sources/LastTrainActivityAttributes.swift b/Projects/Core/LiveActivity/Sources/LastTrainActivityAttributes.swift index 1c805ea..2e3019c 100644 --- a/Projects/Core/LiveActivity/Sources/LastTrainActivityAttributes.swift +++ b/Projects/Core/LiveActivity/Sources/LastTrainActivityAttributes.swift @@ -52,10 +52,14 @@ public enum LastTrainUrgency: String, Codable, Hashable, Sendable { case imminent } -/// 세션 상태. `missed` = 못 타게 됨(앞당겨짐이 이미 비행동 가능), +/// 세션 상태. `departed` = 알람 발화 확인("지금 출발하세요" — Phase 13 신설), +/// `missed` = 못 타게 됨(앞당겨짐이 이미 비행동 가능), /// `serviceEnded` = 운행 종료·경로 소멸. +/// 케이스 추가는 wire 안전 — 앱·익스텐션 동일 바이너리 배포이고, 방어값 정책상 +/// 미지 rawValue는 `.active`로 떨어진다(어댑터의 rawValue 매핑 참조). public enum LastTrainSessionStatus: String, Codable, Hashable, Sendable { case active + case departed case missed case serviceEnded } diff --git a/Projects/Core/LiveActivity/Tests/LastTrainActivityAttributesTests.swift b/Projects/Core/LiveActivity/Tests/LastTrainActivityAttributesTests.swift index bed2104..69473d1 100644 --- a/Projects/Core/LiveActivity/Tests/LastTrainActivityAttributesTests.swift +++ b/Projects/Core/LiveActivity/Tests/LastTrainActivityAttributesTests.swift @@ -50,7 +50,26 @@ struct LastTrainActivityAttributesTests { #expect(LastTrainUrgency.caution.rawValue == "caution") #expect(LastTrainUrgency.imminent.rawValue == "imminent") #expect(LastTrainSessionStatus.active.rawValue == "active") + #expect(LastTrainSessionStatus.departed.rawValue == "departed") #expect(LastTrainSessionStatus.missed.rawValue == "missed") #expect(LastTrainSessionStatus.serviceEnded.rawValue == "serviceEnded") } + + @Test + func contentState_departedStatus_roundTripsCodable() throws { + // Phase 13 신설 케이스 — 앱(발신)·익스텐션(렌더) 사이 wire 왕복 확인. + let departed = LastTrainActivityAttributes.ContentState( + departureTime: state.departureTime, + alarmTime: state.alarmTime, + urgency: .imminent, + changeBadgeExpiry: nil, + status: .departed + ) + let data = try JSONEncoder().encode(departed) + let decoded = try JSONDecoder().decode( + LastTrainActivityAttributes.ContentState.self, from: data + ) + #expect(decoded == departed) + #expect(decoded.status == .departed) + } } diff --git a/Projects/DesignSystem/Sources/Components/DSRouteCard.swift b/Projects/DesignSystem/Sources/Components/DSRouteCard.swift index 0c95d07..6379390 100644 --- a/Projects/DesignSystem/Sources/Components/DSRouteCard.swift +++ b/Projects/DesignSystem/Sources/Components/DSRouteCard.swift @@ -1,25 +1,35 @@ import UIKit public final class DSRouteCard: UIView { + /// 카드 톤 — normal이 기본, muted는 "지난 막차" 등 더 이상 행동할 수 없는 + /// 정보를 비활성 시각으로 남길 때 쓴다. + public enum Tone: Equatable { + case normal + case muted + } + public struct Content { public let badgeText: String? public let departureTimeText: String public let legs: [DSTransportBadge.Kind] public let summaryText: String? public let destinationText: String? + public let tone: Tone public init( badgeText: String? = nil, departureTimeText: String, legs: [DSTransportBadge.Kind] = [], summaryText: String? = nil, - destinationText: String? = nil + destinationText: String? = nil, + tone: Tone = .normal ) { self.badgeText = badgeText self.departureTimeText = departureTimeText self.legs = legs self.summaryText = summaryText self.destinationText = destinationText + self.tone = tone } } @@ -75,6 +85,8 @@ public final class DSRouteCard: UIView { } public func configure(with content: Content) { + applyTone(content.tone) + badgeLabel.text = content.badgeText badgeLabel.isHidden = content.badgeText == nil @@ -106,6 +118,27 @@ public final class DSRouteCard: UIView { destinationLabel.isHidden = content.destinationText == nil } + /// 톤별 색 적용 — 재사용(configure 재호출) 시 양방향 모두 명시적으로 되돌린다. + private func applyTone(_ tone: Tone) { + switch tone { + case .normal: + badgeLabel.textColor = DSColor.Accent.default + badgeLabel.backgroundColor = DSColor.Accent.container + departureTimeLabel.textColor = DSColor.Text.primary + summaryLabel.textColor = DSColor.Text.primary + destinationLabel.textColor = DSColor.Text.secondary + legsStack.alpha = 1 + case .muted: + badgeLabel.textColor = DSColor.Text.secondary + badgeLabel.backgroundColor = DSColor.Fill.elevated + departureTimeLabel.textColor = DSColor.Text.secondary + summaryLabel.textColor = DSColor.Text.tertiary + destinationLabel.textColor = DSColor.Text.tertiary + // 수단 배지는 자체 색을 갖는다 — 톤 다운은 투명도로 일괄 적용. + legsStack.alpha = 0.55 + } + } + @available(*, unavailable) public required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") diff --git a/Projects/DesignSystem/Tests/DSRouteCardTests.swift b/Projects/DesignSystem/Tests/DSRouteCardTests.swift index 717a62e..b446bcc 100644 --- a/Projects/DesignSystem/Tests/DSRouteCardTests.swift +++ b/Projects/DesignSystem/Tests/DSRouteCardTests.swift @@ -50,6 +50,30 @@ struct DSRouteCardTests { #expect(!hidden.isEmpty) } + @Test + func mutedToneDimsLabelsAndRecoversOnNormal() { + // "지난 막차" 등 비활성 표현(Phase 13) — muted 적용 후 normal 재구성 시 원복돼야 + // 한다(카드 재사용). + let card = DSRouteCard() + card.configure( + with: .init( + badgeText: "지난 막차", + departureTimeText: "23:52 출발이었어요", + legs: [.subway(.line2, text: "2")], + tone: .muted + ) + ) + let dimmed = labels(in: card) + #expect(dimmed.first { $0.text == "23:52 출발이었어요" }?.textColor == DSColor.Text.secondary) + #expect(dimmed.first { $0.text == "지난 막차" }?.textColor == DSColor.Text.secondary) + #expect(allSubviews(of: card).contains { $0 is UIStackView && $0.alpha < 1 }) + + card.configure(with: .init(departureTimeText: "23:52 출발", legs: [.walk])) + let restored = labels(in: card) + #expect(restored.first { $0.text == "23:52 출발" }?.textColor == DSColor.Text.primary) + #expect(!allSubviews(of: card).contains { $0 is UIStackView && $0.alpha < 1 }) + } + private func allSubviews(of view: UIView) -> [UIView] { view.subviews + view.subviews.flatMap(allSubviews(of:)) } diff --git a/Projects/Domain/Sources/Entities/AlarmTiming.swift b/Projects/Domain/Sources/Entities/AlarmTiming.swift index 053c67c..a3c8d4c 100644 --- a/Projects/Domain/Sources/Entities/AlarmTiming.swift +++ b/Projects/Domain/Sources/Entities/AlarmTiming.swift @@ -10,4 +10,15 @@ public enum AlarmTiming { public static func alarmFireDate(departureTime: Date) -> Date { departureTime.addingTimeInterval(-bufferSeconds) } + + /// 클라 자체 만료 유예 — 출발 시각 + 60초가 지나면 세션을 로컬 만료로 판정한다 + /// (wake 시점 판정: AlarmSyncService 진입점 / 깨어 있을 때: 홈 배너 틱). + public static let expiryGraceSeconds: TimeInterval = 60 + + /// 만료 판정 순수 함수 — 시각 주입 테스트용(실 Date() 의존 금지 규약). + /// 경계(정확히 출발+60초)는 만료로 본다 — 홈 배너 2단계(now < 출발+유예)와 상보적이다. + /// 서버 refresh가 미래 출발 시각을 주면 호출자가 만료를 취소한다(서버 우선). + public static func isSessionExpired(departureTime: Date, now: Date) -> Bool { + now >= departureTime.addingTimeInterval(expiryGraceSeconds) + } } diff --git a/Projects/Domain/Sources/Entities/LastTrainActivityState.swift b/Projects/Domain/Sources/Entities/LastTrainActivityState.swift index d48c96e..a870d22 100644 --- a/Projects/Domain/Sources/Entities/LastTrainActivityState.swift +++ b/Projects/Domain/Sources/Entities/LastTrainActivityState.swift @@ -16,9 +16,14 @@ public enum LastTrainUrgency: String, Sendable, Equatable { } } -/// 알람 세션 단계 — active(진행 중) / missed(막차 놓침) / serviceEnded(운행 종료). +/// 알람 세션 단계 — active(진행 중) / departed(발화 확인, "지금 출발") / +/// missed(막차 놓침) / serviceEnded(운행 종료). +/// rawValue는 CoreLiveActivity `LastTrainSessionStatus`와 동일 규약(wire) — +/// 앱·익스텐션 동일 바이너리 배포이고, 미지 rawValue는 방어값(.active)으로 떨어진다. public enum LastTrainSessionPhase: String, Sendable, Equatable { case active + /// 알람 발화 후 "확인" 탭(stopIntent) — 카운트다운 대신 "지금 출발하세요" 표시 단계. + case departed case missed case serviceEnded } diff --git a/Projects/Domain/Tests/AlarmTimingTests.swift b/Projects/Domain/Tests/AlarmTimingTests.swift index ec08428..a5a9b02 100644 --- a/Projects/Domain/Tests/AlarmTimingTests.swift +++ b/Projects/Domain/Tests/AlarmTimingTests.swift @@ -13,4 +13,51 @@ struct AlarmTimingTests { func buffer_isFixedThreeMinutes() { #expect(AlarmTiming.bufferSeconds == 180) } + + // MARK: - 클라 자체 만료 (Phase 13) + + @Test + func expiryGrace_isFixedSixtySeconds() { + #expect(AlarmTiming.expiryGraceSeconds == 60) + } + + @Test + func isSessionExpired_boundaries() { + let departure = Date(timeIntervalSince1970: 1_000) + // 출발 전·유예 안은 만료가 아니다. + #expect(!AlarmTiming.isSessionExpired( + departureTime: departure, now: departure.addingTimeInterval(-1) + )) + #expect(!AlarmTiming.isSessionExpired(departureTime: departure, now: departure)) + #expect(!AlarmTiming.isSessionExpired( + departureTime: departure, now: departure.addingTimeInterval(59) + )) + // 경계(정확히 +60초)부터 만료 — 홈 배너 2단계(now < 출발+유예)와 상보적이다. + #expect(AlarmTiming.isSessionExpired( + departureTime: departure, now: departure.addingTimeInterval(60) + )) + #expect(AlarmTiming.isSessionExpired( + departureTime: departure, now: departure.addingTimeInterval(61) + )) + } + + @Test + func isSessionExpired_acrossMidnight_usesAbsoluteDateArithmetic() { + // 자정 경계: 23:59:30 출발 → 익일 00:00:20은 벽시계로 "이른 시각"이지만 + // 절대 시간으로 +50초 — 아직 유예 안이다. + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Seoul")! + let departure = calendar.date( + from: DateComponents(year: 2026, month: 8, day: 23, hour: 23, minute: 59, second: 30) + )! + let beforeGrace = calendar.date( + from: DateComponents(year: 2026, month: 8, day: 24, hour: 0, minute: 0, second: 20) + )! + let afterGrace = calendar.date( + from: DateComponents(year: 2026, month: 8, day: 24, hour: 0, minute: 0, second: 31) + )! + + #expect(!AlarmTiming.isSessionExpired(departureTime: departure, now: beforeGrace)) + #expect(AlarmTiming.isSessionExpired(departureTime: departure, now: afterGrace)) + } } diff --git a/Projects/Domain/Tests/LastTrainSessionPhaseTests.swift b/Projects/Domain/Tests/LastTrainSessionPhaseTests.swift new file mode 100644 index 0000000..e33611c --- /dev/null +++ b/Projects/Domain/Tests/LastTrainSessionPhaseTests.swift @@ -0,0 +1,14 @@ +@testable import Domain +import Testing + +struct LastTrainSessionPhaseTests { + @Test + func rawValues_matchCoreLiveActivityWireContract() { + // 어댑터는 rawValue로 CoreLiveActivity `LastTrainSessionStatus`에 매핑한다 — + // 케이스명 변경은 리팩터가 아니라 wire 계약 파손이다 (Phase 13: departed 추가). + #expect(LastTrainSessionPhase.active.rawValue == "active") + #expect(LastTrainSessionPhase.departed.rawValue == "departed") + #expect(LastTrainSessionPhase.missed.rawValue == "missed") + #expect(LastTrainSessionPhase.serviceEnded.rawValue == "serviceEnded") + } +} diff --git a/Projects/Feature/Home/Sources/HomeViewData.swift b/Projects/Feature/Home/Sources/HomeViewData.swift index 776994f..c6f30af 100644 --- a/Projects/Feature/Home/Sources/HomeViewData.swift +++ b/Projects/Feature/Home/Sources/HomeViewData.swift @@ -12,11 +12,18 @@ private let timeFormatter: DateFormatter = { /// 홈에 표출되는 선택 경로 카드. Entity를 뷰에 직접 노출하지 않는다. struct RouteCardViewData: Equatable { + /// 카드 톤 — past는 유예 경과 후의 "지난 막차" 상태(비활성 시각, Phase 13). + enum Tone: Equatable { + case normal + case past + } + let badgeText: String? let departureTimeText: String let legs: [DSTransportBadge.Kind] let summaryText: String? let destinationText: String + let tone: Tone init(entity: LastRoute) { badgeText = "가장 늦은 차" @@ -26,6 +33,36 @@ struct RouteCardViewData: Equatable { let arrival = entity.departureTime.addingTimeInterval(TimeInterval(entity.totalTime)) destinationText = "도착 \(timeFormatter.string(from: arrival)) · 환승 \(entity.transferCount)회" + tone = .normal + } + + private init( + badgeText: String?, + departureTimeText: String, + legs: [DSTransportBadge.Kind], + summaryText: String?, + destinationText: String, + tone: Tone + ) { + self.badgeText = badgeText + self.departureTimeText = departureTimeText + self.legs = legs + self.summaryText = summaryText + self.destinationText = destinationText + self.tone = tone + } + + /// 유예 경과 후의 "지난 막차" 카드 — 비활성 톤 + "HH:mm 출발이었어요" (Phase 13). + /// 시각은 카드의 등록 시점 문자열이 아니라 최신 세션 출발 시각(서버 갱신 반영)을 받는다. + func asPastTrain(departure: Date) -> RouteCardViewData { + RouteCardViewData( + badgeText: "지난 막차", + departureTimeText: "\(timeFormatter.string(from: departure)) 출발이었어요", + legs: legs, + summaryText: summaryText, + destinationText: destinationText, + tone: .past + ) } var dsContent: DSRouteCard.Content { @@ -34,7 +71,8 @@ struct RouteCardViewData: Equatable { departureTimeText: departureTimeText, legs: legs, summaryText: summaryText, - destinationText: destinationText + destinationText: destinationText, + tone: tone == .past ? .muted : .normal ) } diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index 8e6b007..db9bcb1 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -207,10 +207,12 @@ final class HomeViewModel { private func alarmSynced(_ info: AlarmInfo) { registeredRouteId = info.lastRouteId refreshAlarmButton() - // 이미 출발한 시각으로는 카운트다운을 (재)시작하지 않는다 — "출발까지 0분" 복원은 - // 오정보이고, 못 탐(actionable=false) 판정이 고정한 실패 배너를 후속 동기화가 - // 되살아난 카운트다운으로 덮어쓰는 일도 이 가드가 막는다. - if let departure = info.departureTime, departure > now() { + // 유예(출발+60초)가 지난 시각으로는 배너를 (재)시작하지 않는다 — 지난 막차의 + // 복원은 오정보이고, 못 탐(actionable=false) 판정이 고정한 실패 배너를 후속 + // 동기화가 덮어쓰는 일도 이 가드가 막는다. 유예 안이면 시작한다 — 발화~유예 + // 창의 "지금 출발하세요"(2단계)도 동기화 복원 대상이다. + if let departure = info.departureTime, + !AlarmTiming.isSessionExpired(departureTime: departure, now: now()) { startBannerTimer(departure: departure) } } @@ -299,7 +301,13 @@ final class HomeViewModel { bannerTask = Task { [weak self] in while !Task.isCancelled { guard let now = self?.now() else { return } - self?.state.banner = Self.makeBanner(departure: departure, now: now) + guard let banner = Self.makeBanner(departure: departure, now: now) else { + // 유예 경과(3단계) — 배너·버튼을 내리고 카드를 "지난 막차"로 전환, + // 틱 종료. 알람·LA·서버 정리는 App(AlarmSyncService)의 wake 판정 몫. + self?.sessionExpired(departure: departure) + return + } + self?.state.banner = banner // sleep 동안 self를 잡지 않는다 — deinit cancel이 즉시 먹혀야 한다. guard let interval = self?.bannerTickInterval else { return } try? await Task.sleep(for: interval) @@ -307,6 +315,18 @@ final class HomeViewModel { } } + /// 유예 경과(클라 자체 만료, Phase 13) — 지나간 막차를 "탈 수 있다"고 보여주는 + /// 서피스를 전부 내린다. 카드가 없으면(재실행 복원 상태) 배너·버튼 정리만 남는다. + private func sessionExpired(departure: Date) { + registeredRouteId = nil + selectedRoute = nil + var newState = state + newState.banner = nil + newState.routeCard = newState.routeCard?.asPastTrain(departure: departure) + newState.alarmButton = .hidden + state = newState + } + // MARK: - 순수 계산 nonisolated static func alarmButtonMode( @@ -328,11 +348,21 @@ final class HomeViewModel { /// 카운트다운·긴급도 모두 **버퍼 포함 알람 발화 시각**(AlarmTiming) 기준 — LA와 기준을 /// 통일한다(이중 시각 금지). 그래서 문구도 "막차 출발까지"가 아니라 사용자가 출발해야 /// 할 시각 기준의 "출발까지"다. - nonisolated static func makeBanner(departure: Date, now: Date) -> BannerViewData { + /// + /// Phase 13 배너 3단계 전이: + /// 1) now < 알람 시각: "출발까지 N분" + /// 2) 알람 시각 ≤ now < 출발+유예: "지금 출발하세요" (imminent 고정 — "출발까지 0분" 제거) + /// 3) 유예 경과: nil — 호출자가 배너를 내리고 카드를 "지난 막차"로 전환할 시점. + nonisolated static func makeBanner(departure: Date, now: Date) -> BannerViewData? { + guard !AlarmTiming.isSessionExpired(departureTime: departure, now: now) else { + return nil + } let alarmDate = AlarmTiming.alarmFireDate(departureTime: departure) - let minutes = minutesUntil(departure: alarmDate, now: now) + guard now < alarmDate else { + return BannerViewData(text: "지금 출발하세요", urgency: .imminent) + } return BannerViewData( - text: "출발까지 \(minutes)분", + text: "출발까지 \(minutesUntil(departure: alarmDate, now: now))분", urgency: LastTrainUrgency.forTimeRemaining(alarmDate.timeIntervalSince(now)) ) } diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift index 59b7e2c..b72d7a7 100644 --- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -266,7 +266,7 @@ struct HomeViewModelTests { // 알람까지 601초: caution으로 넘어간다. #expect(HomeViewModel.makeBanner( departure: fixedNow.addingTimeInterval(13 * 60 + 1), now: fixedNow - ).urgency == .caution) + )?.urgency == .caution) // 출발까지 33분 = 알람까지 정확히 1800초: caution 경계. #expect(HomeViewModel.makeBanner( departure: fixedNow.addingTimeInterval(33 * 60), now: fixedNow @@ -274,11 +274,59 @@ struct HomeViewModelTests { // 알람까지 1801초: relaxed. #expect(HomeViewModel.makeBanner( departure: fixedNow.addingTimeInterval(33 * 60 + 1), now: fixedNow - ).urgency == .relaxed) - // 알람 시각이 이미 지났다: 0분 클램프 + imminent. + )?.urgency == .relaxed) + } + + @Test + func makeBanner_threeStageTransition_boundaries() { + // Phase 13: 1단계(카운트다운) → 2단계(지금 출발하세요) → 3단계(nil = 지난 막차). + // 알람 직전(출발 3분 1초 전): 아직 1단계 — "출발까지 1분". + #expect(HomeViewModel.makeBanner( + departure: fixedNow.addingTimeInterval(181), now: fixedNow + ) == .init(text: "출발까지 1분", urgency: .imminent)) + // 알람 시각 정각(출발 3분 전): 2단계 진입 — "출발까지 0분"은 존재하지 않는다. + #expect(HomeViewModel.makeBanner( + departure: fixedNow.addingTimeInterval(180), now: fixedNow + ) == .init(text: "지금 출발하세요", urgency: .imminent)) + // 출발 정각·유예 마지막 초까지 2단계 유지. #expect(HomeViewModel.makeBanner( departure: fixedNow, now: fixedNow - ) == .init(text: "출발까지 0분", urgency: .imminent)) + ) == .init(text: "지금 출발하세요", urgency: .imminent)) + #expect(HomeViewModel.makeBanner( + departure: fixedNow.addingTimeInterval(-59), now: fixedNow + ) == .init(text: "지금 출발하세요", urgency: .imminent)) + // 유예 경계(출발+60초)부터 3단계 — nil. + #expect(HomeViewModel.makeBanner( + departure: fixedNow.addingTimeInterval(-60), now: fixedNow + ) == nil) + } + + @Test + func bannerTimer_graceElapsed_transitionsToPastTrainState() async { + // 유예 경과 시 틱이 3단계 전이를 수행한다: 배너 제거 + "지난 막차" 카드(비활성 톤) + // + 알람 버튼 숨김 + 틱 종료. + let clock = NowBox(fixedNow) + let departure = fixedNow.addingTimeInterval(42 * 60) + let route = makeRoute(id: "r1", departure: departure) + let sut = makeSUT(now: { clock.get() }, bannerTickInterval: .milliseconds(1)) + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.routeSelected(route) + sut.registerAlarmTapped() + await recorder.waitUntilLast { $0.banner != nil } + + clock.set(departure.addingTimeInterval(60)) + await recorder.waitUntilLast { $0.banner == nil } + + #expect(sut.state.alarmButton == .hidden) + #expect(sut.state.routeCard?.tone == .past) + #expect(sut.state.routeCard?.badgeText == "지난 막차") + #expect(sut.state.routeCard?.departureTimeText.hasSuffix("출발이었어요") == true) + + // 틱이 종료됐다 — 살아 있다면 1ms 틱이 상태를 계속 다시 쓴다. + let stateCount = recorder.states.count + try? await Task.sleep(for: .milliseconds(30)) + #expect(recorder.states.count == stateCount) } @Test @@ -518,8 +566,8 @@ struct HomeViewModelTests { } @Test - func alarmSync_pastDeparture_doesNotStartCountdown() async { - // 이미 출발한 시각의 동기화 복원 — "출발까지 0분" 카운트다운을 되살리지 않는다. + func alarmSync_pastGraceDeparture_doesNotStartCountdown() async { + // 유예(출발+60초)가 지난 시각의 동기화 복원 — 지난 막차 배너를 되살리지 않는다. // 못 탐 판정이 고정한 실패 배너를 후속 동기화가 덮어쓰는 것도 같은 가드가 막는다. let (stream, continuation) = AsyncStream.makeStream() let sut = makeSUT(alarmUpdates: { stream }) @@ -540,6 +588,29 @@ struct HomeViewModelTests { #expect(sut.state.banner == nil) } + @Test + func alarmSync_withinGrace_restoresDepartNowBanner() async { + // 발화~유예 창의 동기화 복원 — 2단계 "지금 출발하세요"도 복원 대상이다 (Phase 13). + let (stream, continuation) = AsyncStream.makeStream() + let sut = makeSUT(alarmUpdates: { stream }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + continuation.yield( + AlarmInfo( + lastRouteId: "r1", + departureTime: fixedNow.addingTimeInterval(-30), + updatedAt: nil, + isReal: true + ) + ) + await recorder.waitUntilLast { $0.banner != nil } + + #expect(sut.state.banner == .init(text: "지금 출발하세요", urgency: .imminent)) + #expect(sut.state.alarmButton == .cancel) + } + @Test func makeBanner_acrossMidnight_usesAbsoluteDateArithmetic() { // 자정 경계: 23:40 → 익일 00:10 출발은 벽시계로는 "이른 시각"이지만 절대 시간으로는