diff --git a/.github/workflows/screenshot.yml b/.github/workflows/screenshot.yml index c5282623d3..0647bdbe69 100644 --- a/.github/workflows/screenshot.yml +++ b/.github/workflows/screenshot.yml @@ -16,6 +16,17 @@ jobs: screenshot-test: name: Validate Compose Preview Screenshots runs-on: ubuntu-latest + # baseline 을 가진 모듈의 정본. 종전에는 실행 태스크·아티팩트 경로·실패 안내가 각자 + # 목록을 들고 있어, 모듈을 하나 늘리면 세 곳을 모두 고쳐야 했다 — 하나라도 빠지면 + # 그 모듈은 «초록이지만 돌지 않는» 상태가 된다. 여기 한 줄만 늘리면 셋 다 따라온다. + env: + SCREENSHOT_MODULES: >- + :core:ui + :feature:home:presentation + :feature:receiver:presentation + :feature:onboarding:presentation + :feature:afternote:presentation + :feature:mindrecord:presentation steps: - name: Clone repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -56,13 +67,9 @@ jobs: -w /workspace \ -e KAKAO_NATIVE_APP_KEY \ -e GOOGLE_WEB_CLIENT_ID \ + -e SCREENSHOT_MODULES \ afternote-screenshot:latest \ - ./gradlew :core:ui:validateScreenshotTest \ - :feature:home:presentation:validateScreenshotTest \ - :feature:receiver:presentation:validateScreenshotTest \ - :feature:onboarding:presentation:validateScreenshotTest \ - :feature:afternote:presentation:validateScreenshotTest \ - --parallel --build-cache + sh -c './gradlew $(for m in $SCREENSHOT_MODULES; do printf "%s:validateScreenshotTest " "$m"; done) --parallel --build-cache' # 컨테이너가 root 로 gradle 을 실행해 workspace · ~/.gradle 산출물이 root 소유가 된다. # job 종료 시 'Post Restore Gradle cache'(캐시 저장)의 hashFiles/tar 가 runner 권한이라 @@ -71,18 +78,27 @@ jobs: if: always() run: sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" "$HOME/.gradle" + # 아티팩트 경로도 같은 목록에서 만든다 — 여기만 빠지면 CI 가 빨개졌을 때 무엇이 어떻게 + # 그려졌는지 볼 방법이 없다. + - name: Resolve screenshot result paths + id: screenshot-paths + if: failure() + run: | + { + echo 'list<> "$GITHUB_OUTPUT" + # 실패 시 diff/rendered 이미지를 artifact 로 업로드 — 본인이 다운로드해 시각 차이 확인 가능 - name: Upload screenshot diff on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: screenshot-test-results - path: | - core/ui/build/outputs/screenshotTest-results/ - feature/home/presentation/build/outputs/screenshotTest-results/ - feature/receiver/presentation/build/outputs/screenshotTest-results/ - feature/onboarding/presentation/build/outputs/screenshotTest-results/ - feature/afternote/presentation/build/outputs/screenshotTest-results/ + path: ${{ steps.screenshot-paths.outputs.list }} retention-days: 7 # 실패 시 PR 에 artifact URL + 조치 가이드 자동 코멘트. @@ -93,6 +109,7 @@ jobs: with: script: | const marker = ''; + const modules = process.env.SCREENSHOT_MODULES.trim().split(/\s+/); const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const body = [ marker, @@ -103,7 +120,7 @@ jobs: '🔍 **확인 위치** (artifact zip 내부):', '- diff PNG: `preview/debug/diffs/...`', '- rendered PNG: `preview/debug/rendered/...` (CI 환경에서 새로 그린 결과)', - '- baseline PNG: repo `/src/screenshotTestDebug/reference/...` (`:core:ui` · `:feature:home:presentation` · `:feature:receiver:presentation` · `:feature:onboarding:presentation` · `:feature:afternote:presentation`)', + `- baseline PNG: repo \`/src/screenshotTestDebug/reference/...\` (${modules.map(m => `\`${m}\``).join(' · ')})`, '', '📝 **조치 가이드**:', '- 의도된 시각 변경 → `docker build -t afternote-screenshot:latest -f Dockerfile.screenshot .` 후 `docker run --rm -v "$PWD":/workspace -w /workspace afternote-screenshot:latest ./gradlew ::updateScreenshotTest` 로 baseline 갱신 + commit/push', diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 7da06f2eeb..93eb814b68 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("afternote.android.library.compose") id("afternote.android.hilt") id("afternote.android.navigation") + alias(libs.plugins.compose.screenshot) id("afternote.kover") } @@ -11,6 +12,9 @@ android { // Robolectric 이 컴파일된 리소스로 화면을 띄운다 (#729). testOptions.unitTests.isIncludeAndroidResources = true + + // 스크롤이 없는 화면은 세로가 모자라면 그대로 잘린다 — 좁은 화면 회귀를 CI 가 잡게 한다 (#1131). + experimentalProperties["android.experimental.enableScreenshotTest"] = true } dependencies { @@ -37,6 +41,9 @@ dependencies { // 수신자 기록 본문 시트도 JVM 에서 실제로 렌더해 확인한다 (#618). // 카드 미리보기의 대체 문자 제거도 HtmlCompat 파싱 결과라 실제 파서로 확인한다 (#549). testImplementation(libs.robolectric) + screenshotTestImplementation(libs.screenshot.validation.api) + screenshotTestImplementation(libs.androidx.compose.ui.tooling) + testImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.test.manifest) implementation(libs.compose.rich.editor) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreen.kt index 04654fc93b..78f8dd5a72 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreen.kt @@ -87,8 +87,9 @@ private fun MemorySpaceUiState.statusText(): String? = } } +/** 상태 없는 본문. screenshotTest 가 이 자리에서 좁은 화면 회귀를 잡는다 (#1131). */ @Composable -private fun MemorySpaceContent( +internal fun MemorySpaceContent( onBackClick: () -> Unit, memories: List, modifier: Modifier = Modifier, diff --git a/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/CompactDeviceSpec.kt b/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/CompactDeviceSpec.kt new file mode 100644 index 0000000000..e909811ddc --- /dev/null +++ b/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/CompactDeviceSpec.kt @@ -0,0 +1,14 @@ +package com.afternote.feature.mindrecord.presentation + +/** + * 좁은 화면 baseline 이 쓰는 기기 스펙 — 국내 보급형(720×1600 물리) 기준 (#1131). + * + * 기본 `@Preview` 는 411×914dp 급으로 렌더된다. 이 앱은 고정 dp 리터럴이 많고 반응형 API 는 + * 거의 안 쓰므로, 스크롤이 없는 화면은 세로가 모자라면 그대로 잘린다. 실기 QA 가 돌던 + * 에뮬레이터는 그보다도 넓어(446×967dp) 잘림이 구조적으로 안 잡혔다 (#1129). + * + * dpi 320(xhdpi)은 실기기의 리소스 버킷을 맞추기 위한 값이다 — 같은 360×800dp 라도 160dpi 면 + * 다른 drawable/dimen 이 선택돼 기준으로 쓸 수 없다. `feature:onboarding`·`feature:receiver` + * 의 같은 상수와 값을 맞춘다. + */ +internal const val COMPACT_DEVICE_SPEC = "spec:width=360dp,height=800dp,dpi=320" diff --git a/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTest.kt b/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTest.kt new file mode 100644 index 0000000000..7db63d6299 --- /dev/null +++ b/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTest.kt @@ -0,0 +1,119 @@ +package com.afternote.feature.mindrecord.presentation.screen.memoryspace + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.presentation.COMPACT_DEVICE_SPEC +import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryItem +import com.android.tools.screenshot.PreviewTest + +/** + * 추억 공간은 `verticalScroll`·`Lazy*`·`Pager` 가 없다 — 세로가 모자라면 그대로 잘린다 (#1131). + * + * 상태 셋(기록 있음·0건·조회 실패)을 기본 크기와 좁은 화면([COMPACT_DEVICE_SPEC]) 양쪽에서 + * 고정한다. 0건과 실패는 문구뿐 아니라 **재시도 버튼 유무**로 갈리므로 따로 둔다. + * + * 문구는 하드코딩하지 않고 리소스에서 읽는다 — 잡으려는 것이 «세로가 모자라 잘리는가» 인데 + * 문구가 다르면 줄바꿈과 세로 점유가 달라져 감시 대상 자체가 어긋난다. 리소스를 부르면 + * 문구가 바뀔 때 baseline 이 빨개져 «갱신하라» 고 알려 준다 (#1131 리뷰). + */ +@PreviewTest +@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5) +@Composable +internal fun memorySpaceScreenScreenshot() { + AfternoteTheme { + MemorySpaceContent( + onBackClick = {}, + modifier = Modifier.fillMaxSize(), + memories = previewMemories(), + ) + } +} + +@PreviewTest +@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun memorySpaceScreenCompactScreenshot() { + AfternoteTheme { + MemorySpaceContent( + onBackClick = {}, + modifier = Modifier.fillMaxSize(), + memories = previewMemories(), + ) + } +} + +@PreviewTest +@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5) +@Composable +internal fun memorySpaceScreenEmptyScreenshot() { + AfternoteTheme { + MemorySpaceContent( + onBackClick = {}, + modifier = Modifier.fillMaxSize(), + memories = emptyList(), + statusText = stringResource(R.string.mindrecord_memory_space_empty), + ) + } +} + +@PreviewTest +@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun memorySpaceScreenEmptyCompactScreenshot() { + AfternoteTheme { + MemorySpaceContent( + onBackClick = {}, + modifier = Modifier.fillMaxSize(), + memories = emptyList(), + statusText = stringResource(R.string.mindrecord_memory_space_empty), + ) + } +} + +/** 조회 실패 — 0건과 달리 재시도 버튼이 함께 그려진다. */ +@PreviewTest +@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5) +@Composable +internal fun memorySpaceScreenErrorScreenshot() { + AfternoteTheme { + MemorySpaceContent( + onBackClick = {}, + modifier = Modifier.fillMaxSize(), + memories = emptyList(), + statusText = stringResource(R.string.mindrecord_error_memory_space_failed), + onRetryClick = {}, + ) + } +} + +@PreviewTest +@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun memorySpaceScreenErrorCompactScreenshot() { + AfternoteTheme { + MemorySpaceContent( + onBackClick = {}, + modifier = Modifier.fillMaxSize(), + memories = emptyList(), + statusText = stringResource(R.string.mindrecord_error_memory_space_failed), + onRetryClick = {}, + ) + } +} + +/** + * 네트워크 이미지는 렌더되지 않으므로 baseline 에는 자리와 텍스트만 남는다 — 잘림을 보는 + * 데에는 그것으로 충분하다. + */ +private fun previewMemories(): List = + listOf( + MemoryItem(1L, "https://example.com/1.jpg", "기억 1", "2024.11.11", "미리보기", listOf("태그")), + MemoryItem(2L, "https://example.com/2.jpg", "기억 2", "2024.11.12", "미리보기", emptyList()), + MemoryItem(3L, "https://example.com/3.jpg", "기억 3", "2024.11.13", "미리보기", emptyList()), + MemoryItem(4L, "https://example.com/4.jpg", "기억 4", "2024.11.14", "미리보기", emptyList()), + ) diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenCompactScreenshot_e7ca7c28_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenCompactScreenshot_e7ca7c28_0.png new file mode 100644 index 0000000000..6a11855573 Binary files /dev/null and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenCompactScreenshot_e7ca7c28_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenEmptyCompactScreenshot_e7ca7c28_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenEmptyCompactScreenshot_e7ca7c28_0.png new file mode 100644 index 0000000000..09110adfe4 Binary files /dev/null and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenEmptyCompactScreenshot_e7ca7c28_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenEmptyScreenshot_90705b76_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenEmptyScreenshot_90705b76_0.png new file mode 100644 index 0000000000..748fb481f3 Binary files /dev/null and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenEmptyScreenshot_90705b76_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenErrorCompactScreenshot_e7ca7c28_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenErrorCompactScreenshot_e7ca7c28_0.png new file mode 100644 index 0000000000..b172aedd17 Binary files /dev/null and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenErrorCompactScreenshot_e7ca7c28_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenErrorScreenshot_90705b76_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenErrorScreenshot_90705b76_0.png new file mode 100644 index 0000000000..1a8648cd39 Binary files /dev/null and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenErrorScreenshot_90705b76_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenScreenshot_90705b76_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenScreenshot_90705b76_0.png new file mode 100644 index 0000000000..d471a4b38f Binary files /dev/null and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenScreenshot_90705b76_0.png differ