diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt index b967049f52..2b706fe469 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt @@ -100,6 +100,10 @@ fun rememberMindRecordNavActions(navController: NavController): MindRecordNavAct navController.popBackStack() } + override fun onReceiverMindRecordBack() { + navController.popBackStack() + } + override fun onWriteDailyQuestion() { navController.navigate(MindRecordRoute.DailyQuestionWriteRoute) } diff --git a/docs/qa/QA_2026-07-19.md b/docs/qa/QA_2026-07-19.md new file mode 100644 index 0000000000..c73c1fe891 --- /dev/null +++ b/docs/qa/QA_2026-07-19.md @@ -0,0 +1,94 @@ +# Afternote QA 리포트 — 2026-07-19 + +## 테스트 환경 + +| 항목 | 내용 | +|---|---| +| 기기 | Android Emulator `Medium_Phone` (arm64, Android 17 / targetSdk 36) | +| 빌드 | `develop` 브랜치 `78551075` 기준 debug 빌드 (`:app:installDebug`), versionName 1.0 | +| 서버 | `https://afternote.kro.kr/api/v1/` (라이브 서버) | +| 계정 | rugsn@naver.com (이메일 로그인) | +| 테스트 방식 | 에뮬레이터 수동 시나리오 QA + logcat(OkHttp) 네트워크 로그 + 소스코드 교차 확인 | + +> 소셜 로그인(카카오/구글)은 `local.properties`에 키가 없어 검증 불가. 이메일 로그인 기준으로 진행. + +## 요약 + +- 총 **19건** 발견 (높음 6 / 중간 10 / 낮음 3) + 관찰사항 7건 +- 핵심 문제 3가지 패턴: + 1. **API 실패가 사용자에게 전혀 표시되지 않음** (애프터노트 등록·타임레터 삭제·로그인 실패 모두 무반응) + 2. **저장 후 화면 갱신 누락** (데일리질문/일기 목록, 홈 카드·배지) + 3. **작성 중 이탈 시 경고 없이 입력 내용 소실** (애프터노트·타임레터 에디터) + +--- + +## 발견 사항 (심각도순) + +| # | 심각도 | 구분 | 발생 환경 | 발생 경로 | 문제 현상 | 기대 동작 | 원인 / 비고 | +|---|---|---|---|---|---|---|---| +| 1 | 높음 | 서버 | 라이브 서버 | 노트 탭 → 애프터노트 작성(갤러리 및 파일) → 전체 입력 → 등록 | `POST /api/v1/afternotes` → **500** `Field 'created_at' doesn't have a default value` — 애프터노트 등록이 서버에서 항상 실패 | 등록 성공 | 서버 DB `afternote.created_at` 컬럼 기본값/INSERT 누락 | +| 2 | 높음 | 클라 | 공통 | #1과 동일 경로 | 등록이 500으로 실패해도 **토스트·팝업 등 어떤 안내도 없이 무반응**. 사용자는 등록된 것으로 오인 가능 | 실패 안내 표시 + 재시도 유도 | `AfternoteRepository`가 에러를 로그만 남기고 UI 전달 안 함 | +| 3 | 높음 | 클라 | 공통 | 노트 탭 → 작성 → 종류=소셜네트워크 → 입력 후 등록 | "수신자를 한 명 이상 선택해 주세요" 검증에 걸리지만 **화면에 수신자 지정 UI가 아예 없어 등록이 원천 불가** | 갤러리/추모처럼 수신자 지정 섹션 노출 | `SocialNetworkEditorContent.kt`가 `recipientSection` 파라미터를 받고도 렌더링하지 않음 (갤러리·추모는 `RecipientDesignationSection` 렌더링) | +| 4 | 높음 | 클라+서버 | 라이브 서버 | 타임레터 탭 → 목록 항목 ⋯ → 삭제 | 클라는 `POST /time-letters/delete` 호출, 서버는 **500** "Request method 'POST' is not supported" → **삭제 불가**. 삭제 확인 팝업·실패 안내도 없음 | 삭제 성공(또는 실패 안내), 삭제 전 확인 팝업 | 클라-서버 HTTP 메서드 계약 불일치 (서버는 DELETE 기대 추정) | +| 5 | 높음 | 클라 | 공통 | 로그인 → 올바른 이메일 + 틀린 비밀번호 → 로그인 | 서버가 401 "아이디 또는 비밀번호가 일치하지 않습니다"를 반환해도 **화면 무변화, 에러 문구 없음** | 실패 사유 표시 | 로그인 실패 상태가 UI에 반영되지 않음 | +| 6 | 높음 | 클라 | 공통 | 애프터노트/타임레터 작성 중 → 백 키 또는 ← 버튼 | **경고 없이 화면 종료, 작성 내용 전부 소실**. 백 키가 어떤 때는 키보드만 닫히고 어떤 때는 화면이 닫히는 비일관 동작도 확인 | "작성 중인 내용이 사라집니다" 확인 팝업 | 이탈 가드 미구현 | +| 7 | 중간 | 클라 | 라이브 서버 | 기록 탭 → 데일리 질문 → + → 작성 → 임시저장 | 임시저장은 서버 저장 성공(200, draft:true)하지만 **목록에 안 보이고, 재진입 시 이어쓰기도 안 됨** → 사실상 접근 불가/유실 | 임시저장 글 목록 표시 또는 재진입 시 이어쓰기 제안 | `/daily-questions/today` 응답의 `draft:true`를 클라가 무시 | +| 8 | 중간 | 클라 | 공통 | 데일리질문·일기 저장 → 목록 복귀 | **저장 직후 목록이 갱신되지 않음**(빈 화면 유지). 서브탭 전환 후에야 표시됨. 홈의 일기 TOTAL·수신인 지정 배지도 실시간 미갱신(앱 재시작 시에만 갱신) | 저장/복귀 시 목록·홈 데이터 재조회 | 화면 복귀 시 refetch 누락 | +| 9 | 중간 | 클라 | 공통 | 홈 탭 → TODAY'S QUESTION 카드 | 날짜가 항상 **"2026.04.10"**, 질문도 실제 오늘의 질문("휴식…")이 아닌 기본 문구("고마웠나요?") 고정 표시 | 실제 오늘 날짜·질문 표시 | `TodayQuestionCard.kt:37` 기본값 하드코딩, `HomeTabMindRecordLazyItems.kt:41`에서 실데이터 미전달 | +| 10 | 중간 | 클라 | 공통 | 홈 탭 → "수신인 지정 미완료" 칩 탭 | 클릭 가능한 UI인데 **탭해도 아무 동작 없음** | 수신자 등록 화면으로 이동 | 클릭 핸들러 미연결 | +| 11 | 중간 | 클라 | 공통 | 설정 → 상단 바로가기(고객센터/공지사항/수신자목록) | 3개 아이콘 **모두 탭 무반응** (하단 리스트의 동일 메뉴는 정상 동작) | 각 화면으로 이동 | 클릭 핸들러 미연결 | +| 12 | 중간 | 클라 | 공통 | 타임레터 탭 → 우상단 프로필(👤)/설정(⚙) 아이콘 | **둘 다 탭 무반응** (홈 탭의 동일 설정 아이콘은 정상) | 설정/프로필 이동 | 타임레터 헤더에만 핸들러 미연결 | +| 13 | 중간 | 클라 | 공통 | 노트 탭 → 작성 → 남기실 말씀 카드의 "등록" 버튼 | 카드별 "등록" 버튼이 **아무 동작 없음** | 정의된 동작 수행(또는 버튼 제거) | `AfternoteEditorContent.kt:268,300` `onMessageRegisterClick = {}` 빈 람다 | +| 14 | 중간 | 클라 | 공통 | 노트 탭 → 작성 → 종류=추억 노트 → 추억 플레이리스트 | 카드에는 "현재 **16개**의 노래가 담겨 있습니다"인데 진입하면 "총 **0곡**" 빈 목록. 플레이리스트 화면엔 곡 추가 버튼도 없음 | 실제 곡 수 표시, 곡 추가 진입점 제공 | `EditorFormState.kt:49` `playlistSongCount: Int = 16` 하드코딩 기본값 | +| 15 | 중간 | 클라 | 라이브 서버 | 기록 탭 → 주간리포트 | 상단 카운트는 데일리질문 1·일기 1인데 본문은 "이준혁님은 **0일**의 마음을 기록하셨네요" — 수치 불일치 | 기록 일수 정확 표시 | 기록일수 집계 로직/필드 불일치 | +| 16 | 중간 | 클라 | 공통 | 타임레터 목록 → 항목 탭(상세) | 상세 헤더의 제목·수신인·발송예정일이 **밝은 배경 위 흰색 텍스트로 거의 안 보임** | 대비 확보(어두운 텍스트 또는 배경 딤 처리) | 헤더 배경 이미지 없을 때의 대비 미처리 추정 | +| 17 | 낮음 | 클라 | 공통 | 설정 → 수신자 목록 / 타임레터 작성 → 수신인 선택 | 검색창 placeholder가 리터럴 **"Text Field"** 로 노출 | 한국어 안내 문구 (예: "이름으로 검색") | placeholder 미지정/컴포넌트 기본값 노출 | +| 18 | 낮음 | 클라 | 공통 | 기록 탭 → 데일리 질문(데이터 없음) / 설정 → 공지사항(데이터 없음) | 빈 목록일 때 **안내 문구 없이 완전 빈 화면** | 빈 상태 안내 표시 | 빈 상태 UI 누락 (일기 탭·타임레터에는 있음) | +| 19 | 낮음 | 클라 | 공통 | 기록 탭 → 일기(데이터 없음) | 빈 상태 문구가 "아직 등록된 **답변**이 없어요. **답변을 등록해** 자신을 알아 보아요." — 노트 탭 문구 재사용으로 일기 맥락과 불일치 | 일기 맥락 문구 | 문구 재사용 | + +### 서버 보안 관련 + +| # | 심각도 | 발생 경로 | 문제 현상 | 기대 동작 | +|---|---|---|---|---| +| S1 | 중간 | 애프터노트 등록 실패 응답 (#1) | 500 응답 `message`에 **SQL문·컬럼명 등 내부 정보가 그대로 노출** (`insert into afternote (category_type, ...)`) | 내부 오류는 일반화된 메시지로 치환, 상세는 서버 로그로만 | + +--- + +## 관찰 사항 (버그 확정 아님 — 기획 의도 확인 필요) + +| # | 발생 경로 | 내용 | +|---|---|---| +| O1 | 설정 → 수신자 등록 완료 / 수신자 선택 완료 | 성공 시 토스트 등 피드백 없이 조용히 이전 화면 복귀 | +| O2 | 노트 작성(갤러리) → 수신자 추가 | 설정에 등록해 둔 수신자(QA Tester)를 선택하는 방식이 아니라 이름/관계/전화번호를 **새로 입력**받는 다이얼로그. 등록된 수신자 재사용이 안 되는 게 의도인지 확인 필요 | +| O3 | 설정 → 패스키 관리 → 비밀번호로 인증하기 | **숫자 4자리 키패드**가 뜨는데 계정 비밀번호는 영문+특수문자라 어떤 비밀번호를 요구하는지 불명확 (앱 잠금 PIN 미설정 상태의 동작 포함 확인 필요) | +| O4 | 타임레터 작성 → 발송 시간 미선택 → 등록 | 시간 없이 등록 가능하며 `00:00`(오전 12:00)으로 저장됨. 시간이 필수인지 확인 필요 | +| O5 | 타임레터 4건째 등록 → 무료 플랜 팝업 → "구독 후 기록하기" | 팝업만 닫힘. 코드상 TODO(구독 화면 미구현) — 알려진 상태 | +| O6 | Welcome → 전달 받은 기록 확인하기 | 받은 기록함 빈 상태 문구가 "**수신자**를 등록하여" — 수신자 입장에서는 발신자 등록이므로 용어 확인 필요. 발신자 목록의 "마지막 확인: " 뒤 빈 값 노출 | +| O7 | 수신자 인증 1단계 (이메일 인증) | `POST /receiver-auth/email/auth-code` 200 — 인증 메일 발송까지 정상 확인. 인증번호 입력 이후(마스터 키·서류 업로드)는 테스트 데이터 부재로 미검증 | + +--- + +## 정상 동작 확인된 플로우 + +- 콜드 스타트 → 스플래시 → Welcome 렌더링, 로그인 성공 → 홈 진입 +- 자동 로그인(앱 재시작 시 세션 유지), 로그아웃(확인 팝업 포함) +- 데일리 질문 답변 작성·저장(서버 200), 목록·달력 뷰 표시(단, #8 갱신 문제) +- 일기 작성(제목/기분/내용)·저장, 달력 + DAILY ANSWER 카드 표시 +- 주간리포트 렌더링(감정 키워드 포함, 단 #15 수치 불일치) +- 타임레터: 등록(즉시 목록 반영)·수정(PATCH 200)·빈 폼 검증("발송 날짜를 선택해주세요")·무료 플랜 3건 제한 팝업(수정된 문구 정상 표시) +- 수신자 등록(전화번호 자동 하이픈 포맷)·수신자 목록 표시 +- 사후 전달 조건·알림 설정·프로필 설정 화면 렌더링 및 토글 동작 +- 수신자(유족) 플로우: 발신자 등록 → 상세(열람 불가 상태) → 인증 4단계 진입 → 이메일 인증번호 발송(200) + +## 테스트 중 생성된 데이터 (정리 필요) + +- 수신자 "QA Tester" (아들, 010-1234-5678, rugsn12345@gmail.com) +- 타임레터 3건: "QA letter 3 edited", "QA letter 2", "QA timeletter title" — **#4 삭제 버그로 앱에서 삭제 불가** +- 데일리 질문 답변 1건(2026-07-19), 일기 1건("QA diary title") +- 수신자 플로우: 발신자 "QA Sender" 등록, `rugsn12345@gmail.com`으로 실제 인증 메일 1통 발송됨 + +## 미검증 항목 + +- 카카오/구글 소셜 로그인 (키 미설정), 회원가입 전체 경로(실계정 이메일 인증 필요), 회원 탈퇴(파괴적 동작이라 제외) +- 지문 인증(에뮬레이터 지문 미등록), 사진/영상 업로드 첨부, 수신자 인증 2~4단계(마스터 키·서류) +- 릴리즈 빌드 동작 diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/MindRecordMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/MindRecordMapper.kt index 313bc002a9..cb8c24d691 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/MindRecordMapper.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/MindRecordMapper.kt @@ -30,4 +30,5 @@ fun ReceiverDiaryItemDto.toDomain(): MindRecordSummary = isDraft = isDraft, createdAt = createdAt, imageUrl = imageUrl, + todayMood = todayMood?.toDomain(), ) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapper.kt new file mode 100644 index 0000000000..e9b356b558 --- /dev/null +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapper.kt @@ -0,0 +1,28 @@ +package com.afternote.feature.mindrecord.data.repositoryimpl + +import com.afternote.core.network.model.ApiException +import com.afternote.feature.mindrecord.domain.error.DeliveryNotReadyException + +/** 전달 조건 미충족 — `403 {"code":2009}` (실서버 실측, 2026-08-23). */ +private const val CODE_DELIVERY_CONDITION_NOT_MET = 2009 + +/** + * 수신자 기록 조회 실패를 도메인 예외로 옮긴다 — presentation 이 `core:network` 를 모른 채 + * 타입만으로 분기하게 하는 것이 목적이다 (`mapAccountFailure` 와 같은 자리·같은 이유). + * + * 가르는 신호는 서버 봉투의 `code` 뿐이고 `message` 는 옮기지 않는다. 그 필드가 사용자 + * 노출용이라는 규정이 명세에 없다. 표시 문구는 화면이 자기 리소스로 갖는다. + */ +internal fun Result.mapReceiverFailure(): Result = + when (val exception = exceptionOrNull()) { + is ApiException -> { + when (exception.code) { + CODE_DELIVERY_CONDITION_NOT_MET -> Result.failure(DeliveryNotReadyException(exception)) + else -> this + } + } + + else -> { + this + } + } diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt index c671986eeb..87c08401f6 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt @@ -26,5 +26,5 @@ class MindRecordReceiverRepositoryImpl diaries = diariesDeferred.await().diaries.map { it.toDomain() }, ) } - } + }.mapReceiverFailure() } diff --git a/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapperTest.kt b/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapperTest.kt new file mode 100644 index 0000000000..9ddd632418 --- /dev/null +++ b/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapperTest.kt @@ -0,0 +1,58 @@ +package com.afternote.feature.mindrecord.data.repositoryimpl + +import com.afternote.core.network.model.ApiException +import com.afternote.feature.mindrecord.domain.error.DeliveryNotReadyException +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * 수신자 조회 실패의 계층 경계 가드 (#614). + * + * "서버 에러 코드" 는 전송 계층 개념이라 화면까지 올라가면 안 된다. 여기서 도메인 예외로 + * 바꿔 두면 presentation 은 타입만 보고 분기하고 `core:network` 를 모른다. + */ +class MindRecordReceiverFailureMapperTest { + private fun apiFailure( + status: Int, + code: Int, + ) = Result.failure( + ApiException(status = status, code = code, serverMessage = "서버 원문", message = "서버 원문"), + ) + + @Test + fun `전달 조건 미충족 코드는 도메인 예외가 된다`() { + val mapped = apiFailure(status = 403, code = 2009).mapReceiverFailure() + + assertTrue(mapped.exceptionOrNull() is DeliveryNotReadyException) + } + + @Test + fun `원래 예외를 cause 로 남긴다`() { + // 진단 정보를 버리지 않는다 — 화면에 쓰지 않을 뿐이다. + val original = apiFailure(status = 403, code = 2009) + val mapped = original.mapReceiverFailure() + + assertSame(original.exceptionOrNull(), mapped.exceptionOrNull()?.cause) + } + + @Test + fun `다른 서버 코드는 그대로 흘려보낸다`() { + val original = apiFailure(status = 500, code = 1004) + + assertSame(original.exceptionOrNull(), original.mapReceiverFailure().exceptionOrNull()) + } + + @Test + fun `서버 응답이 없는 실패는 손대지 않는다`() { + val original = Result.failure(IOException("timeout")) + + assertSame(original.exceptionOrNull(), original.mapReceiverFailure().exceptionOrNull()) + } + + @Test + fun `성공은 그대로 통과한다`() { + assertSame(Unit, Result.success(Unit).mapReceiverFailure().getOrNull()) + } +} diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/error/DeliveryNotReadyException.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/error/DeliveryNotReadyException.kt new file mode 100644 index 0000000000..aebe73cd68 --- /dev/null +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/error/DeliveryNotReadyException.kt @@ -0,0 +1,11 @@ +package com.afternote.feature.mindrecord.domain.error + +/** + * 전달 조건이 아직 충족되지 않아 수신자가 기록을 열람할 수 없는 상태 (#614). + * + * 수신자가 할 수 있는 일이 없다 — 발신자가 전달 조건을 설정해야 풀린다. 화면은 이 타입만 + * 보고 문구를 고르고, 어떤 서버 코드에서 왔는지는 data 계층의 매핑만 안다. + */ +class DeliveryNotReadyException( + cause: Throwable? = null, +) : Exception(cause) diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/MindRecordModels.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/MindRecordModels.kt index 6d42259fdc..6556b4d69f 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/MindRecordModels.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/MindRecordModels.kt @@ -22,4 +22,10 @@ data class MindRecordSummary( /** 서버 원본 표기 (`yyyy.MM.dd 요일`) — 카드에 그대로 노출. */ val createdAt: String, val imageUrl: String? = null, + /** + * 작성자가 고른 오늘의 기분. 일기에만 있고 데일리질문에는 없다. + * + * 종전에는 목록이 이 값을 버려서, 수신자가 본문과 함께 볼 수 없었다 (#618). + */ + val todayMood: TodayMood? = null, ) diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 4b42a8bde8..f79fe00f71 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { // 열린 주차 메뉴의 스크롤·최하단 선택도 JVM 에서 그대로 재현한다 (#729). // 매퍼가 android.util.Log 를 타 JVM 단위 테스트로는 돌지 않는다 (#751). // 부분 성공에서 어떤 문구가 나가는지도 컴포지션을 태워야 확인된다 (#725). + // 수신자 기록 본문 시트도 JVM 에서 실제로 렌더해 확인한다 (#618). testImplementation(libs.robolectric) testImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverRecordDetailSheet.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverRecordDetailSheet.kt new file mode 100644 index 0000000000..d3c96d50b4 --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverRecordDetailSheet.kt @@ -0,0 +1,107 @@ +package com.afternote.feature.mindrecord.presentation.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.MindRecordSummary +import com.afternote.feature.mindrecord.domain.model.MindRecordType +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.presentation.mapper.toEmoji +import com.afternote.feature.mindrecord.presentation.util.htmlToPlainText + +/** + * 수신자가 받은 기록 하나의 본문을 읽는 시트 (#618). + * + * 카드를 탭해도 아무 일이 없어 **제목 목록에서 멈춰** 있었다. 수신자가 고인의 기록을 + * 읽는 것이 이 화면의 목적인데 본문·기분에 도달할 경로가 없었다. + * + * **추가 조회를 하지 않는다.** 목록 응답이 이미 `content` 와 `todayMood` 를 함께 주므로 + * (`GET receiver-auth/diary` 실측), 화면이 들고 있는 항목을 그대로 펼치면 된다. + * + * 별도 라우트가 아니라 시트인 이유: 이 화면에는 뒤로가기가 없어(#614) 새 화면을 쌓으면 + * 나올 방법이 시스템 백키뿐이다. 시트는 스와이프·바깥 탭으로 닫힌다. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReceiverRecordDetailSheet( + record: MindRecordSummary, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = AfternoteDesign.colors.white, + modifier = modifier, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 40.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = record.createdAt, + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + // 일기에만 있는 값이라 없으면 자리도 만들지 않는다. + record.todayMood?.let { mood -> + Text(text = mood.toEmoji(), style = AfternoteDesign.typography.captionLargeR) + } + } + + Text( + text = record.title, + style = AfternoteDesign.typography.bodyLargeB, + color = AfternoteDesign.colors.gray9, + ) + + // 본문은 HTML 조각이라 태그를 벗겨 읽을 수 있는 텍스트로 만든다. + Text( + text = record.content.htmlToPlainText(), + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray9, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ReceiverRecordDetailSheetPreview() { + AfternoteTheme { + ReceiverRecordDetailSheet( + record = + MindRecordSummary( + id = 11L, + type = MindRecordType.DIARY, + title = "오늘의 산책", + content = "

