Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package com.afternote.afternote_fe

import androidx.activity.compose.setContent
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.afternote.afternote_fe.navigation.AppNavigation
import com.afternote.afternote_fe.navigation.AppState
import com.afternote.afternote_fe.navigation.rememberMindRecordNavActions
import com.afternote.afternote_fe.test.FailureArtifactRule
import com.afternote.core.ui.Route
import com.afternote.core.ui.theme.AfternoteTheme
import com.afternote.feature.mindrecord.presentation.navigation.MindRecordNavActions
import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import com.afternote.core.ui.R as CoreUiR
import com.afternote.feature.mindrecord.presentation.R as MindRecordR

/**
* 마인드레코드의 「한 칸 뒤로」가 **실제 NavHost 에서** 백스택을 원래대로 돌려놓는지 본다 (#1311).
*
* 화면별로 나뉘어 있던 단순 `popBackStack()` 5개를 [MindRecordNavActions.popBack] 하나로 합쳤다.
*
* 두 층을 나눠 본다.
* - `screenBackButton_…` — **화면의 뒤로가기를 실제로 눌러** `mindRecordNavGraph` 의 매핑을 지난다.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[normal] 매핑 7줄 중 5줄입니다 — onSubmitSuccess 2줄은 여전히 아무 데도 지나가지 않습니다.

screenBackButton_… 이 하는 일은 «navigate → 뒤로가기 클릭» 이고 제출을 태우지 않습니다. 그래서 mindRecordNavGraph 의

DailyQuestionWriteScreen(onSubmitSuccess = actions::popBack, …)
DiaryWriteScreen(onSubmitSuccess = actions::popBack, …)

두 줄은 이 층에도, popBack_… 둘(명령 직접 호출)에도 들어오지 않습니다. onSubmitSuccess = actions::onNavigateToDraftList 로 오배선해도 세 테스트가 전부 초록입니다 — 지난 리뷰에서 실측한 대로 컴파일도 시그니처가 같아 잡지 못합니다.

이 KDoc 은 그 구분을 하지 않습니다. 「mindRecordNavGraph 의 매핑을 지난다 … 이 층이 없으면 감시하는 것이 없다」 로 읽으면 일곱 줄 전부에 가드가 생긴 것으로 보입니다. popBack_popsExactlyOneEntryInsteadOfClearingTheGraph 주석에는 「이 테스트는 제출 성공 경로를 지나가지 않는다」 고 적어 두셨으니, 클래스 KDoc 에도 범위를 적어 주시면 정합합니다 — 예: 「화면의 뒤로가기 매핑 5줄을 지난다. 제출 성공 2줄(onSubmitSuccess)은 이 계측이 태우지 않는다 — 컴파일도 잡지 못하므로 그 자리는 여전히 무방비다.」

커버리지를 올리는 쪽을 택하신다면 제출까지 태우는 비용이 크니, 범위를 적어 두는 쪽이면 저는 충분하다고 봅니다. 막아야 할 것은 다음 사람이 «가드가 있다» 고 믿는 것뿐입니다.

* 매핑 오배선은 컴파일이 잡지 못하므로(#1311 리뷰 실측) 이 층이 없으면 감시하는 것이 없다.
* - `popBack_…` 둘 — 명령 자체의 계약(어느 목적지에서든 한 칸, 잔재 없음).
*/
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalTestApi::class)
class MindRecordBackStackAndroidTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)

@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()

@get:Rule(order = 2)
val failureArtifactRule =
FailureArtifactRule {
composeRule.onRoot().captureToImage().asAndroidBitmap()
}

private lateinit var navController: TestNavHostController

/** 라우트 객체가 NavHost 에 등록될 때의 route 문자열. 인자가 있는 라우트는 패턴이 붙는다. */
private fun routeOf(destination: Any): String? =
composeRule.runOnIdle {
navController.graph.find { node -> node.hasRoute(destination::class) }?.route
}

private fun label(resId: Int): String = InstrumentationRegistry.getInstrumentation().targetContext.getString(resId)

/** 화면마다 뒤로가기 라벨이 달라 둘 중 실재하는 쪽을 누른다. */
private fun clickBackAffordance() {
val labels =
listOf(
label(CoreUiR.string.core_ui_content_description_back),
label(MindRecordR.string.mindrecord_memory_space_back),
)
val target =
labels.firstOrNull { candidate ->
composeRule.onAllNodesWithContentDescription(candidate).fetchSemanticsNodes().isNotEmpty()
}
requireNotNull(target) { "뒤로가기 어포던스를 찾지 못했다 — 라벨 후보: $labels" }
composeRule.onAllNodesWithContentDescription(target).onFirst().performClick()
}

private lateinit var actions: MindRecordNavActions

