Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ internal sealed interface AfternoteEditorReducerEvent : ReducerEvent {
data class PrefillLoaded(
val prefill: EditorFormPrefill,
val baseline: AfternoteEditorSnapshot,
val preserveRestoredForm: Boolean,
) : AfternoteEditorReducerEvent

data object PrefillFailed : AfternoteEditorReducerEvent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import kotlinx.serialization.json.Json

private const val EDITOR_FORM_SNAPSHOT_KEY = "editor_form_snapshot_v6"
private const val INITIALIZED_ACTION_TEMPLATE_TYPE_KEY = "initialized_action_template_type"
private const val PREFILL_SEEDED_ITEM_ID_KEY = "editor_prefill_seeded_item_id"

private const val TAG = "AfternoteEditorViewModel"

Expand Down Expand Up @@ -193,6 +194,18 @@ internal class AfternoteEditorViewModel
encodeDefaults = true
}

private val restoredForm: RestoredForm = readFormSnapshotOrDefault()

/**
* 상세 prefill을 적용했던 폼이 성공적으로 복원된 경우 사용자의 미저장 입력을 보존한다.
* 표식만 남거나 디코딩이 실패했을 때는 서버 prefill을 받아 빈 폼으로 저장하는 일을 막는다.
* 계정 정보와 남기실 말씀은 화면이 복원하므로 폼 값 비교로 적용 여부를 대신하지 않는다.
*/
private val restoredFromSeededSnapshot: Boolean =
route.itemId != null &&
restoredForm.fromSnapshot &&
savedStateHandle.get<Long>(PREFILL_SEEDED_ITEM_ID_KEY) == route.itemId

val isEditing: Boolean get() = route.itemId != null

override fun onIntent(intent: AfternoteEditorIntent) {
Expand Down Expand Up @@ -255,6 +268,7 @@ internal class AfternoteEditorViewModel
}

is AfternoteEditorIntent.ApplyPrefill -> {
readEditItemId()?.let { savedStateHandle[PREFILL_SEEDED_ITEM_ID_KEY] = it }
dispatchForm(AfternoteEditorReducerEvent.PrefillApplied(intent.prefill))
}

