From a776d787a131fa50c5a28f387a8bda1e291013c3 Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:09:27 +0900 Subject: [PATCH 1/3] =?UTF-8?q?test(offline):=20=EC=98=A4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=A1=B0=ED=9A=8C=EC=99=80=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=EB=A8=B8=20=EC=9E=AC=EC=97=B0=EA=B2=B0=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=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 | 35 ++- .maestro/offline/assert-exercise-record.yaml | 33 ++ .maestro/offline/assert-timer-draft.yaml | 13 + .maestro/offline/assert-timer-list.yaml | 47 +++ .maestro/offline/create-timer.yaml | 48 +++ .maestro/offline/edit-timer.yaml | 26 ++ .maestro/offline/offline-exercise-cached.yaml | 9 + .maestro/offline/offline-exercise-create.yaml | 46 +++ .../offline/offline-exercise-missing.yaml | 21 ++ .../offline-timers-after-reconnect.yaml | 22 ++ .maestro/offline/offline-timers-mutate.yaml | 53 ++++ .maestro/offline/offline-timers-online.yaml | 13 + .../offline/offline-timers-reconnect.yaml | 9 + .maestro/offline/offline-timers-relaunch.yaml | 19 ++ .maestro/offline/open-timer-list.yaml | 16 + .maestro/offline/prepare-timer-draft.yaml | 20 ++ .maestro/offline/set-timer-time.yaml | 21 ++ .maestro/scripts/run-tests.py | 184 ++++++++++- .maestro/scripts/test_run_tests.py | 297 ++++++++++++++++++ README.md | 5 +- .../undabang/e2e/MaestroOfflineFixtures.kt | 168 ++++++++++ .../undabang/e2e/MaestroSessionBootstrap.kt | 11 + .../undabang/e2e/MaestroSetupCoordinator.kt | 22 ++ .../undabang/e2e/MaestroSetupProtocol.kt | 31 ++ .../e2e/MaestroOfflineFixturesTest.kt | 211 +++++++++++++ .../e2e/MaestroSetupCoordinatorTest.kt | 75 +++++ .../exercise/main/ExerciseMainFragment.kt | 47 ++- .../exercise/main/ExerciseMainViewModel.kt | 6 + .../exercise/src/main/res/values/strings.xml | 2 + .../ExerciseListAccessibilityStateTest.kt | 58 ++++ .../exercise/ExerciseMainViewModelTest.kt | 68 ++++ .../feature/timer/TimerListScreen.kt | 10 +- .../timer/custom/CustomTimerFormScreen.kt | 17 +- .../feature/timer/custom/CustomTimerScreen.kt | 9 +- 35 files changed, 1642 insertions(+), 31 deletions(-) create mode 100644 .maestro/offline/assert-exercise-record.yaml create mode 100644 .maestro/offline/assert-timer-draft.yaml create mode 100644 .maestro/offline/assert-timer-list.yaml create mode 100644 .maestro/offline/create-timer.yaml create mode 100644 .maestro/offline/edit-timer.yaml create mode 100644 .maestro/offline/offline-exercise-cached.yaml create mode 100644 .maestro/offline/offline-exercise-create.yaml create mode 100644 .maestro/offline/offline-exercise-missing.yaml create mode 100644 .maestro/offline/offline-timers-after-reconnect.yaml create mode 100644 .maestro/offline/offline-timers-mutate.yaml create mode 100644 .maestro/offline/offline-timers-online.yaml create mode 100644 .maestro/offline/offline-timers-reconnect.yaml create mode 100644 .maestro/offline/offline-timers-relaunch.yaml create mode 100644 .maestro/offline/open-timer-list.yaml create mode 100644 .maestro/offline/prepare-timer-draft.yaml create mode 100644 .maestro/offline/set-timer-time.yaml create mode 100644 app/src/e2e/java/com/project200/undabang/e2e/MaestroOfflineFixtures.kt create mode 100644 app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineFixturesTest.kt create mode 100644 app/src/testE2e/java/com/project200/undabang/e2e/MaestroSetupCoordinatorTest.kt create mode 100644 feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseListAccessibilityStateTest.kt diff --git a/.github/workflows/android-maestro.yml b/.github/workflows/android-maestro.yml index 31169b5f..7d3a4f4e 100644 --- a/.github/workflows/android-maestro.yml +++ b/.github/workflows/android-maestro.yml @@ -15,6 +15,7 @@ on: options: - regression - all + - offline permissions: contents: read diff --git a/.maestro/README.md b/.maestro/README.md index efbfd090..d317bec4 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -102,7 +102,7 @@ python3 .maestro/scripts/run-tests.py --suite login --device emulator-5554 1. 앱 강제 종료와 `pm clear`로 이전 로컬 인증·Room 캐시·권한을 지웁니다. 2. 보호 플로우는 앱 private 파일에 `prepare` 요청을 stdin으로 전달합니다. 요청은 `authState`, `memberId`, `fixtureStartDate`를 포함합니다. Intent extra/deep link에는 인증 정보가 없습니다. 3. e2e Activity가 DEV discovery와 가져온 endpoint/client를 검증하고 실제 토큰을 갱신합니다. 서버 등록 상태/member ID를 확인한 뒤 예약 기록만 정리합니다. `ready` 결과가 아니면 Maestro를 실행하지 않습니다. -4. 실제 UI 플로우를 한 개 실행합니다. Maestro 환경에는 인증/CI 비밀을 상속하지 않습니다. 전달하는 `-e` 값은 UUID 기반 제목 두 개와 기기 한국 시간의 시작/종료 날짜·시간 네 개뿐입니다. +4. 실제 UI 플로우를 한 개 실행합니다. Maestro 환경에는 인증/CI 비밀을 상속하지 않습니다. `-e`에는 UUID 기반 운동 제목·기기 한국 시간의 날짜/시간만 전달하며, 명시 선택한 `offline`에는 같은 UUID의 커스텀 타이머 제목도 전달합니다. 5. 성공, 단언 실패, 타임아웃, 준비 실패, 처리 가능한 중단 신호 모두 `finally`에서 `cleanup`을 요청합니다. 로컬 세션이 사라졌어도 실제 인증을 다시 복원·갱신한 뒤 서버 member ID를 확인합니다. 마지막에는 앱 데이터를 다시 지웁니다. 테스트나 정리 중 하나라도 실패하면 종료 코드는 **1**입니다. `--flow-timeout` 기본값은 플로우당 900초입니다. bootstrap 작업은 300초 제한입니다. SIGINT/SIGTERM은 실행 중 Maestro 자식을 종료하고 정리를 시도합니다. **SIGKILL, 기기 단절, GitHub 강제 종료/전체 job 타임아웃은 정리 완료를 보장할 수 없습니다.** 다음 정상 prepare가 같은 날짜 범위의 중단 기록을 정리합니다. 정리 실패는 JUnit 실패로 기록되며 숨기지 않습니다. @@ -121,6 +121,8 @@ 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 뒤 실제 로그인 화면/버튼| +|`offline/offline-exercise-*.yaml`|직접 자동 탐색 안 함|`offline`만|UI로 온라인 기록 생성·캘린더/목록/상세 로드, 실제 단절과 상태 보존 재실행, 별도 캐시 미스| +|`offline/offline-timers-*.yaml`|직접 자동 탐색 안 함|`offline`만|고유 커스텀 타이머 오프라인 생성/수정/삭제·재실행, 재접속 후 UI 최종 값과 미저장 폼, 독립 서버 GET| `login-screen`은 세션을 지우므로 직접 Maestro 전체 실행의 `config.yaml`에서도 마지막입니다. 호스트 러너는 앞 플로우의 로그인 상태에 의존하지 않고 매번 복원합니다. 직접 실행한 뒤에는 다시 수동 로그인해야 합니다. @@ -130,6 +132,29 @@ CRUD 입력은 ASCII 제목과 숫자를 사용합니다. 러너는 준비 완 현재 viewport의 `assertNotVisible` 하나로 끝내지 않습니다. 캘린더 헤더부터 화면 20%의 느리고 겹치는 swipe(`duration: 2000ms`, 최대 200회)마다 대상 제목이 없는지 확인하며, 기록이 남아 있으면 마지막 실제 행의 시간 TextView 접근성 레이블 `'운동 기록 목록 마지막 항목: '`에 도달해야 통과합니다. 빈 목록은 정확한 날짜의 성공한 빈 응답 레이블이 필요합니다. 제한된 횟수 안에 끝에 도달하지 못하거나 요청·목록 검사에 실패하면 실패합니다. 이전 날짜·초기 빈 화면을 성공으로 처리하지 않습니다. 호스트의 플로우 제한(기본 900초)을 넘겨도 실패하며 자동 재시도하지 않습니다. +### 명시 선택 오프라인 회귀(#617) + +이 suite는 #609의 세션/private-file 전송과 #610의 DEV 계정·CI 신뢰 경계를 재사용합니다. **#584 운동 캐시, #588 업로드, #589 동기화 실행 경로와 api-server #574/#575의 실제 배포가 먼저 필요합니다.** 이 브랜치의 기준 소스에는 그 구현이 없으므로 실행 통과를 기대하지 않습니다. 서버 conflict/editedAt 응답 형식을 추측하거나 테스트가 업로드를 대신하지 않습니다. + +**BLOCKED — pending 행이 남아 있는 동안 성공한 원격 목록을 적용해도 변경이 보존되는지 증명하는 기준은 아직 충족하지 못합니다.** 재접속 때 Worker가 GET보다 먼저 업로드할 수 있고, UI 목록 조회가 Room fallback으로 성공할 수도 있습니다. 실제 #588/#589 uploader에서 pending 상태·성공한 원격 목록 적용·업로드 완료의 순서를 관찰하거나 안전하게 제어할 계약이 필요합니다. 이 계약은 기준 소스에 없으며, 알 수 없는 Worker를 멈추거나 대체하지 않습니다. 아래 재접속 시나리오가 통과해도 이 기준이나 #617 전체 완료의 증거는 아닙니다. + +```bash +python3 .maestro/scripts/run-with-session.py \ + "$HOME/.config/undabang-e2e/maestro-session.json" --suite offline \ + --device emulator-5554 --apk app/build/outputs/apk/e2e/app-e2e.apk +``` + +- `offline`은 `smoke`, `regression`, `all`과 `.maestro/config.yaml`의 자동 탐색에 포함하지 않습니다. 수동 DEV CI에서 `offline`을 직접 선택합니다. `.maestro/offline/`의 개별 단계는 호스트의 네트워크·세션·정리 순서를 필요로 하므로 단독 Maestro 실행은 지원하지 않습니다. +- DEV Firebase Remote Config의 **`offline_cache_enabled=true`를 실제로 게시**해야 합니다. 준비는 기존 앱의 fetch/activate와 `FeatureFlagDataSource`를 사용해 값을 확인합니다. 기본값은 false이며 e2e override는 없습니다. 꺼져 있으면 `offline-suite-requires-enabled-cache-flag`로 실패합니다. +- API30의 `wifi_on`, `mobile_data`, `airplane_mode_on`이 명확한 0/1이고 초기 기본망이 있어야 합니다. 러너는 Wi-Fi와 모바일 데이터를 실제 `svc`로 끈 뒤 `dumpsys connectivity`의 **현재** `Active default network: none`을 기다립니다. 설정값만 바뀌거나 Ethernet/VPN 등 기본망이 남으면 실패합니다. 보고서 형식이 다른 기기도 실패하며 가짜 offline 모드로 대체하지 않습니다. +- 운동 기록은 기존 UI 폼으로 만듭니다. 실제 서버 목록/상세에서 고유 제목과 시작/종료 시각을 확인한 뒤, 온라인에서 로드한 캘린더의 해당 날짜 표시·목록·상세를 단절 후 새 프로세스에서 다시 단언합니다. 별도 초기화 시나리오는 운동 화면을 미리 열지 않고 단절해 해당 날짜 목록의 실제 `NETWORK_ERROR` 완료 레이블을 요구합니다. 점수/정책 조회의 공통 Toast나 초기 빈 목록은 캐시 미스 검증으로 인정하지 않습니다. +- 타이머는 `maestro-offline-<32자리 UUID>-update/delete` 두 개를 UI로 만들어 **서버 GET에 실제 나타날 때까지** 기다립니다. 단절 후 하나를 `-updated`/75초로 수정하고 다른 하나는 삭제하며, `-create`/45초를 새로 만듭니다. 재실행 뒤 제목·스텝·시간·전체 목록의 삭제 부재를 확인합니다. 심플 타이머와 일반 타이머는 변경하지 않습니다. +- 재접속 직전 120초와 `-draft` 제목을 입력하되 저장하지 않습니다. 연결 복귀 직후에도 이 폼을 확인하고, 폼을 버린 뒤 새 타이머 목록 화면에 표시되는 저장값을 확인합니다. 이는 재접속 결과 검사이며 성공한 원격 목록 적용이나 그때의 pending 상태를 증명하지 않습니다. 다시 미저장 폼을 열어 둔 상태에서 독립 서버 관찰을 완료한 뒤에도 입력값이 그대로인지 검사합니다. +- 서버 관찰은 **e2e 전용 관찰 코드가 `ApiService`를 직접 GET**하며 Room/Repository fallback을 사용하지 않습니다. 동일 UUID의 정확히 두 타이머, 45초/75초의 이름·순서 0 스텝 하나, 원제목·삭제 제목·미저장 draft·중복 부재를 확인합니다. 최대 120초 안에 실제 업로드가 없으면 `offline-suite-requires-real-server-upload`로 실패합니다. 관찰은 현재 DEV 세션과 member ID를 재검증하되 인증을 재가져오거나 앱을 강제 종료하지 않으며, 관찰 코드 자체가 로컬 데이터를 초기화하지 않습니다. +- 각 시나리오의 `finally`는 **원래 Wi-Fi/모바일 설정과 실제 기본망 상태를 먼저 복원·확인**한 뒤, 검증된 전용 계정에서 이번 UUID의 정확한 운동/커스텀 타이머 제목만 상세 재확인 후 삭제합니다. 복원 실패는 실패이며 서버 정리를 진행하지 않습니다. 실패·SIGINT/SIGTERM도 같은 경로를 사용합니다. SIGKILL·기기 단절·전체 job 강제 종료 뒤 복원/정리는 보장할 수 없으며, 다음 UUID 실행이 이전 오프라인 타이머까지 지운다고 가정하지 않습니다. + +이 경로는 Room fixture 삽입, sync 상태 강제 변경, Worker 대리 업로드를 하지 않습니다. 선택자/네트워크/서버/플래그 준비가 안 되면 실패를 남기며 skip으로 통과시키지 않습니다. + ### 수동 UI 개발(격리 CI와 다른 경로) debug/e2e APK를 직접 설치하고 **수동 로그인한 기기**에서 `maestro test`를 실행할 수도 있습니다. 이 경로에는 호스트 준비/정리·CI 계정 검증이 없습니다. 세션이 만료되면 수동 로그인해야 합니다. CRUD는 필요한 제목/날짜 변수를 직접 공급해야 하므로 안전한 정리까지 필요한 실행은 호스트 러너를 권장합니다. @@ -152,7 +177,7 @@ XML ID는 `id` 셀렉터를 사용합니다. Compose의 `feed_content`, `feed_em 프로필 smoke 플로우는 작은 화면에서 화면 밖 콘텐츠를 화면 누락으로 오인하지 않도록 `preferred_exercise_tv`까지 스크롤한 뒤 `선호 운동` 표시를 단언합니다. -## CI(#610): 신뢰된 dev push/PR smoke와 수동 regression +## CI(#610): 신뢰된 dev push/PR smoke와 수동 회귀 `.github/workflows/android-maestro.yml`은 기존 `android-dev-ci.yml`/릴리스 CI와 분리되어 있습니다. @@ -163,7 +188,7 @@ XML ID는 `id` 셀렉터를 사용합니다. Compose의 `feed_content`, `feed_em |`pull_request`|base가 `dev`, 같은 저장소 head, 작성자 association `OWNER`/`MEMBER`/`COLLABORATOR`/`CONTRIBUTOR`|`smoke`| |`pull_request`|base가 `dev`, fork head 또는 비신뢰 작성자|비밀 없는 거절 job 실패, 인증 job 미실행| |`pull_request`|다른 base branch|트리거 필터에서 제외, 비밀 미제공| -|`workflow_dispatch`|ref `refs/heads/dev`, 선택한 suite|`regression` 또는 `all`| +|`workflow_dispatch`|ref `refs/heads/dev`, 선택한 suite|`regression`, `all` 또는 명시적인 `offline`| |`workflow_dispatch`|다른 ref|비밀 없는 거절 job 실패, 인증 job 미실행| - 인증 suite를 실행하지 않은 skip은 인증 통과가 아닙니다. 거절 job은 미실행 상태를 명시하며 **실패**합니다. `pull_request_target`은 사용하지 않습니다. @@ -206,7 +231,7 @@ CI는 실패해도 `always()`로 상태 파일과 안전한 XML/log/JSON/PNG만 ## 검증 상태와 남은 외부 준비 -이번 transport 수정에서는 **로컬 unittest 하네스·빌드·lint·formatter·설치·실기기/에뮬레이터·OAuth·Maestro·CI 검증을 실행하지 않았습니다.** 러너와 관련 소스/문서를 정적으로 읽었으며, 명령 예시는 실행 성공 증거가 아닙니다. +세션 전송·오프라인 회귀 변경의 **로컬 unittest·빌드·lint·formatter·설치·실기기/에뮬레이터·OAuth·Maestro·CI 검증은 이 작업에서 실행하지 않았습니다.** 러너와 관련 소스/문서를 정적으로 읽었으며 명령 예시는 실행 성공 증거가 아닙니다. 저장소 루트에서 실행할 표준 라이브러리 회귀 테스트 명령(이 작업에서는 미실행): @@ -214,6 +239,6 @@ CI는 실패해도 `always()`로 상태 파일과 안전한 XML/log/JSON/PNG만 python3 -m unittest discover -s .maestro/scripts -p 'test_run_tests.py' ``` -`test_run_tests.py`의 로컬 셸 하네스는 실제 adb/기기/계정 없이 stdin 전달, 원격 종료 상태, JSON 검증과 실패 시 export 파일 제거를 검사합니다. 하네스 통과도 실기기 export 증거는 아닙니다. 후속 기기 검증에서는 인증된 세션/member ID 쌍, 비ASCII JSON 왕복과 private 파일 제거를 별도로 확인해야 합니다. +`test_run_tests.py`는 기존 로컬 셸 전송 하네스에 더해, 격리된 가짜 기기/시계/도구 경계로 실제 단절 판정·원래 라디오 복원·부분 실패·중단·서버 업로드 미관찰·정리 실패·opt-in 경계를 결정적으로 검사합니다. 앱의 `testE2e`는 직접 서버 응답 값/중복/실패와 실행별 삭제 범위, 관찰 중 인증 초기화 금지를 검사합니다. 단위 테스트도 실기기·서버 통과 증거는 아닙니다. 최초 실기 검증에는 실제 DEV 전용 등록 계정, 유효한 내보낸 세션과 9개 CI 비밀, 보호된 `maestro-dev` 환경, Firebase/Kakao debug 등록, KVM/ARM translation이 있는 기기가 필요합니다. 첫 API30 실행에서 Compose/XML ID 노출, Android Toast 필수값 단언, 캘린더/시간 선택, CRUD 후 재진입과 정리 실패 경로를 확인해야 합니다. 세션 비밀과 서버 fixture가 실제로 준비되었다고 가정하지 않습니다. diff --git a/.maestro/offline/assert-exercise-record.yaml b/.maestro/offline/assert-exercise-record.yaml new file mode 100644 index 00000000..9078ab72 --- /dev/null +++ b/.maestro/offline/assert-exercise-record.yaml @@ -0,0 +1,33 @@ +appId: com.project200.undabang +--- +- runFlow: ../subflows/select-exercise-date.yaml +- scrollUntilVisible: + element: + id: exercise_complete_iv + text: "${'운동 기록이 있는 날짜: ' + EXERCISE_START_DATE.split('.').join('-')}" + direction: UP + timeout: 30000 +- assertVisible: + id: exercise_complete_iv + text: "${'운동 기록이 있는 날짜: ' + EXERCISE_START_DATE.split('.').join('-')}" +- scrollUntilVisible: + element: + id: exercise_title_tv + text: "${EXERCISE_TITLE}" + direction: DOWN + timeout: 30000 +- assertVisible: + id: exercise_list_cl + containsDescendants: + - id: exercise_title_tv + text: "${EXERCISE_TITLE}" + - id: exercise_time_tv + text: "${EXERCISE_START_HOUR + ':00 ~ ' + EXERCISE_END_HOUR + ':00'}" +- tapOn: + id: exercise_title_tv + text: "${EXERCISE_TITLE}" +- runFlow: + file: ../subflows/assert-exercise-detail.yaml + env: + RECORD_TITLE: "${EXERCISE_TITLE}" + START_MINUTE: "00" diff --git a/.maestro/offline/assert-timer-draft.yaml b/.maestro/offline/assert-timer-draft.yaml new file mode 100644 index 00000000..001c22c8 --- /dev/null +++ b/.maestro/offline/assert-timer-draft.yaml @@ -0,0 +1,13 @@ +appId: com.project200.undabang +--- +- assertVisible: + id: custom_timer_form +- assertVisible: + id: custom_timer_title + text: "${TIMER_DRAFT_TITLE}" +- assertVisible: + id: custom_timer_step_name_0 + text: offline-step +- assertVisible: + id: custom_timer_step_time_0 + text: "02:00" diff --git a/.maestro/offline/assert-timer-list.yaml b/.maestro/offline/assert-timer-list.yaml new file mode 100644 index 00000000..8f4d4f95 --- /dev/null +++ b/.maestro/offline/assert-timer-list.yaml @@ -0,0 +1,47 @@ +appId: com.project200.undabang +--- +- assertVisible: + id: custom_timer_list +- scrollUntilVisible: + element: + text: 심플 타이머 + direction: UP + timeout: 30000 +# 한 화면의 부재가 아니라 목록 끝의 추가 카드까지 겹치는 영역을 관찰합니다. +- repeat: + times: 200 + while: + notVisible: + id: custom_timer_add + commands: + - assertNotVisible: "${TIMER_DELETE_TITLE + '|' + TIMER_ORIGINAL_TITLE + '|' + TIMER_DRAFT_TITLE}" + - swipe: + start: "50%, 60%" + end: "50%, 40%" + duration: 2000 +- assertVisible: + id: custom_timer_add +- assertNotVisible: "${TIMER_DELETE_TITLE + '|' + TIMER_ORIGINAL_TITLE + '|' + TIMER_DRAFT_TITLE}" +- scrollUntilVisible: + element: + text: "${TIMER_CREATE_TITLE}" + direction: UP + timeout: 30000 +- assertVisible: "${TIMER_CREATE_TITLE}" +- scrollUntilVisible: + element: + text: 심플 타이머 + direction: UP + timeout: 30000 +- scrollUntilVisible: + element: + text: "${TIMER_UPDATED_TITLE}" + direction: DOWN + timeout: 30000 +- assertVisible: "${TIMER_UPDATED_TITLE}" +# 호출자가 모든 행을 UP 방향으로 찾을 수 있도록 끝으로 돌려놓습니다. +- scrollUntilVisible: + element: + id: custom_timer_add + direction: DOWN + timeout: 30000 diff --git a/.maestro/offline/create-timer.yaml b/.maestro/offline/create-timer.yaml new file mode 100644 index 00000000..d11751e0 --- /dev/null +++ b/.maestro/offline/create-timer.yaml @@ -0,0 +1,48 @@ +appId: com.project200.undabang +--- +- scrollUntilVisible: + element: + id: custom_timer_add + direction: DOWN + timeout: 30000 +- tapOn: + id: custom_timer_add +- extendedWaitUntil: + visible: + id: custom_timer_title + timeout: 30000 +- tapOn: + id: custom_timer_title +- inputText: "${NEW_TIMER_TITLE}" +- hideKeyboard +- tapOn: + id: custom_timer_new_step_name +- inputText: offline-step +- hideKeyboard +- runFlow: + file: set-timer-time.yaml + env: + TIMER_TIME_FIELD: custom_timer_new_step_time + TIMER_MINUTES: "00" + TIMER_SECONDS: "45" +- tapOn: + id: custom_timer_add_step +- assertVisible: + id: custom_timer_step_name_0 + text: offline-step +- assertVisible: + id: custom_timer_step_time_0 + text: "00:45" +- tapOn: + id: custom_timer_complete +- extendedWaitUntil: + visible: + id: custom_timer_detail + timeout: 30000 +- assertVisible: "${NEW_TIMER_TITLE}" +- assertVisible: offline-step +- back +- extendedWaitUntil: + visible: + id: custom_timer_list + timeout: 30000 diff --git a/.maestro/offline/edit-timer.yaml b/.maestro/offline/edit-timer.yaml new file mode 100644 index 00000000..5f8d97da --- /dev/null +++ b/.maestro/offline/edit-timer.yaml @@ -0,0 +1,26 @@ +appId: com.project200.undabang +--- +- scrollUntilVisible: + element: + text: "${EDIT_TIMER_TITLE}" + direction: UP + timeout: 30000 +- tapOn: "${EDIT_TIMER_TITLE}" +- extendedWaitUntil: + visible: + id: custom_timer_menu + timeout: 30000 +- tapOn: + id: custom_timer_menu +- tapOn: 수정하기 +- extendedWaitUntil: + visible: + id: custom_timer_title + text: "${EDIT_TIMER_TITLE}" + timeout: 30000 +- assertVisible: + id: custom_timer_step_name_0 + text: offline-step +- assertVisible: + id: custom_timer_step_time_0 + text: "${EXPECTED_TIMER_TIME}" diff --git a/.maestro/offline/offline-exercise-cached.yaml b/.maestro/offline/offline-exercise-cached.yaml new file mode 100644 index 00000000..086be94a --- /dev/null +++ b/.maestro/offline/offline-exercise-cached.yaml @@ -0,0 +1,9 @@ +appId: com.project200.undabang +name: 실제 단절 후 재실행한 캘린더 목록 상세 캐시 +--- +# 호스트가 두 라디오와 현재 기본망의 단절을 확인한 뒤 실행합니다. +- stopApp +- runFlow: ../subflows/launch-logged-in.yaml +- tapOn: + id: exercise_nav_graph +- runFlow: assert-exercise-record.yaml diff --git a/.maestro/offline/offline-exercise-create.yaml b/.maestro/offline/offline-exercise-create.yaml new file mode 100644 index 00000000..aae6ac19 --- /dev/null +++ b/.maestro/offline/offline-exercise-create.yaml @@ -0,0 +1,46 @@ +appId: com.project200.undabang +name: 오프라인 조회용 실제 운동 기록 생성 +--- +- runFlow: ../subflows/launch-logged-in.yaml +- tapOn: + id: exercise_nav_graph +- tapOn: + id: exercise_create_btn +- extendedWaitUntil: + visible: + id: exercise_title_input + timeout: 30000 +- tapOn: + id: exercise_title_input +- inputText: "${EXERCISE_TITLE}" +- hideKeyboard +- runFlow: + file: ../subflows/set-exercise-time.yaml + env: + TIME_BUTTON: exercise_start_time + TIME_HOUR: "${EXERCISE_START_HOUR}" + TIME_MINUTE: "00" +- runFlow: + file: ../subflows/set-exercise-time.yaml + env: + TIME_BUTTON: exercise_end_time + TIME_HOUR: "${EXERCISE_END_HOUR}" + TIME_MINUTE: "00" +- tapOn: + id: exercise_complete +- extendedWaitUntil: + visible: + id: ".*(confirm_btn|exercise_create_btn)" + timeout: 30000 +- runFlow: + when: + visible: + id: confirm_btn + commands: + - tapOn: + id: confirm_btn +- extendedWaitUntil: + visible: + id: exercise_create_btn + timeout: 30000 +- runFlow: assert-exercise-record.yaml diff --git a/.maestro/offline/offline-exercise-missing.yaml b/.maestro/offline/offline-exercise-missing.yaml new file mode 100644 index 00000000..77f7f518 --- /dev/null +++ b/.maestro/offline/offline-exercise-missing.yaml @@ -0,0 +1,21 @@ +appId: com.project200.undabang +name: 운동 캐시 없는 첫 조회는 네트워크 오류 +--- +# 별도 pm clear와 인증 뒤 운동 화면을 한 번도 열지 않고 실제 망을 끊습니다. +- runFlow: ../subflows/launch-logged-in.yaml +- tapOn: + id: exercise_nav_graph +- scrollUntilVisible: + element: + id: exercise_list_empty_tv + text: "${EXERCISE_END_DATE.split('.').join('-') + ' 운동 기록 목록 네트워크 오류'}" + direction: DOWN + timeout: 30000 +- assertVisible: + id: exercise_list_empty_tv + text: "${EXERCISE_END_DATE.split('.').join('-') + ' 운동 기록 목록 네트워크 오류'}" +- assertVisible: + id: exercise_create_btn +# 초기 빈 목록을 성공한 빈 캐시로 오인하지 않습니다. +- assertNotVisible: + text: "${EXERCISE_END_DATE.split('.').join('-') + '( 운동 기록 목록|에 운동 기록이 없습니다)'}" diff --git a/.maestro/offline/offline-timers-after-reconnect.yaml b/.maestro/offline/offline-timers-after-reconnect.yaml new file mode 100644 index 00000000..6193676e --- /dev/null +++ b/.maestro/offline/offline-timers-after-reconnect.yaml @@ -0,0 +1,22 @@ +appId: com.project200.undabang +name: 접속 복귀 후 폼과 다시 연 목록의 최종 값 +--- +# launchApp/stopApp 없이 직전 폼을 그대로 관찰합니다. +- runFlow: assert-timer-draft.yaml +- back +- back +- back +- scrollUntilVisible: + element: + id: timer_btn + direction: UP + timeout: 30000 +# 새 목록 화면을 엽니다. 성공한 원격 GET이나 pending 상태에서의 병합 순서를 증명하지는 않습니다. +- tapOn: + id: timer_btn +- extendedWaitUntil: + visible: + id: custom_timer_list + timeout: 30000 +- runFlow: assert-timer-list.yaml +- runFlow: prepare-timer-draft.yaml diff --git a/.maestro/offline/offline-timers-mutate.yaml b/.maestro/offline/offline-timers-mutate.yaml new file mode 100644 index 00000000..fe76b3d0 --- /dev/null +++ b/.maestro/offline/offline-timers-mutate.yaml @@ -0,0 +1,53 @@ +appId: com.project200.undabang +name: 실제 오프라인 커스텀 타이머 수정 삭제 생성 +--- +- runFlow: open-timer-list.yaml +- scrollUntilVisible: + element: + id: custom_timer_add + direction: DOWN + timeout: 30000 +- runFlow: + file: edit-timer.yaml + env: + EDIT_TIMER_TITLE: "${TIMER_ORIGINAL_TITLE}" + EXPECTED_TIMER_TIME: "00:45" +- tapOn: + id: custom_timer_title +- eraseText: 255 +- inputText: "${TIMER_UPDATED_TITLE}" +- hideKeyboard +- runFlow: + file: set-timer-time.yaml + env: + TIMER_TIME_FIELD: custom_timer_step_time_0 + TIMER_MINUTES: "01" + TIMER_SECONDS: "15" +- tapOn: + id: custom_timer_complete +- extendedWaitUntil: + visible: + id: custom_timer_detail + timeout: 30000 +- assertVisible: "${TIMER_UPDATED_TITLE}" +- back +- scrollUntilVisible: + element: + text: "${TIMER_DELETE_TITLE}" + direction: DOWN + timeout: 30000 +- tapOn: "${TIMER_DELETE_TITLE}" +- tapOn: + id: custom_timer_menu +- tapOn: 삭제하기 +- assertVisible: "정말 타이머를 삭제할까요[?]" +- tapOn: 확인 +- extendedWaitUntil: + visible: + id: custom_timer_list + timeout: 30000 +- runFlow: + file: create-timer.yaml + env: + NEW_TIMER_TITLE: "${TIMER_CREATE_TITLE}" +- runFlow: assert-timer-list.yaml diff --git a/.maestro/offline/offline-timers-online.yaml b/.maestro/offline/offline-timers-online.yaml new file mode 100644 index 00000000..9fbf5636 --- /dev/null +++ b/.maestro/offline/offline-timers-online.yaml @@ -0,0 +1,13 @@ +appId: com.project200.undabang +name: 동기화될 전용 커스텀 타이머 두 개를 UI로 생성 +--- +- runFlow: open-timer-list.yaml +- runFlow: + file: create-timer.yaml + env: + NEW_TIMER_TITLE: "${TIMER_ORIGINAL_TITLE}" +- runFlow: + file: create-timer.yaml + env: + NEW_TIMER_TITLE: "${TIMER_DELETE_TITLE}" +# 호스트는 실제 서버 GET에서 두 이름과 45초 스텝이 나타나야 다음 단계로 갑니다. diff --git a/.maestro/offline/offline-timers-reconnect.yaml b/.maestro/offline/offline-timers-reconnect.yaml new file mode 100644 index 00000000..61e6e63d --- /dev/null +++ b/.maestro/offline/offline-timers-reconnect.yaml @@ -0,0 +1,9 @@ +appId: com.project200.undabang +name: 실제 서버 업로드 완료 뒤에도 미저장 폼과 화면 값 유지 +--- +# 호스트의 독립 GET이 create/75초 update/delete/중복 부재를 확인한 뒤 실행합니다. +# 이 단계도 앱을 재실행하지 않아 서버 결과가 작성 중 폼을 대체하는 오류를 잡습니다. +- runFlow: assert-timer-draft.yaml +- back +- back +- runFlow: assert-timer-list.yaml diff --git a/.maestro/offline/offline-timers-relaunch.yaml b/.maestro/offline/offline-timers-relaunch.yaml new file mode 100644 index 00000000..4de31d5a --- /dev/null +++ b/.maestro/offline/offline-timers-relaunch.yaml @@ -0,0 +1,19 @@ +appId: com.project200.undabang +name: 오프라인 재실행 후 생성 수정 삭제 영속성과 작성 중 폼 +--- +- stopApp +- runFlow: open-timer-list.yaml +- runFlow: assert-timer-list.yaml +- runFlow: + file: edit-timer.yaml + env: + EDIT_TIMER_TITLE: "${TIMER_CREATE_TITLE}" + EXPECTED_TIMER_TIME: "00:45" +- back +- back +- scrollUntilVisible: + element: + id: custom_timer_add + direction: DOWN + timeout: 30000 +- runFlow: prepare-timer-draft.yaml diff --git a/.maestro/offline/open-timer-list.yaml b/.maestro/offline/open-timer-list.yaml new file mode 100644 index 00000000..52fb47eb --- /dev/null +++ b/.maestro/offline/open-timer-list.yaml @@ -0,0 +1,16 @@ +appId: com.project200.undabang +--- +- runFlow: ../subflows/launch-logged-in.yaml +- tapOn: + id: exercise_nav_graph +- scrollUntilVisible: + element: + id: timer_btn + direction: UP + timeout: 30000 +- tapOn: + id: timer_btn +- extendedWaitUntil: + visible: + id: custom_timer_list + timeout: 30000 diff --git a/.maestro/offline/prepare-timer-draft.yaml b/.maestro/offline/prepare-timer-draft.yaml new file mode 100644 index 00000000..88881dca --- /dev/null +++ b/.maestro/offline/prepare-timer-draft.yaml @@ -0,0 +1,20 @@ +appId: com.project200.undabang +--- +- runFlow: + file: edit-timer.yaml + env: + EDIT_TIMER_TITLE: "${TIMER_UPDATED_TITLE}" + EXPECTED_TIMER_TIME: "01:15" +- tapOn: + id: custom_timer_title +- eraseText: 255 +- inputText: "${TIMER_DRAFT_TITLE}" +- hideKeyboard +- runFlow: + file: set-timer-time.yaml + env: + TIMER_TIME_FIELD: custom_timer_step_time_0 + TIMER_MINUTES: "02" + TIMER_SECONDS: "00" +- runFlow: assert-timer-draft.yaml +# 완료는 누르지 않습니다. 연결 복귀와 실제 업로드 뒤에도 이 입력이 유지되어야 합니다. diff --git a/.maestro/offline/set-timer-time.yaml b/.maestro/offline/set-timer-time.yaml new file mode 100644 index 00000000..4fac67b4 --- /dev/null +++ b/.maestro/offline/set-timer-time.yaml @@ -0,0 +1,21 @@ +appId: com.project200.undabang +--- +- tapOn: + id: "${TIMER_TIME_FIELD}" +- extendedWaitUntil: + visible: + id: minute_et + timeout: 30000 +- tapOn: + id: minute_et +- eraseText: 2 +- inputText: "${TIMER_MINUTES}" +- tapOn: + id: second_et +- eraseText: 2 +- inputText: "${TIMER_SECONDS}" +- tapOn: + id: confirm_btn +- assertVisible: + id: "${TIMER_TIME_FIELD}" + text: "${TIMER_MINUTES + ':' + TIMER_SECONDS}" diff --git a/.maestro/scripts/run-tests.py b/.maestro/scripts/run-tests.py index eb76a46f..f5895ddc 100755 --- a/.maestro/scripts/run-tests.py +++ b/.maestro/scripts/run-tests.py @@ -32,6 +32,15 @@ "regression": ("exercise-form-entry", "exercise-crud"), "all": ("smoke-tabs", "exercise-form-entry", "exercise-crud", "login-screen"), "login": ("login-screen",), + "offline": ("offline-exercise-cache", "offline-exercise-miss", "offline-custom-timers"), +} +OFFLINE_PHASES = { + "offline-exercise-cache": ("offline-exercise-create", "offline-exercise-cached"), + "offline-exercise-miss": ("offline-exercise-missing",), + "offline-custom-timers": ( + "offline-timers-online", "offline-timers-mutate", + "offline-timers-relaunch", "offline-timers-after-reconnect", "offline-timers-reconnect", + ), } TOOL_ENV_KEYS = ( "PATH", "HOME", "USER", "LOGNAME", "JAVA_HOME", "ANDROID_HOME", @@ -256,7 +265,9 @@ def remove_private(self, include_session=False): self.require("shell", "-T", "run-as", APP_ID, "rm", "-f", *paths) def bootstrap(self, operation, credentials=None, timeout=300): - self.require("shell", "am", "force-stop", APP_ID) + # 서버 관찰은 실행 중 Worker와 폼을 강제 종료하거나 인증/Room을 초기화하지 않습니다. + if operation != "offline-observe": + self.require("shell", "am", "force-stop", APP_ID) self.remove_private(include_session=operation == "export") request = {"operation": operation, **(credentials or {})} payload = json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8") @@ -264,7 +275,10 @@ def bootstrap(self, operation, credentials=None, timeout=300): script = "umask 077; mkdir -p files && cat > " + REQUEST self.require("shell", "-T", "run-as", APP_ID, "sh", "-c", shlex.quote(script), payload=payload) self.require("shell", "am", "start", "-W", "-n", COMPONENT) - expected = {"prepare": "ready", "cleanup": "cleaned", "export": "exported"}[operation] + expected = { + "prepare": "ready", "cleanup": "cleaned", "export": "exported", + "offline-prepare": "ready", "offline-cleanup": "cleaned", "offline-observe": "observed", + }[operation] # 결과 파일이 아직 없을 때만 기다리고, 실제 읽기 실패의 종료 상태는 보존합니다. probe = "if [ ! -e " + RESULT + " ]; then exit 3; fi; cat " + RESULT deadline = time.monotonic() + timeout @@ -281,12 +295,132 @@ def bootstrap(self, operation, credentials=None, timeout=300): raise RunnerFailure("invalid-bootstrap-result") from None if not isinstance(result, dict) or result.get("status") != expected: # 앱의 reason도 외부 입력입니다. 로그/리포트에는 고정 오류만 남깁니다. + offline_failures = { + "offline_cache_flag_disabled": "offline-suite-requires-enabled-cache-flag", + "offline_upload_not_observed": "offline-suite-requires-real-server-upload", + "offline_exercise_server_mismatch": "offline-exercise-server-values-mismatch", + } + if operation.startswith("offline-") and isinstance(result, dict): + reason = result.get("reason") + if isinstance(reason, str) and reason in offline_failures: + raise RunnerFailure(offline_failures[reason]) raise RunnerFailure("bootstrap-" + operation + "-failed") self.remove_private() return raise RunnerFailure("bootstrap-" + operation + "-timeout") +class NetworkState: + """Change real device radios, and verify ConnectivityService's current default.""" + + KEYS = ("wifi_on", "mobile_data", "airplane_mode_on") + + def __init__(self, device): + self.device = device + self.original = self.settings() + self.original_connected = self.connected() + if not self.original_connected: + raise RunnerFailure("offline-suite-requires-initial-online-network") + + def settings(self): + values = {} + for key in self.KEYS: + value = self.device.require("shell", "settings", "get", "global", key).strip() + if value not in (b"0", b"1"): + raise RunnerFailure("unsupported-device-network-settings") + values[key] = value + return values + + def connected(self): + output = self.device.require("shell", "dumpsys", "connectivity").decode("utf-8", "replace") + # API30의 현재 기본망만 읽습니다. 이력의 VALIDATED나 설정값만으로 단절을 판정하지 않습니다. + values = re.findall(r"^\s*Active default network:\s*(none|[0-9]+)\s*$", output, re.MULTILINE) + if len(values) != 1: + raise RunnerFailure("unsupported-device-connectivity-report") + return values[0] != "none" + + def wait(self, expected, connected): + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + if self.settings() == expected and self.connected() == connected: + return + time.sleep(0.5) + raise RunnerFailure("device-network-transition-not-observed") + + def disconnect(self): + self.device.require("shell", "svc", "wifi", "disable") + self.device.require("shell", "svc", "data", "disable") + expected = {**self.original, "wifi_on": b"0", "mobile_data": b"0"} + self.wait(expected, False) + + def restore(self): + failed = False + for service, key in (("wifi", "wifi_on"), ("data", "mobile_data")): + try: + self.device.require("shell", "svc", service, "enable" if self.original[key] == b"1" else "disable") + except Exception: + failed = True + if failed: + raise RunnerFailure("device-network-restore-failed") + self.wait(self.original, self.original_connected) + + +def offline_variables(device): + variables = device.exercise_variables() + prefix = "maestro-offline-" + variables["EXERCISE_TITLE"].removeprefix("maestro-e2e-") + variables.update({ + "TIMER_CREATE_TITLE": prefix + "-create", + "TIMER_ORIGINAL_TITLE": prefix + "-update", + "TIMER_UPDATED_TITLE": prefix + "-updated", + "TIMER_DELETE_TITLE": prefix + "-delete", + "TIMER_DRAFT_TITLE": prefix + "-draft", + }) + return variables + + +def offline_request(credentials, variables, stage=None): + request = { + "memberId": credentials["memberId"], + "runId": variables["EXERCISE_TITLE"].removeprefix("maestro-e2e-"), + "startedAt": variables["EXERCISE_START_DATE"].replace(".", "-") + "T" + variables["EXERCISE_START_HOUR"] + ":00:00", + "endedAt": variables["EXERCISE_END_DATE"].replace(".", "-") + "T" + variables["EXERCISE_END_HOUR"] + ":00:00", + } + if stage is None: + request["authState"] = credentials["authState"] + else: + request["stage"] = stage + return request + + +def run_offline_scenario(device, scenario, network, credentials, variables, output, redactor, timeout): + def phase(name): + run_maestro(device, name, output, redactor, timeout, variables=variables) + + def observe(stage): + device.bootstrap("offline-observe", offline_request(credentials, variables, stage)) + + if scenario == "offline-exercise-cache": + phase("offline-exercise-create") + observe("exercise-online") + network.disconnect() + phase("offline-exercise-cached") + elif scenario == "offline-exercise-miss": + network.disconnect() + phase("offline-exercise-missing") + elif scenario == "offline-custom-timers": + phase("offline-timers-online") + observe("timers-online") + network.disconnect() + phase("offline-timers-mutate") + phase("offline-timers-relaunch") + network.restore() + phase("offline-timers-after-reconnect") + observe("timers-synced") + phase("offline-timers-reconnect") + else: + raise RunnerFailure("unknown-offline-scenario") + + def secure_export_path(value): path = Path(value).expanduser() if not path.is_absolute(): @@ -384,7 +518,7 @@ def maestro_result(report): raise RunnerFailure("maestro-assertion-failed") -def run_maestro(device, flow, output, redactor, timeout, fixture_start_date=None): +def run_maestro(device, flow, output, redactor, timeout, fixture_start_date=None, variables=None): maestro = shutil.which("maestro") if not maestro: raise RunnerFailure("maestro-unavailable") @@ -403,7 +537,7 @@ def run_maestro(device, flow, output, redactor, timeout, fixture_start_date=None code, stdout, stderr = command([maestro, "--version"], env) if code != 0 or MAESTRO_VERSION not in stdout.decode("utf-8", "replace").split(): raise RunnerFailure("maestro-version-must-be-2.11.0") - variables = device.exercise_variables() + variables = dict(variables) if variables is not None else device.exercise_variables() if flow == "exercise-crud" and variables["EXERCISE_START_DATE"].replace(".", "-") < fixture_start_date: raise RunnerFailure("fixture-start-date-excludes-flow-record-date") report = raw / "report.xml" @@ -411,7 +545,7 @@ def run_maestro(device, flow, output, redactor, timeout, fixture_start_date=None maestro, "test", "--device", device.serial, "--no-ansi", "--format", "junit", "--output", str(report), "--debug-output", str(raw / "debug"), "--test-output-dir", str(raw / "artifacts"), "--flatten-debug-output", - str(ROOT / ".maestro" / "flows" / (flow + ".yaml")), + str(ROOT / ".maestro" / ("offline" if flow.startswith("offline-") else "flows") / (flow + ".yaml")), ] for key, value in variables.items(): argv[2:2] = ["-e", key + "=" + value] @@ -450,7 +584,9 @@ def run_suite(args): if any(flow != "login-screen" for flow in selected): credentials = read_credentials() for flow in selected: - if not (ROOT / ".maestro" / "flows" / (flow + ".yaml")).is_file(): + phases = OFFLINE_PHASES.get(flow, (flow,)) + directory = "offline" if flow in OFFLINE_PHASES else "flows" + if any(not (ROOT / ".maestro" / directory / (phase + ".yaml")).is_file() for phase in phases): raise RunnerFailure("selected-flow-file-missing") device = Device(args.device) if args.configure_emulator: @@ -465,12 +601,23 @@ def run_suite(args): started = time.monotonic() errors = [] interrupted = False + network = None + variables = None try: - device.clear() - if flow != "login-screen": - device.bootstrap("prepare", credentials) - run_maestro(device, flow, output, redactor, args.flow_timeout, - credentials["fixtureStartDate"] if credentials else None) + if flow in OFFLINE_PHASES: + network = NetworkState(device) + variables = offline_variables(device) + device.clear() + device.bootstrap("offline-prepare", offline_request(credentials, variables)) + run_offline_scenario( + device, flow, network, credentials, variables, output, redactor, args.flow_timeout, + ) + else: + device.clear() + if flow != "login-screen": + device.bootstrap("prepare", credentials) + run_maestro(device, flow, output, redactor, args.flow_timeout, + credentials["fixtureStartDate"] if credentials else None) except RunInterrupted: errors.append("run-interrupted") interrupted = True @@ -482,7 +629,20 @@ def run_suite(args): # 중단 신호가 정리 중 다시 들어와도 삭제/로컬 비밀 제거를 중간에 끊지 않습니다. handlers = {sig: signal.signal(sig, signal.SIG_IGN) for sig in (signal.SIGINT, signal.SIGTERM)} try: - if flow != "login-screen": + restored = True + if network is not None: + try: + network.restore() + except Exception: + restored = False + errors.append("device-network-restore-failed") + if flow in OFFLINE_PHASES: + if restored and variables is not None: + try: + device.bootstrap("offline-cleanup", offline_request(credentials, variables)) + except Exception: + errors.append("fixture-cleanup-failed") + elif flow != "login-screen": try: device.bootstrap("cleanup", credentials) except Exception: diff --git a/.maestro/scripts/test_run_tests.py b/.maestro/scripts/test_run_tests.py index 07cb225a..195cdfad 100755 --- a/.maestro/scripts/test_run_tests.py +++ b/.maestro/scripts/test_run_tests.py @@ -237,6 +237,31 @@ def test_cleanup_waits_for_cleaned_status(self): self.assertFalse((self.transport.root / runner.RESULT).exists()) self.assert_no_credential_arguments() + def test_offline_observation_keeps_running_process_and_sends_no_auth_state(self): + self.transport.result = b'{"status":"observed"}' + request = { + "memberId": CREDENTIALS["memberId"], "runId": "0123456789abcdef0123456789abcdef", + "startedAt": "2026-10-15T09:00:00", "endedAt": "2026-10-15T10:00:00", + "stage": "timers-synced", + } + self.device.bootstrap("offline-observe", request) + self.assertEqual(json.loads(self.transport.request_bytes), {"operation": "offline-observe", **request}) + self.assertFalse(any("force-stop" in argv for argv, _env in self.transport.calls)) + self.assertNotIn("authState", json.loads(self.transport.request_bytes)) + self.assertEqual(self.transport.request_mode, 0o600) + self.assert_no_credential_arguments() + + def test_offline_prerequisite_failures_use_only_allowlisted_messages(self): + self.transport.pending_sleeps = 0 + for reason, expected in ( + ("offline_cache_flag_disabled", "offline-suite-requires-enabled-cache-flag"), + ("offline_upload_not_observed", "offline-suite-requires-real-server-upload"), + (SYNTHETIC_SECRET, "bootstrap-offline-observe-failed"), + ): + with self.subTest(reason=reason): + self.transport.result = json.dumps({"status": "failed", "reason": reason}).encode() + self.assert_failure(expected, lambda: self.device.bootstrap("offline-observe", {"stage": "timers-synced"})) + def test_empty_and_malformed_results_fail_without_polling_again(self): self.transport.pending_sleeps = 0 for payload in (b"", b" \n\t", b'{"status":', b"not-json"): @@ -392,5 +417,277 @@ def test_remove_private_preserves_session_until_explicit_cleanup(self): self.assertEqual(unrelated.read_bytes(), b"keep") +class NetworkClock: + def __init__(self): + self.now = 0 + + def monotonic(self): + return self.now + + def sleep(self, seconds): + self.now += seconds + + +class RadioDevice: + def __init__(self): + self.values = {"wifi_on": b"0", "mobile_data": b"1", "airplane_mode_on": b"0"} + self.calls = [] + self.external_network = False + self.failed_radio = None + self.report = None + + def require(self, *args): + self.calls.append(args) + if args[:4] == ("shell", "settings", "get", "global"): + return self.values[args[4]] + b"\n" + if args == ("shell", "dumpsys", "connectivity"): + if self.report is not None: + return self.report + connected = self.external_network or any(self.values[key] == b"1" for key in ("wifi_on", "mobile_data")) + active = b"100" if connected else b"none" + return b"Active default network: " + active + b"\nHistory: VALIDATED network 99\n" + if args[:2] == ("shell", "svc"): + if args[2] == self.failed_radio: + raise runner.RunnerFailure("adb-operation-failed") + key = {"wifi": "wifi_on", "data": "mobile_data"}[args[2]] + self.values[key] = b"1" if args[3] == "enable" else b"0" + return b"" + raise AssertionError("Unexpected radio command") + + +class OfflineNetworkTests(unittest.TestCase): + def setUp(self): + self.device = RadioDevice() + self.clock = NetworkClock() + patch = mock.patch.object(runner, "time", self.clock) + patch.start() + self.addCleanup(patch.stop) + + def test_disconnect_checks_current_network_and_restores_original_radios(self): + original = dict(self.device.values) + network = runner.NetworkState(self.device) + network.disconnect() + self.assertFalse(network.connected()) + self.assertEqual(self.device.values["mobile_data"], b"0") + network.restore() + self.assertEqual(self.device.values, original) + self.assertTrue(network.connected()) + self.assertNotIn(("shell", "svc", "wifi", "enable"), self.device.calls) + + def test_ethernet_or_other_active_network_prevents_false_offline_success(self): + network = runner.NetworkState(self.device) + self.device.external_network = True + with self.assertRaisesRegex(runner.RunnerFailure, "^device-network-transition-not-observed$"): + network.disconnect() + self.assertEqual(self.clock.now, 30) + network.restore() + self.assertEqual(self.device.values["mobile_data"], b"1") + + def test_restore_attempts_both_radios_when_one_command_fails(self): + network = runner.NetworkState(self.device) + network.disconnect() + self.device.failed_radio = "wifi" + with self.assertRaisesRegex(runner.RunnerFailure, "^device-network-restore-failed$"): + network.restore() + self.assertEqual(self.device.values["mobile_data"], b"1") + self.assertIn(("shell", "svc", "data", "enable"), self.device.calls) + + def test_unknown_settings_and_reports_fail_before_radio_changes(self): + for report in (b"", b"History: Active default network: 100\n", b"Active default network: none\nActive default network: 100\n"): + with self.subTest(report=report): + self.device.report = report + with self.assertRaisesRegex(runner.RunnerFailure, "^unsupported-device-connectivity-report$"): + runner.NetworkState(self.device) + self.device.values["mobile_data"] = b"null" + with self.assertRaisesRegex(runner.RunnerFailure, "^unsupported-device-network-settings$"): + runner.NetworkState(self.device) + self.assertFalse(any(args[:2] == ("shell", "svc") for args in self.device.calls)) + + def test_initially_offline_device_is_not_silently_reconfigured(self): + self.device.values["mobile_data"] = b"0" + with self.assertRaisesRegex(runner.RunnerFailure, "^offline-suite-requires-initial-online-network$"): + runner.NetworkState(self.device) + self.assertFalse(any(args[:2] == ("shell", "svc") for args in self.device.calls)) + + +class OfflineSuiteTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="maestro-offline-unit-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + for phase in runner.OFFLINE_PHASES["offline-custom-timers"]: + path = self.root / ".maestro" / "offline" / (phase + ".yaml") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("synthetic unit-test flow", encoding="utf-8") + self.events = [] + self.fail_phase = None + self.fail_observe = False + self.fail_restore = False + self.fail_cleanup = False + self.interrupted = False + self.device = mock.Mock() + self.device.clear.side_effect = lambda: self.events.append("clear") + self.device.bootstrap.side_effect = self.bootstrap + self.device.exercise_variables.return_value = { + "EXERCISE_TITLE": "maestro-e2e-0123456789abcdef0123456789abcdef", + "EDITED_EXERCISE_TITLE": "maestro-e2e-0123456789abcdef0123456789abcdef-edited", + "EXERCISE_START_DATE": "2026.10.15", "EXERCISE_END_DATE": "2026.10.15", + "EXERCISE_START_HOUR": "09", "EXERCISE_END_HOUR": "10", + } + self.network = mock.Mock() + self.network.disconnect.side_effect = lambda: self.events.append("disconnect") + self.network.restore.side_effect = self.restore + patches = ( + mock.patch.object(runner, "ROOT", self.root), + mock.patch.dict(runner.FLOWS, {"offline": ("offline-custom-timers",)}), + mock.patch.object(runner, "Device", return_value=self.device), + mock.patch.object(runner, "NetworkState", return_value=self.network), + mock.patch.object(runner, "read_credentials", return_value=CREDENTIALS), + mock.patch.object(runner, "run_maestro", side_effect=self.phase), + mock.patch.object(runner, "signal", SimpleNamespace( + SIGINT=signal.SIGINT, SIGTERM=signal.SIGTERM, SIG_IGN=signal.SIG_IGN, + signal=mock.Mock(return_value=signal.SIG_DFL), + )), + ) + for patch in patches: + patch.start() + self.addCleanup(patch.stop) + + def bootstrap(self, operation, request): + stage = request.get("stage") + self.events.append(operation + (":" + stage if stage else "")) + self.assertEqual(request["memberId"], CREDENTIALS["memberId"]) + self.assertEqual(request["runId"], "0123456789abcdef0123456789abcdef") + if operation == "offline-observe": + self.assertNotIn("authState", request) + if self.fail_observe and stage == "timers-synced": + raise runner.RunnerFailure("offline-suite-requires-real-server-upload") + else: + self.assertEqual(request["authState"], CREDENTIALS["authState"]) + if operation == "offline-cleanup" and self.fail_cleanup: + raise runner.RunnerFailure("bootstrap-offline-cleanup-failed") + + def restore(self): + self.events.append("restore") + if self.fail_restore: + raise runner.RunnerFailure("device-network-restore-failed") + + def phase(self, _device, name, _output, _redactor, _timeout, **kwargs): + self.events.append(name) + self.assertEqual(kwargs["variables"]["TIMER_CREATE_TITLE"], "maestro-offline-0123456789abcdef0123456789abcdef-create") + self.assertNotIn("authState", kwargs["variables"]) + self.assertNotIn("memberId", kwargs["variables"]) + if name == self.fail_phase: + failure = runner.RunInterrupted if self.interrupted else runner.RunnerFailure + raise failure("run-interrupted" if self.interrupted else "maestro-flow-failed") + + def run_suite(self): + args = SimpleNamespace(suite="offline", device="synthetic", configure_emulator=False, apk=None, flow_timeout=42) + output = io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): + result = runner.run_suite(args) + self.assertNotIn(SYNTHETIC_SECRET, output.getvalue()) + reports = list((self.root / "build" / "maestro").glob("*/report.xml")) + self.assertEqual(len(reports), 1) + report = runner.ET.parse(reports[0]).getroot() + self.assertEqual(report.get("skipped"), "0") + return result, report + + def test_success_requires_offline_ui_reconnect_outcome_upload_and_preserved_form(self): + result, report = self.run_suite() + self.assertEqual(result, 0) + self.assertEqual(report.get("failures"), "0") + self.assertEqual(self.events, [ + "clear", "offline-prepare", "offline-timers-online", "offline-observe:timers-online", + "disconnect", "offline-timers-mutate", "offline-timers-relaunch", "restore", + "offline-timers-after-reconnect", "offline-observe:timers-synced", "offline-timers-reconnect", + "restore", "offline-cleanup", "clear", + ]) + + def test_ui_failure_and_interruption_restore_before_scoped_cleanup(self): + self.fail_phase = "offline-timers-relaunch" + for interrupted in (False, True): + with self.subTest(interrupted=interrupted): + self.interrupted = interrupted + self.events.clear() + # 실행마다 별도 디렉터리를 써서 보고서 수가 타임스탬프에 의존하지 않게 합니다. + with tempfile.TemporaryDirectory(dir=self.root) as directory: + build = Path(directory) + with mock.patch.object(runner, "ROOT", build), mock.patch.object(Path, "is_file", return_value=True): + args = SimpleNamespace(suite="offline", device="synthetic", configure_emulator=False, apk=None, flow_timeout=42) + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(runner.run_suite(args), 1) + self.assertEqual(self.events[-3:], ["restore", "offline-cleanup", "clear"]) + self.assertNotIn("offline-timers-after-reconnect", self.events) + + def test_missing_real_upload_fails_without_skip_and_still_restores(self): + self.fail_observe = True + result, report = self.run_suite() + self.assertEqual(result, 1) + self.assertIn("offline-suite-requires-real-server-upload", report.find("testcase/failure").get("message")) + self.assertNotIn("offline-timers-reconnect", self.events) + self.assertEqual(self.events[-3:], ["restore", "offline-cleanup", "clear"]) + + def test_prepare_failure_restores_without_running_any_ui_phase(self): + original_bootstrap = self.bootstrap + + def fail_prepare(operation, request): + if operation == "offline-prepare": + self.events.append(operation) + raise runner.RunnerFailure("offline-suite-requires-enabled-cache-flag") + original_bootstrap(operation, request) + + self.device.bootstrap.side_effect = fail_prepare + result, report = self.run_suite() + self.assertEqual(result, 1) + self.assertIn("offline-suite-requires-enabled-cache-flag", report.find("testcase/failure").get("message")) + self.assertEqual(self.events, ["clear", "offline-prepare", "restore", "offline-cleanup", "clear"]) + + def test_failed_restore_prevents_server_cleanup_and_fails_the_report(self): + self.fail_phase = "offline-timers-mutate" + self.fail_restore = True + result, report = self.run_suite() + self.assertEqual(result, 1) + self.assertIn("device-network-restore-failed", report.find("testcase/failure").get("message")) + self.assertNotIn("offline-cleanup", self.events) + self.assertEqual(self.events[-1], "clear") + + def test_cleanup_failure_cannot_be_reported_as_a_pass(self): + self.fail_cleanup = True + result, report = self.run_suite() + self.assertEqual(result, 1) + self.assertIn("fixture-cleanup-failed", report.find("testcase/failure").get("message")) + self.assertEqual(self.events[-3:], ["restore", "offline-cleanup", "clear"]) + + def test_offline_remains_explicit_and_outside_every_default_suite(self): + for suite in ("smoke", "regression", "all", "login"): + self.assertFalse(any(flow.startswith("offline-") for flow in runner.FLOWS[suite])) + + +class OfflineExerciseOrderTests(unittest.TestCase): + def test_cache_hit_and_miss_use_distinct_preconditions_and_relaunch_phases(self): + variables = { + "EXERCISE_TITLE": "maestro-e2e-0123456789abcdef0123456789abcdef", + "EXERCISE_START_DATE": "2026.10.15", "EXERCISE_END_DATE": "2026.10.15", + "EXERCISE_START_HOUR": "09", "EXERCISE_END_HOUR": "10", + } + for scenario, expected in ( + ("offline-exercise-cache", ["offline-exercise-create", "exercise-online", "disconnect", "offline-exercise-cached"]), + ("offline-exercise-miss", ["disconnect", "offline-exercise-missing"]), + ): + with self.subTest(scenario=scenario): + events = [] + device, network = mock.Mock(), mock.Mock() + device.bootstrap.side_effect = lambda _operation, request: events.append(request["stage"]) + network.disconnect.side_effect = lambda: events.append("disconnect") + with mock.patch.object(runner, "run_maestro", side_effect=lambda _device, phase, *_args, **_kwargs: events.append(phase)): + runner.run_offline_scenario( + device, scenario, network, CREDENTIALS, variables, Path("/synthetic-output"), runner.Redactor(CREDENTIALS), 42, + ) + self.assertEqual(events, expected) + device.clear.assert_not_called() + network.restore.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/README.md b/README.md index 0f3a3fcb..8821d8dc 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ 구현 사례별 소스와 테스트는 위 링크에서 확인할 수 있습니다. - **로직·저장소**: UseCase·ViewModel 단위 테스트, Robolectric 기반 Room 테스트를 두고 있습니다. JaCoCo 집계 대상은 UseCase·ViewModel입니다. -- **사용자 흐름**: Maestro 플로우로 로그인 화면, 하단 탭과 콘텐츠, 운동 기록 작성 화면 진입을 검사하도록 구성했습니다. 기기 준비와 실행 조건은 [Maestro 가이드](.maestro/README.md)에 있습니다. +- **사용자 흐름**: Maestro로 로그인 화면, 탭/콘텐츠와 운동 기록 CRUD를 검사합니다. 명시 선택 `offline` suite는 운동 캐시·커스텀 타이머 오프라인 변경과 독립 서버 GET을 검사하며, #584/#588/#589·서버 #574/#575 배포와 활성화된 캐시 플래그가 필요합니다. 실행 조건은 [Maestro 가이드](.maestro/README.md)에 있습니다. - **CI/CD 구성**: `dev` push·PR은 debug 검증과 Firebase App Distribution 배포, `master` push·PR은 release 검증을 수행하도록 설정되어 있습니다. Google Play 내부 테스트 배포는 `master` push에서만 실행합니다. [dev 워크플로우](.github/workflows/android-dev-ci.yml) · [master 워크플로우](.github/workflows/android-master-ci-cd.yml) @@ -202,7 +202,8 @@ ### 현재 지원 범위 - 타이머의 로컬 저장과 달리, 현재 [운동 기록 Repository](data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt)의 목록·상세·횟수 조회는 서버를 호출합니다. Room 저장 계층이 있다는 이유만으로 앱 전체의 오프라인 지원을 의미하지는 않습니다. -- CI 단위 테스트 명령에는 Kotlin/JVM 모듈인 `:domain:test`가 포함되어 있지 않습니다. 로컬 전체 단위 테스트 안내에는 이를 별도로 포함했습니다. Maestro도 현재 CI에서 실행하지 않습니다. +- CI 단위 테스트 명령에는 Kotlin/JVM 모듈인 `:domain:test`가 포함되어 있지 않습니다. 로컬 전체 단위 테스트 안내에는 이를 별도로 포함했습니다. 별도 DEV Maestro CI는 smoke를 자동 실행하며, 의존 기능이 필요한 `offline`은 수동 선택에서만 실행합니다. +- `offline`의 재접속 시나리오는 최종 UI·서버 값을 검사합니다. pending 상태에서 성공한 원격 목록을 적용하는 순서의 검증은 실제 #588/#589 uploader 관찰/제어 계약이 없어 **BLOCKED**이며, suite 통과만으로 #617 전체 완료를 뜻하지 않습니다. - `dev` 워크플로우에는 배포를 push로 제한하는 조건이 없어 PR에도 배포 단계가 있습니다. 배포 구성은 공개 출시 여부나 실제 배포 성공을 뜻하지 않습니다. ## 실행 방법 diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroOfflineFixtures.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroOfflineFixtures.kt new file mode 100644 index 00000000..54751916 --- /dev/null +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroOfflineFixtures.kt @@ -0,0 +1,168 @@ +package com.project200.undabang.e2e + +import com.project200.data.api.ApiService +import com.project200.data.datasource.FeatureFlagDataSource +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.GetCustomTimerDetailDTO +import com.project200.data.dto.GetExerciseRecordListDto +import com.project200.domain.repository.AppUpdateRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull +import java.time.LocalDate +import java.time.LocalDateTime +import javax.inject.Inject + +internal data class MaestroOfflineRun( + val id: String, + val startedAt: LocalDateTime, + val endedAt: LocalDateTime, +) { + init { + if (!ID_PATTERN.matches(id) || endedAt != startedAt.plusHours(1) || + startedAt.minute != 0 || startedAt.second != 0 || startedAt.nano != 0 + ) { + throw MaestroSetupFailure("invalid_request") + } + } + + val exerciseTitle: String = "maestro-e2e-$id" + val createdTimer: String = "maestro-offline-$id-create" + val originalTimer: String = "maestro-offline-$id-update" + val updatedTimer: String = "maestro-offline-$id-updated" + val deletedTimer: String = "maestro-offline-$id-delete" + val draftTimer: String = "maestro-offline-$id-draft" + val timerNames: Set = setOf(createdTimer, originalTimer, updatedTimer, deletedTimer, draftTimer) + + companion object { + private val ID_PATTERN = Regex("[a-f0-9]{32}") + } +} + +/** 생성·수정은 UI와 실제 동기화만 수행합니다. 이 클래스는 직접 GET과 실행별 정리만 수행합니다 */ +internal class MaestroOfflineFixtures + @Inject + constructor( + private val api: ApiService, + private val featureFlags: FeatureFlagDataSource, + private val appUpdateRepository: AppUpdateRepository, + ) { + suspend fun requireCacheEnabled() { + // 기본값 false인 플래그를 가정하지 않고 실제 fetch/activate 경로에서 확인합니다. + appUpdateRepository.getUpdateInfo().getOrElse { throw MaestroSetupFailure("offline_flag_fetch_failed") } + if (!featureFlags.isOfflineCacheEnabled()) throw MaestroSetupFailure("offline_cache_flag_disabled") + } + + fun validateDate(run: MaestroOfflineRun) { + if (run.endedAt.toLocalDate() > LocalDate.now()) throw MaestroSetupFailure("fixture_range_invalid") + } + + suspend fun observe( + run: MaestroOfflineRun, + stage: String, + ) { + if (stage == "exercise-online") { + val records = exerciseRecords(run) + if (records.size != 1) throw MaestroSetupFailure("offline_exercise_server_mismatch") + val detail = read(api.getExerciseRecordDetail(records.single().exerciseId)) + if (detail.exerciseTitle != run.exerciseTitle || detail.exerciseStartedAt != run.startedAt || + detail.exerciseEndedAt != run.endedAt + ) { + throw MaestroSetupFailure("offline_exercise_server_mismatch") + } + return + } + val expected = + when (stage) { + "timers-online" -> mapOf(run.originalTimer to 45, run.deletedTimer to 45) + "timers-synced" -> mapOf(run.createdTimer to 45, run.updatedTimer to 75) + else -> throw MaestroSetupFailure("invalid_request") + } + val observed = + withTimeoutOrNull(UPLOAD_TIMEOUT_MS) { + var matches = timersMatch(run, expected) + while (!matches) { + delay(2000) + matches = timersMatch(run, expected) + } + true + } + if (observed != true) throw MaestroSetupFailure("offline_upload_not_observed") + } + + private suspend fun timersMatch( + run: MaestroOfflineRun, + expected: Map, + ): Boolean { + val summaries = timerSummaries() + val owned = summaries.filter { it.customTimerName in run.timerNames } + if (owned.size != expected.size || owned.map { it.customTimerName }.toSet() != expected.keys) return false + for (summary in owned) { + val detail = read(api.getCustomTimer(summary.customTimerId)) + if (!matchesTimer(detail, summary.customTimerId, summary.customTimerName, expected.getValue(summary.customTimerName))) { + return false + } + } + return true + } + + suspend fun clean(run: MaestroOfflineRun) { + // 호출자가 DEV 세션과 전용 member ID를 검증한 뒤에만 실행합니다. + val exercises = exerciseRecords(run) + val timers = timerSummaries().filter { it.customTimerName in run.timerNames } + // 양쪽 목록 GET이 성공한 뒤 UUID와 상세를 재확인한 행만 삭제합니다. + for (record in exercises) { + val detail = read(api.getExerciseRecordDetail(record.exerciseId)) + if (detail.exerciseTitle != run.exerciseTitle) continue + if (detail.exerciseStartedAt.toLocalDate() != run.startedAt.toLocalDate()) { + throw MaestroSetupFailure("fixture_read_failed") + } + if (!api.deleteExerciseRecord(record.exerciseId).succeed) throw MaestroSetupFailure("fixture_delete_failed") + } + for (timer in timers) { + val detail = read(api.getCustomTimer(timer.customTimerId)) + if (detail.customTimerId != timer.customTimerId) throw MaestroSetupFailure("fixture_read_failed") + if (detail.customTimerName !in run.timerNames) continue + if (!api.deleteCustomTimer(timer.customTimerId).succeed) throw MaestroSetupFailure("fixture_delete_failed") + } + } + + private suspend fun exerciseRecords(run: MaestroOfflineRun): List { + val records = read(api.getExerciseList(run.startedAt.toLocalDate())) + if (records.any { it.exerciseId <= 0 || it.exerciseStartedAt.toLocalDate() != run.startedAt.toLocalDate() } || + records.map { it.exerciseId }.toSet().size != records.size + ) { + throw MaestroSetupFailure("fixture_read_failed") + } + return records.filter { it.exerciseTitle == run.exerciseTitle } + } + + private suspend fun timerSummaries() = + read(api.getCustomTimerList()).also { list -> + if (list.customTimerCount != list.customTimers.size || list.customTimers.any { it.customTimerId <= 0 } || + list.customTimers.map { it.customTimerId }.toSet().size != list.customTimers.size + ) { + throw MaestroSetupFailure("fixture_read_failed") + } + }.customTimers + + private fun read(response: BaseResponse): T { + if (!response.succeed) throw MaestroSetupFailure("fixture_read_failed") + return response.data ?: throw MaestroSetupFailure("fixture_read_failed") + } + + companion object { + val STAGES = setOf("exercise-online", "timers-online", "timers-synced") + private const val UPLOAD_TIMEOUT_MS = 120_000L + + internal fun matchesTimer( + detail: GetCustomTimerDetailDTO, + id: Long, + name: String, + seconds: Int, + ): Boolean = + detail.customTimerId == id && detail.customTimerName == name && detail.customTimerStepCount == 1 && + detail.customTimerSteps.singleOrNull()?.let { + it.customTimerStepName == "offline-step" && it.customTimerStepOrder == 0 && it.customTimerStepTime == seconds + } == true + } + } diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroSessionBootstrap.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroSessionBootstrap.kt index a17117b8..e6ef7425 100644 --- a/app/src/e2e/java/com/project200/undabang/e2e/MaestroSessionBootstrap.kt +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroSessionBootstrap.kt @@ -52,6 +52,17 @@ internal class MaestroSessionBootstrap authStateManager.update(authorizationResponse, null) authStateManager.update(restoredTokens, null) + return refreshAndVerify(expectedMemberId) + } + + /** 서버 관찰은 현재 세션을 검증하되 Room과 미전송 변경을 지우지 않습니다 */ + suspend fun verifyCurrent(expectedMemberId: String): String { + val trusted = fetchTrustedConfiguration() + validateState(currentState(), trusted) + return refreshAndVerify(expectedMemberId) + } + + private suspend fun refreshAndVerify(expectedMemberId: String?): String { // 만료 시각이나 가져온 ID 토큰을 믿지 않고 실제 DEV 서버에서 새 토큰을 발급받습니다 if (authManager.refreshAccessToken() !is TokenRefreshResult.Success) { throw MaestroSetupFailure("refresh_failed") diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupCoordinator.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupCoordinator.kt index 2f472e3e..1742d536 100644 --- a/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupCoordinator.kt +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupCoordinator.kt @@ -16,6 +16,7 @@ internal class MaestroSetupCoordinator private val protocol: MaestroSetupProtocol, private val sessionBootstrap: MaestroSessionBootstrap, private val fixtureCleaner: MaestroFixtureCleaner, + private val offlineFixtures: MaestroOfflineFixtures, ) { private val requestMutex = Mutex() @@ -40,6 +41,27 @@ internal class MaestroSetupCoordinator if (request.operation == "cleanup") sessionBootstrap.clearSession() protocol.writeStatus(if (request.operation == "prepare") "ready" else "cleaned") } + is MaestroSetupRequest.OfflineOperation -> { + offlineFixtures.validateDate(request.run) + if (request.operation == "offline-observe") { + sessionBootstrap.verifyCurrent(request.memberId) + offlineFixtures.observe(request.run, request.stage ?: throw MaestroSetupFailure("invalid_request")) + protocol.writeStatus("observed") + } else { + sessionBootstrap.authenticate( + request.authState ?: throw MaestroSetupFailure("invalid_request"), + request.memberId, + ) + offlineFixtures.clean(request.run) + if (request.operation == "offline-prepare") { + offlineFixtures.requireCacheEnabled() + protocol.writeStatus("ready") + } else { + sessionBootstrap.clearSession() + protocol.writeStatus("cleaned") + } + } + } } } catch (failure: MaestroSetupFailure) { failClosed(failure.reason) diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupProtocol.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupProtocol.kt index b082eb67..0da9d4a7 100644 --- a/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupProtocol.kt +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupProtocol.kt @@ -8,6 +8,7 @@ import net.openid.appauth.AuthState import org.json.JSONObject import java.io.File import java.time.LocalDate +import java.time.LocalDateTime import javax.inject.Inject internal sealed interface MaestroSetupRequest { @@ -19,6 +20,14 @@ internal sealed interface MaestroSetupRequest { val memberId: String, val fixtureStartDate: LocalDate, ) : MaestroSetupRequest + + data class OfflineOperation( + val operation: String, + val memberId: String, + val authState: AuthState?, + val run: MaestroOfflineRun, + val stage: String?, + ) : MaestroSetupRequest } internal class MaestroSetupFailure(val reason: String) : Exception(reason) @@ -64,6 +73,28 @@ internal class MaestroSetupProtocol fixtureStartDate = LocalDate.parse(startDate), ) } + "offline-prepare", "offline-cleanup", "offline-observe" -> { + val keys = setOf("operation", "memberId", "runId", "startedAt", "endedAt") + val observing = operation == "offline-observe" + requireKeys(json, keys + if (observing) setOf("stage") else setOf("authState")) + val memberId = json.get("memberId") as? String + if (memberId.isNullOrBlank()) throw MaestroSetupFailure("invalid_request") + val run = + MaestroOfflineRun( + id = json.getString("runId"), + startedAt = LocalDateTime.parse(json.getString("startedAt")), + endedAt = LocalDateTime.parse(json.getString("endedAt")), + ) + val stage = if (observing) json.getString("stage") else null + if (observing && stage !in MaestroOfflineFixtures.STAGES) throw MaestroSetupFailure("invalid_request") + MaestroSetupRequest.OfflineOperation( + operation = operation, + memberId = memberId, + authState = if (observing) null else AuthState.jsonDeserialize(json.getJSONObject("authState")), + run = run, + stage = stage, + ) + } else -> throw MaestroSetupFailure("invalid_request") } } catch (failure: MaestroSetupFailure) { diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineFixturesTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineFixturesTest.kt new file mode 100644 index 00000000..e2603b1a --- /dev/null +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineFixturesTest.kt @@ -0,0 +1,211 @@ +package com.project200.undabang.e2e + +import com.google.common.truth.Truth.assertThat +import com.project200.data.api.ApiService +import com.project200.data.datasource.FeatureFlagDataSource +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.CustomTimerDetailStepDTO +import com.project200.data.dto.CustomTimerSummaryDTO +import com.project200.data.dto.GetCustomTimerDetailDTO +import com.project200.data.dto.GetCustomTimerListDTO +import com.project200.data.dto.GetExerciseRecordData +import com.project200.data.dto.GetExerciseRecordListDto +import com.project200.domain.model.UpdateInfo +import com.project200.domain.repository.AppUpdateRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.time.LocalDateTime + +class MaestroOfflineFixturesTest { + private val api = mockk() + private val flags = mockk() + private val updates = mockk() + private val fixtures = MaestroOfflineFixtures(api, flags, updates) + private val run = + MaestroOfflineRun( + "0123456789abcdef0123456789abcdef", + LocalDateTime.of(2026, 10, 15, 9, 0), + LocalDateTime.of(2026, 10, 15, 10, 0), + ) + + @Test + fun `실제 활성화된 캐시 플래그가 꺼져 있으면 준비를 거절한다`() = + runTest { + coEvery { updates.getUpdateInfo() } returns Result.success(mockk()) + every { flags.isOfflineCacheEnabled() } returns false + + expectFailure("offline_cache_flag_disabled") { fixtures.requireCacheEnabled() } + + coVerify(exactly = 1) { updates.getUpdateInfo() } + coVerify(exactly = 0) { api.getExerciseList(any()) } + } + + @Test + fun `서버 목록과 상세의 정확한 이름 시간 스텝을 직접 확인한다`() = + runTest { + coEvery { api.getCustomTimerList() } returns timerList(run.createdTimer, run.updatedTimer) + coEvery { api.getCustomTimer(1) } returns success(timer(1, run.createdTimer, 45)) + coEvery { api.getCustomTimer(2) } returns success(timer(2, run.updatedTimer, 75)) + + fixtures.observe(run, "timers-synced") + + coVerify(exactly = 1) { api.getCustomTimerList() } + coVerify(exactly = 1) { api.getCustomTimer(1) } + coVerify(exactly = 1) { api.getCustomTimer(2) } + coVerify(exactly = 0) { api.postCustomTimer(any()) } + coVerify(exactly = 0) { api.putCustomTimer(any(), any()) } + coVerify(exactly = 0) { api.deleteCustomTimer(any()) } + } + + @Test + fun `미전송 생성이나 삭제 잔존은 제한 시간 뒤 실제 실패다`() = + runTest { + coEvery { api.getCustomTimerList() } returns timerList(run.originalTimer, run.deletedTimer) + + expectFailure("offline_upload_not_observed") { fixtures.observe(run, "timers-synced") } + + coVerify(atLeast = 2) { api.getCustomTimerList() } + coVerify(exactly = 0) { api.postCustomTimer(any()) } + } + + @Test + fun `같은 고유 제목의 중복 서버 행은 업로드 성공으로 인정하지 않는다`() = + runTest { + coEvery { api.getCustomTimerList() } returns timerList(run.createdTimer, run.createdTimer, run.updatedTimer) + + expectFailure("offline_upload_not_observed") { fixtures.observe(run, "timers-synced") } + } + + @Test + fun `수정 제목만 맞고 스텝 시간이 이전 값이면 성공하지 않는다`() = + runTest { + coEvery { api.getCustomTimerList() } returns timerList(run.createdTimer, run.updatedTimer) + coEvery { api.getCustomTimer(1) } returns success(timer(1, run.createdTimer, 45)) + coEvery { api.getCustomTimer(2) } returns success(timer(2, run.updatedTimer, 45)) + + expectFailure("offline_upload_not_observed") { fixtures.observe(run, "timers-synced") } + } + + @Test + fun `서버 실패와 null 본문은 빈 목록이나 로컬 성공으로 바꾸지 않는다`() = + runTest { + for (response in listOf( + BaseResponse(false, "SERVER_ERROR", "private server diagnostic"), + BaseResponse(true, "OK", "", null), + )) { + coEvery { api.getCustomTimerList() } returns response + expectFailure("fixture_read_failed") { fixtures.observe(run, "timers-synced") } + } + } + + @Test + fun `UI로 만든 운동의 목록과 상세를 서버에서 독립 확인한다`() = + runTest { + coEvery { api.getExerciseList(run.startedAt.toLocalDate()) } returns success(listOf(exercise(1, run.exerciseTitle))) + coEvery { api.getExerciseRecordDetail(1) } returns success(exerciseDetail(run.exerciseTitle)) + + fixtures.observe(run, "exercise-online") + + coVerify(exactly = 1) { api.getExerciseRecordDetail(1) } + coVerify(exactly = 0) { api.postExerciseRecord(any()) } + } + + @Test + fun `정리는 이번 UUID와 정확히 일치하는 운동과 타이머만 삭제한다`() = + runTest { + val other = "maestro-offline-ffffffffffffffffffffffffffffffff-create" + coEvery { api.getExerciseList(run.startedAt.toLocalDate()) } returns + success(listOf(exercise(1, run.exerciseTitle), exercise(2, "personal exercise"))) + coEvery { api.getCustomTimerList() } returns timerList(run.createdTimer, other, "personal timer") + coEvery { api.getExerciseRecordDetail(1) } returns success(exerciseDetail(run.exerciseTitle)) + coEvery { api.getCustomTimer(1) } returns success(timer(1, run.createdTimer, 45)) + coEvery { api.deleteExerciseRecord(1) } returns success(null) + coEvery { api.deleteCustomTimer(1) } returns success(null) + + fixtures.clean(run) + + coVerify(exactly = 1) { api.deleteExerciseRecord(1) } + coVerify(exactly = 1) { api.deleteCustomTimer(1) } + coVerify(exactly = 0) { api.deleteExerciseRecord(2) } + coVerify(exactly = 0) { api.deleteCustomTimer(2) } + coVerify(exactly = 0) { api.deleteCustomTimer(3) } + coVerify(exactly = 0) { api.deleteSimpleTimer(any()) } + } + + @Test + fun `모든 목록 조회 전에 삭제하지 않고 뒤 조회 실패 시 후보도 보존한다`() = + runTest { + coEvery { api.getExerciseList(run.startedAt.toLocalDate()) } returns success(listOf(exercise(1, run.exerciseTitle))) + coEvery { api.getCustomTimerList() } returns BaseResponse(false, "SERVER_ERROR", "") + + expectFailure("fixture_read_failed") { fixtures.clean(run) } + + coVerify(exactly = 0) { api.deleteExerciseRecord(any()) } + coVerify(exactly = 0) { api.deleteCustomTimer(any()) } + } + + @Test + fun `상세가 일반 제목으로 바뀐 타이머는 정리하지 않는다`() = + runTest { + coEvery { api.getExerciseList(run.startedAt.toLocalDate()) } returns success(emptyList()) + coEvery { api.getCustomTimerList() } returns timerList(run.createdTimer) + coEvery { api.getCustomTimer(1) } returns success(timer(1, "personal timer", 45)) + + fixtures.clean(run) + + coVerify(exactly = 0) { api.deleteCustomTimer(any()) } + } + + @Test + fun `서버에 미저장 draft가 추가되면 두 저장값이 맞아도 성공하지 않는다`() = + runTest { + coEvery { api.getCustomTimerList() } returns timerList(run.createdTimer, run.updatedTimer, run.draftTimer) + + expectFailure("offline_upload_not_observed") { fixtures.observe(run, "timers-synced") } + } + + @Test + fun `삭제 실패는 정리 성공으로 숨기지 않는다`() = + runTest { + coEvery { api.getExerciseList(run.startedAt.toLocalDate()) } returns success(emptyList()) + coEvery { api.getCustomTimerList() } returns timerList(run.createdTimer) + coEvery { api.getCustomTimer(1) } returns success(timer(1, run.createdTimer, 45)) + coEvery { api.deleteCustomTimer(1) } returns BaseResponse(false, "SERVER_ERROR", "private diagnostic") + + expectFailure("fixture_delete_failed") { fixtures.clean(run) } + } + + private fun timerList(vararg names: String) = + success(GetCustomTimerListDTO(names.size, names.mapIndexed { index, name -> CustomTimerSummaryDTO(index.toLong() + 1, name) })) + + private fun timer( + id: Long, + name: String, + seconds: Int, + ) = GetCustomTimerDetailDTO(id, name, 1, listOf(CustomTimerDetailStepDTO(100 + id, "offline-step", 0, seconds))) + + private fun exercise( + id: Long, + title: String, + ) = GetExerciseRecordListDto(id, title, "OTHER", run.startedAt, run.endedAt, null) + + private fun exerciseDetail(title: String) = GetExerciseRecordData(title, "", "OTHER", run.startedAt, run.endedAt, "", null) + + private fun success(data: T): BaseResponse = BaseResponse(true, "OK", "", data) + + private suspend fun expectFailure( + reason: String, + operation: suspend () -> Unit, + ) { + try { + operation() + throw AssertionError("실패할 서버 관찰이 성공했습니다") + } catch (failure: MaestroSetupFailure) { + assertThat(failure.reason).isEqualTo(reason) + } + } +} diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroSetupCoordinatorTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroSetupCoordinatorTest.kt new file mode 100644 index 00000000..6ad411f0 --- /dev/null +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroSetupCoordinatorTest.kt @@ -0,0 +1,75 @@ +package com.project200.undabang.e2e + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.time.LocalDateTime + +class MaestroSetupCoordinatorTest { + private val protocol = mockk(relaxed = true) + private val session = mockk(relaxed = true) + private val legacyCleaner = mockk(relaxed = true) + private val fixtures = mockk(relaxed = true) + private val coordinator = MaestroSetupCoordinator(protocol, session, legacyCleaner, fixtures) + private val run = + MaestroOfflineRun( + "0123456789abcdef0123456789abcdef", + LocalDateTime.of(2026, 10, 15, 9, 0), + LocalDateTime.of(2026, 10, 15, 10, 0), + ) + + @Test + fun `업로드 관찰은 현재 세션을 검증하고 인증 복원이나 Room 초기화를 수행하지 않는다`() = + runTest { + every { protocol.consumeRequest() } returns + MaestroSetupRequest.OfflineOperation("offline-observe", "dedicated-member", null, run, "timers-synced") + + coordinator.consumeRequest() + + coVerifyOrder { + session.verifyCurrent("dedicated-member") + fixtures.observe(run, "timers-synced") + protocol.writeStatus("observed") + } + coVerify(exactly = 0) { session.authenticate(any(), any()) } + coVerify(exactly = 0) { session.clearSession() } + coVerify(exactly = 0) { fixtures.clean(any()) } + coVerify(exactly = 0) { legacyCleaner.clean(any(), any()) } + } + + @Test + fun `오프라인 준비는 계정 확인과 실행별 정리와 플래그 확인 뒤에만 성공한다`() = + runTest { + val auth = mockk() + every { protocol.consumeRequest() } returns + MaestroSetupRequest.OfflineOperation("offline-prepare", "dedicated-member", auth, run, null) + + coordinator.consumeRequest() + + coVerifyOrder { + session.authenticate(auth, "dedicated-member") + fixtures.clean(run) + fixtures.requireCacheEnabled() + protocol.writeStatus("ready") + } + } + + @Test + fun `계정 불일치면 서버 관찰과 삭제를 수행하지 않는다`() = + runTest { + every { protocol.consumeRequest() } returns + MaestroSetupRequest.OfflineOperation("offline-observe", "wrong-member", null, run, "timers-synced") + coEvery { session.verifyCurrent("wrong-member") } throws MaestroSetupFailure("member_mismatch") + + coordinator.consumeRequest() + + coVerify(exactly = 0) { fixtures.observe(any(), any()) } + coVerify(exactly = 0) { fixtures.clean(any()) } + coVerify(exactly = 1) { protocol.writeFailure("member_mismatch") } + coVerify(exactly = 0) { protocol.writeStatus(any()) } + } +} diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainFragment.kt index cfcfd92f..09c1946f 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainFragment.kt @@ -18,6 +18,7 @@ import com.kizitonwose.calendar.view.ViewContainer import com.project200.common.constants.RuleConstants.SCORE_HIGH_LEVEL import com.project200.common.constants.RuleConstants.SCORE_MIDDLE_LEVEL import com.project200.common.utils.CommonDateTimeFormatters.YYYY_M_KR +import com.project200.domain.model.BaseResult import com.project200.presentation.base.BindingFragment import com.project200.undabang.feature.exercise.R import com.project200.undabang.feature.exercise.databinding.CalendarDayLayoutBinding @@ -97,6 +98,7 @@ class ExerciseMainFragment : BindingFragment(R.layo selectedIv.isVisible = false exerciseCompleteIv.apply { isVisible = false + contentDescription = null animate().cancel() // 진행 중인 애니메이션이 있다면 취소 alpha = 1f } @@ -123,6 +125,7 @@ class ExerciseMainFragment : BindingFragment(R.layo if (exerciseCompleteDates.contains(data.date)) { exerciseCompleteIv.apply { alpha = 0f + contentDescription = getString(R.string.exercise_calendar_record, data.date.toString()) isVisible = true animate().alpha(1f).setDuration(300).start() } @@ -260,7 +263,6 @@ class ExerciseMainFragment : BindingFragment(R.layo updateListSelectors() exerciseAdapter.submitList(list) { if (submissionId == listSubmissionId && owner.lifecycle.currentState != Lifecycle.State.DESTROYED) { - binding.exerciseListEmptyTv.isVisible = list.isNullOrEmpty() binding.exerciseListRv.doOnNextLayout { if (submissionId == listSubmissionId && owner.lifecycle.currentState != Lifecycle.State.DESTROYED) { isListCommitted = true @@ -275,16 +277,31 @@ class ExerciseMainFragment : BindingFragment(R.layo viewModel.isExerciseListLoaded.observe(viewLifecycleOwner) { updateListSelectors() } + viewModel.exerciseListError.observe(viewLifecycleOwner) { + updateListSelectors() + } } private fun updateListSelectors() { - // 같은 내용의 목록도 현재 제출의 비동기 Diff가 반영된 뒤에만 완료 상태를 노출합니다. - val isLoaded = viewModel.isExerciseListLoaded.value == true && isListCommitted + // 성공 표시는 어댑터 반영을 기다리지만, 최신 요청의 오류는 목록 제출 없이도 완료 상태입니다. + val state = + exerciseListAccessibilityState( + isListCommitted = isListCommitted, + isListLoaded = viewModel.isExerciseListLoaded.value == true, + isListEmpty = exerciseAdapter.currentList.isEmpty(), + error = viewModel.exerciseListError.value, + ) val date = viewModel.selectedDate.value.toString() binding.exerciseListRv.contentDescription = - if (isLoaded && exerciseAdapter.currentList.isNotEmpty()) getString(R.string.exercise_list_loaded, date) else null + if (state == ExerciseListAccessibilityState.CONTENT) getString(R.string.exercise_list_loaded, date) else null + binding.exerciseListEmptyTv.isVisible = + state == ExerciseListAccessibilityState.EMPTY || state == ExerciseListAccessibilityState.NETWORK_ERROR binding.exerciseListEmptyTv.contentDescription = - if (isLoaded && exerciseAdapter.currentList.isEmpty()) getString(R.string.exercise_empty_loaded, date) else null + when (state) { + ExerciseListAccessibilityState.EMPTY -> getString(R.string.exercise_empty_loaded, date) + ExerciseListAccessibilityState.NETWORK_ERROR -> getString(R.string.exercise_list_network_error, date) + else -> null + } } inner class DayViewContainer(val binding: CalendarDayLayoutBinding) : @@ -307,3 +324,23 @@ class ExerciseMainFragment : BindingFragment(R.layo viewModel.refreshData() } } + +internal enum class ExerciseListAccessibilityState { + NOT_READY, + CONTENT, + EMPTY, + NETWORK_ERROR, +} + +internal fun exerciseListAccessibilityState( + isListCommitted: Boolean, + isListLoaded: Boolean, + isListEmpty: Boolean, + error: BaseResult.Error?, +): ExerciseListAccessibilityState = + when { + error?.errorCode == "NETWORK_ERROR" -> ExerciseListAccessibilityState.NETWORK_ERROR + !isListCommitted || !isListLoaded -> ExerciseListAccessibilityState.NOT_READY + isListEmpty -> ExerciseListAccessibilityState.EMPTY + else -> ExerciseListAccessibilityState.CONTENT + } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainViewModel.kt index d8f2fe8a..795a5c63 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainViewModel.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainViewModel.kt @@ -63,6 +63,8 @@ class ExerciseMainViewModel // 초기값과 조회 실패가 삭제 검증의 빈 목록 선택자로 노출되지 않게 합니다. private val _isExerciseListLoaded = MutableLiveData(false) val isExerciseListLoaded: LiveData = _isExerciseListLoaded + private val _exerciseListError = MutableLiveData(null) + val exerciseListError: LiveData = _exerciseListError private var exerciseListRequestId = 0L private val expectedScoreInfo = MutableLiveData() @@ -106,6 +108,7 @@ class ExerciseMainViewModel private fun loadExercisesForDate(date: LocalDate) { val requestId = ++exerciseListRequestId _isExerciseListLoaded.value = false + _exerciseListError.value = null viewModelScope.launch { // 이전 요청의 완료 상태가 새 응답의 목록 관찰자에 전달되지 않게 먼저 해제합니다. val result = getExerciseRecordListUseCase(date) @@ -117,6 +120,9 @@ class ExerciseMainViewModel } is BaseResult.Error -> { _exerciseList.value = emptyList() // 실패 시 빈 리스트 + if (requestId == exerciseListRequestId && date == _selectedDate.value) { + _exerciseListError.value = result + } _toastMessage.value = result.message ?: "기록을 불러오는데 실패했습니다." } } diff --git a/feature/exercise/src/main/res/values/strings.xml b/feature/exercise/src/main/res/values/strings.xml index 08c9e7de..80268dfc 100644 --- a/feature/exercise/src/main/res/values/strings.xml +++ b/feature/exercise/src/main/res/values/strings.xml @@ -108,4 +108,6 @@ 미니멀 테마 사진 편집 공유하려면 사진이 필요해요 + 운동 기록이 있는 날짜: %1$s + %1$s 운동 기록 목록 네트워크 오류 \ No newline at end of file diff --git a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseListAccessibilityStateTest.kt b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseListAccessibilityStateTest.kt new file mode 100644 index 00000000..f017a6ac --- /dev/null +++ b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseListAccessibilityStateTest.kt @@ -0,0 +1,58 @@ +package com.project200.feature.exercise + +import com.google.common.truth.Truth.assertThat +import com.project200.domain.model.BaseResult +import com.project200.feature.exercise.main.ExerciseListAccessibilityState +import com.project200.feature.exercise.main.exerciseListAccessibilityState +import org.junit.Test + +class ExerciseListAccessibilityStateTest { + @Test + fun `첫 요청 네트워크 실패는 목록 제출이나 레이아웃 완료 없이 오류로 노출한다`() { + val state = + exerciseListAccessibilityState( + isListCommitted = false, + isListLoaded = false, + isListEmpty = true, + error = BaseResult.Error("NETWORK_ERROR", "network"), + ) + + assertThat(state).isEqualTo(ExerciseListAccessibilityState.NETWORK_ERROR) + } + + @Test + fun `최신 네트워크 실패는 이전 어댑터 행이 남아 있어도 오류로 노출한다`() { + val state = + exerciseListAccessibilityState( + isListCommitted = false, + isListLoaded = false, + isListEmpty = false, + error = BaseResult.Error("NETWORK_ERROR", "network"), + ) + + assertThat(state).isEqualTo(ExerciseListAccessibilityState.NETWORK_ERROR) + } + + @Test + fun `성공한 빈 목록은 어댑터 반영 전에는 성공 상태를 노출하지 않는다`() { + assertThat(exerciseListAccessibilityState(false, true, true, null)) + .isEqualTo(ExerciseListAccessibilityState.NOT_READY) + assertThat(exerciseListAccessibilityState(true, true, true, null)) + .isEqualTo(ExerciseListAccessibilityState.EMPTY) + } + + @Test + fun `성공한 실제 행도 최신 조회와 어댑터 반영이 모두 끝나야 노출한다`() { + assertThat(exerciseListAccessibilityState(true, false, false, null)) + .isEqualTo(ExerciseListAccessibilityState.NOT_READY) + assertThat(exerciseListAccessibilityState(true, true, false, null)) + .isEqualTo(ExerciseListAccessibilityState.CONTENT) + } + + @Test + fun `일반 서버 오류는 네트워크 실패로 바꾸지 않는다`() { + val state = exerciseListAccessibilityState(false, false, true, BaseResult.Error("SERVER_ERROR", "server")) + + assertThat(state).isEqualTo(ExerciseListAccessibilityState.NOT_READY) + } +} diff --git a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt index ceb1ceda..8720851d 100644 --- a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt +++ b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt @@ -530,4 +530,72 @@ class ExerciseMainViewModelTest { viewModel.exerciseList.removeObserver(observer) } } + + @Test + fun `운동 목록 네트워크 오류는 성공한 빈 목록과 구분하고 새 요청에서 해제한다`() = + runTest { + testDispatcher.scheduler.advanceUntilIdle() + coEvery { mockGetExerciseRecordListUseCase.invoke(today) } returns BaseResult.Error("NETWORK_ERROR", "network") + + viewModel.refreshData() + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.exerciseListError.value?.errorCode).isEqualTo("NETWORK_ERROR") + assertThat(viewModel.isExerciseListLoaded.value).isFalse() + + val response = CompletableDeferred>>() + coEvery { mockGetExerciseRecordListUseCase.invoke(today) } coAnswers { response.await() } + viewModel.refreshData() + assertThat(viewModel.exerciseListError.value).isNull() + testDispatcher.scheduler.runCurrent() + assertThat(viewModel.exerciseListError.value).isNull() + + response.complete(BaseResult.Success(emptyList())) + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.exerciseListError.value).isNull() + assertThat(viewModel.isExerciseListLoaded.value).isTrue() + } + + @Test + fun `점수 조회 네트워크 토스트만으로 운동 목록 오류 상태를 만들지 않는다`() = + runTest { + testDispatcher.scheduler.advanceUntilIdle() + coEvery { mockGetScoreUseCase.invoke() } returns BaseResult.Error("NETWORK_ERROR", "score network") + + viewModel.refreshData() + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.exerciseListError.value).isNull() + assertThat(viewModel.isExerciseListLoaded.value).isTrue() + } + + @Test + fun `이전 날짜에서 늦게 온 네트워크 오류는 현재 날짜 오류로 노출하지 않는다`() = + runTest { + testDispatcher.scheduler.advanceUntilIdle() + val previous = CompletableDeferred>>() + coEvery { mockGetExerciseRecordListUseCase.invoke(today) } coAnswers { previous.await() } + viewModel.refreshData() + testDispatcher.scheduler.runCurrent() + + viewModel.onDateSelected(today.minusDays(1)) + testDispatcher.scheduler.runCurrent() + previous.complete(BaseResult.Error("NETWORK_ERROR", "old date network")) + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.selectedDate.value).isEqualTo(today.minusDays(1)) + assertThat(viewModel.exerciseListError.value).isNull() + } + + @Test + fun `첫 목록 요청의 실패도 추가 새로고침 없이 오류 객체로 노출한다`() = + runTest { + val failure = BaseResult.Error("NETWORK_ERROR", "first request") + coEvery { mockGetExerciseRecordListUseCase.invoke(today) } returns failure + + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.exerciseListError.value).isEqualTo(failure) + assertThat(viewModel.isExerciseListLoaded.value).isFalse() + } } diff --git a/feature/timer/src/main/java/com/project200/feature/timer/TimerListScreen.kt b/feature/timer/src/main/java/com/project200/feature/timer/TimerListScreen.kt index 14827ffb..d396206d 100644 --- a/feature/timer/src/main/java/com/project200/feature/timer/TimerListScreen.kt +++ b/feature/timer/src/main/java/com/project200/feature/timer/TimerListScreen.kt @@ -21,11 +21,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.key 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.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 com.project200.domain.model.CustomTimer @@ -38,6 +42,7 @@ import com.project200.presentation.compose.theme.ColorWhite300 import com.project200.presentation.compose.theme.header import com.project200.undabang.feature.timer.R +@OptIn(ExperimentalComposeUiApi::class) @Composable fun TimerListScreen( customTimers: List, @@ -51,7 +56,9 @@ fun TimerListScreen( modifier = modifier .fillMaxSize() - .background(ColorBackground), + .background(ColorBackground) + .semantics { testTagsAsResourceId = true } + .testTag("custom_timer_list"), ) { UndabangTopBar( title = stringResource(R.string.timer_title), @@ -171,6 +178,7 @@ private fun AddCustomTimerCard( Box( modifier = modifier + .testTag("custom_timer_add") .fillMaxWidth() .padding(horizontal = 20.dp, vertical = 5.dp) .height(60.dp) diff --git a/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerFormScreen.kt b/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerFormScreen.kt index 030cf0eb..428bac81 100644 --- a/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerFormScreen.kt +++ b/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerFormScreen.kt @@ -25,6 +25,7 @@ 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.drawWithContent @@ -35,8 +36,11 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.Stroke +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.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -60,6 +64,7 @@ import com.project200.undabang.feature.timer.R import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState +@OptIn(ExperimentalComposeUiApi::class) @Composable fun CustomTimerFormScreen( title: String, @@ -78,7 +83,7 @@ fun CustomTimerFormScreen( modifier: Modifier = Modifier, ) { UndabangScaffold( - modifier = modifier, + modifier = modifier.semantics { testTagsAsResourceId = true }.testTag("custom_timer_form"), topBar = { UndabangTopBar( title = @@ -91,7 +96,7 @@ fun CustomTimerFormScreen( text = stringResource(com.project200.undabang.presentation.R.string.complete), onClick = onCompleteClick, isCompact = true, - modifier = Modifier.padding(end = 20.dp), + modifier = Modifier.padding(end = 20.dp).testTag("custom_timer_complete"), ) }, ) @@ -129,6 +134,7 @@ fun CustomTimerFormScreen( modifier = Modifier .fillMaxWidth() + .testTag("custom_timer_title") .padding(horizontal = 20.dp, vertical = 30.dp), decorationBox = { inner -> if (title.isEmpty()) { @@ -237,7 +243,7 @@ private fun StepRow( color = ColorBlack, ), singleLine = true, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f).testTag("custom_timer_step_name_${step.order}"), ) Text( text = formatStepTime(step.time), @@ -245,6 +251,7 @@ private fun StepRow( color = ColorBlack, modifier = Modifier + .testTag("custom_timer_step_time_${step.order}") .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -330,7 +337,7 @@ private fun FooterRow( color = ColorBlack, ), singleLine = true, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f).testTag("custom_timer_new_step_name"), decorationBox = { inner -> if (name.isEmpty()) { Text( @@ -349,6 +356,7 @@ private fun FooterRow( color = ColorBlack, modifier = Modifier + .testTag("custom_timer_new_step_time") .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -362,6 +370,7 @@ private fun FooterRow( modifier = Modifier .padding(start = 16.dp) + .testTag("custom_timer_add_step") .size(24.dp) .clickable( interactionSource = remember { MutableInteractionSource() }, diff --git a/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerScreen.kt b/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerScreen.kt index ce20f818..981569bb 100644 --- a/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerScreen.kt +++ b/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.drawBehind @@ -36,8 +37,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke +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.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -58,6 +62,7 @@ import com.project200.presentation.compose.theme.contentBold import com.project200.presentation.compose.theme.header import com.project200.undabang.feature.timer.R +@OptIn(ExperimentalComposeUiApi::class) @Composable fun CustomTimerScreen( title: String, @@ -77,13 +82,13 @@ fun CustomTimerScreen( modifier: Modifier = Modifier, ) { UndabangScaffold( - modifier = modifier, + modifier = modifier.semantics { testTagsAsResourceId = true }.testTag("custom_timer_detail"), topBar = { UndabangTopBar( title = title, onNavigationClick = onBackClick, actions = { - IconButton(onClick = onMenuClick) { + IconButton(onClick = onMenuClick, modifier = Modifier.testTag("custom_timer_menu")) { androidx.compose.material3.Icon( painter = painterResource(R.drawable.ic_menu), contentDescription = null, From 273a9964ffdc72d91114cb86ec9509e9734e4cbb Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:44:50 +0900 Subject: [PATCH 2/3] =?UTF-8?q?style(e2e):=20=EC=98=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=98=95=EC=8B=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project200/undabang/e2e/MaestroOfflineFixturesTest.kt | 2 +- .../com/project200/undabang/e2e/MaestroOfflineRecoveryTest.kt | 1 + .../com/project200/undabang/e2e/MaestroSetupCoordinatorTest.kt | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineFixturesTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineFixturesTest.kt index 5efb14bf..3109a251 100644 --- a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineFixturesTest.kt +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineFixturesTest.kt @@ -18,8 +18,8 @@ import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest -import org.junit.Test import org.json.JSONObject +import org.junit.Test import java.time.LocalDateTime class MaestroOfflineFixturesTest { diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineRecoveryTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineRecoveryTest.kt index 2e405e88..da415c72 100644 --- a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineRecoveryTest.kt +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineRecoveryTest.kt @@ -64,6 +64,7 @@ class MaestroOfflineRecoveryTest { private val preferences = PreferenceManager(context) private val protocol = MaestroSetupProtocol(context) private val offline = MaestroOfflineFixtures(api, mockk(), mockk()) + // 실제 AuthRepository가 clearAll을 호출하면 pending 증거가 사라지는 경계를 감지합니다. private val pending = mutableListOf("pending-create", "pending-update", "pending-delete") private val run = diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroSetupCoordinatorTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroSetupCoordinatorTest.kt index 3c745598..07710569 100644 --- a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroSetupCoordinatorTest.kt +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroSetupCoordinatorTest.kt @@ -88,7 +88,8 @@ class MaestroSetupCoordinatorTest { runTest { every { protocol.consumeRequest() } returns MaestroSetupRequest.OfflineObservation("dedicated-member", run, "timers-synced", feature) - every { protocol.validateFeatureRecovery(feature, required = true) } throws MaestroSetupFailure("feature_recovery_owner_mismatch") + every { protocol.validateFeatureRecovery(feature, required = true) } throws + MaestroSetupFailure("feature_recovery_owner_mismatch") coordinator.consumeRequest() From 7a5a64d71940a4aadc6cfd0fdf0634c146fe3c66 Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:52:51 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test(offline):=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=98=A4=EB=A5=98=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/exercise/ExerciseMainViewModelTest.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt index 8720851d..aaca13f1 100644 --- a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt +++ b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt @@ -590,8 +590,20 @@ class ExerciseMainViewModelTest { @Test fun `첫 목록 요청의 실패도 추가 새로고침 없이 오류 객체로 노출한다`() = runTest { + // 공통 설정 인스턴스의 초기 요청을 먼저 완료합니다. + testDispatcher.scheduler.advanceUntilIdle() + val failure = BaseResult.Error("NETWORK_ERROR", "first request") coEvery { mockGetExerciseRecordListUseCase.invoke(today) } returns failure + viewModel = + ExerciseMainViewModel( + mockGetExerciseCountUseCase, + mockGetScoreUseCase, + mockGetScorePolicyUseCase, + mockGetExerciseRecordListUseCase, + mockGetExpectedScoreInfoUseCase, + mockClockProvider, + ) testDispatcher.scheduler.advanceUntilIdle()