@Before
fun setUp() {
hiltRule.inject()
composeRule.activityRule.scenario.onActivity { activity ->
navController =
TestNavHostController(activity).apply {
navigatorProvider.addNavigator(ComposeNavigator())
}
activity.setContent {
AfternoteTheme {
actions = rememberMindRecordNavActions(navController)
AppNavigation(
startDestination = Route.MindRecord,
appState = AppState(navController),
)
}
}
}
awaitRoute<Route.MindRecord>()
}

/**
* **화면의 뒤로가기를 실제로 눌러** 매핑을 태운다 (#1311 리뷰).
*
* 명령(`actions.popBack()`)을 직접 부르면 이 PR 이 손댄 `mindRecordNavGraph` 의 매핑 줄을
* 하나도 지나가지 않는다 — 매핑을 오염시켜도 컴파일이 통과하므로 그때 잡을 것이 없다.
* 그래서 각 화면의 뒤로가기 버튼을 눌러 `onBackClick = actions::popBack` 를 실제로 지난다.
*/
@Test
fun screenBackButton_returnsToMindRecordHomeFromEveryMindRecordDestination() {
listOf<Any>(
Route.MemorySpace,
Route.ReceiverMindRecord,
MindRecordRoute.DailyQuestionWriteRoute(),
MindRecordRoute.DiaryWriteRoute(),
MindRecordRoute.DraftListRoute,
).forEach { destination ->
composeRule.runOnIdle { navController.navigate(destination) }
composeRule.waitForIdle()

// 화면이 그린 뒤로가기다 — 명령을 직접 부르지 않는다.
// 추억 공간만 자체 라벨(BackPill)을 쓰므로 둘 중 있는 쪽을 누른다.
clickBackAffordance()

awaitRoute<Route.MindRecord>()
assertEquals(
"$destination 에서 돌아온 뒤 백스택에 잔재가 남았다",
null,
composeRule.runOnIdle { navController.previousBackStackEntry?.destination?.route },
)
}
}

@Test
fun popBack_returnsToMindRecordHomeFromEveryMindRecordDestination() {
// 명령 자체의 계약 — «한 칸 뒤로» 가 어느 목적지에서든 홈으로 돌아오고 잔재를 남기지 않는다.
listOf<Any>(
Route.MemorySpace,
Route.ReceiverMindRecord,
MindRecordRoute.DailyQuestionWriteRoute(),
MindRecordRoute.DiaryWriteRoute(),
MindRecordRoute.DraftListRoute,
).forEach { destination ->
composeRule.runOnIdle { navController.navigate(destination) }
composeRule.waitForIdle()
// «홈이 아니다» 만 보면 엉뚱한 목적지도 통과한다 — 실제로 그 목적지인지 본다 (#1311 리뷰).
assertEquals(
"$destination 으로 이동하지 못했다",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] 이동 확인이 «홈이 아니다» 뿐이라, 다섯 목적지 중 엉뚱한 곳으로 갔어도 통과합니다. 바로 아래에서 이미 쓰는 hasRoute 로 목적지를 직접 단언하면 같은 비용으로 강해집니다.

assertTrue(
    "$destination 으로 이동하지 못했다",
    composeRule.runOnIdle { navController.currentDestination?.hierarchy?.any { … } } == true,
)

destination 이 Any 라 reified 타입으로 못 받는 게 걸리면, 리스트를 listOf<Pair<Any, KClass<*>>> 로 들거나 라우트 문자열 비교로도 충분합니다.

routeOf(destination),
composeRule.runOnIdle { navController.currentDestination?.route },
)

composeRule.runOnIdle { actions.popBack() }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[normal] 이 계측은 «매핑» 을 태우지 않습니다 — 이 PR 이 실제로 손댄 7줄이 무방비입니다.

35행 KDoc 은 «명령을 직접 부르는 것이 아니라 각 화면 라우트로 실제 이동한 뒤» 라고 적었지만, 실제로 하는 일은 navController.navigate(...) 로 이동한 다음 이 줄에서 actions.popBack() 을 직접 부르는 것입니다. mindRecordNavGraph 의 onBackClick = actions::popBack 7줄은 실행 경로에 들어오지 않습니다 — 화면의 뒤로 버튼이 눌리지 않으니까요.

그래서 이 테스트가 고정하는 것은 rememberMindRecordNavActions(...).popBack() 이 한 칸 pop 한다는 것 하나입니다. 그건 이 PR 이 바꾸지 않은 부분입니다.

컴파일도 이 자리를 막지 않습니다. 삭제한 5개 심볼을 참조하면 컴파일이 잡지만, 남은 명령들 사이의 오배선은 시그니처가 전부 () -> Unit 이라 그냥 통과합니다. 이 브랜치를 워크트리로 받아 MindRecordNavGraph 의 매핑 두 곳을 일부러 오염시키고 돌렸습니다.

DraftListScreen(onBackClick = actions::onNavigateToDraftList)   // 뒤로가기가 자기 자신을 다시 연다
DiaryWriteScreen(onSubmitSuccess = actions::onWriteDiary)       // 제출 성공이 작성 화면을 또 연다

./gradlew :feature:mindrecord:presentation:compileDebugKotlin :app:compileDebugKotlin
BUILD SUCCESSFUL in 11s

이 상태에서도 위 두 테스트는 초록입니다 — actions.popBack() 만 부르므로 오염시킨 줄을 지나가지 않습니다. 즉 명령을 합치면서 새로 생긴 유일한 위험(«어느 화면이 어느 명령에 붙는가»)에 가드가 없습니다.

둘 중 하나로 정리해 주세요.

  1. 매핑을 태운다 — 화면의 뒤로 버튼을 실제로 누르는 형태. 다섯 화면이 hiltViewModel() 을 요구하므로 비용이 듭니다. 값이 있다고 보시면 이 쪽이 본문 주장과 맞습니다.
  2. 주장을 실체에 맞춘다 — 35행 KDoc 과 PR 본문(«다섯 매핑 중 하나라도 어긋나면 잡힌다»)을 «popBack() 이 한 칸 pop 한다는 계약을 실제 NavHost 에서 고정한다» 로 낮춥니다. 매핑 쪽은 컴파일도 테스트도 못 잡는다는 것을 KDoc 에 남겨 주시면 다음 사람이 가드가 있다고 믿지 않습니다.

제거 자체가 안전하다는 판단은 그대로입니다 — 다섯 본문이 같은 한 줄이었다는 건 develop 에서 확인했습니다. 막아야 할 것은 «지금 같음» 이 아니라 «앞으로 갈릴 때 어디서 갈리는가» 인데, PR 본문은 그 답을 NavGraph 라고 적어 두고 정작 NavGraph 를 검증하지 않습니다.

awaitRoute<Route.MindRecord>()
// 뒤로 온 뒤 백스택 위에 남은 것이 없어야 한다 — 남으면 다음 뒤로가기가 죽은 화면을 연다.
assertEquals(
"$destination 에서 돌아온 뒤 백스택에 잔재가 남았다",
null,
composeRule.runOnIdle { navController.previousBackStackEntry?.destination?.route },
)
}
}