Expand Down Expand Up @@ -462,7 +476,9 @@ internal class AfternoteEditorViewModel
is AfternoteEditorReducerEvent.PrefillLoaded -> {
state.copy(
originalType = event.prefill.type,
pendingPrefill = event.prefill,
pendingPrefill = if (event.preserveRestoredForm) null else event.prefill,
isPrefillLoading =
state.isPrefillLoading && !event.preserveRestoredForm,
updateBaseline = event.baseline,
)
}
Expand Down Expand Up @@ -503,7 +519,7 @@ internal class AfternoteEditorViewModel
init {
dispatch(
AfternoteEditorReducerEvent.Initialized(
readFormSnapshotOrDefault(),
restoredForm.form,
route.initialType.takeIf { route.itemId != null },
readEditItemId() != null,
),
Expand All @@ -513,16 +529,29 @@ internal class AfternoteEditorViewModel

private fun readEditItemId(): Long? = route.itemId

private fun readFormSnapshotOrDefault(): EditorFormState {
/** 빈 기본 폼과 성공적으로 복원된 폼을 구분한다. */
private fun readFormSnapshotOrDefault(): RestoredForm {
val defaultForm = EditorFormState().withType(route.initialType)
val raw = savedStateHandle.get<String>(EDITOR_FORM_SNAPSHOT_KEY) ?: return defaultForm
val raw =
savedStateHandle.get<String>(EDITOR_FORM_SNAPSHOT_KEY)
?: return RestoredForm(form = defaultForm, fromSnapshot = false)
return runCatching {
formSnapshotJson
.decodeFromString(EditorFormSnapshot.serializer(), raw)
.toEditorFormState()
}.getOrElse { defaultForm }
RestoredForm(
form =
formSnapshotJson
.decodeFromString(EditorFormSnapshot.serializer(), raw)
.toEditorFormState(),
fromSnapshot = true,
)
}.getOrElse { RestoredForm(form = defaultForm, fromSnapshot = false) }
}

/** [readFormSnapshotOrDefault]의 폼과 실제 복원 성공 여부. */
private data class RestoredForm(
val form: EditorFormState,
val fromSnapshot: Boolean,
)

/** [EditorFormSnapshot] 직렬화. 실패 시 무시한다(용량 초과 등은 [EditorFormSnapshot] KDoc 참고). */
private fun persistFormSnapshot(form: EditorFormState) {
runCatching {
Expand Down Expand Up @@ -814,10 +843,15 @@ internal class AfternoteEditorViewModel
// skeleton 종료는 UI 가 prefill 적용을 마친 뒤 [AfternoteEditorIntent.ConsumePrefill] 로 통보한다
// (uiState 갱신 시점에 prefill 도착했어도 UI 가 form·TextFieldState 에 반영하기 전이라
// 여기서 끄면 skeleton 사라짐 → 빈 폼 → prefill 깜빡임 발생).
//
// 복원된 편집이 있으면 프리필을 싣지 않는다 (#1732). 서버 조회는 유지한다.
// 기준 스냅샷([AfternoteEditorUiState.updateBaseline])이 없으면 저장이 막히고, 카테고리도
// 서버가 아는 값이어야 한다. 복원된 입력에 대한 덮어쓰기만 생략한다.
dispatch(
AfternoteEditorReducerEvent.PrefillLoaded(
prefill = prefill,
baseline = AfternoteEditorFormMapper.buildUpdateBaseline(detail),
preserveRestoredForm = restoredFromSeededSnapshot,
),
)
}.onFailure { e ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository
import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase
import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase
import com.afternote.feature.afternote.presentation.editor.model.EditorContentPrefill
import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload
import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
Expand All @@ -24,29 +25,16 @@ import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

/**
* **알려진 한계를 드러내는 가드** — 프로세스 사망 복원이 사용자의 미저장 편집을 서버 값으로 덮는다.
*
* 복원 경로는 두 갈래가 겹친다: `SavedStateHandle` 의 폼 스냅샷이 편집분을 되살리고, 곧이어
* `init` 의 상세 재조회가 `pendingPrefill` 을 다시 발행해 UI 가 폼을 서버 값으로 덮어쓴다.
* 이 테스트는 그 두 번째 발행이 **폼이 더러운지 묻지 않고** 일어난다는 사실을 고정한다.
*
* 이 축은 #1617 의 부분 PATCH 가 만든 것이 아니고 고치지도 않는다 — 스냅샷 복원과 프리필 재적용이
* 함께 있던 종전부터의 동작이다. 부분 PATCH 는 오히려 이 경로를 **덜 위험하게** 만든다: 덮인 폼은
* 서버 값과 같아져 「바뀐 것 없음」으로 아무 슬롯도 실리지 않고, 프리필이 도착하기 전 저장을
* 시도하면 기준이 없다는 이유로 [AfternoteEditorMissingBaselineTest] 의 가드가 막는다.
*
* 제대로 고치려면 「복원된 편집이 있으면 프리필을 덮어쓰지 않거나 사용자에게 고를 기회를 준다」는
* 화면 흐름 결정이 필요하다 — 편집 유실 안내·충돌 해소 UI 는 #1617 의 해야 할 일 2번(서버 거절 시
* 화면 동작)과 같은 결의 별도 축이라 그쪽에서 다룬다.
*/
/** 발행 수정 화면 복원 뒤 재조회가 미저장 입력을 보존하고 저장 기준은 갱신하는지 검증한다. */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
Expand All @@ -64,7 +52,7 @@ class AfternoteEditorProcessDeathPrefillTest {
}

@Test
fun `복원된 편집이 있어도 상세 재조회 프리필이 다시 발행된다`() =
fun `복원된 편집이 있으면 상세 재조회가 프리필을 발행하지 않는다`() =
runTest(dispatcher) {
val repository =
FakeAfternoteRepository.strict().apply {
Expand All @@ -74,45 +62,144 @@ class AfternoteEditorProcessDeathPrefillTest {
backgroundScope.launch { viewModel.uiState.collect {} }
runCurrent()

// 복원 직후의 폼은 사용자가 고친 제목을 들고 있다.
assertEquals(EDITED_SERVICE, viewModel.uiState.value.form.selectedService)
assertEquals(
"복원된 폼은 사용자가 고친 제목을 그대로 들고 있다",
EDITED_SERVICE,
viewModel.uiState.value.form.selectedService,
)
assertNull(
"재조회가 프리필을 발행하면 화면이 그것을 실어 사용자의 편집이 사라진다",
viewModel.uiState.value.pendingPrefill,
)
}

@Test
fun `프리필을 막아도 복원된 편집 그대로 저장이 나간다`() =
runTest(dispatcher) {
val repository =
FakeAfternoteRepository.strict().apply {
onGetDetail = { Result.success(serverDetail()) }
onUpdate = { id, _ -> Result.success(id) }
}
val viewModel = viewModel(repository)
backgroundScope.launch { viewModel.uiState.collect {} }
runCurrent()

assertFalse(
"프리필을 싣지 않았으니 skeleton 도 여기서 걷혀야 한다",
viewModel.uiState.value.isPrefillLoading,
)

// 화면이 복원된 폼 값을 그대로 담아 보내는 저장이다.
viewModel.onIntent(
AfternoteEditorIntent.Save(
payload =
RegisterAfternotePayload(
serviceName = EDITED_SERVICE,
date = "2026-08-30",
processingMethods = listOf(SERVER_PROCESSING_METHOD),
),
selectedReceiverIds = emptyList(),
memorialMedia = SaveAfternoteMemorialMedia(),
),
)
runCurrent()

val updated = repository.updateCalls.single().second
assertEquals("사용자가 고친 제목이 그대로 나간다", EDITED_SERVICE, updated.title)
assertNull(
"복원된 폼이 서버 값을 그대로 들고 있으므로 안 건드린 필드는 실리지 않는다 (#1617)",
updated.processingMethods,
)
}

@Test
fun `프리필이 실린 적 없는 복원은 여전히 프리필을 받는다`() =
runTest(dispatcher) {
val repository =
FakeAfternoteRepository.strict().apply {
onGetDetail = { Result.success(serverDetail()) }
}
val viewModel = viewModel(repository, prefillSeeded = false)
backgroundScope.launch { viewModel.uiState.collect {} }
runCurrent()

val prefill = viewModel.uiState.value.pendingPrefill
assertNotNull("서버 값을 본 적 없는 폼은 프리필로 채워야 한다", prefill)
assertEquals(
SERVER_SERVICE,
(prefill?.content as EditorContentPrefill.Gallery).serviceName,
)
}

@Test
fun `스냅샷이 깨져 빈 폼으로 떨어진 복원은 프리필을 받는다`() =
runTest(dispatcher) {
val repository =
FakeAfternoteRepository.strict().apply {
onGetDetail = { Result.success(serverDetail()) }
}
val viewModel = viewModel(repository, snapshot = "{ 이건 EditorFormSnapshot 이 아니다 }")
backgroundScope.launch { viewModel.uiState.collect {} }
runCurrent()

val prefill = viewModel.uiState.value.pendingPrefill
assertNotNull("상세 재조회는 폼이 더러운지 묻지 않고 프리필을 발행한다", prefill)
assertNotNull("빈 폼으로 떨어졌으면 프리필을 막으면 안 된다", prefill)
assertEquals(
"그 프리필은 서버 값이라, UI 가 적용하면 사용자의 편집이 사라진다",
SERVER_SERVICE,
(prefill?.content as EditorContentPrefill.Gallery).serviceName,
)
}

@Test
fun `표식만 남고 폼 스냅샷이 없으면 프리필을 받는다`() =
runTest(dispatcher) {
val repository =
FakeAfternoteRepository.strict().apply {
onGetDetail = { Result.success(serverDetail()) }
}
val viewModel = viewModel(repository, snapshot = null)
backgroundScope.launch { viewModel.uiState.collect {} }
runCurrent()

val prefill = viewModel.uiState.value.pendingPrefill
assertNotNull("실제 폼 복원이 없으면 서버 값을 받아야 한다", prefill)
assertEquals(SERVER_SERVICE, (prefill?.content as EditorContentPrefill.Gallery).serviceName)
}

private fun serverDetail() =
Detail(
id = EDIT_ID,
serviceName = SERVER_SERVICE,
timestamps = DetailTimestamps(updatedAt = "2026-08-30"),
receivers = emptyList(),
leaveMessageBlocks = emptyList(),
content = DetailContent.Gallery(processingMethods = listOf("파일 전달")),
content = DetailContent.Gallery(processingMethods = listOf(SERVER_PROCESSING_METHOD)),
)

/** 프로세스 사망 전에 저장돼 있던 폼 스냅샷 — 사용자가 제목을 고쳐 둔 상태다. */
/** 서버 prefill 적용 뒤 제목만 고친 폼. */
private fun restoredSnapshot(): String =
"""
{"type":"GALLERY_AND_FILES","selectedService":"$EDITED_SERVICE","receivers":[],
"processingMethods":[],"memorialPlaylistSongs":[]}
"processingMethods":[{"localId":0,"text":"$SERVER_PROCESSING_METHOD"}],"memorialPlaylistSongs":[]}
""".trimIndent().replace("\n", "")

private fun viewModel(afternoteRepository: FakeAfternoteRepository): AfternoteEditorViewModel =
private fun viewModel(
afternoteRepository: FakeAfternoteRepository,
prefillSeeded: Boolean = true,
snapshot: String? = restoredSnapshot(),
): AfternoteEditorViewModel =
AfternoteEditorViewModel(
route = AfternoteRoute.EditorFlowRoute(itemId = EDIT_ID, initialType = AfternoteType.GALLERY_AND_FILES),
savedStateHandle =
SavedStateHandle(
mapOf(
"initialType" to AfternoteType.GALLERY_AND_FILES,
"itemId" to EDIT_ID,
"editor_form_snapshot_v6" to restoredSnapshot(),
),
buildMap {
put("initialType", AfternoteType.GALLERY_AND_FILES)
put("itemId", EDIT_ID)
if (snapshot != null) put("editor_form_snapshot_v6", snapshot)
// 화면이 프리필을 폼에 실을 때 ViewModel 이 같은 번들에 남기는 표식.
if (prefillSeeded) put("editor_prefill_seeded_item_id", EDIT_ID)
},
),
userReceiverRepository = FakeUserReceiverRepository.strict(),
afternoteRepository = afternoteRepository,
Expand All @@ -137,5 +224,6 @@ class AfternoteEditorProcessDeathPrefillTest {
const val EDIT_ID = 73L
const val EDITED_SERVICE = "사용자가 고친 제목"
const val SERVER_SERVICE = "구글 포토"
const val SERVER_PROCESSING_METHOD = "파일 전달"
}
}
Loading