diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt index f69fa525a..35b9c47e5 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt @@ -4,6 +4,7 @@ import androidx.annotation.StringRes import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorState +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo // 에디터 조립부가 쓰는 순수 헬퍼들이다. Route 파일에는 조립만 남긴다 (#1514). @@ -95,8 +96,7 @@ internal fun buildOnRegisterClick( memorialMedia = SaveAfternoteMemorialMedia( memorialVideo = form.memorialVideo ?: EditableMemorialVideo.empty(), - memorialPhotoUrl = form.memorialPhotoUrl, - pickedMemorialPhotoUri = form.pickedMemorialPhotoUri, + memorialPhoto = form.memorialPhoto ?: EditableMemorialPhoto.empty(), ), ) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt index c29215e4f..9e10b8f1b 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt @@ -25,6 +25,7 @@ import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditor import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorErrorEvent import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorUiState import com.afternote.feature.afternote.presentation.editor.state.AfternoteTypeForm +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo import com.afternote.feature.afternote.presentation.editor.state.EditorFormState import com.afternote.feature.afternote.presentation.editor.state.withMemorialPhoto @@ -84,6 +85,7 @@ private data class ProcessingMethodSnap( /** * [SavedStateHandle]에 JSON으로 넣는 폼 스냅샷. 번들 전체 크기는 대략 500KB~1MB를 넘기지 않도록 설계해야 하며, * 그렇지 않으면 [android.os.TransactionTooLargeException]이 날 수 있다. 큰 Base64/data URL은 폼에 넣지 말고 URL·URI 문자열만 저장한다. + * 사진 값 객체는 기존 `pickedMemorialPhotoUri`·`memorialPhotoUrl` 두 키로 변환해 사진 필드의 JSON 호환성을 유지한다. * * **wire 형태는 키에 박힌 버전과 함께 움직인다.** [EditableMemorialVideo]가 sealed 로 바뀌면서(#1901) * `memorialVideo` 에 판별자가 붙었고(v5), 상태가 넷에서 셋으로 줄면서(#2114) 판별자 값이 @@ -129,9 +131,8 @@ private data class EditorFormSnapshot( AfternoteType.MEMORIAL -> { AfternoteTypeForm.Memorial( - pickedPhotoUri = pickedMemorialPhotoUri, + photo = EditableMemorialPhoto.fromSnapshot(memorialPhotoUrl, pickedMemorialPhotoUri), video = memorialVideo ?: EditableMemorialVideo.empty(), - photoUrl = memorialPhotoUrl, playlistSongs = memorialPlaylistSongs, ) } @@ -152,9 +153,9 @@ private data class EditorFormSnapshot( ReceiverSnap(id = it.id, name = it.name, label = it.label) }, processingMethods = form.processingMethods.map { ProcessingMethodSnap(it.localId, it.text) }, - pickedMemorialPhotoUri = form.pickedMemorialPhotoUri, + pickedMemorialPhotoUri = form.memorialPhoto?.toSnapshot()?.selection, memorialVideo = form.memorialVideo, - memorialPhotoUrl = form.memorialPhotoUrl, + memorialPhotoUrl = form.memorialPhoto?.toSnapshot()?.persisted, memorialPlaylistSongs = form.memorialPlaylistSongs, ) } @@ -543,17 +544,6 @@ class AfternoteEditorViewModel } } - // 영정 사진: 새로 고른 로컬 픽 우선 → 없으면 기존 원격 → 둘 다 없으면 없음. - private fun photoMediaInput( - picked: String?, - existing: String?, - ): MediaInput = - when { - !picked.isNullOrBlank() -> MediaInput.Local(picked) - !existing.isNullOrBlank() -> MediaInput.Remote(existing) - else -> MediaInput.None - } - private suspend fun buildSaveCommand( editingId: Long?, typeForSave: AfternoteType, @@ -566,11 +556,7 @@ class AfternoteEditorViewModel val resolved = resolveMemorialMediaForSave( video = memorialMedia.memorialVideo.toMediaInput(), - photo = - photoMediaInput( - picked = memorialMedia.pickedMemorialPhotoUri, - existing = memorialMedia.memorialPhotoUrl, - ), + photo = memorialMedia.memorialPhoto.toMediaInput(), ).getOrElse { return Result.failure(it) } val command = diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/SaveAfternoteMemorialMedia.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/SaveAfternoteMemorialMedia.kt index 8a7920d87..ca6ebec2d 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/SaveAfternoteMemorialMedia.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/SaveAfternoteMemorialMedia.kt @@ -1,12 +1,12 @@ package com.afternote.feature.afternote.presentation.editor +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo -/** 저장 시 추억 노트 미디어 필드. 영상의 출처와 기준값은 [memorialVideo]가 보존한다. */ +/** 저장 시 추억 노트 미디어 필드. 사진과 영상의 출처·기준값은 각 값 객체가 보존한다. */ internal data class SaveAfternoteMemorialMedia( val memorialVideo: EditableMemorialVideo = EditableMemorialVideo.empty(), - val memorialPhotoUrl: String? = null, - val pickedMemorialPhotoUri: String? = null, + val memorialPhoto: EditableMemorialPhoto = EditableMemorialPhoto.empty(), ) /** diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheet.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheet.kt index c4441cc35..8ef108dad 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheet.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheet.kt @@ -34,7 +34,7 @@ internal enum class MemorialMediaTarget { */ internal fun EditorFormState.removableMemorialMediaTargets(): Set = buildSet { - if (!displayMemorialPhotoUri().isNullOrBlank()) add(MemorialMediaTarget.PHOTO) + if (memorialPhoto?.canRemove == true) add(MemorialMediaTarget.PHOTO) if (canRemoveMemorialVideo) add(MemorialMediaTarget.VIDEO) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteTypeForm.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteTypeForm.kt index dd1a627f2..c16c890ad 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteTypeForm.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteTypeForm.kt @@ -81,18 +81,17 @@ sealed interface AfternoteTypeForm { * 카테고리 전용 필드. 영상이 서버에 있는지·새로 골라 올려야 하는지는 [EditableMemorialVideo]가 감춘다. * * 시트의 삭제는 슬롯을 비운다 — 교체분과 서버 값을 함께. 비어 있는 서버 값은 저장 시 PATCH `null` - * 로 이어진다(#1597). 사진은 `pickedPhotoUri`·`photoUrl` 두 칸이 같은 규칙을 따른다. + * 로 이어진다(#1597). 사진도 [EditableMemorialPhoto]가 같은 규칙을 닫는다. */ @ConsistentCopyVisibility data class Memorial internal constructor( - val pickedPhotoUri: String? = null, + internal val photo: EditableMemorialPhoto = EditableMemorialPhoto.empty(), internal val video: EditableMemorialVideo = EditableMemorialVideo.empty(), - val photoUrl: String? = null, val playlistSongs: List = emptyList(), ) : AfternoteTypeForm { override val type = AfternoteType.MEMORIAL - fun displayPhotoUri(): String? = pickedPhotoUri ?: photoUrl + internal fun displayPhotoUri(): String? = photo.displayed /** * 미디어는 수정 진입 기준선과 비교해야 서버 원본 삭제도 미저장 변경으로 잡힌다(#1597). 영상은 @@ -149,7 +148,7 @@ sealed interface AfternoteTypeForm { thumbnailUrl = content.thumbnailUrl, ), ), - photoUrl = content.photoUrl, + photo = EditableMemorialPhoto.fromPersisted(content.photoUrl), playlistSongs = content.playlistSongs, ) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialPhoto.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialPhoto.kt new file mode 100644 index 000000000..3349d3216 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialPhoto.kt @@ -0,0 +1,87 @@ +package com.afternote.feature.afternote.presentation.editor.state + +import com.afternote.feature.afternote.domain.repository.author.MediaInput + +/** + * 영정사진의 서버 기준값과 미저장 선택. 표시·삭제·업로드 출처를 한 경계에서 결정한다. + * 교체 전 서버 값은 이탈 가드와 기존 SavedStateHandle 스냅샷의 동일성을 위해 보존한다. + * 삭제는 [empty]로 두 층을 함께 비우며, 서버 값으로 되돌리는 동작은 없다. + */ +internal sealed class EditableMemorialPhoto private constructor() { + private data object Empty : EditableMemorialPhoto() + + private data class ServerOnly( + val persisted: String, + ) : EditableMemorialPhoto() + + private data class SelectionOnly( + val selection: String, + ) : EditableMemorialPhoto() + + private data class Replaced( + val persisted: String, + val selection: String, + ) : EditableMemorialPhoto() + + internal val displayed: String? + get() = + when (this) { + Empty -> null + is ServerOnly -> persisted + is SelectionOnly -> selection + is Replaced -> selection + } + + internal val canRemove: Boolean + get() = + when (this) { + Empty -> false + is ServerOnly -> persisted.isNotBlank() + is SelectionOnly -> selection.isNotBlank() + is Replaced -> selection.isNotBlank() + } + + internal fun withSelection(uri: String): EditableMemorialPhoto = + when (this) { + Empty -> SelectionOnly(uri) + is ServerOnly -> Replaced(persisted, uri) + is SelectionOnly -> SelectionOnly(uri) + is Replaced -> Replaced(persisted, uri) + } + + /** 기존 빈 문자열 처리도 유지하며 URL 형태로 출처를 재추론하지 않는다. */ + internal fun toMediaInput(): MediaInput = + when (this) { + Empty -> MediaInput.None + is ServerOnly -> persisted.asRemoteInput() + is SelectionOnly -> if (selection.isBlank()) MediaInput.None else MediaInput.Local(selection) + is Replaced -> if (selection.isBlank()) persisted.asRemoteInput() else MediaInput.Local(selection) + } + + /** 기존 JSON의 두 키를 유지하는 직렬화 경계. UI와 저장 명령은 이 표현을 사용하지 않는다. */ + internal fun toSnapshot(): Snapshot = + when (this) { + Empty -> Snapshot(null, null) + is ServerOnly -> Snapshot(persisted, null) + is SelectionOnly -> Snapshot(null, selection) + is Replaced -> Snapshot(persisted, selection) + } + + internal data class Snapshot( + val persisted: String?, + val selection: String?, + ) + + internal companion object { + internal fun empty(): EditableMemorialPhoto = Empty + + internal fun fromPersisted(uri: String?): EditableMemorialPhoto = if (uri == null) Empty else ServerOnly(uri) + + internal fun fromSnapshot( + persisted: String?, + selection: String?, + ): EditableMemorialPhoto = fromPersisted(persisted).let { photo -> selection?.let(photo::withSelection) ?: photo } + } +} + +private fun String.asRemoteInput(): MediaInput = if (isBlank()) MediaInput.None else MediaInput.Remote(this) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormMutations.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormMutations.kt index 0e7a2f31a..dae1c395d 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormMutations.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormMutations.kt @@ -31,15 +31,15 @@ internal fun EditorFormState.withType(type: AfternoteType): EditorFormState = internal fun EditorFormState.withService(service: String): EditorFormState = mapServiceForm { it.withService(service) } -/** 사진 선택은 `picked` 층에 실린다. 서버 사진은 `photoUrl` 에 그대로 남는다 — 저장 없이 나가면 그것이 사실이고, 이탈 가드가 기준선과 비교한다. */ -internal fun EditorFormState.withMemorialPhoto(uri: String): EditorFormState = mapMemorial { it.copy(pickedPhotoUri = uri) } +/** 사진 선택은 값 객체 안의 미저장 층을 교체하며, 서버 기준값은 이탈 가드 비교를 위해 유지한다. */ +internal fun EditorFormState.withMemorialPhoto(uri: String): EditorFormState = mapMemorial { it.copy(photo = it.photo.withSelection(uri)) } /** * 시트의 사진 삭제(#1114). 슬롯을 비운다 — 로컬 교체분과 서버 사진을 함께. 서버 삭제는 여기서 일어나지 * 않는다 — 두 칸이 빈 폼을 저장이 PATCH `null` 로 보낼 뿐이다(#1597). 저장 없이 나가면 서버 사진은 * 그대로다(이탈 가드). */ -internal fun EditorFormState.withMemorialPhotoRemoved(): EditorFormState = mapMemorial { it.copy(pickedPhotoUri = null, photoUrl = null) } +internal fun EditorFormState.withMemorialPhotoRemoved(): EditorFormState = mapMemorial { it.copy(photo = EditableMemorialPhoto.empty()) } /** * 영상 첨부는 통째로 갈린다 — 새 영상에는 썸네일이 아직 없고, 이전 영상의 썸네일을 물려주면 다른 diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormState.kt index c06199e32..089c400f9 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormState.kt @@ -27,7 +27,7 @@ data class EditorFormState( internal val memorialForm: AfternoteTypeForm.Memorial? get() = typeForm as? AfternoteTypeForm.Memorial - val pickedMemorialPhotoUri: String? get() = memorialForm?.pickedPhotoUri + internal val memorialPhoto: EditableMemorialPhoto? get() = memorialForm?.photo /** 서버 기준값과 이번 폼의 미저장 교체분을 함께 가진 영상 편집 상태. */ internal val memorialVideo: EditableMemorialVideo? get() = memorialForm?.video @@ -36,7 +36,6 @@ data class EditorFormState( /** 시트에 영상 삭제 항목을 내놓을지 — 표시된 층이 있으면 출처와 무관하게 지울 수 있다(#1597). */ internal val canRemoveMemorialVideo: Boolean get() = memorialVideo?.canRemove == true - val memorialPhotoUrl: String? get() = memorialForm?.photoUrl val memorialPlaylistSongs: List get() = memorialForm?.playlistSongs.orEmpty() fun displayMemorialPhotoUri(): String? = memorialForm?.displayPhotoUri() diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt index 30df4dad9..921280032 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt @@ -27,6 +27,7 @@ import com.afternote.feature.afternote.presentation.editor.SaveAfternoteMemorial import com.afternote.feature.afternote.presentation.editor.memorial.Song import com.afternote.feature.afternote.presentation.editor.message.EditorMessageTextBlock import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo import com.afternote.feature.afternote.presentation.editor.state.MemorialVideoAttachment import org.junit.Assert.assertEquals @@ -174,8 +175,10 @@ class AfternoteAuthorImplementedCoverageTest { .empty() .withSelection("content://videos/farewell") .withSelectionThumbnail("https://cdn.test/thumbnail.jpg"), - memorialPhotoUrl = "https://cdn.test/old-photo.jpg", - pickedMemorialPhotoUri = "content://photos/new-portrait", + memorialPhoto = + EditableMemorialPhoto + .fromPersisted("https://cdn.test/old-photo.jpg") + .withSelection("content://photos/new-portrait"), ), ) } @@ -249,7 +252,7 @@ class AfternoteAuthorImplementedCoverageTest { ), restored.displayedMemorialVideo, ) - assertEquals("content://photos/portrait", restored.pickedMemorialPhotoUri) + assertEquals("content://photos/portrait", restored.memorialPhoto?.toSnapshot()?.selection) assertEquals( listOf(Song("91", "첫 번째 노래", "가수 A", "https://cdn.test/cover.jpg")), restored.memorialPlaylistSongs, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt index b90599027..dc6cc7d25 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt @@ -5,6 +5,7 @@ import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository +import com.afternote.feature.afternote.domain.repository.author.MediaInput import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase @@ -18,6 +19,10 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -72,8 +77,20 @@ class AfternoteEditorMemorialMediaRemoveTest { viewModel.removeMemorialPhoto() - assertNull(viewModel.currentForm().pickedMemorialPhotoUri) - assertNull(viewModel.currentForm().memorialPhotoUrl) + assertNull( + viewModel + .currentForm() + .memorialPhoto + ?.toSnapshot() + ?.selection, + ) + assertNull( + viewModel + .currentForm() + .memorialPhoto + ?.toSnapshot() + ?.persisted, + ) assertNull(viewModel.currentForm().displayMemorialPhotoUri()) } @@ -109,8 +126,20 @@ class AfternoteEditorMemorialMediaRemoveTest { viewModel.removeMemorialPhoto() viewModel.removeMemorialVideo() - assertNull(viewModel.currentForm().pickedMemorialPhotoUri) - assertNull(viewModel.currentForm().memorialPhotoUrl) + assertNull( + viewModel + .currentForm() + .memorialPhoto + ?.toSnapshot() + ?.selection, + ) + assertNull( + viewModel + .currentForm() + .memorialPhoto + ?.toSnapshot() + ?.persisted, + ) assertNull(viewModel.currentForm().displayMemorialPhotoUri()) assertNull(viewModel.currentForm().displayedMemorialVideo?.url) assertNull(viewModel.currentForm().displayedMemorialVideo?.thumbnailUrl) @@ -134,12 +163,56 @@ class AfternoteEditorMemorialMediaRemoveTest { val restored = viewModel(handle).currentForm() assertNull(restored.displayedMemorialVideo) - assertNull(restored.pickedMemorialPhotoUri) - assertNull(restored.memorialPhotoUrl) + assertNull(restored.memorialPhoto?.toSnapshot()?.selection) + assertNull(restored.memorialPhoto?.toSnapshot()?.persisted) assertNull(restored.displayMemorialPhotoUri()) assertEquals(listOf("노래"), restored.memorialPlaylistSongs.map { it.title }) } + @Test + fun `빈 사진 상태를 기존 JSON 키로 저장하고 복원한다`() { + assertPhotoSnapshotRoundTrip(null, null, MediaInput.None) + } + + @Test + fun `서버 사진 상태를 기존 JSON 키로 저장하고 복원한다`() { + assertPhotoSnapshotRoundTrip("https://cdn.test/portrait.jpg", null, MediaInput.Remote("https://cdn.test/portrait.jpg")) + } + + @Test + fun `로컬 사진 상태를 기존 JSON 키로 저장하고 복원한다`() { + assertPhotoSnapshotRoundTrip(null, "content://photo", MediaInput.Local("content://photo")) + } + + @Test + fun `교체 중 사진의 서버 기준값과 로컬 선택을 함께 복원한다`() { + assertPhotoSnapshotRoundTrip("https://cdn.test/portrait.jpg", "content://photo", MediaInput.Local("content://photo")) + } + + private fun assertPhotoSnapshotRoundTrip( + persisted: String?, + selection: String?, + expectedInput: MediaInput, + ) { + val handle = memorialSavedStateHandle() + val original = viewModel(handle) + original.applyPrefill(memorialPrefill(photoUrl = persisted)) + if (selection != null) original.setMemorialPhoto(selection) + val encoded = requireNotNull(handle.get("editor_form_snapshot_v6")) + val fields = Json.parseToJsonElement(encoded).jsonObject + assertEquals(persisted?.let(::JsonPrimitive) ?: JsonNull, fields["memorialPhotoUrl"]) + assertEquals(selection?.let(::JsonPrimitive) ?: JsonNull, fields["pickedMemorialPhotoUri"]) + + // 같은 진입 경로의 새 SavedStateHandle로 실제 직렬화된 스냅샷만 복사한다. + val restored = viewModel(memorialSavedStateHandle().apply { set("editor_form_snapshot_v6", encoded) }) + val photo = requireNotNull(restored.currentForm().memorialPhoto) + assertEquals(original.currentForm(), restored.currentForm()) + assertEquals(selection ?: persisted, photo.displayed) + assertEquals(expectedInput, photo.toMediaInput()) + restored.removeMemorialPhoto() + assertEquals(MediaInput.None, requireNotNull(restored.currentForm().memorialPhoto).toMediaInput()) + } + private fun memorialSavedStateHandle(): SavedStateHandle = SavedStateHandle(mapOf("initialType" to AfternoteType.MEMORIAL)) private fun memorialPrefill( diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt index d33b57e7f..933070541 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt @@ -17,6 +17,7 @@ import com.afternote.feature.afternote.presentation.NoopAuthorErrorReporter import com.afternote.feature.afternote.presentation.afternoteAuthorUserReceiverRepository import com.afternote.feature.afternote.presentation.afternoteEditorSavedStateHandle import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo import com.afternote.feature.afternote.presentation.editor.state.EditorFormState import com.afternote.feature.afternote.presentation.editorFlowRoute @@ -165,20 +166,19 @@ class AfternoteEditorServerMediaDeleteSaveTest { private fun EditorFormState.fullMemorialMediaForSave(): SaveAfternoteMemorialMedia = SaveAfternoteMemorialMedia( memorialVideo = memorialVideo ?: EditableMemorialVideo.empty(), - memorialPhotoUrl = memorialPhotoUrl, - pickedMemorialPhotoUri = pickedMemorialPhotoUri, + memorialPhoto = memorialPhoto ?: EditableMemorialPhoto.empty(), ) private fun assertServerMediaAndSongs(form: EditorFormState) { - assertEquals("https://cdn.test/portrait.jpg", form.memorialPhotoUrl) + assertEquals("https://cdn.test/portrait.jpg", form.memorialPhoto?.toSnapshot()?.persisted) assertEquals("https://cdn.test/farewell.mp4", form.displayedMemorialVideo?.url) assertEquals("https://cdn.test/thumbnail.jpg", form.displayedMemorialVideo?.thumbnailUrl) assertEquals(listOf("배경음악"), form.memorialPlaylistSongs.map { it.title }) } private fun assertDeletedMediaAndSongs(form: EditorFormState) { - assertNull(form.pickedMemorialPhotoUri) - assertNull(form.memorialPhotoUrl) + assertNull(form.memorialPhoto?.toSnapshot()?.selection) + assertNull(form.memorialPhoto?.toSnapshot()?.persisted) assertNull(form.displayMemorialPhotoUri()) assertNull(form.displayedMemorialVideo?.url) assertNull(form.displayedMemorialVideo?.thumbnailUrl) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt index 10bd398cd..1e9f72024 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt @@ -5,6 +5,7 @@ import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.presentation.editor.receiver.AfternoteEditorReceiver import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorState import com.afternote.feature.afternote.presentation.editor.state.AfternoteTypeForm +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo import com.afternote.feature.afternote.presentation.editor.state.EditorFormState import com.afternote.feature.afternote.presentation.editor.state.MemorialVideoAttachment @@ -111,7 +112,9 @@ class EditorContentSignatureCompletenessTest { val before = editorContentSignature(EditorFormState(typeForm = AfternoteTypeForm.Memorial()), state) val after = editorContentSignature( - EditorFormState(typeForm = AfternoteTypeForm.Memorial(pickedPhotoUri = "content://photo")), + EditorFormState( + typeForm = AfternoteTypeForm.Memorial(photo = EditableMemorialPhoto.empty().withSelection("content://photo")), + ), state, ) assertNotEquals(before, after) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaRemovableTargetsTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaRemovableTargetsTest.kt index 93e8ae245..352eeb33a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaRemovableTargetsTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaRemovableTargetsTest.kt @@ -1,6 +1,7 @@ package com.afternote.feature.afternote.presentation.editor.memorial import com.afternote.feature.afternote.presentation.editor.state.AfternoteTypeForm +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo import com.afternote.feature.afternote.presentation.editor.state.EditorFormState import com.afternote.feature.afternote.presentation.editor.state.MemorialVideoAttachment @@ -23,8 +24,7 @@ class MemorialMediaRemovableTargetsTest { EditorFormState( typeForm = AfternoteTypeForm.Memorial( - pickedPhotoUri = pickedPhotoUri, - photoUrl = photoUrl, + photo = EditableMemorialPhoto.fromSnapshot(photoUrl, pickedPhotoUri), video = EditableMemorialVideo .fromServer(MemorialVideoAttachment.ofOrNull(persistedVideoUrl))