강변을 한 시간 걸었다.

", + recordDate = "2026-07-29", + isDraft = false, + createdAt = "2026.07.29 수", + todayMood = TodayMood.HAPPY, + ), + onDismiss = {}, + ) + } +} diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt index eb6213e21b..6b7fefc2aa 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt @@ -10,6 +10,15 @@ package com.afternote.feature.mindrecord.presentation.navigation interface MindRecordNavActions { fun onMemorySpaceBack() + /** + * 수신자 기록 화면에서 나간다. + * + * 지금 동작은 추억 공간과 같은 `popBackStack()` 이지만 이름을 빌리지 않는다 — 이 + * 인터페이스는 화면별로 액션을 나눠 두는 곳이고, 빌려 쓰면 «추억 공간만 다르게 + * 처리하자» 는 변경이 이 화면까지 조용히 끌고 간다. + */ + fun onReceiverMindRecordBack() + fun onWriteDailyQuestion() fun onWriteDiary() diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt index 5e2813c196..6632640691 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt @@ -33,7 +33,8 @@ fun NavGraphBuilder.mindRecordNavGraph(actions: MindRecordNavActions) { MemorySpaceScreen(onBackClick = actions::onMemorySpaceBack) } composable { - ReceiverMindRecordScreen() + // 앱바 뒤로가기를 실제로 붙인다 — 없으면 이 화면이 막다른 곳이 된다 (#614). + ReceiverMindRecordScreen(onBackClick = actions::onReceiverMindRecordBack) } composable { DailyQuestionWriteScreen( diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt index 06990987b5..982f3fb0f3 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -29,19 +30,25 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.asString import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.mindrecord.domain.model.MindRecordSummary import com.afternote.feature.mindrecord.domain.model.MindRecordType +import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.component.ReceiverDiaryGridCard import com.afternote.feature.mindrecord.presentation.component.ReceiverMindRecordTopBar import com.afternote.feature.mindrecord.presentation.component.ReceiverRecordCard +import com.afternote.feature.mindrecord.presentation.component.ReceiverRecordDetailSheet import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordUiState import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordViewModel import androidx.compose.foundation.lazy.grid.items as gridItems @@ -57,10 +64,11 @@ fun ReceiverMindRecordScreen( modifier: Modifier = Modifier, viewModel: ReceiverMindRecordViewModel = hiltViewModel(), onBackClick: () -> Unit = {}, - onRecordClick: (Long) -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var filterSheetVisible by remember { mutableStateOf(false) } + // 탭한 기록의 본문을 여는 시트. 목록 응답이 이미 본문을 갖고 있어 추가 조회가 없다 (#618). + var openedRecordId by remember { mutableStateOf(null) } // 화면을 떠났다 돌아오면 다시 조회한다 — 작성·삭제하고 복귀했을 때 목록이 낡은 채로 // 남지 않게 한다. 마인드레코드 홈이 쓰는 결선과 같다 (#702). @@ -71,9 +79,17 @@ fun ReceiverMindRecordScreen( viewModel.refreshOnReturn() } + // 앱바가 없으면 이 화면은 막다른 곳이 된다 — 실패 시 화면에 남는 것이 오류 문구 하나뿐이라 + // 시스템 백키 외에 빠져나갈 수단이 없었다 (#614). Scaffold( modifier = modifier, containerColor = Color.Transparent, + topBar = { + DetailTopBar( + title = stringResource(R.string.mindrecord_receiver_title), + onBackClick = onBackClick, + ) + }, ) { paddingValues -> Box(modifier = Modifier.padding(paddingValues)) { when (val state = uiState) { @@ -82,20 +98,32 @@ fun ReceiverMindRecordScreen( } is ReceiverMindRecordUiState.Error -> { - ErrorBox(message = state.message, onRetry = viewModel::refresh) + ErrorBox(message = state.message.asString(), onRetry = viewModel::refresh) } is ReceiverMindRecordUiState.Success -> { SuccessContent( state = state, onFilterClick = { filterSheetVisible = true }, - onRecordClick = onRecordClick, + // 카드 탭의 목적지는 이 화면 안의 시트다. 종전에는 화면이 받아 두고 + // 아무도 넘기지 않는 `onRecordClick` 파라미터가 있었는데(#618 이 지목한 + // 그 콜백), 남겨 두면 나중에 실제 네비게이션이 붙는 순간 시트가 열리며 + // 화면까지 전환된다. 소유자를 하나로 둔다. + onRecordClick = { id -> openedRecordId = id }, ) } } } } + val openedRecord = findOpenedRecord(uiState, openedRecordId) + openedRecord?.let { record -> + ReceiverRecordDetailSheet( + record = record, + onDismiss = { openedRecordId = null }, + ) + } + if (filterSheetVisible && uiState is ReceiverMindRecordUiState.Success) { ReceiverMindRecordFilterSheet( current = (uiState as ReceiverMindRecordUiState.Success).filter, @@ -221,10 +249,24 @@ private fun ErrorBox( onRetry: () -> Unit, ) { Box(modifier = Modifier.fillMaxSize().padding(20.dp), contentAlignment = Alignment.Center) { - Text(text = message, color = AfternoteDesign.colors.gray9) - // 재시도 트리거는 후속 PR. 현재는 라이프사이클 재진입으로 처리. - @Suppress("UNUSED_EXPRESSION") - onRetry + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = message, + color = AfternoteDesign.colors.gray9, + textAlign = TextAlign.Center, + ) + // 종전에는 콜백을 받아 두고 쓰지 않아, 화면에 재시도 수단이 없었다 (#614). + TextButton(onClick = onRetry) { + Text( + text = stringResource(R.string.mindrecord_receiver_retry), + style = AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + } + } } } @@ -258,3 +300,20 @@ private fun ReceiverMindRecordScreenPreview() { ) } } + +/** + * 탭한 기록을 화면이 들고 있는 목록에서 찾는다 (#618). + * + * 두 탭(데일리질문·일기)이 같은 시트를 쓰므로 양쪽을 함께 뒤진다. 목록 응답이 이미 + * 본문을 갖고 있어 여기서 찾은 항목을 그대로 펼치면 되고, 추가 조회가 필요 없다. + * + * 필터·정렬로 목록에서 빠진 항목은 자연히 null 이 되어 시트가 닫힌다. + */ +internal fun findOpenedRecord( + uiState: ReceiverMindRecordUiState, + openedRecordId: Long?, +): MindRecordSummary? { + if (openedRecordId == null) return null + val success = uiState as? ReceiverMindRecordUiState.Success ?: return null + return (success.dailyQuestions + success.diaries).firstOrNull { it.id == openedRecordId } +} diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordUiState.kt index b1db3312b9..23b4722f8c 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordUiState.kt @@ -1,5 +1,6 @@ package com.afternote.feature.mindrecord.presentation.viewmodel +import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.MindRecordSummary /** @@ -16,8 +17,15 @@ sealed interface ReceiverMindRecordUiState { val filter: ReceiverMindRecordFilter = ReceiverMindRecordFilter(), ) : ReceiverMindRecordUiState + /** + * 조회 실패. + * + * [message] 는 **도메인 문구**다. 종전에는 서버 응답의 `message` 를 그대로 실어 + * "아직 전달 조건이 충족되지 않았습니다" 같은 원문이 화면 문구가 됐다 — 사용자는 + * "전달 조건" 이 무엇인지, 자기가 무엇을 해야 하는지 알 수 없었다 (#614). + */ data class Error( - val message: String, + val message: UiText, ) : ReceiverMindRecordUiState } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt index 84c771fb36..960957042e 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt @@ -2,9 +2,12 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.domain.error.DeliveryNotReadyException import com.afternote.feature.mindrecord.domain.model.MindRecordSummary import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository +import com.afternote.feature.mindrecord.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -88,10 +91,9 @@ class ReceiverMindRecordViewModel }.onFailure { e -> // 재진입 갱신 실패는 보고 있던 화면을 유지한다. if (showsLoading) { + // 서버 원문을 화면 문구로 쓰지 않는다 — 타입에서 막는다 (#614). _uiState.value = - ReceiverMindRecordUiState.Error( - message = e.message ?: "마음의 기록을 불러오지 못했습니다.", - ) + ReceiverMindRecordUiState.Error(message = e.toDomainMessage()) } } } @@ -131,3 +133,16 @@ class ReceiverMindRecordViewModel } } } + +/** + * 실패를 화면 문구로 바꾼다 (#614). + * + * 서버 에러 코드는 보지 않는다 — 그 판정은 data 계층(`mapReceiverFailure`)이 하고 여기는 + * 도메인 예외 타입만 본다. 전달 조건 미충족은 수신자가 할 수 있는 일이 없는 상태라 + * (발신자가 조건을 설정해야 풀린다) 원문 대신 무엇을 기다리는지 알려 준다. + */ +internal fun Throwable.toDomainMessage(): UiText = + when (this) { + is DeliveryNotReadyException -> UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready) + else -> UiText.Resource(R.string.mindrecord_receiver_load_failed) + } diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index 376282284c..28a557c852 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -193,4 +193,9 @@ 답변을 입력해 주세요. 오늘의 질문을 불러오지 못해 저장할 수 없습니다. 잠시 후 다시 시도해 주세요. 주간 리포트를 불러오지 못했습니다. + + 아직 열람할 수 있는 기록이 없어요.\n작성자가 전달 시점을 정하면 이곳에서 볼 수 있어요. + 마음의 기록을 불러오지 못했어요. + 다시 시도 + 마음의 기록 diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt new file mode 100644 index 0000000000..c588513835 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt @@ -0,0 +1,47 @@ +package com.afternote.feature.mindrecord.presentation.screen.receiver + +import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.domain.error.DeliveryNotReadyException +import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.viewmodel.toDomainMessage +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.IOException + +/** + * 수신자 열람 실패 문구 가드 (#614). + * + * 종전에는 서버 응답의 `message` 를 그대로 화면에 실어 «아직 전달 조건이 충족되지 + * 않았습니다» 라는 원문이 그대로 노출됐다. 사용자는 "전달 조건" 이 무엇인지, 자기가 + * 무엇을 해야 하는지 알 수 없었다 — 실제로는 발신자가 설정해야 풀리는 상태라 + * 수신자가 할 수 있는 일이 없다. + * + * 화면은 **도메인 예외 타입만** 본다. 어떤 서버 코드가 그 타입이 되는지는 data 계층의 + * `mapReceiverFailure` 가 알고, 그쪽 계약은 `MindRecordReceiverFailureMapperTest` 가 잡는다. + */ +class ReceiverDeadEndTest { + @Test + fun `전달 조건 미충족은 기다리는 상태임을 알리는 문구가 된다`() { + assertEquals( + UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready), + DeliveryNotReadyException().toDomainMessage(), + ) + } + + @Test + fun `서버 원문을 화면 문구로 쓰지 않는다`() { + // 결과 타입이 UiText.Resource 라 원문이 들어갈 자리 자체가 없다. + assertEquals( + UiText.Resource(R.string.mindrecord_receiver_load_failed), + IllegalStateException("서버 내부 오류가 발생했습니다.").toDomainMessage(), + ) + } + + @Test + fun `네트워크 예외도 같은 실패 문구로 수렴한다`() { + assertEquals( + UiText.Resource(R.string.mindrecord_receiver_load_failed), + IOException("timeout").toDomainMessage(), + ) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt new file mode 100644 index 0000000000..40cef0447e --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt @@ -0,0 +1,125 @@ +package com.afternote.feature.mindrecord.presentation.screen.receiver + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.MindRecordSummary +import com.afternote.feature.mindrecord.domain.model.MindRecordType +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.presentation.component.ReceiverRecordDetailSheet +import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordFilter +import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordUiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 수신자 기록 본문 열람 가드 (#618). + * + * 카드가 clickable 인데 콜백이 빈 람다라 탭해도 아무 일이 없었고, 목록에서 제목만 보이고 + * `content`·`todayMood` 에 도달할 경로가 없었다. 서버는 목록 응답에 본문을 함께 주므로 + * 화면이 들고 있는 항목을 펼치면 된다 — 추가 조회가 없다는 점도 함께 고정한다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ReceiverRecordDetailTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val diary = + MindRecordSummary( + id = 11L, + type = MindRecordType.DIARY, + title = "오늘의 산책", + content = "