@Test
fun popBack_popsExactlyOneEntryInsteadOfClearingTheGraph() {
// 이름을 실체에 맞춘다 — 이 테스트는 제출 성공 경로를 지나가지 않는다 (#1311 리뷰).
// 제출 성공도 같은 명령(`onSubmitSuccess = actions::popBack`)에 붙였으므로, 여기서 보는
// 것은 그 명령의 성질이다: «한 칸» 이 아니라 그래프째 비우면 홈까지 사라진다.
composeRule.runOnIdle { navController.navigate(MindRecordRoute.DiaryWriteRoute()) }
composeRule.waitForIdle()
composeRule.runOnIdle { navController.navigate(MindRecordRoute.DraftListRoute) }
composeRule.waitForIdle()

composeRule.runOnIdle { actions.popBack() }

// 임시저장 목록에서 한 칸 뒤로면 작성 화면이다 — 홈이 아니다.
awaitRoute<MindRecordRoute.DiaryWriteRoute>()
}

private inline fun <reified T : Any> awaitRoute() {
composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) {
navController.currentDestination?.hasRoute<T>() == true
}
}

private companion object {
const val TIMEOUT_MILLIS = 10_000L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,7 @@ fun rememberOnboardingNavActions(navController: NavController): OnboardingNavAct
fun rememberMindRecordNavActions(navController: NavController): MindRecordNavActions =
remember(navController) {
object : MindRecordNavActions {
override fun onMemorySpaceBack() {
navController.popBackStack()
}

override fun onReceiverMindRecordBack() {
override fun popBack() {
navController.popBackStack()
}

Expand All @@ -115,22 +111,10 @@ fun rememberMindRecordNavActions(navController: NavController): MindRecordNavAct
navController.navigate(MindRecordRoute.DiaryWriteRoute())
}

override fun onWriteBack() {
navController.popBackStack()
}

override fun onWriteSubmitSuccess() {
navController.popBackStack()
}

override fun onNavigateToDraftList() {
navController.navigate(MindRecordRoute.DraftListRoute)
}

override fun onDraftListBack() {
navController.popBackStack()
}

override fun onOpenRecordDetail(
recordId: Long,
isDiary: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,27 @@ package com.afternote.feature.mindrecord.presentation.navigation
* “actions 객체 단일 전달” 패턴이다.
*/
interface MindRecordNavActions {
fun onMemorySpaceBack()

/**
* 수신자 기록 화면에서 나간다.
* 한 칸 뒤로 — 화면별로 나뉘어 있던 단순 `popBackStack()` 5개를 하나로 합쳤다 (#1311).
*
* 종전에는 `onMemorySpaceBack`·`onReceiverMindRecordBack`·`onWriteBack`·
* `onWriteSubmitSuccess`·`onDraftListBack` 이 **본문이 전부 같은 한 줄**이었다. 그때의
* 근거는 「빌려 쓰면 한 화면만 다르게 바꾸려는 변경이 나머지까지 조용히 끌고 간다」였는데,
* **그 걱정은 이 계약이 아니라 NavGraph 가 막는다.**
*
* 지금 동작은 추억 공간과 같은 `popBackStack()` 이지만 이름을 빌리지 않는다 — 이
* 인터페이스는 화면별로 액션을 나눠 두는 곳이고, 빌려 쓰면 «추억 공간만 다르게
* 처리하자» 는 변경이 이 화면까지 조용히 끌고 간다.
* 화면의 이벤트 이름(`onBackClick`·`onSubmitSuccess`)은 그대로 남고, 그것을 어느 명령에
* 붙일지는 [mindRecordNavGraph] 가 화면마다 따로 정한다. 한 화면이 다른 뒤로가기를
* 갖게 되면 그 한 줄의 매핑만 새 명령으로 바꾸면 되고, 나머지는 이 명령에 그대로 남는다 —
* 분기점이 사라진 것이 아니라 **명령 쪽에서 매핑 쪽으로 옮겨졌다.**
*/
fun onReceiverMindRecordBack()
fun popBack()

fun onWriteDailyQuestion()

fun onWriteDiary()

fun onWriteBack()

fun onWriteSubmitSuccess()

fun onNavigateToDraftList()

fun onDraftListBack()

/** 목록 항목 탭 — 저장된 기록 본문을 여는 상세 화면 (#759). */
fun onOpenRecordDetail(
recordId: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,35 +47,41 @@ fun NavGraphBuilder.mindRecordNavGraph(actions: MindRecordNavActions) {
)
}
composable<Route.MemorySpace> {
MemorySpaceScreen(onBackClick = actions::onMemorySpaceBack)
MemorySpaceScreen(onBackClick = actions::popBack)
}
composable<Route.ReceiverMindRecord> {
// 앱바 뒤로가기를 실제로 붙인다 — 없으면 이 화면이 막다른 곳이 된다 (#614).
ReceiverMindRecordScreen(onBackClick = actions::onReceiverMindRecordBack)
ReceiverMindRecordScreen(onBackClick = actions::popBack)
}
composable<MindRecordRoute.DailyQuestionWriteRoute> {
DailyQuestionWriteScreen(
onSubmitSuccess = actions::onWriteSubmitSuccess,
onBackClick = actions::onWriteBack,
// 제출 성공도 뒤로가기와 같은 «한 칸 뒤로» 다. 화면 이벤트 이름은 그대로 두고
// 여기서 같은 명령에 붙인다 — 둘이 갈리면 이 두 줄만 달라진다 (#1311).
onSubmitSuccess = actions::popBack,
onBackClick = actions::popBack,
onDraftListClick = actions::onNavigateToDraftList,
)
}
composable<MindRecordRoute.DiaryWriteRoute> {
DiaryWriteScreen(
onSubmitSuccess = actions::onWriteSubmitSuccess,
onBackClick = actions::onWriteBack,
// 제출 성공도 뒤로가기와 같은 «한 칸 뒤로» 다. 화면 이벤트 이름은 그대로 두고
// 여기서 같은 명령에 붙인다 — 둘이 갈리면 이 두 줄만 달라진다 (#1311).
onSubmitSuccess = actions::popBack,
onBackClick = actions::popBack,
onDraftListClick = actions::onNavigateToDraftList,
)
}
composable<MindRecordRoute.RecordDetailRoute> { entry ->
RecordDetailScreen(
isDiary = entry.toRoute<MindRecordRoute.RecordDetailRoute>().isDiary,
onBackClick = actions::onWriteBack,
// develop 에서 들어온 이 화면(#759)도 «한 칸 뒤로» 다 — 걷어낸 onWriteBack 대신
// 같은 명령에 붙인다 (#1311).
onBackClick = actions::popBack,
)
}
composable<MindRecordRoute.DraftListRoute> {
DraftListScreen(
onBackClick = actions::onDraftListBack,
onBackClick = actions::popBack,
onDiaryDraftClick = actions::onEditDiaryDraft,
onDailyQuestionDraftClick = actions::onEditDailyQuestionDraft,
)
Expand Down
Loading