From 4f2deb5a5ad1dfdf6e5112ab23c174ed2636a20c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 11 Sep 2026 16:51:53 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor(afternote):=20SaveAfternoteUseCase?= =?UTF-8?q?=20=EB=A1=9C=20=EC=A0=80=EC=9E=A5=20=EB=AA=85=EB=A0=B9=20?= =?UTF-8?q?=ED=95=B4=EC=84=9D=20=EA=B2=BD=EA=B3=84=EB=A5=BC=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=ED=95=9C=EB=8B=A4=20(#1694)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/editor/SaveAfternoteUseCase.kt | 48 +++++ .../editor/SaveAfternoteUseCaseTest.kt | 182 ++++++++++++++++++ .../editor/AfternoteEditorViewModel.kt | 28 +-- .../AfternoteAuthorExtendedTest.kt | 2 + .../AfternoteAuthorImplementedCoverageTest.kt | 2 + .../presentation/AfternoteAuthorTest.kt | 2 + .../AfternoteEditorMemorialMediaRemoveTest.kt | 2 + .../AfternoteEditorMissingBaselineTest.kt | 2 + ...ternoteEditorMultiReceiverSelectionTest.kt | 2 + .../AfternoteEditorPrefillFailureTest.kt | 3 + .../AfternoteEditorProcessDeathPrefillTest.kt | 2 + ...rnoteEditorProcessingMethodDefaultsTest.kt | 2 + ...noteEditorReceiverSelectionRecoveryTest.kt | 2 + .../AfternoteEditorReceiverSnapshotTest.kt | 2 + ...fternoteEditorServerMediaDeleteSaveTest.kt | 2 + .../AfternoteEditorThumbnailRetryTest.kt | 2 + .../AfternoteEditorValidationEventTest.kt | 2 + 17 files changed, 263 insertions(+), 24 deletions(-) create mode 100644 feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCase.kt create mode 100644 feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCase.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCase.kt new file mode 100644 index 0000000000..c937b3a6ba --- /dev/null +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCase.kt @@ -0,0 +1,48 @@ +package com.afternote.feature.afternote.domain.usecase.editor + +import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput +import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand +import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository +import javax.inject.Inject + +/** + * 완성된 [SaveAfternoteCommand] 를 해석하는 **단일 경계**. + * + * 도입 근거는 「비즈니스 로직이 많다」가 아니라 *해석의 소재지* 다. [SaveAfternoteCommand] 는 도메인이 + * 소유한 닫힌(sealed) 명령이고, 그 5갈래(Create 4종 · Update)를 [AfternoteRepository] 의 어느 메서드로 + * 보낼지는 명령 정의와 짝을 이루는 도메인 지식이다. 명령이 도메인에 있는 한 해석도 도메인에 있어야 + * 명령에 갈래가 늘 때 고칠 곳이 한 곳으로 닫힌다 — 화면이 몇 개든, 호출부가 지금 하나든 상관없다. + * + * 과거 `#246` 은 이 UseCase 를 «Repository 프록시» 로 보고 제거했지만, 같은 `when` 은 사라지지 않고 + * `AfternoteEditorViewModel` 로 옮겨갔을 뿐이었다. 즉 제거된 것은 중복이 아니라 경계였다 (`#1694`). + * + * 여기서 하지 않는 일 — 입력 검증, UI 폼 매핑, 미디어 입력 선택, 저장 상태·문구·네비게이션. + * 그 책임들은 명령을 *만드는* 쪽(presentation)에 그대로 남는다. + */ +class SaveAfternoteUseCase + @Inject + constructor( + private val afternoteRepository: AfternoteRepository, + ) { + /** + * Repository 의 [Result] 를 감싸거나 다시 던지지 않고 그대로 돌려준다 — 저장 실패의 해석은 + * 호출부(에러 문구·재시도)의 몫이고, `runCatching` 을 두르지 않아 코루틴 취소도 삼키지 않는다. + * + * @return 생성·수정된 애프터노트 id. + */ + suspend operator fun invoke(command: SaveAfternoteCommand): Result = + when (command) { + is SaveAfternoteCommand.Create -> { + when (val input = command.input) { + is CreateAfternoteInput.Social -> afternoteRepository.createSocial(input.payload) + is CreateAfternoteInput.Business -> afternoteRepository.createBusiness(input.payload) + is CreateAfternoteInput.Gallery -> afternoteRepository.createGallery(input.payload) + is CreateAfternoteInput.Memorial -> afternoteRepository.createMemorial(input.payload) + } + } + + is SaveAfternoteCommand.Update -> { + afternoteRepository.update(command.id, command.payload) + } + } + } diff --git a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt new file mode 100644 index 0000000000..88bf8c8c20 --- /dev/null +++ b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt @@ -0,0 +1,182 @@ +package com.afternote.feature.afternote.domain.usecase.editor + +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload +import com.afternote.feature.afternote.domain.model.author.CreateAccountPayload +import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput +import com.afternote.feature.afternote.domain.model.author.CreateGalleryPayload +import com.afternote.feature.afternote.domain.model.author.CreateMemorialPayload +import com.afternote.feature.afternote.domain.model.author.FieldPatch +import com.afternote.feature.afternote.domain.model.author.MemorialPatchPayload +import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload +import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [SaveAfternoteUseCase] 가 소유하는 것 — [SaveAfternoteCommand] 5갈래의 해석 경계. + * + * 고정하는 계약: + * 1. Create 4종(Social·Business·Gallery·Memorial)이 각각 *정확히 하나의* Repository 메서드로만 간다. + * (기록 목록을 전부 세어, 목표 외 메서드가 0회임을 같이 단언한다 — 라우팅이 옆칸으로 새는 회귀를 잡는다) + * 2. payload 를 가공하지 않고 그대로 넘긴다. Update 는 id 와 payload 를 둘 다 바꾸지 않는다. + * 3. Repository 의 성공·실패 [Result] 를 감싸지 않고 그대로 돌려준다. + * 4. `runCatching` 을 두르지 않아 코루틴 취소를 [Result.failure] 로 삼키지 않는다. + */ +class SaveAfternoteUseCaseTest { + private val accountPayload = + CreateAccountPayload( + title = "인스타그램", + processingMethods = listOf("계정 삭제"), + ) + + private val galleryPayload = + CreateGalleryPayload( + title = "사진첩", + processingMethods = listOf("전달"), + ) + + private val memorialPayload = + CreateMemorialPayload( + title = "장례식", + memorial = + MemorialWritePayload( + memorialPhotoUrl = null, + songs = emptyList(), + memorialVideo = null, + ), + ) + + @Test + fun `Social 생성은 createSocial 만 호출하고 payload 를 그대로 넘긴다`() { + val repository = FakeAfternoteRepository() + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Social(accountPayload))) + + assertTrue(result.isSuccess) + assertEquals(listOf(accountPayload), repository.socialPayloads) + repository.assertOnlyCalled(SOCIAL) + } + + @Test + fun `Business 생성은 createBusiness 만 호출한다 - 같은 스키마의 createSocial 로 새지 않는다`() { + val repository = FakeAfternoteRepository() + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Business(accountPayload))) + + assertTrue(result.isSuccess) + assertEquals(listOf(accountPayload), repository.businessPayloads) + repository.assertOnlyCalled(BUSINESS) + } + + @Test + fun `Gallery 생성은 createGallery 만 호출한다`() { + val repository = FakeAfternoteRepository() + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Gallery(galleryPayload))) + + assertTrue(result.isSuccess) + assertEquals(listOf(galleryPayload), repository.galleryPayloads) + repository.assertOnlyCalled(GALLERY) + } + + @Test + fun `Memorial 생성은 createMemorial 만 호출한다`() { + val repository = FakeAfternoteRepository() + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Memorial(memorialPayload))) + + assertTrue(result.isSuccess) + assertEquals(listOf(memorialPayload), repository.memorialPayloads) + repository.assertOnlyCalled(MEMORIAL) + } + + @Test + fun `Update 는 id 와 payload 를 바꾸지 않고 update 로만 보낸다`() { + val repository = FakeAfternoteRepository() + val payload = + AfternoteUpdatePayload( + type = AfternoteType.MEMORIAL, + title = "바뀐 제목", + memorial = + MemorialPatchPayload( + memorialPhotoUrl = FieldPatch.Unchanged, + songs = emptyList(), + memorialVideo = FieldPatch.Set(null), + ), + isDraft = false, + ) + + // 기본 update 는 대상이 없으면 실패하므로 성공 Result 를 열어 라우팅만 본다. + repository.onUpdate = { _, _ -> Result.success(42L) } + val result = repository.save(SaveAfternoteCommand.Update(id = 42L, payload = payload)) + + assertEquals(42L, result.getOrNull()) + assertEquals(listOf(42L to payload), repository.updateCalls) + assertSame(payload, repository.updateCalls.single().second) + repository.assertOnlyCalled(UPDATE) + } + + @Test + fun `Repository 성공 id 를 그대로 돌려준다`() { + val repository = FakeAfternoteRepository().apply { onCreateGallery = { Result.success(7L) } } + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Gallery(galleryPayload))) + + assertEquals(7L, result.getOrNull()) + } + + @Test + fun `Repository 실패는 감싸지 않고 같은 예외로 돌려준다`() { + val failure = IllegalStateException("저장 실패") + val repository = FakeAfternoteRepository().apply { onCreateSocial = { Result.failure(failure) } } + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Social(accountPayload))) + + assertTrue(result.isFailure) + assertSame(failure, result.exceptionOrNull()) + } + + @Test + fun `취소는 실패 Result 로 삼키지 않고 그대로 전파한다`() { + val repository = + FakeAfternoteRepository().apply { + onCreateMemorial = { throw CancellationException("저장 중 화면 이탈") } + } + + // 삼켰다면 Result.failure 로 정상 반환돼 assertThrows 가 실패한다. + assertThrows(CancellationException::class.java) { + repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Memorial(memorialPayload))) + } + } + + private fun FakeAfternoteRepository.save(command: SaveAfternoteCommand): Result = + runBlocking { SaveAfternoteUseCase(this@save)(command) } + + /** [only] 로 지목한 저장 메서드만 1회 호출되고 나머지 4개는 0회임을 단언한다. */ + private fun FakeAfternoteRepository.assertOnlyCalled(only: String) { + val calls = + mapOf( + SOCIAL to socialPayloads.size, + BUSINESS to businessPayloads.size, + GALLERY to galleryPayloads.size, + MEMORIAL to memorialPayloads.size, + UPDATE to updateCalls.size, + ) + assertEquals(calls.keys.associateWith { if (it == only) 1 else 0 }, calls) + } + + private companion object { + const val SOCIAL = "createSocial" + const val BUSINESS = "createBusiness" + const val GALLERY = "createGallery" + const val MEMORIAL = "createMemorial" + const val UPDATE = "update" + } +} 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 a346a36909..4a957b5427 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 @@ -9,12 +9,12 @@ import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.error.AfternoteFailure -import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand 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.MemorialThumbnailUploadRepository 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.mapper.toAfternoteEditorReceivers import com.afternote.feature.afternote.presentation.editor.memorial.Song import com.afternote.feature.afternote.presentation.editor.model.EditorFormPrefill @@ -175,6 +175,8 @@ class AfternoteEditorViewModel private val afternoteRepository: AfternoteRepository, private val memorialThumbnailUploadRepository: MemorialThumbnailUploadRepository, private val resolveMemorialMediaForSave: ResolveMemorialMediaForSaveUseCase, + // 이 클래스에 동명의 `saveAfternote(...)` 진입점이 이미 있어 `UseCase` 접미사를 남긴다. + private val saveAfternoteUseCase: SaveAfternoteUseCase, private val errorReporter: ErrorReporter, ) : ViewModel() { /** 진행 중인 prefill 조회 — 재시도가 이전 조회를 자르기 위한 핸들. */ @@ -579,7 +581,7 @@ class AfternoteEditorViewModel updateBaseline = updateBaseline, ).fold( onSuccess = { command -> - executeSaveCommand(command).fold( + saveAfternoteUseCase(command).fold( onSuccess = { id -> internalState.update { it.copy( @@ -597,28 +599,6 @@ class AfternoteEditorViewModel } } - /** - * [SaveAfternoteCommand] 분기에 따라 [AfternoteRepository] 의 적합한 메서드를 직접 호출한다. - * - * 과거에는 별도 `SaveAfternoteUseCase` 로 분리돼 있었으나, 단일 Repository 내 메서드 라우팅 - * 외에 비즈니스 로직이 없어 *약한 UseCase* (`#246`) 로 판단해 ViewModel 로 흡수. - */ - private suspend fun executeSaveCommand(command: SaveAfternoteCommand): Result = - when (command) { - is SaveAfternoteCommand.Create -> { - when (val input = command.input) { - is CreateAfternoteInput.Social -> afternoteRepository.createSocial(input.payload) - is CreateAfternoteInput.Business -> afternoteRepository.createBusiness(input.payload) - is CreateAfternoteInput.Gallery -> afternoteRepository.createGallery(input.payload) - is CreateAfternoteInput.Memorial -> afternoteRepository.createMemorial(input.payload) - } - } - - is SaveAfternoteCommand.Update -> { - afternoteRepository.update(command.id, command.payload) - } - } - // 영정 사진: 새로 고른 로컬 픽 우선 → 없으면 기존 원격 → 둘 다 없으면 없음. private fun photoMediaInput( picked: String?, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt index adf82e019a..103020f356 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt @@ -45,6 +45,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository 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.R import com.afternote.feature.afternote.presentation.detail.AfternoteDetailDeleteResult import com.afternote.feature.afternote.presentation.detail.AfternoteDetailUiState @@ -547,6 +548,7 @@ private fun editorViewModel( ) }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = NoopAuthorErrorReporter, ) 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 446d5776bc..30df4dad97 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 @@ -21,6 +21,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository 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.AfternoteEditorViewModel import com.afternote.feature.afternote.presentation.editor.SaveAfternoteMemorialMedia import com.afternote.feature.afternote.presentation.editor.memorial.Song @@ -317,5 +318,6 @@ private fun implementedCoverageViewModel( ) }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = NoopAuthorErrorReporter, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt index bb19c9fe64..4c49b8b7c5 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt @@ -13,6 +13,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository 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.AfternoteEditorViewModel import com.afternote.feature.afternote.presentation.editor.SaveAfternoteMemorialMedia import com.afternote.feature.afternote.presentation.editor.message.EditorMessageTextBlock @@ -166,6 +167,7 @@ class AfternoteAuthorTest { ) }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = NoopAuthorErrorReporter, ) } 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 1638219098..b90599027b 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 @@ -8,6 +8,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit 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 +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.memorial.Song import com.afternote.feature.afternote.presentation.editor.model.EditorContentPrefill import com.afternote.feature.afternote.presentation.editor.model.EditorFormPrefill @@ -171,6 +172,7 @@ class AfternoteEditorMemorialMediaRemoveTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 업로드가 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = repositoryProxy(), ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt index 8e25f01c09..5d3e9809cd 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt @@ -11,6 +11,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository 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.R import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError @@ -151,6 +152,7 @@ class AfternoteEditorMissingBaselineTest { ), userReceiverRepository = FakeUserReceiverRepository.strict(), afternoteRepository = afternoteRepository, + saveAfternoteUseCase = SaveAfternoteUseCase(afternoteRepository), memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, resolveMemorialMediaForSave = diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt index 4417ba2768..ae169570ee 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt @@ -9,6 +9,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit 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 +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import kotlinx.coroutines.Dispatchers @@ -176,6 +177,7 @@ class AfternoteEditorMultiReceiverSelectionTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 저장이 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(unusedProxy()), errorReporter = NoopErrorReporter, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt index 3c294434b3..511229516f 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt @@ -14,6 +14,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository 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.R import com.afternote.feature.afternote.presentation.afternoteEditorSavedStateHandle import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload @@ -254,6 +255,7 @@ class AfternoteEditorPrefillFailureTest { if (input == MediaInput.None) Result.success(null) else error("미디어 업로드가 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = RecordingErrorReporter(), ) backgroundScope.launch { viewModel.uiState.collect {} } @@ -308,6 +310,7 @@ class AfternoteEditorPrefillFailureTest { } }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = errorReporter, ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt index e52cb182d7..483a752428 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -11,6 +11,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository 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 @@ -223,6 +224,7 @@ class AfternoteEditorProcessDeathPrefillTest { ), userReceiverRepository = FakeUserReceiverRepository.strict(), afternoteRepository = afternoteRepository, + saveAfternoteUseCase = SaveAfternoteUseCase(afternoteRepository), memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, resolveMemorialMediaForSave = diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt index 938f3d39f1..0aed91e3df 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt @@ -9,6 +9,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit 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 +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.editorFlowRoute import kotlinx.coroutines.Dispatchers @@ -102,6 +103,7 @@ class AfternoteEditorProcessingMethodDefaultsTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 업로드가 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = repositoryProxy(), ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt index 80ef17118c..e9b0b78d75 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt @@ -9,6 +9,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit 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 +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute @@ -126,6 +127,7 @@ class AfternoteEditorReceiverSelectionRecoveryTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 저장이 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(unusedProxy()), errorReporter = NoopErrorReporter, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt index de27b758c6..878a3a96cb 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt @@ -9,6 +9,7 @@ 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 +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.state.MemorialVideoAttachment import com.afternote.feature.afternote.presentation.editorFlowRoute import org.junit.Assert.assertEquals @@ -114,6 +115,7 @@ class AfternoteEditorReceiverSnapshotTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 저장이 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = repositoryProxy(), ) 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 7a7bc2adc0..d33b57e7f4 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 @@ -12,6 +12,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository 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.NoopAuthorErrorReporter import com.afternote.feature.afternote.presentation.afternoteAuthorUserReceiverRepository import com.afternote.feature.afternote.presentation.afternoteEditorSavedStateHandle @@ -207,6 +208,7 @@ class AfternoteEditorServerMediaDeleteSaveTest { } }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = NoopAuthorErrorReporter, ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt index 1ec547faad..c9aa9d5fa2 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt @@ -8,6 +8,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit 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 +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import kotlinx.coroutines.Dispatchers @@ -160,6 +161,7 @@ class AfternoteEditorThumbnailRetryTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 업로드가 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = RecordingErrorReporter(), ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt index a43a15458d..7057dd9a4a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt @@ -8,6 +8,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit 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 +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.editor.state.AfternoteValidationError @@ -113,6 +114,7 @@ class AfternoteEditorValidationEventTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 저장이 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = repositoryProxy(), ) From 2e26021e31efdcf50b4b56969aaddda0ac522eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 11 Sep 2026 16:52:19 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor(afternote):=20=EC=98=81=EC=A0=95?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=ED=8E=B8=EC=A7=91=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EC=99=80=20=EC=A0=80=EC=9E=A5=20=EC=B6=9C=EC=B2=98=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=ED=95=9C=EB=8B=A4=20(#1902)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/AfternoteEditorRouteHelpers.kt | 4 +- .../editor/AfternoteEditorViewModel.kt | 26 ++---- .../editor/SaveAfternoteMemorialMedia.kt | 6 +- .../MemorialMediaSourceBottomSheet.kt | 2 +- .../editor/state/AfternoteTypeForm.kt | 9 +- .../editor/state/EditableMemorialPhoto.kt | 87 +++++++++++++++++++ .../editor/state/EditorFormMutations.kt | 6 +- .../editor/state/EditorFormState.kt | 3 +- .../AfternoteAuthorImplementedCoverageTest.kt | 9 +- .../AfternoteEditorMemorialMediaRemoveTest.kt | 85 ++++++++++++++++-- ...fternoteEditorServerMediaDeleteSaveTest.kt | 10 +-- .../EditorContentSignatureCompletenessTest.kt | 5 +- .../MemorialMediaRemovableTargetsTest.kt | 4 +- 13 files changed, 203 insertions(+), 53 deletions(-) create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialPhoto.kt 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 f69fa525ac..35b9c47e50 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 4a957b5427..2bb8cbf657 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 @@ -85,6 +86,7 @@ private data class ProcessingMethodSnap( /** * [SavedStateHandle]에 JSON으로 넣는 폼 스냅샷. 번들 전체 크기는 대략 500KB~1MB를 넘기지 않도록 설계해야 하며, * 그렇지 않으면 [android.os.TransactionTooLargeException]이 날 수 있다. 큰 Base64/data URL은 폼에 넣지 말고 URL·URI 문자열만 저장한다. + * 사진 값 객체는 기존 `pickedMemorialPhotoUri`·`memorialPhotoUrl` 두 키로 변환하므로 v4 JSON 호환성을 유지한다. */ @Serializable private data class EditorFormSnapshot( @@ -124,9 +126,8 @@ private data class EditorFormSnapshot( AfternoteType.MEMORIAL -> { AfternoteTypeForm.Memorial( - pickedPhotoUri = pickedMemorialPhotoUri, + photo = EditableMemorialPhoto.fromSnapshot(memorialPhotoUrl, pickedMemorialPhotoUri), video = memorialVideo ?: EditableMemorialVideo.empty(), - photoUrl = memorialPhotoUrl, playlistSongs = memorialPlaylistSongs, ) } @@ -147,9 +148,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, ) } @@ -599,17 +600,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, @@ -622,11 +612,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 8a7920d876..ca6ebec2d0 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 c4441cc354..8ef108dadf 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 bab21a382e..9efa4ea0d1 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 0000000000..3349d3216c --- /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 21ac64356f..bab54b1029 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 c06199e32a..089c400f95 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 30df4dad97..9212800326 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 b90599027b..61ac63cd04 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_v4")) + 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_v4", 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 d33b57e7f4..9330705413 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 8087255ea7..20af2f2313 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 6e49804e42..c3733a276b 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 .fromPersisted(MemorialVideoAttachment.ofOrNull(persistedVideoUrl)) From 1a4f91b7dd46e790dd12c8e995e0ed719fe19bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 20 Sep 2026 20:43:21 +0900 Subject: [PATCH 3/4] test(afternote): round-trip photos through current snapshot key (#1902) --- .../editor/AfternoteEditorMemorialMediaRemoveTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 61ac63cd04..580cff63e5 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 @@ -198,13 +198,13 @@ class AfternoteEditorMemorialMediaRemoveTest { val original = viewModel(handle) original.applyPrefill(memorialPrefill(photoUrl = persisted)) if (selection != null) original.setMemorialPhoto(selection) - val encoded = requireNotNull(handle.get("editor_form_snapshot_v4")) + val encoded = requireNotNull(handle.get("editor_form_snapshot_v5")) 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_v4", encoded) }) + val restored = viewModel(memorialSavedStateHandle().apply { set("editor_form_snapshot_v5", encoded) }) val photo = requireNotNull(restored.currentForm().memorialPhoto) assertEquals(original.currentForm(), restored.currentForm()) assertEquals(selection ?: persisted, photo.displayed) From a843f085de38e2cf7f5152cf3d1161ff1a1f95dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 13:40:58 +0900 Subject: [PATCH 4/4] Update photo restoration regression to snapshot v6 --- .../editor/AfternoteEditorMemorialMediaRemoveTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 580cff63e5..dc6cc7d25f 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 @@ -198,13 +198,13 @@ class AfternoteEditorMemorialMediaRemoveTest { val original = viewModel(handle) original.applyPrefill(memorialPrefill(photoUrl = persisted)) if (selection != null) original.setMemorialPhoto(selection) - val encoded = requireNotNull(handle.get("editor_form_snapshot_v5")) + 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_v5", encoded) }) + 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)