강변을 한 시간 걸었다.

", + recordDate = "2026-07-29", + isDraft = false, + createdAt = "2026.07.29 수", + todayMood = TodayMood.HAPPY, + ) + + private val answer = + MindRecordSummary( + id = 22L, + type = MindRecordType.DAILY_QUESTION, + title = "가장 기억에 남는 여행지는?", + content = "

제주.

", + recordDate = "2026-07-28", + isDraft = false, + createdAt = "2026.07.28 화", + ) + + private fun success() = + ReceiverMindRecordUiState.Success( + dailyQuestions = listOf(answer), + diaries = listOf(diary), + filter = ReceiverMindRecordFilter(), + ) + + @Test + fun `탭한 일기를 두 탭 목록에서 찾아낸다`() { + assertEquals(diary, findOpenedRecord(success(), 11L)) + } + + @Test + fun `탭한 데일리질문도 같은 경로로 찾는다`() { + assertEquals(answer, findOpenedRecord(success(), 22L)) + } + + @Test + fun `아무것도 안 눌렀으면 열지 않는다`() { + assertNull(findOpenedRecord(success(), null)) + } + + @Test + fun `필터로 목록에서 빠진 항목은 열리지 않는다`() { + // 시트가 열린 채 필터가 바뀌면 자연히 닫힌다 — 없는 기록을 계속 붙들지 않는다. + // 목록에 없던 id 를 넣으면 위의 «못 찾으면 null» 을 한 번 더 밟을 뿐이므로, + // **방금까지 열려 있던** 11L 을 그대로 두고 그 항목이 빠진 목록을 넣는다. + val openedId = 11L + assertEquals(diary, findOpenedRecord(success(), openedId)) + + val filtered = success().copy(diaries = emptyList()) + + assertNull(findOpenedRecord(filtered, openedId)) + } + + @Test + fun `본문 시트가 제목과 본문과 기분을 보여준다`() { + // 목록에서 제목만 보이던 상태에서 content·todayMood 에 도달하는 것이 이 이슈의 목적이다. + composeRule.setContent { + AfternoteTheme { + ReceiverRecordDetailSheet(record = diary, onDismiss = {}) + } + } + + composeRule.onNodeWithText("오늘의 산책").assertIsDisplayed() + // HTML 조각이라 태그를 벗겨 읽을 수 있는 텍스트로 보여준다. + composeRule.onNodeWithText("강변을 한 시간 걸었다.").assertIsDisplayed() + composeRule.onNodeWithText("2026.07.29 수").assertIsDisplayed() + composeRule.onNodeWithText("😊").assertIsDisplayed() + } + + @Test + fun `기분이 없는 데일리질문은 기분 자리를 만들지 않는다`() { + composeRule.setContent { + AfternoteTheme { + ReceiverRecordDetailSheet(record = answer, onDismiss = {}) + } + } + + composeRule.onNodeWithText("제주.").assertIsDisplayed() + assertEquals( + "기분이 없는 기록에는 이모지 자리가 없다", + 0, + composeRule.onAllNodesWithText("😊").fetchSemanticsNodes().size, + ) + } +}