From 606beb8384ff64213f9cbe9897af22e5422b9afd Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Mon, 12 Oct 2026 21:55:19 +0900 Subject: [PATCH 01/14] =?UTF-8?q?test(maestro):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=EA=B3=BC=20=EC=84=A4=EC=A0=95=20E2E=20=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80=20#615?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .maestro/README.md | 11 ++ .maestro/config.yaml | 4 + .maestro/flows/profile-edit-validation.yaml | 72 ++++++++ .maestro/flows/profile-edit.yaml | 107 +++++++++++ .maestro/flows/profile-notification.yaml | 121 ++++++++++++ .../flows/profile-preferred-exercise.yaml | 173 ++++++++++++++++++ .../subflows/profile-notification-denied.yaml | 17 ++ .../profile/mypage/ProfileEditScreen.kt | 16 +- .../PreferredExerciseScreen.kt | 23 ++- .../profile/setting/NotificationScreen.kt | 13 +- 10 files changed, 552 insertions(+), 5 deletions(-) create mode 100644 .maestro/flows/profile-edit-validation.yaml create mode 100644 .maestro/flows/profile-edit.yaml create mode 100644 .maestro/flows/profile-notification.yaml create mode 100644 .maestro/flows/profile-preferred-exercise.yaml create mode 100644 .maestro/subflows/profile-notification-denied.yaml diff --git a/.maestro/README.md b/.maestro/README.md index c180ec7e..96a02c64 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -117,6 +117,17 @@ UI가 생성·수정·삭제를 수행합니다. bootstrap은 테스트를 통 |`flows/exercise-form-entry.yaml`|`regression`, `requires-login`|`regression`, `all`|실제 기록 폼 진입과 필드 표시| |`flows/exercise-crud.yaml`|`regression`, `requires-login`|`regression`, `all`|필수값 누락, UI 생성/조회/수정, 재실행 후 영속성, UI 삭제와 전체 목록 부재 검사| |`flows/login-screen.yaml`|`smoke`|`login`, `smoke`, `all`|clearState 뒤 실제 로그인 화면/버튼| + +### 기능별 확장 플로우 + +|파일|태그|검증| +|---|---|---| +|`flows/profile-edit.yaml`|`regression`, `requires-login`|닉네임·소개 수정, 재진입 확인, 원래 값 복구| +|`flows/profile-edit-validation.yaml`|`regression`, `requires-login`|닉네임 중복 확인 누락과 유효하지 않은 값 저장 방지| +|`flows/profile-preferred-exercise.yaml`|`regression`, `requires-login`|운동 종목·요일·숙련도 저장, 변경·일부 해제·복구| +|`flows/profile-notification.yaml`|`regression`, `requires-login`|운동·채팅 알림 저장, 재진입, OS 권한 거부 상태| + +프로필 플로우는 전용 계정 설정을 변경하므로 각 플로우에서 원래 상태를 복구합니다. 선호 운동과 알림 상태를 완전히 읽을 수 없는 항목은 전용 계정 기준 상태를 명시해 실행해야 합니다. `login-screen`은 세션을 지우므로 직접 Maestro 전체 실행의 `config.yaml`에서도 마지막입니다. 호스트 러너는 앞 플로우의 로그인 상태에 의존하지 않고 매번 복원합니다. 직접 실행한 뒤에는 다시 수동 로그인해야 합니다. diff --git a/.maestro/config.yaml b/.maestro/config.yaml index 7d7f5619..dbb898d4 100644 --- a/.maestro/config.yaml +++ b/.maestro/config.yaml @@ -9,6 +9,10 @@ executionOrder: - smoke-tabs - exercise-form-entry - exercise-crud + - profile-edit + - profile-edit-validation + - profile-preferred-exercise + - profile-notification - login-screen testOutputDir: build/maestro diff --git a/.maestro/flows/profile-edit-validation.yaml b/.maestro/flows/profile-edit-validation.yaml new file mode 100644 index 00000000..936d14fc --- /dev/null +++ b/.maestro/flows/profile-edit-validation.yaml @@ -0,0 +1,72 @@ +appId: com.project200.undabang +name: 프로필 편집 - 저장 방지 경로 +tags: + - regression + - requires-login +--- +# 세 가지 저장 방지 경로를 봅니다. 셋 다 ProfileEditViewModel이 저장 API를 호출하지 않고 +# 토스트만 띄우므로, 이 플로우는 끝까지 한 번도 저장하지 않습니다 — 복구할 서버 상태가 없습니다 +- runFlow: ../subflows/launch-logged-in.yaml + +- tapOn: + id: profile_nav_graph +- extendedWaitUntil: + visible: "최근 30일 운동 횟수" + timeout: 30000 + +- tapOn: + id: mypage_edit_btn +- extendedWaitUntil: + visible: + id: profile_edit_nickname_field + timeout: 30000 + +- evalScript: ${output.nickA = 'e2e615a' + Date.now()} +- evalScript: ${output.nickB = 'e2e615b' + Date.now()} + +# 1) 중복 확인 없이 완료 -> 저장이 막힙니다 +- tapOn: + id: profile_edit_nickname_field +- eraseText +- inputText: ${output.nickA} +- tapOn: "완료" +- extendedWaitUntil: + visible: "닉네임 중복 확인을 해주세요." + timeout: 10000 + +# 2) 유효하지 않은 값 -> 규칙 위반 문구가 뜨고, 중복 확인 API는 호출되지 않습니다 +# ValidateNicknameUseCase 정규식(한글/영문/숫자만)에 맞춰 '!'로 위반시킵니다 +- tapOn: + id: profile_edit_nickname_field +- eraseText +- inputText: "e2e615!invalid" +- tapOn: "중복 확인" +- assertVisible: "닉네임 조건을 확인해주세요." +- tapOn: "완료" +- extendedWaitUntil: + visible: "닉네임 중복 확인을 해주세요." + timeout: 10000 + +# 3) 닉네임 A로 중복 확인을 통과한 뒤 B로 다시 바꾸면 확인 상태가 풀립니다(요구 완료 기준) +- tapOn: + id: profile_edit_nickname_field +- eraseText +- inputText: ${output.nickA} +- tapOn: "중복 확인" +- extendedWaitUntil: + visible: "사용 가능한 닉네임입니다." + timeout: 15000 + +- tapOn: + id: profile_edit_nickname_field +- eraseText +- inputText: ${output.nickB} +# updateNickname이 값이 바뀔 때마다 isNicknameChecked를 초기화합니다. 중복 확인 문구가 사라졌는지 확인합니다 +- assertNotVisible: "사용 가능한 닉네임입니다." +- tapOn: "완료" +- extendedWaitUntil: + visible: "닉네임 중복 확인을 해주세요." + timeout: 10000 + +# 세 경로 모두 저장되지 않았으므로 로컬 입력만 버리고 나갑니다 +- back diff --git a/.maestro/flows/profile-edit.yaml b/.maestro/flows/profile-edit.yaml new file mode 100644 index 00000000..15f92255 --- /dev/null +++ b/.maestro/flows/profile-edit.yaml @@ -0,0 +1,107 @@ +appId: com.project200.undabang +name: 프로필 편집 - 닉네임·소개 변경과 복구 +tags: + - regression + - requires-login +--- +# 닉네임 중복 확인과 소개 변경을 저장한 뒤 마이페이지와 편집 화면 재진입으로 대조하고, +# 끝에서 원래 값으로 되돌립니다. ProfileEditViewModel.checkProfileChanged는 저장 전 중복 확인을 +# 요구하므로(NO_DUPLICATE_CHECKED) 복구 역시 실제 저장 경로를 한 번 더 거쳐야 합니다 +- runFlow: ../subflows/launch-logged-in.yaml + +- tapOn: + id: profile_nav_graph +- assertVisible: + id: profile_nav_graph + selected: true +- extendedWaitUntil: + visible: "최근 30일 운동 횟수" + timeout: 30000 + +- tapOn: + id: mypage_edit_btn +- extendedWaitUntil: + visible: + id: profile_edit_nickname_field + timeout: 30000 + +# 원래 값을 보관합니다. 소개가 비어 있으면 힌트 문구가 대신 읽혀서 복구 값이 힌트 문구가 될 수 있습니다. +# 전용 계정은 소개를 비워 두지 않는다고 가정합니다(README에 명시) +- copyTextFrom: + id: profile_edit_nickname_field +- evalScript: ${output.originalNickname = maestro.copiedText} +- copyTextFrom: + id: profile_edit_introduction_field +- evalScript: ${output.originalIntroduction = maestro.copiedText} +- evalScript: ${output.newNickname = 'e2e615' + Date.now()} +- evalScript: ${output.newIntroduction = 'e2e615-intro-' + Date.now()} + +- tapOn: + id: profile_edit_nickname_field +- eraseText +- inputText: ${output.newNickname} +- tapOn: "중복 확인" +- extendedWaitUntil: + visible: "사용 가능한 닉네임입니다." + timeout: 15000 + +- tapOn: + id: profile_edit_introduction_field +- eraseText +- inputText: ${output.newIntroduction} + +- tapOn: "완료" +- extendedWaitUntil: + visible: "프로필 수정이 완료됐습니다." + timeout: 15000 + +# 저장 뒤 자동으로 마이페이지로 돌아옵니다. REFRESH_KEY로 다시 조회된 값을 대조합니다 +- extendedWaitUntil: + visible: + id: nickname_tv + text: ${output.newNickname} + timeout: 15000 +- assertVisible: + id: introduction_tv + text: ${output.newIntroduction} + +# 편집 화면을 다시 열어 서버에 실제로 반영됐는지 재확인합니다 +- tapOn: + id: mypage_edit_btn +- extendedWaitUntil: + visible: + id: profile_edit_nickname_field + text: ${output.newNickname} + timeout: 30000 +- assertVisible: + id: profile_edit_introduction_field + text: ${output.newIntroduction} + +# 복구: 원래 닉네임은 이번 세션 기준으로는 새 값이라 중복 확인을 다시 거쳐야 저장됩니다 +- tapOn: + id: profile_edit_nickname_field +- eraseText +- inputText: ${output.originalNickname} +- tapOn: "중복 확인" +- extendedWaitUntil: + visible: "사용 가능한 닉네임입니다." + timeout: 15000 + +- tapOn: + id: profile_edit_introduction_field +- eraseText +- inputText: ${output.originalIntroduction} + +- tapOn: "완료" +- extendedWaitUntil: + visible: "프로필 수정이 완료됐습니다." + timeout: 15000 + +- extendedWaitUntil: + visible: + id: nickname_tv + text: ${output.originalNickname} + timeout: 15000 +- assertVisible: + id: introduction_tv + text: ${output.originalIntroduction} diff --git a/.maestro/flows/profile-notification.yaml b/.maestro/flows/profile-notification.yaml new file mode 100644 index 00000000..9a074c0e --- /dev/null +++ b/.maestro/flows/profile-notification.yaml @@ -0,0 +1,121 @@ +appId: com.project200.undabang +name: 알림 설정 - 토글·권한 거부 구분과 복구 +tags: + - regression + - requires-login +--- +# 전용 계정의 운동·채팅 알림 스위치 기준 상태는 모두 꺼짐(OFF)이라고 가정합니다(README에 명시). +# NotificationViewModel.updateNotiStateByPermission은 권한이 없으면 서버에 쓰지 않고 화면 값만 +# 강제로 OFF로 보이게 합니다. 이 플로우는 먼저 켠 값을 서버에 실제로 저장해 두고, 권한을 거부한 채 +# 재진입했을 때도 화면은 OFF로 보이지만 서버 값은 그대로 켜져 있다는 것을 권한을 복구한 뒤 다시 읽어 증명합니다 +- runFlow: ../subflows/launch-logged-in.yaml + +- tapOn: + id: profile_nav_graph +- extendedWaitUntil: + visible: "최근 30일 운동 횟수" + timeout: 30000 +- tapOn: + id: setting_btn +- extendedWaitUntil: + visible: "설정" + timeout: 30000 +- tapOn: "알림" +- extendedWaitUntil: + visible: + id: exercise_noti_switch + timeout: 30000 + +# 두 스위치를 켭니다. 권한은 이미 허용된 상태라 실제로 서버에 저장됩니다 +- tapOn: + id: exercise_noti_switch +- assertVisible: + id: exercise_noti_switch + checked: true +- tapOn: + id: chatting_noti_switch +- assertVisible: + id: chatting_noti_switch + checked: true + +# 재진입해서 켠 상태가 저장됐는지 확인합니다(화면 재진입에서 저장 상태 확인) +- back +- tapOn: "알림" +- extendedWaitUntil: + visible: + id: exercise_noti_switch + checked: true + timeout: 15000 +- assertVisible: + id: chatting_noti_switch + checked: true + +# 알림 권한만 거부한 채로 같은 화면에 다시 들어갑니다(세션은 유지) +- runFlow: ../subflows/profile-notification-denied.yaml +- tapOn: + id: profile_nav_graph +- tapOn: + id: setting_btn +- extendedWaitUntil: + visible: "설정" + timeout: 30000 +- tapOn: "알림" + +# 권한이 없으면 서버 값과 무관하게 화면은 항상 OFF로 보입니다(NotificationViewModel.updateNotiStateByPermission) +- extendedWaitUntil: + visible: + id: exercise_noti_switch + checked: false + timeout: 15000 +- assertVisible: + id: chatting_noti_switch + checked: false +- assertVisible: "알림을 받으려면 앱 설정에서 권한을 허용해주세요." + +# 권한이 없어도 화면을 계속 쓸 수 있습니다 - 뒤로 가기로 빠져나가도 앱이 멈추지 않습니다 +- back +- assertVisible: "설정" + +# 권한을 되돌립니다. isInitialized가 false인 새 프로세스라 getNotificationState로 서버 값을 다시 읽습니다 +- runFlow: ../subflows/launch-logged-in.yaml +- tapOn: + id: profile_nav_graph +- tapOn: + id: setting_btn +- extendedWaitUntil: + visible: "설정" + timeout: 30000 +- tapOn: "알림" + +# 권한 거부 중에는 OFF로 보였지만, 서버에는 실제로 켜진 값이 그대로 남아 있었다는 증거입니다 +- extendedWaitUntil: + visible: + id: exercise_noti_switch + checked: true + timeout: 15000 +- assertVisible: + id: chatting_noti_switch + checked: true + +# 복구: 기준 상태인 꺼짐(OFF)으로 되돌립니다 +- tapOn: + id: exercise_noti_switch +- assertVisible: + id: exercise_noti_switch + checked: false +- tapOn: + id: chatting_noti_switch +- assertVisible: + id: chatting_noti_switch + checked: false + +- back +- tapOn: "알림" +- extendedWaitUntil: + visible: + id: exercise_noti_switch + checked: false + timeout: 15000 +- assertVisible: + id: chatting_noti_switch + checked: false diff --git a/.maestro/flows/profile-preferred-exercise.yaml b/.maestro/flows/profile-preferred-exercise.yaml new file mode 100644 index 00000000..7478af28 --- /dev/null +++ b/.maestro/flows/profile-preferred-exercise.yaml @@ -0,0 +1,173 @@ +appId: com.project200.undabang +name: 선호 운동 설정 - 추가·변경·검증과 복구 +tags: + - regression + - requires-login +--- +# 전용 계정은 선호 운동이 최소 1개 이상 저장돼 있다고 가정합니다(README에 명시). 완전 해제 상태는 +# PreferredExerciseViewModel.validateComplete가 저장을 막아서(NoneSelected) 복구할 수 있는 빈 상태가 +# 아니기 때문입니다. 이 플로우는 기존 선택을 건드리지 않고, 화면에 남은 미선택 종목 중 하나만 추가했다가 +# 끝에서 그 종목만 다시 지워 원래 목록으로 되돌립니다. +# +# 종목 이름은 서버 카탈로그 순서로 나와 계정마다 다르고 추가한 종목이 선택 목록의 몇 번째에 놓일지도 +# 알 수 없습니다. 그래서 testTag + selected로 종목을 고르고, 상세 단계의 요일/숙련도 버튼은 방금 읽어 둔 +# 종목 이름 텍스트 바로 아래(below)에 있는 버튼만 index로 골라 다른 카드와 섞이지 않게 합니다 +- runFlow: ../subflows/launch-logged-in.yaml + +- tapOn: + id: profile_nav_graph +- extendedWaitUntil: + visible: "최근 30일 운동 횟수" + timeout: 30000 + +- tapOn: + id: preferred_exercise_edit_btn +- extendedWaitUntil: + visible: + id: preferred_exercise_type_chip + timeout: 30000 + +- copyTextFrom: + id: preferred_exercise_type_chip + selected: false +- evalScript: ${output.exName = maestro.copiedText} + +- tapOn: + id: preferred_exercise_type_chip + selected: false +- assertVisible: + text: ${output.exName} + selected: true + +- tapOn: "다음" + +# 요일·숙련도가 비어 있는 채로 저장 시도 -> 최소 요일 조건에 막힙니다 +- tapOn: "완료" +- extendedWaitUntil: + visible: "요일과 숙련도를 모두 선택해주세요" + timeout: 10000 + +# 요일만 하나(월) 채우고 숙련도 없이 저장 시도 -> 여전히 막힙니다 +- tapOn: + id: preferred_exercise_day_btn + below: + text: ${output.exName} + index: 0 +- tapOn: "완료" +- extendedWaitUntil: + visible: "요일과 숙련도를 모두 선택해주세요" + timeout: 10000 + +# 숙련도까지 채우면 저장이 통과합니다 +- tapOn: + id: preferred_exercise_skill_btn + below: + text: ${output.exName} + index: 0 +- tapOn: "완료" +- extendedWaitUntil: + visible: "선호 운동이 저장되었습니다" + timeout: 15000 + +# 마이페이지 목록에 새 종목 이름이 보입니다 +- extendedWaitUntil: + visible: ${output.exName} + timeout: 15000 + +# 재진입해서 저장된 요일·숙련도가 유지되는지 확인합니다(저장 버튼 클릭만으로 통과시키지 않습니다) +- tapOn: + id: preferred_exercise_edit_btn +- extendedWaitUntil: + visible: + id: preferred_exercise_type_chip + timeout: 30000 +- assertVisible: + text: ${output.exName} + selected: true +- tapOn: "다음" +- assertVisible: + id: preferred_exercise_day_btn + below: + text: ${output.exName} + index: 0 + selected: true +- assertVisible: + id: preferred_exercise_skill_btn + below: + text: ${output.exName} + index: 0 + selected: true + +# 기존 설정 변경: 요일을 하나(수) 더 추가합니다 +- tapOn: + id: preferred_exercise_day_btn + below: + text: ${output.exName} + index: 2 +- tapOn: "완료" +- extendedWaitUntil: + visible: "선호 운동이 저장되었습니다" + timeout: 15000 + +# 일부 해제: 처음 선택했던 월요일만 해제합니다. 수요일이 남아 있어 완전 해제가 아니라 저장됩니다 +- tapOn: + id: preferred_exercise_edit_btn +- extendedWaitUntil: + visible: + id: preferred_exercise_type_chip + timeout: 30000 +- tapOn: "다음" +- tapOn: + id: preferred_exercise_day_btn + below: + text: ${output.exName} + index: 0 +- tapOn: "완료" +- extendedWaitUntil: + visible: "선호 운동이 저장되었습니다" + timeout: 15000 + +# 최소 한 종목 검증: 화면에 선택된 모든 종목(기존 것 포함)을 해제하면 저장이 막힙니다. +# 전체 해제 저장은 차단된 동작이라 성공 기준으로 보지 않습니다 — 막히는지만 확인하고 저장하지 않고 빠져나갑니다 +- tapOn: + id: preferred_exercise_edit_btn +- extendedWaitUntil: + visible: + id: preferred_exercise_type_chip + timeout: 30000 +- repeat: + while: + visible: + id: preferred_exercise_type_chip + selected: true + commands: + - tapOn: + id: preferred_exercise_type_chip + selected: true +- tapOn: "다음" +- tapOn: "완료" +- extendedWaitUntil: + visible: "최소 하나의 운동을 선택해주세요" + timeout: 10000 +# 저장하지 않고 두 번 뒤로 가 로컬 변경(전체 해제 포함)을 버립니다. 서버 값은 그대로입니다 +- back +- back + +# 정리: 우리가 추가한 종목만 다시 해제해 저장합니다. 기존 베이스라인 종목은 건드리지 않습니다 +- tapOn: + id: preferred_exercise_edit_btn +- extendedWaitUntil: + visible: + id: preferred_exercise_type_chip + timeout: 30000 +- tapOn: + text: ${output.exName} + selected: true +- tapOn: "다음" +- tapOn: "완료" +- extendedWaitUntil: + visible: "선호 운동이 저장되었습니다" + timeout: 15000 +- extendedWaitUntil: + notVisible: ${output.exName} + timeout: 15000 diff --git a/.maestro/subflows/profile-notification-denied.yaml b/.maestro/subflows/profile-notification-denied.yaml new file mode 100644 index 00000000..d06411a8 --- /dev/null +++ b/.maestro/subflows/profile-notification-denied.yaml @@ -0,0 +1,17 @@ +appId: com.project200.undabang +--- +# POST_NOTIFICATIONS만 거부한 채로 앱을 다시 띄웁니다. launch-logged-in은 notifications: allow를 미리 주기 때문에 +# 권한 거부 경로를 보려면 이 서브플로우로 전제를 깹니다. clearState는 쓰지 않습니다 — 세션과 Room 캐시는 유지하고 +# adb 권한 상태만 바꿉니다(pm revoke와 동일) +- launchApp: + clearState: false + permissions: + all: allow + notifications: deny + location: allow + +# 콜드 스타트는 아니지만 재기동이라 스플래시를 다시 거칩니다 +- extendedWaitUntil: + visible: + id: matching_nav_graph + timeout: 60000 diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt index 8772c6f3..dbc98eda 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt @@ -31,12 +31,16 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage @@ -60,6 +64,7 @@ import com.project200.undabang.profile.mypage.ProfileEditFragment.Companion.MALE import com.project200.undabang.profile.utils.NicknameValidationState import com.project200.undabang.presentation.R as PresentationR +@OptIn(ExperimentalComposeUiApi::class) @Composable fun ProfileEditScreen( nickname: String, @@ -82,7 +87,9 @@ fun ProfileEditScreen( modifier = modifier .fillMaxSize() - .background(ColorWhite300), + .background(ColorWhite300) + // Maestro id 셀렉터가 이 화면 하위 testTag를 android:id처럼 찾게 합니다 + .semantics { testTagsAsResourceId = true }, ) { UndabangTopBar( title = stringResource(R.string.profile_edit), @@ -203,7 +210,8 @@ private fun NicknameSection( UndabangTextField( value = nickname, onValueChange = onNicknameChange, - modifier = Modifier.weight(1f), + // Maestro가 중복 확인 전후 상태를 읽고 닉네임을 다시 입력할 지점 + modifier = Modifier.weight(1f).testTag("profile_edit_nickname_field"), variant = TextFieldVariant.Underline, maxLength = 30, ) @@ -364,7 +372,9 @@ private fun IntroductionSection( modifier = Modifier .fillMaxWidth() - .defaultMinSize(minHeight = 120.dp), + .defaultMinSize(minHeight = 120.dp) + // 값이 비어 있으면 힌트 텍스트가 대신 보입니다. copyTextFrom이 힌트를 읽을 수 있다는 점을 README에 남깁니다 + .testTag("profile_edit_introduction_field"), ) } } diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt index 73a81701..77709778 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt @@ -28,12 +28,17 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -65,6 +70,7 @@ enum class PreferredExerciseStep { DETAIL, } +@OptIn(ExperimentalComposeUiApi::class) @Composable fun PreferredExerciseScreen( step: PreferredExerciseStep, @@ -81,7 +87,13 @@ fun PreferredExerciseScreen( onSkillClick: (exerciseTypeId: Long, skill: SkillLevel) -> Unit, modifier: Modifier = Modifier, ) { - Box(modifier = modifier.fillMaxSize()) { + Box( + modifier = + modifier + .fillMaxSize() + // Maestro id 셀렉터가 이 화면 하위 testTag를 android:id처럼 찾게 합니다 + .semantics { testTagsAsResourceId = true }, + ) { Column( modifier = Modifier @@ -201,6 +213,9 @@ private fun PreferredExerciseTypeChip( indication = null, onClick = onClick, ) + // Maestro가 미선택/선택 칩을 selected 속성으로 구분해 고릅니다. 종목 목록이 계정마다 달라 이름을 미리 고정할 수 없습니다 + .semantics { selected = uiModel.isSelected } + .testTag("preferred_exercise_type_chip") .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -326,6 +341,7 @@ private fun PreferredExerciseDetailCard( text = stringResource(resId), selected = uiModel.selectedDays[index], onClick = { onDayClick(index) }, + testTag = "preferred_exercise_day_btn", modifier = Modifier.weight(1f).padding(5.dp).height(40.dp), ) } @@ -349,6 +365,7 @@ private fun PreferredExerciseDetailCard( text = stringResource(skill.resId), selected = uiModel.skillLevel == skill, onClick = { onSkillClick(skill) }, + testTag = "preferred_exercise_skill_btn", modifier = Modifier.weight(1f).padding(5.dp).height(36.dp), ) } @@ -367,6 +384,7 @@ private fun DayOrSkillButton( text: String, selected: Boolean, onClick: () -> Unit, + testTag: String, modifier: Modifier = Modifier, ) { val backgroundColor = if (selected) ColorMain else ColorGray300 @@ -377,6 +395,9 @@ private fun DayOrSkillButton( modifier .clip(RoundedCornerShape(8.dp)) .background(backgroundColor) + // 요일/숙련도 버튼을 같은 모양으로 공유해 호출부에서 구분용 testTag를 넘깁니다 + .semantics { this.selected = selected } + .testTag(testTag) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt index b1a24bb4..8f0a6e17 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt @@ -15,8 +15,12 @@ import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.project200.presentation.compose.components.layout.UndabangScaffold @@ -29,6 +33,7 @@ import com.project200.presentation.compose.theme.ColorWhite300 import com.project200.presentation.compose.theme.contentRegular import com.project200.undabang.feature.profile.R +@OptIn(ExperimentalComposeUiApi::class) @Composable fun NotificationScreen( isExerciseOn: Boolean, @@ -39,7 +44,8 @@ fun NotificationScreen( modifier: Modifier = Modifier, ) { UndabangScaffold( - modifier = modifier, + // Maestro id 셀렉터가 이 화면 하위 testTag를 android:id처럼 찾게 합니다 + modifier = modifier.semantics { testTagsAsResourceId = true }, topBar = { UndabangTopBar( title = stringResource(R.string.notification), @@ -57,11 +63,13 @@ fun NotificationScreen( labelRes = R.string.exercise_notification, checked = isExerciseOn, onCheckedChange = onExerciseToggle, + testTag = "exercise_noti_switch", ) NotificationSwitchRow( labelRes = R.string.chatting_notification, checked = isChattingOn, onCheckedChange = onChattingToggle, + testTag = "chatting_noti_switch", ) } } @@ -72,6 +80,7 @@ private fun NotificationSwitchRow( @StringRes labelRes: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit, + testTag: String, modifier: Modifier = Modifier, ) { Column(modifier = modifier.fillMaxWidth()) { @@ -92,6 +101,8 @@ private fun NotificationSwitchRow( Switch( checked = checked, onCheckedChange = onCheckedChange, + // 운동/채팅 알림 스위치를 구분해 토글·상태 검증에 씁니다 + modifier = Modifier.testTag(testTag), colors = SwitchDefaults.colors( checkedThumbColor = ColorWhite300, From 90a3e77ed783fd90e392c92ce72504d6d05ee284 Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:54:40 +0900 Subject: [PATCH 02/14] =?UTF-8?q?fix(maestro):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20CI=EC=99=80=20=EC=9B=90=EB=B3=B8=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B3=B5=EA=B5=AC=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .maestro/README-profile.md | 61 ++++ .maestro/README.md | 11 - .maestro/config.yaml | 4 - .maestro/flows/profile-edit-validation.yaml | 25 +- .maestro/flows/profile-edit.yaml | 104 ++++--- .maestro/flows/profile-notification.yaml | 84 +++--- .../flows/profile-preferred-exercise.yaml | 215 +++++++------- .../subflows/profile-notification-denied.yaml | 7 +- .../subflows/profile-notification-ready.yaml | 7 + .../subflows/profile-open-preferences.yaml | 23 ++ .../subflows/profile-preference-detail.yaml | 12 + .maestro/suites/profile.json | 12 + .../undabang/e2e/MaestroProfileFixture.kt | 222 +++++++++++++++ .../e2e/MaestroProfileFixtureModule.kt | 17 ++ .../MaestroProfileNotificationReadiness.kt | 81 ++++++ .../undabang/e2e/MaestroProfilePolicy.kt | 77 +++++ .../undabang/e2e/MaestroProfileRecovery.kt | 126 +++++++++ ...MaestroProfileNotificationReadinessTest.kt | 214 ++++++++++++++ .../undabang/e2e/MaestroProfilePolicyTest.kt | 71 +++++ .../e2e/MaestroProfileRecoveryTest.kt | 259 +++++++++++++++++ .../profile/mypage/ProfileEditFragment.kt | 2 + .../profile/mypage/ProfileEditScreen.kt | 13 +- .../profile/mypage/ProfileEditViewModel.kt | 6 + .../PreferredExerciseFragment.kt | 3 +- .../PreferredExerciseScreen.kt | 46 +-- .../PreferredExerciseViewModel.kt | 5 + .../profile/setting/NotificationFragment.kt | 4 + .../profile/setting/NotificationScreen.kt | 48 +++- .../profile/setting/NotificationViewModel.kt | 161 ++++++----- .../profile/src/main/res/values/strings.xml | 1 + .../mypage/ProfileEditViewModelTest.kt | 13 + .../PreferredExerciseViewModelTest.kt | 16 ++ .../setting/NotificationViewModelTest.kt | 266 +++++++----------- 33 files changed, 1720 insertions(+), 496 deletions(-) create mode 100644 .maestro/README-profile.md create mode 100644 .maestro/subflows/profile-notification-ready.yaml create mode 100644 .maestro/subflows/profile-open-preferences.yaml create mode 100644 .maestro/subflows/profile-preference-detail.yaml create mode 100644 .maestro/suites/profile.json create mode 100644 app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileFixture.kt create mode 100644 app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileFixtureModule.kt create mode 100644 app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileNotificationReadiness.kt create mode 100644 app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt create mode 100644 app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileRecovery.kt create mode 100644 app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileNotificationReadinessTest.kt create mode 100644 app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt create mode 100644 app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileRecoveryTest.kt diff --git a/.maestro/README-profile.md b/.maestro/README-profile.md new file mode 100644 index 00000000..7dd7cbbc --- /dev/null +++ b/.maestro/README-profile.md @@ -0,0 +1,61 @@ +# Profile Maestro CI + +`.maestro/suites/profile.json` registers four real authenticated flows: + +| Flow | Required behavior | +| --- | --- | +| `profile-edit` | Check nickname availability, save nickname/bio, read persisted values, restore through the UI, and read the original values. | +| `profile-edit-validation` | Reject unchecked nicknames, invalid input, and a nickname changed after availability confirmation. No profile write is permitted. | +| `profile-preferred-exercise` | Reject incomplete/empty selections, create one real catalog preference, persist Monday/BEGINNER, add Wednesday, remove Monday, verify both changes after re-entry, and delete only the added preference. | +| `profile-notification` | Persist OFF→ON, deny the actual Android notification permission, show OFF without writing server settings, grant permission, read the persisted ON values, and persist OFF again. | + +The shared runner selects this descriptor with `--suite profile`; `--suite ci` discovers it automatically. The descriptor requires **API33 or newer** and `fixture: true`. The complete denied-permission branch is mandatory. API30 cannot exercise this behavior. Use the shared runner's disposable CI emulator, installed E2E APK, required ABI, real DEV/Firebase configuration, and existing private authentication bootstrap. Do not invoke these YAML files directly against a personal account. + +## Real prerequisites + +The existing `MAESTRO_AUTH_STATE_JSON`, `MAESTRO_MEMBER_ID`, and `MAESTRO_FIXTURE_START_DATE` inputs are required by the shared lifecycle. There are no additional profile secrets, fake FCM tokens, or fixed exercise IDs. + +Preparation reads the authenticated member's real profile and preference records before any feature write. Only `profile-notification` requires device registration and notification settings. Any unsuccessful response or missing required data fails preparation. + +- The original nickname must match `[A-Za-z0-9]{1,30}`. The original gender must be one of the app's `MALE`, `FEMALE`, or `UNKNOWN` values. +- The bio must be non-null, nonempty, at most 4096 characters, and match `[A-Za-z0-9][A-Za-z0-9 _-]*`. These explicit restrictions permit exact Android `inputText` restoration. Null, Unicode, punctuation outside that set, and control characters fail **before mutation**. The fixture does not replace the account baseline with test text. +- Every original preference must have a positive unique record ID/type ID, seven day booleans with at least one true day, and a recognized skill. The preference scenario requires 1–4 original selections and at least one real unselected catalog type. Preparation obtains its ID/name from the real catalog; the flow never chooses an arbitrary offscreen chip. +- For `profile-notification`, the fixture waits for the real saved FCM token, registers that token, and reads the actual device settings. The response must contain exactly one `WORKOUT_REMINDER` and one `CHAT_MESSAGE` row. Missing rows fail; the fixture never synthesizes OFF/OFF defaults. +- Notification values need not initially be OFF. Preparation snapshots the real values before its notification PATCH. It sets only the two UI-owned types to OFF and verifies that readback. Additional server notification type names and values remain intact. + +After `pm clear`, Firebase token creation is asynchronous. `MaestroProfileNotificationReadiness` supplies the registration barrier inside authenticated feature preparation: + +1. Wait at most 60 seconds for a nonblank `FcmRepository.getSavedToken()`, checking every 250 milliseconds. Firebase's existing startup initialization and `FcmService.onNewToken` save the real token in encrypted preferences. The fixture does not create or write tokens. +2. Call the real `FcmRepository.registerToken()` once, with a 30-second limit. This uses the existing authenticated `POST /login` and `X-Fcm-Token` header. Only `BaseResult.Success` permits continuation; an error, exception, or timeout fails preparation. +3. Require the saved token to remain unchanged. Read actual settings with a 30-second limit, validate the required rows, and check the token again. +4. Save the original settings and token in the private recovery snapshot before the first feature PATCH. No profile or preference mutation can occur before preparation returns. + +Only absent or blank tokens are polled. Storage failures, registration failures, invalid rows, and token rotations are not retried. Fixed errors include `profile_fcm_token_timeout`, `profile_fcm_storage_failed`, `profile_fcm_registration_failed`, `profile_fcm_registration_timeout`, `profile_fcm_settings_timeout`, and `profile_fcm_identity_changed`. No error includes a token or backend exception body. The E2E application does not install the normal debug Timber tree. + +The fixture does not wait for `MainViewModel` or the normal registration worker. Firebase initialization occurs at application startup; the manifest retains the real messaging service and does not disable auto-initialization. No new Firebase API or manual persistence path is used. The other three profile flows never open notification settings and do not require, read, register, or restore notification state in new runs. + +CI still needs valid `GOOGLE_SERVICES_JSON_DEV_BASE64` for `com.project200.undabang`, working Google Play services on the API33+ `google_apis` emulator, network access to Firebase and DEV APIs, and valid dedicated-account authentication. Source inspection cannot establish these external conditions. If Firebase cannot supply a token within the limit, preparation fails before feature mutation. Initial device registration can create real server device settings; their returned values, not assumed defaults, become the notification baseline. This suite does not redesign authentication or remove backend device-registration records. + +The host serializes the dedicated account. Run IDs are the shared 16-character ASCII hex `E2E_RUN_ID`. Generated nickname/bio values and the chosen catalog ID are internal nonsecret Maestro variables. Credentials and the actual FCM token never become Maestro command-line variables. + +## Failure recovery + +The E2E-only Hilt map contributes `MaestroProfileFixture` under `profile`. It consumes the shared `MaestroFeatureRequest` and returns only safe string variables. Before its first feature write, it atomically stores `files/maestro-profile-recovery.json` in app-private storage. The record contains member/run/flow identity, original nickname/gender/bio, and exact preference IDs/type IDs/skills/days. The notification flow also stores every raw notification type/value and the original FCM device identity. New non-notification snapshots store explicit JSON nulls for both notification fields, not invented settings or tokens. The record contains no raw authentication state. + +The common `finally` calls feature cleanup **before** session cleanup or app-data clear, including when preparation or a UI assertion fails. Cleanup attempts profile, preference, and each recorded notification recovery independently. It uses real PUT/PATCH/DELETE calls and successful GET readbacks: + +1. Restore the original profile tuple. Writes outside the scenario's owned nickname/bio values fail rather than overwrite unrelated data. Validation-flow writes and unintended gender changes remain failures even if recovery succeeds. +2. Delete only the record for the run's previously absent catalog type. Restore altered original values with PATCH while keeping their original IDs. Missing/replaced IDs and unrelated additions fail; the fixture never wipes or recreates baseline preferences. Unintended original-record changes remain test failures after restoration. +3. For a recorded notification snapshot, restore values on the **same saved FCM token**, preserving raw unknown type names. Changed device identity, missing rows, unrelated unknown-type changes, unsuccessful writes, or mismatched readbacks fail. Existing snapshots with notification data retain this exact recovery even for another flow. Only explicitly absent notification data in a non-notification flow skips this area; missing data for `profile-notification` fails. + +The fixture keeps its snapshot after successful feature restoration. The host's final successful `pm clear` removes it only after feature restoration, exercise cleanup, and session clear succeed. If a later shared cleanup step fails, the snapshot remains available for a retry with the exact same member/run/flow identity. Repeated restoration reads the original profile, preference IDs, and notification values without rewriting an already-restored baseline or registering another FCM identity. Any restoration failure still fails the flow and preserves recovery metadata. The shared recovery marker prevents app-data clear and the next flow from destroying that metadata. Do not upload this file, manually clear app data, or substitute a new device token to conceal a failed restoration. Force-stopping the app before bootstrap prevents live UI coroutines from issuing later local writes; remote server commit timing still requires real CI observation. + +Android permission changes use a disposable emulator policy: the flow grants the permission again on its normal path, and the shared runner clears the disposable installation after successful backend cleanup. Reusable devices are not supported by this suite's permission-reset policy. No server setting is inferred from the Android permission state. + +## Observable UI state and tests + +Profile fields put test tags on `UndabangTextField.inputModifier`, not its layout wrapper. `profile_edit_loaded` appears only after a successful profile read. Preference screens expose loaded TYPE/DETAIL states, use exercise-type-scoped chip/day/skill IDs, and scroll to each target explicitly. The empty-selection check also reaches originally selected chips outside the viewport. Notification switches are hidden on read errors, disabled while saving, and exposed as `notification_ready` only after required rows and PATCH readback succeed. Permission denial has a persistent OS-derived explanation as well as the existing toast. + +Behavioral coverage is in profile ViewModel tests and `app/src/testE2e/.../MaestroProfilePolicyTest.kt`, `MaestroProfileRecoveryTest.kt`, and `MaestroProfileNotificationReadinessTest.kt`. The readiness tests cover delayed callback storage, the timeout boundary, absent/blank tokens, backend errors/timeouts, token rotation, missing rows, cancellation, and notification-only scope. The recovery tests cover original-ID preservation, exact owned deletion, raw additional notification types, backend read errors, wrong device identity, failed readback, and explicit notification absence. They retain cross-flow recovery checks for existing notification snapshots. Additional tests call restoration twice with the same baseline to cover profile, preference, and notification idempotence, plus rejection of token rotation before a retry. These are unit-test doubles only; the actual fixture and all four Maestro scenarios call the real backend. + +No build, unit test, lint, emulator, Firebase registration, or Maestro result is established by this document. The controller must run the integrated E2E build, profile/ViewModel/E2E unit tests, and the API33+ authenticated suite in approved CI. Actual account baselines, FCM registration, and remote flow results are runtime prerequisites, not source-inspection evidence. diff --git a/.maestro/README.md b/.maestro/README.md index f03ac6f1..efbfd090 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -121,17 +121,6 @@ UI가 생성·수정·삭제를 수행합니다. bootstrap은 테스트를 통 |`flows/exercise-form-entry.yaml`|`regression`, `requires-login`|`regression`, `all`|실제 기록 폼 진입과 필드 표시| |`flows/exercise-crud.yaml`|`regression`, `requires-login`|`regression`, `all`|필수값 누락, UI 생성/조회/수정, 재실행 후 영속성, UI 삭제와 전체 목록 부재 검사| |`flows/login-screen.yaml`|`smoke`|`login`, `smoke`, `all`|clearState 뒤 실제 로그인 화면/버튼| - -### 기능별 확장 플로우 - -|파일|태그|검증| -|---|---|---| -|`flows/profile-edit.yaml`|`regression`, `requires-login`|닉네임·소개 수정, 재진입 확인, 원래 값 복구| -|`flows/profile-edit-validation.yaml`|`regression`, `requires-login`|닉네임 중복 확인 누락과 유효하지 않은 값 저장 방지| -|`flows/profile-preferred-exercise.yaml`|`regression`, `requires-login`|운동 종목·요일·숙련도 저장, 변경·일부 해제·복구| -|`flows/profile-notification.yaml`|`regression`, `requires-login`|운동·채팅 알림 저장, 재진입, OS 권한 거부 상태| - -프로필 플로우는 전용 계정 설정을 변경하므로 각 플로우에서 원래 상태를 복구합니다. 선호 운동과 알림 상태를 완전히 읽을 수 없는 항목은 전용 계정 기준 상태를 명시해 실행해야 합니다. `login-screen`은 세션을 지우므로 직접 Maestro 전체 실행의 `config.yaml`에서도 마지막입니다. 호스트 러너는 앞 플로우의 로그인 상태에 의존하지 않고 매번 복원합니다. 직접 실행한 뒤에는 다시 수동 로그인해야 합니다. diff --git a/.maestro/config.yaml b/.maestro/config.yaml index dbb898d4..7d7f5619 100644 --- a/.maestro/config.yaml +++ b/.maestro/config.yaml @@ -9,10 +9,6 @@ executionOrder: - smoke-tabs - exercise-form-entry - exercise-crud - - profile-edit - - profile-edit-validation - - profile-preferred-exercise - - profile-notification - login-screen testOutputDir: build/maestro diff --git a/.maestro/flows/profile-edit-validation.yaml b/.maestro/flows/profile-edit-validation.yaml index 936d14fc..8d74d49b 100644 --- a/.maestro/flows/profile-edit-validation.yaml +++ b/.maestro/flows/profile-edit-validation.yaml @@ -4,8 +4,7 @@ tags: - regression - requires-login --- -# 세 가지 저장 방지 경로를 봅니다. 셋 다 ProfileEditViewModel이 저장 API를 호출하지 않고 -# 토스트만 띄우므로, 이 플로우는 끝까지 한 번도 저장하지 않습니다 — 복구할 서버 상태가 없습니다 +# 세 가지 차단 경로 모두 저장하지 않아야 합니다. finally가 실제 원본과 대조하고 복구합니다. - runFlow: ../subflows/launch-logged-in.yaml - tapOn: @@ -18,17 +17,25 @@ tags: id: mypage_edit_btn - extendedWaitUntil: visible: - id: profile_edit_nickname_field + id: profile_edit_loaded timeout: 30000 -- evalScript: ${output.nickA = 'e2e615a' + Date.now()} -- evalScript: ${output.nickB = 'e2e615b' + Date.now()} +- scrollUntilVisible: + element: + id: profile_edit_nickname_field + direction: DOWN +- assertVisible: + id: profile_edit_nickname_field + text: ${E2E_PROFILE_ORIGINAL_NICKNAME} +- evalScript: ${output.nickA = 'e2ea' + E2E_RUN_ID} +- evalScript: ${output.nickB = 'e2eb' + E2E_RUN_ID} # 1) 중복 확인 없이 완료 -> 저장이 막힙니다 - tapOn: id: profile_edit_nickname_field - eraseText - inputText: ${output.nickA} +- hideKeyboard - tapOn: "완료" - extendedWaitUntil: visible: "닉네임 중복 확인을 해주세요." @@ -40,6 +47,7 @@ tags: id: profile_edit_nickname_field - eraseText - inputText: "e2e615!invalid" +- hideKeyboard - tapOn: "중복 확인" - assertVisible: "닉네임 조건을 확인해주세요." - tapOn: "완료" @@ -52,6 +60,7 @@ tags: id: profile_edit_nickname_field - eraseText - inputText: ${output.nickA} +- hideKeyboard - tapOn: "중복 확인" - extendedWaitUntil: visible: "사용 가능한 닉네임입니다." @@ -61,6 +70,7 @@ tags: id: profile_edit_nickname_field - eraseText - inputText: ${output.nickB} +- hideKeyboard # updateNickname이 값이 바뀔 때마다 isNicknameChecked를 초기화합니다. 중복 확인 문구가 사라졌는지 확인합니다 - assertNotVisible: "사용 가능한 닉네임입니다." - tapOn: "완료" @@ -70,3 +80,8 @@ tags: # 세 경로 모두 저장되지 않았으므로 로컬 입력만 버리고 나갑니다 - back +- extendedWaitUntil: + visible: + id: nickname_tv + text: ${E2E_PROFILE_ORIGINAL_NICKNAME} + timeout: 15000 diff --git a/.maestro/flows/profile-edit.yaml b/.maestro/flows/profile-edit.yaml index 15f92255..41cee5d6 100644 --- a/.maestro/flows/profile-edit.yaml +++ b/.maestro/flows/profile-edit.yaml @@ -4,104 +4,142 @@ tags: - regression - requires-login --- -# 닉네임 중복 확인과 소개 변경을 저장한 뒤 마이페이지와 편집 화면 재진입으로 대조하고, -# 끝에서 원래 값으로 되돌립니다. ProfileEditViewModel.checkProfileChanged는 저장 전 중복 확인을 -# 요구하므로(NO_DUPLICATE_CHECKED) 복구 역시 실제 저장 경로를 한 번 더 거쳐야 합니다 +# 준비 API가 읽은 원문과 로드된 입력 노드가 일치한 뒤에만 수정합니다. +# 실패 복구는 공통 finally의 profile fixture가 실제 API로 수행합니다. - runFlow: ../subflows/launch-logged-in.yaml - - tapOn: id: profile_nav_graph -- assertVisible: - id: profile_nav_graph - selected: true - extendedWaitUntil: visible: "최근 30일 운동 횟수" timeout: 30000 - - tapOn: id: mypage_edit_btn - extendedWaitUntil: visible: - id: profile_edit_nickname_field + id: profile_edit_loaded timeout: 30000 - -# 원래 값을 보관합니다. 소개가 비어 있으면 힌트 문구가 대신 읽혀서 복구 값이 힌트 문구가 될 수 있습니다. -# 전용 계정은 소개를 비워 두지 않는다고 가정합니다(README에 명시) +- scrollUntilVisible: + element: + id: profile_edit_nickname_field + direction: DOWN +- assertVisible: + id: profile_edit_nickname_field + text: ${E2E_PROFILE_ORIGINAL_NICKNAME} - copyTextFrom: id: profile_edit_nickname_field - evalScript: ${output.originalNickname = maestro.copiedText} +- assertTrue: ${output.originalNickname === E2E_PROFILE_ORIGINAL_NICKNAME} +- scrollUntilVisible: + element: + id: profile_edit_introduction_field + direction: DOWN +- assertVisible: + id: profile_edit_introduction_field + text: ${E2E_PROFILE_ORIGINAL_BIO} - copyTextFrom: id: profile_edit_introduction_field - evalScript: ${output.originalIntroduction = maestro.copiedText} -- evalScript: ${output.newNickname = 'e2e615' + Date.now()} -- evalScript: ${output.newIntroduction = 'e2e615-intro-' + Date.now()} - +- assertTrue: ${output.originalIntroduction === E2E_PROFILE_ORIGINAL_BIO} +- scrollUntilVisible: + element: + id: profile_edit_nickname_field + direction: UP - tapOn: id: profile_edit_nickname_field - eraseText -- inputText: ${output.newNickname} +- inputText: ${E2E_PROFILE_NEW_NICKNAME} +- hideKeyboard - tapOn: "중복 확인" - extendedWaitUntil: visible: "사용 가능한 닉네임입니다." timeout: 15000 - +- scrollUntilVisible: + element: + id: profile_edit_introduction_field + direction: DOWN - tapOn: id: profile_edit_introduction_field - eraseText -- inputText: ${output.newIntroduction} - +- inputText: ${E2E_PROFILE_NEW_BIO} +- hideKeyboard - tapOn: "완료" - extendedWaitUntil: visible: "프로필 수정이 완료됐습니다." timeout: 15000 - -# 저장 뒤 자동으로 마이페이지로 돌아옵니다. REFRESH_KEY로 다시 조회된 값을 대조합니다 - extendedWaitUntil: visible: id: nickname_tv - text: ${output.newNickname} + text: ${E2E_PROFILE_NEW_NICKNAME} timeout: 15000 +- scrollUntilVisible: + element: + id: introduction_tv + text: ${E2E_PROFILE_NEW_BIO} + direction: DOWN - assertVisible: id: introduction_tv - text: ${output.newIntroduction} - -# 편집 화면을 다시 열어 서버에 실제로 반영됐는지 재확인합니다 + text: ${E2E_PROFILE_NEW_BIO} +- scrollUntilVisible: + element: + id: mypage_edit_btn + direction: UP - tapOn: id: mypage_edit_btn - extendedWaitUntil: visible: - id: profile_edit_nickname_field - text: ${output.newNickname} + id: profile_edit_loaded timeout: 30000 +- scrollUntilVisible: + element: + id: profile_edit_nickname_field + direction: DOWN +- assertVisible: + id: profile_edit_nickname_field + text: ${E2E_PROFILE_NEW_NICKNAME} +- scrollUntilVisible: + element: + id: profile_edit_introduction_field + direction: DOWN - assertVisible: id: profile_edit_introduction_field - text: ${output.newIntroduction} - -# 복구: 원래 닉네임은 이번 세션 기준으로는 새 값이라 중복 확인을 다시 거쳐야 저장됩니다 + text: ${E2E_PROFILE_NEW_BIO} +# UI 복구도 저장·재조회 경로를 반드시 통과합니다. API finally 복구의 대체물이 아닙니다. +- scrollUntilVisible: + element: + id: profile_edit_nickname_field + direction: UP - tapOn: id: profile_edit_nickname_field - eraseText - inputText: ${output.originalNickname} +- hideKeyboard - tapOn: "중복 확인" - extendedWaitUntil: visible: "사용 가능한 닉네임입니다." timeout: 15000 - +- scrollUntilVisible: + element: + id: profile_edit_introduction_field + direction: DOWN - tapOn: id: profile_edit_introduction_field - eraseText - inputText: ${output.originalIntroduction} - +- hideKeyboard - tapOn: "완료" - extendedWaitUntil: visible: "프로필 수정이 완료됐습니다." timeout: 15000 - - extendedWaitUntil: visible: id: nickname_tv text: ${output.originalNickname} timeout: 15000 +- scrollUntilVisible: + element: + id: introduction_tv + text: ${output.originalIntroduction} + direction: DOWN - assertVisible: id: introduction_tv text: ${output.originalIntroduction} diff --git a/.maestro/flows/profile-notification.yaml b/.maestro/flows/profile-notification.yaml index 9a074c0e..5d0f82aa 100644 --- a/.maestro/flows/profile-notification.yaml +++ b/.maestro/flows/profile-notification.yaml @@ -4,12 +4,9 @@ tags: - regression - requires-login --- -# 전용 계정의 운동·채팅 알림 스위치 기준 상태는 모두 꺼짐(OFF)이라고 가정합니다(README에 명시). -# NotificationViewModel.updateNotiStateByPermission은 권한이 없으면 서버에 쓰지 않고 화면 값만 -# 강제로 OFF로 보이게 합니다. 이 플로우는 먼저 켠 값을 서버에 실제로 저장해 두고, 권한을 거부한 채 -# 재진입했을 때도 화면은 OFF로 보이지만 서버 값은 그대로 켜져 있다는 것을 권한을 복구한 뒤 다시 읽어 증명합니다 +# fixture가 실제 기기 설정 원본을 저장하고 두 known type만 OFF로 준비합니다. +# finally는 원래 ON/OFF와 알 수 없는 서버 type까지 같은 FCM 기기에 복구합니다. - runFlow: ../subflows/launch-logged-in.yaml - - tapOn: id: profile_nav_graph - extendedWaitUntil: @@ -21,36 +18,38 @@ tags: visible: "설정" timeout: 30000 - tapOn: "알림" -- extendedWaitUntil: - visible: - id: exercise_noti_switch - timeout: 30000 - -# 두 스위치를 켭니다. 권한은 이미 허용된 상태라 실제로 서버에 저장됩니다 +- runFlow: ../subflows/profile-notification-ready.yaml +- assertNotVisible: + id: notification_permission_denied +- assertVisible: + id: exercise_noti_switch + checked: false +- assertVisible: + id: chatting_noti_switch + checked: false +# 각 PATCH와 GET 완료를 기다려 전체 상태를 포함한 동시 쓰기를 막습니다. - tapOn: id: exercise_noti_switch +- runFlow: ../subflows/profile-notification-ready.yaml - assertVisible: id: exercise_noti_switch checked: true - tapOn: id: chatting_noti_switch +- runFlow: ../subflows/profile-notification-ready.yaml - assertVisible: id: chatting_noti_switch checked: true - -# 재진입해서 켠 상태가 저장됐는지 확인합니다(화면 재진입에서 저장 상태 확인) - back - tapOn: "알림" -- extendedWaitUntil: - visible: - id: exercise_noti_switch - checked: true - timeout: 15000 +- runFlow: ../subflows/profile-notification-ready.yaml +- assertVisible: + id: exercise_noti_switch + checked: true - assertVisible: id: chatting_noti_switch checked: true - -# 알림 권한만 거부한 채로 같은 화면에 다시 들어갑니다(세션은 유지) +# apiLevel33 descriptor에서 실제 POST_NOTIFICATIONS 권한을 거부합니다. - runFlow: ../subflows/profile-notification-denied.yaml - tapOn: id: profile_nav_graph @@ -60,23 +59,19 @@ tags: visible: "설정" timeout: 30000 - tapOn: "알림" - -# 권한이 없으면 서버 값과 무관하게 화면은 항상 OFF로 보입니다(NotificationViewModel.updateNotiStateByPermission) -- extendedWaitUntil: - visible: - id: exercise_noti_switch - checked: false - timeout: 15000 +- runFlow: ../subflows/profile-notification-ready.yaml +- assertVisible: + id: notification_permission_denied +- assertVisible: + id: exercise_noti_switch + checked: false - assertVisible: id: chatting_noti_switch checked: false - assertVisible: "알림을 받으려면 앱 설정에서 권한을 허용해주세요." - -# 권한이 없어도 화면을 계속 쓸 수 있습니다 - 뒤로 가기로 빠져나가도 앱이 멈추지 않습니다 - back - assertVisible: "설정" - -# 권한을 되돌립니다. isInitialized가 false인 새 프로세스라 getNotificationState로 서버 값을 다시 읽습니다 +# 권한만 허용하고 서버를 다시 읽어 ON이 유지됨을 확인합니다. - runFlow: ../subflows/launch-logged-in.yaml - tapOn: id: profile_nav_graph @@ -86,36 +81,33 @@ tags: visible: "설정" timeout: 30000 - tapOn: "알림" - -# 권한 거부 중에는 OFF로 보였지만, 서버에는 실제로 켜진 값이 그대로 남아 있었다는 증거입니다 -- extendedWaitUntil: - visible: - id: exercise_noti_switch - checked: true - timeout: 15000 +- runFlow: ../subflows/profile-notification-ready.yaml +- assertNotVisible: + id: notification_permission_denied +- assertVisible: + id: exercise_noti_switch + checked: true - assertVisible: id: chatting_noti_switch checked: true - -# 복구: 기준 상태인 꺼짐(OFF)으로 되돌립니다 - tapOn: id: exercise_noti_switch +- runFlow: ../subflows/profile-notification-ready.yaml - assertVisible: id: exercise_noti_switch checked: false - tapOn: id: chatting_noti_switch +- runFlow: ../subflows/profile-notification-ready.yaml - assertVisible: id: chatting_noti_switch checked: false - - back - tapOn: "알림" -- extendedWaitUntil: - visible: - id: exercise_noti_switch - checked: false - timeout: 15000 +- runFlow: ../subflows/profile-notification-ready.yaml +- assertVisible: + id: exercise_noti_switch + checked: false - assertVisible: id: chatting_noti_switch checked: false diff --git a/.maestro/flows/profile-preferred-exercise.yaml b/.maestro/flows/profile-preferred-exercise.yaml index 7478af28..0139ddf4 100644 --- a/.maestro/flows/profile-preferred-exercise.yaml +++ b/.maestro/flows/profile-preferred-exercise.yaml @@ -4,170 +4,167 @@ tags: - regression - requires-login --- -# 전용 계정은 선호 운동이 최소 1개 이상 저장돼 있다고 가정합니다(README에 명시). 완전 해제 상태는 -# PreferredExerciseViewModel.validateComplete가 저장을 막아서(NoneSelected) 복구할 수 있는 빈 상태가 -# 아니기 때문입니다. 이 플로우는 기존 선택을 건드리지 않고, 화면에 남은 미선택 종목 중 하나만 추가했다가 -# 끝에서 그 종목만 다시 지워 원래 목록으로 되돌립니다. -# -# 종목 이름은 서버 카탈로그 순서로 나와 계정마다 다르고 추가한 종목이 선택 목록의 몇 번째에 놓일지도 -# 알 수 없습니다. 그래서 testTag + selected로 종목을 고르고, 상세 단계의 요일/숙련도 버튼은 방금 읽어 둔 -# 종목 이름 텍스트 바로 아래(below)에 있는 버튼만 index로 골라 다른 카드와 섞이지 않게 합니다 +# API가 원본 ID를 보관하고 실제 미선택 catalog type 하나만 이번 실행에 배정합니다. - runFlow: ../subflows/launch-logged-in.yaml - - tapOn: id: profile_nav_graph - extendedWaitUntil: visible: "최근 30일 운동 횟수" timeout: 30000 - -- tapOn: - id: preferred_exercise_edit_btn -- extendedWaitUntil: - visible: - id: preferred_exercise_type_chip - timeout: 30000 - -- copyTextFrom: - id: preferred_exercise_type_chip +- runFlow: ../subflows/profile-open-preferences.yaml +- assertVisible: + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} selected: false -- evalScript: ${output.exName = maestro.copiedText} - - tapOn: - id: preferred_exercise_type_chip - selected: false + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} - assertVisible: - text: ${output.exName} + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} selected: true - -- tapOn: "다음" - -# 요일·숙련도가 비어 있는 채로 저장 시도 -> 최소 요일 조건에 막힙니다 +- runFlow: ../subflows/profile-preference-detail.yaml +# 요일과 숙련도 누락은 모두 실제 저장 검증에 막혀야 합니다. - tapOn: "완료" - extendedWaitUntil: visible: "요일과 숙련도를 모두 선택해주세요" timeout: 10000 - -# 요일만 하나(월) 채우고 숙련도 없이 저장 시도 -> 여전히 막힙니다 - tapOn: - id: preferred_exercise_day_btn - below: - text: ${output.exName} - index: 0 + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 - tapOn: "완료" - extendedWaitUntil: visible: "요일과 숙련도를 모두 선택해주세요" timeout: 10000 - -# 숙련도까지 채우면 저장이 통과합니다 +- scrollUntilVisible: + element: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER + direction: DOWN - tapOn: - id: preferred_exercise_skill_btn - below: - text: ${output.exName} - index: 0 + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER - tapOn: "완료" - extendedWaitUntil: visible: "선호 운동이 저장되었습니다" timeout: 15000 - -# 마이페이지 목록에 새 종목 이름이 보입니다 -- extendedWaitUntil: - visible: ${output.exName} +- scrollUntilVisible: + element: + text: ${E2E_PROFILE_ADDED_NAME} + direction: DOWN timeout: 15000 - -# 재진입해서 저장된 요일·숙련도가 유지되는지 확인합니다(저장 버튼 클릭만으로 통과시키지 않습니다) -- tapOn: - id: preferred_exercise_edit_btn -- extendedWaitUntil: - visible: - id: preferred_exercise_type_chip - timeout: 30000 +- assertVisible: ${E2E_PROFILE_ADDED_NAME} +# 첫 저장의 실제 재조회 결과입니다. +- runFlow: ../subflows/profile-open-preferences.yaml - assertVisible: - text: ${output.exName} + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} selected: true -- tapOn: "다음" +- runFlow: ../subflows/profile-preference-detail.yaml - assertVisible: - id: preferred_exercise_day_btn - below: - text: ${output.exName} - index: 0 + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 selected: true - assertVisible: - id: preferred_exercise_skill_btn - below: - text: ${output.exName} - index: 0 + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_2 + selected: false +- scrollUntilVisible: + element: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER + direction: DOWN +- assertVisible: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER selected: true - -# 기존 설정 변경: 요일을 하나(수) 더 추가합니다 +- scrollUntilVisible: + element: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_2 + direction: UP - tapOn: - id: preferred_exercise_day_btn - below: - text: ${output.exName} - index: 2 + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_2 - tapOn: "완료" - extendedWaitUntil: visible: "선호 운동이 저장되었습니다" timeout: 15000 - -# 일부 해제: 처음 선택했던 월요일만 해제합니다. 수요일이 남아 있어 완전 해제가 아니라 저장됩니다 -- tapOn: - id: preferred_exercise_edit_btn -- extendedWaitUntil: - visible: - id: preferred_exercise_type_chip - timeout: 30000 -- tapOn: "다음" +# 월+수 변경을 재조회로 확인한 뒤 월요일만 해제합니다. +- runFlow: ../subflows/profile-open-preferences.yaml +- runFlow: ../subflows/profile-preference-detail.yaml +- assertVisible: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 + selected: true +- assertVisible: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_2 + selected: true - tapOn: - id: preferred_exercise_day_btn - below: - text: ${output.exName} - index: 0 + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 - tapOn: "완료" - extendedWaitUntil: visible: "선호 운동이 저장되었습니다" timeout: 15000 - -# 최소 한 종목 검증: 화면에 선택된 모든 종목(기존 것 포함)을 해제하면 저장이 막힙니다. -# 전체 해제 저장은 차단된 동작이라 성공 기준으로 보지 않습니다 — 막히는지만 확인하고 저장하지 않고 빠져나갑니다 +# 수요일만 남긴 값도 실제 재조회 뒤 확인합니다. +- runFlow: ../subflows/profile-open-preferences.yaml +- runFlow: ../subflows/profile-preference-detail.yaml +- assertVisible: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 + selected: false +- assertVisible: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_2 + selected: true +- scrollUntilVisible: + element: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER + direction: DOWN +- assertVisible: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER + selected: true +- back +- back +# 화면 밖의 선택도 ID별로 스크롤해서 모두 해제합니다. 이 변경은 저장하지 않습니다. +- runFlow: ../subflows/profile-open-preferences.yaml - tapOn: - id: preferred_exercise_edit_btn -- extendedWaitUntil: - visible: - id: preferred_exercise_type_chip - timeout: 30000 + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} +- evalScript: ${output.baselineTypeIds = E2E_PROFILE_BASELINE_TYPE_IDS.split(','); output.typeIndex = 0} - repeat: - while: - visible: - id: preferred_exercise_type_chip - selected: true + times: ${output.baselineTypeIds.length} commands: - - tapOn: - id: preferred_exercise_type_chip + - scrollUntilVisible: + element: + id: preferred_exercise_type_start + direction: UP + - scrollUntilVisible: + element: + id: preferred_exercise_type_${output.baselineTypeIds[output.typeIndex]} + direction: DOWN + - assertVisible: + id: preferred_exercise_type_${output.baselineTypeIds[output.typeIndex]} selected: true + - tapOn: + id: preferred_exercise_type_${output.baselineTypeIds[output.typeIndex]} + - evalScript: ${output.typeIndex = output.typeIndex + 1} - tapOn: "다음" - tapOn: "완료" - extendedWaitUntil: visible: "최소 하나의 운동을 선택해주세요" timeout: 10000 -# 저장하지 않고 두 번 뒤로 가 로컬 변경(전체 해제 포함)을 버립니다. 서버 값은 그대로입니다 - back - back - -# 정리: 우리가 추가한 종목만 다시 해제해 저장합니다. 기존 베이스라인 종목은 건드리지 않습니다 -- tapOn: - id: preferred_exercise_edit_btn -- extendedWaitUntil: - visible: - id: preferred_exercise_type_chip - timeout: 30000 -- tapOn: - text: ${output.exName} +- scrollUntilVisible: + element: + id: nickname_tv + direction: UP +- assertVisible: "최근 30일 운동 횟수" +# 이번 실행이 추가한 type만 해제합니다. 기존 레코드를 삭제·재생성하지 않습니다. +- runFlow: ../subflows/profile-open-preferences.yaml +- assertVisible: + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} selected: true +- tapOn: + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} - tapOn: "다음" - tapOn: "완료" - extendedWaitUntil: visible: "선호 운동이 저장되었습니다" timeout: 15000 -- extendedWaitUntil: - notVisible: ${output.exName} - timeout: 15000 +- assertNotVisible: ${E2E_PROFILE_ADDED_NAME} +# 최종 부재는 offscreen 판정이 아니라 로드된 type 선택 상태와 API finally 대조로 확인합니다. +- runFlow: ../subflows/profile-open-preferences.yaml +- assertVisible: + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} + selected: false +- back +- scrollUntilVisible: + element: + id: nickname_tv + direction: UP +- assertVisible: "최근 30일 운동 횟수" diff --git a/.maestro/subflows/profile-notification-denied.yaml b/.maestro/subflows/profile-notification-denied.yaml index d06411a8..5f3078bd 100644 --- a/.maestro/subflows/profile-notification-denied.yaml +++ b/.maestro/subflows/profile-notification-denied.yaml @@ -1,8 +1,7 @@ appId: com.project200.undabang --- -# POST_NOTIFICATIONS만 거부한 채로 앱을 다시 띄웁니다. launch-logged-in은 notifications: allow를 미리 주기 때문에 -# 권한 거부 경로를 보려면 이 서브플로우로 전제를 깹니다. clearState는 쓰지 않습니다 — 세션과 Room 캐시는 유지하고 -# adb 권한 상태만 바꿉니다(pm revoke와 동일) +# API33+에서 POST_NOTIFICATIONS만 실제로 거부합니다. 앱 데이터와 FCM identity는 유지합니다. +- stopApp - launchApp: clearState: false permissions: @@ -10,7 +9,7 @@ appId: com.project200.undabang notifications: deny location: allow -# 콜드 스타트는 아니지만 재기동이라 스플래시를 다시 거칩니다 +# 재기동 뒤 실제 OS 권한은 NotificationFragment가 확인하고 화면에 표시합니다. - extendedWaitUntil: visible: id: matching_nav_graph diff --git a/.maestro/subflows/profile-notification-ready.yaml b/.maestro/subflows/profile-notification-ready.yaml new file mode 100644 index 00000000..40893268 --- /dev/null +++ b/.maestro/subflows/profile-notification-ready.yaml @@ -0,0 +1,7 @@ +appId: com.project200.undabang +--- +# ready는 필수 행 조회와 마지막 PATCH의 서버 재확인까지 성공했음을 뜻합니다. +- extendedWaitUntil: + visible: + id: notification_ready + timeout: 30000 diff --git a/.maestro/subflows/profile-open-preferences.yaml b/.maestro/subflows/profile-open-preferences.yaml new file mode 100644 index 00000000..43213873 --- /dev/null +++ b/.maestro/subflows/profile-open-preferences.yaml @@ -0,0 +1,23 @@ +appId: com.project200.undabang +--- +- scrollUntilVisible: + element: + id: nickname_tv + direction: UP + timeout: 30000 +- scrollUntilVisible: + element: + id: preferred_exercise_edit_btn + direction: DOWN + timeout: 30000 +- tapOn: + id: preferred_exercise_edit_btn +- extendedWaitUntil: + visible: + id: preferred_exercise_type_loaded + timeout: 30000 +- scrollUntilVisible: + element: + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} + direction: DOWN + timeout: 30000 diff --git a/.maestro/subflows/profile-preference-detail.yaml b/.maestro/subflows/profile-preference-detail.yaml new file mode 100644 index 00000000..440fefbf --- /dev/null +++ b/.maestro/subflows/profile-preference-detail.yaml @@ -0,0 +1,12 @@ +appId: com.project200.undabang +--- +- tapOn: "다음" +- extendedWaitUntil: + visible: + id: preferred_exercise_detail_loaded + timeout: 30000 +- scrollUntilVisible: + element: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 + direction: DOWN + timeout: 30000 diff --git a/.maestro/suites/profile.json b/.maestro/suites/profile.json new file mode 100644 index 00000000..6251e433 --- /dev/null +++ b/.maestro/suites/profile.json @@ -0,0 +1,12 @@ +{ + "flows": [ + "profile-edit", + "profile-edit-validation", + "profile-preferred-exercise", + "profile-notification" + ], + "apiLevel": 33, + "fixture": true, + "requiredEnvironment": [], + "driver": null +} diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileFixture.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileFixture.kt new file mode 100644 index 00000000..415fb241 --- /dev/null +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileFixture.kt @@ -0,0 +1,222 @@ +package com.project200.undabang.e2e + +import android.content.Context +import android.util.AtomicFile +import com.project200.data.api.ApiService +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.GetPreferredExerciseDTO +import com.project200.data.dto.NotificationStateDTO +import com.project200.data.dto.PutProfileRequest +import com.project200.domain.repository.FcmRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import javax.inject.Inject + +internal class MaestroProfileFixture + @Inject + constructor( + @ApplicationContext context: Context, + private val api: ApiService, + private val fcmRepository: FcmRepository, + private val recovery: MaestroProfileRecovery, + private val notificationReadiness: MaestroProfileNotificationReadiness, + ) : MaestroFeatureFixture { + private val snapshotFile = AtomicFile(File(context.filesDir, "maestro-profile-recovery.json")) + + private fun hasSnapshot(): Boolean { + val path = snapshotFile.baseFile.path + return snapshotFile.baseFile.exists() || File("$path.bak").exists() || File("$path.new").exists() + } + + override suspend fun prepare(request: MaestroFeatureRequest): Map { + validateRequest(request) + if (hasSnapshot()) throw MaestroSetupFailure("profile_recovery_pending") + val profile = read(api.getProfile()) + MaestroProfilePolicy.validateText(profile.nickname, profile.gender, profile.bio) + val preferences = read(api.getPreferredExercises()) + MaestroProfilePolicy.validatePreferences(preferences) + var addedType: Long? = null + var addedName = "" + if (request.flow == "profile-preferred-exercise") { + if (preferences.size !in 1..4) throw MaestroSetupFailure("profile_preference_baseline_unavailable") + val catalog = read(api.getPreferredExerciseTypes()) + if ( + catalog.any { it.exerciseId <= 0 || it.exerciseName.isBlank() } || + catalog.map { it.exerciseId }.toSet().size != catalog.size || + preferences.any { original -> catalog.none { it.exerciseId == original.exerciseTypeId } } + ) { + throw MaestroSetupFailure("profile_catalog_invalid") + } + val candidate = + catalog.firstOrNull { type -> preferences.none { it.exerciseTypeId == type.exerciseId } } + ?: throw MaestroSetupFailure("profile_unselected_exercise_missing") + if (candidate.exerciseName.length > 4096 || candidate.exerciseName.any { it.code < 32 }) { + throw MaestroSetupFailure("profile_catalog_invalid") + } + addedType = candidate.exerciseId + addedName = candidate.exerciseName + } + val notificationSnapshot = notificationReadiness.prepare(request.flow) + val snapshot = + JSONObject() + .put("memberId", request.memberId) + .put("runId", request.runId) + .put("flow", request.flow) + .put("nickname", profile.nickname) + .put("gender", profile.gender) + .put("bio", profile.bio) + .put("fcmToken", notificationSnapshot?.token ?: JSONObject.NULL) + .put( + "preferences", + JSONArray().apply { preferences.forEach { put(encodePreference(it)) } }, + ) + .put( + "notifications", + notificationSnapshot?.let { + JSONArray().apply { + it.settings.forEach { put(JSONObject().put("type", it.type).put("enabled", it.enabled)) } + } + } ?: JSONObject.NULL, + ) + .put("addedType", addedType ?: JSONObject.NULL) + .put("addedName", addedName) + // 서버 설정/프로필 쓰기 전에 원본 ID/값과 해당 flow의 기기 토큰을 앱 전용 파일에 보관합니다. + save(snapshot) + if (notificationSnapshot != null) { + val token = notificationSnapshot.token + val notifications = notificationSnapshot.settings + if (fcmRepository.getSavedToken() != token) throw MaestroSetupFailure("profile_fcm_identity_changed") + write( + api.patchNotiState( + token, + notifications.filter { it.type in knownTypes }.map { it.copy(enabled = false) }, + ), + ) + val prepared = read(api.getNotiState(token)) + MaestroProfilePolicy.validateNotifications(prepared) + val expected = notifications.map { if (it.type in knownTypes) it.copy(enabled = false) else it } + if (notificationValues(prepared) != notificationValues(expected)) { + throw MaestroSetupFailure("profile_notification_prepare_failed") + } + } + return mapOf( + "E2E_PROFILE_ORIGINAL_NICKNAME" to profile.nickname, + "E2E_PROFILE_ORIGINAL_BIO" to requireNotNull(profile.bio), + "E2E_PROFILE_NEW_NICKNAME" to "e2e${request.runId}", + "E2E_PROFILE_NEW_BIO" to "e2e-intro-${request.runId}", + "E2E_PROFILE_ADDED_TYPE_ID" to (addedType?.toString() ?: ""), + "E2E_PROFILE_ADDED_NAME" to addedName, + "E2E_PROFILE_BASELINE_TYPE_IDS" to preferences.joinToString(",") { it.exerciseTypeId.toString() }, + ) + } + + override suspend fun cleanup(request: MaestroFeatureRequest) { + validateRequest(request) + if (!hasSnapshot()) return + val snapshot = JSONObject(snapshotFile.openRead().bufferedReader().use { it.readText() }) + if ( + snapshot.getString("memberId") != request.memberId || snapshot.getString("runId") != request.runId || + snapshot.getString("flow") != request.flow + ) { + throw MaestroSetupFailure("profile_recovery_identity_failed") + } + if (!snapshot.has("fcmToken") || !snapshot.has("notifications")) { + throw MaestroSetupFailure("profile_recovery_notification_invalid") + } + val notificationSnapshot = + if (snapshot.isNull("fcmToken") && snapshot.isNull("notifications")) { + if (request.flow == "profile-notification") throw MaestroSetupFailure("profile_recovery_notification_invalid") + null + } else { + if (snapshot.isNull("fcmToken") || snapshot.isNull("notifications")) { + throw MaestroSetupFailure("profile_recovery_notification_invalid") + } + val token = snapshot.getString("fcmToken") + if (token.isBlank()) throw MaestroSetupFailure("profile_fcm_identity_changed") + val notifications = decodeNotifications(snapshot.getJSONArray("notifications")) + MaestroProfilePolicy.validateNotifications(notifications) + MaestroProfileNotificationSnapshot(token, notifications) + } + val original = + PutProfileRequest( + snapshot.getString("nickname"), + snapshot.getString("gender"), + snapshot.getString("bio"), + ) + val preferences = decodePreferences(snapshot.getJSONArray("preferences")) + MaestroProfilePolicy.validateText(original.nickname, original.gender, original.bio) + val addedType = if (snapshot.isNull("addedType")) null else snapshot.getLong("addedType") + recovery.restore(original, preferences, notificationSnapshot, addedType, request.flow, request.runId) + // 이후 공용 정리가 실패해도 같은 소유권으로 재시도하도록 호스트의 최종 pm clear까지 snapshot을 보존합니다. + } + + private fun validateRequest(request: MaestroFeatureRequest) { + if (request.feature != "profile" || request.flow !in flows || !runIdPattern.matches(request.runId)) { + throw MaestroSetupFailure("profile_request_invalid") + } + } + + private fun save(snapshot: JSONObject) { + val file = snapshotFile + val content = snapshot.toString() + val stream = file.startWrite() + try { + stream.write(content.toByteArray(Charsets.UTF_8)) + file.finishWrite(stream) + } catch (_: Exception) { + file.failWrite(stream) + throw MaestroSetupFailure("profile_snapshot_write_failed") + } + if (file.openRead().bufferedReader().use { it.readText() } != content) { + throw MaestroSetupFailure("profile_snapshot_write_failed") + } + } + + private fun encodePreference(value: GetPreferredExerciseDTO): JSONObject = + JSONObject() + .put("id", value.preferredExerciseId) + .put("type", value.exerciseTypeId) + .put("name", value.exerciseName) + .put("skill", value.skillLevel) + .put("days", JSONArray(value.daysOfWeek)) + .put("image", value.imageUrl ?: JSONObject.NULL) + + private fun decodePreferences(values: JSONArray): List = + (0 until values.length()).map { index -> + val value = values.getJSONObject(index) + val days = value.getJSONArray("days") + GetPreferredExerciseDTO( + value.getLong("id"), + value.getLong("type"), + value.getString("name"), + value.getString("skill"), + (0 until days.length()).map { days.getBoolean(it) }, + if (value.isNull("image")) null else value.getString("image"), + ) + } + + private fun decodeNotifications(values: JSONArray): List = + (0 until values.length()).map { index -> + val value = values.getJSONObject(index) + NotificationStateDTO(value.getString("type"), value.getBoolean("enabled")) + } + + private fun notificationValues(values: List) = values.associate { it.type to it.enabled } + + private fun read(response: BaseResponse): T { + if (!response.succeed) throw MaestroSetupFailure("profile_fixture_read_failed") + return response.data ?: throw MaestroSetupFailure("profile_fixture_read_failed") + } + + private fun write(response: BaseResponse) { + if (!response.succeed) throw MaestroSetupFailure("profile_fixture_write_failed") + } + + private companion object { + val runIdPattern = Regex("[a-f0-9]{16}") + val knownTypes = setOf("WORKOUT_REMINDER", "CHAT_MESSAGE") + val flows = setOf("profile-edit", "profile-edit-validation", "profile-preferred-exercise", "profile-notification") + } + } diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileFixtureModule.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileFixtureModule.kt new file mode 100644 index 00000000..0a0a4ecd --- /dev/null +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileFixtureModule.kt @@ -0,0 +1,17 @@ +package com.project200.undabang.e2e + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class MaestroProfileFixtureModule { + @Binds + @IntoMap + @StringKey("profile") + abstract fun bindProfileFixture(impl: MaestroProfileFixture): MaestroFeatureFixture +} diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileNotificationReadiness.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileNotificationReadiness.kt new file mode 100644 index 00000000..3933c11f --- /dev/null +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileNotificationReadiness.kt @@ -0,0 +1,81 @@ +package com.project200.undabang.e2e + +import com.project200.data.api.ApiService +import com.project200.data.dto.NotificationStateDTO +import com.project200.domain.model.BaseResult +import com.project200.domain.repository.FcmRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject + +// data class의 자동 toString에 실제 기기 토큰이 포함되지 않도록 일반 클래스로 보관합니다. +internal class MaestroProfileNotificationSnapshot( + val token: String, + val settings: List, +) + +internal class MaestroProfileNotificationReadiness + @Inject + constructor( + private val api: ApiService, + private val fcmRepository: FcmRepository, + ) { + suspend fun prepare(flow: String): MaestroProfileNotificationSnapshot? { + if (flow != "profile-notification") return null + // Firebase 자동 초기화 → FcmService.onNewToken → 암호화 저장소의 실제 경로만 기다립니다. + val token = + withTimeoutOrNull(TOKEN_TIMEOUT_MS) { + var saved = savedToken() + while (saved.isNullOrBlank()) { + delay(TOKEN_POLL_MS) + saved = savedToken() + } + saved + } ?: throw MaestroSetupFailure("profile_fcm_token_timeout") + // SetupActivity에는 MainViewModel의 예약이 없으므로 인증된 저장 토큰을 직접 등록합니다. + val registration = + try { + withTimeoutOrNull(REGISTRATION_TIMEOUT_MS) { fcmRepository.registerToken() } + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + throw MaestroSetupFailure("profile_fcm_registration_failed") + } ?: throw MaestroSetupFailure("profile_fcm_registration_timeout") + if (registration !is BaseResult.Success) throw MaestroSetupFailure("profile_fcm_registration_failed") + requireIdentity(token) + val response = + try { + withTimeoutOrNull(SETTINGS_TIMEOUT_MS) { api.getNotiState(token) } + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + throw MaestroSetupFailure("profile_fixture_read_failed") + } ?: throw MaestroSetupFailure("profile_fcm_settings_timeout") + if (!response.succeed) throw MaestroSetupFailure("profile_fixture_read_failed") + val settings = response.data ?: throw MaestroSetupFailure("profile_fixture_read_failed") + MaestroProfilePolicy.validateNotifications(settings) + requireIdentity(token) + return MaestroProfileNotificationSnapshot(token, settings) + } + + private suspend fun savedToken(): String? = + try { + fcmRepository.getSavedToken() + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + throw MaestroSetupFailure("profile_fcm_storage_failed") + } + + private suspend fun requireIdentity(token: String) { + if (savedToken() != token) throw MaestroSetupFailure("profile_fcm_identity_changed") + } + + private companion object { + const val TOKEN_TIMEOUT_MS = 60_000L + const val TOKEN_POLL_MS = 250L + const val REGISTRATION_TIMEOUT_MS = 30_000L + const val SETTINGS_TIMEOUT_MS = 30_000L + } + } diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt new file mode 100644 index 00000000..3c953ca5 --- /dev/null +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt @@ -0,0 +1,77 @@ +package com.project200.undabang.e2e + +import com.project200.data.dto.GetPreferredExerciseDTO +import com.project200.data.dto.NotificationStateDTO + +internal object MaestroProfilePolicy { + private val nicknamePattern = Regex("[A-Za-z0-9]{1,30}") + private val bioPattern = Regex("[A-Za-z0-9][A-Za-z0-9 _-]*") + private val genders = setOf("MALE", "FEMALE", "UNKNOWN") + private val skills = setOf("BEGINNER", "ROOKIE", "INTERMEDIATE", "ADVANCED", "SKILLED", "PRO") + + data class PreferenceRecovery( + val deleteIds: List, + val restore: List, + ) + + fun validateText(nickname: String, gender: String, bio: String?) { + // Maestro Android inputText로 원문을 정확히 복구할 수 있는 전용 계정만 허용합니다. + if (!nicknamePattern.matches(nickname) || gender !in genders || + bio == null || bio.length > 4096 || !bioPattern.matches(bio) + ) { + throw MaestroSetupFailure("profile_baseline_text_unsupported") + } + } + + fun validatePreferences(preferences: List) { + if (preferences.map { it.preferredExerciseId }.toSet().size != preferences.size || + preferences.map { it.exerciseTypeId }.toSet().size != preferences.size || + preferences.any { + it.preferredExerciseId <= 0 || it.exerciseTypeId <= 0 || it.skillLevel !in skills || + it.daysOfWeek.size != 7 || !it.daysOfWeek.contains(true) + } + ) { + throw MaestroSetupFailure("profile_preferences_invalid") + } + } + + fun validateNotifications(settings: List) { + val types = settings.map { it.type } + if (types.any { it.isBlank() } || types.toSet().size != types.size || + "WORKOUT_REMINDER" !in types || "CHAT_MESSAGE" !in types + ) { + throw MaestroSetupFailure("profile_notifications_invalid") + } + } + + fun preferenceRecovery( + baseline: List, + current: List, + addedType: Long?, + ): PreferenceRecovery { + validatePreferences(baseline) + validatePreferences(current) + val originalIds = baseline.map { it.preferredExerciseId }.toSet() + val currentById = current.associateBy { it.preferredExerciseId } + // 기존 ID를 삭제·재생성하면 정확한 복구가 아니므로, 누락과 type 변경은 중단합니다. + if (baseline.any { currentById[it.preferredExerciseId]?.exerciseTypeId != it.exerciseTypeId }) { + throw MaestroSetupFailure("profile_preference_identity_changed") + } + val added = current.filter { it.preferredExerciseId !in originalIds } + if (added.any { it.exerciseTypeId != addedType } || added.size > 1 || + baseline.any { it.exerciseTypeId == addedType } + ) { + throw MaestroSetupFailure("profile_preference_ownership_failed") + } + return PreferenceRecovery( + deleteIds = added.map { it.preferredExerciseId }, + restore = baseline.filter { + val actual = currentById.getValue(it.preferredExerciseId) + it.skillLevel != actual.skillLevel || it.daysOfWeek != actual.daysOfWeek + }, + ) + } + + fun preferenceValues(preferences: List) = + preferences.associate { it.preferredExerciseId to Triple(it.exerciseTypeId, it.skillLevel, it.daysOfWeek) } +} diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileRecovery.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileRecovery.kt new file mode 100644 index 00000000..f4de98e4 --- /dev/null +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileRecovery.kt @@ -0,0 +1,126 @@ +package com.project200.undabang.e2e + +import com.project200.data.api.ApiService +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.DeletePreferredExerciseDTO +import com.project200.data.dto.GetPreferredExerciseDTO +import com.project200.data.dto.NotificationStateDTO +import com.project200.data.dto.PostPreferredExerciseDTO +import com.project200.data.dto.PutProfileRequest +import com.project200.domain.repository.FcmRepository +import javax.inject.Inject + +internal class MaestroProfileRecovery + @Inject + constructor( + private val api: ApiService, + private val fcmRepository: FcmRepository, + ) { + suspend fun restore( + original: PutProfileRequest, + preferences: List, + notificationSnapshot: MaestroProfileNotificationSnapshot?, + addedType: Long?, + flow: String, + runId: String, + ) { + // 한 영역의 조회/복구 실패가 다른 영역의 원본 복구를 막지 않게 합니다. + var failed = flow == "profile-notification" && notificationSnapshot == null + + suspend fun restoreArea(block: suspend () -> Unit) { + try { + block() + } catch (_: Exception) { + failed = true + } + } + + restoreArea { + val current = read(api.getProfile()) + val allowedNicknames = + when (flow) { + "profile-edit" -> setOf(original.nickname, "e2e$runId") + "profile-edit-validation" -> setOf(original.nickname, "e2ea$runId", "e2eb$runId", "e2e615!invalid") + else -> setOf(original.nickname) + } + if (current.nickname !in allowedNicknames || current.bio !in setOf(original.bio, "e2e-intro-$runId")) { + throw MaestroSetupFailure("profile_recovery_ownership_failed") + } + val changed = + current.nickname != original.nickname || current.gender != original.gender || current.bio != original.bio + if (current.gender != original.gender || (flow != "profile-edit" && changed)) failed = true + if (changed) write(api.editProfile(original)) + val restored = read(api.getProfile()) + if (restored.nickname != original.nickname || restored.gender != original.gender || restored.bio != original.bio) { + throw MaestroSetupFailure("profile_restore_failed") + } + } + restoreArea { + val current = read(api.getPreferredExercises()) + val recovery = MaestroProfilePolicy.preferenceRecovery(preferences, current, addedType) + // 기존 행은 이 시나리오에서 변경하지 않습니다. 복구가 되어도 잘못된 쓰기는 실패입니다. + if (recovery.restore.isNotEmpty() || (flow != "profile-preferred-exercise" && recovery.deleteIds.isNotEmpty())) { + failed = true + } + if (recovery.deleteIds.isNotEmpty()) { + write(api.deletePreferredExercises(DeletePreferredExerciseDTO(recovery.deleteIds))) + } + if (recovery.restore.isNotEmpty()) { + write( + api.patchPreferredExercises( + recovery.restore.map { + PostPreferredExerciseDTO(it.exerciseTypeId, it.skillLevel, it.daysOfWeek) + }, + ), + ) + } + val restored = read(api.getPreferredExercises()) + MaestroProfilePolicy.validatePreferences(restored) + if (MaestroProfilePolicy.preferenceValues(restored) != MaestroProfilePolicy.preferenceValues(preferences)) { + throw MaestroSetupFailure("profile_preference_restore_failed") + } + } + // 다른 flow의 새 snapshot에는 알림 영역이 없습니다. 기존 snapshot은 flow와 무관하게 복구합니다. + if (notificationSnapshot != null) { + restoreArea { + val token = notificationSnapshot.token + val notifications = notificationSnapshot.settings + if (token.isBlank() || fcmRepository.getSavedToken() != token) { + throw MaestroSetupFailure("profile_fcm_identity_changed") + } + val current = read(api.getNotiState(token)) + MaestroProfilePolicy.validateNotifications(current) + if ( + current.map { it.type }.toSet() != notifications.map { it.type }.toSet() || + values(current.filter { it.type !in knownTypes }) != values(notifications.filter { it.type !in knownTypes }) + ) { + throw MaestroSetupFailure("profile_recovery_ownership_failed") + } + if (flow != "profile-notification" && values(current) != values(notifications)) failed = true + if (values(current) != values(notifications)) write(api.patchNotiState(token, notifications)) + val restored = read(api.getNotiState(token)) + MaestroProfilePolicy.validateNotifications(restored) + if (fcmRepository.getSavedToken() != token || values(restored) != values(notifications)) { + throw MaestroSetupFailure("profile_notification_restore_failed") + } + } + } + // 성공해도 snapshot은 호스트의 최종 pm clear까지 남겨 같은 실행의 재시도를 허용합니다. + if (failed) throw MaestroSetupFailure("profile_restore_failed") + } + + private fun values(values: List) = values.associate { it.type to it.enabled } + + private fun read(response: BaseResponse): T { + if (!response.succeed) throw MaestroSetupFailure("profile_fixture_read_failed") + return response.data ?: throw MaestroSetupFailure("profile_fixture_read_failed") + } + + private fun write(response: BaseResponse) { + if (!response.succeed) throw MaestroSetupFailure("profile_fixture_write_failed") + } + + private companion object { + val knownTypes = setOf("WORKOUT_REMINDER", "CHAT_MESSAGE") + } + } diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileNotificationReadinessTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileNotificationReadinessTest.kt new file mode 100644 index 00000000..f438ec67 --- /dev/null +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileNotificationReadinessTest.kt @@ -0,0 +1,214 @@ +package com.project200.undabang.e2e + +import com.google.common.truth.Truth.assertThat +import com.project200.data.api.ApiService +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.NotificationStateDTO +import com.project200.domain.model.BaseResult +import com.project200.domain.repository.FcmRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifySequence +import io.mockk.mockk +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MaestroProfileNotificationReadinessTest { + private val api = mockk() + private val fcm = mockk() + private val readiness = MaestroProfileNotificationReadiness(api, fcm) + private val token = "unit-test-device-identity" + private val notifications = + listOf( + NotificationStateDTO("WORKOUT_REMINDER", true), + NotificationStateDTO("CHAT_MESSAGE", false), + NotificationStateDTO("NEW_SERVER_TYPE", true), + ) + + @Test + fun `FCM callback 저장을 기다린 뒤 실제 등록 성공과 같은 기기의 원본 행을 요구한다`() = + runTest { + baseline() + coEvery { fcm.getSavedToken() } returnsMany listOf(null, "", " ", token) + val snapshot = requireNotNull(readiness.prepare("profile-notification")) + assertThat(testScheduler.currentTime).isEqualTo(750L) + assertThat(snapshot.token).isEqualTo(token) + assertThat(snapshot.settings).containsExactlyElementsIn(notifications) + coVerifySequence { + fcm.getSavedToken() + fcm.getSavedToken() + fcm.getSavedToken() + fcm.getSavedToken() + fcm.registerToken() + fcm.getSavedToken() + api.getNotiState(token) + fcm.getSavedToken() + } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + } + + @Test + fun `다른 profile flow는 FCM 토큰과 알림 API를 요구하지 않는다`() = + runTest { + for (flow in listOf("profile-edit", "profile-edit-validation", "profile-preferred-exercise")) { + assertThat(readiness.prepare(flow)).isNull() + } + coVerify(exactly = 0) { fcm.getSavedToken() } + coVerify(exactly = 0) { fcm.registerToken() } + coVerify(exactly = 0) { api.getNotiState(any()) } + } + + @Test + fun `저장 토큰이 없으면 제한 시간에 실패하고 등록이나 기본 행을 만들지 않는다`() = + runTest { + coEvery { fcm.getSavedToken() } returns null + expectFailure("profile_fcm_token_timeout") { readiness.prepare("profile-notification") } + assertThat(testScheduler.currentTime).isEqualTo(60_000L) + coVerify(exactly = 0) { fcm.registerToken() } + coVerify(exactly = 0) { api.getNotiState(any()) } + } + + @Test + fun `마지막 polling 구간에 저장된 토큰도 제한 시간 전에 등록할 수 있다`() = + runTest { + baseline() + coEvery { fcm.getSavedToken() } coAnswers { + if (testScheduler.currentTime >= 59_750L) token else null + } + assertThat(readiness.prepare("profile-notification")?.token).isEqualTo(token) + assertThat(testScheduler.currentTime).isEqualTo(59_750L) + coVerify(exactly = 1) { fcm.registerToken() } + coVerify(exactly = 1) { api.getNotiState(token) } + } + + @Test + fun `토큰 저장소 오류를 누락 토큰으로 재시도하지 않는다`() = + runTest { + coEvery { fcm.getSavedToken() } throws IllegalStateException(token) + expectFailure("profile_fcm_storage_failed") { readiness.prepare("profile-notification") } + assertThat(testScheduler.currentTime).isEqualTo(0L) + coVerify(exactly = 1) { fcm.getSavedToken() } + coVerify(exactly = 0) { fcm.registerToken() } + } + + @Test + fun `백엔드 등록 Error는 재시도나 설정 조회 없이 고정 오류로 실패한다`() = + runTest { + baseline() + coEvery { fcm.registerToken() } returns BaseResult.Error("PRIVATE", token, IllegalStateException(token)) + expectFailure("profile_fcm_registration_failed") { readiness.prepare("profile-notification") } + coVerify(exactly = 1) { fcm.registerToken() } + coVerify(exactly = 0) { api.getNotiState(any()) } + } + + @Test + fun `등록 예외에 포함된 원문은 결과 오류에 전달하지 않는다`() = + runTest { + baseline() + coEvery { fcm.registerToken() } throws IllegalStateException(token) + expectFailure("profile_fcm_registration_failed") { readiness.prepare("profile-notification") } + coVerify(exactly = 1) { fcm.registerToken() } + coVerify(exactly = 0) { api.getNotiState(any()) } + } + + @Test + fun `등록 응답이 끝나지 않으면 제한 시간에 실패한다`() = + runTest { + baseline() + coEvery { fcm.registerToken() } coAnswers { + delay(30_001L) + BaseResult.Success(Unit) + } + expectFailure("profile_fcm_registration_timeout") { readiness.prepare("profile-notification") } + assertThat(testScheduler.currentTime).isEqualTo(30_000L) + coVerify(exactly = 1) { fcm.registerToken() } + coVerify(exactly = 0) { api.getNotiState(any()) } + } + + @Test + fun `등록 중 토큰이 바뀌면 어떤 기기의 설정도 읽지 않는다`() = + runTest { + baseline() + coEvery { fcm.getSavedToken() } returnsMany listOf(token, "rotated-device") + expectFailure("profile_fcm_identity_changed") { readiness.prepare("profile-notification") } + coVerify(exactly = 1) { fcm.registerToken() } + coVerify(exactly = 0) { api.getNotiState(any()) } + } + + @Test + fun `설정 조회 중 토큰이 바뀌면 원본 snapshot을 반환하지 않는다`() = + runTest { + baseline() + coEvery { fcm.getSavedToken() } returnsMany listOf(token, token, "rotated-device") + expectFailure("profile_fcm_identity_changed") { readiness.prepare("profile-notification") } + coVerify(exactly = 1) { api.getNotiState(token) } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + } + + @Test + fun `실패한 설정 응답에 행이 있어도 원본으로 인정하지 않는다`() = + runTest { + baseline() + coEvery { api.getNotiState(token) } returns BaseResponse(false, "PRIVATE", token, notifications) + expectFailure("profile_fixture_read_failed") { readiness.prepare("profile-notification") } + coVerify(exactly = 1) { api.getNotiState(token) } + } + + @Test + fun `등록 성공이어도 필수 알림 행이 없으면 기본값으로 대체하지 않는다`() = + runTest { + baseline() + coEvery { api.getNotiState(token) } returns BaseResponse(true, "OK", "", notifications.drop(1)) + expectFailure("profile_notifications_invalid") { readiness.prepare("profile-notification") } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + } + + @Test + fun `설정 조회가 끝나지 않으면 제한 시간에 실패한다`() = + runTest { + baseline() + coEvery { api.getNotiState(token) } coAnswers { + delay(30_001L) + BaseResponse(true, "OK", "", notifications) + } + expectFailure("profile_fcm_settings_timeout") { readiness.prepare("profile-notification") } + assertThat(testScheduler.currentTime).isEqualTo(30_000L) + coVerify(exactly = 1) { api.getNotiState(token) } + } + + @Test + fun `상위 작업 취소는 readiness 실패로 삼키지 않는다`() = + runTest { + baseline() + coEvery { fcm.registerToken() } throws CancellationException("cancelled") + try { + readiness.prepare("profile-notification") + throw AssertionError("취소된 등록이 성공으로 처리되었습니다") + } catch (_: CancellationException) { + coVerify(exactly = 0) { api.getNotiState(any()) } + } + } + + private fun baseline() { + coEvery { fcm.getSavedToken() } returns token + coEvery { fcm.registerToken() } returns BaseResult.Success(Unit) + coEvery { api.getNotiState(token) } returns BaseResponse(true, "OK", "", notifications) + } + + private suspend fun expectFailure( + reason: String, + operation: suspend () -> Unit, + ) { + try { + operation() + throw AssertionError("준비 실패가 성공으로 처리되었습니다") + } catch (failure: MaestroSetupFailure) { + assertThat(failure.reason).isEqualTo(reason) + assertThat(failure.cause).isNull() + } + } +} diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt new file mode 100644 index 00000000..9cedb507 --- /dev/null +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt @@ -0,0 +1,71 @@ +package com.project200.undabang.e2e + +import com.project200.data.dto.GetPreferredExerciseDTO +import com.project200.data.dto.NotificationStateDTO +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class MaestroProfilePolicyTest { + private val baseline = preference(10, 1) + + @Test + fun `원본 preference ID가 사라지면 재생성하지 않고 실패한다`() { + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.preferenceRecovery(listOf(baseline), listOf(preference(11, 1)), 2) + } + } + + @Test + fun `이번 실행의 추가 종목만 삭제하고 원본 값만 수정한다`() { + val changed = baseline.copy(skillLevel = "PRO") + val recovery = MaestroProfilePolicy.preferenceRecovery(listOf(baseline), listOf(changed, preference(20, 2)), 2) + assertEquals(listOf(20L), recovery.deleteIds) + assertEquals(listOf(baseline), recovery.restore) + } + + @Test + fun `소유하지 않은 추가 종목은 삭제하지 않는다`() { + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.preferenceRecovery(listOf(baseline), listOf(baseline, preference(30, 3)), 2) + } + } + + @Test + fun `알림의 서버 확장 type은 원문과 값을 보존한다`() { + val settings = + listOf( + NotificationStateDTO("WORKOUT_REMINDER", true), + NotificationStateDTO("CHAT_MESSAGE", false), + NotificationStateDTO("SERVER_NEW_TYPE", true), + ) + MaestroProfilePolicy.validateNotifications(settings) + assertEquals("SERVER_NEW_TYPE", settings.last().type) + assertEquals(true, settings.last().enabled) + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.validateNotifications(settings.filterNot { it.type == "CHAT_MESSAGE" }) + } + } + + @Test + fun `null과 Android 입력으로 복구할 수 없는 소개는 mutation 전에 거부한다`() { + for (bio in listOf(null, "", "한글 소개", "percent%text", "line\nbreak")) { + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.validateText("OriginalUser", "MALE", bio) + } + } + MaestroProfilePolicy.validateText("OriginalUser", "FEMALE", "Original intro-1") + } + + @Test + fun `동일 type에 여러 ID가 있으면 소유권을 추측하지 않는다`() { + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.preferenceRecovery(listOf(baseline), listOf(baseline, preference(20, 2), preference(21, 2)), 2) + } + } + + private fun preference( + id: Long, + type: Long, + ) = GetPreferredExerciseDTO(id, type, "Sport$type", "BEGINNER", listOf(true, false, false, false, false, false, false), null) +} diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileRecoveryTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileRecoveryTest.kt new file mode 100644 index 00000000..116bd2e3 --- /dev/null +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileRecoveryTest.kt @@ -0,0 +1,259 @@ +package com.project200.undabang.e2e + +import com.google.common.truth.Truth.assertThat +import com.project200.data.api.ApiService +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.DeletePreferredExerciseDTO +import com.project200.data.dto.GetPreferredExerciseDTO +import com.project200.data.dto.GetProfileDTO +import com.project200.data.dto.NotificationStateDTO +import com.project200.data.dto.PostPreferredExerciseDTO +import com.project200.data.dto.PutProfileRequest +import com.project200.domain.repository.FcmRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class MaestroProfileRecoveryTest { + private val api = mockk() + private val fcm = mockk() + private val recovery = MaestroProfileRecovery(api, fcm) + private val original = PutProfileRequest("OriginalUser", "UNKNOWN", "Original intro") + private val runId = "0123456789abcdef" + private val token = "unit-test-device-identity" + private val preference = + GetPreferredExerciseDTO(10, 1, "Sport", "BEGINNER", listOf(true, false, false, false, false, false, false), null) + private val notifications = + listOf( + NotificationStateDTO("WORKOUT_REMINDER", true), + NotificationStateDTO("CHAT_MESSAGE", false), + NotificationStateDTO("NEW_SERVER_TYPE", true), + ) + private val notificationSnapshot = MaestroProfileNotificationSnapshot(token, notifications) + + @Test + fun `원본 ID를 유지하고 이번 실행의 추가 ID만 삭제한다`() = + runTest { + baseline() + val extra = preference.copy(preferredExerciseId = 20, exerciseTypeId = 2) + coEvery { api.getPreferredExercises() } returnsMany listOf(success(listOf(preference, extra)), success(listOf(preference))) + coEvery { api.deletePreferredExercises(DeletePreferredExerciseDTO(listOf(20))) } returns success(null) + recovery.restore(original, listOf(preference), notificationSnapshot, 2, "profile-preferred-exercise", runId) + coVerify(exactly = 1) { api.deletePreferredExercises(DeletePreferredExerciseDTO(listOf(20))) } + coVerify(exactly = 0) { api.postPreferredExercises(any()) } + coVerify(exactly = 0) { api.patchPreferredExercises(any()) } + } + + @Test + fun `원문 type 복구와 재조회를 수행하되 다른 flow의 알림 변경은 실패로 남긴다`() = + runTest { + baseline() + coEvery { api.getProfile() } returnsMany listOf(success(profile("e2e$runId", "e2e-intro-$runId")), success(profile())) + coEvery { api.editProfile(original) } returns success(null) + val changed = notifications.map { if (it.type == "CHAT_MESSAGE") it.copy(enabled = true) else it } + coEvery { api.getNotiState(token) } returnsMany listOf(success(changed), success(notifications)) + coEvery { api.patchNotiState(token, notifications) } returns success(null) + expectFailure { recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-edit", runId) } + coVerify(exactly = 1) { api.editProfile(original) } + coVerify(exactly = 1) { api.patchNotiState(token, notifications) } + coVerify(exactly = 2) { api.getNotiState(token) } + } + + @Test + fun `원본 preference 변경은 같은 type PATCH로 복구해도 테스트 실패를 유지한다`() = + runTest { + baseline() + coEvery { api.getPreferredExercises() } returnsMany + listOf(success(listOf(preference.copy(skillLevel = "PRO"))), success(listOf(preference))) + coEvery { + api.patchPreferredExercises(listOf(PostPreferredExerciseDTO(1, "BEGINNER", preference.daysOfWeek))) + } returns success(null) + expectFailure { recovery.restore(original, listOf(preference), notificationSnapshot, 2, "profile-preferred-exercise", runId) } + coVerify(exactly = 1) { + api.patchPreferredExercises(listOf(PostPreferredExerciseDTO(1, "BEGINNER", preference.daysOfWeek))) + } + coVerify(exactly = 0) { api.postPreferredExercises(any()) } + } + + @Test + fun `알림 조회 오류는 빈 성공이 아니며 다른 영역의 복구를 막지 않는다`() = + runTest { + baseline() + coEvery { api.getProfile() } returnsMany listOf(success(profile("e2e$runId", "e2e-intro-$runId")), success(profile())) + coEvery { api.editProfile(original) } returns success(null) + coEvery { api.getNotiState(token) } returns BaseResponse(false, "ERROR", "", emptyList()) + expectFailure { recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-edit", runId) } + coVerify(exactly = 1) { api.editProfile(original) } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + } + + @Test + fun `FCM identity가 바뀌면 새 기기로 원본 설정을 보내지 않는다`() = + runTest { + baseline() + coEvery { fcm.getSavedToken() } returns "different-device" + expectFailure { recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-notification", runId) } + coVerify(exactly = 0) { api.getNotiState(any()) } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + } + + @Test + fun `서버 성공 응답 뒤 재조회 값이 다르면 복구 성공을 반환하지 않는다`() = + runTest { + baseline() + val changed = notifications.map { if (it.type == "WORKOUT_REMINDER") it.copy(enabled = false) else it } + coEvery { api.getNotiState(token) } returns success(changed) + coEvery { api.patchNotiState(token, notifications) } returns success(null) + expectFailure { recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-notification", runId) } + } + + @Test + fun `실패한 PATCH는 응답 data가 있어도 복구 성공이 아니다`() = + runTest { + baseline() + val changed = notifications.map { if (it.type == "WORKOUT_REMINDER") it.copy(enabled = false) else it } + coEvery { api.getNotiState(token) } returns success(changed) + coEvery { api.patchNotiState(token, notifications) } returns BaseResponse(false, "ERROR", "", Unit) + expectFailure { recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-notification", runId) } + coVerify(exactly = 1) { api.patchNotiState(token, notifications) } + } + + @Test + fun `알림 snapshot이 없는 다른 flow는 알림 저장소와 API 없이 원본을 복구한다`() = + runTest { + coEvery { api.getProfile() } returnsMany listOf(success(profile("e2e$runId", "e2e-intro-$runId")), success(profile())) + coEvery { api.editProfile(original) } returns success(null) + coEvery { api.getPreferredExercises() } returns success(listOf(preference)) + recovery.restore(original, listOf(preference), null, null, "profile-edit", runId) + coVerify(exactly = 1) { api.editProfile(original) } + coVerify(exactly = 0) { fcm.getSavedToken() } + coVerify(exactly = 0) { api.getNotiState(any()) } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + } + + @Test + fun `알림 flow의 누락 snapshot은 원본이 같아도 실패하고 다른 영역을 조회한다`() = + runTest { + baseline() + expectFailure { recovery.restore(original, listOf(preference), null, null, "profile-notification", runId) } + coVerify(exactly = 2) { api.getProfile() } + coVerify(exactly = 2) { api.getPreferredExercises() } + coVerify(exactly = 0) { fcm.getSavedToken() } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + } + + @Test + fun `같은 실행을 다시 복구해도 이미 복구한 프로필을 다시 쓰지 않는다`() = + runTest { + baseline() + var current = profile("e2e$runId", "e2e-intro-$runId") + coEvery { api.getProfile() } coAnswers { success(current) } + coEvery { api.editProfile(original) } coAnswers { + current = profile() + success(null) + } + + recovery.restore(original, listOf(preference), null, null, "profile-edit", runId) + recovery.restore(original, listOf(preference), null, null, "profile-edit", runId) + + assertThat(current).isEqualTo(profile()) + coVerify(exactly = 1) { api.editProfile(original) } + coVerify(exactly = 4) { api.getProfile() } + coVerify(exactly = 0) { api.deletePreferredExercises(any()) } + coVerify(exactly = 0) { api.postPreferredExercises(any()) } + coVerify(exactly = 0) { api.patchPreferredExercises(any()) } + coVerify(exactly = 0) { fcm.getSavedToken() } + coVerify(exactly = 0) { fcm.registerToken() } + } + + @Test + fun `같은 실행의 추가 ID를 이미 삭제했으면 원본 ID와 기기 설정을 보존한다`() = + runTest { + baseline() + val extra = preference.copy(preferredExerciseId = 20, exerciseTypeId = 2) + var current = listOf(preference, extra) + coEvery { api.getPreferredExercises() } coAnswers { success(current) } + coEvery { api.deletePreferredExercises(DeletePreferredExerciseDTO(listOf(20))) } coAnswers { + current = listOf(preference) + success(null) + } + + recovery.restore(original, listOf(preference), notificationSnapshot, 2, "profile-preferred-exercise", runId) + recovery.restore(original, listOf(preference), notificationSnapshot, 2, "profile-preferred-exercise", runId) + + assertThat(current).containsExactly(preference) + coVerify(exactly = 1) { api.deletePreferredExercises(DeletePreferredExerciseDTO(listOf(20))) } + coVerify(exactly = 4) { api.getPreferredExercises() } + coVerify(exactly = 0) { api.editProfile(any()) } + coVerify(exactly = 0) { api.postPreferredExercises(any()) } + coVerify(exactly = 0) { api.patchPreferredExercises(any()) } + coVerify(exactly = 4) { fcm.getSavedToken() } + coVerify(exactly = 4) { api.getNotiState(token) } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + coVerify(exactly = 0) { fcm.registerToken() } + } + + @Test + fun `같은 실행의 알림을 다시 복구하면 같은 기기의 원본 행을 재조회만 한다`() = + runTest { + baseline() + var current = notifications.map { if (it.type == "WORKOUT_REMINDER") it.copy(enabled = false) else it } + coEvery { api.getNotiState(token) } coAnswers { success(current) } + coEvery { api.patchNotiState(token, notifications) } coAnswers { + current = notifications + success(null) + } + + recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-notification", runId) + recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-notification", runId) + + assertThat(current).containsExactlyElementsIn(notifications) + coVerify(exactly = 1) { api.patchNotiState(token, notifications) } + coVerify(exactly = 4) { api.getNotiState(token) } + coVerify(exactly = 4) { fcm.getSavedToken() } + coVerify(exactly = 0) { fcm.registerToken() } + coVerify(exactly = 0) { api.editProfile(any()) } + coVerify(exactly = 0) { api.deletePreferredExercises(any()) } + coVerify(exactly = 0) { api.postPreferredExercises(any()) } + coVerify(exactly = 0) { api.patchPreferredExercises(any()) } + } + + @Test + fun `복구 완료 뒤 재시도해도 바뀐 FCM identity는 거부한다`() = + runTest { + baseline() + recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-notification", runId) + coEvery { fcm.getSavedToken() } returns "different-device" + + expectFailure { recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-notification", runId) } + + coVerify(exactly = 2) { api.getNotiState(token) } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + coVerify(exactly = 0) { fcm.registerToken() } + } + + private fun baseline() { + coEvery { api.getProfile() } returns success(profile()) + coEvery { api.getPreferredExercises() } returns success(listOf(preference)) + coEvery { api.getNotiState(token) } returns success(notifications) + coEvery { fcm.getSavedToken() } returns token + } + + private fun profile( + nickname: String = original.nickname, + bio: String = original.bio, + ) = GetProfileDTO(null, null, nickname, original.gender, "1990-01-01", bio, 0, 0, 0, emptyList()) + + private fun success(data: T): BaseResponse = BaseResponse(true, "OK", "", data) + + private suspend fun expectFailure(operation: suspend () -> Unit) { + try { + operation() + throw AssertionError("복구 실패가 성공으로 처리되었습니다") + } catch (failure: MaestroSetupFailure) { + assertThat(failure.reason).isEqualTo("profile_restore_failed") + } + } +} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt index 3bdff5e1..0f8ad469 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt @@ -71,6 +71,7 @@ class ProfileEditFragment : Fragment() { val newProfileImageUri by viewModel.newProfileImageUri.collectAsStateWithLifecycle() val nicknameValidationState by viewModel.nicknameValidationState.collectAsStateWithLifecycle() val isNicknameChecked by viewModel.isNicknameChecked.collectAsStateWithLifecycle() + val isLoaded by viewModel.isLoaded.collectAsStateWithLifecycle() ProfileEditScreen( nickname = nickname, @@ -80,6 +81,7 @@ class ProfileEditFragment : Fragment() { newProfileImageUri = newProfileImageUri, nicknameValidationState = nicknameValidationState, isNicknameChecked = isNicknameChecked, + isLoaded = isLoaded, onNicknameChange = viewModel::updateNickname, onIntroductionChange = viewModel::updateIntroduction, onGenderSelect = viewModel::selectGender, diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt index dbc98eda..53a247de 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt @@ -82,6 +82,7 @@ fun ProfileEditScreen( onCompleteClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, + isLoaded: Boolean = true, ) { Column( modifier = @@ -89,7 +90,8 @@ fun ProfileEditScreen( .fillMaxSize() .background(ColorWhite300) // Maestro id 셀렉터가 이 화면 하위 testTag를 android:id처럼 찾게 합니다 - .semantics { testTagsAsResourceId = true }, + .semantics { testTagsAsResourceId = true } + .testTag(if (isLoaded) "profile_edit_loaded" else "profile_edit_loading"), ) { UndabangTopBar( title = stringResource(R.string.profile_edit), @@ -98,6 +100,7 @@ fun ProfileEditScreen( PrimaryButton( text = stringResource(PresentationR.string.complete), onClick = onCompleteClick, + enabled = isLoaded, isCompact = true, modifier = Modifier.padding(end = 20.dp), ) @@ -211,7 +214,8 @@ private fun NicknameSection( value = nickname, onValueChange = onNicknameChange, // Maestro가 중복 확인 전후 상태를 읽고 닉네임을 다시 입력할 지점 - modifier = Modifier.weight(1f).testTag("profile_edit_nickname_field"), + modifier = Modifier.weight(1f), + inputModifier = Modifier.testTag("profile_edit_nickname_field"), variant = TextFieldVariant.Underline, maxLength = 30, ) @@ -372,9 +376,8 @@ private fun IntroductionSection( modifier = Modifier .fillMaxWidth() - .defaultMinSize(minHeight = 120.dp) - // 값이 비어 있으면 힌트 텍스트가 대신 보입니다. copyTextFrom이 힌트를 읽을 수 있다는 점을 README에 남깁니다 - .testTag("profile_edit_introduction_field"), + .defaultMinSize(minHeight = 120.dp), + inputModifier = Modifier.testTag("profile_edit_introduction_field"), ) } } diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditViewModel.kt index 460d5b9f..d3cbee51 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditViewModel.kt @@ -34,6 +34,9 @@ class ProfileEditViewModel private val _initProfile = MutableStateFlow(null) val initProfile: StateFlow = _initProfile.asStateFlow() + private val _isLoaded = MutableStateFlow(false) + val isLoaded: StateFlow = _isLoaded.asStateFlow() + private val _nickname = MutableStateFlow("") val nickname: StateFlow = _nickname.asStateFlow() @@ -66,6 +69,7 @@ class ProfileEditViewModel } fun getProfile() { + _isLoaded.value = false viewModelScope.launch { when (val result = getUserProfileUseCase()) { is BaseResult.Success -> { @@ -74,6 +78,7 @@ class ProfileEditViewModel _nickname.value = profile.nickname _gender.value = profile.gender _introduction.value = profile.bio.orEmpty() + _isLoaded.value = true } is BaseResult.Error -> { @@ -104,6 +109,7 @@ class ProfileEditViewModel } fun completeEditProfile() { + if (!_isLoaded.value) return viewModelScope.launch { val currentNickname = _nickname.value val currentGender = _gender.value ?: return@launch diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseFragment.kt index 22a69cf1..10ef5d5e 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseFragment.kt @@ -49,6 +49,7 @@ class PreferredExerciseFragment : Fragment() { val uiModels by viewModel.exerciseUiModels.collectAsStateWithLifecycle() val selectedUiModels by viewModel.selectedExerciseUiModels.collectAsStateWithLifecycle() val completionState by viewModel.completionState.collectAsStateWithLifecycle() + val isLoaded by viewModel.isLoaded.collectAsStateWithLifecycle() BackHandler(enabled = step == PreferredExerciseStep.DETAIL) { step = PreferredExerciseStep.TYPE @@ -59,7 +60,7 @@ class PreferredExerciseFragment : Fragment() { nickname = viewModel.nickname, uiModels = uiModels, selectedUiModels = selectedUiModels, - isLoading = completionState is CompletionState.Loading, + isLoading = !isLoaded || completionState is CompletionState.Loading, formatter = formatter, onBackClick = { if (step == PreferredExerciseStep.DETAIL) { diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt index 77709778..576a96f7 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt @@ -19,8 +19,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -92,7 +94,8 @@ fun PreferredExerciseScreen( modifier .fillMaxSize() // Maestro id 셀렉터가 이 화면 하위 testTag를 android:id처럼 찾게 합니다 - .semantics { testTagsAsResourceId = true }, + .semantics { testTagsAsResourceId = true } + .testTag(if (isLoading) "preferred_exercise_loading" else "preferred_exercise_${step.name.lowercase()}_loaded"), ) { Column( modifier = @@ -121,21 +124,23 @@ fun PreferredExerciseScreen( }, ) - when (step) { - PreferredExerciseStep.TYPE -> - PreferredExerciseTypeContent( - nickname = nickname, - uiModels = uiModels, - onTypeClick = onTypeClick, - onLimitReached = onTypeLimitReached, - ) - PreferredExerciseStep.DETAIL -> - PreferredExerciseDetailContent( - items = selectedUiModels, - formatter = formatter, - onDayClick = onDayClick, - onSkillClick = onSkillClick, - ) + if (!isLoading) { + when (step) { + PreferredExerciseStep.TYPE -> + PreferredExerciseTypeContent( + nickname = nickname, + uiModels = uiModels, + onTypeClick = onTypeClick, + onLimitReached = onTypeLimitReached, + ) + PreferredExerciseStep.DETAIL -> + PreferredExerciseDetailContent( + items = selectedUiModels, + formatter = formatter, + onDayClick = onDayClick, + onSkillClick = onSkillClick, + ) + } } } @@ -160,11 +165,13 @@ private fun PreferredExerciseTypeContent( modifier = modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(horizontal = 20.dp), ) { Spacer(Modifier.height(44.dp)) Text( text = stringResource(R.string.preferred_exercise_type_title, nickname), + modifier = Modifier.testTag("preferred_exercise_type_start"), style = MaterialTheme.typography.contentBold, color = ColorBlack, ) @@ -215,7 +222,7 @@ private fun PreferredExerciseTypeChip( ) // Maestro가 미선택/선택 칩을 selected 속성으로 구분해 고릅니다. 종목 목록이 계정마다 달라 이름을 미리 고정할 수 없습니다 .semantics { selected = uiModel.isSelected } - .testTag("preferred_exercise_type_chip") + .testTag("preferred_exercise_type_${uiModel.exercise.exerciseTypeId}") .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -276,6 +283,7 @@ private fun PreferredExerciseDetailCard( modifier = modifier .fillMaxWidth() + .testTag("preferred_exercise_card_${uiModel.exercise.exerciseTypeId}") .padding(horizontal = 20.dp) .shadow(elevation = 2.dp, shape = RoundedCornerShape(15.dp), clip = false) .clip(RoundedCornerShape(15.dp)) @@ -341,7 +349,7 @@ private fun PreferredExerciseDetailCard( text = stringResource(resId), selected = uiModel.selectedDays[index], onClick = { onDayClick(index) }, - testTag = "preferred_exercise_day_btn", + testTag = "preferred_exercise_${uiModel.exercise.exerciseTypeId}_day_$index", modifier = Modifier.weight(1f).padding(5.dp).height(40.dp), ) } @@ -365,7 +373,7 @@ private fun PreferredExerciseDetailCard( text = stringResource(skill.resId), selected = uiModel.skillLevel == skill, onClick = { onSkillClick(skill) }, - testTag = "preferred_exercise_skill_btn", + testTag = "preferred_exercise_${uiModel.exercise.exerciseTypeId}_skill_${skill.name}", modifier = Modifier.weight(1f).padding(5.dp).height(36.dp), ) } diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModel.kt index 3e4584e2..2b5d536b 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModel.kt @@ -51,6 +51,9 @@ class PreferredExerciseViewModel private val _exerciseUiModels = MutableStateFlow>(emptyList()) val exerciseUiModels: StateFlow> = _exerciseUiModels.asStateFlow() + private val _isLoaded = MutableStateFlow(false) + val isLoaded: StateFlow = _isLoaded.asStateFlow() + val selectedExerciseUiModels: StateFlow> = _exerciseUiModels .map { list -> list.filter { it.isSelected } } @@ -80,6 +83,7 @@ class PreferredExerciseViewModel exerciseTypes.value = allTypesResult.data preferredExercise.value = exerciseResult.data updateUiModels() + _isLoaded.value = true } } } @@ -181,6 +185,7 @@ class PreferredExerciseViewModel * 선호 운동 설정 완료 */ fun completePreferredExerciseChanges() { + if (!_isLoaded.value || _completionState.value is CompletionState.Loading) return _completionState.value = CompletionState.Loading val selectedUiModels = _exerciseUiModels.value.filter { it.isSelected } diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt index 843fe301..b4f0b68d 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt @@ -48,12 +48,16 @@ class NotificationFragment : Fragment() { ComposeView(requireContext()).apply { applyAppTheme { val states by viewModel.notificationStates.collectAsStateWithLifecycle() + val syncState by viewModel.syncState.collectAsStateWithLifecycle() + val hasDevicePermission by viewModel.hasDevicePermission.collectAsStateWithLifecycle() val isExerciseOn = states.find { it.type == NotificationType.WORKOUT_REMINDER }?.enabled ?: false val isChattingOn = states.find { it.type == NotificationType.CHAT_MESSAGE }?.enabled ?: false NotificationScreen( isExerciseOn = isExerciseOn, isChattingOn = isChattingOn, + syncState = syncState, + hasDevicePermission = hasDevicePermission, onNavigateBack = { findNavController().popBackStack() }, onExerciseToggle = { viewModel.onSwitchToggled(NotificationType.WORKOUT_REMINDER, it) }, onChattingToggle = { viewModel.onSwitchToggled(NotificationType.CHAT_MESSAGE, it) }, diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt index 8f0a6e17..a58d7c12 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt @@ -8,6 +8,7 @@ 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.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch @@ -42,6 +43,8 @@ fun NotificationScreen( onExerciseToggle: (Boolean) -> Unit, onChattingToggle: (Boolean) -> Unit, modifier: Modifier = Modifier, + syncState: NotificationSyncState = NotificationSyncState.Ready, + hasDevicePermission: Boolean = true, ) { UndabangScaffold( // Maestro id 셀렉터가 이 화면 하위 testTag를 android:id처럼 찾게 합니다 @@ -49,7 +52,7 @@ fun NotificationScreen( topBar = { UndabangTopBar( title = stringResource(R.string.notification), - onNavigationClick = onNavigateBack, + onNavigationClick = { if (syncState != NotificationSyncState.Saving) onNavigateBack() }, ) }, ) { innerPadding -> @@ -57,20 +60,35 @@ fun NotificationScreen( modifier = Modifier .padding(innerPadding) - .verticalScroll(rememberScrollState()), + .verticalScroll(rememberScrollState()) + .testTag("notification_${syncState.name.lowercase()}"), ) { - NotificationSwitchRow( - labelRes = R.string.exercise_notification, - checked = isExerciseOn, - onCheckedChange = onExerciseToggle, - testTag = "exercise_noti_switch", - ) - NotificationSwitchRow( - labelRes = R.string.chatting_notification, - checked = isChattingOn, - onCheckedChange = onChattingToggle, - testTag = "chatting_noti_switch", - ) + when (syncState) { + NotificationSyncState.Loading -> CircularProgressIndicator() + NotificationSyncState.Error -> Text(text = stringResource(R.string.noti_state_failed)) + NotificationSyncState.Ready, NotificationSyncState.Saving -> { + if (!hasDevicePermission) { + Text( + text = stringResource(R.string.noti_permission_announce), + modifier = Modifier.testTag("notification_permission_denied"), + ) + } + NotificationSwitchRow( + labelRes = R.string.exercise_notification, + checked = isExerciseOn, + onCheckedChange = onExerciseToggle, + testTag = "exercise_noti_switch", + enabled = syncState == NotificationSyncState.Ready, + ) + NotificationSwitchRow( + labelRes = R.string.chatting_notification, + checked = isChattingOn, + onCheckedChange = onChattingToggle, + testTag = "chatting_noti_switch", + enabled = syncState == NotificationSyncState.Ready, + ) + } + } } } } @@ -82,6 +100,7 @@ private fun NotificationSwitchRow( onCheckedChange: (Boolean) -> Unit, testTag: String, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Column(modifier = modifier.fillMaxWidth()) { Row( @@ -101,6 +120,7 @@ private fun NotificationSwitchRow( Switch( checked = checked, onCheckedChange = onCheckedChange, + enabled = enabled, // 운동/채팅 알림 스위치를 구분해 토글·상태 검증에 씁니다 modifier = Modifier.testTag(testTag), colors = diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt index d439162c..1c381707 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt @@ -11,9 +11,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import timber.log.Timber import javax.inject.Inject +enum class NotificationSyncState { + Loading, + Ready, + Saving, + Error, +} + @HiltViewModel class NotificationViewModel @Inject @@ -24,111 +30,112 @@ class NotificationViewModel private val _notificationStates = MutableStateFlow>(emptyList()) val notificationStates: StateFlow> = _notificationStates + private val _syncState = MutableStateFlow(NotificationSyncState.Loading) + val syncState: StateFlow = _syncState + private val _permissionRequestTrigger = MutableStateFlow(false) val permissionRequestTrigger: StateFlow = _permissionRequestTrigger private val _toastMessage = MutableStateFlow(null) val toastMessage: StateFlow = _toastMessage - // 디바이스 권한 상태 - private var hasDevicePermission: Boolean = false - - // 대기 중인 알림 설정 타입 + private var serverStates: List = emptyList() + private val _hasDevicePermission = MutableStateFlow(false) + val hasDevicePermission: StateFlow = _hasDevicePermission private var pendingSettingType: NotificationType? = null - - private var isInitialized = false + private var busy = false fun initNotiState(isGranted: Boolean) { - this.hasDevicePermission = isGranted - if (isGranted) { - getNotificationState() - } else { - _notificationStates.value = - listOf( - NotificationState(NotificationType.WORKOUT_REMINDER, false), - NotificationState(NotificationType.CHAT_MESSAGE, false), - ) - } - isInitialized = true + updateNotiStateByPermission(isGranted) } fun updateNotiStateByPermission(isGranted: Boolean) { - if (!isInitialized) { - initNotiState(isGranted) - return - } - - val wasGranted = this.hasDevicePermission - this.hasDevicePermission = isGranted - - // 권한 거부 - if (!isGranted) { - // 모든 알림 설정을 false로 조정 - Timber.tag("NotificationViewModel").e("권한 거부로 모든 알림 설정 비활성화") - _notificationStates.value = _notificationStates.value.map { it.copy(enabled = false) } - } else if (!wasGranted) { // 권한 거부에서 허용으로 변경된 경우 - Timber.tag("NotificationViewModel").e("권한 허용으로 변경") - pendingSettingType?.let { - updateSetting(it, true) - pendingSettingType = null + _hasDevicePermission.value = isGranted + publishStates() + if (busy) return + busy = true + _syncState.value = NotificationSyncState.Loading + viewModelScope.launch { + try { + if (readStates()) { + val pending = pendingSettingType + if (_hasDevicePermission.value && pending != null) { + pendingSettingType = null + persist(pending, true) + } else { + _syncState.value = NotificationSyncState.Ready + } + } + } finally { + busy = false } } } - private fun getNotificationState() { + fun onSwitchToggled(type: NotificationType, isEnabled: Boolean) { + if (busy || _syncState.value != NotificationSyncState.Ready || type == NotificationType.UNKNOWN) return + if (!_hasDevicePermission.value) { + if (isEnabled) { + pendingSettingType = type + _permissionRequestTrigger.value = true + } + return + } + busy = true + _syncState.value = NotificationSyncState.Saving viewModelScope.launch { - when (val result = getNotiStateUseCase()) { - is BaseResult.Success -> { - _notificationStates.value = result.data - } - is BaseResult.Error -> { - _toastMessage.value = result.message - } + try { + persist(type, isEnabled) + } finally { + busy = false } } } - fun onSwitchToggled( - type: NotificationType, - isEnabled: Boolean, - ) { - // 권한이 없는 경우 - if (isEnabled && !hasDevicePermission) { - // 권한 허용 후에 적용할 설정을 저장 - pendingSettingType = type - _permissionRequestTrigger.value = true - return + private suspend fun persist(type: NotificationType, enabled: Boolean) { + _syncState.value = NotificationSyncState.Saving + // 전체 목록의 UNKNOWN 재전송은 원래 서버 type을 잃습니다. 변경한 행만 PATCH합니다. + when (val result = updateNotiSettingUseCase(listOf(NotificationState(type, enabled)))) { + is BaseResult.Success -> { + if (readStates(NotificationState(type, enabled))) _syncState.value = NotificationSyncState.Ready + } + is BaseResult.Error -> fail(result.message) } - updateSetting(type, isEnabled) } - private fun updateSetting( - type: NotificationType, - enabled: Boolean, - ) { - Timber.tag("NotificationViewModel").e("알림 설정 변경 요청: $type, enabled: $enabled") - viewModelScope.launch { - // UI를 먼저 변경 - val originalStates = _notificationStates.value - val newStates = - originalStates.map { - // 전달받은 type에 해당하는 아이템의 enabled 값만 변경 - if (it.type == type) it.copy(enabled = enabled) else it - } - _notificationStates.value = newStates - - when (val result = updateNotiSettingUseCase(newStates)) { - is BaseResult.Success -> { /* 성공 시 별도 처리 없음 */ } - is BaseResult.Error -> { - Timber.tag("NotificationViewModel").e("알림 설정 변경 실패: $type, enabled: $enabled") - // 실패 시 스위치 원상복구 - _notificationStates.value = originalStates - _toastMessage.value = result.message + private suspend fun readStates(expected: NotificationState? = null): Boolean { + return when (val result = getNotiStateUseCase()) { + is BaseResult.Success -> { + val states = result.data + if (states.count { it.type == NotificationType.WORKOUT_REMINDER } != 1 || + states.count { it.type == NotificationType.CHAT_MESSAGE } != 1 || + (expected != null && states.none { it.type == expected.type && it.enabled == expected.enabled }) + ) { + fail("알림 설정을 확인하지 못했습니다.") + false + } else { + serverStates = states + publishStates() + true } } + is BaseResult.Error -> { + fail(result.message) + false + } } } + private fun publishStates() { + // OS 권한은 표시만 바꾸고, 확인된 서버 값은 별도로 보존합니다. + _notificationStates.value = if (_hasDevicePermission.value) serverStates else serverStates.map { it.copy(enabled = false) } + } + + private fun fail(message: String?) { + _syncState.value = NotificationSyncState.Error + _toastMessage.value = message ?: "알림 설정을 확인하지 못했습니다." + } + fun onPermissionRequestHandled() { _permissionRequestTrigger.value = false } diff --git a/feature/profile/src/main/res/values/strings.xml b/feature/profile/src/main/res/values/strings.xml index 235dd295..a247545f 100644 --- a/feature/profile/src/main/res/values/strings.xml +++ b/feature/profile/src/main/res/values/strings.xml @@ -101,6 +101,7 @@ 알림이 활성화되었습니다. 알림을 받으려면 앱 설정에서 권한을 허용해주세요. 알림이 비활성화되었습니다. + 알림 설정을 확인하지 못했습니다. 차단 해제 아직 차단된 계정이 없어요 diff --git a/feature/profile/src/test/java/com/project200/undabang/profile/mypage/ProfileEditViewModelTest.kt b/feature/profile/src/test/java/com/project200/undabang/profile/mypage/ProfileEditViewModelTest.kt index 5bdbe266..ddf55c62 100644 --- a/feature/profile/src/test/java/com/project200/undabang/profile/mypage/ProfileEditViewModelTest.kt +++ b/feature/profile/src/test/java/com/project200/undabang/profile/mypage/ProfileEditViewModelTest.kt @@ -117,6 +117,18 @@ class ProfileEditViewModelTest { coVerify(exactly = 1) { mockGetUserProfileUseCase() } } + @Test + fun `프로필 조회 성공 전에는 loaded 상태와 저장 API를 허용하지 않는다`() = + runTest { + coEvery { mockGetUserProfileUseCase() } returns BaseResult.Success(sampleProfile) + createViewModel() + assertThat(viewModel.isLoaded.value).isFalse() + viewModel.completeEditProfile() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.isLoaded.value).isTrue() + coVerify(exactly = 0) { mockEditProfileUseCase(any(), any(), any()) } + } + @Test fun `getProfile - 성공 시 initProfile이 업데이트된다`() = runTest { @@ -145,6 +157,7 @@ class ProfileEditViewModelTest { // Then assertThat(awaitItem()).isEqualTo(ProfileEditErrorType.LOAD_FAILED) + assertThat(viewModel.isLoaded.value).isFalse() } } diff --git a/feature/profile/src/test/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModelTest.kt b/feature/profile/src/test/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModelTest.kt index ddfcf882..307bbc30 100644 --- a/feature/profile/src/test/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModelTest.kt +++ b/feature/profile/src/test/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModelTest.kt @@ -119,6 +119,21 @@ class PreferredExerciseViewModelTest { coVerify(exactly = 1) { mockGetPreferredExerciseUseCase() } } + @Test + fun `서버 조회 실패는 로드된 빈 목록이나 저장 가능한 상태가 아니다`() = + runTest { + coEvery { mockGetPreferredExerciseTypesUseCase() } returns BaseResult.Success(sampleExerciseTypes) + coEvery { mockGetPreferredExerciseUseCase() } returns BaseResult.Error("ERROR", "조회 실패") + createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.isLoaded.value).isFalse() + viewModel.completePreferredExerciseChanges() + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 0) { mockCreatePreferredExerciseUseCase(any()) } + coVerify(exactly = 0) { mockEditPreferredExerciseUseCase(any()) } + coVerify(exactly = 0) { mockDeletePreferredExerciseUseCase(any()) } + } + @Test fun `fetchInitialData - 성공 시 exerciseUiModels가 업데이트된다`() = runTest { @@ -129,6 +144,7 @@ class PreferredExerciseViewModelTest { testDispatcher.scheduler.advanceUntilIdle() val uiModels = viewModel.exerciseUiModels.value + assertThat(viewModel.isLoaded.value).isTrue() assertThat(uiModels).isNotNull() assertThat(uiModels).hasSize(2) assertThat(uiModels!!.first { it.exercise.exerciseTypeId == 1L }.isSelected).isTrue() diff --git a/feature/profile/src/test/java/com/project200/undabang/profile/setting/NotificationViewModelTest.kt b/feature/profile/src/test/java/com/project200/undabang/profile/setting/NotificationViewModelTest.kt index 3804fe5f..da74ead4 100644 --- a/feature/profile/src/test/java/com/project200/undabang/profile/setting/NotificationViewModelTest.kt +++ b/feature/profile/src/test/java/com/project200/undabang/profile/setting/NotificationViewModelTest.kt @@ -1,6 +1,5 @@ package com.project200.undabang.profile.setting -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.common.truth.Truth.assertThat import com.project200.domain.model.BaseResult import com.project200.domain.model.NotificationState @@ -11,6 +10,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.impl.annotations.MockK import io.mockk.junit4.MockKRule +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -22,33 +22,28 @@ import org.junit.Before import org.junit.Rule import org.junit.Test -@ExperimentalCoroutinesApi +@OptIn(ExperimentalCoroutinesApi::class) class NotificationViewModelTest { @get:Rule val mockkRule = MockKRule(this) - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - @MockK - private lateinit var mockGetNotiStateUseCase: GetNotificationStateUseCase + private lateinit var getState: GetNotificationStateUseCase @MockK - private lateinit var mockUpdateNotiSettingUseCase: UpdateNotificationStateUseCase - - private lateinit var viewModel: NotificationViewModel - - private val testDispatcher = StandardTestDispatcher() + private lateinit var updateState: UpdateNotificationStateUseCase - private val sampleNotificationStates = - listOf( - NotificationState(NotificationType.WORKOUT_REMINDER, true), - NotificationState(NotificationType.CHAT_MESSAGE, false), - ) + private val dispatcher = StandardTestDispatcher() + private val original = listOf( + NotificationState(NotificationType.WORKOUT_REMINDER, true), + NotificationState(NotificationType.CHAT_MESSAGE, false), + NotificationState(NotificationType.UNKNOWN, true), + ) @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + Dispatchers.setMain(dispatcher) + coEvery { getState() } returns BaseResult.Success(original) } @After @@ -56,168 +51,103 @@ class NotificationViewModelTest { Dispatchers.resetMain() } - private fun createViewModel(): NotificationViewModel { - return NotificationViewModel( - getNotiStateUseCase = mockGetNotiStateUseCase, - updateNotiSettingUseCase = mockUpdateNotiSettingUseCase, - ) - } - - @Test - fun `initNotiState - 권한 있을 때 getNotificationState가 호출된다`() = - runTest { - coEvery { mockGetNotiStateUseCase() } returns BaseResult.Success(sampleNotificationStates) - - viewModel = createViewModel() - viewModel.initNotiState(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - coVerify(exactly = 1) { mockGetNotiStateUseCase() } - assertThat(viewModel.notificationStates.value).isEqualTo(sampleNotificationStates) - } - - @Test - fun `initNotiState - 권한 없을 때 모든 알림이 비활성화된 기본 상태가 설정된다`() = - runTest { - viewModel = createViewModel() - viewModel.initNotiState(isGranted = false) - - val states = viewModel.notificationStates.value - assertThat(states).hasSize(2) - assertThat(states.all { !it.enabled }).isTrue() - } - @Test - fun `initNotiState - API 에러 시 toastMessage가 설정된다`() = - runTest { - coEvery { mockGetNotiStateUseCase() } returns BaseResult.Error("ERROR", "로드 실패") - - viewModel = createViewModel() - viewModel.initNotiState(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - assertThat(viewModel.toastMessage.value).isEqualTo("로드 실패") - } - - @Test - fun `updateNotiStateByPermission - 권한 거부 시 모든 알림이 비활성화된다`() = - runTest { - coEvery { mockGetNotiStateUseCase() } returns BaseResult.Success(sampleNotificationStates) - - viewModel = createViewModel() - viewModel.initNotiState(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - viewModel.updateNotiStateByPermission(isGranted = false) - - val states = viewModel.notificationStates.value - assertThat(states.all { !it.enabled }).isTrue() - } - - @Test - fun `updateNotiStateByPermission - 권한 거부에서 허용으로 변경 시 대기 중인 설정이 적용된다`() = - runTest { - coEvery { mockUpdateNotiSettingUseCase(any()) } returns BaseResult.Success(Unit) - - viewModel = createViewModel() - viewModel.initNotiState(isGranted = false) - - viewModel.onSwitchToggled(NotificationType.WORKOUT_REMINDER, true) - assertThat(viewModel.permissionRequestTrigger.value).isTrue() - - viewModel.updateNotiStateByPermission(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { mockUpdateNotiSettingUseCase(any()) } - } + fun `조회가 성공한 뒤에만 ready 상태가 된다`() = runTest { + val model = NotificationViewModel(getState, updateState) + model.initNotiState(true) + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Loading) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Ready) + assertThat(model.notificationStates.value).isEqualTo(original) + } @Test - fun `onSwitchToggled - 권한 없이 활성화 시도 시 권한 요청 트리거가 발생한다`() = - runTest { - viewModel = createViewModel() - viewModel.initNotiState(isGranted = false) - - viewModel.onSwitchToggled(NotificationType.WORKOUT_REMINDER, true) - - assertThat(viewModel.permissionRequestTrigger.value).isTrue() - } + fun `권한 거부 중에도 실제 서버 조회가 실패하면 OFF 성공 화면이 아니다`() = runTest { + coEvery { getState() } returns BaseResult.Error("ERROR", "로드 실패") + val model = NotificationViewModel(getState, updateState) + model.initNotiState(false) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) + assertThat(model.notificationStates.value).isEmpty() + assertThat(model.toastMessage.value).isEqualTo("로드 실패") + } @Test - fun `onSwitchToggled - 권한 있을 때 updateSetting이 호출된다`() = - runTest { - coEvery { mockGetNotiStateUseCase() } returns BaseResult.Success(sampleNotificationStates) - coEvery { mockUpdateNotiSettingUseCase(any()) } returns BaseResult.Success(Unit) - - viewModel = createViewModel() - viewModel.initNotiState(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - viewModel.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { mockUpdateNotiSettingUseCase(any()) } - } + fun `필수 알림 행이 없으면 기본 OFF로 대체하지 않는다`() = runTest { + coEvery { getState() } returns BaseResult.Success(original.filterNot { it.type == NotificationType.CHAT_MESSAGE }) + val model = NotificationViewModel(getState, updateState) + model.initNotiState(true) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) + } @Test - fun `updateSetting - 성공 시 UI가 업데이트된다`() = - runTest { - coEvery { mockGetNotiStateUseCase() } returns BaseResult.Success(sampleNotificationStates) - coEvery { mockUpdateNotiSettingUseCase(any()) } returns BaseResult.Success(Unit) - - viewModel = createViewModel() - viewModel.initNotiState(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - viewModel.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) - testDispatcher.scheduler.advanceUntilIdle() - - val chatMessageState = viewModel.notificationStates.value.find { it.type == NotificationType.CHAT_MESSAGE } - assertThat(chatMessageState?.enabled).isTrue() - } + fun `PATCH 완료와 재조회 전에는 낙관적 checked 상태나 다음 쓰기를 허용하지 않는다`() = runTest { + val patch = CompletableDeferred>() + coEvery { updateState(any()) } coAnswers { patch.await() } + val model = NotificationViewModel(getState, updateState) + model.initNotiState(true) + dispatcher.scheduler.advanceUntilIdle() + model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) + model.onSwitchToggled(NotificationType.WORKOUT_REMINDER, false) + dispatcher.scheduler.runCurrent() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Saving) + assertThat(model.notificationStates.value).isEqualTo(original) + coVerify(exactly = 1) { updateState(listOf(NotificationState(NotificationType.CHAT_MESSAGE, true))) } + coEvery { getState() } returns BaseResult.Success(original.map { if (it.type == NotificationType.CHAT_MESSAGE) it.copy(enabled = true) else it }) + patch.complete(BaseResult.Success(Unit)) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Ready) + assertThat(model.notificationStates.value.first { it.type == NotificationType.CHAT_MESSAGE }.enabled).isTrue() + } @Test - fun `updateSetting - 실패 시 UI가 원상복구되고 토스트 메시지가 설정된다`() = - runTest { - coEvery { mockGetNotiStateUseCase() } returns BaseResult.Success(sampleNotificationStates) - coEvery { mockUpdateNotiSettingUseCase(any()) } returns BaseResult.Error("ERROR", "업데이트 실패") - - viewModel = createViewModel() - viewModel.initNotiState(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - val originalState = viewModel.notificationStates.value.find { it.type == NotificationType.CHAT_MESSAGE }?.enabled - - viewModel.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) - testDispatcher.scheduler.advanceUntilIdle() - - val revertedState = viewModel.notificationStates.value.find { it.type == NotificationType.CHAT_MESSAGE }?.enabled - assertThat(revertedState).isEqualTo(originalState) - assertThat(viewModel.toastMessage.value).isEqualTo("업데이트 실패") - } + fun `PATCH 실패 또는 재조회 불일치는 settled 성공이 아니다`() = runTest { + coEvery { updateState(any()) } returns BaseResult.Error("ERROR", "업데이트 실패") + val model = NotificationViewModel(getState, updateState) + model.initNotiState(true) + dispatcher.scheduler.advanceUntilIdle() + model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) + assertThat(model.toastMessage.value).isEqualTo("업데이트 실패") + assertThat(model.notificationStates.value).isEqualTo(original) + model.updateNotiStateByPermission(true) + dispatcher.scheduler.advanceUntilIdle() + coEvery { updateState(any()) } returns BaseResult.Success(Unit) + model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) + } @Test - fun `onPermissionRequestHandled - 트리거가 리셋된다`() = - runTest { - viewModel = createViewModel() - viewModel.initNotiState(isGranted = false) - - viewModel.onSwitchToggled(NotificationType.WORKOUT_REMINDER, true) - assertThat(viewModel.permissionRequestTrigger.value).isTrue() - - viewModel.onPermissionRequestHandled() - - assertThat(viewModel.permissionRequestTrigger.value).isFalse() - } + fun `권한 거부는 화면만 OFF로 만들고 서버 설정을 수정하지 않는다`() = runTest { + val model = NotificationViewModel(getState, updateState) + model.initNotiState(false) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.notificationStates.value.all { !it.enabled }).isTrue() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Ready) + coVerify(exactly = 0) { updateState(any()) } + model.updateNotiStateByPermission(true) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.notificationStates.value).isEqualTo(original) + } @Test - fun `updateNotiStateByPermission - 초기화 전 호출 시 initNotiState가 호출된다`() = - runTest { - coEvery { mockGetNotiStateUseCase() } returns BaseResult.Success(sampleNotificationStates) - - viewModel = createViewModel() - viewModel.updateNotiStateByPermission(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - coVerify(exactly = 1) { mockGetNotiStateUseCase() } - } + fun `권한 요청은 처리 후 초기화되고 허용 후 해당 type만 저장한다`() = runTest { + coEvery { updateState(any()) } returns BaseResult.Success(Unit) + val enabled = original.map { if (it.type == NotificationType.CHAT_MESSAGE) it.copy(enabled = true) else it } + val model = NotificationViewModel(getState, updateState) + model.updateNotiStateByPermission(false) + dispatcher.scheduler.advanceUntilIdle() + model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) + assertThat(model.permissionRequestTrigger.value).isTrue() + model.onPermissionRequestHandled() + assertThat(model.permissionRequestTrigger.value).isFalse() + coEvery { getState() } returnsMany listOf(BaseResult.Success(original), BaseResult.Success(enabled)) + model.updateNotiStateByPermission(true) + dispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 1) { updateState(listOf(NotificationState(NotificationType.CHAT_MESSAGE, true))) } + assertThat(model.notificationStates.value).isEqualTo(enabled) + } } From ecaff42db34623d9d2ddf155d8a98ba1e9022a4f Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:59:17 +0900 Subject: [PATCH 03/14] =?UTF-8?q?style(maestro):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=A0=95=EC=B1=85=20=EC=A4=84=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../undabang/e2e/MaestroProfilePolicy.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt index 3c953ca5..dd246e51 100644 --- a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt @@ -14,7 +14,11 @@ internal object MaestroProfilePolicy { val restore: List, ) - fun validateText(nickname: String, gender: String, bio: String?) { + fun validateText( + nickname: String, + gender: String, + bio: String?, + ) { // Maestro Android inputText로 원문을 정확히 복구할 수 있는 전용 계정만 허용합니다. if (!nicknamePattern.matches(nickname) || gender !in genders || bio == null || bio.length > 4096 || !bioPattern.matches(bio) @@ -65,10 +69,11 @@ internal object MaestroProfilePolicy { } return PreferenceRecovery( deleteIds = added.map { it.preferredExerciseId }, - restore = baseline.filter { - val actual = currentById.getValue(it.preferredExerciseId) - it.skillLevel != actual.skillLevel || it.daysOfWeek != actual.daysOfWeek - }, + restore = + baseline.filter { + val actual = currentById.getValue(it.preferredExerciseId) + it.skillLevel != actual.skillLevel || it.daysOfWeek != actual.daysOfWeek + }, ) } From 474ed0e66a21042538ce9fac7853d2713a2385c6 Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:40:17 +0900 Subject: [PATCH 04/14] =?UTF-8?q?style(profile):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=91=9C=ED=98=84=EC=8B=9D=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../setting/NotificationViewModelTest.kt | 195 ++++++++++-------- 1 file changed, 104 insertions(+), 91 deletions(-) diff --git a/feature/profile/src/test/java/com/project200/undabang/profile/setting/NotificationViewModelTest.kt b/feature/profile/src/test/java/com/project200/undabang/profile/setting/NotificationViewModelTest.kt index da74ead4..a1944d14 100644 --- a/feature/profile/src/test/java/com/project200/undabang/profile/setting/NotificationViewModelTest.kt +++ b/feature/profile/src/test/java/com/project200/undabang/profile/setting/NotificationViewModelTest.kt @@ -34,11 +34,12 @@ class NotificationViewModelTest { private lateinit var updateState: UpdateNotificationStateUseCase private val dispatcher = StandardTestDispatcher() - private val original = listOf( - NotificationState(NotificationType.WORKOUT_REMINDER, true), - NotificationState(NotificationType.CHAT_MESSAGE, false), - NotificationState(NotificationType.UNKNOWN, true), - ) + private val original = + listOf( + NotificationState(NotificationType.WORKOUT_REMINDER, true), + NotificationState(NotificationType.CHAT_MESSAGE, false), + NotificationState(NotificationType.UNKNOWN, true), + ) @Before fun setUp() { @@ -52,102 +53,114 @@ class NotificationViewModelTest { } @Test - fun `조회가 성공한 뒤에만 ready 상태가 된다`() = runTest { - val model = NotificationViewModel(getState, updateState) - model.initNotiState(true) - assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Loading) - dispatcher.scheduler.advanceUntilIdle() - assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Ready) - assertThat(model.notificationStates.value).isEqualTo(original) - } + fun `조회가 성공한 뒤에만 ready 상태가 된다`() = + runTest { + val model = NotificationViewModel(getState, updateState) + model.initNotiState(true) + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Loading) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Ready) + assertThat(model.notificationStates.value).isEqualTo(original) + } @Test - fun `권한 거부 중에도 실제 서버 조회가 실패하면 OFF 성공 화면이 아니다`() = runTest { - coEvery { getState() } returns BaseResult.Error("ERROR", "로드 실패") - val model = NotificationViewModel(getState, updateState) - model.initNotiState(false) - dispatcher.scheduler.advanceUntilIdle() - assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) - assertThat(model.notificationStates.value).isEmpty() - assertThat(model.toastMessage.value).isEqualTo("로드 실패") - } + fun `권한 거부 중에도 실제 서버 조회가 실패하면 OFF 성공 화면이 아니다`() = + runTest { + coEvery { getState() } returns BaseResult.Error("ERROR", "로드 실패") + val model = NotificationViewModel(getState, updateState) + model.initNotiState(false) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) + assertThat(model.notificationStates.value).isEmpty() + assertThat(model.toastMessage.value).isEqualTo("로드 실패") + } @Test - fun `필수 알림 행이 없으면 기본 OFF로 대체하지 않는다`() = runTest { - coEvery { getState() } returns BaseResult.Success(original.filterNot { it.type == NotificationType.CHAT_MESSAGE }) - val model = NotificationViewModel(getState, updateState) - model.initNotiState(true) - dispatcher.scheduler.advanceUntilIdle() - assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) - } + fun `필수 알림 행이 없으면 기본 OFF로 대체하지 않는다`() = + runTest { + coEvery { getState() } returns BaseResult.Success(original.filterNot { it.type == NotificationType.CHAT_MESSAGE }) + val model = NotificationViewModel(getState, updateState) + model.initNotiState(true) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) + } @Test - fun `PATCH 완료와 재조회 전에는 낙관적 checked 상태나 다음 쓰기를 허용하지 않는다`() = runTest { - val patch = CompletableDeferred>() - coEvery { updateState(any()) } coAnswers { patch.await() } - val model = NotificationViewModel(getState, updateState) - model.initNotiState(true) - dispatcher.scheduler.advanceUntilIdle() - model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) - model.onSwitchToggled(NotificationType.WORKOUT_REMINDER, false) - dispatcher.scheduler.runCurrent() - assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Saving) - assertThat(model.notificationStates.value).isEqualTo(original) - coVerify(exactly = 1) { updateState(listOf(NotificationState(NotificationType.CHAT_MESSAGE, true))) } - coEvery { getState() } returns BaseResult.Success(original.map { if (it.type == NotificationType.CHAT_MESSAGE) it.copy(enabled = true) else it }) - patch.complete(BaseResult.Success(Unit)) - dispatcher.scheduler.advanceUntilIdle() - assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Ready) - assertThat(model.notificationStates.value.first { it.type == NotificationType.CHAT_MESSAGE }.enabled).isTrue() - } + fun `PATCH 완료와 재조회 전에는 낙관적 checked 상태나 다음 쓰기를 허용하지 않는다`() = + runTest { + val patch = CompletableDeferred>() + coEvery { updateState(any()) } coAnswers { patch.await() } + val model = NotificationViewModel(getState, updateState) + model.initNotiState(true) + dispatcher.scheduler.advanceUntilIdle() + model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) + model.onSwitchToggled(NotificationType.WORKOUT_REMINDER, false) + dispatcher.scheduler.runCurrent() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Saving) + assertThat(model.notificationStates.value).isEqualTo(original) + coVerify(exactly = 1) { updateState(listOf(NotificationState(NotificationType.CHAT_MESSAGE, true))) } + coEvery { getState() } returns + BaseResult.Success( + original.map { + if (it.type == NotificationType.CHAT_MESSAGE) it.copy(enabled = true) else it + }, + ) + patch.complete(BaseResult.Success(Unit)) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Ready) + assertThat(model.notificationStates.value.first { it.type == NotificationType.CHAT_MESSAGE }.enabled).isTrue() + } @Test - fun `PATCH 실패 또는 재조회 불일치는 settled 성공이 아니다`() = runTest { - coEvery { updateState(any()) } returns BaseResult.Error("ERROR", "업데이트 실패") - val model = NotificationViewModel(getState, updateState) - model.initNotiState(true) - dispatcher.scheduler.advanceUntilIdle() - model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) - dispatcher.scheduler.advanceUntilIdle() - assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) - assertThat(model.toastMessage.value).isEqualTo("업데이트 실패") - assertThat(model.notificationStates.value).isEqualTo(original) - model.updateNotiStateByPermission(true) - dispatcher.scheduler.advanceUntilIdle() - coEvery { updateState(any()) } returns BaseResult.Success(Unit) - model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) - dispatcher.scheduler.advanceUntilIdle() - assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) - } + fun `PATCH 실패 또는 재조회 불일치는 settled 성공이 아니다`() = + runTest { + coEvery { updateState(any()) } returns BaseResult.Error("ERROR", "업데이트 실패") + val model = NotificationViewModel(getState, updateState) + model.initNotiState(true) + dispatcher.scheduler.advanceUntilIdle() + model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) + assertThat(model.toastMessage.value).isEqualTo("업데이트 실패") + assertThat(model.notificationStates.value).isEqualTo(original) + model.updateNotiStateByPermission(true) + dispatcher.scheduler.advanceUntilIdle() + coEvery { updateState(any()) } returns BaseResult.Success(Unit) + model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) + } @Test - fun `권한 거부는 화면만 OFF로 만들고 서버 설정을 수정하지 않는다`() = runTest { - val model = NotificationViewModel(getState, updateState) - model.initNotiState(false) - dispatcher.scheduler.advanceUntilIdle() - assertThat(model.notificationStates.value.all { !it.enabled }).isTrue() - assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Ready) - coVerify(exactly = 0) { updateState(any()) } - model.updateNotiStateByPermission(true) - dispatcher.scheduler.advanceUntilIdle() - assertThat(model.notificationStates.value).isEqualTo(original) - } + fun `권한 거부는 화면만 OFF로 만들고 서버 설정을 수정하지 않는다`() = + runTest { + val model = NotificationViewModel(getState, updateState) + model.initNotiState(false) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.notificationStates.value.all { !it.enabled }).isTrue() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Ready) + coVerify(exactly = 0) { updateState(any()) } + model.updateNotiStateByPermission(true) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.notificationStates.value).isEqualTo(original) + } @Test - fun `권한 요청은 처리 후 초기화되고 허용 후 해당 type만 저장한다`() = runTest { - coEvery { updateState(any()) } returns BaseResult.Success(Unit) - val enabled = original.map { if (it.type == NotificationType.CHAT_MESSAGE) it.copy(enabled = true) else it } - val model = NotificationViewModel(getState, updateState) - model.updateNotiStateByPermission(false) - dispatcher.scheduler.advanceUntilIdle() - model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) - assertThat(model.permissionRequestTrigger.value).isTrue() - model.onPermissionRequestHandled() - assertThat(model.permissionRequestTrigger.value).isFalse() - coEvery { getState() } returnsMany listOf(BaseResult.Success(original), BaseResult.Success(enabled)) - model.updateNotiStateByPermission(true) - dispatcher.scheduler.advanceUntilIdle() - coVerify(exactly = 1) { updateState(listOf(NotificationState(NotificationType.CHAT_MESSAGE, true))) } - assertThat(model.notificationStates.value).isEqualTo(enabled) - } + fun `권한 요청은 처리 후 초기화되고 허용 후 해당 type만 저장한다`() = + runTest { + coEvery { updateState(any()) } returns BaseResult.Success(Unit) + val enabled = original.map { if (it.type == NotificationType.CHAT_MESSAGE) it.copy(enabled = true) else it } + val model = NotificationViewModel(getState, updateState) + model.updateNotiStateByPermission(false) + dispatcher.scheduler.advanceUntilIdle() + model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) + assertThat(model.permissionRequestTrigger.value).isTrue() + model.onPermissionRequestHandled() + assertThat(model.permissionRequestTrigger.value).isFalse() + coEvery { getState() } returnsMany listOf(BaseResult.Success(original), BaseResult.Success(enabled)) + model.updateNotiStateByPermission(true) + dispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 1) { updateState(listOf(NotificationState(NotificationType.CHAT_MESSAGE, true))) } + assertThat(model.notificationStates.value).isEqualTo(enabled) + } } From dcbdc7711b9fc77d7756bd42b3bb2b682034402f Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:39:11 +0900 Subject: [PATCH 05/14] =?UTF-8?q?fix(maestro):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20API=2034=20=EC=84=A0=ED=83=9D=EA=B3=BC=20ktlint=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .maestro/README-profile.md | 8 +++++--- .maestro/flows/profile-notification.yaml | 2 +- .maestro/suites/profile.json | 2 +- .../undabang/profile/setting/NotificationViewModel.kt | 10 ++++++++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.maestro/README-profile.md b/.maestro/README-profile.md index 7dd7cbbc..85735a78 100644 --- a/.maestro/README-profile.md +++ b/.maestro/README-profile.md @@ -9,7 +9,9 @@ | `profile-preferred-exercise` | Reject incomplete/empty selections, create one real catalog preference, persist Monday/BEGINNER, add Wednesday, remove Monday, verify both changes after re-entry, and delete only the added preference. | | `profile-notification` | Persist OFF→ON, deny the actual Android notification permission, show OFF without writing server settings, grant permission, read the persisted ON values, and persist OFF again. | -The shared runner selects this descriptor with `--suite profile`; `--suite ci` discovers it automatically. The descriptor requires **API33 or newer** and `fixture: true`. The complete denied-permission branch is mandatory. API30 cannot exercise this behavior. Use the shared runner's disposable CI emulator, installed E2E APK, required ABI, real DEV/Firebase configuration, and existing private authentication bootstrap. Do not invoke these YAML files directly against a personal account. +The shared runner selects this descriptor with `--suite profile`; `--suite ci` discovers it automatically. **API33 is the functional minimum** for the notification-permission scenario. The descriptor explicitly selects **API34** as the CI candidate and keeps `fixture: true`. All four flows and the complete denied-permission branch are mandatory. API30 cannot exercise this behavior. Use the shared runner's disposable CI emulator, installed E2E APK, required ABI, real DEV/Firebase configuration, and existing private authentication bootstrap. Do not invoke these YAML files directly against a personal account. + +The API34 `google_apis`/`x86_64` candidate has source evidence: Google's [official `build.prop`](https://android.googlesource.com/platform/prebuilts/android-emulator-build/system-images/+/fd510d08d1ee747159b42cc05a22d8e65eade830/generic/system-images/android-34/google_apis/x86_64/build.prop?format=HTML) declares `arm64-v8a`, `libndk_translation.so`, and `userdebug`. The matching [source.properties](https://android.googlesource.com/platform/prebuilts/android-emulator-build/system-images/+/fd510d08d1ee747159b42cc05a22d8e65eade830/generic/system-images/android-34/google_apis/x86_64/source.properties?format=HTML) identifies historical image revision 8, not the current CI image. This evidence does not guarantee runtime compatibility. No successful API34 profile CI run is established. The installed image must still pass the existing root, locale, input, ABI, and installation guards, followed by all four authenticated flows and cleanup. No image override or automatic fallback replaces those checks. ## Real prerequisites @@ -34,7 +36,7 @@ Only absent or blank tokens are polled. Storage failures, registration failures, The fixture does not wait for `MainViewModel` or the normal registration worker. Firebase initialization occurs at application startup; the manifest retains the real messaging service and does not disable auto-initialization. No new Firebase API or manual persistence path is used. The other three profile flows never open notification settings and do not require, read, register, or restore notification state in new runs. -CI still needs valid `GOOGLE_SERVICES_JSON_DEV_BASE64` for `com.project200.undabang`, working Google Play services on the API33+ `google_apis` emulator, network access to Firebase and DEV APIs, and valid dedicated-account authentication. Source inspection cannot establish these external conditions. If Firebase cannot supply a token within the limit, preparation fails before feature mutation. Initial device registration can create real server device settings; their returned values, not assumed defaults, become the notification baseline. This suite does not redesign authentication or remove backend device-registration records. +CI still needs valid `GOOGLE_SERVICES_JSON_DEV_BASE64` for `com.project200.undabang`, working Google Play services on the selected API34 `google_apis` emulator, network access to Firebase and DEV APIs, and valid dedicated-account authentication. Source inspection cannot establish these external conditions. If Firebase cannot supply a token within the limit, preparation fails before feature mutation. Initial device registration can create real server device settings; their returned values, not assumed defaults, become the notification baseline. This suite does not redesign authentication or remove backend device-registration records. The host serializes the dedicated account. Run IDs are the shared 16-character ASCII hex `E2E_RUN_ID`. Generated nickname/bio values and the chosen catalog ID are internal nonsecret Maestro variables. Credentials and the actual FCM token never become Maestro command-line variables. @@ -58,4 +60,4 @@ Profile fields put test tags on `UndabangTextField.inputModifier`, not its layou Behavioral coverage is in profile ViewModel tests and `app/src/testE2e/.../MaestroProfilePolicyTest.kt`, `MaestroProfileRecoveryTest.kt`, and `MaestroProfileNotificationReadinessTest.kt`. The readiness tests cover delayed callback storage, the timeout boundary, absent/blank tokens, backend errors/timeouts, token rotation, missing rows, cancellation, and notification-only scope. The recovery tests cover original-ID preservation, exact owned deletion, raw additional notification types, backend read errors, wrong device identity, failed readback, and explicit notification absence. They retain cross-flow recovery checks for existing notification snapshots. Additional tests call restoration twice with the same baseline to cover profile, preference, and notification idempotence, plus rejection of token rotation before a retry. These are unit-test doubles only; the actual fixture and all four Maestro scenarios call the real backend. -No build, unit test, lint, emulator, Firebase registration, or Maestro result is established by this document. The controller must run the integrated E2E build, profile/ViewModel/E2E unit tests, and the API33+ authenticated suite in approved CI. Actual account baselines, FCM registration, and remote flow results are runtime prerequisites, not source-inspection evidence. +No build, unit test, lint, emulator, Firebase registration, or Maestro result is established by this document. The controller must run the integrated E2E build, profile/ViewModel/E2E unit tests, and the selected API34 authenticated suite in approved CI. Actual image compatibility, account baselines, FCM registration, and remote flow results are runtime prerequisites, not source-inspection evidence. diff --git a/.maestro/flows/profile-notification.yaml b/.maestro/flows/profile-notification.yaml index 5d0f82aa..7700c951 100644 --- a/.maestro/flows/profile-notification.yaml +++ b/.maestro/flows/profile-notification.yaml @@ -49,7 +49,7 @@ tags: - assertVisible: id: chatting_noti_switch checked: true -# apiLevel33 descriptor에서 실제 POST_NOTIFICATIONS 권한을 거부합니다. +# apiLevel34 descriptor에서 실제 POST_NOTIFICATIONS 권한을 거부합니다. - runFlow: ../subflows/profile-notification-denied.yaml - tapOn: id: profile_nav_graph diff --git a/.maestro/suites/profile.json b/.maestro/suites/profile.json index 6251e433..c24b556f 100644 --- a/.maestro/suites/profile.json +++ b/.maestro/suites/profile.json @@ -5,7 +5,7 @@ "profile-preferred-exercise", "profile-notification" ], - "apiLevel": 33, + "apiLevel": 34, "fixture": true, "requiredEnvironment": [], "driver": null diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt index 1c381707..26f4efdd 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt @@ -72,7 +72,10 @@ class NotificationViewModel } } - fun onSwitchToggled(type: NotificationType, isEnabled: Boolean) { + fun onSwitchToggled( + type: NotificationType, + isEnabled: Boolean, + ) { if (busy || _syncState.value != NotificationSyncState.Ready || type == NotificationType.UNKNOWN) return if (!_hasDevicePermission.value) { if (isEnabled) { @@ -92,7 +95,10 @@ class NotificationViewModel } } - private suspend fun persist(type: NotificationType, enabled: Boolean) { + private suspend fun persist( + type: NotificationType, + enabled: Boolean, + ) { _syncState.value = NotificationSyncState.Saving // 전체 목록의 UNKNOWN 재전송은 원래 서버 type을 잃습니다. 변경한 행만 PATCH합니다. when (val result = updateNotiSettingUseCase(listOf(NotificationState(type, enabled)))) { From ede010c2695bdfff07275f87dd7a7b7720b40621 Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:28:48 +0900 Subject: [PATCH 06/14] =?UTF-8?q?test(profile):=20=EC=A4=80=EB=B9=84=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EA=B3=A0=EC=A0=95=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A7=84=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .maestro/README-profile.md | 4 +- .maestro/scripts/run-tests.py | 29 +++++++++++- .maestro/scripts/test_run_tests.py | 76 ++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/.maestro/README-profile.md b/.maestro/README-profile.md index 85735a78..f9021275 100644 --- a/.maestro/README-profile.md +++ b/.maestro/README-profile.md @@ -19,6 +19,8 @@ The existing `MAESTRO_AUTH_STATE_JSON`, `MAESTRO_MEMBER_ID`, and `MAESTRO_FIXTUR Preparation reads the authenticated member's real profile and preference records before any feature write. Only `profile-notification` requires device registration and notification settings. Any unsuccessful response or missing required data fails preparation. +For failed profile preparation, the runner reports only exact fixed codes reviewed in `MaestroProfileFixture`, `MaestroProfilePolicy`, and `MaestroProfileNotificationReadiness`. The diagnostic mapping applies only to `prepare` + `profile` + `status: failed`. Unknown strings, prefixes with extra text, non-string reasons, other features, cleanup, and other statuses retain the generic bootstrap error. Profile values, tokens, exception messages, and arbitrary server responses are never included. The transport regression tests preserve the existing synthetic-secret redaction case and cover these boundaries. + - The original nickname must match `[A-Za-z0-9]{1,30}`. The original gender must be one of the app's `MALE`, `FEMALE`, or `UNKNOWN` values. - The bio must be non-null, nonempty, at most 4096 characters, and match `[A-Za-z0-9][A-Za-z0-9 _-]*`. These explicit restrictions permit exact Android `inputText` restoration. Null, Unicode, punctuation outside that set, and control characters fail **before mutation**. The fixture does not replace the account baseline with test text. - Every original preference must have a positive unique record ID/type ID, seven day booleans with at least one true day, and a recognized skill. The preference scenario requires 1–4 original selections and at least one real unselected catalog type. Preparation obtains its ID/name from the real catalog; the flow never chooses an arbitrary offscreen chip. @@ -60,4 +62,4 @@ Profile fields put test tags on `UndabangTextField.inputModifier`, not its layou Behavioral coverage is in profile ViewModel tests and `app/src/testE2e/.../MaestroProfilePolicyTest.kt`, `MaestroProfileRecoveryTest.kt`, and `MaestroProfileNotificationReadinessTest.kt`. The readiness tests cover delayed callback storage, the timeout boundary, absent/blank tokens, backend errors/timeouts, token rotation, missing rows, cancellation, and notification-only scope. The recovery tests cover original-ID preservation, exact owned deletion, raw additional notification types, backend read errors, wrong device identity, failed readback, and explicit notification absence. They retain cross-flow recovery checks for existing notification snapshots. Additional tests call restoration twice with the same baseline to cover profile, preference, and notification idempotence, plus rejection of token rotation before a retry. These are unit-test doubles only; the actual fixture and all four Maestro scenarios call the real backend. -No build, unit test, lint, emulator, Firebase registration, or Maestro result is established by this document. The controller must run the integrated E2E build, profile/ViewModel/E2E unit tests, and the selected API34 authenticated suite in approved CI. Actual image compatibility, account baselines, FCM registration, and remote flow results are runtime prerequisites, not source-inspection evidence. +At `dcbdc771`, [normal CI](https://github.com/projects200/android/actions/runs/38888921656) passed. [Maestro CI](https://github.com/projects200/android/actions/runs/38888921682) installed the API34 E2E APK and passed the two smoke flows, but all four profile flows failed at `bootstrap-prepare-failed` before their UI commands. That generic code does not identify the failed prerequisite. The fixed-code diagnostics now distinguish reviewed preparation failures; their runtime result and the full profile suite still require approved CI. No local build, test, lint, emulator, Firebase, or Maestro command was run for this change. diff --git a/.maestro/scripts/run-tests.py b/.maestro/scripts/run-tests.py index 66dda940..25d33789 100755 --- a/.maestro/scripts/run-tests.py +++ b/.maestro/scripts/run-tests.py @@ -52,6 +52,27 @@ "MAESTRO_MATCHING_SEARCH_QUERY", "MAESTRO_MATCHING_CATALOG_PLACE_NAME", "MAESTRO_MATCHING_MARKER_POINT", "MAESTRO_MATCHING_ROOM_ID", }) +PROFILE_PREPARE_FAILURES = { + "profile_recovery_pending": "bootstrap-prepare-failed-profile-recovery-pending", + "profile_fixture_read_failed": "bootstrap-prepare-failed-profile-fixture-read-failed", + "profile_baseline_text_unsupported": "bootstrap-prepare-failed-profile-baseline-text-unsupported", + "profile_preferences_invalid": "bootstrap-prepare-failed-profile-preferences-invalid", + "profile_preference_baseline_unavailable": + "bootstrap-prepare-failed-profile-preference-baseline-unavailable", + "profile_catalog_invalid": "bootstrap-prepare-failed-profile-catalog-invalid", + "profile_unselected_exercise_missing": "bootstrap-prepare-failed-profile-unselected-exercise-missing", + "profile_fcm_token_timeout": "bootstrap-prepare-failed-profile-fcm-token-timeout", + "profile_fcm_registration_failed": "bootstrap-prepare-failed-profile-fcm-registration-failed", + "profile_fcm_registration_timeout": "bootstrap-prepare-failed-profile-fcm-registration-timeout", + "profile_fcm_settings_timeout": "bootstrap-prepare-failed-profile-fcm-settings-timeout", + "profile_notifications_invalid": "bootstrap-prepare-failed-profile-notifications-invalid", + "profile_fcm_storage_failed": "bootstrap-prepare-failed-profile-fcm-storage-failed", + "profile_fcm_identity_changed": "bootstrap-prepare-failed-profile-fcm-identity-changed", + "profile_snapshot_write_failed": "bootstrap-prepare-failed-profile-snapshot-write-failed", + "profile_fixture_write_failed": "bootstrap-prepare-failed-profile-fixture-write-failed", + "profile_notification_prepare_failed": "bootstrap-prepare-failed-profile-notification-prepare-failed", + "profile_request_invalid": "bootstrap-prepare-failed-profile-request-invalid", +} class RunnerFailure(Exception): @@ -729,8 +750,12 @@ def bootstrap(self, operation, credentials=None, timeout=300, feature=None, flow except (ValueError, TypeError): raise RunnerFailure("invalid-bootstrap-result") from None if not isinstance(result, dict) or result.get("status") != expected: - # 앱의 reason도 외부 입력입니다. 로그/리포트에는 고정 오류만 남깁니다. - raise RunnerFailure("bootstrap-" + operation + "-failed") + # 앱의 reason도 외부 입력이므로 검토된 프로필 준비 코드만 고정 오류로 공개합니다. + failure = "bootstrap-" + operation + "-failed" + if (operation == "prepare" and feature == "profile" and isinstance(result, dict) + and result.get("status") == "failed" and isinstance(result.get("reason"), str)): + failure = PROFILE_PREPARE_FAILURES.get(result["reason"], failure) + raise RunnerFailure(failure) variables = safe_variables(result.get("variables", {})) self.remove_private() return variables diff --git a/.maestro/scripts/test_run_tests.py b/.maestro/scripts/test_run_tests.py index 114851e2..b52a0bbe 100755 --- a/.maestro/scripts/test_run_tests.py +++ b/.maestro/scripts/test_run_tests.py @@ -288,6 +288,82 @@ def test_wrong_status_and_non_object_results_fail_closed(self): self.assertEqual(self.clock.sleeps, []) self.assert_no_credential_arguments() + def test_profile_prepare_discloses_only_fixed_reviewed_failures(self): + self.transport.pending_sleeps = 0 + cases = ( + ("profile_baseline_text_unsupported", + "bootstrap-prepare-failed-profile-baseline-text-unsupported"), + ("profile_fcm_token_timeout", "bootstrap-prepare-failed-profile-fcm-token-timeout"), + ) + for reason, expected in cases: + with self.subTest(reason=reason): + self.transport.result = json.dumps({ + "status": "failed", "reason": reason, "private": SYNTHETIC_SECRET, + }).encode("utf-8") + self.assert_failure( + expected, + lambda: self.device.bootstrap( + "prepare", CREDENTIALS, feature="profile", flow="profile-edit", + run_id="0123456789abcdef", parameters={}, + ), + ) + self.assertTrue((self.transport.root / runner.RESULT).exists()) + self.assertEqual(self.clock.sleeps, []) + self.assert_no_credential_arguments() + + def test_profile_prepare_unknown_and_non_string_reasons_remain_generic(self): + self.transport.pending_sleeps = 0 + reasons = ( + "profile_unknown_failure", + "profile_baseline_text_unsupported-" + SYNTHETIC_SECRET, + SYNTHETIC_SECRET, + {"code": "profile_baseline_text_unsupported", "private": SYNTHETIC_SECRET}, + ["profile_fcm_token_timeout", SYNTHETIC_SECRET], + None, + 7, + True, + ) + for index, reason in enumerate(reasons): + with self.subTest(case=index): + self.transport.result = json.dumps({"status": "failed", "reason": reason}).encode("utf-8") + self.assert_failure( + "bootstrap-prepare-failed", + lambda: self.device.bootstrap( + "prepare", CREDENTIALS, feature="profile", flow="profile-edit", + run_id="0123456789abcdef", parameters={}, + ), + ) + self.assertTrue((self.transport.root / runner.RESULT).exists()) + self.assertEqual(self.clock.sleeps, []) + self.assert_no_credential_arguments() + + def test_reviewed_profile_reason_outside_failed_profile_prepare_remains_generic(self): + self.transport.pending_sleeps = 0 + cases = ( + ("prepare", "profile", "exported", "bootstrap-prepare-failed"), + ("prepare", "profile", None, "bootstrap-prepare-failed"), + ("prepare", "matching", "failed", "bootstrap-prepare-failed"), + ("cleanup", "profile", "failed", "bootstrap-cleanup-failed"), + ("prepare", None, "failed", "bootstrap-prepare-failed"), + ) + for operation, feature, status, expected in cases: + with self.subTest(operation=operation, feature=feature, status=status): + self.transport.result = json.dumps({ + "status": status, "reason": "profile_fcm_token_timeout", "private": SYNTHETIC_SECRET, + }).encode("utf-8") + metadata = {} + if feature is not None: + metadata = { + "feature": feature, "flow": feature + "-edit", + "run_id": "0123456789abcdef", "parameters": {}, + } + self.assert_failure( + expected, lambda: self.device.bootstrap(operation, CREDENTIALS, **metadata), + ) + self.assertTrue((self.transport.root / runner.RESULT).exists()) + self.assertEqual(self.clock.sleeps, []) + self.assert_no_credential_arguments() + def test_verify_setup_rejects_run_as_failure_without_diagnostics(self): self.transport.allow_run_as = False self.assert_failure("adb-operation-failed", self.device.verify_setup) From a7cd245ecb1e14f148aadbe80d91873845717174 Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:56:42 +0900 Subject: [PATCH 07/14] =?UTF-8?q?fix(ci):=20=EA=B3=B5=EC=9C=A0=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EB=8C=80=EA=B8=B0=20=EC=8B=A4=ED=96=89=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/android-maestro.yml | 1 + .maestro/README.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android-maestro.yml b/.github/workflows/android-maestro.yml index 520caab0..4e42ee1e 100644 --- a/.github/workflows/android-maestro.yml +++ b/.github/workflows/android-maestro.yml @@ -66,6 +66,7 @@ jobs: concurrency: group: maestro-dev-dedicated-account cancel-in-progress: false + queue: max env: MAESTRO_SUITE: ${{ github.event_name == 'workflow_dispatch' && inputs.suite || 'ci' }} steps: diff --git a/.maestro/README.md b/.maestro/README.md index 50b920c7..eaf43974 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -128,7 +128,7 @@ python3 .maestro/scripts/run-tests.py --suite login --device emulator-5554 `^maestro-e2e-[a-f0-9]{32}(-edited)?$`에 정확히 맞는 제목은 **검증된 전용 계정에서 이 suite만 소유**합니다. 일반 기록 제목에 이 패턴을 사용하지 마세요. 준비/정리는 시작일~기기 오늘의 월별 운동 횟수를 읽고 기록이 있는 날짜만 조회하여 이 제목만 삭제합니다. 다른 제목·다른 계정·범위 밖 기록은 삭제하지 않습니다. 서버 오류나 계정 불일치 시 삭제를 진행하지 않습니다. -UI가 생성·수정·삭제를 수행합니다. bootstrap은 테스트를 통과시키려고 기록을 대신 생성하지 않습니다. CI는 `maestro-dev-dedicated-account` concurrency group으로 같은 계정의 job을 직렬화하고 이전 job을 자동 취소하지 않습니다. **로컬 실행끼리 또는 로컬과 CI가 같은 계정을 동시에 사용하면 서로의 예약 기록을 지울 수 있습니다.** 병렬 실행에는 서로 다른 DEV 계정을 사용하세요. +UI가 생성·수정·삭제를 수행합니다. bootstrap은 테스트를 통과시키려고 기록을 대신 생성하지 않습니다. CI는 `maestro-dev-dedicated-account` concurrency group으로 같은 계정의 job을 한 번에 하나만 실행합니다. `cancel-in-progress: false`로 실행 중인 job을 자동 취소하지 않으며, `queue: max`로 최대 100개의 pending job을 대기시킵니다. **로컬 실행끼리 또는 로컬과 CI가 같은 계정을 동시에 사용하면 서로의 예약 기록을 지울 수 있습니다.** 병렬 실행에는 서로 다른 DEV 계정을 사용하세요. ## suite와 플로우(#607, #608) @@ -233,7 +233,7 @@ XML ID는 `id` 셀렉터를 사용합니다. Compose의 `feed_content`, `feed_em |`MAESTRO_MATCHING_MARKER_POINT`|전용 emulator에서 보정한 실제 지도 marker 위치| |`MAESTRO_MATCHING_ROOM_ID`|matching 두 계정의 검증할 기존 전용 방(정리 시 보존)| -peer/matching 비밀은 선택된 descriptor가 요구할 때만 필수입니다. workflow는 정확히 이 이름을 authenticated preflight와 emulator 실행 단계에만 전달합니다. 누락 시 **이름만** 표시하고 실패합니다. CI 계정 잠금은 기능별 job으로 나누지 않습니다. GitHub concurrency의 pending 슬롯은 하나이므로 여러 PR을 동시에 게시하면 이전 pending job이 취소될 수 있습니다. 컨트롤러는 순차 게시/관찰하거나 취소된 실행을 명시적으로 다시 실행해야 합니다. +peer/matching 비밀은 선택된 descriptor가 요구할 때만 필수입니다. workflow는 정확히 이 이름을 authenticated preflight와 emulator 실행 단계에만 전달합니다. 누락 시 **이름만** 표시하고 실패합니다. CI 계정 잠금은 기능별 job으로 나누지 않습니다. GitHub concurrency의 `queue: max`는 최대 100개의 pending job을 대기시키며, 용량을 넘는 job은 취소됩니다. FIFO 순서는 workflow dispatch 순서가 아니라 job이 concurrency 대기에 들어간 순서입니다([공식 문서](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency)). 비밀 누락은 구성 단계에서 이름만 표시하고 실패합니다. 잘못된 세션·등록되지 않은 계정·만료/철회·다른 DEV pool/client·잘못된 member ID는 준비 실패입니다. CI에 개인 OAuth 자격 증명을 입력하거나 테스트마다 브라우저 로그인을 조작하지 않습니다. 원본 인증 파일, APK, google-services/local.properties, raw logcat은 업로드하지 않습니다. From f708bec49a52bd8e0465bdbbb1a6f532b2232002 Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:27:26 +0900 Subject: [PATCH 08/14] =?UTF-8?q?fix(maestro):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EA=B8=B0=EC=A4=80=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=EB=A5=BC=20=EC=95=B1=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .maestro/README-profile.md | 4 ++-- .../undabang/e2e/MaestroProfilePolicy.kt | 15 ++++++++++----- .../undabang/e2e/MaestroProfilePolicyTest.kt | 19 ++++++++++++++++--- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.maestro/README-profile.md b/.maestro/README-profile.md index f9021275..77c4c522 100644 --- a/.maestro/README-profile.md +++ b/.maestro/README-profile.md @@ -21,8 +21,8 @@ Preparation reads the authenticated member's real profile and preference records For failed profile preparation, the runner reports only exact fixed codes reviewed in `MaestroProfileFixture`, `MaestroProfilePolicy`, and `MaestroProfileNotificationReadiness`. The diagnostic mapping applies only to `prepare` + `profile` + `status: failed`. Unknown strings, prefixes with extra text, non-string reasons, other features, cleanup, and other statuses retain the generic bootstrap error. Profile values, tokens, exception messages, and arbitrary server responses are never included. The transport regression tests preserve the existing synthetic-secret redaction case and cover these boundaries. -- The original nickname must match `[A-Za-z0-9]{1,30}`. The original gender must be one of the app's `MALE`, `FEMALE`, or `UNKNOWN` values. -- The bio must be non-null, nonempty, at most 4096 characters, and match `[A-Za-z0-9][A-Za-z0-9 _-]*`. These explicit restrictions permit exact Android `inputText` restoration. Null, Unicode, punctuation outside that set, and control characters fail **before mutation**. The fixture does not replace the account baseline with test text. +- The original nickname must pass the app's `ValidateNicknameUseCase` rule (`^[가-힣a-zA-Z0-9]{1,30}$`), because UI restoration runs the real duplicate check. The original gender must be one of the app's `MALE`, `FEMALE`, or `UNKNOWN` values. +- The bio must be non-null, nonempty, at most 50 UTF-16 characters, and contain no control characters. Maestro CLI 2.11.0 types non-ASCII Android `inputText` through its own IME, so Hangul and punctuation can be restored exactly. The 50-character limit matches the default `eraseText` count used by the flow. Null cannot be sent in the profile PUT body, and an empty field can make `copyTextFrom` read hint text. These cases fail **before mutation**. The fixture does not replace the account baseline with test text. - Every original preference must have a positive unique record ID/type ID, seven day booleans with at least one true day, and a recognized skill. The preference scenario requires 1–4 original selections and at least one real unselected catalog type. Preparation obtains its ID/name from the real catalog; the flow never chooses an arbitrary offscreen chip. - For `profile-notification`, the fixture waits for the real saved FCM token, registers that token, and reads the actual device settings. The response must contain exactly one `WORKOUT_REMINDER` and one `CHAT_MESSAGE` row. Missing rows fail; the fixture never synthesizes OFF/OFF defaults. - Notification values need not initially be OFF. Preparation snapshots the real values before its notification PATCH. It sets only the two UI-owned types to OFF and verifies that readback. Additional server notification type names and values remain intact. diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt index dd246e51..b52f8ac2 100644 --- a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt @@ -2,10 +2,14 @@ package com.project200.undabang.e2e import com.project200.data.dto.GetPreferredExerciseDTO import com.project200.data.dto.NotificationStateDTO +import com.project200.domain.usecase.ValidateNicknameUseCase internal object MaestroProfilePolicy { - private val nicknamePattern = Regex("[A-Za-z0-9]{1,30}") - private val bioPattern = Regex("[A-Za-z0-9][A-Za-z0-9 _-]*") + // 플로우의 eraseText는 Maestro 2.11.0 기본값인 50자만 지우므로 더 긴 소개는 UI로 정확히 되돌릴 수 없습니다. + private const val MAX_ERASABLE_BIO_LENGTH = 50 + + // UI 복구도 실제 중복 확인을 거치므로 닉네임은 앱 규칙과 같은 기준으로 검사합니다. + private val validateNickname = ValidateNicknameUseCase() private val genders = setOf("MALE", "FEMALE", "UNKNOWN") private val skills = setOf("BEGINNER", "ROOKIE", "INTERMEDIATE", "ADVANCED", "SKILLED", "PRO") @@ -19,9 +23,10 @@ internal object MaestroProfilePolicy { gender: String, bio: String?, ) { - // Maestro Android inputText로 원문을 정확히 복구할 수 있는 전용 계정만 허용합니다. - if (!nicknamePattern.matches(nickname) || gender !in genders || - bio == null || bio.length > 4096 || !bioPattern.matches(bio) + // Maestro 2.11.0은 Android 비ASCII inputText를 자체 IME로 입력하므로 한글·문장부호도 복구할 수 있습니다. + // null은 PUT 본문으로 표현할 수 없고 빈 소개는 copyTextFrom이 text 대신 hint를 읽을 수 있어 mutation 전에 거부합니다. + if (!validateNickname(nickname) || gender !in genders || + bio.isNullOrEmpty() || bio.length > MAX_ERASABLE_BIO_LENGTH || bio.any { it.isISOControl() } ) { throw MaestroSetupFailure("profile_baseline_text_unsupported") } diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt index 9cedb507..3cf9023f 100644 --- a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt @@ -48,13 +48,26 @@ class MaestroProfilePolicyTest { } @Test - fun `null과 Android 입력으로 복구할 수 없는 소개는 mutation 전에 거부한다`() { - for (bio in listOf(null, "", "한글 소개", "percent%text", "line\nbreak")) { + fun `앱이 허용하는 한글 닉네임과 문장부호 소개는 복구 가능한 기준으로 받는다`() { + MaestroProfilePolicy.validateText("운동하는사람1", "UNKNOWN", "주 3회 헬스, 러닝 좋아해요!") + MaestroProfilePolicy.validateText("OriginalUser", "FEMALE", "a".repeat(50)) + } + + @Test + fun `UI로 정확히 복구할 수 없는 기준 텍스트는 mutation 전에 거부한다`() { + for (nickname in listOf("", "닉네임!", "a".repeat(31))) { + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.validateText(nickname, "MALE", "intro") + } + } + for (bio in listOf(null, "", "line\nbreak", "tab\tbio", "a".repeat(51))) { assertThrows(MaestroSetupFailure::class.java) { MaestroProfilePolicy.validateText("OriginalUser", "MALE", bio) } } - MaestroProfilePolicy.validateText("OriginalUser", "FEMALE", "Original intro-1") + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.validateText("OriginalUser", "OTHER", "intro") + } } @Test From 2a4d055590298d19b15071c9d21b39af3ee2563c Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:08:37 +0900 Subject: [PATCH 09/14] =?UTF-8?q?fix(maestro):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=A4=80=EB=B9=84=20=EC=8B=A4=ED=8C=A8=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .maestro/README-profile.md | 4 +- .maestro/scripts/run-tests.py | 8 ++- .maestro/scripts/test_run_tests.py | 51 +++++++++++-------- .../undabang/e2e/MaestroProfilePolicy.kt | 16 ++++-- .../undabang/e2e/MaestroProfilePolicyTest.kt | 39 +++++++++++--- 5 files changed, 81 insertions(+), 37 deletions(-) diff --git a/.maestro/README-profile.md b/.maestro/README-profile.md index 77c4c522..f5339d57 100644 --- a/.maestro/README-profile.md +++ b/.maestro/README-profile.md @@ -19,7 +19,7 @@ The existing `MAESTRO_AUTH_STATE_JSON`, `MAESTRO_MEMBER_ID`, and `MAESTRO_FIXTUR Preparation reads the authenticated member's real profile and preference records before any feature write. Only `profile-notification` requires device registration and notification settings. Any unsuccessful response or missing required data fails preparation. -For failed profile preparation, the runner reports only exact fixed codes reviewed in `MaestroProfileFixture`, `MaestroProfilePolicy`, and `MaestroProfileNotificationReadiness`. The diagnostic mapping applies only to `prepare` + `profile` + `status: failed`. Unknown strings, prefixes with extra text, non-string reasons, other features, cleanup, and other statuses retain the generic bootstrap error. Profile values, tokens, exception messages, and arbitrary server responses are never included. The transport regression tests preserve the existing synthetic-secret redaction case and cover these boundaries. +For failed profile preparation, the runner reports only exact fixed codes reviewed in `MaestroProfileFixture`, `MaestroProfilePolicy`, and `MaestroProfileNotificationReadiness`. Baseline validation reports the first failed condition in this order: `profile_baseline_nickname_unsupported`, `profile_baseline_gender_unsupported`, `profile_baseline_bio_null`, `profile_baseline_bio_empty`, `profile_baseline_bio_too_long`, then `profile_baseline_bio_control_characters`. The runner maps each code to its fixed `bootstrap-prepare-failed-profile-baseline-...` error. The diagnostic mapping applies only to `prepare` + `profile` + `status: failed`. Unknown strings, prefixes with extra text, non-string reasons, the retired `profile_baseline_text_unsupported` code, other features, cleanup, and other statuses retain the generic bootstrap error. Diagnostics never include profile values, lengths, character codes, tokens, exception messages, or arbitrary server responses. The transport regression tests retain the synthetic-secret redaction case and cover all six exact mappings and these boundaries. - The original nickname must pass the app's `ValidateNicknameUseCase` rule (`^[가-힣a-zA-Z0-9]{1,30}$`), because UI restoration runs the real duplicate check. The original gender must be one of the app's `MALE`, `FEMALE`, or `UNKNOWN` values. - The bio must be non-null, nonempty, at most 50 UTF-16 characters, and contain no control characters. Maestro CLI 2.11.0 types non-ASCII Android `inputText` through its own IME, so Hangul and punctuation can be restored exactly. The 50-character limit matches the default `eraseText` count used by the flow. Null cannot be sent in the profile PUT body, and an empty field can make `copyTextFrom` read hint text. These cases fail **before mutation**. The fixture does not replace the account baseline with test text. @@ -62,4 +62,4 @@ Profile fields put test tags on `UndabangTextField.inputModifier`, not its layou Behavioral coverage is in profile ViewModel tests and `app/src/testE2e/.../MaestroProfilePolicyTest.kt`, `MaestroProfileRecoveryTest.kt`, and `MaestroProfileNotificationReadinessTest.kt`. The readiness tests cover delayed callback storage, the timeout boundary, absent/blank tokens, backend errors/timeouts, token rotation, missing rows, cancellation, and notification-only scope. The recovery tests cover original-ID preservation, exact owned deletion, raw additional notification types, backend read errors, wrong device identity, failed readback, and explicit notification absence. They retain cross-flow recovery checks for existing notification snapshots. Additional tests call restoration twice with the same baseline to cover profile, preference, and notification idempotence, plus rejection of token rotation before a retry. These are unit-test doubles only; the actual fixture and all four Maestro scenarios call the real backend. -At `dcbdc771`, [normal CI](https://github.com/projects200/android/actions/runs/38888921656) passed. [Maestro CI](https://github.com/projects200/android/actions/runs/38888921682) installed the API34 E2E APK and passed the two smoke flows, but all four profile flows failed at `bootstrap-prepare-failed` before their UI commands. That generic code does not identify the failed prerequisite. The fixed-code diagnostics now distinguish reviewed preparation failures; their runtime result and the full profile suite still require approved CI. No local build, test, lint, emulator, Firebase, or Maestro command was run for this change. +At `f708bec4`, [normal CI](https://github.com/projects200/android/actions/runs/38997439274) passed. [Maestro CI run 38997439311](https://github.com/projects200/android/actions/runs/38997439311) passed all 12 common flows, but all four profile flows failed preparation at `bootstrap-prepare-failed-profile-baseline-text-unsupported`; no profile UI commands ran. The actual rejected baseline condition remains unknown. These first-failure diagnostic changes and regression tests are local and unexecuted. Their runtime result, the full profile suite, and the recovery requirements above still require approved CI. No local build, test, lint, formatter, syntax check, adb, emulator, Firebase, or Maestro command was run for this change. diff --git a/.maestro/scripts/run-tests.py b/.maestro/scripts/run-tests.py index 25d33789..27e7a94c 100755 --- a/.maestro/scripts/run-tests.py +++ b/.maestro/scripts/run-tests.py @@ -55,7 +55,13 @@ PROFILE_PREPARE_FAILURES = { "profile_recovery_pending": "bootstrap-prepare-failed-profile-recovery-pending", "profile_fixture_read_failed": "bootstrap-prepare-failed-profile-fixture-read-failed", - "profile_baseline_text_unsupported": "bootstrap-prepare-failed-profile-baseline-text-unsupported", + "profile_baseline_nickname_unsupported": "bootstrap-prepare-failed-profile-baseline-nickname-unsupported", + "profile_baseline_gender_unsupported": "bootstrap-prepare-failed-profile-baseline-gender-unsupported", + "profile_baseline_bio_null": "bootstrap-prepare-failed-profile-baseline-bio-null", + "profile_baseline_bio_empty": "bootstrap-prepare-failed-profile-baseline-bio-empty", + "profile_baseline_bio_too_long": "bootstrap-prepare-failed-profile-baseline-bio-too-long", + "profile_baseline_bio_control_characters": + "bootstrap-prepare-failed-profile-baseline-bio-control-characters", "profile_preferences_invalid": "bootstrap-prepare-failed-profile-preferences-invalid", "profile_preference_baseline_unavailable": "bootstrap-prepare-failed-profile-preference-baseline-unavailable", diff --git a/.maestro/scripts/test_run_tests.py b/.maestro/scripts/test_run_tests.py index b52a0bbe..fed4b544 100755 --- a/.maestro/scripts/test_run_tests.py +++ b/.maestro/scripts/test_run_tests.py @@ -291,8 +291,15 @@ def test_wrong_status_and_non_object_results_fail_closed(self): def test_profile_prepare_discloses_only_fixed_reviewed_failures(self): self.transport.pending_sleeps = 0 cases = ( - ("profile_baseline_text_unsupported", - "bootstrap-prepare-failed-profile-baseline-text-unsupported"), + ("profile_baseline_nickname_unsupported", + "bootstrap-prepare-failed-profile-baseline-nickname-unsupported"), + ("profile_baseline_gender_unsupported", + "bootstrap-prepare-failed-profile-baseline-gender-unsupported"), + ("profile_baseline_bio_null", "bootstrap-prepare-failed-profile-baseline-bio-null"), + ("profile_baseline_bio_empty", "bootstrap-prepare-failed-profile-baseline-bio-empty"), + ("profile_baseline_bio_too_long", "bootstrap-prepare-failed-profile-baseline-bio-too-long"), + ("profile_baseline_bio_control_characters", + "bootstrap-prepare-failed-profile-baseline-bio-control-characters"), ("profile_fcm_token_timeout", "bootstrap-prepare-failed-profile-fcm-token-timeout"), ) for reason, expected in cases: @@ -315,10 +322,11 @@ def test_profile_prepare_unknown_and_non_string_reasons_remain_generic(self): self.transport.pending_sleeps = 0 reasons = ( "profile_unknown_failure", - "profile_baseline_text_unsupported-" + SYNTHETIC_SECRET, + "profile_baseline_text_unsupported", + "profile_baseline_nickname_unsupported-" + SYNTHETIC_SECRET, SYNTHETIC_SECRET, - {"code": "profile_baseline_text_unsupported", "private": SYNTHETIC_SECRET}, - ["profile_fcm_token_timeout", SYNTHETIC_SECRET], + {"code": "profile_baseline_bio_null", "private": SYNTHETIC_SECRET}, + ["profile_baseline_bio_control_characters", SYNTHETIC_SECRET], None, 7, True, @@ -346,22 +354,23 @@ def test_reviewed_profile_reason_outside_failed_profile_prepare_remains_generic( ("cleanup", "profile", "failed", "bootstrap-cleanup-failed"), ("prepare", None, "failed", "bootstrap-prepare-failed"), ) - for operation, feature, status, expected in cases: - with self.subTest(operation=operation, feature=feature, status=status): - self.transport.result = json.dumps({ - "status": status, "reason": "profile_fcm_token_timeout", "private": SYNTHETIC_SECRET, - }).encode("utf-8") - metadata = {} - if feature is not None: - metadata = { - "feature": feature, "flow": feature + "-edit", - "run_id": "0123456789abcdef", "parameters": {}, - } - self.assert_failure( - expected, lambda: self.device.bootstrap(operation, CREDENTIALS, **metadata), - ) - self.assertTrue((self.transport.root / runner.RESULT).exists()) - self.assertEqual(self.clock.sleeps, []) + for reason in ("profile_baseline_bio_null", "profile_fcm_token_timeout"): + for operation, feature, status, expected in cases: + with self.subTest(reason=reason, operation=operation, feature=feature, status=status): + self.transport.result = json.dumps({ + "status": status, "reason": reason, "private": SYNTHETIC_SECRET, + }).encode("utf-8") + metadata = {} + if feature is not None: + metadata = { + "feature": feature, "flow": feature + "-edit", + "run_id": "0123456789abcdef", "parameters": {}, + } + self.assert_failure( + expected, lambda: self.device.bootstrap(operation, CREDENTIALS, **metadata), + ) + self.assertTrue((self.transport.root / runner.RESULT).exists()) + self.assertEqual(self.clock.sleeps, []) self.assert_no_credential_arguments() def test_verify_setup_rejects_run_as_failure_without_diagnostics(self): diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt index b52f8ac2..898305f0 100644 --- a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt @@ -25,11 +25,17 @@ internal object MaestroProfilePolicy { ) { // Maestro 2.11.0은 Android 비ASCII inputText를 자체 IME로 입력하므로 한글·문장부호도 복구할 수 있습니다. // null은 PUT 본문으로 표현할 수 없고 빈 소개는 copyTextFrom이 text 대신 hint를 읽을 수 있어 mutation 전에 거부합니다. - if (!validateNickname(nickname) || gender !in genders || - bio.isNullOrEmpty() || bio.length > MAX_ERASABLE_BIO_LENGTH || bio.any { it.isISOControl() } - ) { - throw MaestroSetupFailure("profile_baseline_text_unsupported") - } + val reason = + when { + !validateNickname(nickname) -> "profile_baseline_nickname_unsupported" + gender !in genders -> "profile_baseline_gender_unsupported" + bio == null -> "profile_baseline_bio_null" + bio.isEmpty() -> "profile_baseline_bio_empty" + bio.length > MAX_ERASABLE_BIO_LENGTH -> "profile_baseline_bio_too_long" + bio.any { it.isISOControl() } -> "profile_baseline_bio_control_characters" + else -> return + } + throw MaestroSetupFailure(reason) } fun validatePreferences(preferences: List) { diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt index 3cf9023f..f4844345 100644 --- a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt @@ -56,18 +56,41 @@ class MaestroProfilePolicyTest { @Test fun `UI로 정확히 복구할 수 없는 기준 텍스트는 mutation 전에 거부한다`() { for (nickname in listOf("", "닉네임!", "a".repeat(31))) { + val failure = + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.validateText(nickname, "MALE", "intro") + } + assertEquals("profile_baseline_nickname_unsupported", failure.reason) + } + for ((bio, reason) in + listOf( + null to "profile_baseline_bio_null", + "" to "profile_baseline_bio_empty", + "line\nbreak" to "profile_baseline_bio_control_characters", + "tab\tbio" to "profile_baseline_bio_control_characters", + "a".repeat(51) to "profile_baseline_bio_too_long", + ) + ) { + val failure = + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.validateText("OriginalUser", "MALE", bio) + } + assertEquals(reason, failure.reason) + } + val failure = assertThrows(MaestroSetupFailure::class.java) { - MaestroProfilePolicy.validateText(nickname, "MALE", "intro") + MaestroProfilePolicy.validateText("OriginalUser", "OTHER", "intro") } - } - for (bio in listOf(null, "", "line\nbreak", "tab\tbio", "a".repeat(51))) { + assertEquals("profile_baseline_gender_unsupported", failure.reason) + } + + @Test + fun `여러 기준 조건이 실패하면 닉네임 실패를 먼저 알린다`() { + val failure = assertThrows(MaestroSetupFailure::class.java) { - MaestroProfilePolicy.validateText("OriginalUser", "MALE", bio) + MaestroProfilePolicy.validateText("닉네임!", "OTHER", "line\nbreak") } - } - assertThrows(MaestroSetupFailure::class.java) { - MaestroProfilePolicy.validateText("OriginalUser", "OTHER", "intro") - } + assertEquals("profile_baseline_nickname_unsupported", failure.reason) } @Test From 67cea31f3de75caa58b1bf21cace4d21b4b1996b Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:18:12 +0900 Subject: [PATCH 10/14] =?UTF-8?q?style(maestro):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=93=A4=EC=97=AC=EC=93=B0=EA=B8=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project200/undabang/e2e/MaestroProfilePolicyTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt index f4844345..86c55c67 100644 --- a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt @@ -62,7 +62,7 @@ class MaestroProfilePolicyTest { } assertEquals("profile_baseline_nickname_unsupported", failure.reason) } - for ((bio, reason) in + val unsupportedBios = listOf( null to "profile_baseline_bio_null", "" to "profile_baseline_bio_empty", @@ -70,7 +70,7 @@ class MaestroProfilePolicyTest { "tab\tbio" to "profile_baseline_bio_control_characters", "a".repeat(51) to "profile_baseline_bio_too_long", ) - ) { + for ((bio, reason) in unsupportedBios) { val failure = assertThrows(MaestroSetupFailure::class.java) { MaestroProfilePolicy.validateText("OriginalUser", "MALE", bio) From bc819f8ff63fa839623f7eac7d5b9561019c7424 Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:47:46 +0900 Subject: [PATCH 11/14] =?UTF-8?q?fix(profile):=20=EB=B9=88=20=EC=86=8C?= =?UTF-8?q?=EA=B0=9C=EC=9D=98=20UI=20=EC=99=95=EB=B3=B5=EA=B3=BC=20?= =?UTF-8?q?=EC=9B=90=EB=AC=B8=20=EB=B3=B5=EA=B5=AC=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .maestro/README-profile.md | 10 +-- .maestro/flows/profile-edit.yaml | 73 ++++++++++++++++--- .maestro/scripts/run-tests.py | 1 - .maestro/scripts/test_run_tests.py | 1 - .../undabang/e2e/MaestroProfilePolicy.kt | 3 +- .../undabang/e2e/MaestroProfilePolicyTest.kt | 1 - .../e2e/MaestroProfileRecoveryTest.kt | 35 ++++++++- .../profile/mypage/ProfileEditScreen.kt | 8 +- .../mypage/ProfileEditViewModelTest.kt | 31 ++++++++ 9 files changed, 140 insertions(+), 23 deletions(-) diff --git a/.maestro/README-profile.md b/.maestro/README-profile.md index f5339d57..15ef8f52 100644 --- a/.maestro/README-profile.md +++ b/.maestro/README-profile.md @@ -19,10 +19,10 @@ The existing `MAESTRO_AUTH_STATE_JSON`, `MAESTRO_MEMBER_ID`, and `MAESTRO_FIXTUR Preparation reads the authenticated member's real profile and preference records before any feature write. Only `profile-notification` requires device registration and notification settings. Any unsuccessful response or missing required data fails preparation. -For failed profile preparation, the runner reports only exact fixed codes reviewed in `MaestroProfileFixture`, `MaestroProfilePolicy`, and `MaestroProfileNotificationReadiness`. Baseline validation reports the first failed condition in this order: `profile_baseline_nickname_unsupported`, `profile_baseline_gender_unsupported`, `profile_baseline_bio_null`, `profile_baseline_bio_empty`, `profile_baseline_bio_too_long`, then `profile_baseline_bio_control_characters`. The runner maps each code to its fixed `bootstrap-prepare-failed-profile-baseline-...` error. The diagnostic mapping applies only to `prepare` + `profile` + `status: failed`. Unknown strings, prefixes with extra text, non-string reasons, the retired `profile_baseline_text_unsupported` code, other features, cleanup, and other statuses retain the generic bootstrap error. Diagnostics never include profile values, lengths, character codes, tokens, exception messages, or arbitrary server responses. The transport regression tests retain the synthetic-secret redaction case and cover all six exact mappings and these boundaries. +For failed profile preparation, the runner reports only exact fixed codes reviewed in `MaestroProfileFixture`, `MaestroProfilePolicy`, and `MaestroProfileNotificationReadiness`. Baseline validation reports the first failed condition in this order: `profile_baseline_nickname_unsupported`, `profile_baseline_gender_unsupported`, `profile_baseline_bio_null`, `profile_baseline_bio_too_long`, then `profile_baseline_bio_control_characters`. The runner maps each code to its fixed `bootstrap-prepare-failed-profile-baseline-...` error. The diagnostic mapping applies only to `prepare` + `profile` + `status: failed`. Unknown strings, prefixes with extra text, non-string reasons, the retired `profile_baseline_text_unsupported` code, other features, cleanup, and other statuses retain the generic bootstrap error. Diagnostics never include profile values, lengths, character codes, tokens, exception messages, or arbitrary server responses. The transport regression tests retain the synthetic-secret redaction case and cover all five baseline mappings and these boundaries. - The original nickname must pass the app's `ValidateNicknameUseCase` rule (`^[가-힣a-zA-Z0-9]{1,30}$`), because UI restoration runs the real duplicate check. The original gender must be one of the app's `MALE`, `FEMALE`, or `UNKNOWN` values. -- The bio must be non-null, nonempty, at most 50 UTF-16 characters, and contain no control characters. Maestro CLI 2.11.0 types non-ASCII Android `inputText` through its own IME, so Hangul and punctuation can be restored exactly. The 50-character limit matches the default `eraseText` count used by the flow. Null cannot be sent in the profile PUT body, and an empty field can make `copyTextFrom` read hint text. These cases fail **before mutation**. The fixture does not replace the account baseline with test text. +- The bio must be non-null, at most 50 UTF-16 characters, and contain no control characters. An empty string is a valid baseline, distinct from null. After `profile_edit_loaded`, the edit flow checks the introduction's actual empty/present state. It never copies an empty field's hint or calls `inputText` with an empty value. Nonempty baselines still require exact `copyTextFrom` equality. UI restoration clears the field, types only a nonempty original, saves, reopens the editor, waits for its profile read, and checks the actual empty state or exact original content. Maestro CLI 2.11.0 types non-ASCII Android `inputText` through its own IME, so Hangul and punctuation remain supported. The 50-character limit matches the flow's default `eraseText` count. Null cannot be sent in the profile PUT body. Null and unrepresentable baselines fail **before mutation**; the fixture never replaces the account baseline with hint or test text. - Every original preference must have a positive unique record ID/type ID, seven day booleans with at least one true day, and a recognized skill. The preference scenario requires 1–4 original selections and at least one real unselected catalog type. Preparation obtains its ID/name from the real catalog; the flow never chooses an arbitrary offscreen chip. - For `profile-notification`, the fixture waits for the real saved FCM token, registers that token, and reads the actual device settings. The response must contain exactly one `WORKOUT_REMINDER` and one `CHAT_MESSAGE` row. Missing rows fail; the fixture never synthesizes OFF/OFF defaults. - Notification values need not initially be OFF. Preparation snapshots the real values before its notification PATCH. It sets only the two UI-owned types to OFF and verifies that readback. Additional server notification type names and values remain intact. @@ -58,8 +58,8 @@ Android permission changes use a disposable emulator policy: the flow grants the ## Observable UI state and tests -Profile fields put test tags on `UndabangTextField.inputModifier`, not its layout wrapper. `profile_edit_loaded` appears only after a successful profile read. Preference screens expose loaded TYPE/DETAIL states, use exercise-type-scoped chip/day/skill IDs, and scroll to each target explicitly. The empty-selection check also reaches originally selected chips outside the viewport. Notification switches are hidden on read errors, disabled while saving, and exposed as `notification_ready` only after required rows and PATCH readback succeed. Permission denial has a persistent OS-derived explanation as well as the existing toast. +Profile fields put their stable input IDs on `UndabangTextField.inputModifier`, not its layout wrapper. `profile_edit_loaded` appears only after a successful profile read. The existing introduction section wrapper additionally exposes `profile_edit_introduction_empty` or `profile_edit_introduction_present` from the actual input string, without spoken content or a replacement field ID. The flow checks that state only after the loaded barrier. Preference screens expose loaded TYPE/DETAIL states, use exercise-type-scoped chip/day/skill IDs, and scroll to each target explicitly. The empty-selection check also reaches originally selected chips outside the viewport. Notification switches are hidden on read errors, disabled while saving, and exposed as `notification_ready` only after required rows and PATCH readback succeed. Permission denial has a persistent OS-derived explanation as well as the existing toast. -Behavioral coverage is in profile ViewModel tests and `app/src/testE2e/.../MaestroProfilePolicyTest.kt`, `MaestroProfileRecoveryTest.kt`, and `MaestroProfileNotificationReadinessTest.kt`. The readiness tests cover delayed callback storage, the timeout boundary, absent/blank tokens, backend errors/timeouts, token rotation, missing rows, cancellation, and notification-only scope. The recovery tests cover original-ID preservation, exact owned deletion, raw additional notification types, backend read errors, wrong device identity, failed readback, and explicit notification absence. They retain cross-flow recovery checks for existing notification snapshots. Additional tests call restoration twice with the same baseline to cover profile, preference, and notification idempotence, plus rejection of token rotation before a retry. These are unit-test doubles only; the actual fixture and all four Maestro scenarios call the real backend. +Behavioral coverage is in profile ViewModel tests and `app/src/testE2e/.../MaestroProfilePolicyTest.kt`, `MaestroProfileRecoveryTest.kt`, and `MaestroProfileNotificationReadinessTest.kt`. The empty-bio recovery regression validates the baseline before checking the exact empty-string PUT and GET readback. Separate policy coverage rejects null baselines. The ViewModel regression loads an existing introduction, clears and saves an empty string, then reloads empty input over an unsaved introduction. Recovery rejects null, whitespace-only, and changed nonempty readbacks. The readiness tests cover delayed callback storage, the timeout boundary, absent/blank tokens, backend errors/timeouts, token rotation, missing rows, cancellation, and notification-only scope. The recovery tests retain original-ID preservation, exact owned deletion, raw additional notification types, backend read errors, wrong device identity, failed readback, explicit notification absence, cross-flow recovery for existing notification snapshots, and repeat-restoration ownership checks. These are unit-test doubles only; the actual fixture and all four Maestro scenarios call the real backend. -At `f708bec4`, [normal CI](https://github.com/projects200/android/actions/runs/38997439274) passed. [Maestro CI run 38997439311](https://github.com/projects200/android/actions/runs/38997439311) passed all 12 common flows, but all four profile flows failed preparation at `bootstrap-prepare-failed-profile-baseline-text-unsupported`; no profile UI commands ran. The actual rejected baseline condition remains unknown. These first-failure diagnostic changes and regression tests are local and unexecuted. Their runtime result, the full profile suite, and the recovery requirements above still require approved CI. No local build, test, lint, formatter, syntax check, adb, emulator, Firebase, or Maestro command was run for this change. +[Maestro CI run 39020015008](https://github.com/projects200/android/actions/runs/39020015008), at `2a4d0555` (formatting-only differences from `67cea31f`), passed all 12 common flows. All four profile flows stopped on the empty-bio baseline check before mutation; no profile UI commands ran. This identifies an actual empty string, not a null bio or invalid nickname. [Normal CI run 39020625352](https://github.com/projects200/android/actions/runs/39020625352) succeeded, but does not establish profile UI behavior. Exact empty persistence through the real UI and API, nonempty roundtrips, all four profile scenarios, and recovery after failure require CI verification of this fix. No local build, test, lint, formatter, syntax-check command, adb, emulator, Firebase, or Maestro command was run for this change. diff --git a/.maestro/flows/profile-edit.yaml b/.maestro/flows/profile-edit.yaml index 41cee5d6..ac85cd23 100644 --- a/.maestro/flows/profile-edit.yaml +++ b/.maestro/flows/profile-edit.yaml @@ -33,13 +33,21 @@ tags: element: id: profile_edit_introduction_field direction: DOWN +# 빈 입력은 hint를 복사하지 않고, 로드된 실제 값의 상태를 확인한 뒤 API 원문을 보관합니다. - assertVisible: - id: profile_edit_introduction_field - text: ${E2E_PROFILE_ORIGINAL_BIO} -- copyTextFrom: - id: profile_edit_introduction_field -- evalScript: ${output.originalIntroduction = maestro.copiedText} -- assertTrue: ${output.originalIntroduction === E2E_PROFILE_ORIGINAL_BIO} + id: "${E2E_PROFILE_ORIGINAL_BIO === '' ? 'profile_edit_introduction_empty' : 'profile_edit_introduction_present'}" +- evalScript: ${output.originalIntroduction = E2E_PROFILE_ORIGINAL_BIO} +- runFlow: + when: + true: ${E2E_PROFILE_ORIGINAL_BIO !== ''} + commands: + - assertVisible: + id: profile_edit_introduction_field + text: ${E2E_PROFILE_ORIGINAL_BIO} + - copyTextFrom: + id: profile_edit_introduction_field + - evalScript: ${output.originalIntroduction = maestro.copiedText} + - assertTrue: ${output.originalIntroduction === E2E_PROFILE_ORIGINAL_BIO} - scrollUntilVisible: element: id: profile_edit_nickname_field @@ -124,7 +132,12 @@ tags: - tapOn: id: profile_edit_introduction_field - eraseText -- inputText: ${output.originalIntroduction} +# 빈 원문은 실제 입력을 지운 상태로 저장하며 inputText에 빈 문자열을 넘기지 않습니다. +- runFlow: + when: + true: ${output.originalIntroduction !== ''} + commands: + - inputText: ${output.originalIntroduction} - hideKeyboard - tapOn: "완료" - extendedWaitUntil: @@ -135,11 +148,49 @@ tags: id: nickname_tv text: ${output.originalNickname} timeout: 15000 +- runFlow: + when: + true: ${output.originalIntroduction !== ''} + commands: + - scrollUntilVisible: + element: + id: introduction_tv + text: ${output.originalIntroduction} + direction: DOWN + - assertVisible: + id: introduction_tv + text: ${output.originalIntroduction} +# 저장 뒤 다시 편집기를 열어 실제 서버 재조회가 끝난 입력 상태로 빈 원문까지 검증합니다. - scrollUntilVisible: element: - id: introduction_tv - text: ${output.originalIntroduction} + id: mypage_edit_btn + direction: UP +- tapOn: + id: mypage_edit_btn +- extendedWaitUntil: + visible: + id: profile_edit_loaded + timeout: 30000 +- scrollUntilVisible: + element: + id: profile_edit_nickname_field direction: DOWN - assertVisible: - id: introduction_tv - text: ${output.originalIntroduction} + id: profile_edit_nickname_field + text: ${output.originalNickname} +- scrollUntilVisible: + element: + id: profile_edit_introduction_field + direction: DOWN +- assertVisible: + id: "${output.originalIntroduction === '' ? 'profile_edit_introduction_empty' : 'profile_edit_introduction_present'}" +- runFlow: + when: + true: ${output.originalIntroduction !== ''} + commands: + - assertVisible: + id: profile_edit_introduction_field + text: ${output.originalIntroduction} + - copyTextFrom: + id: profile_edit_introduction_field + - assertTrue: ${maestro.copiedText === output.originalIntroduction} diff --git a/.maestro/scripts/run-tests.py b/.maestro/scripts/run-tests.py index 27e7a94c..bcf9f604 100755 --- a/.maestro/scripts/run-tests.py +++ b/.maestro/scripts/run-tests.py @@ -58,7 +58,6 @@ "profile_baseline_nickname_unsupported": "bootstrap-prepare-failed-profile-baseline-nickname-unsupported", "profile_baseline_gender_unsupported": "bootstrap-prepare-failed-profile-baseline-gender-unsupported", "profile_baseline_bio_null": "bootstrap-prepare-failed-profile-baseline-bio-null", - "profile_baseline_bio_empty": "bootstrap-prepare-failed-profile-baseline-bio-empty", "profile_baseline_bio_too_long": "bootstrap-prepare-failed-profile-baseline-bio-too-long", "profile_baseline_bio_control_characters": "bootstrap-prepare-failed-profile-baseline-bio-control-characters", diff --git a/.maestro/scripts/test_run_tests.py b/.maestro/scripts/test_run_tests.py index fed4b544..03e62f1d 100755 --- a/.maestro/scripts/test_run_tests.py +++ b/.maestro/scripts/test_run_tests.py @@ -296,7 +296,6 @@ def test_profile_prepare_discloses_only_fixed_reviewed_failures(self): ("profile_baseline_gender_unsupported", "bootstrap-prepare-failed-profile-baseline-gender-unsupported"), ("profile_baseline_bio_null", "bootstrap-prepare-failed-profile-baseline-bio-null"), - ("profile_baseline_bio_empty", "bootstrap-prepare-failed-profile-baseline-bio-empty"), ("profile_baseline_bio_too_long", "bootstrap-prepare-failed-profile-baseline-bio-too-long"), ("profile_baseline_bio_control_characters", "bootstrap-prepare-failed-profile-baseline-bio-control-characters"), diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt index 898305f0..978ce784 100644 --- a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt @@ -24,13 +24,12 @@ internal object MaestroProfilePolicy { bio: String?, ) { // Maestro 2.11.0은 Android 비ASCII inputText를 자체 IME로 입력하므로 한글·문장부호도 복구할 수 있습니다. - // null은 PUT 본문으로 표현할 수 없고 빈 소개는 copyTextFrom이 text 대신 hint를 읽을 수 있어 mutation 전에 거부합니다. + // 빈 소개는 로드된 UI 상태로 확인하고 지운 채 저장합니다. null은 PUT 본문으로 표현할 수 없어 거부합니다. val reason = when { !validateNickname(nickname) -> "profile_baseline_nickname_unsupported" gender !in genders -> "profile_baseline_gender_unsupported" bio == null -> "profile_baseline_bio_null" - bio.isEmpty() -> "profile_baseline_bio_empty" bio.length > MAX_ERASABLE_BIO_LENGTH -> "profile_baseline_bio_too_long" bio.any { it.isISOControl() } -> "profile_baseline_bio_control_characters" else -> return diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt index 86c55c67..e32f78f5 100644 --- a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt @@ -65,7 +65,6 @@ class MaestroProfilePolicyTest { val unsupportedBios = listOf( null to "profile_baseline_bio_null", - "" to "profile_baseline_bio_empty", "line\nbreak" to "profile_baseline_bio_control_characters", "tab\tbio" to "profile_baseline_bio_control_characters", "a".repeat(51) to "profile_baseline_bio_too_long", diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileRecoveryTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileRecoveryTest.kt index 116bd2e3..128245b0 100644 --- a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileRecoveryTest.kt +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileRecoveryTest.kt @@ -61,6 +61,39 @@ class MaestroProfileRecoveryTest { coVerify(exactly = 2) { api.getNotiState(token) } } + @Test + fun `빈 소개 원본은 빈 문자열 PUT과 정확한 GET 재조회로 복구한다`() = + runTest { + baseline() + val emptyOriginal = original.copy(bio = "") + coEvery { api.getProfile() } returnsMany + listOf(success(profile("e2e$runId", "e2e-intro-$runId")), success(profile(bio = ""))) + coEvery { api.editProfile(emptyOriginal) } returns success(null) + MaestroProfilePolicy.validateText(emptyOriginal.nickname, emptyOriginal.gender, emptyOriginal.bio) + + recovery.restore(emptyOriginal, listOf(preference), null, null, "profile-edit", runId) + + coVerify(exactly = 1) { api.editProfile(emptyOriginal) } + coVerify(exactly = 2) { api.getProfile() } + coVerify(exactly = 0) { fcm.getSavedToken() } + } + + @Test + fun `빈 소개 PUT 성공 뒤 null이나 다른 문자열을 읽으면 복구 실패다`() = + runTest { + baseline() + val emptyOriginal = original.copy(bio = "") + coEvery { api.editProfile(emptyOriginal) } returns success(null) + for (readback in listOf(null, " ", "e2e-intro-$runId")) { + coEvery { api.getProfile() } returnsMany + listOf(success(profile("e2e$runId", "e2e-intro-$runId")), success(profile(bio = readback))) + + expectFailure { recovery.restore(emptyOriginal, listOf(preference), null, null, "profile-edit", runId) } + } + coVerify(exactly = 3) { api.editProfile(emptyOriginal) } + coVerify(exactly = 6) { api.getProfile() } + } + @Test fun `원본 preference 변경은 같은 type PATCH로 복구해도 테스트 실패를 유지한다`() = runTest { @@ -243,7 +276,7 @@ class MaestroProfileRecoveryTest { private fun profile( nickname: String = original.nickname, - bio: String = original.bio, + bio: String? = original.bio, ) = GetProfileDTO(null, null, nickname, original.gender, "1990-01-01", bio, 0, 0, 0, emptyList()) private fun success(data: T): BaseResponse = BaseResponse(true, "OK", "", data) diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt index 53a247de..64c9e66b 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt @@ -360,7 +360,13 @@ private fun IntroductionSection( onChange: (String) -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier = modifier.fillMaxWidth()) { + Column( + modifier = + modifier + .fillMaxWidth() + // 빈 입력의 hint를 원문으로 오인하지 않도록 실제 값의 상태만 비음성 testTag로 노출합니다. + .testTag(if (introduction.isEmpty()) "profile_edit_introduction_empty" else "profile_edit_introduction_present"), + ) { Text( text = stringResource(R.string.profile_edit_introduction), style = MaterialTheme.typography.contentBold, diff --git a/feature/profile/src/test/java/com/project200/undabang/profile/mypage/ProfileEditViewModelTest.kt b/feature/profile/src/test/java/com/project200/undabang/profile/mypage/ProfileEditViewModelTest.kt index ddf55c62..f20cb6c4 100644 --- a/feature/profile/src/test/java/com/project200/undabang/profile/mypage/ProfileEditViewModelTest.kt +++ b/feature/profile/src/test/java/com/project200/undabang/profile/mypage/ProfileEditViewModelTest.kt @@ -349,6 +349,37 @@ class ProfileEditViewModelTest { } } + @Test + fun `소개를 모두 지우면 빈 문자열을 저장하고 재조회한 빈 입력을 로드한다`() = + runTest { + coEvery { mockGetUserProfileUseCase() } returnsMany + listOf(BaseResult.Success(sampleProfile), BaseResult.Success(sampleProfile.copy(bio = ""))) + coEvery { + mockEditProfileUseCase(sampleProfile.nickname, sampleProfile.gender, "") + } returns BaseResult.Success(Unit) + createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.introduction.value).isEqualTo(sampleProfile.bio) + + viewModel.updateIntroduction("") + viewModel.editResult.test { + viewModel.completeEditProfile() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(awaitItem()).isTrue() + } + coVerify(exactly = 1) { mockEditProfileUseCase(sampleProfile.nickname, sampleProfile.gender, "") } + coVerify(exactly = 0) { mockAddProfileImageUseCase(any()) } + + viewModel.updateIntroduction("저장하지 않은 소개") + viewModel.getProfile() + assertThat(viewModel.isLoaded.value).isFalse() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.isLoaded.value).isTrue() + assertThat(viewModel.initProfile.value?.bio).isEqualTo("") + assertThat(viewModel.introduction.value).isEmpty() + coVerify(exactly = 2) { mockGetUserProfileUseCase() } + } + @Test fun `completeEditProfile - 닉네임 변경 시 중복확인 안하면 NO_DUPLICATE_CHECKED 에러가 emit된다`() = runTest { From 719619ab6b715ecb3702e96a7a227fb6dfe0adf5 Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:05:00 +0900 Subject: [PATCH 12/14] =?UTF-8?q?fix(profile):=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=82=AD=EC=A0=9C=EC=99=80=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=A0=84=20=EC=9B=90=EB=AC=B8=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .maestro/README-profile.md | 10 +++- .maestro/flows/profile-edit-validation.yaml | 53 +++++++++++++++----- .maestro/flows/profile-edit.yaml | 48 +++++++++++++----- .maestro/subflows/profile-assert-inputs.yaml | 24 +++++++++ .maestro/subflows/profile-clear-text.yaml | 12 +++++ 5 files changed, 121 insertions(+), 26 deletions(-) create mode 100644 .maestro/subflows/profile-assert-inputs.yaml create mode 100644 .maestro/subflows/profile-clear-text.yaml diff --git a/.maestro/README-profile.md b/.maestro/README-profile.md index 15ef8f52..ed4bdd1d 100644 --- a/.maestro/README-profile.md +++ b/.maestro/README-profile.md @@ -22,7 +22,7 @@ Preparation reads the authenticated member's real profile and preference records For failed profile preparation, the runner reports only exact fixed codes reviewed in `MaestroProfileFixture`, `MaestroProfilePolicy`, and `MaestroProfileNotificationReadiness`. Baseline validation reports the first failed condition in this order: `profile_baseline_nickname_unsupported`, `profile_baseline_gender_unsupported`, `profile_baseline_bio_null`, `profile_baseline_bio_too_long`, then `profile_baseline_bio_control_characters`. The runner maps each code to its fixed `bootstrap-prepare-failed-profile-baseline-...` error. The diagnostic mapping applies only to `prepare` + `profile` + `status: failed`. Unknown strings, prefixes with extra text, non-string reasons, the retired `profile_baseline_text_unsupported` code, other features, cleanup, and other statuses retain the generic bootstrap error. Diagnostics never include profile values, lengths, character codes, tokens, exception messages, or arbitrary server responses. The transport regression tests retain the synthetic-secret redaction case and cover all five baseline mappings and these boundaries. - The original nickname must pass the app's `ValidateNicknameUseCase` rule (`^[가-힣a-zA-Z0-9]{1,30}$`), because UI restoration runs the real duplicate check. The original gender must be one of the app's `MALE`, `FEMALE`, or `UNKNOWN` values. -- The bio must be non-null, at most 50 UTF-16 characters, and contain no control characters. An empty string is a valid baseline, distinct from null. After `profile_edit_loaded`, the edit flow checks the introduction's actual empty/present state. It never copies an empty field's hint or calls `inputText` with an empty value. Nonempty baselines still require exact `copyTextFrom` equality. UI restoration clears the field, types only a nonempty original, saves, reopens the editor, waits for its profile read, and checks the actual empty state or exact original content. Maestro CLI 2.11.0 types non-ASCII Android `inputText` through its own IME, so Hangul and punctuation remain supported. The 50-character limit matches the flow's default `eraseText` count. Null cannot be sent in the profile PUT body. Null and unrepresentable baselines fail **before mutation**; the fixture never replaces the account baseline with hint or test text. +- The bio must be non-null, at most 50 UTF-16 characters, and contain no control characters. An empty string is a valid baseline, distinct from null. After `profile_edit_loaded`, the edit flow checks the introduction's actual empty/present state. It never copies an empty field's hint or calls `inputText` with an empty value. Nonempty baselines still require exact `copyTextFrom` equality. UI restoration selects and clears the entire field, types only a nonempty original, verifies both actual inputs before saving, reopens the editor, waits for its profile read, and checks the actual empty state or exact original content. Maestro CLI 2.11.0 types non-ASCII Android `inputText` through its own IME, so Hangul and punctuation remain supported. Null cannot be sent in the profile PUT body. Null and unrepresentable baselines fail **before mutation**; the fixture never replaces the account baseline with hint or test text. - Every original preference must have a positive unique record ID/type ID, seven day booleans with at least one true day, and a recognized skill. The preference scenario requires 1–4 original selections and at least one real unselected catalog type. Preparation obtains its ID/name from the real catalog; the flow never chooses an arbitrary offscreen chip. - For `profile-notification`, the fixture waits for the real saved FCM token, registers that token, and reads the actual device settings. The response must contain exactly one `WORKOUT_REMINDER` and one `CHAT_MESSAGE` row. Missing rows fail; the fixture never synthesizes OFF/OFF defaults. - Notification values need not initially be OFF. Preparation snapshots the real values before its notification PATCH. It sets only the two UI-owned types to OFF and verifies that readback. Additional server notification type names and values remain intact. @@ -60,6 +60,12 @@ Android permission changes use a disposable emulator policy: the flow grants the Profile fields put their stable input IDs on `UndabangTextField.inputModifier`, not its layout wrapper. `profile_edit_loaded` appears only after a successful profile read. The existing introduction section wrapper additionally exposes `profile_edit_introduction_empty` or `profile_edit_introduction_present` from the actual input string, without spoken content or a replacement field ID. The flow checks that state only after the loaded barrier. Preference screens expose loaded TYPE/DETAIL states, use exercise-type-scoped chip/day/skill IDs, and scroll to each target explicitly. The empty-selection check also reaches originally selected chips outside the viewport. Notification switches are hidden on read errors, disabled while saving, and exposed as `notification_ready` only after required rows and PATCH readback succeed. Permission denial has a persistent OS-derived explanation as well as the existing toast. +Both edit flows use `profile-clear-text.yaml` for all eight nickname/introduction edits. The helper long-presses the actual field, selects Korean `모두 선택` when available, and erases the selection. A center tap followed by `eraseText` can leave text after the cursor and is not sufficient. Before every duplicate check or save attempt, `profile-assert-inputs.yaml` verifies the exact expected nickname and introduction in the actual inputs. It uses `copyTextFrom` plus strict equality for the nickname and nonempty introduction. For an empty introduction, it checks `profile_edit_introduction_empty` instead of copying hint text. Verification never overwrites `output.originalNickname` or `output.originalIntroduction`. A failed input guard stops before that action; the previously persisted baseline or exact test tuple remains within the unchanged recovery allowlist. + Behavioral coverage is in profile ViewModel tests and `app/src/testE2e/.../MaestroProfilePolicyTest.kt`, `MaestroProfileRecoveryTest.kt`, and `MaestroProfileNotificationReadinessTest.kt`. The empty-bio recovery regression validates the baseline before checking the exact empty-string PUT and GET readback. Separate policy coverage rejects null baselines. The ViewModel regression loads an existing introduction, clears and saves an empty string, then reloads empty input over an unsaved introduction. Recovery rejects null, whitespace-only, and changed nonempty readbacks. The readiness tests cover delayed callback storage, the timeout boundary, absent/blank tokens, backend errors/timeouts, token rotation, missing rows, cancellation, and notification-only scope. The recovery tests retain original-ID preservation, exact owned deletion, raw additional notification types, backend read errors, wrong device identity, failed readback, explicit notification absence, cross-flow recovery for existing notification snapshots, and repeat-restoration ownership checks. These are unit-test doubles only; the actual fixture and all four Maestro scenarios call the real backend. -[Maestro CI run 39020015008](https://github.com/projects200/android/actions/runs/39020015008), at `2a4d0555` (formatting-only differences from `67cea31f`), passed all 12 common flows. All four profile flows stopped on the empty-bio baseline check before mutation; no profile UI commands ran. This identifies an actual empty string, not a null bio or invalid nickname. [Normal CI run 39020625352](https://github.com/projects200/android/actions/runs/39020625352) succeeded, but does not establish profile UI behavior. Exact empty persistence through the real UI and API, nonempty roundtrips, all four profile scenarios, and recovery after failure require CI verification of this fix. No local build, test, lint, formatter, syntax-check command, adb, emulator, Firebase, or Maestro command was run for this change. +[Maestro CI run 39020015008](https://github.com/projects200/android/actions/runs/39020015008), at `2a4d0555` (formatting-only differences from `67cea31f`), passed all 12 common flows. All four profile flows stopped on the empty-bio baseline check before mutation; no profile UI commands ran. [Normal CI run 39020625352](https://github.com/projects200/android/actions/runs/39020625352) succeeded, but does not establish profile UI behavior. + +[Maestro CI run 39022487235](https://github.com/projects200/android/actions/runs/39022487235), at `bc819f8f`, passed six smoke/feed flows. The empty-state tag and empty environment-value checks passed before mutation. `profile-edit` saved and read back the first new tuple, then reopened the editor. Center taps followed by `eraseText` left run-ID suffixes in both restored fields. The final nickname assertion failed. Exact recovery ownership validation then refused the unexpected tuple, so the later nine flows did not execute. The run's `profile-edit` command log and hierarchy establish this sequence; do not publish actual profile values. + +The selection-clear and exact pre-action guards prevent that unintended save without changing authentication or API recovery ownership. Recovery snapshots are emulator-local and are not included in uploaded artifacts; the completed runner cannot resume that snapshot. The earlier server values need separate verified restoration. A new run taking the leftover values as its starting snapshot does not prove that restoration. Actual API34 execution must still verify the four profile flows, empty and nonempty UI/API roundtrips, and cleanup after an input guard fails. No local build, test, lint, formatter, syntax-check command, adb, emulator, Firebase, or Maestro command was run for this change. diff --git a/.maestro/flows/profile-edit-validation.yaml b/.maestro/flows/profile-edit-validation.yaml index 8d74d49b..5b764ff6 100644 --- a/.maestro/flows/profile-edit-validation.yaml +++ b/.maestro/flows/profile-edit-validation.yaml @@ -31,11 +31,17 @@ tags: - evalScript: ${output.nickB = 'e2eb' + E2E_RUN_ID} # 1) 중복 확인 없이 완료 -> 저장이 막힙니다 -- tapOn: - id: profile_edit_nickname_field -- eraseText +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_nickname_field - inputText: ${output.nickA} - hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: ${output.nickA} + EXPECTED_INTRODUCTION: ${E2E_PROFILE_ORIGINAL_BIO} - tapOn: "완료" - extendedWaitUntil: visible: "닉네임 중복 확인을 해주세요." @@ -43,34 +49,57 @@ tags: # 2) 유효하지 않은 값 -> 규칙 위반 문구가 뜨고, 중복 확인 API는 호출되지 않습니다 # ValidateNicknameUseCase 정규식(한글/영문/숫자만)에 맞춰 '!'로 위반시킵니다 -- tapOn: - id: profile_edit_nickname_field -- eraseText +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_nickname_field - inputText: "e2e615!invalid" - hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: e2e615!invalid + EXPECTED_INTRODUCTION: ${E2E_PROFILE_ORIGINAL_BIO} - tapOn: "중복 확인" - assertVisible: "닉네임 조건을 확인해주세요." +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: e2e615!invalid + EXPECTED_INTRODUCTION: ${E2E_PROFILE_ORIGINAL_BIO} - tapOn: "완료" - extendedWaitUntil: visible: "닉네임 중복 확인을 해주세요." timeout: 10000 # 3) 닉네임 A로 중복 확인을 통과한 뒤 B로 다시 바꾸면 확인 상태가 풀립니다(요구 완료 기준) -- tapOn: - id: profile_edit_nickname_field -- eraseText +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_nickname_field - inputText: ${output.nickA} - hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: ${output.nickA} + EXPECTED_INTRODUCTION: ${E2E_PROFILE_ORIGINAL_BIO} - tapOn: "중복 확인" - extendedWaitUntil: visible: "사용 가능한 닉네임입니다." timeout: 15000 -- tapOn: - id: profile_edit_nickname_field -- eraseText +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_nickname_field - inputText: ${output.nickB} - hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: ${output.nickB} + EXPECTED_INTRODUCTION: ${E2E_PROFILE_ORIGINAL_BIO} # updateNickname이 값이 바뀔 때마다 isNicknameChecked를 초기화합니다. 중복 확인 문구가 사라졌는지 확인합니다 - assertNotVisible: "사용 가능한 닉네임입니다." - tapOn: "완료" diff --git a/.maestro/flows/profile-edit.yaml b/.maestro/flows/profile-edit.yaml index ac85cd23..7e0dd965 100644 --- a/.maestro/flows/profile-edit.yaml +++ b/.maestro/flows/profile-edit.yaml @@ -52,11 +52,17 @@ tags: element: id: profile_edit_nickname_field direction: UP -- tapOn: - id: profile_edit_nickname_field -- eraseText +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_nickname_field - inputText: ${E2E_PROFILE_NEW_NICKNAME} - hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: ${E2E_PROFILE_NEW_NICKNAME} + EXPECTED_INTRODUCTION: ${output.originalIntroduction} - tapOn: "중복 확인" - extendedWaitUntil: visible: "사용 가능한 닉네임입니다." @@ -65,11 +71,17 @@ tags: element: id: profile_edit_introduction_field direction: DOWN -- tapOn: - id: profile_edit_introduction_field -- eraseText +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_introduction_field - inputText: ${E2E_PROFILE_NEW_BIO} - hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: ${E2E_PROFILE_NEW_NICKNAME} + EXPECTED_INTRODUCTION: ${E2E_PROFILE_NEW_BIO} - tapOn: "완료" - extendedWaitUntil: visible: "프로필 수정이 완료됐습니다." @@ -116,11 +128,17 @@ tags: element: id: profile_edit_nickname_field direction: UP -- tapOn: - id: profile_edit_nickname_field -- eraseText +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_nickname_field - inputText: ${output.originalNickname} - hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: ${output.originalNickname} + EXPECTED_INTRODUCTION: ${E2E_PROFILE_NEW_BIO} - tapOn: "중복 확인" - extendedWaitUntil: visible: "사용 가능한 닉네임입니다." @@ -129,9 +147,10 @@ tags: element: id: profile_edit_introduction_field direction: DOWN -- tapOn: - id: profile_edit_introduction_field -- eraseText +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_introduction_field # 빈 원문은 실제 입력을 지운 상태로 저장하며 inputText에 빈 문자열을 넘기지 않습니다. - runFlow: when: @@ -139,6 +158,11 @@ tags: commands: - inputText: ${output.originalIntroduction} - hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: ${output.originalNickname} + EXPECTED_INTRODUCTION: ${output.originalIntroduction} - tapOn: "완료" - extendedWaitUntil: visible: "프로필 수정이 완료됐습니다." diff --git a/.maestro/subflows/profile-assert-inputs.yaml b/.maestro/subflows/profile-assert-inputs.yaml new file mode 100644 index 00000000..c0d11969 --- /dev/null +++ b/.maestro/subflows/profile-assert-inputs.yaml @@ -0,0 +1,24 @@ +appId: com.project200.undabang +--- +# 중복 확인·저장 직전에 실제 입력을 대조합니다. 원문 보관용 output 값은 덮어쓰지 않습니다. +- scrollUntilVisible: + element: + id: profile_edit_introduction_field + direction: DOWN +# 빈 소개는 hint를 복사하지 않고 실제 입력의 빈 상태를 확인합니다. +- assertVisible: + id: "${EXPECTED_INTRODUCTION === '' ? 'profile_edit_introduction_empty' : 'profile_edit_introduction_present'}" +- runFlow: + when: + true: ${EXPECTED_INTRODUCTION !== ''} + commands: + - copyTextFrom: + id: profile_edit_introduction_field + - assertTrue: ${maestro.copiedText === EXPECTED_INTRODUCTION} +- scrollUntilVisible: + element: + id: profile_edit_nickname_field + direction: UP +- copyTextFrom: + id: profile_edit_nickname_field +- assertTrue: ${maestro.copiedText === EXPECTED_NICKNAME} diff --git a/.maestro/subflows/profile-clear-text.yaml b/.maestro/subflows/profile-clear-text.yaml new file mode 100644 index 00000000..a9f8330e --- /dev/null +++ b/.maestro/subflows/profile-clear-text.yaml @@ -0,0 +1,12 @@ +appId: com.project200.undabang +--- +# matching-clear-text와 같은 선택 삭제로 커서 뒤의 기존 닉네임·소개도 모두 지웁니다. +# 한 단어 전체가 이미 선택되면 '모두 선택' 메뉴가 없을 수 있습니다. +- longPressOn: + id: ${FIELD_ID} +- runFlow: + when: + visible: "모두 선택" + commands: + - tapOn: "모두 선택" +- eraseText From 4ca316ad4a7e4395a0ebe673105d94fbdeae5d7e Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:45:28 +0900 Subject: [PATCH 13/14] =?UTF-8?q?fix(profile):=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=99=80=20=EC=95=8C=EB=A6=BC=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EA=B1=B0=EB=B6=80=20=EA=B2=80=EC=A6=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .maestro/README-profile.md | 6 ++-- .../flows/profile-preferred-exercise.yaml | 28 +++++++++---------- .../subflows/profile-notification-denied.yaml | 13 +++++++++ .../PreferredExerciseScreen.kt | 2 +- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/.maestro/README-profile.md b/.maestro/README-profile.md index ed4bdd1d..7a9cd75a 100644 --- a/.maestro/README-profile.md +++ b/.maestro/README-profile.md @@ -64,8 +64,8 @@ Both edit flows use `profile-clear-text.yaml` for all eight nickname/introductio Behavioral coverage is in profile ViewModel tests and `app/src/testE2e/.../MaestroProfilePolicyTest.kt`, `MaestroProfileRecoveryTest.kt`, and `MaestroProfileNotificationReadinessTest.kt`. The empty-bio recovery regression validates the baseline before checking the exact empty-string PUT and GET readback. Separate policy coverage rejects null baselines. The ViewModel regression loads an existing introduction, clears and saves an empty string, then reloads empty input over an unsaved introduction. Recovery rejects null, whitespace-only, and changed nonempty readbacks. The readiness tests cover delayed callback storage, the timeout boundary, absent/blank tokens, backend errors/timeouts, token rotation, missing rows, cancellation, and notification-only scope. The recovery tests retain original-ID preservation, exact owned deletion, raw additional notification types, backend read errors, wrong device identity, failed readback, explicit notification absence, cross-flow recovery for existing notification snapshots, and repeat-restoration ownership checks. These are unit-test doubles only; the actual fixture and all four Maestro scenarios call the real backend. -[Maestro CI run 39020015008](https://github.com/projects200/android/actions/runs/39020015008), at `2a4d0555` (formatting-only differences from `67cea31f`), passed all 12 common flows. All four profile flows stopped on the empty-bio baseline check before mutation; no profile UI commands ran. [Normal CI run 39020625352](https://github.com/projects200/android/actions/runs/39020625352) succeeded, but does not establish profile UI behavior. +[Maestro CI run 39027346415](https://github.com/projects200/android/actions/runs/39027346415), at `719619ab`, passed `profile-edit`, `profile-edit-validation`, and all 12 common flows. [Normal CI run 39027346334](https://github.com/projects200/android/actions/runs/39027346334) also succeeded. Only the preference and notification flows failed. -[Maestro CI run 39022487235](https://github.com/projects200/android/actions/runs/39022487235), at `bc819f8f`, passed six smoke/feed flows. The empty-state tag and empty environment-value checks passed before mutation. `profile-edit` saved and read back the first new tuple, then reopened the editor. Center taps followed by `eraseText` left run-ID suffixes in both restored fields. The final nickname assertion failed. Exact recovery ownership validation then refused the unexpected tuple, so the later nine flows did not execute. The run's `profile-edit` command log and hierarchy establish this sequence; do not publish actual profile values. +The preference failure hierarchy showed the selected chip as `checked: true`, `selected: false`. Type, day, and skill controls share Compose `selected` semantics; the flow now checks their exported Android `checked` state without removing positive or negative assertions. The notification flow persisted ON and read it back, then stopped at Android's notification permission prompt after relaunch. The denial helper now waits for that prompt or app readiness, explicitly taps the actual deny button when present, and still requires app readiness and the caller's denied-state checks. These two corrections require a new CI result. -The selection-clear and exact pre-action guards prevent that unintended save without changing authentication or API recovery ownership. Recovery snapshots are emulator-local and are not included in uploaded artifacts; the completed runner cannot resume that snapshot. The earlier server values need separate verified restoration. A new run taking the leftover values as its starting snapshot does not prove that restoration. Actual API34 execution must still verify the four profile flows, empty and nonempty UI/API roundtrips, and cleanup after an input guard fails. No local build, test, lint, formatter, syntax-check command, adb, emulator, Firebase, or Maestro command was run for this change. +The earlier [run 39022487235](https://github.com/projects200/android/actions/runs/39022487235) passed the empty-bio baseline checks but left run-ID suffixes during UI restoration. Exact recovery ownership validation refused that unexpected tuple. Recovery snapshots are emulator-local, absent from uploaded artifacts, and unavailable on the completed runner. Run `39027346415` used the leftover values as its baseline; its successful edit roundtrip does not prove restoration of the earlier account values. Those values still need separate verified restoration. No local build, test, lint, formatter, syntax-check command, adb, emulator, Firebase, or Maestro command was run for these changes. diff --git a/.maestro/flows/profile-preferred-exercise.yaml b/.maestro/flows/profile-preferred-exercise.yaml index 0139ddf4..ea52ba6a 100644 --- a/.maestro/flows/profile-preferred-exercise.yaml +++ b/.maestro/flows/profile-preferred-exercise.yaml @@ -14,12 +14,12 @@ tags: - runFlow: ../subflows/profile-open-preferences.yaml - assertVisible: id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} - selected: false + checked: false - tapOn: id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} - assertVisible: id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} - selected: true + checked: true - runFlow: ../subflows/profile-preference-detail.yaml # 요일과 숙련도 누락은 모두 실제 저장 검증에 막혀야 합니다. - tapOn: "완료" @@ -52,21 +52,21 @@ tags: - runFlow: ../subflows/profile-open-preferences.yaml - assertVisible: id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} - selected: true + checked: true - runFlow: ../subflows/profile-preference-detail.yaml - assertVisible: id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 - selected: true + checked: true - assertVisible: id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_2 - selected: false + checked: false - scrollUntilVisible: element: id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER direction: DOWN - assertVisible: id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER - selected: true + checked: true - scrollUntilVisible: element: id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_2 @@ -82,10 +82,10 @@ tags: - runFlow: ../subflows/profile-preference-detail.yaml - assertVisible: id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 - selected: true + checked: true - assertVisible: id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_2 - selected: true + checked: true - tapOn: id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 - tapOn: "완료" @@ -97,17 +97,17 @@ tags: - runFlow: ../subflows/profile-preference-detail.yaml - assertVisible: id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 - selected: false + checked: false - assertVisible: id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_2 - selected: true + checked: true - scrollUntilVisible: element: id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER direction: DOWN - assertVisible: id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER - selected: true + checked: true - back - back # 화면 밖의 선택도 ID별로 스크롤해서 모두 해제합니다. 이 변경은 저장하지 않습니다. @@ -128,7 +128,7 @@ tags: direction: DOWN - assertVisible: id: preferred_exercise_type_${output.baselineTypeIds[output.typeIndex]} - selected: true + checked: true - tapOn: id: preferred_exercise_type_${output.baselineTypeIds[output.typeIndex]} - evalScript: ${output.typeIndex = output.typeIndex + 1} @@ -148,7 +148,7 @@ tags: - runFlow: ../subflows/profile-open-preferences.yaml - assertVisible: id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} - selected: true + checked: true - tapOn: id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} - tapOn: "다음" @@ -161,7 +161,7 @@ tags: - runFlow: ../subflows/profile-open-preferences.yaml - assertVisible: id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} - selected: false + checked: false - back - scrollUntilVisible: element: diff --git a/.maestro/subflows/profile-notification-denied.yaml b/.maestro/subflows/profile-notification-denied.yaml index 5f3078bd..6677dad0 100644 --- a/.maestro/subflows/profile-notification-denied.yaml +++ b/.maestro/subflows/profile-notification-denied.yaml @@ -9,6 +9,19 @@ appId: com.project200.undabang notifications: deny location: allow +# 재요청된 OS 권한 창은 실제로 거부하고, 이미 거부된 상태면 앱 진입을 기다립니다. +- extendedWaitUntil: + visible: + id: "com\\.android\\.permissioncontroller:id/permission_deny_button|matching_nav_graph" + timeout: 60000 +- runFlow: + when: + visible: + id: "com\\.android\\.permissioncontroller:id/permission_deny_button" + commands: + - tapOn: + id: "com\\.android\\.permissioncontroller:id/permission_deny_button" + # 재기동 뒤 실제 OS 권한은 NotificationFragment가 확인하고 화면에 표시합니다. - extendedWaitUntil: visible: diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt index 576a96f7..c5be47bb 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt @@ -220,7 +220,7 @@ private fun PreferredExerciseTypeChip( indication = null, onClick = onClick, ) - // Maestro가 미선택/선택 칩을 selected 속성으로 구분해 고릅니다. 종목 목록이 계정마다 달라 이름을 미리 고정할 수 없습니다 + // Compose의 selected 상태는 Maestro에 checked로 노출됩니다. 계정별 종목은 이름 대신 ID로 식별합니다 .semantics { selected = uiModel.isSelected } .testTag("preferred_exercise_type_${uiModel.exercise.exerciseTypeId}") .padding(horizontal = 12.dp, vertical = 8.dp), From dc8f19bf8e46ae32f818bfbd56ff0bc0d30d03cb Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:04:37 +0900 Subject: [PATCH 14/14] =?UTF-8?q?test(profile):=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20UI=20=EC=A4=91=EB=8B=A8=20=EC=A7=84=EB=8B=A8=20?= =?UTF-8?q?=EC=88=98=EC=A7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .maestro/README-profile.md | 8 +- .maestro/scripts/run-tests.py | 43 +++++++ .maestro/scripts/test_run_tests.py | 174 +++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 2 deletions(-) diff --git a/.maestro/README-profile.md b/.maestro/README-profile.md index 7a9cd75a..7b1c03d1 100644 --- a/.maestro/README-profile.md +++ b/.maestro/README-profile.md @@ -66,6 +66,10 @@ Behavioral coverage is in profile ViewModel tests and `app/src/testE2e/.../Maest [Maestro CI run 39027346415](https://github.com/projects200/android/actions/runs/39027346415), at `719619ab`, passed `profile-edit`, `profile-edit-validation`, and all 12 common flows. [Normal CI run 39027346334](https://github.com/projects200/android/actions/runs/39027346334) also succeeded. Only the preference and notification flows failed. -The preference failure hierarchy showed the selected chip as `checked: true`, `selected: false`. Type, day, and skill controls share Compose `selected` semantics; the flow now checks their exported Android `checked` state without removing positive or negative assertions. The notification flow persisted ON and read it back, then stopped at Android's notification permission prompt after relaunch. The denial helper now waits for that prompt or app readiness, explicitly taps the actual deny button when present, and still requires app readiness and the caller's denied-state checks. These two corrections require a new CI result. +The preference failure hierarchy showed the selected chip as `checked: true`, `selected: false`. Type, day, and skill controls share Compose `selected` semantics; the flow now checks their exported Android `checked` state without removing positive or negative assertions. The notification flow persisted ON and read it back, then stopped at Android's notification permission prompt after relaunch. The denial helper now waits for that prompt or app readiness, explicitly taps the actual deny button when present, and still requires app readiness and the caller's denied-state checks. The latest startup failure did not exercise either correction; both still require successful runtime verification. -The earlier [run 39022487235](https://github.com/projects200/android/actions/runs/39022487235) passed the empty-bio baseline checks but left run-ID suffixes during UI restoration. Exact recovery ownership validation refused that unexpected tuple. Recovery snapshots are emulator-local, absent from uploaded artifacts, and unavailable on the completed runner. Run `39027346415` used the leftover values as its baseline; its successful edit roundtrip does not prove restoration of the earlier account values. Those values still need separate verified restoration. No local build, test, lint, formatter, syntax-check command, adb, emulator, Firebase, or Maestro command was run for these changes. +At `4ca316ad`, [Normal CI run 39029919843](https://github.com/projects200/android/actions/runs/39029919843) succeeded, but [Maestro CI run 39029919892](https://github.com/projects200/android/actions/runs/39029919892) failed all 16 flows during startup. The inspected `smoke-tabs` and `login-screen` hierarchies both show a System UI ANR dialog. No preference or notification feature step ran. The ANR trigger remains unknown; the diagnostics below are instrumentation, not a root-cause fix. + +After a nonzero Maestro exit, the runner reads `dumpsys activity lastanr` once and writes only `/last-anr-summary.json`: fixed process categories (`system-ui`, `application`, `other`, `unknown`), fixed reason categories (`input-dispatch`, `broadcast`, `service`, `content-provider`, `unknown`), and numeric load averages/total CPU or null. The last ANR may describe an earlier event; it does not prove the current failure's cause. Raw reports, process names, PIDs, window titles, Intent contents, and backend strings are not published; raw logcat remains uncollected. Expected collection or write failures preserve the original `maestro-flow-failed` result and the existing redacted console; cancellation still propagates. The runner does not retry or dismiss the ANR. + +The earlier [run 39022487235](https://github.com/projects200/android/actions/runs/39022487235) passed the empty-bio baseline checks but left run-ID suffixes during UI restoration. Exact recovery ownership validation refused that unexpected tuple. Recovery snapshots are emulator-local, absent from uploaded artifacts, and unavailable on the completed runner. Run `39027346415` used the leftover values as its baseline; its successful edit roundtrip does not prove restoration of the earlier account values. The current starting snapshot remains contaminated, and the earlier server values still need separate verified restoration. No local build, test, lint, formatter, syntax-check command, adb, emulator, Firebase, or Maestro command was run for these changes. diff --git a/.maestro/scripts/run-tests.py b/.maestro/scripts/run-tests.py index bcf9f604..a36d7633 100755 --- a/.maestro/scripts/run-tests.py +++ b/.maestro/scripts/run-tests.py @@ -852,6 +852,48 @@ def save_debug(raw, destination, redactor, console): (destination / ("screenshot-%03d.png" % index)).write_bytes(content) +def save_failure_anr(device, destination): + try: + code, output, _ = device.call("shell", "dumpsys", "activity", "lastanr", timeout=30) + if code != 0: + return + report = output.decode("utf-8", "replace") + process = re.search(r"^ANR in ([A-Za-z0-9_.:]+)(?:\s|$)", report, re.MULTILINE) + process_name = process.group(1) if process else None + category = { + "com.android.systemui": "system-ui", APP_ID: "application", + }.get(process_name, "other" if process_name else "unknown") + reason = re.search(r"^Reason: ([^\n]*)", report, re.MULTILINE) + reason_category = "unknown" + if reason: + for prefix, name in ( + ("Input dispatching timed out", "input-dispatch"), + ("Broadcast of Intent", "broadcast"), + ("executing service", "service"), + ("ContentProvider not responding", "content-provider"), + ): + if reason.group(1).startswith(prefix): + reason_category = name + break + load = re.search(r"^Load: ([0-9]+(?:\.[0-9]+)?) / ([0-9]+(?:\.[0-9]+)?) / ([0-9]+(?:\.[0-9]+)?)$", + report, re.MULTILINE) + cpu = re.search(r"^\s*([0-9]+(?:\.[0-9]+)?)% TOTAL:", report, re.MULTILINE) + # 마지막 ANR은 이전 사건일 수 있습니다. 원문·PID·창 제목·Intent는 공개하지 않습니다. + summary = { + "process": category, "reason": reason_category, + "load_average": [float(value) for value in load.groups()] if load else None, + "total_cpu_percent": float(cpu.group(1)) if cpu else None, + } + destination.mkdir(parents=True, exist_ok=True) + (destination / "last-anr-summary.json").write_text( + json.dumps(summary, sort_keys=True) + "\n", encoding="utf-8", + ) + except RunInterrupted: + raise + except (RunnerFailure, OSError): + return + + def maestro_result(report): try: root = ET.parse(report).getroot() @@ -901,6 +943,7 @@ def run_maestro(device, flow, output, redactor, timeout, fixture_start_date=None try: code, stdout, stderr = command(argv, env, timeout=timeout) if code != 0: + save_failure_anr(device, output / flow) raise RunnerFailure("maestro-flow-failed") maestro_result(report) finally: diff --git a/.maestro/scripts/test_run_tests.py b/.maestro/scripts/test_run_tests.py index 03e62f1d..8ccec4b9 100755 --- a/.maestro/scripts/test_run_tests.py +++ b/.maestro/scripts/test_run_tests.py @@ -497,6 +497,180 @@ def test_remove_private_preserves_session_until_explicit_cleanup(self): self.assertEqual(unrelated.read_bytes(), b"keep") +class MaestroFailureArtifactTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory( + prefix="maestro-failure-test-", dir=Path("/tmp").resolve(), + ) + self.addCleanup(temporary.cleanup) + self.output = Path(temporary.name) / "profile" + self.flow = "profile-preferred-exercise" + self.destination = self.output / self.flow + self.escaped_secret = json.dumps(SYNTHETIC_SECRET, ensure_ascii=False)[1:-1] + self.console = ("Maestro UI assertion failed: " + SYNTHETIC_SECRET).encode("utf-8") + self.device = SimpleNamespace( + serial="failure-test", exercise_variables=mock.Mock(return_value={}), call=mock.Mock(), + ) + + def command(argv, _env, timeout=60, payload=None): + if argv[1:] == ["--version"]: + return 0, runner.MAESTRO_VERSION.encode("utf-8"), b"" + if argv[1:2] == ["test"]: + return 1, self.console, b"" + raise AssertionError("Unexpected Maestro operation") + + patches = ( + mock.patch.object(runner, "command", side_effect=command), + mock.patch.object(runner, "shutil", SimpleNamespace(which=lambda _name: "test-maestro")), + ) + for patch in patches: + patch.start() + self.addCleanup(patch.stop) + + def test_system_ui_anr_summary_keeps_only_categories_and_load(self): + private = "synthetic-private-window-and-intent" + report = ( + "ACTIVITY MANAGER LAST ANR (dumpsys activity lastanr)\n" + "ANR in com.android.systemui\nPID: 6175\n" + "Reason: Broadcast of Intent { " + private + " }\n" + "Load: 14.67 / 12.04 / 6.64\n" + " 98.5% TOTAL: 70% user + 28.5% kernel\n" + "Subject: " + self.escaped_secret + "\n" + ) + self.device.call.return_value = (0, report.encode("utf-8"), b"") + with self.assertRaises(runner.RunnerFailure) as caught: + runner.run_maestro( + self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, + ) + self.assertEqual(str(caught.exception), "maestro-flow-failed") + self.assertEqual( + json.loads((self.destination / "last-anr-summary.json").read_text(encoding="utf-8")), + { + "process": "system-ui", "reason": "broadcast", + "load_average": [14.67, 12.04, 6.64], "total_cpu_percent": 98.5, + }, + ) + for path in self.destination.iterdir(): + content = path.read_text(encoding="utf-8") + for value in ( + private, self.escaped_secret, SYNTHETIC_SECRET, "com.android.systemui", "6175", + ): + self.assertNotIn(value, content) + + def test_application_anr_summary_uses_fixed_reason_categories(self): + for reason, category in ( + ("Input dispatching timed out (synthetic-private-window)", "input-dispatch"), + ("executing service synthetic.private.service", "service"), + ("ContentProvider not responding: synthetic.private.provider", "content-provider"), + ): + with self.subTest(category=category): + self.device.call.return_value = ( + 0, ("ANR in " + runner.APP_ID + "\nReason: " + reason + "\n").encode("utf-8"), b"", + ) + runner.save_failure_anr(self.device, self.destination) + self.assertEqual( + json.loads((self.destination / "last-anr-summary.json").read_text(encoding="utf-8")), + { + "process": "application", "reason": category, + "load_average": None, "total_cpu_percent": None, + }, + ) + + def test_anr_summary_does_not_publish_other_process_or_reason_text(self): + self.device.call.return_value = ( + 0, b"ANR in synthetic.private.process\nReason: private-user-content\n", b"", + ) + runner.save_failure_anr(self.device, self.destination) + self.assertEqual( + json.loads((self.destination / "last-anr-summary.json").read_text(encoding="utf-8")), + {"process": "other", "reason": "unknown", "load_average": None, "total_cpu_percent": None}, + ) + + def test_missing_anr_report_is_unknown_not_an_application_failure(self): + self.device.call.return_value = (0, b"\n", b"") + runner.save_failure_anr(self.device, self.destination) + self.assertEqual( + json.loads((self.destination / "last-anr-summary.json").read_text(encoding="utf-8")), + {"process": "unknown", "reason": "unknown", "load_average": None, "total_cpu_percent": None}, + ) + + def test_unavailable_anr_preserves_original_failure_and_console(self): + self.device.call.return_value = ( + 1, SYNTHETIC_SECRET.encode("utf-8"), self.escaped_secret.encode("utf-8"), + ) + published = io.StringIO() + with contextlib.redirect_stdout(published), contextlib.redirect_stderr(published): + with self.assertRaises(runner.RunnerFailure) as caught: + runner.run_maestro( + self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, + ) + self.assertEqual(str(caught.exception), "maestro-flow-failed") + self.assertEqual( + (self.destination / "console.log").read_text(encoding="utf-8"), + "Maestro UI assertion failed: [REDACTED]", + ) + self.assertEqual(published.getvalue(), "") + self.assertFalse((self.destination / "last-anr-summary.json").exists()) + + def test_anr_capture_failure_preserves_original_failure_and_console(self): + for failure in (runner.RunnerFailure("tool-timeout"), OSError(SYNTHETIC_SECRET)): + with self.subTest(failure=type(failure).__name__): + self.device.call.side_effect = failure + published = io.StringIO() + with contextlib.redirect_stdout(published), contextlib.redirect_stderr(published): + with self.assertRaises(runner.RunnerFailure) as caught: + runner.run_maestro( + self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, + ) + self.assertEqual(str(caught.exception), "maestro-flow-failed") + self.assertEqual( + (self.destination / "console.log").read_text(encoding="utf-8"), + "Maestro UI assertion failed: [REDACTED]", + ) + self.assertEqual(published.getvalue(), "") + self.assertFalse((self.destination / "last-anr-summary.json").exists()) + + def test_anr_write_failure_preserves_original_failure_without_private_error(self): + self.device.call.return_value = ( + 0, b"ANR in com.project200.undabang\nReason: Input dispatching timed out\n", b"", + ) + write_text = Path.write_text + + def fail_anr_write(path, *args, **kwargs): + if path == self.destination / "last-anr-summary.json": + raise OSError(SYNTHETIC_SECRET) + return write_text(path, *args, **kwargs) + + published = io.StringIO() + with mock.patch.object(Path, "write_text", new=fail_anr_write): + with contextlib.redirect_stdout(published), contextlib.redirect_stderr(published): + with self.assertRaises(runner.RunnerFailure) as caught: + runner.run_maestro( + self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, + ) + self.assertEqual(str(caught.exception), "maestro-flow-failed") + self.assertEqual( + (self.destination / "console.log").read_text(encoding="utf-8"), + "Maestro UI assertion failed: [REDACTED]", + ) + self.assertEqual(published.getvalue(), "") + self.assertFalse((self.destination / "last-anr-summary.json").exists()) + + def test_anr_interruption_propagates_unchanged(self): + interruption = runner.RunInterrupted("run-interrupted") + self.device.call.side_effect = interruption + with self.assertRaises(runner.RunInterrupted) as caught: + runner.run_maestro( + self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, + ) + self.assertIs(caught.exception, interruption) + self.assertEqual( + (self.destination / "console.log").read_text(encoding="utf-8"), + "Maestro UI assertion failed: [REDACTED]", + ) + self.assertFalse((self.destination / "last-anr-summary.json").exists()) + + class EmulatorInputTests(unittest.TestCase): def input_method(self, subtypes): return runner.ET.fromstring(