From d90501c8e46480703cc9a3b1a5e66fe732353c17 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:13:39 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=EB=B3=B4=EA=B4=80=ED=95=A8=20?= =?UTF-8?q?=EC=9B=94=EB=B3=84=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EA=B0=90?= =?UTF-8?q?=EC=A0=95=EB=B3=84=20=EC=B9=B4=EB=93=9C=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=94=EA=BF=89=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 월별 조회에 카드 식별자가 없어 날짜와 그날 순번을 카드의 신원으로 쓰고 있었습니다. /api/cards/monthly/emotions/{emotion} 은 id 와 요약, 대사까지 담아 감정 필터도 서버가 겁니다. - CardService.getCardsByMonth 를 getCardsByMonthAndEmotion 으로 교체합니다. - 응답이 최신순이라 저장소에서 뒤집습니다. 보관함 더미가 목록 순서대로 위에 얹으며 쌓기 때문에, 오래된 순으로 줘야 최근 카드가 맨 위에 옵니다. - 날짜별 조회와 스키마가 같아 CardResponse 와 toDomainOrNull 을 그대로 씁니다. - CardEntry 와 CardCalendarResponse 를 지웁니다. 날짜+순번으로 카드를 지목하던 방식이 사라집니다. --- .../1.json | 12 +- .../gamss/android/data/local/card/CardDao.kt | 15 ++- .../data/local/card/CardLocalDataSource.kt | 2 + .../local/card/RoomCardLocalDataSource.kt | 5 + .../data/local/card/model/CardEntity.kt | 3 - .../android/data/remote/card/CardService.kt | 10 +- .../model/response/CardCalendarResponse.kt | 20 --- .../card/model/response/CardResponse.kt | 11 +- .../data/repository/CardRepositoryImpl.kt | 57 ++++++--- .../data/local/card/model/CardEntityTest.kt | 1 - .../data/repository/CardRepositoryImplTest.kt | 119 +++++++++++------- .../gamss/android/domain/card/CardEntry.kt | 11 -- .../android/domain/card/CardRepository.kt | 7 +- .../card/GetCardsByMonthAndEmotionUseCase.kt | 22 ++++ .../domain/card/GetCardsByMonthUseCase.kt | 15 --- .../android/domain/card/FakeCardRepository.kt | 5 +- .../GetCardsByMonthAndEmotionUseCaseTest.kt | 56 +++++++++ .../domain/card/GetCardsByMonthUseCaseTest.kt | 39 ------ 18 files changed, 231 insertions(+), 179 deletions(-) delete mode 100644 data/src/main/java/com/gamss/android/data/remote/card/model/response/CardCalendarResponse.kt delete mode 100644 domain/src/main/kotlin/com/gamss/android/domain/card/CardEntry.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByMonthAndEmotionUseCase.kt delete mode 100644 domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByMonthUseCase.kt create mode 100644 domain/src/test/kotlin/com/gamss/android/domain/card/GetCardsByMonthAndEmotionUseCaseTest.kt delete mode 100644 domain/src/test/kotlin/com/gamss/android/domain/card/GetCardsByMonthUseCaseTest.kt diff --git a/data/schemas/com.gamss.android.data.local.card.GamssDatabase/1.json b/data/schemas/com.gamss.android.data.local.card.GamssDatabase/1.json index 56105a1d..f328a653 100644 --- a/data/schemas/com.gamss.android.data.local.card.GamssDatabase/1.json +++ b/data/schemas/com.gamss.android.data.local.card.GamssDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "9cfd76bd48d1b7540b588561b3258716", + "identityHash": "ed660b41190bf72caaaa9ce9e9647cd2", "entities": [ { "tableName": "cards", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `conversation_id` INTEGER NOT NULL, `emotion` TEXT NOT NULL, `emotion_label` TEXT NOT NULL, `summary` TEXT NOT NULL, `message` TEXT NOT NULL, `date` TEXT NOT NULL, `index_in_date` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `conversation_id` INTEGER NOT NULL, `emotion` TEXT NOT NULL, `emotion_label` TEXT NOT NULL, `summary` TEXT NOT NULL, `message` TEXT NOT NULL, `date` TEXT NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -49,12 +49,6 @@ "columnName": "date", "affinity": "TEXT", "notNull": true - }, - { - "fieldPath": "indexInDate", - "columnName": "index_in_date", - "affinity": "INTEGER", - "notNull": true } ], "primaryKey": { @@ -78,7 +72,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9cfd76bd48d1b7540b588561b3258716')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ed660b41190bf72caaaa9ce9e9647cd2')" ] } } \ No newline at end of file diff --git a/data/src/main/java/com/gamss/android/data/local/card/CardDao.kt b/data/src/main/java/com/gamss/android/data/local/card/CardDao.kt index 9b81e08b..0f363687 100644 --- a/data/src/main/java/com/gamss/android/data/local/card/CardDao.kt +++ b/data/src/main/java/com/gamss/android/data/local/card/CardDao.kt @@ -9,10 +9,21 @@ import com.gamss.android.data.local.card.model.CardEntity @Dao internal interface CardDao { - /** indexInDate 순서를 그대로 복원해야 selectCard 가 고르는 순번이 서버 응답과 어긋나지 않는다. */ - @Query("SELECT * FROM cards WHERE date = :date ORDER BY index_in_date ASC") + @Query("SELECT * FROM cards WHERE date = :date ORDER BY id ASC") suspend fun findByDate(date: String): List + /** + * 날짜를 `yyyy-MM-dd` 문자열로 담으므로 `yyyy-MM` 접두사로 그 달을 고른다. + * + * 서버 감정 키(`emotion`)를 그대로 비교한다. 오래된 순으로 돌려주는 것은 보관함 더미가 목록 + * 순서대로 쌓기 때문이다. + */ + @Query( + "SELECT * FROM cards WHERE emotion = :emotion AND date LIKE :yearMonthPrefix || '%' " + + "ORDER BY date ASC, id ASC", + ) + suspend fun findByEmotionAndMonth(emotion: String, yearMonthPrefix: String): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(cards: List) diff --git a/data/src/main/java/com/gamss/android/data/local/card/CardLocalDataSource.kt b/data/src/main/java/com/gamss/android/data/local/card/CardLocalDataSource.kt index 761644b3..5a29ba8b 100644 --- a/data/src/main/java/com/gamss/android/data/local/card/CardLocalDataSource.kt +++ b/data/src/main/java/com/gamss/android/data/local/card/CardLocalDataSource.kt @@ -2,9 +2,11 @@ package com.gamss.android.data.local.card import com.gamss.android.data.local.card.model.CardEntity import java.time.LocalDate +import java.time.YearMonth internal interface CardLocalDataSource { suspend fun findByDate(date: LocalDate): List + suspend fun findByEmotionAndMonth(emotion: String, yearMonth: YearMonth): List suspend fun upsertAll(cards: List) suspend fun deleteAll() } diff --git a/data/src/main/java/com/gamss/android/data/local/card/RoomCardLocalDataSource.kt b/data/src/main/java/com/gamss/android/data/local/card/RoomCardLocalDataSource.kt index 064a4142..afa85974 100644 --- a/data/src/main/java/com/gamss/android/data/local/card/RoomCardLocalDataSource.kt +++ b/data/src/main/java/com/gamss/android/data/local/card/RoomCardLocalDataSource.kt @@ -2,6 +2,7 @@ package com.gamss.android.data.local.card import com.gamss.android.data.local.card.model.CardEntity import java.time.LocalDate +import java.time.YearMonth import javax.inject.Inject import javax.inject.Singleton @@ -13,6 +14,10 @@ internal class RoomCardLocalDataSource @Inject constructor( override suspend fun findByDate(date: LocalDate): List = cardDao.findByDate(date.toString()) + // YearMonth.toString() 이 date 컬럼의 yyyy-MM-dd 앞부분과 그대로 맞는다. + override suspend fun findByEmotionAndMonth(emotion: String, yearMonth: YearMonth): List = + cardDao.findByEmotionAndMonth(emotion, yearMonth.toString()) + override suspend fun upsertAll(cards: List) { cardDao.upsertAll(cards) } diff --git a/data/src/main/java/com/gamss/android/data/local/card/model/CardEntity.kt b/data/src/main/java/com/gamss/android/data/local/card/model/CardEntity.kt index 010096a3..77c37d4d 100644 --- a/data/src/main/java/com/gamss/android/data/local/card/model/CardEntity.kt +++ b/data/src/main/java/com/gamss/android/data/local/card/model/CardEntity.kt @@ -34,9 +34,6 @@ internal data class CardEntity( val message: String, @ColumnInfo(name = "date") val date: String, - /** 캐시에서 그 날짜의 카드를 복원할 때 CardEntry.indexInDate 와 같은 기준으로 순서를 매기기 위한 값. */ - @ColumnInfo(name = "index_in_date") - val indexInDate: Int, ) /** diff --git a/data/src/main/java/com/gamss/android/data/remote/card/CardService.kt b/data/src/main/java/com/gamss/android/data/remote/card/CardService.kt index ee0a383a..13dfa0b3 100644 --- a/data/src/main/java/com/gamss/android/data/remote/card/CardService.kt +++ b/data/src/main/java/com/gamss/android/data/remote/card/CardService.kt @@ -1,7 +1,6 @@ package com.gamss.android.data.remote.card import com.gamss.android.data.remote.card.model.request.CreateCardRequest -import com.gamss.android.data.remote.card.model.response.CardCalendarResponse import com.gamss.android.data.remote.card.model.response.CardDeleteResponse import com.gamss.android.data.remote.card.model.response.CardResponse import com.gamss.android.data.remote.model.response.ApiResponse @@ -17,11 +16,12 @@ internal interface CardService { @GET("/api/cards") suspend fun getCardsByDate(@Query("date") date: String): ApiResponse> - /** 그 달(KST)의 날짜별 대표 감정 목록만 준다. */ - @GET("/api/cards/monthly") - suspend fun getCardsByMonth( + /** 그 달(KST) 그 감정의 카드를 내용까지 담아 최신순으로 준다. 페이지네이션이 없다. */ + @GET("/api/cards/monthly/emotions/{emotion}") + suspend fun getCardsByMonthAndEmotion( + @Path("emotion") emotion: String, @Query("yearMonth") yearMonth: String, - ): ApiResponse> + ): ApiResponse> /** 종료된 채팅방에만 만들 수 있다. 대사는 서버가 생성한다. */ @POST("/api/cards") diff --git a/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardCalendarResponse.kt b/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardCalendarResponse.kt deleted file mode 100644 index 9d5a8f8b..00000000 --- a/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardCalendarResponse.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.gamss.android.data.remote.card.model.response - -import com.gamss.android.data.remote.emotion.toEmotionCharacter -import com.gamss.android.domain.card.CardEntry -import kotlinx.serialization.Serializable - -@Serializable -internal data class CardCalendarResponse( - val date: String, - /** 카드 생성순이며 같은 감정이 여러 번 올 수 있다. */ - val emotions: List = emptyList(), -) - -internal fun CardCalendarResponse.toDomain(): List { - val createdDate = date.toLocalDateOrNull() ?: return emptyList() - return emotions.mapNotNull { it.toEmotionCharacter() } - .mapIndexed { index, character -> - CardEntry(date = createdDate, indexInDate = index, character = character) - } -} diff --git a/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt b/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt index c8fdc771..24573c53 100644 --- a/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt +++ b/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt @@ -52,13 +52,8 @@ internal fun CardResponse.toDomainOrNull(): Card? = emotion.toEmotionCharacter() } } -/** - * 서버 응답을 그대로 캐시 저장 형태로 옮긴다. 파싱 실패 값도 다음 조회 때 재해석할 수 있게 원문 그대로 둔다. - * - * @param indexInDate 같은 날짜 응답 목록에서 유효한 카드만 남긴 뒤의 순번. selectCard 가 CardEntry.indexInDate 로 - * 집는 카드와 캐시에서 복원한 카드가 같은 기준으로 정렬돼야 하므로, 원본 응답의 위치가 아니라 이 값을 저장한다. - */ -internal fun CardResponse.toEntity(indexInDate: Int): CardEntity = +/** 서버 응답을 그대로 캐시 저장 형태로 옮긴다. 파싱 실패 값도 다음 조회 때 재해석할 수 있게 원문 그대로 둔다. */ +internal fun CardResponse.toEntity(): CardEntity = CardEntity( id = id, conversationId = conversationId, @@ -67,10 +62,8 @@ internal fun CardResponse.toEntity(indexInDate: Int): CardEntity = summary = summary, message = message, date = date, - indexInDate = indexInDate, ) -/** 날짜별·월별 응답이 같은 기준으로 날짜를 버려야 CardEntry.indexInDate 가 두 응답에서 같은 카드를 가리킨다. */ internal fun String.toLocalDateOrNull(): LocalDate? = try { LocalDate.parse(this) } catch (_: DateTimeParseException) { diff --git a/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt index 93203037..b8c579bd 100644 --- a/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt @@ -13,7 +13,6 @@ import com.gamss.android.data.remote.emotion.toServerEmotionType import com.gamss.android.data.remote.runCatchingApiCall import com.gamss.android.data.remote.throwIfFailed import com.gamss.android.domain.card.Card -import com.gamss.android.domain.card.CardEntry import com.gamss.android.domain.card.CardNotRetryableException import com.gamss.android.domain.card.CardRepository import com.gamss.android.domain.emotion.EmotionCharacter @@ -63,22 +62,53 @@ internal class CardRepositoryImpl @Inject constructor( raw.toDomainOrNull()?.let { raw to it } } - cardLocalDataSource.upsertAll( - validCards.mapIndexed { index, (raw, _) -> - raw.toEntity(indexInDate = index) - }, - ) + cardLocalDataSource.upsertAll(validCards.map { (raw, _) -> raw.toEntity() }) validCards.map { (_, card) -> card } } } // YearMonth.toString() 이 서버가 요구하는 yyyy-MM 그대로다. - override suspend fun getCardsByMonth(yearMonth: YearMonth): AppResult> = runCatchingApiCall { - val response = cardService.getCardsByMonth(yearMonth.toString()) - response.throwIfFailed() - checkNotNull(response.data) { "No available card data" } - .flatMap { it.toDomain() } + /** 그 감정 칸의 그 달이 캐시에 있으면 캐시를 쓰고, 없거나 캐시 조회에 실패하면 서버에서 받는다. */ + override suspend fun getCardsByMonthAndEmotion( + character: EmotionCharacter, + yearMonth: YearMonth, + ): AppResult> { + val serverEmotion = character.toServerEmotionType() + runCatching { + cardLocalDataSource + .findByEmotionAndMonth(serverEmotion, yearMonth) + .map { it.toDomain() } + } + .onFailure { throwable -> + if (throwable is CancellationException) throw throwable + + Log.w( + TAG, + "카드 캐시 조회에 실패해 서버 조회로 대체합니다. emotion=$serverEmotion, yearMonth=$yearMonth", + throwable, + ) + } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + ?.let { return AppResult.Success(it) } + + return runCatchingApiCall { + val response = cardService.getCardsByMonthAndEmotion( + emotion = serverEmotion, + yearMonth = yearMonth.toString(), + ) + response.throwIfFailed() + + val validCards = checkNotNull(response.data) { "No available card data" } + .mapNotNull { raw -> raw.toDomainOrNull()?.let { raw to it } } + + cardLocalDataSource.upsertAll(validCards.map { (raw, _) -> raw.toEntity() }) + + // 서버는 최신순으로 준다. 보관함 더미는 목록 순서대로 위에 얹으며 쌓으므로, 뒤집어 + // 오래된 순으로 돌려줘야 최근 카드가 맨 위에 온다. + validCards.map { (_, card) -> card }.reversed() + } } override suspend fun createCard( @@ -122,9 +152,8 @@ internal class CardRepositoryImpl @Inject constructor( } /** - * 카드를 지우면 같은 날짜 뒤 카드들의 indexInDate 가 한 칸씩 당겨져 캐시에 남은 순번이 서버와 어긋난다. - * 어느 카드가 영향받는지 이 메서드는 날짜를 모르므로, 지운 카드만 골라내는 대신 캐시 전체를 비워 - * 다음 조회 때 다시 채우게 한다. + * 지운 카드가 어느 감정 칸·어느 달에 있었는지 이 메서드는 모른다. 그 칸만 골라 비우는 대신 + * 캐시 전체를 비워 다음 조회 때 다시 채우게 한다. */ override suspend fun deleteCard(cardId: Long): AppResult { val result = runCatchingApiCall { diff --git a/data/src/test/java/com/gamss/android/data/local/card/model/CardEntityTest.kt b/data/src/test/java/com/gamss/android/data/local/card/model/CardEntityTest.kt index 2cc95bec..c9420aba 100644 --- a/data/src/test/java/com/gamss/android/data/local/card/model/CardEntityTest.kt +++ b/data/src/test/java/com/gamss/android/data/local/card/model/CardEntityTest.kt @@ -37,6 +37,5 @@ class CardEntityTest { summary = "요약", message = "메시지", date = "2026-08-16", - indexInDate = 0, ) } diff --git a/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt b/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt index ab770da1..0a00bd6c 100644 --- a/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt +++ b/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt @@ -6,13 +6,11 @@ import com.gamss.android.data.local.card.CardLocalDataSource import com.gamss.android.data.local.card.model.CardEntity import com.gamss.android.data.local.card.model.toDomain import com.gamss.android.data.remote.card.CardService -import com.gamss.android.data.remote.card.model.response.CardCalendarResponse import com.gamss.android.data.remote.card.model.response.CardDeleteResponse import com.gamss.android.data.remote.card.model.response.CardResponse import com.gamss.android.data.remote.model.response.ApiError import com.gamss.android.data.remote.model.response.ApiResponse import com.gamss.android.domain.auth.SessionExpiredException -import com.gamss.android.domain.card.CardEntry import com.gamss.android.domain.emotion.EmotionCharacter import io.mockk.coEvery import io.mockk.coVerify @@ -93,7 +91,7 @@ class CardRepositoryImplTest { @Test fun `그 날짜가 캐시에 있으면 서버를 호출하지 않고 캐시를 그대로 돌려준다`() = runTest { - val cached = listOf(cardEntity(id = 7L, indexInDate = 0), cardEntity(id = 8L, indexInDate = 1)) + val cached = listOf(cardEntity(id = 7L), cardEntity(id = 8L)) coEvery { cardLocalDataSource.findByDate(DATE) } returns cached val result = repository.getCardsByDate(DATE) @@ -102,12 +100,8 @@ class CardRepositoryImplTest { coVerify(exactly = 0) { cardService.getCardsByDate(any()) } } - /** - * 월별 응답과 마찬가지로 selectCard 는 유효한 카드만 남긴 목록의 위치로 카드를 집으므로, - * 캐시에 적는 indexInDate 도 원본 응답 위치가 아니라 걸러낸 뒤의 위치와 같아야 한다. - */ @Test - fun `캐시가 비어 있으면 서버에서 가져와 유효한 카드만 걸러낸 순서로 캐시에 저장한다`() = runTest { + fun `캐시가 비어 있으면 서버에서 가져와 유효한 카드만 캐시에 저장한다`() = runTest { coEvery { cardLocalDataSource.findByDate(DATE) } returns emptyList() coEvery { cardService.getCardsByDate(any()) } returns ApiResponse( success = true, @@ -122,10 +116,7 @@ class CardRepositoryImplTest { repository.getCardsByDate(DATE) - assertEquals( - listOf(2L to 0, 3L to 1), - upserted.captured.map { it.id to it.indexInDate }, - ) + assertEquals(listOf(2L, 3L), upserted.captured.map { it.id }) } @Test @@ -273,62 +264,90 @@ class CardRepositoryImplTest { } @Test - fun `월을 API 형식으로 조회하고 하루치 감정 목록을 카드 한 건씩으로 펼친다`() = runTest { - coEvery { cardService.getCardsByMonth("2026-08") } returns ApiResponse( + fun `그 감정 칸의 그 달이 캐시에 있으면 서버를 호출하지 않는다`() = runTest { + val cached = listOf(cardEntity(id = 1L), cardEntity(id = 2L, date = "2026-08-16")) + coEvery { + cardLocalDataSource.findByEmotionAndMonth("ANGER", YearMonth.of(2026, 8)) + } returns cached + + val result = repository.getCardsByMonthAndEmotion(EmotionCharacter.ANGER, YearMonth.of(2026, 8)) + + assertEquals(cached.map { it.toDomain() }, (result as AppResult.Success).data) + coVerify(exactly = 0) { cardService.getCardsByMonthAndEmotion(any(), any()) } + } + + @Test + fun `캐시가 비어 있으면 서버에서 받아 캐시에 저장한다`() = runTest { + stubEmptyMonthCache() + val upserted = slot>() + coEvery { cardLocalDataSource.upsertAll(capture(upserted)) } returns Unit + coEvery { cardService.getCardsByMonthAndEmotion(any(), any()) } returns ApiResponse( success = true, - data = listOf( - CardCalendarResponse(date = "2026-08-15", emotions = listOf("ANGER", "JOY")), - CardCalendarResponse(date = "2026-08-16", emotions = listOf("GRUMPY")), - ), + data = listOf(cardResponse(id = 2L, emotion = "ANGER"), cardResponse(id = 1L, emotion = "ANGER")), ) - val result = repository.getCardsByMonth(YearMonth.of(2026, 8)) + repository.getCardsByMonthAndEmotion(EmotionCharacter.ANGER, YearMonth.of(2026, 8)) - assertEquals( - listOf( - CardEntry(LocalDate.of(2026, 8, 15), 0, EmotionCharacter.ANGER), - CardEntry(LocalDate.of(2026, 8, 15), 1, EmotionCharacter.JOY), - CardEntry(LocalDate.of(2026, 8, 16), 0, EmotionCharacter.PRICKLY), - ), - (result as AppResult.Success).data, - ) - coVerify(exactly = 1) { cardService.getCardsByMonth("2026-08") } + assertEquals(listOf(2L, 1L), upserted.captured.map { it.id }) } - /** 날짜별 조회도 알 수 없는 감정을 버리므로, 순번은 버린 뒤를 기준으로 세야 두 응답이 맞물린다. */ @Test - fun `날짜를 못 읽는 날은 그 날만 버리고 나머지 달은 살린다`() = runTest { - coEvery { cardService.getCardsByMonth(any()) } returns ApiResponse( + fun `감정과 월을 API 형식으로 조회하고 오래된 순으로 뒤집는다`() = runTest { + stubEmptyMonthCache() + coEvery { cardService.getCardsByMonthAndEmotion("ANGER", "2026-08") } returns ApiResponse( success = true, data = listOf( - CardCalendarResponse(date = "2026-08-99", emotions = listOf("ANGER")), - CardCalendarResponse(date = "2026-08-16", emotions = listOf("JOY")), + cardResponse(emotion = "ANGER").copy(id = 2L, date = "2026-08-16"), + cardResponse(emotion = "ANGER").copy(id = 1L, date = "2026-08-15"), ), ) - val result = repository.getCardsByMonth(YearMonth.of(2026, 8)) + val result = repository.getCardsByMonthAndEmotion(EmotionCharacter.ANGER, YearMonth.of(2026, 8)) - assertEquals( - listOf(CardEntry(LocalDate.of(2026, 8, 16), 0, EmotionCharacter.JOY)), - (result as AppResult.Success).data, + assertEquals(listOf(1L, 2L), (result as AppResult.Success).data.map { it.id }) + coVerify(exactly = 1) { cardService.getCardsByMonthAndEmotion("ANGER", "2026-08") } + } + + @Test + fun `캐릭터를 서버 감정 이름으로 바꿔 경로에 넣는다`() = runTest { + stubEmptyMonthCache() + coEvery { cardService.getCardsByMonthAndEmotion("GRUMPY", any()) } returns ApiResponse( + success = true, + data = emptyList(), ) + + repository.getCardsByMonthAndEmotion(EmotionCharacter.PRICKLY, YearMonth.of(2026, 8)) + + coVerify(exactly = 1) { cardService.getCardsByMonthAndEmotion("GRUMPY", "2026-08") } } @Test - fun `알 수 없는 감정을 버린 뒤를 기준으로 그날 순번을 센다`() = runTest { - coEvery { cardService.getCardsByMonth(any()) } returns ApiResponse( + fun `못 읽는 카드는 그것만 버리고 나머지 달은 살린다`() = runTest { + stubEmptyMonthCache() + coEvery { cardService.getCardsByMonthAndEmotion(any(), any()) } returns ApiResponse( success = true, data = listOf( - CardCalendarResponse(date = "2026-08-15", emotions = listOf("UNKNOWN", "ANGER")), + cardResponse(emotion = "ANGER").copy(id = 2L, date = "2026-08-99"), + cardResponse(emotion = "ANGER").copy(id = 1L), ), ) - val result = repository.getCardsByMonth(YearMonth.of(2026, 8)) + val result = repository.getCardsByMonthAndEmotion(EmotionCharacter.ANGER, YearMonth.of(2026, 8)) - assertEquals( - listOf(CardEntry(LocalDate.of(2026, 8, 15), 0, EmotionCharacter.ANGER)), - (result as AppResult.Success).data, + assertEquals(listOf(1L), (result as AppResult.Success).data.map { it.id }) + } + + @Test + fun `대상이 없는 달은 빈 목록으로 성공한다`() = runTest { + stubEmptyMonthCache() + coEvery { cardService.getCardsByMonthAndEmotion(any(), any()) } returns ApiResponse( + success = true, + data = emptyList(), ) + + val result = repository.getCardsByMonthAndEmotion(EmotionCharacter.ANGER, YearMonth.of(2026, 8)) + + assertTrue((result as AppResult.Success).data.isEmpty()) } @Test @@ -351,15 +370,19 @@ class CardRepositoryImplTest { repository.clearCache() } - private fun cardEntity(id: Long, indexInDate: Int) = CardEntity( + private fun stubEmptyMonthCache() { + coEvery { cardLocalDataSource.findByEmotionAndMonth(any(), any()) } returns emptyList() + coEvery { cardLocalDataSource.upsertAll(any()) } returns Unit + } + + private fun cardEntity(id: Long, emotion: String = "ANGER", date: String = "2026-08-15") = CardEntity( id = id, conversationId = id, - emotion = "ANGER", + emotion = emotion, emotionLabel = "분노", summary = "요약", message = "대사", - date = "2026-08-15", - indexInDate = indexInDate, + date = date, ) private companion object { diff --git a/domain/src/main/kotlin/com/gamss/android/domain/card/CardEntry.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/CardEntry.kt deleted file mode 100644 index 6a051947..00000000 --- a/domain/src/main/kotlin/com/gamss/android/domain/card/CardEntry.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.gamss.android.domain.card - -import com.gamss.android.domain.emotion.EmotionCharacter -import java.time.LocalDate - -/** 월별 응답에는 카드 식별자가 없어 [date] 와 그날 순번 [indexInDate] 가 카드의 신원이다. */ -data class CardEntry( - val date: LocalDate, - val indexInDate: Int, - val character: EmotionCharacter, -) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt index 464b47fc..b011b220 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt @@ -13,8 +13,11 @@ interface CardRepository { */ suspend fun getCardsByDate(date: LocalDate): AppResult> - /** 해당 달(KST)에 생성된 카드를 요약 없이 날짜·순번·감정만 가져온다. */ - suspend fun getCardsByMonth(yearMonth: YearMonth): AppResult> + /** 해당 달(KST)에 생성된 그 감정의 카드를 내용까지 가져온다. 오래된 순이다. */ + suspend fun getCardsByMonthAndEmotion( + character: EmotionCharacter, + yearMonth: YearMonth, + ): AppResult> /** 종료된 채팅방에만 만들 수 있고 방당 한 번만 성공한다. */ suspend fun createCard( diff --git a/domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByMonthAndEmotionUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByMonthAndEmotionUseCase.kt new file mode 100644 index 00000000..6495871f --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByMonthAndEmotionUseCase.kt @@ -0,0 +1,22 @@ +package com.gamss.android.domain.card + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.emotion.EmotionCharacter +import com.gamss.android.domain.usecase.UseCase +import java.time.YearMonth +import javax.inject.Inject + +/** 보관함 한 칸이 한 번에 보는 범위. 감정 없이 달만으로는 조회하지 않는다. */ +data class MonthlyEmotionQuery( + val character: EmotionCharacter, + val yearMonth: YearMonth, +) + +/** 선택한 달(KST)에 생성된 그 감정의 카드를 내용까지 조회한다. */ +class GetCardsByMonthAndEmotionUseCase @Inject constructor( + private val cardRepository: CardRepository, +) : UseCase>> { + + override suspend fun invoke(params: MonthlyEmotionQuery): AppResult> = + cardRepository.getCardsByMonthAndEmotion(params.character, params.yearMonth) +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByMonthUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByMonthUseCase.kt deleted file mode 100644 index ce28bfbd..00000000 --- a/domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByMonthUseCase.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.gamss.android.domain.card - -import com.gamss.android.core.common.AppResult -import com.gamss.android.domain.usecase.UseCase -import java.time.YearMonth -import javax.inject.Inject - -/** 선택한 달(KST)에 생성된 카드를 요약 없이 조회한다. */ -class GetCardsByMonthUseCase @Inject constructor( - private val cardRepository: CardRepository, -) : UseCase>> { - - override suspend fun invoke(params: YearMonth): AppResult> = - cardRepository.getCardsByMonth(params) -} diff --git a/domain/src/test/kotlin/com/gamss/android/domain/card/FakeCardRepository.kt b/domain/src/test/kotlin/com/gamss/android/domain/card/FakeCardRepository.kt index e7615560..dfac0bbb 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/card/FakeCardRepository.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/card/FakeCardRepository.kt @@ -11,7 +11,10 @@ internal open class FakeCardRepository : CardRepository { override suspend fun getCardsByDate(date: LocalDate): AppResult> = error("이 테스트에서 쓰지 않는다") - override suspend fun getCardsByMonth(yearMonth: YearMonth): AppResult> = + override suspend fun getCardsByMonthAndEmotion( + character: EmotionCharacter, + yearMonth: YearMonth, + ): AppResult> = error("이 테스트에서 쓰지 않는다") override suspend fun createCard( diff --git a/domain/src/test/kotlin/com/gamss/android/domain/card/GetCardsByMonthAndEmotionUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/card/GetCardsByMonthAndEmotionUseCaseTest.kt new file mode 100644 index 00000000..4c8c7ba8 --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/card/GetCardsByMonthAndEmotionUseCaseTest.kt @@ -0,0 +1,56 @@ +package com.gamss.android.domain.card + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.emotion.EmotionCharacter +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test +import java.time.LocalDate +import java.time.YearMonth + +class GetCardsByMonthAndEmotionUseCaseTest { + + @Test + fun `고른 감정과 달을 그대로 저장소에 전달한다`() = runBlocking { + val query = MonthlyEmotionQuery(EmotionCharacter.ANGER, YearMonth.of(2026, 8)) + val expected = AppResult.Success( + listOf( + Card( + id = 1L, + conversationId = 10L, + character = EmotionCharacter.ANGER, + emotionLabel = "분노", + summary = "요약", + message = "대사", + date = LocalDate.of(2026, 8, 15), + ), + ), + ) + val repository = RecordingRepository(expected) + + val result = GetCardsByMonthAndEmotionUseCase(repository)(query) + + assertEquals(query.character, repository.requestedCharacter) + assertEquals(query.yearMonth, repository.requestedMonth) + assertSame(expected, result) + } + + private class RecordingRepository( + private val result: AppResult>, + ) : FakeCardRepository() { + var requestedCharacter: EmotionCharacter? = null + private set + var requestedMonth: YearMonth? = null + private set + + override suspend fun getCardsByMonthAndEmotion( + character: EmotionCharacter, + yearMonth: YearMonth, + ): AppResult> { + requestedCharacter = character + requestedMonth = yearMonth + return result + } + } +} diff --git a/domain/src/test/kotlin/com/gamss/android/domain/card/GetCardsByMonthUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/card/GetCardsByMonthUseCaseTest.kt deleted file mode 100644 index fe52354b..00000000 --- a/domain/src/test/kotlin/com/gamss/android/domain/card/GetCardsByMonthUseCaseTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.gamss.android.domain.card - -import com.gamss.android.core.common.AppResult -import com.gamss.android.domain.emotion.EmotionCharacter -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Assert.assertSame -import org.junit.Test -import java.time.LocalDate -import java.time.YearMonth - -class GetCardsByMonthUseCaseTest { - - @Test - fun `선택한 달을 그대로 저장소에 전달한다`() = runBlocking { - val yearMonth = YearMonth.of(2026, 8) - val expected = AppResult.Success( - listOf(CardEntry(LocalDate.of(2026, 8, 15), indexInDate = 0, character = EmotionCharacter.ANGER)), - ) - val repository = RecordingRepository(expected) - - val result = GetCardsByMonthUseCase(repository)(yearMonth) - - assertEquals(yearMonth, repository.requestedMonth) - assertSame(expected, result) - } - - private class RecordingRepository( - private val result: AppResult>, - ) : FakeCardRepository() { - var requestedMonth: YearMonth? = null - private set - - override suspend fun getCardsByMonth(yearMonth: YearMonth): AppResult> { - requestedMonth = yearMonth - return result - } - } -} From afd158372902b151c1a5fa509f194c7be6db091d Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:14:00 +0900 Subject: [PATCH 02/12] =?UTF-8?q?refactor:=20=EB=B3=B4=EA=B4=80=ED=95=A8?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=EA=B0=80=20=EC=B9=B4=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=EC=A7=B8=EB=A1=9C=20=EB=8B=A4=EB=A3=B9=EB=8B=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록이 id 와 요약, 대사를 이미 들고 있어 종이를 누를 때 다시 조회할 이유가 없어졌습니다. - ArchiveCards.Loaded 가 CardEntry 대신 Card 를 듭니다. - selectCard 가 네트워크 없이 상태만 바꿉니다. isCardLoading 과 CardLoadFailed 가 사라집니다. - 카드를 버리면 목록에서 그 id 만 빼냅니다. 순번이 당겨지는 문제가 없어 달을 다시 받지 않습니다. - 감정 필터가 서버로 넘어가 loadMonth 의 filter 가 사라집니다. 늦게 온 응답이 지금 보고 있는 달을 덮지 않게 막는 가드는 그대로 둡니다. --- .../feature/archive/ArchiveDetailScreen.kt | 41 ++- .../archive/ArchiveDetailSideEffect.kt | 3 +- .../feature/archive/ArchiveDetailState.kt | 4 +- .../feature/archive/ArchiveDetailViewModel.kt | 42 +-- .../feature/archive/component/PaperPile.kt | 32 +- .../archive/ArchiveDetailViewModelTest.kt | 277 ++++++------------ .../android/feature/home/HomeTestFakes.kt | 6 +- 7 files changed, 129 insertions(+), 276 deletions(-) diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt index 47910e57..e9e15660 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt @@ -33,7 +33,6 @@ import com.gamss.android.core.designsystem.dialog.GamssDialog import com.gamss.android.core.designsystem.dialog.GamssDialogAction import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.domain.card.Card -import com.gamss.android.domain.card.CardEntry import com.gamss.android.domain.emotion.EmotionCharacter import com.gamss.android.feature.archive.component.CardDetailDialog import com.gamss.android.feature.archive.component.ConversationCardDialog @@ -53,7 +52,7 @@ import com.gamss.android.core.designsystem.R as DesignSystemR @Composable fun ArchiveDetailScreen( emotion: EmotionCharacter, - droppedCardDate: LocalDate?, + droppedCardId: Long?, hasShreddedCard: Boolean, onBackClick: () -> Unit, onNavigateToCardDelete: (Long?) -> Unit, @@ -62,22 +61,18 @@ fun ArchiveDetailScreen( val state by viewModel.collectAsState() val context = LocalContext.current val shareChooserTitle = stringResource(R.string.archive_card_share_chooser_title) - val cardLoadFailedMessage = stringResource(R.string.archive_card_load_error) val conversationLoadFailedMessage = stringResource(R.string.archive_conversation_load_error) // 파쇄 화면에서 돌아왔을 때도 다시 받아야 한다. 카드를 지우면 같은 날짜 뒤 순번이 한 칸씩 // 당겨져, 살아남은 종이가 들고 있던 순번이 서버와 어긋난다. LaunchedEffect(emotion) { - viewModel.load(emotion, force = droppedCardDate != null || hasShreddedCard) + viewModel.load(emotion, force = droppedCardId != null || hasShreddedCard) } viewModel.collectSideEffect { sideEffect -> when (sideEffect) { is ArchiveDetailSideEffect.OpenCardDelete -> onNavigateToCardDelete(sideEffect.cardId) - ArchiveDetailSideEffect.CardLoadFailed -> - Toast.makeText(context, cardLoadFailedMessage, Toast.LENGTH_SHORT).show() - ArchiveDetailSideEffect.ConversationLoadFailed -> Toast.makeText(context, conversationLoadFailedMessage, Toast.LENGTH_SHORT).show() } @@ -86,7 +81,7 @@ fun ArchiveDetailScreen( ArchiveDetailFrame( emotion = emotion, state = state, - droppedCardDate = droppedCardDate, + droppedCardId = droppedCardId, onBackClick = onBackClick, onPaperClick = viewModel::selectCard, onMonthClick = viewModel::showMonthPicker, @@ -113,9 +108,9 @@ fun ArchiveDetailScreen( private fun ArchiveDetailFrame( emotion: EmotionCharacter, state: ArchiveDetailState, - droppedCardDate: LocalDate?, + droppedCardId: Long?, onBackClick: () -> Unit, - onPaperClick: (CardEntry) -> Unit, + onPaperClick: (Card) -> Unit, onMonthClick: () -> Unit, onClearClick: () -> Unit, ) { @@ -132,7 +127,7 @@ private fun ArchiveDetailFrame( Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) { ArchiveDetailCards( cards = state.cards, - droppedCardDate = droppedCardDate, + droppedCardId = droppedCardId, onPaperClick = onPaperClick, ) // 종이가 쌓일 자리보다 나중에 둔다. 카드가 많아 더미가 위로 넘치면 종이가 셀렉터를 @@ -273,19 +268,19 @@ private fun ArchiveDetailTopBar( @Composable private fun ArchiveDetailCards( cards: ArchiveCards, - droppedCardDate: LocalDate?, - onPaperClick: (CardEntry) -> Unit, + droppedCardId: Long?, + onPaperClick: (Card) -> Unit, ) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { when (cards) { ArchiveCards.Loading -> CircularProgressIndicator(color = GamssTheme.colors.gray700) ArchiveCards.LoadFailed -> EmptyMessage(textRes = R.string.archive_cards_load_failed) - is ArchiveCards.Loaded -> if (cards.entries.isEmpty()) { + is ArchiveCards.Loaded -> if (cards.cards.isEmpty()) { EmptyMessage(textRes = R.string.archive_cards_empty) } else { PaperPile( - cards = cards.entries, - droppedCardDate = droppedCardDate, + cards = cards.cards, + droppedCardId = droppedCardId, onPaperClick = onPaperClick, ) } @@ -318,9 +313,9 @@ private fun ArchiveDetailPaperPilePreview() { state = ArchiveDetailState( emotion = EmotionCharacter.QUIRKY, yearMonth = YearMonth.of(2026, 7), - cards = ArchiveCards.Loaded(List(24) { index -> PreviewCard.copy(indexInDate = index) }), + cards = ArchiveCards.Loaded(List(24) { index -> PreviewCard.copy(id = index.toLong()) }), ), - droppedCardDate = null, + droppedCardId = null, onBackClick = {}, onPaperClick = {}, onMonthClick = {}, @@ -329,8 +324,12 @@ private fun ArchiveDetailPaperPilePreview() { } } -private val PreviewCard = CardEntry( - date = LocalDate.of(2026, 7, 23), - indexInDate = 0, +private val PreviewCard = Card( + id = 0L, + conversationId = 0L, character = EmotionCharacter.QUIRKY, + emotionLabel = "엉뚱", + summary = "오늘은 좀 엉뚱한 하루였어요.", + message = "그런 날도 있죠.", + date = LocalDate.of(2026, 7, 23), ) diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailSideEffect.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailSideEffect.kt index ac84fb7f..085a7bed 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailSideEffect.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailSideEffect.kt @@ -1,13 +1,12 @@ package com.gamss.android.feature.archive sealed interface ArchiveDetailSideEffect { - data object CardLoadFailed : ArchiveDetailSideEffect data object ConversationLoadFailed : ArchiveDetailSideEffect /** * 실제 삭제는 파쇄 화면이 맡는다. 여기서는 그 화면으로 넘기기만 한다. * - * @param cardId 파쇄할 카드. null 이면 이 칸이 아니라 보관함 전체를 비운다. + * @param cardId 파쇄할 카드. null 이면 이 감정 칸을 통째로 비운다. */ data class OpenCardDelete(val cardId: Long?) : ArchiveDetailSideEffect } diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailState.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailState.kt index 515294f3..a1f086da 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailState.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailState.kt @@ -1,7 +1,6 @@ package com.gamss.android.feature.archive import com.gamss.android.domain.card.Card -import com.gamss.android.domain.card.CardEntry import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.emotion.EmotionCharacter import java.time.YearMonth @@ -13,7 +12,6 @@ data class ArchiveDetailState( val isMonthPickerVisible: Boolean = false, val isClearDialogVisible: Boolean = false, val selectedCard: Card? = null, - val isCardLoading: Boolean = false, val conversationCard: ConversationCard? = null, val isConversationLoading: Boolean = false, ) @@ -22,7 +20,7 @@ data class ArchiveDetailState( sealed interface ArchiveCards { data object Loading : ArchiveCards data object LoadFailed : ArchiveCards - data class Loaded(val entries: List) : ArchiveCards + data class Loaded(val cards: List) : ArchiveCards } /** diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt index 967537ff..c9115a13 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt @@ -3,10 +3,9 @@ package com.gamss.android.feature.archive import androidx.lifecycle.ViewModel import com.gamss.android.core.common.AppResult import com.gamss.android.core.common.util.KoreanTimeZone -import com.gamss.android.domain.card.CardEntry -import com.gamss.android.domain.card.ClearCardCacheUseCase -import com.gamss.android.domain.card.GetCardsByDateUseCase -import com.gamss.android.domain.card.GetCardsByMonthUseCase +import com.gamss.android.domain.card.Card +import com.gamss.android.domain.card.GetCardsByMonthAndEmotionUseCase +import com.gamss.android.domain.card.MonthlyEmotionQuery import com.gamss.android.domain.conversation.GetConversationUseCase import com.gamss.android.domain.emotion.EmotionCharacter import dagger.hilt.android.lifecycle.HiltViewModel @@ -18,9 +17,7 @@ import javax.inject.Inject @HiltViewModel class ArchiveDetailViewModel @Inject constructor( - private val getCardsByMonth: GetCardsByMonthUseCase, - private val getCardsByDate: GetCardsByDateUseCase, - private val clearCardCache: ClearCardCacheUseCase, + private val getCardsByMonthAndEmotion: GetCardsByMonthAndEmotionUseCase, private val getConversation: GetConversationUseCase, ) : ViewModel(), ContainerHost { @@ -82,27 +79,8 @@ class ArchiveDetailViewModel @Inject constructor( loadMonth(emotion, yearMonth) } - /** - * 월별 응답에는 카드 식별자가 없어 종이는 날짜와 그날 순번만 들고 있다. 눌린 종이의 날짜로 다시 - * 조회해 그 순번의 카드를 집어야 요약·대사와 id 가 손에 들어온다. - */ - fun selectCard(entry: CardEntry) = intent { - if (state.isCardLoading) return@intent - - reduce { state.copy(isCardLoading = true) } - - val firstResult = getCardsByDate(entry.date) - val card = when (firstResult) { - is AppResult.Success -> firstResult.data.getOrNull(entry.indexInDate) - ?: run { - clearCardCache() - (getCardsByDate(entry.date) as? AppResult.Success)?.data?.getOrNull(entry.indexInDate) - } - is AppResult.Failure -> null - } - reduce { state.copy(isCardLoading = false, selectedCard = card) } - - if (card == null) postSideEffect(ArchiveDetailSideEffect.CardLoadFailed) + fun selectCard(card: Card) = intent { + reduce { state.copy(selectedCard = card) } } fun dismissCard() = intent { @@ -111,8 +89,7 @@ class ArchiveDetailViewModel @Inject constructor( /** * 한 장 버리기도 되돌릴 수 없어 파쇄 화면을 거친다. 지운 뒤 목록을 다시 받는 일은 그 화면에서 - * 돌아올 때 [load] 가 맡는다 — 카드를 지우면 같은 날짜 뒤 순번이 한 칸씩 당겨지므로, 목록에서 - * 빼는 것만으로는 남은 종이의 순번이 서버와 어긋난다. + * 돌아올 때 [load] 가 맡는다. */ fun discardSelectedCard() = intent { val card = state.selectedCard ?: return@intent @@ -159,13 +136,12 @@ class ArchiveDetailViewModel @Inject constructor( reduce { state.copy(conversationCard = null, selectedCard = card) } } - /** 월별 응답은 모든 감정을 섞어 주므로 이 화면이 보고 있는 감정만 남긴다. */ private suspend fun Syntax.loadMonth( emotion: EmotionCharacter, yearMonth: YearMonth, ) { - val cards = when (val result = getCardsByMonth(yearMonth)) { - is AppResult.Success -> ArchiveCards.Loaded(result.data.filter { it.character == emotion }) + val cards = when (val result = getCardsByMonthAndEmotion(MonthlyEmotionQuery(emotion, yearMonth))) { + is AppResult.Success -> ArchiveCards.Loaded(result.data) is AppResult.Failure -> ArchiveCards.LoadFailed } // intent 는 서로 병렬로 돈다. 느린 달을 기다리는 사이 다른 달로 옮겼다면, 늦게 온 응답이 diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/PaperPile.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/PaperPile.kt index e7eebdf3..955a8b7e 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/PaperPile.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/PaperPile.kt @@ -22,26 +22,25 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import com.gamss.android.core.designsystem.theme.designScale import com.gamss.android.core.designsystem.theme.designWidth -import com.gamss.android.domain.card.CardEntry +import com.gamss.android.domain.card.Card import com.gamss.android.feature.archive.PAPER_COLLISION_RADIUS_SCALE import com.gamss.android.feature.archive.PaperFall import com.gamss.android.feature.archive.PaperGeometry import com.gamss.android.feature.archive.PaperPileTopPadding import com.gamss.android.feature.archive.PaperSize import com.gamss.android.feature.archive.R -import java.time.LocalDate /** * 위에서 쏟아져 바닥에 쌓이는 종이 더미. 어디에 어떻게 놓이는지는 [PaperFall] 이 정한다. * - * @param droppedCardDate 방금 버려서 이 화면으로 넘어온 카드의 날짜. 그 한 장만 떨어지고 나머지는 - * 이미 쌓인 채로 시작한다. null 이면 전부 쏟는다. 첫 더미에만 쓰고 버린다. + * @param droppedCardId 방금 버려서 이 화면으로 넘어온 카드. 그 한 장만 떨어지고 나머지는 이미 + * 쌓인 채로 시작한다. null 이면 전부 쏟는다. 첫 더미에만 쓰고 버린다. */ @Composable internal fun PaperPile( - cards: List, - droppedCardDate: LocalDate?, - onPaperClick: (CardEntry) -> Unit, + cards: List, + droppedCardId: Long?, + onPaperClick: (Card) -> Unit, ) { BoxWithConstraints( modifier = Modifier @@ -68,7 +67,7 @@ internal fun PaperPile( // Navigator 가 한 번만 내주지만 이 화면은 그 값을 파라미터로 계속 들고 있다. 목록이 새로 // 만들어질 때마다(카드 삭제, 달 바꿔 돌아오기) 또 한 장만 떨어지지 않게 여기서도 한 번 // 쓰고 비운다. - var pendingDrop by remember { mutableStateOf(droppedCardDate) } + var pendingDrop by remember { mutableStateOf(droppedCardId) } // 키에 화면 크기를 넣지 않는다. 크기만 바뀌었을 때 이미 쌓인 종이가 다시 쏟아지면 안 된다. // 바뀐 칸은 컴포지션이 확정된 뒤에 흘려 넣는다. 버려질 수 있는 컴포지션에서 쓰면 안 된다. val fall = remember(cards) { @@ -109,17 +108,8 @@ internal fun PaperPile( } } -/** - * 방금 버린 카드가 목록에서 몇 번째인지. 월별 응답에 카드 식별자가 없어 날짜와 그날 순번으로 찾고, - * 같은 날 여러 장이면 순번이 가장 큰 마지막 장이 방금 버린 것이다. - * - * 못 찾으면 null 이라 전부 쏟는 원래 연출로 돌아간다. 달을 바꿔 그 날짜가 목록에서 사라졌을 때가 - * 그렇다. - */ -private fun List.droppedIndex(date: LocalDate?): Int? { - if (date == null) return null - return withIndex() - .filter { (_, entry) -> entry.date == date } - .maxByOrNull { (_, entry) -> entry.indexInDate } - ?.index +/** 못 찾으면 null 이라 전부 쏟는 원래 연출로 돌아간다. 달을 바꿔 목록에서 사라졌을 때가 그렇다. */ +private fun List.droppedIndex(cardId: Long?): Int? { + if (cardId == null) return null + return indexOfFirst { it.id == cardId }.takeIf { it >= 0 } } diff --git a/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt b/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt index 65c2879f..0cc16c7d 100644 --- a/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt +++ b/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt @@ -4,11 +4,8 @@ import androidx.paging.PagingData import com.gamss.android.core.common.AppResult import com.gamss.android.core.common.util.KoreanTimeZone import com.gamss.android.domain.card.Card -import com.gamss.android.domain.card.CardEntry import com.gamss.android.domain.card.CardRepository -import com.gamss.android.domain.card.ClearCardCacheUseCase -import com.gamss.android.domain.card.GetCardsByDateUseCase -import com.gamss.android.domain.card.GetCardsByMonthUseCase +import com.gamss.android.domain.card.GetCardsByMonthAndEmotionUseCase import com.gamss.android.domain.conversation.Conversation import com.gamss.android.domain.conversation.ConversationDetail import com.gamss.android.domain.conversation.ConversationRepository @@ -33,15 +30,20 @@ import java.time.YearMonth class ArchiveDetailViewModelTest { @Test - fun `선택한 감정의 카드만 종이 목록으로 남긴다`() = runTest { - val viewModel = viewModel(FakeCardRepository(AppResult.Success(listOf(angerEntry, joyEntry)))) + fun `보고 있는 감정과 달로 조회해 받은 목록을 그대로 쓴다`() = runTest { + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard, joyCard))) + val viewModel = viewModel(repository) + val currentMonth = YearMonth.now(KoreanTimeZone) viewModel.test(this) { containerHost.load(EmotionCharacter.ANGER) expectState { copy(emotion = EmotionCharacter.ANGER) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + // 감정 필터는 서버가 맡으므로 받은 목록을 클라이언트에서 다시 걸러내지 않는다. + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard, joyCard))) } } + + assertEquals(listOf(EmotionCharacter.ANGER to currentMonth), repository.requests) } @Test @@ -58,14 +60,14 @@ class ArchiveDetailViewModelTest { @Test fun `다른 달을 고르면 그 달을 다시 조회하고 시트를 닫는다`() = runTest { - val repository = FakeCardRepository(AppResult.Success(listOf(angerEntry))) + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard))) val viewModel = viewModel(repository) val previousMonth = YearMonth.of(2026, 7) viewModel.test(this) { containerHost.load(EmotionCharacter.ANGER) expectState { copy(emotion = EmotionCharacter.ANGER) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } containerHost.showMonthPicker() expectState { copy(isMonthPickerVisible = true) } @@ -78,23 +80,23 @@ class ArchiveDetailViewModelTest { cards = ArchiveCards.Loading, ) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } } - assertEquals(previousMonth, repository.requestedMonths.last()) - assertEquals(2, repository.requestedMonths.size) + assertEquals(EmotionCharacter.ANGER to previousMonth, repository.requests.last()) + assertEquals(2, repository.requests.size) } @Test fun `보고 있는 달을 다시 고르면 다시 조회하지 않고 시트만 닫는다`() = runTest { - val repository = FakeCardRepository(AppResult.Success(listOf(angerEntry))) + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard))) val viewModel = viewModel(repository) val currentMonth = YearMonth.now(KoreanTimeZone) viewModel.test(this) { containerHost.load(EmotionCharacter.ANGER) expectState { copy(emotion = EmotionCharacter.ANGER) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } containerHost.showMonthPicker() expectState { copy(isMonthPickerVisible = true) } @@ -103,151 +105,65 @@ class ArchiveDetailViewModelTest { expectState { copy(isMonthPickerVisible = false) } } - assertEquals(listOf(currentMonth), repository.requestedMonths) - } - - @Test - fun `종이를 누르면 그 날짜의 그날 순번 카드를 상세로 올린다`() = runTest { - val repository = FakeCardRepository( - monthResult = AppResult.Success(listOf(angerEntry, joyEntry)), - dateResult = AppResult.Success(listOf(firstCardOfDay, secondCardOfDay)), - ) - val viewModel = viewModel(repository) - - viewModel.test(this) { - containerHost.load(EmotionCharacter.ANGER) - expectState { copy(emotion = EmotionCharacter.ANGER) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } - - containerHost.selectCard(joyEntry) - expectState { copy(isCardLoading = true) } - expectState { copy(isCardLoading = false, selectedCard = secondCardOfDay) } - } - - assertEquals(listOf(joyEntry.date), repository.requestedDates) + assertEquals(listOf(EmotionCharacter.ANGER to currentMonth), repository.requests) } - /** 재조회까지 마쳤는데도 그 순번이 없다면 서버에도 정말 없는 카드다. */ @Test - fun `재조회해도 그 순번에 카드가 없으면 상세를 올리지 않고 실패를 알린다`() = runTest { - val repository = FakeCardRepository( - monthResult = AppResult.Success(listOf(angerEntry)), - dateResult = AppResult.Success(emptyList()), - ) - val viewModel = viewModel(repository) - - viewModel.test(this) { - containerHost.load(EmotionCharacter.ANGER) - expectState { copy(emotion = EmotionCharacter.ANGER) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } - - containerHost.selectCard(angerEntry) - expectState { copy(isCardLoading = true) } - expectState { copy(isCardLoading = false) } - expectSideEffect(ArchiveDetailSideEffect.CardLoadFailed) - } - - assertEquals(1, repository.clearCacheCallCount) - assertEquals(listOf(angerEntry.date, angerEntry.date), repository.requestedDates) - } - - /** - * 다른 기기에서 그 날짜에 카드가 추가되면 월별 응답엔 새 순번이 보이지만, 이 기기의 날짜 캐시는 - * 그 전 상태로 멈춰 있어 그 순번을 못 찾을 수 있다. 캐시를 비우고 한 번 더 받으면 찾아야 한다. - */ - @Test - fun `해당 순번에 카드가 없으면 캐시를 비우고 한 번 더 조회해 상세를 올린다`() = runTest { - val repository = FakeCardRepository( - monthResult = AppResult.Success(listOf(angerEntry, joyEntry)), - dateResult = AppResult.Success(listOf(firstCardOfDay)), - dateResultAfterClear = AppResult.Success(listOf(firstCardOfDay, secondCardOfDay)), - ) + fun `종이를 누르면 조회 없이 그 카드를 상세로 올린다`() = runTest { + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard, joyCard))) val viewModel = viewModel(repository) viewModel.test(this) { containerHost.load(EmotionCharacter.ANGER) expectState { copy(emotion = EmotionCharacter.ANGER) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard, joyCard))) } - containerHost.selectCard(joyEntry) - expectState { copy(isCardLoading = true) } - expectState { copy(isCardLoading = false, selectedCard = secondCardOfDay) } + containerHost.selectCard(joyCard) + expectState { copy(selectedCard = joyCard) } } - assertEquals(1, repository.clearCacheCallCount) - assertEquals(listOf(joyEntry.date, joyEntry.date), repository.requestedDates) - } - - /** 이미 네트워크까지 갔다가 실패한 경우엔 재시도로 캐시를 다시 비우지 않는다. */ - @Test - fun `카드 조회 자체가 실패하면 재조회하지 않고 바로 실패를 알린다`() = runTest { - val repository = FakeCardRepository( - monthResult = AppResult.Success(listOf(angerEntry)), - dateResult = AppResult.Failure(IllegalStateException("network")), - ) - val viewModel = viewModel(repository) - - viewModel.test(this) { - containerHost.load(EmotionCharacter.ANGER) - expectState { copy(emotion = EmotionCharacter.ANGER) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } - - containerHost.selectCard(angerEntry) - expectState { copy(isCardLoading = true) } - expectState { copy(isCardLoading = false) } - expectSideEffect(ArchiveDetailSideEffect.CardLoadFailed) - } - - assertEquals(0, repository.clearCacheCallCount) - assertEquals(listOf(angerEntry.date), repository.requestedDates) + // 목록이 이미 카드 내용까지 들고 있어 상세를 열 때 두 번째 조회가 없다. + assertEquals(1, repository.requests.size) } @Test fun `카드를 버리면 그 카드를 지우는 파쇄 화면을 연다`() = runTest { - val repository = FakeCardRepository( - monthResult = AppResult.Success(listOf(angerEntry)), - dateResult = AppResult.Success(listOf(firstCardOfDay)), - ) + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard))) val viewModel = viewModel(repository) viewModel.test(this) { containerHost.load(EmotionCharacter.ANGER) expectState { copy(emotion = EmotionCharacter.ANGER) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } - containerHost.selectCard(angerEntry) - expectState { copy(isCardLoading = true) } - expectState { copy(isCardLoading = false, selectedCard = firstCardOfDay) } + containerHost.selectCard(angerCard) + expectState { copy(selectedCard = angerCard) } // 실제 삭제는 파쇄 화면이 맡으므로 여기서는 카드를 지우지 않는다. containerHost.discardSelectedCard() expectState { copy(selectedCard = null) } - expectSideEffect(ArchiveDetailSideEffect.OpenCardDelete(cardId = firstCardOfDay.id)) + expectSideEffect(ArchiveDetailSideEffect.OpenCardDelete(cardId = angerCard.id)) } // 다시 받아 오는 일은 파쇄 화면에서 돌아올 때 force load 가 맡는다. - assertEquals(1, repository.requestedMonths.size) + assertEquals(1, repository.requests.size) } @Test fun `대화보기를 누르면 채팅방으로 나가지 않고 그 대화를 카드로 띄운다`() = runTest { - val repository = FakeCardRepository( - monthResult = AppResult.Success(listOf(angerEntry)), - dateResult = AppResult.Success(listOf(firstCardOfDay)), - ) + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard))) val conversationRepository = FakeConversationRepository( - AppResult.Success(conversationDetail(firstCardOfDay.conversationId)), + AppResult.Success(conversationDetail(angerCard.conversationId)), ) val viewModel = viewModel(repository, conversationRepository) viewModel.test(this) { containerHost.load(EmotionCharacter.ANGER) expectState { copy(emotion = EmotionCharacter.ANGER) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } - containerHost.selectCard(angerEntry) - expectState { copy(isCardLoading = true) } - expectState { copy(isCardLoading = false, selectedCard = firstCardOfDay) } + containerHost.selectCard(angerCard) + expectState { copy(selectedCard = angerCard) } containerHost.viewSelectedConversation() expectState { copy(isConversationLoading = true) } @@ -256,35 +172,31 @@ class ArchiveDetailViewModelTest { isConversationLoading = false, selectedCard = null, conversationCard = ConversationCard( - card = firstCardOfDay, + card = angerCard, messages = listOf(userMessage, characterMessage), ), ) } } - assertEquals(listOf(firstCardOfDay.conversationId), conversationRepository.requestedConversationIds) + assertEquals(listOf(angerCard.conversationId), conversationRepository.requestedConversationIds) } @Test fun `대화 카드를 닫으면 원래 보던 감정 카드로 돌아온다`() = runTest { - val repository = FakeCardRepository( - monthResult = AppResult.Success(listOf(angerEntry)), - dateResult = AppResult.Success(listOf(firstCardOfDay)), - ) + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard))) val viewModel = viewModel( repository, - FakeConversationRepository(AppResult.Success(conversationDetail(firstCardOfDay.conversationId))), + FakeConversationRepository(AppResult.Success(conversationDetail(angerCard.conversationId))), ) viewModel.test(this) { containerHost.load(EmotionCharacter.ANGER) expectState { copy(emotion = EmotionCharacter.ANGER) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } - containerHost.selectCard(angerEntry) - expectState { copy(isCardLoading = true) } - expectState { copy(isCardLoading = false, selectedCard = firstCardOfDay) } + containerHost.selectCard(angerCard) + expectState { copy(selectedCard = angerCard) } containerHost.viewSelectedConversation() expectState { copy(isConversationLoading = true) } @@ -293,23 +205,20 @@ class ArchiveDetailViewModelTest { isConversationLoading = false, selectedCard = null, conversationCard = ConversationCard( - card = firstCardOfDay, + card = angerCard, messages = listOf(userMessage, characterMessage), ), ) } containerHost.dismissConversationCard() - expectState { copy(conversationCard = null, selectedCard = firstCardOfDay) } + expectState { copy(conversationCard = null, selectedCard = angerCard) } } } @Test fun `대화 조회에 실패하면 감정 카드를 그대로 두고 실패를 알린다`() = runTest { - val repository = FakeCardRepository( - monthResult = AppResult.Success(listOf(angerEntry)), - dateResult = AppResult.Success(listOf(firstCardOfDay)), - ) + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard))) val viewModel = viewModel( repository, FakeConversationRepository(AppResult.Failure(IllegalStateException("network"))), @@ -318,11 +227,10 @@ class ArchiveDetailViewModelTest { viewModel.test(this) { containerHost.load(EmotionCharacter.ANGER) expectState { copy(emotion = EmotionCharacter.ANGER) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } - containerHost.selectCard(angerEntry) - expectState { copy(isCardLoading = true) } - expectState { copy(isCardLoading = false, selectedCard = firstCardOfDay) } + containerHost.selectCard(angerCard) + expectState { copy(selectedCard = angerCard) } containerHost.viewSelectedConversation() expectState { copy(isConversationLoading = true) } @@ -333,10 +241,7 @@ class ArchiveDetailViewModelTest { @Test fun `늦게 온 대화 실패 응답은 이미 닫은 감정 카드를 되살리지 않는다`() = runTest { - val repository = FakeCardRepository( - monthResult = AppResult.Success(listOf(angerEntry)), - dateResult = AppResult.Success(listOf(firstCardOfDay)), - ) + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard))) val conversationGate = CompletableDeferred() val viewModel = viewModel( repository, @@ -347,11 +252,10 @@ class ArchiveDetailViewModelTest { viewModel.test(this) { containerHost.load(EmotionCharacter.ANGER) expectState { copy(emotion = EmotionCharacter.ANGER) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } - containerHost.selectCard(angerEntry) - expectState { copy(isCardLoading = true) } - expectState { copy(isCardLoading = false, selectedCard = firstCardOfDay) } + containerHost.selectCard(angerCard) + expectState { copy(selectedCard = angerCard) } // 대화 응답이 게이트에 걸려 멈춰 있는 동안 감정 카드를 닫는다. containerHost.viewSelectedConversation() @@ -370,15 +274,12 @@ class ArchiveDetailViewModelTest { @Test fun `늦게 온 대화 성공 응답은 이미 닫은 감정 카드를 다시 열지 않는다`() = runTest { - val repository = FakeCardRepository( - monthResult = AppResult.Success(listOf(angerEntry)), - dateResult = AppResult.Success(listOf(firstCardOfDay)), - ) + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard))) val conversationGate = CompletableDeferred() val viewModel = viewModel( repository, FakeConversationRepository( - AppResult.Success(conversationDetail(firstCardOfDay.conversationId)), + AppResult.Success(conversationDetail(angerCard.conversationId)), conversationGate, ), ) @@ -387,11 +288,10 @@ class ArchiveDetailViewModelTest { viewModel.test(this) { containerHost.load(EmotionCharacter.ANGER) expectState { copy(emotion = EmotionCharacter.ANGER) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } - containerHost.selectCard(angerEntry) - expectState { copy(isCardLoading = true) } - expectState { copy(isCardLoading = false, selectedCard = firstCardOfDay) } + containerHost.selectCard(angerCard) + expectState { copy(selectedCard = angerCard) } containerHost.viewSelectedConversation() expectState { copy(isConversationLoading = true) } @@ -417,8 +317,8 @@ class ArchiveDetailViewModelTest { monthResult = AppResult.Success(emptyList()), monthGates = mapOf(slowMonth to slowMonthGate), monthResults = mapOf( - slowMonth to AppResult.Success(listOf(staleEntry)), - fastMonth to AppResult.Success(listOf(angerEntry)), + slowMonth to AppResult.Success(listOf(staleCard)), + fastMonth to AppResult.Success(listOf(angerCard)), ), ) val viewModel = viewModel(repository) @@ -435,7 +335,7 @@ class ArchiveDetailViewModelTest { containerHost.selectMonth(fastMonth) expectState { copy(yearMonth = fastMonth, cards = ArchiveCards.Loading) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } // 뒤늦게 도착한 이전 달 응답이 지금 목록을 덮으면 소비되지 않은 상태가 남는다. slowMonthGate.complete(Unit) @@ -446,12 +346,12 @@ class ArchiveDetailViewModelTest { @Test fun `비우기를 확인하면 다이얼로그를 닫고 파쇄 화면을 연다`() = runTest { - val viewModel = viewModel(FakeCardRepository(AppResult.Success(listOf(angerEntry)))) + val viewModel = viewModel(FakeCardRepository(AppResult.Success(listOf(angerCard)))) viewModel.test(this) { containerHost.load(EmotionCharacter.ANGER) expectState { copy(emotion = EmotionCharacter.ANGER) } - expectState { copy(cards = ArchiveCards.Loaded(listOf(angerEntry))) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } containerHost.showClearDialog() expectState { copy(isClearDialogVisible = true) } @@ -467,42 +367,37 @@ class ArchiveDetailViewModelTest { repository: FakeCardRepository, conversationRepository: FakeConversationRepository = FakeConversationRepository(), ) = ArchiveDetailViewModel( - getCardsByMonth = GetCardsByMonthUseCase(repository), - getCardsByDate = GetCardsByDateUseCase(repository), - clearCardCache = ClearCardCacheUseCase(repository), + getCardsByMonthAndEmotion = GetCardsByMonthAndEmotionUseCase(repository), getConversation = GetConversationUseCase(conversationRepository), ) private companion object { val DATE: LocalDate = LocalDate.of(2026, 8, 15) - val angerEntry = CardEntry(date = DATE, indexInDate = 0, character = EmotionCharacter.ANGER) - val staleEntry = CardEntry(date = DATE.minusMonths(2), indexInDate = 0, character = EmotionCharacter.ANGER) - val joyEntry = CardEntry(date = DATE, indexInDate = 1, character = EmotionCharacter.JOY) + val angerCard = card(id = 1L, character = EmotionCharacter.ANGER) + val joyCard = card(id = 2L, character = EmotionCharacter.JOY) + val staleCard = card(id = 3L, character = EmotionCharacter.ANGER, date = DATE.minusMonths(2)) - val firstCardOfDay = card(id = 1L, character = EmotionCharacter.ANGER) - val secondCardOfDay = card(id = 2L, character = EmotionCharacter.JOY) - - fun card(id: Long, character: EmotionCharacter) = Card( + fun card(id: Long, character: EmotionCharacter, date: LocalDate = DATE) = Card( id = id, conversationId = id * 10, character = character, emotionLabel = character.displayName, summary = "요약 $id", message = "대사 $id", - date = DATE, + date = date, ) val userMessage = Message( id = 1L, - conversationId = firstCardOfDay.conversationId, + conversationId = angerCard.conversationId, sender = MessageSender.User, content = "오늘 진짜 화났어", createdTime = "오후 1:37", ) val characterMessage = Message( id = 2L, - conversationId = firstCardOfDay.conversationId, + conversationId = angerCard.conversationId, sender = MessageSender.Character(EmotionCharacter.ANGER), content = "그럴 만했네!", createdTime = "오후 1:38", @@ -516,32 +411,28 @@ class ArchiveDetailViewModelTest { } private class FakeCardRepository( - private val monthResult: AppResult>, - private val dateResult: AppResult> = AppResult.Success(emptyList()), - /** clearCache() 이후의 getCardsByDate 응답. 지정하지 않으면 [dateResult] 를 그대로 다시 준다. */ - private val dateResultAfterClear: AppResult>? = null, + private val monthResult: AppResult>, /** 여기 담긴 달은 게이트가 열릴 때까지 응답을 붙잡는다. 늦게 도착하는 응답을 만들 때 쓴다. */ private val monthGates: Map> = emptyMap(), /** 달마다 다른 목록을 줘야 할 때만 채운다. 없는 달은 [monthResult] 로 답한다. */ - private val monthResults: Map>> = emptyMap(), + private val monthResults: Map>> = emptyMap(), ) : CardRepository { - val requestedMonths = mutableListOf() - val requestedDates = mutableListOf() - var clearCacheCallCount = 0 - private set - - override suspend fun getCardsByDate(date: LocalDate): AppResult> { - requestedDates += date - return if (clearCacheCallCount > 0) dateResultAfterClear ?: dateResult else dateResult - } + /** 감정도 서버로 넘기므로 달만이 아니라 (감정, 달) 쌍으로 기록한다. */ + val requests = mutableListOf>() - override suspend fun getCardsByMonth(yearMonth: YearMonth): AppResult> { - requestedMonths += yearMonth + override suspend fun getCardsByMonthAndEmotion( + character: EmotionCharacter, + yearMonth: YearMonth, + ): AppResult> { + requests += character to yearMonth monthGates[yearMonth]?.await() return monthResults[yearMonth] ?: monthResult } + override suspend fun getCardsByDate(date: LocalDate): AppResult> = + error("보관함 테스트에서 쓰지 않는다") + override suspend fun createCard( conversationId: Long, character: EmotionCharacter, @@ -557,9 +448,7 @@ private class FakeCardRepository( override suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult = error("보관함 테스트에서 쓰지 않는다") - override suspend fun clearCache() { - clearCacheCallCount++ - } + override suspend fun clearCache() = error("보관함 테스트에서 쓰지 않는다") } private class FakeConversationRepository( diff --git a/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt b/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt index cbf8a226..4f9017da 100644 --- a/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt +++ b/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt @@ -3,7 +3,6 @@ package com.gamss.android.feature.home import androidx.paging.PagingData import com.gamss.android.core.common.AppResult import com.gamss.android.domain.card.Card -import com.gamss.android.domain.card.CardEntry import com.gamss.android.domain.card.CardRepository import com.gamss.android.domain.card.CreateCardUseCase import com.gamss.android.domain.card.CreateConversationCardUseCase @@ -128,7 +127,10 @@ internal class RecordingConversationRepository( } private object NoCardRepository : CardRepository { - override suspend fun getCardsByMonth(yearMonth: YearMonth): AppResult> = + override suspend fun getCardsByMonthAndEmotion( + character: EmotionCharacter, + yearMonth: YearMonth, + ): AppResult> = error("홈 테스트에서 쓰지 않는다") override suspend fun getCardsByDate(date: LocalDate): AppResult> = From 0a515a170d083dc639fa9c64ca4a00355775dd84 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:14:01 +0900 Subject: [PATCH 03/12] =?UTF-8?q?refactor:=20=EB=B0=A9=EA=B8=88=20?= =?UTF-8?q?=EB=B2=84=EB=A6=B0=20=EC=B9=B4=EB=93=9C=EB=A5=BC=20id=EB=A1=9C?= =?UTF-8?q?=20=EC=A7=80=EB=AA=A9=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 날짜와 그날 마지막 순번으로 어느 종이가 방금 버린 것인지 추측하고 있었습니다. 카드 id 가 생겼으므로 그대로 지목합니다. 같은 날 여러 장을 버려도 어긋나지 않습니다. - ChatRoomScreen 의 onCardDiscard 가 날짜 대신 카드 id 를 넘깁니다. - Navigator 가 드는 일회성 신호도 id 로 바뀝니다. - PaperPile 의 droppedIndex 가 indexOfFirst 한 줄이 됩니다. --- .../main/kotlin/com/gamss/android/app/main/MainScreen.kt | 8 ++++---- .../kotlin/com/gamss/android/app/navigation/Navigator.kt | 9 ++++----- .../com/gamss/android/feature/chat/ChatRoomScreen.kt | 9 ++++----- .../com/gamss/android/feature/chat/ChatRoomTestFakes.kt | 6 ++++-- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt b/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt index a9d9836a..ad7e9020 100644 --- a/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt +++ b/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt @@ -140,11 +140,11 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { // 보관함 상세도 탭 안쪽의 상세 화면이라 다른 상세들과 같은 슬라이드를 쓴다. entry(metadata = detailSlideTransition) { key -> // 대화방에 들렀다 돌아오면 이 컴포지션이 다시 만들어진다. 그때는 이미 비어 있어야 한다. - val droppedCardDate = remember { navigator.consumeDroppedCardDate() } + val droppedCardId = remember { navigator.consumeDroppedCardId() } val hasShreddedCard = remember { navigator.consumeShreddedCard() } ArchiveDetailScreen( emotion = key.emotion, - droppedCardDate = droppedCardDate, + droppedCardId = droppedCardId, hasShreddedCard = hasShreddedCard, onBackClick = navigator::goBack, onNavigateToCardDelete = { cardId -> navigator.navigate(CardDeleteKey(key.emotion, cardId)) }, @@ -196,8 +196,8 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { ChatRoomScreen( conversationId = key.conversationId, // 버린 카드가 어디로 갔는지 바로 보여 준다. - onCardDiscard = { emotion, date -> - navigator.openDroppedCard(ArchiveKey, ArchiveDetailKey(emotion), date) + onCardDiscard = { emotion, cardId -> + navigator.openDroppedCard(ArchiveKey, ArchiveDetailKey(emotion), cardId) }, onCardSkip = navigator::goBack, onBackClick = navigator::goBack, diff --git a/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt b/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt index c1895867..fb889fc6 100644 --- a/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt +++ b/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt @@ -1,7 +1,6 @@ package com.gamss.android.app.navigation import androidx.navigation3.runtime.NavKey -import java.time.LocalDate /** * NavigationState를 변경하는 앱 전용 navigator. @@ -18,9 +17,9 @@ class Navigator(val state: NavigationState) { * 때마다 방금 버린 것처럼 또 떨어진다. 프로세스가 죽으면 이 값도 사라지는데, 그때는 낙하를 * 건너뛰는 쪽이 맞다. */ - private var droppedCardDate: LocalDate? = null + private var droppedCardId: Long? = null - fun consumeDroppedCardDate(): LocalDate? = droppedCardDate.also { droppedCardDate = null } + fun consumeDroppedCardId(): Long? = droppedCardId.also { droppedCardId = null } /** * 파쇄 화면이 카드를 지웠다는 일회성 신호. 위 날짜와 같은 이유로 key 에 싣지 않는다. @@ -81,8 +80,8 @@ class Navigator(val state: NavigationState) { * currentSubStack 이 옮겨간 탭을 가리켜 손댈 수 없다. 옮겨간 탭도 root 까지 비우고 [detail] * 하나만 올린다. 보관함에 다른 감정 칸이 열려 있었다면 뒤로 나갔을 때 그 칸이 다시 뜬다. */ - fun openDroppedCard(topLevel: NavKey, detail: NavKey, droppedCardDate: LocalDate) { - this.droppedCardDate = droppedCardDate + fun openDroppedCard(topLevel: NavKey, detail: NavKey, droppedCardId: Long) { + this.droppedCardId = droppedCardId clearSubStack() goToTopLevel(topLevel) clearSubStack() diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 9393ae14..22eed466 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -74,20 +74,19 @@ import com.gamss.android.feature.chat.util.rememberChatScrollState import kotlinx.coroutines.delay import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect -import java.time.LocalDate /** * 두 콜백 모두 이 화면을 실제로 벗어나야 한다. 머무르면 카드 단계가 그대로라 접기 연출이 다시 열린다. * * @param onCardDiscard 접은 카드를 통에 버린 뒤 호출한다. 버린 카드가 쌓인 보관함 칸으로 보내려면 - * 어느 감정 칸인지, 그중 어느 날 카드인지 알아야 하므로 함께 넘긴다. + * 어느 감정 칸인지, 그중 어느 카드인지 알아야 하므로 함께 넘긴다. * @param onCardSkip 연출을 건너뛴 뒤 호출한다. 카드는 이미 기록에 남아 결과는 같지만, 버리는 * 동작을 하지 않았으니 보관함까지 데려가지 않는다. */ @Composable fun ChatRoomScreen( conversationId: Long, - onCardDiscard: (EmotionCharacter, LocalDate) -> Unit, + onCardDiscard: (EmotionCharacter, Long) -> Unit, onCardSkip: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, @@ -151,7 +150,7 @@ private fun ChatRoomEndFlowHost( onEndCancel: () -> Unit, onFoldTap: () -> Unit, onCardSkip: () -> Unit, - onCardDiscard: (EmotionCharacter, LocalDate) -> Unit, + onCardDiscard: (EmotionCharacter, Long) -> Unit, ) { // else 를 두지 않아야 단계를 추가할 때 화면이 컴파일 에러로 알려준다. when (endFlow) { @@ -165,7 +164,7 @@ private fun ChatRoomEndFlowHost( foldStage = endFlow.foldStage, onFoldTap = onFoldTap, onSkip = onCardSkip, - onDiscard = { onCardDiscard(endFlow.card.character, endFlow.card.date) }, + onDiscard = { onCardDiscard(endFlow.card.character, endFlow.card.id) }, ) EndFlow.NotStarted, diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt index 6d0a58b5..f1a2f29c 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt @@ -3,7 +3,6 @@ package com.gamss.android.feature.chat import androidx.paging.PagingData import com.gamss.android.core.common.AppResult import com.gamss.android.domain.card.Card -import com.gamss.android.domain.card.CardEntry import com.gamss.android.domain.card.CardRepository import com.gamss.android.domain.card.CreateCardUseCase import com.gamss.android.domain.card.CreateConversationCardUseCase @@ -221,7 +220,10 @@ internal class CountingCardRepository( var calls = 0 private set - override suspend fun getCardsByMonth(yearMonth: YearMonth): AppResult> = + override suspend fun getCardsByMonthAndEmotion( + character: EmotionCharacter, + yearMonth: YearMonth, + ): AppResult> = error("채팅 테스트에서 쓰지 않는다") override suspend fun getCardsByDate(date: LocalDate): AppResult> = From af2e14b0b3822a2252a9fe1376446042216e7c98 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:08:33 +0900 Subject: [PATCH 04/12] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=A6=84=EC=9D=B4?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=20=EB=A7=90=ED=95=98=EB=8A=94=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=EC=9D=84=20=EA=B1=B7=EC=96=B4=EB=83=85=EB=8B=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 감정별 조회를 넣으면서 붙인 주석 중 시그니처를 되풀이하는 부분을 지웁니다. 최신순으로 온다는 API 계약과 오래된 순으로 뒤집는 이유만 남깁니다. --- .../java/com/gamss/android/data/remote/card/CardService.kt | 2 +- .../kotlin/com/gamss/android/domain/card/CardRepository.kt | 2 +- .../android/domain/card/GetCardsByMonthAndEmotionUseCase.kt | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/data/src/main/java/com/gamss/android/data/remote/card/CardService.kt b/data/src/main/java/com/gamss/android/data/remote/card/CardService.kt index 13dfa0b3..0cdf7cfa 100644 --- a/data/src/main/java/com/gamss/android/data/remote/card/CardService.kt +++ b/data/src/main/java/com/gamss/android/data/remote/card/CardService.kt @@ -16,7 +16,7 @@ internal interface CardService { @GET("/api/cards") suspend fun getCardsByDate(@Query("date") date: String): ApiResponse> - /** 그 달(KST) 그 감정의 카드를 내용까지 담아 최신순으로 준다. 페이지네이션이 없다. */ + /** 최신순이고 페이지네이션이 없다. 한 달 한 감정 분량을 한 번에 준다. */ @GET("/api/cards/monthly/emotions/{emotion}") suspend fun getCardsByMonthAndEmotion( @Path("emotion") emotion: String, diff --git a/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt index b011b220..1ace78bf 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt @@ -13,7 +13,7 @@ interface CardRepository { */ suspend fun getCardsByDate(date: LocalDate): AppResult> - /** 해당 달(KST)에 생성된 그 감정의 카드를 내용까지 가져온다. 오래된 순이다. */ + /** 오래된 순으로 돌려준다. 종이 더미가 목록 순서대로 쌓기 때문이다. */ suspend fun getCardsByMonthAndEmotion( character: EmotionCharacter, yearMonth: YearMonth, diff --git a/domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByMonthAndEmotionUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByMonthAndEmotionUseCase.kt index 6495871f..cdd8d4d1 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByMonthAndEmotionUseCase.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/card/GetCardsByMonthAndEmotionUseCase.kt @@ -6,13 +6,12 @@ import com.gamss.android.domain.usecase.UseCase import java.time.YearMonth import javax.inject.Inject -/** 보관함 한 칸이 한 번에 보는 범위. 감정 없이 달만으로는 조회하지 않는다. */ +/** 감정 없이 달만으로는 조회하지 않는다. */ data class MonthlyEmotionQuery( val character: EmotionCharacter, val yearMonth: YearMonth, ) -/** 선택한 달(KST)에 생성된 그 감정의 카드를 내용까지 조회한다. */ class GetCardsByMonthAndEmotionUseCase @Inject constructor( private val cardRepository: CardRepository, ) : UseCase>> { From 904a1cb654e3ca319266d6e03b70338207814d35 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:37:56 +0900 Subject: [PATCH 05/12] =?UTF-8?q?refactor:=20=EC=82=AC=EC=8B=A4=EA=B3=BC?= =?UTF-8?q?=20=EC=96=B4=EA=B8=8B=EB=82=9C=20=EC=A3=BC=EC=84=9D=EA=B3=BC=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EA=B0=84=20=EC=A4=91=EB=B3=B5=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=EC=9D=84=20=EA=B1=B7=EC=96=B4=EB=83=85=EB=8B=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 순번(indexInDate)을 없앤 뒤에도 그 순번을 근거로 대는 주석이 두 군데 남아 있었습니다. 사실과 다르니 고칩니다. - ArchiveDetailScreen: "같은 날짜 뒤 순번이 당겨져" → 지운 카드가 목록에 남는다 - Navigator: "위 날짜와 같은 이유로" → 이제 날짜가 아니라 카드 id 다 같은 문장이 계층마다 반복되던 것도 한 곳만 남깁니다. 오래된 순으로 준다는 계약은 CardRepository 에만, YearMonth.toString() 이 맞물린다는 사실은 Room 구현에만 둡니다. CardService 와 글자까지 겹치던 CardRepository 의 삭제 KDoc 세 개를 줄입니다. Navigator 의 navigate·goBack·finishCurrentFlow·goToKey·goToTopLevel·clearSubStack 은 이름이 그대로 하는 말을 지우고 규칙만 남깁니다. --- .../gamss/android/app/navigation/Navigator.kt | 21 ++----------------- .../gamss/android/data/local/card/CardDao.kt | 7 +------ .../local/card/RoomCardLocalDataSource.kt | 1 - .../data/repository/CardRepositoryImpl.kt | 5 +---- .../android/domain/card/CardRepository.kt | 6 +++--- .../feature/archive/ArchiveDetailScreen.kt | 3 +-- 6 files changed, 8 insertions(+), 35 deletions(-) diff --git a/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt b/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt index fb889fc6..0b4d3a78 100644 --- a/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt +++ b/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt @@ -21,17 +21,12 @@ class Navigator(val state: NavigationState) { fun consumeDroppedCardId(): Long? = droppedCardId.also { droppedCardId = null } - /** - * 파쇄 화면이 카드를 지웠다는 일회성 신호. 위 날짜와 같은 이유로 key 에 싣지 않는다. - */ + /** 위 카드 id 와 같은 이유로 key 에 싣지 않는다. */ private var hasShreddedCard = false fun consumeShreddedCard(): Boolean = hasShreddedCard.also { hasShreddedCard = false } - /** - * 지정한 key로 이동한다. - * top-level key인지, 현재 탭인지, 상세 key인지에 따라 stack 갱신 규칙이 달라진다. - */ + /** top-level key인지, 현재 탭인지, 상세 key인지에 따라 stack 갱신 규칙이 달라진다. */ fun navigate(key: NavKey) { when (key) { state.currentTopLevelKey -> clearSubStack() @@ -41,8 +36,6 @@ class Navigator(val state: NavigationState) { } /** - * 현재 위치에서 뒤로 이동한다. - * * 현재 탭의 root 화면에서는 이전에 방문한 탭으로 돌아가고, 상세 화면에서는 현재 탭의 * sub stack에서 한 단계 pop한다. */ @@ -56,7 +49,6 @@ class Navigator(val state: NavigationState) { } } - /** 완료된 상세 흐름을 닫고 현재 탭의 첫 화면으로 돌아간다. */ fun finishCurrentFlow() { clearSubStack() } @@ -88,9 +80,6 @@ class Navigator(val state: NavigationState) { goToKey(detail) } - /** - * 현재 탭의 상세 화면으로 이동한다. - */ private fun goToKey(key: NavKey) { state.currentSubStack.apply { remove(key) @@ -98,9 +87,6 @@ class Navigator(val state: NavigationState) { } } - /** - * 다른 top-level 탭으로 이동한다. - */ private fun goToTopLevel(key: NavKey) { state.topLevelStack.apply { if (key == state.startKey) { @@ -112,9 +98,6 @@ class Navigator(val state: NavigationState) { } } - /** - * 현재 탭을 다시 선택했을 때 root 화면만 남긴다. - */ private fun clearSubStack() { state.currentSubStack.run { if (size > 1) subList(1, size).clear() diff --git a/data/src/main/java/com/gamss/android/data/local/card/CardDao.kt b/data/src/main/java/com/gamss/android/data/local/card/CardDao.kt index 0f363687..0c31a00c 100644 --- a/data/src/main/java/com/gamss/android/data/local/card/CardDao.kt +++ b/data/src/main/java/com/gamss/android/data/local/card/CardDao.kt @@ -12,12 +12,7 @@ internal interface CardDao { @Query("SELECT * FROM cards WHERE date = :date ORDER BY id ASC") suspend fun findByDate(date: String): List - /** - * 날짜를 `yyyy-MM-dd` 문자열로 담으므로 `yyyy-MM` 접두사로 그 달을 고른다. - * - * 서버 감정 키(`emotion`)를 그대로 비교한다. 오래된 순으로 돌려주는 것은 보관함 더미가 목록 - * 순서대로 쌓기 때문이다. - */ + /** 날짜를 `yyyy-MM-dd` 문자열로 담으므로 `yyyy-MM` 접두사로 그 달을 고른다. */ @Query( "SELECT * FROM cards WHERE emotion = :emotion AND date LIKE :yearMonthPrefix || '%' " + "ORDER BY date ASC, id ASC", diff --git a/data/src/main/java/com/gamss/android/data/local/card/RoomCardLocalDataSource.kt b/data/src/main/java/com/gamss/android/data/local/card/RoomCardLocalDataSource.kt index afa85974..c820298b 100644 --- a/data/src/main/java/com/gamss/android/data/local/card/RoomCardLocalDataSource.kt +++ b/data/src/main/java/com/gamss/android/data/local/card/RoomCardLocalDataSource.kt @@ -14,7 +14,6 @@ internal class RoomCardLocalDataSource @Inject constructor( override suspend fun findByDate(date: LocalDate): List = cardDao.findByDate(date.toString()) - // YearMonth.toString() 이 date 컬럼의 yyyy-MM-dd 앞부분과 그대로 맞는다. override suspend fun findByEmotionAndMonth(emotion: String, yearMonth: YearMonth): List = cardDao.findByEmotionAndMonth(emotion, yearMonth.toString()) diff --git a/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt index b8c579bd..290d6804 100644 --- a/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt @@ -68,8 +68,6 @@ internal class CardRepositoryImpl @Inject constructor( } } - // YearMonth.toString() 이 서버가 요구하는 yyyy-MM 그대로다. - /** 그 감정 칸의 그 달이 캐시에 있으면 캐시를 쓰고, 없거나 캐시 조회에 실패하면 서버에서 받는다. */ override suspend fun getCardsByMonthAndEmotion( character: EmotionCharacter, yearMonth: YearMonth, @@ -105,8 +103,7 @@ internal class CardRepositoryImpl @Inject constructor( cardLocalDataSource.upsertAll(validCards.map { (raw, _) -> raw.toEntity() }) - // 서버는 최신순으로 준다. 보관함 더미는 목록 순서대로 위에 얹으며 쌓으므로, 뒤집어 - // 오래된 순으로 돌려줘야 최근 카드가 맨 위에 온다. + // 서버는 최신순으로 준다. validCards.map { (_, card) -> card }.reversed() } } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt index 1ace78bf..153792bf 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt @@ -26,13 +26,13 @@ interface CardRepository { summary: String, ): AppResult - /** 모든 카드와 카드가 나온 채팅방을 함께 삭제한다. 되돌릴 수 없다. */ + /** 되돌릴 수 없다. */ suspend fun deleteAllCards(): AppResult - /** 카드 한 장과 카드가 나온 채팅방을 함께 삭제한다. 되돌릴 수 없다. */ + /** 되돌릴 수 없다. */ suspend fun deleteCard(cardId: Long): AppResult - /** 해당 감정인 카드와 카드가 나온 채팅방을 모두 함께 삭제한다. 되돌릴 수 없다. */ + /** 되돌릴 수 없다. */ suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult /** diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt index e9e15660..a0637424 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt @@ -63,8 +63,7 @@ fun ArchiveDetailScreen( val shareChooserTitle = stringResource(R.string.archive_card_share_chooser_title) val conversationLoadFailedMessage = stringResource(R.string.archive_conversation_load_error) - // 파쇄 화면에서 돌아왔을 때도 다시 받아야 한다. 카드를 지우면 같은 날짜 뒤 순번이 한 칸씩 - // 당겨져, 살아남은 종이가 들고 있던 순번이 서버와 어긋난다. + // 파쇄 화면에서 돌아왔을 때도 다시 받아야 한다. 지운 카드가 목록에 그대로 남는다. LaunchedEffect(emotion) { viewModel.load(emotion, force = droppedCardId != null || hasShreddedCard) } From 34fcf4e4082e3de8f9868565739bb2b558be98be Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:13:06 +0900 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=EA=B0=80=20=EB=B0=94=EB=80=90=20=EC=B9=B4=EB=93=9C=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20DB=20=EB=B2=84=EC=A0=84=EC=9D=84=20=EC=98=AC?= =?UTF-8?q?=EB=A6=BD=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index_in_date 를 지우면서 identityHash 가 바뀌었는데 version 은 1 에 그대로 있었습니다. Room 은 DB 를 열 때 저장된 해시를 먼저 확인하고 다르면 예외로 던지는데, fallbackToDestructiveMigration 은 버전이 바뀐 경우만 타므로 이 상황을 구제하지 못합니다. 이전 스키마가 깔린 기기는 카드 화면에 들어가는 순간 죽습니다. - version 을 2 로 올려 파괴적 마이그레이션이 실제로 돌게 합니다. - 덮어써 버린 1.json 을 변경 전 스키마로 되돌리고, 새 스키마는 2.json 으로 내보냅니다. - 엔티티를 고치면 version 도 올려야 한다는 이유를 클래스 KDoc 에 남깁니다. --- .../1.json | 12 ++- .../2.json | 78 +++++++++++++++++++ .../android/data/local/card/GamssDatabase.kt | 6 +- 3 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 data/schemas/com.gamss.android.data.local.card.GamssDatabase/2.json diff --git a/data/schemas/com.gamss.android.data.local.card.GamssDatabase/1.json b/data/schemas/com.gamss.android.data.local.card.GamssDatabase/1.json index f328a653..56105a1d 100644 --- a/data/schemas/com.gamss.android.data.local.card.GamssDatabase/1.json +++ b/data/schemas/com.gamss.android.data.local.card.GamssDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "ed660b41190bf72caaaa9ce9e9647cd2", + "identityHash": "9cfd76bd48d1b7540b588561b3258716", "entities": [ { "tableName": "cards", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `conversation_id` INTEGER NOT NULL, `emotion` TEXT NOT NULL, `emotion_label` TEXT NOT NULL, `summary` TEXT NOT NULL, `message` TEXT NOT NULL, `date` TEXT NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `conversation_id` INTEGER NOT NULL, `emotion` TEXT NOT NULL, `emotion_label` TEXT NOT NULL, `summary` TEXT NOT NULL, `message` TEXT NOT NULL, `date` TEXT NOT NULL, `index_in_date` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -49,6 +49,12 @@ "columnName": "date", "affinity": "TEXT", "notNull": true + }, + { + "fieldPath": "indexInDate", + "columnName": "index_in_date", + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { @@ -72,7 +78,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ed660b41190bf72caaaa9ce9e9647cd2')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9cfd76bd48d1b7540b588561b3258716')" ] } } \ No newline at end of file diff --git a/data/schemas/com.gamss.android.data.local.card.GamssDatabase/2.json b/data/schemas/com.gamss.android.data.local.card.GamssDatabase/2.json new file mode 100644 index 00000000..342b5066 --- /dev/null +++ b/data/schemas/com.gamss.android.data.local.card.GamssDatabase/2.json @@ -0,0 +1,78 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "ed660b41190bf72caaaa9ce9e9647cd2", + "entities": [ + { + "tableName": "cards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `conversation_id` INTEGER NOT NULL, `emotion` TEXT NOT NULL, `emotion_label` TEXT NOT NULL, `summary` TEXT NOT NULL, `message` TEXT NOT NULL, `date` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversation_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emotion", + "columnName": "emotion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emotionLabel", + "columnName": "emotion_label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_cards_conversation_id", + "unique": true, + "columnNames": [ + "conversation_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cards_conversation_id` ON `${TABLE_NAME}` (`conversation_id`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ed660b41190bf72caaaa9ce9e9647cd2')" + ] + } +} \ No newline at end of file diff --git a/data/src/main/java/com/gamss/android/data/local/card/GamssDatabase.kt b/data/src/main/java/com/gamss/android/data/local/card/GamssDatabase.kt index c4000104..0f30a9fb 100644 --- a/data/src/main/java/com/gamss/android/data/local/card/GamssDatabase.kt +++ b/data/src/main/java/com/gamss/android/data/local/card/GamssDatabase.kt @@ -4,9 +4,13 @@ import androidx.room3.Database import androidx.room3.RoomDatabase import com.gamss.android.data.local.card.model.CardEntity +/** + * 엔티티를 고치면 version 을 올려야 한다. identityHash 만 바뀌고 version 이 그대로면 파괴적 + * 마이그레이션도 타지 않아, 이전 스키마가 깔린 기기는 DB 첫 접근에서 예외로 죽는다. + */ @Database( entities = [CardEntity::class], - version = 1, + version = 2, exportSchema = true, ) internal abstract class GamssDatabase : RoomDatabase() { From 299905b6970f458cc197ae04d1fb2d4784269f2e Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:13:16 +0900 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20=EC=BA=90=EC=8B=9C=EC=99=80=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EA=B0=80=20=EA=B0=99=EC=9D=80=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=EB=A1=9C=20=EC=B9=B4=EB=93=9C=EB=A5=BC=20=EB=8F=8C?= =?UTF-8?q?=EB=A0=A4=EC=A3=BC=EA=B2=8C=20=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캐시 경로는 SQL 의 date, id 오름차순을 쓰고 서버 경로는 "서버가 최신순으로 준다"는 가정 아래 reversed() 로 뒤집었습니다. 두 결과가 일치하는 것은 서버 정렬이 정확히 그 역순일 때뿐인데, 그 가정은 코드로도 테스트로도 고정돼 있지 않았습니다. 같은 목록이 첫 진입과 재진입에서 다른 순서로 쌓일 수 있습니다. - 두 경로 모두 date, id 기준 결정적 정렬로 정규화하고 서버 순서 가정을 걷어냅니다. - 두 경로가 같은 순서를 내는지, 캐시 조회가 실패하면 서버로 대체하는지, 월별 응답의 data 가 null 이면 실패로 전하는지 테스트로 덮습니다. - 사라진 indexInDate 를 설명하던 테스트 주석을 지금 동작으로 고칩니다. --- .../data/repository/CardRepositoryImpl.kt | 11 ++- .../data/repository/CardRepositoryImplTest.kt | 77 +++++++++++++++++-- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt index 290d6804..1e4a1892 100644 --- a/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt @@ -77,6 +77,7 @@ internal class CardRepositoryImpl @Inject constructor( cardLocalDataSource .findByEmotionAndMonth(serverEmotion, yearMonth) .map { it.toDomain() } + .sortedOldestFirst() } .onFailure { throwable -> if (throwable is CancellationException) throw throwable @@ -103,8 +104,7 @@ internal class CardRepositoryImpl @Inject constructor( cardLocalDataSource.upsertAll(validCards.map { (raw, _) -> raw.toEntity() }) - // 서버는 최신순으로 준다. - validCards.map { (_, card) -> card }.reversed() + validCards.map { (_, card) -> card }.sortedOldestFirst() } } @@ -192,3 +192,10 @@ internal class CardRepositoryImpl @Inject constructor( } } } + +/** + * 캐시와 서버, 두 경로가 같은 목록에 같은 순서를 내야 한다. 서버 정렬을 가정하지 않고 여기서 + * 확정한다. + */ +private fun List.sortedOldestFirst(): List = + sortedWith(compareBy({ it.date }, { it.id })) diff --git a/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt b/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt index 0a00bd6c..650624dc 100644 --- a/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt +++ b/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt @@ -1,5 +1,6 @@ package com.gamss.android.data.repository +import android.util.Log import com.gamss.android.core.common.AppResult import com.gamss.android.core.common.network.ApiException import com.gamss.android.data.local.card.CardLocalDataSource @@ -14,8 +15,11 @@ import com.gamss.android.domain.auth.SessionExpiredException import com.gamss.android.domain.emotion.EmotionCharacter import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.slot +import io.mockk.unmockkStatic import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -176,10 +180,7 @@ class CardRepositoryImplTest { coVerify(exactly = 1) { cardLocalDataSource.deleteAll() } } - /** - * 카드를 지우면 같은 날짜 뒤 카드들의 indexInDate 가 한 칸씩 당겨지는데, 이 메서드는 지운 카드의 - * 날짜를 모르기 때문에 그 카드만 골라 지우는 대신 캐시 전체를 비워 다음 조회 때 다시 채운다. - */ + /** 지운 카드의 감정도 날짜도 모르므로, 그 한 장만 골라 지우는 대신 캐시 전체를 비운다. */ @Test fun `이미 삭제된 카드도 성공으로 전달하며 캐시를 비운다`() = runTest { coEvery { cardService.deleteCard(1L) } returns ApiResponse( @@ -292,7 +293,7 @@ class CardRepositoryImplTest { } @Test - fun `감정과 월을 API 형식으로 조회하고 오래된 순으로 뒤집는다`() = runTest { + fun `감정과 월을 API 형식으로 조회하고 오래된 순으로 돌려준다`() = runTest { stubEmptyMonthCache() coEvery { cardService.getCardsByMonthAndEmotion("ANGER", "2026-08") } returns ApiResponse( success = true, @@ -350,6 +351,72 @@ class CardRepositoryImplTest { assertTrue((result as AppResult.Success).data.isEmpty()) } + /** + * 같은 목록인데 캐시에서 왔는지 서버에서 왔는지에 따라 순서가 달라지면 종이 더미가 진입 경로마다 + * 다르게 쌓인다. 두 경로가 같은 기준으로 정렬하는지 못 박는다. + */ + @Test + fun `캐시 경로와 서버 경로가 같은 순서를 돌려준다`() = runTest { + stubEmptyMonthCache() + coEvery { cardService.getCardsByMonthAndEmotion(any(), any()) } returns ApiResponse( + success = true, + data = listOf( + cardResponse(emotion = "ANGER").copy(id = 3L, date = "2026-08-17"), + cardResponse(emotion = "ANGER").copy(id = 2L, date = "2026-08-15"), + cardResponse(emotion = "ANGER").copy(id = 1L, date = "2026-08-15"), + ), + ) + val fromServer = repository.getCardsByMonthAndEmotion(EmotionCharacter.ANGER, YearMonth.of(2026, 8)) + + coEvery { + cardLocalDataSource.findByEmotionAndMonth("ANGER", YearMonth.of(2026, 8)) + } returns listOf( + cardEntity(id = 3L, date = "2026-08-17"), + cardEntity(id = 2L), + cardEntity(id = 1L), + ) + val fromCache = repository.getCardsByMonthAndEmotion(EmotionCharacter.ANGER, YearMonth.of(2026, 8)) + + val serverOrder = (fromServer as AppResult.Success).data.map { it.id } + assertEquals(listOf(1L, 2L, 3L), serverOrder) + assertEquals(serverOrder, (fromCache as AppResult.Success).data.map { it.id }) + } + + /** 폴백 경로가 경고를 남기므로 Log 를 세워 둔다. 유닛 테스트에서는 mock 이 없어 그대로 던진다. */ + @Test + fun `캐시 조회가 실패하면 서버 조회로 대체한다`() = runTest { + mockkStatic(Log::class) + every { Log.w(any(), any(), any()) } returns 0 + try { + coEvery { + cardLocalDataSource.findByEmotionAndMonth(any(), any()) + } throws IllegalStateException("db") + coEvery { cardLocalDataSource.upsertAll(any()) } returns Unit + coEvery { cardService.getCardsByMonthAndEmotion(any(), any()) } returns ApiResponse( + success = true, + data = listOf(cardResponse(emotion = "ANGER")), + ) + + val result = repository.getCardsByMonthAndEmotion(EmotionCharacter.ANGER, YearMonth.of(2026, 8)) + + assertEquals(listOf(1L), (result as AppResult.Success).data.map { it.id }) + coVerify(exactly = 1) { cardService.getCardsByMonthAndEmotion(any(), any()) } + } finally { + unmockkStatic(Log::class) + } + } + + /** 빈 달은 `data = []` 로 온다는 계약에 기댄다. null 이면 빈 상태가 아니라 오류로 알린다. */ + @Test + fun `월별 감정 응답에 카드 데이터가 없으면 실패로 전한다`() = runTest { + stubEmptyMonthCache() + coEvery { cardService.getCardsByMonthAndEmotion(any(), any()) } returns ApiResponse(success = true) + + val result = repository.getCardsByMonthAndEmotion(EmotionCharacter.ANGER, YearMonth.of(2026, 8)) + + assertTrue((result as AppResult.Failure).throwable is IllegalStateException) + } + @Test fun `캐시를 비울 때 서버에는 아무 요청도 보내지 않는다`() = runTest { coEvery { cardLocalDataSource.deleteAll() } returns Unit From d813b0048bb6d7c08197e0fec76403d0e6698e00 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:13:30 +0900 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20=EB=8B=AC=EC=9D=84=20=EB=B0=94?= =?UTF-8?q?=EA=BF=94=20=EB=8F=8C=EC=95=84=EC=99=80=EB=8F=84=20=EB=B2=84?= =?UTF-8?q?=EB=A6=B0=20=EC=B9=B4=EB=93=9C=EA=B0=80=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=96=A8=EC=96=B4=EC=A7=80=EC=A7=80=20=EC=95=8A=EA=B2=8C=20?= =?UTF-8?q?=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한 번 쓰고 비우는 기록을 종이 더미가 들고 있었습니다. 달을 바꾸면 목록이 Loading 으로 돌아가 더미가 컴포지션에서 빠지므로 그 기록도 함께 폐기되고, 원래 달로 돌아오면 화면이 계속 들고 있는 값으로 다시 초기화됩니다. 카드를 버린 뒤 지난 달을 거쳐 돌아오면 같은 종이가 또 떨어졌습니다. - 소비 기록을 더미보다 오래 사는 화면으로 올리고, 더미는 다 썼다고 알리기만 합니다. - 다시 받는 동안 지난 목록을 남기지 않습니다. 파쇄 화면에서 돌아온 직후 응답이 늦으면 이미 지운 종이를 눌러 그 카드로 파쇄까지 들어갈 수 있었습니다. - cards.cards 로 읽히던 이중 이름을 cardsState 로 풉니다. - 날짜에서 id 로 바뀐 낙하 신호와 force 재조회 분기를 테스트로 덮습니다. 둘 다 검증이 없어 파쇄 후 목록이 갱신되지 않는 회귀가 조용히 지나갈 수 있었습니다. --- .../android/app/navigation/NavigatorTest.kt | 29 +++++++++++++++ .../feature/archive/ArchiveDetailScreen.kt | 27 ++++++++++---- .../feature/archive/ArchiveDetailViewModel.kt | 8 ++--- .../feature/archive/component/PaperPile.kt | 16 ++++----- .../archive/ArchiveDetailViewModelTest.kt | 36 +++++++++++++++++++ 5 files changed, 95 insertions(+), 21 deletions(-) diff --git a/app/src/test/kotlin/com/gamss/android/app/navigation/NavigatorTest.kt b/app/src/test/kotlin/com/gamss/android/app/navigation/NavigatorTest.kt index a4c58757..1a0b309c 100644 --- a/app/src/test/kotlin/com/gamss/android/app/navigation/NavigatorTest.kt +++ b/app/src/test/kotlin/com/gamss/android/app/navigation/NavigatorTest.kt @@ -7,6 +7,7 @@ import com.gamss.android.feature.archive.navigation.ArchiveDetailKey import com.gamss.android.feature.archive.navigation.ArchiveKey import com.gamss.android.feature.carddelete.navigation.CardDeleteKey import com.gamss.android.feature.chat.navigation.ChatKey +import com.gamss.android.feature.chat.navigation.ChatRoomKey import com.gamss.android.feature.home.navigation.HomeKey import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -82,6 +83,33 @@ class NavigatorTest { assertTrue(navigator.consumeShreddedCard()) } + /** 끝난 대화방이 남으면 뒤로 나갔을 때 이어 쓸 수 없는 방으로 돌아간다. */ + @Test + fun 버린_카드를_열면_대화방을_걷어내고_보관함_상세만_올린다() { + val navigator = navigator() + navigator.navigate(ChatKey) + navigator.navigate(ChatRoomKey(CONVERSATION_ID)) + + navigator.openDroppedCard(ArchiveKey, ArchiveDetailKey(EMOTION), CARD_ID) + + assertEquals(ArchiveDetailKey(EMOTION), navigator.state.currentKey) + assertEquals( + listOf(ArchiveKey, ArchiveDetailKey(EMOTION)), + navigator.state.currentSubStack.toList(), + ) + assertEquals(listOf(ChatKey), navigator.state.subStacks.getValue(ChatKey).toList()) + } + + /** 신호를 비우지 않으면 그 칸에 다시 들어올 때마다 같은 종이가 또 떨어진다. */ + @Test + fun 버린_카드_신호는_한_번만_읽힌다() { + val navigator = navigator() + navigator.openDroppedCard(ArchiveKey, ArchiveDetailKey(EMOTION), CARD_ID) + + assertEquals(CARD_ID, navigator.consumeDroppedCardId()) + assertEquals(null, navigator.consumeDroppedCardId()) + } + /** 신호를 비우지 않으면 그 칸에 다시 들어올 때마다 목록을 또 받는다. */ @Test fun 파쇄_신호는_한_번만_읽힌다() { @@ -97,6 +125,7 @@ class NavigatorTest { private companion object { const val CARD_ID = 7L + const val CONVERSATION_ID = 42L val EMOTION = EmotionCharacter.ANGER } } diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt index a0637424..f7ec1683 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt @@ -17,6 +17,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -63,11 +66,15 @@ fun ArchiveDetailScreen( val shareChooserTitle = stringResource(R.string.archive_card_share_chooser_title) val conversationLoadFailedMessage = stringResource(R.string.archive_conversation_load_error) - // 파쇄 화면에서 돌아왔을 때도 다시 받아야 한다. 지운 카드가 목록에 그대로 남는다. + // 다시 받지 않으면 파쇄 화면에서 지운 카드가 목록에 그대로 남는다. LaunchedEffect(emotion) { viewModel.load(emotion, force = droppedCardId != null || hasShreddedCard) } + // Navigator 가 한 번만 내주지만 이 화면은 그 값을 파라미터로 계속 들고 있다. 달을 바꾸면 종이 + // 더미가 컴포지션에서 빠지므로, 소비 기록은 더미보다 오래 사는 이 자리에 둔다. + var pendingDropId by remember { mutableStateOf(droppedCardId) } + viewModel.collectSideEffect { sideEffect -> when (sideEffect) { is ArchiveDetailSideEffect.OpenCardDelete -> onNavigateToCardDelete(sideEffect.cardId) @@ -80,7 +87,8 @@ fun ArchiveDetailScreen( ArchiveDetailFrame( emotion = emotion, state = state, - droppedCardId = droppedCardId, + droppedCardId = pendingDropId, + onDropConsumed = { pendingDropId = null }, onBackClick = onBackClick, onPaperClick = viewModel::selectCard, onMonthClick = viewModel::showMonthPicker, @@ -108,6 +116,7 @@ private fun ArchiveDetailFrame( emotion: EmotionCharacter, state: ArchiveDetailState, droppedCardId: Long?, + onDropConsumed: () -> Unit, onBackClick: () -> Unit, onPaperClick: (Card) -> Unit, onMonthClick: () -> Unit, @@ -125,8 +134,9 @@ private fun ArchiveDetailFrame( ) { innerPadding -> Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) { ArchiveDetailCards( - cards = state.cards, + cardsState = state.cards, droppedCardId = droppedCardId, + onDropConsumed = onDropConsumed, onPaperClick = onPaperClick, ) // 종이가 쌓일 자리보다 나중에 둔다. 카드가 많아 더미가 위로 넘치면 종이가 셀렉터를 @@ -266,20 +276,22 @@ private fun ArchiveDetailTopBar( @Composable private fun ArchiveDetailCards( - cards: ArchiveCards, + cardsState: ArchiveCards, droppedCardId: Long?, + onDropConsumed: () -> Unit, onPaperClick: (Card) -> Unit, ) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - when (cards) { + when (cardsState) { ArchiveCards.Loading -> CircularProgressIndicator(color = GamssTheme.colors.gray700) ArchiveCards.LoadFailed -> EmptyMessage(textRes = R.string.archive_cards_load_failed) - is ArchiveCards.Loaded -> if (cards.cards.isEmpty()) { + is ArchiveCards.Loaded -> if (cardsState.cards.isEmpty()) { EmptyMessage(textRes = R.string.archive_cards_empty) } else { PaperPile( - cards = cards.cards, + cards = cardsState.cards, droppedCardId = droppedCardId, + onDropConsumed = onDropConsumed, onPaperClick = onPaperClick, ) } @@ -315,6 +327,7 @@ private fun ArchiveDetailPaperPilePreview() { cards = ArchiveCards.Loaded(List(24) { index -> PreviewCard.copy(id = index.toLong()) }), ), droppedCardId = null, + onDropConsumed = {}, onBackClick = {}, onPaperClick = {}, onMonthClick = {}, diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt index c9115a13..e0f45ef9 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt @@ -28,14 +28,14 @@ class ArchiveDetailViewModel @Inject constructor( /** * 보던 달의 카드를 받아 온다. * - * @param force 같은 감정이어도 다시 받는다. 방금 버린 카드를 보고 들어왔을 때 쓴다. 이 칸에 - * 이미 들어와 있던 채로 또 버리면 back stack 이 그대로라 ViewModel 도 살아남는데, 그때 - * 건너뛰면 방금 만든 카드가 목록에 안 들어온다. + * @param force 같은 감정이어도 다시 받는다. 방금 버린 카드를 보고 들어왔을 때와 파쇄 화면에서 + * 돌아왔을 때 쓴다. 그 칸의 ViewModel 이 back stack 에 살아남아 지운 카드를 그대로 들고 있다. */ fun load(emotion: EmotionCharacter, force: Boolean = false) = intent { if (!force && state.emotion == emotion) return@intent - reduce { state.copy(emotion = emotion) } + // 다시 받는 동안 지난 목록을 남겨 두면 이미 지운 종이를 눌러 그 카드로 파쇄까지 들어갈 수 있다. + reduce { state.copy(emotion = emotion, cards = ArchiveCards.Loading) } loadMonth(emotion, state.yearMonth) } diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/PaperPile.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/PaperPile.kt index 955a8b7e..16ae25e4 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/PaperPile.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/PaperPile.kt @@ -9,10 +9,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer @@ -34,12 +31,15 @@ import com.gamss.android.feature.archive.R * 위에서 쏟아져 바닥에 쌓이는 종이 더미. 어디에 어떻게 놓이는지는 [PaperFall] 이 정한다. * * @param droppedCardId 방금 버려서 이 화면으로 넘어온 카드. 그 한 장만 떨어지고 나머지는 이미 - * 쌓인 채로 시작한다. null 이면 전부 쏟는다. 첫 더미에만 쓰고 버린다. + * 쌓인 채로 시작한다. null 이면 전부 쏟는다. + * @param onDropConsumed 첫 더미에만 쓰고 비우라고 호출자에게 알린다. 이 컴포저블은 달을 바꾸면 + * 컴포지션에서 빠져 소비 기록을 스스로 들고 있을 수 없다. */ @Composable internal fun PaperPile( cards: List, droppedCardId: Long?, + onDropConsumed: () -> Unit, onPaperClick: (Card) -> Unit, ) { BoxWithConstraints( @@ -64,22 +64,18 @@ internal fun PaperPile( pileOffsetX = ((maxWidth - designWidth(scale)) / 2).toPx() } - // Navigator 가 한 번만 내주지만 이 화면은 그 값을 파라미터로 계속 들고 있다. 목록이 새로 - // 만들어질 때마다(카드 삭제, 달 바꿔 돌아오기) 또 한 장만 떨어지지 않게 여기서도 한 번 - // 쓰고 비운다. - var pendingDrop by remember { mutableStateOf(droppedCardId) } // 키에 화면 크기를 넣지 않는다. 크기만 바뀌었을 때 이미 쌓인 종이가 다시 쏟아지면 안 된다. // 바뀐 칸은 컴포지션이 확정된 뒤에 흘려 넣는다. 버려질 수 있는 컴포지션에서 쓰면 안 된다. val fall = remember(cards) { PaperFall( count = cards.size, geometry = geometry, - droppingIndex = cards.droppedIndex(pendingDrop), + droppingIndex = cards.droppedIndex(droppedCardId), ) } SideEffect { fall.geometry = geometry - pendingDrop = null + if (droppedCardId != null) onDropConsumed() } LaunchedEffect(fall) { fall.run() } diff --git a/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt b/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt index 0cc16c7d..c7bb4e90 100644 --- a/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt +++ b/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt @@ -108,6 +108,42 @@ class ArchiveDetailViewModelTest { assertEquals(listOf(EmotionCharacter.ANGER to currentMonth), repository.requests) } + @Test + fun `같은 감정으로 다시 들어오면 이미 받은 목록을 그대로 쓴다`() = runTest { + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard))) + val viewModel = viewModel(repository) + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } + + containerHost.load(EmotionCharacter.ANGER) + } + + assertEquals(1, repository.requests.size) + } + + /** 파쇄 화면에서 돌아오는 길이 이 스위치에 걸려 있다. 건너뛰면 지운 카드가 목록에 남는다. */ + @Test + fun `force 면 같은 감정이어도 목록을 비우고 다시 받는다`() = runTest { + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard))) + val viewModel = viewModel(repository) + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } + + containerHost.load(EmotionCharacter.ANGER, force = true) + // 다시 받는 동안 지운 종이가 남아 눌리지 않도록 목록을 먼저 비운다. + expectState { copy(cards = ArchiveCards.Loading) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } + } + + assertEquals(2, repository.requests.size) + } + @Test fun `종이를 누르면 조회 없이 그 카드를 상세로 올린다`() = runTest { val repository = FakeCardRepository(AppResult.Success(listOf(angerCard, joyCard))) From 94a92c33f0252719701fe1cda73db4f953c8a59f Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:13:39 +0900 Subject: [PATCH 09/12] =?UTF-8?q?chore:=20=EC=A3=BD=EC=9D=80=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EC=97=B4=EA=B3=BC=20=EC=82=AC=EC=8B=A4=EA=B3=BC=20?= =?UTF-8?q?=EC=96=B4=EA=B8=8B=EB=82=9C=20=EC=A3=BC=EC=84=9D=EC=9D=84=20?= =?UTF-8?q?=EA=B1=B7=EC=96=B4=EB=83=85=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카드 상세를 다시 조회하지 않게 되면서 쓰이지 않게 된 archive_card_load_error 를 지웁니다. - 삭제 메서드 셋의 KDoc 에 카드가 나온 채팅방까지 함께 지운다는 부수 효과를 되돌립니다. 호출부가 알아야 하는 정책인데 data 계층에만 남아 있었습니다. - toEntity 가 파싱 실패 값도 남긴다고 했지만 두 조회 경로 모두 걸러낸 카드만 저장합니다. --- .../android/data/remote/card/model/response/CardResponse.kt | 2 +- .../kotlin/com/gamss/android/domain/card/CardRepository.kt | 6 +++--- feature/archive/src/main/res/values/strings.xml | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt b/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt index 24573c53..e6f417b6 100644 --- a/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt +++ b/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt @@ -52,7 +52,7 @@ internal fun CardResponse.toDomainOrNull(): Card? = emotion.toEmotionCharacter() } } -/** 서버 응답을 그대로 캐시 저장 형태로 옮긴다. 파싱 실패 값도 다음 조회 때 재해석할 수 있게 원문 그대로 둔다. */ +/** 감정과 날짜를 서버가 준 원문 그대로 둔다. 캐시를 읽을 때 같은 규칙으로 다시 해석한다. */ internal fun CardResponse.toEntity(): CardEntity = CardEntity( id = id, diff --git a/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt index 153792bf..6d41a421 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/card/CardRepository.kt @@ -26,13 +26,13 @@ interface CardRepository { summary: String, ): AppResult - /** 되돌릴 수 없다. */ + /** 카드가 나온 채팅방까지 함께 지운다. 되돌릴 수 없다. */ suspend fun deleteAllCards(): AppResult - /** 되돌릴 수 없다. */ + /** 카드가 나온 채팅방까지 함께 지운다. 되돌릴 수 없다. */ suspend fun deleteCard(cardId: Long): AppResult - /** 되돌릴 수 없다. */ + /** 카드가 나온 채팅방까지 함께 지운다. 되돌릴 수 없다. */ suspend fun deleteCardsByEmotion(character: EmotionCharacter): AppResult /** diff --git a/feature/archive/src/main/res/values/strings.xml b/feature/archive/src/main/res/values/strings.xml index 9c8fdbfd..96a2efe3 100644 --- a/feature/archive/src/main/res/values/strings.xml +++ b/feature/archive/src/main/res/values/strings.xml @@ -26,7 +26,6 @@ 대화보기 공유하기 카드 공유하기 - 카드를 불러오지 못했어요. 대화를 불러오지 못했어요. 남아 있는 대화가 없어요. 마지막 대화로 이동 From c0daeda759fd3e4c8f862edc8b320ec91ef30630 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:46:07 +0900 Subject: [PATCH 10/12] =?UTF-8?q?fix:=20=EC=BA=90=EC=8B=9C=20=EC=99=84?= =?UTF-8?q?=EC=A0=84=EC=84=B1=EA=B3=BC=20=EC=9D=91=EB=8B=B5=20=EA=B4=80?= =?UTF-8?q?=EC=9A=A9=EC=84=B1=EC=9D=84=20=EB=B0=94=EB=A1=9C=EC=9E=A1?= =?UTF-8?q?=EA=B3=A0=20=EA=B3=B5=EC=9C=A0=20=EB=B2=84=ED=8A=BC=EC=9D=84=20?= =?UTF-8?q?=EC=88=A8=EA=B9=81=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캐시가 부분 데이터를 완전한 것으로 착각하고 있었습니다. 날짜별 조회는 하루치만 캐시에 넣는데, 감정+월 조회가 그 행 하나만 찾아도 비어 있지 않다는 이유로 캐시 히트로 판정해 서버를 건너뛰었습니다. 한 달치 대신 하루치 종이만 뜹니다. 캐시를 감정+월 조회 전용으로 만듭니다. 쓰는 쪽과 읽는 쪽의 키가 같아야 "있으면 완전하다"가 성립합니다. 날짜별 조회는 늘 서버를 봅니다. 읽는 곳이 없어진 findByDate 를 걷어냅니다. CardResponse 의 문자열 필드에 기본값을 둡니다. coerceInputValues 는 선언된 기본값이 있을 때만 null 을 바꿔 주므로, 기본값이 없으면 카드 한 장의 null 하나로 그 달 전체가 실패로 떨어졌습니다. toDomainOrNull 이 약속한 "한 장만 버린다" 가 작동하지 못했습니다. 공유하기는 아직 동작하지 않아 숨깁니다. GamssEmotionCard 에 isShareVisible 을 두고 보관함 카드 상세에서 끕니다. 자리는 남겨 카드 안 간격이 시안과 어긋나지 않게 하고, 투명한 동안은 터치도 받지 않습니다. --- .../core/designsystem/card/GamssImageCard.kt | 8 +++- .../gamss/android/data/local/card/CardDao.kt | 3 -- .../data/local/card/CardLocalDataSource.kt | 2 - .../local/card/RoomCardLocalDataSource.kt | 4 -- .../card/model/response/CardResponse.kt | 15 ++++-- .../data/repository/CardRepositoryImpl.kt | 46 ++++--------------- .../data/repository/CardRepositoryImplTest.kt | 33 ++++--------- .../archive/component/CardDetailDialog.kt | 2 + 8 files changed, 37 insertions(+), 76 deletions(-) diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/card/GamssImageCard.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/card/GamssImageCard.kt index fc3d5a4e..a9e05b61 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/card/GamssImageCard.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/card/GamssImageCard.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.PathEffect @@ -133,6 +134,8 @@ private fun GamssImageCardEmotionDarkPreview() { * * 카드의 고정 구조와 감정별 캐릭터 선택은 이 컴포넌트가 맡고, 문구와 사용자 동작만 호출부가 제공한다. * 따라서 화면마다 [GamssImageCard]의 간격과 텍스트 스타일을 다시 조합할 필요가 없다. + * + * @param isShareVisible 공유 줄을 보일지. 끄면 자리만 남기고 투명하게 두어 카드 안 간격이 바뀌지 않는다. */ @Composable @Suppress("LongParameterList") @@ -149,6 +152,7 @@ fun GamssEmotionCard( onShareClick: () -> Unit, modifier: Modifier = Modifier, shape: Shape = RectangleShape, + isShareVisible: Boolean = true, topEndAction: @Composable BoxScope.() -> Unit = {}, ) { GamssImageCard( @@ -195,10 +199,12 @@ fun GamssEmotionCard( ) } Spacer(modifier = Modifier.height(GamssTheme.spacing.spacing200)) + // 숨겨도 자리는 남긴다. 빼 버리면 카드 안 다른 요소가 아래로 밀려 시안과 어긋난다. Row( modifier = Modifier .fillMaxWidth() - .noRippleClickableIfNotNull(onShareClick), + .alpha(if (isShareVisible) 1f else 0f) + .noRippleClickableIfNotNull(onShareClick.takeIf { isShareVisible }), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { diff --git a/data/src/main/java/com/gamss/android/data/local/card/CardDao.kt b/data/src/main/java/com/gamss/android/data/local/card/CardDao.kt index 0c31a00c..1f856c3e 100644 --- a/data/src/main/java/com/gamss/android/data/local/card/CardDao.kt +++ b/data/src/main/java/com/gamss/android/data/local/card/CardDao.kt @@ -9,9 +9,6 @@ import com.gamss.android.data.local.card.model.CardEntity @Dao internal interface CardDao { - @Query("SELECT * FROM cards WHERE date = :date ORDER BY id ASC") - suspend fun findByDate(date: String): List - /** 날짜를 `yyyy-MM-dd` 문자열로 담으므로 `yyyy-MM` 접두사로 그 달을 고른다. */ @Query( "SELECT * FROM cards WHERE emotion = :emotion AND date LIKE :yearMonthPrefix || '%' " + diff --git a/data/src/main/java/com/gamss/android/data/local/card/CardLocalDataSource.kt b/data/src/main/java/com/gamss/android/data/local/card/CardLocalDataSource.kt index 5a29ba8b..c199c657 100644 --- a/data/src/main/java/com/gamss/android/data/local/card/CardLocalDataSource.kt +++ b/data/src/main/java/com/gamss/android/data/local/card/CardLocalDataSource.kt @@ -1,11 +1,9 @@ package com.gamss.android.data.local.card import com.gamss.android.data.local.card.model.CardEntity -import java.time.LocalDate import java.time.YearMonth internal interface CardLocalDataSource { - suspend fun findByDate(date: LocalDate): List suspend fun findByEmotionAndMonth(emotion: String, yearMonth: YearMonth): List suspend fun upsertAll(cards: List) suspend fun deleteAll() diff --git a/data/src/main/java/com/gamss/android/data/local/card/RoomCardLocalDataSource.kt b/data/src/main/java/com/gamss/android/data/local/card/RoomCardLocalDataSource.kt index c820298b..fd41cc6a 100644 --- a/data/src/main/java/com/gamss/android/data/local/card/RoomCardLocalDataSource.kt +++ b/data/src/main/java/com/gamss/android/data/local/card/RoomCardLocalDataSource.kt @@ -1,7 +1,6 @@ package com.gamss.android.data.local.card import com.gamss.android.data.local.card.model.CardEntity -import java.time.LocalDate import java.time.YearMonth import javax.inject.Inject import javax.inject.Singleton @@ -11,9 +10,6 @@ internal class RoomCardLocalDataSource @Inject constructor( private val cardDao: CardDao, ) : CardLocalDataSource { - override suspend fun findByDate(date: LocalDate): List = - cardDao.findByDate(date.toString()) - override suspend fun findByEmotionAndMonth(emotion: String, yearMonth: YearMonth): List = cardDao.findByEmotionAndMonth(emotion, yearMonth.toString()) diff --git a/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt b/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt index e6f417b6..ea9bc384 100644 --- a/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt +++ b/data/src/main/java/com/gamss/android/data/remote/card/model/response/CardResponse.kt @@ -8,15 +8,20 @@ import kotlinx.serialization.Serializable import java.time.LocalDate import java.time.format.DateTimeParseException +/** + * 문자열 필드에 기본값을 둔다. gamssJson 의 coerceInputValues 는 선언된 기본값이 있을 때만 + * null 을 그 값으로 바꾼다. 기본값이 없으면 카드 한 장의 null 하나로 목록 전체가 예외로 죽어, + * [toDomainOrNull] 이 약속한 "한 장만 버리고 나머지는 살린다" 가 아예 작동하지 못한다. + */ @Serializable internal data class CardResponse( val id: Long, val conversationId: Long, - val emotion: String, - val emotionLabel: String, - val summary: String, - val message: String, - val date: String, + val emotion: String = "", + val emotionLabel: String = "", + val summary: String = "", + val message: String = "", + val date: String = "", ) /** diff --git a/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt index 1e4a1892..a098c965 100644 --- a/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt @@ -30,42 +30,15 @@ internal class CardRepositoryImpl @Inject constructor( private val cardLocalDataSource: CardLocalDataSource, ) : CardRepository { - /** 그 날짜가 캐시에 있으면 캐시를 그대로 쓰고, 없거나 캐시 조회에 실패하면 서버에서 다시 가져온다. */ - override suspend fun getCardsByDate(date: LocalDate): AppResult> { - runCatching { - cardLocalDataSource - .findByDate(date) - .map { it.toDomain() } - } - .onFailure { throwable -> - if (throwable is CancellationException) { - throw throwable - } - - Log.w( - TAG, - "카드 캐시 조회에 실패해 서버 조회로 대체합니다. date=$date", - throwable, - ) - } - .getOrNull() - ?.takeIf { it.isNotEmpty() } - ?.let { return AppResult.Success(it) } - - return runCatchingApiCall { - val response = cardService.getCardsByDate(date.toString()) - response.throwIfFailed() - - val validCards = checkNotNull(response.data) { - "No available card data" - }.mapNotNull { raw -> - raw.toDomainOrNull()?.let { raw to it } - } - - cardLocalDataSource.upsertAll(validCards.map { (raw, _) -> raw.toEntity() }) - - validCards.map { (_, card) -> card } - } + /** + * 캐시를 읽지도 쓰지도 않는다. 캐시는 (감정, 달) 단위로만 채워지므로, 날짜로 걸러 읽으면 그 + * 날의 카드가 다 들어 있다는 보장이 없다. 한 장이라도 있으면 완전하다고 오해해 서버를 + * 건너뛰게 되므로, 이 조회는 늘 서버를 본다. + */ + override suspend fun getCardsByDate(date: LocalDate): AppResult> = runCatchingApiCall { + val response = cardService.getCardsByDate(date.toString()) + response.throwIfFailed() + checkNotNull(response.data) { "No available card data" }.mapNotNull { it.toDomainOrNull() } } override suspend fun getCardsByMonthAndEmotion( @@ -73,6 +46,7 @@ internal class CardRepositoryImpl @Inject constructor( yearMonth: YearMonth, ): AppResult> { val serverEmotion = character.toServerEmotionType() + // 이 조회만 캐시를 채우므로, 한 행이라도 있으면 그 (감정, 달) 은 통째로 받아 둔 것이다. runCatching { cardLocalDataSource .findByEmotionAndMonth(serverEmotion, yearMonth) diff --git a/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt b/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt index 650624dc..b61c475c 100644 --- a/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt +++ b/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt @@ -36,7 +36,6 @@ class CardRepositoryImplTest { @Test fun `날짜를 API 형식으로 조회하고 카드 감정을 캐릭터로 매핑한다`() = runTest { val date = LocalDate.of(2026, 8, 15) - coEvery { cardLocalDataSource.findByDate(date) } returns emptyList() coEvery { cardService.getCardsByDate("2026-08-15") } returns ApiResponse( success = true, data = listOf(cardResponse(emotion = "ANGER")), @@ -52,7 +51,6 @@ class CardRepositoryImplTest { @Test fun `알 수 없는 감정 카드는 제외하고 유효한 카드는 남긴다`() = runTest { - coEvery { cardLocalDataSource.findByDate(DATE) } returns emptyList() coEvery { cardService.getCardsByDate(any()) } returns ApiResponse( success = true, data = listOf( @@ -72,7 +70,6 @@ class CardRepositoryImplTest { @Test fun `성공 응답이어도 실패 envelope는 실패로 전한다`() = runTest { - coEvery { cardLocalDataSource.findByDate(DATE) } returns emptyList() coEvery { cardService.getCardsByDate(any()) } returns ApiResponse( success = false, error = ApiError(code = "EXPIRED_TOKEN", message = "만료"), @@ -85,7 +82,6 @@ class CardRepositoryImplTest { @Test fun `카드 데이터가 없으면 실패로 전한다`() = runTest { - coEvery { cardLocalDataSource.findByDate(DATE) } returns emptyList() coEvery { cardService.getCardsByDate(any()) } returns ApiResponse(success = true) val result = repository.getCardsByDate(DATE) @@ -93,34 +89,21 @@ class CardRepositoryImplTest { assertTrue((result as AppResult.Failure).throwable is IllegalStateException) } + /** + * 캐시는 (감정, 달) 단위로만 채워진다. 날짜로 걸러 읽으면 그 날이 다 들어 있다는 보장이 없어, + * 한 장만 있어도 완전하다고 오해하고 서버를 건너뛴다. + */ @Test - fun `그 날짜가 캐시에 있으면 서버를 호출하지 않고 캐시를 그대로 돌려준다`() = runTest { - val cached = listOf(cardEntity(id = 7L), cardEntity(id = 8L)) - coEvery { cardLocalDataSource.findByDate(DATE) } returns cached - - val result = repository.getCardsByDate(DATE) - - assertEquals(cached.map { it.toDomain() }, (result as AppResult.Success).data) - coVerify(exactly = 0) { cardService.getCardsByDate(any()) } - } - - @Test - fun `캐시가 비어 있으면 서버에서 가져와 유효한 카드만 캐시에 저장한다`() = runTest { - coEvery { cardLocalDataSource.findByDate(DATE) } returns emptyList() + fun `날짜별 조회는 캐시를 읽지도 쓰지도 않는다`() = runTest { coEvery { cardService.getCardsByDate(any()) } returns ApiResponse( success = true, - data = listOf( - cardResponse(id = 1L, emotion = "UNKNOWN"), - cardResponse(id = 2L, emotion = "ANGER"), - cardResponse(id = 3L, emotion = "JOY"), - ), + data = listOf(cardResponse(emotion = "ANGER")), ) - val upserted = slot>() - coEvery { cardLocalDataSource.upsertAll(capture(upserted)) } returns Unit repository.getCardsByDate(DATE) - assertEquals(listOf(2L, 3L), upserted.captured.map { it.id }) + coVerify(exactly = 1) { cardService.getCardsByDate("2026-08-15") } + coVerify(exactly = 0) { cardLocalDataSource.upsertAll(any()) } } @Test diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt index 679e4ab5..00199b8d 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt @@ -33,6 +33,8 @@ internal fun CardDetailDialog( onPrimaryActionClick = onDiscardClick, onSecondaryActionClick = onViewConversationClick, onShareClick = onShareClick, + // 공유가 아직 동작하지 않아 자리만 남기고 숨긴다. + isShareVisible = false, topEndAction = { CardCloseButton(onClick = onDismiss) }, ) } From 56883d77700bb8fa7e7b1d6638f50f82a3089622 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:01:08 +0900 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20=EC=84=9C=EB=B2=84=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=8F=84=20=EA=B0=90=EC=A0=95=EA=B3=BC=20=EB=8B=AC?= =?UTF-8?q?=EB=A1=9C=20=EA=B1=B8=EB=9F=AC=20=EC=BA=90=EC=8B=9C=EC=99=80=20?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20=EB=A7=9E=EC=B6=A5=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캐시는 SQL 로 감정과 달을 걸러 읽는데 서버 경로는 받은 것을 그대로 통과시켰습니다. 서버가 범위를 벗어난 카드를 섞어 보내면 첫 조회에만 보이고 캐시가 답하는 다음 조회에서 사라졌습니다. 범위 밖 카드를 캐시에 넣으면 그 달을 반쪽만 채우게도 됩니다. 이 걸러내기가 ViewModel 에서 없앤 감정 필터의 자리도 대신합니다. 서버가 다른 감정을 흘려도 분노 칸에 기쁨 카드가 뜨지 않습니다. 두 경로가 한 규칙을 쓰므로 답이 갈리지 않습니다. 아무도 호출하지 않는 ClearCardCacheUseCase 와 그 테스트를 지웁니다. 세션 정리는 AuthRepositoryImpl 이 CardRepository.clearCache 를 직접 부릅니다. --- .../data/repository/CardRepositoryImpl.kt | 4 +++ .../data/repository/CardRepositoryImplTest.kt | 21 ++++++++++++++++ .../domain/card/ClearCardCacheUseCase.kt | 17 ------------- .../domain/card/ClearCardCacheUseCaseTest.kt | 25 ------------------- 4 files changed, 25 insertions(+), 42 deletions(-) delete mode 100644 domain/src/main/kotlin/com/gamss/android/domain/card/ClearCardCacheUseCase.kt delete mode 100644 domain/src/test/kotlin/com/gamss/android/domain/card/ClearCardCacheUseCaseTest.kt diff --git a/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt index a098c965..1afe5745 100644 --- a/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/CardRepositoryImpl.kt @@ -73,8 +73,12 @@ internal class CardRepositoryImpl @Inject constructor( ) response.throwIfFailed() + // 서버가 범위를 벗어난 카드를 섞어 보내도 캐시 경로와 답이 갈리지 않게 여기서 한 번 + // 더 거른다. 캐시는 SQL 로 감정과 달을 걸러 읽으므로, 안 거르면 첫 조회에만 보이고 + // 다음 조회에서 사라진다. 범위 밖 카드를 캐시에 넣으면 그 달을 반쪽만 채우게도 된다. val validCards = checkNotNull(response.data) { "No available card data" } .mapNotNull { raw -> raw.toDomainOrNull()?.let { raw to it } } + .filter { (_, card) -> card.character == character && YearMonth.from(card.date) == yearMonth } cardLocalDataSource.upsertAll(validCards.map { (raw, _) -> raw.toEntity() }) diff --git a/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt b/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt index b61c475c..e2510d67 100644 --- a/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt +++ b/data/src/test/java/com/gamss/android/data/repository/CardRepositoryImplTest.kt @@ -321,6 +321,27 @@ class CardRepositoryImplTest { assertEquals(listOf(1L), (result as AppResult.Success).data.map { it.id }) } + /** 안 거르면 첫 조회에만 보이고, 캐시가 답하는 다음 조회에서 사라진다. */ + @Test + fun `서버가 범위를 벗어난 카드를 섞어 보내면 버린다`() = runTest { + stubEmptyMonthCache() + val upserted = slot>() + coEvery { cardLocalDataSource.upsertAll(capture(upserted)) } returns Unit + coEvery { cardService.getCardsByMonthAndEmotion(any(), any()) } returns ApiResponse( + success = true, + data = listOf( + cardResponse(id = 1L, emotion = "ANGER"), + cardResponse(id = 2L, emotion = "JOY"), + cardResponse(id = 3L, emotion = "ANGER").copy(date = "2026-07-31"), + ), + ) + + val result = repository.getCardsByMonthAndEmotion(EmotionCharacter.ANGER, YearMonth.of(2026, 8)) + + assertEquals(listOf(1L), (result as AppResult.Success).data.map { it.id }) + assertEquals(listOf(1L), upserted.captured.map { it.id }) + } + @Test fun `대상이 없는 달은 빈 목록으로 성공한다`() = runTest { stubEmptyMonthCache() diff --git a/domain/src/main/kotlin/com/gamss/android/domain/card/ClearCardCacheUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/card/ClearCardCacheUseCase.kt deleted file mode 100644 index e5e30d81..00000000 --- a/domain/src/main/kotlin/com/gamss/android/domain/card/ClearCardCacheUseCase.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.gamss.android.domain.card - -import com.gamss.android.domain.usecase.NoParamUseCase -import javax.inject.Inject - -/** - * 기기에 남은 카드 캐시를 지운다. 서버 요청은 없다. - * - * 로그아웃·탈퇴처럼 계정을 벗어나는 시점, 그리고 캐시가 서버 상태와 어긋난 것으로 확인된 - * 시점(예: ArchiveDetailViewModel.selectCard 가 월별 응답의 인덱스를 캐시에서 못 찾을 때)에 쓴다. - */ -class ClearCardCacheUseCase @Inject constructor( - private val cardRepository: CardRepository, -) : NoParamUseCase { - - override suspend fun invoke() = cardRepository.clearCache() -} diff --git a/domain/src/test/kotlin/com/gamss/android/domain/card/ClearCardCacheUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/card/ClearCardCacheUseCaseTest.kt deleted file mode 100644 index b2e5ac12..00000000 --- a/domain/src/test/kotlin/com/gamss/android/domain/card/ClearCardCacheUseCaseTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.gamss.android.domain.card - -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Test - -class ClearCardCacheUseCaseTest { - - private class RecordingRepository : FakeCardRepository() { - var clearCacheCalled = false - - override suspend fun clearCache() { - clearCacheCalled = true - } - } - - @Test - fun 캐시_비우기를_리포지토리에_위임한다() = runBlocking { - val repository = RecordingRepository() - - ClearCardCacheUseCase(repository)() - - assertEquals(true, repository.clearCacheCalled) - } -} From d85095fda7014bd6d7ef0b985c1dbf9f15ab79ea Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:19:41 +0900 Subject: [PATCH 12/12] =?UTF-8?q?refactor:=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=87=84=20=ED=9B=84=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EB=B0=9B=EC=A7=80=20=EC=95=8A=EA=B3=A0=20?= =?UTF-8?q?=EA=B7=B8=20=EC=B9=B4=EB=93=9C=EB=A7=8C=20=EB=BA=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파쇄 화면에서 돌아올 때 load(force = true) 로 그 달을 통째로 다시 받고 있었다. 카드를 날짜·순번으로 다루던 시절, 한 장을 지우면 같은 날 뒤 순번이 당겨져 목록이 서버와 어긋났기 때문이다. 카드를 id 로 다루게 된 뒤 그 이유가 사라졌는데 재조회만 남아 있었다. Navigator 가 파쇄한 카드 id 를 남기고, 보관함 상세가 목록에서 그 한 장만 뺀다. 파쇄가 끝났다는 건 서버에서 이미 지워졌다는 뜻이라 남은 목록이 어긋나지 않는다. 대신 남은 종이가 사라졌다 다시 쌓이지 않고, 조회가 실패해 지운 카드와 상관없는 나머지까지 못 보게 되는 일도 없어진다. 방금 만든 카드를 보고 들어오는 길(droppedCardId)은 목록에 없는 카드를 받아야 하므로 force 재조회를 그대로 둔다. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/gamss/android/app/main/MainScreen.kt | 11 ++-- .../gamss/android/app/navigation/Navigator.kt | 13 ++-- .../android/app/navigation/NavigatorTest.kt | 17 +++-- .../feature/archive/ArchiveDetailScreen.kt | 8 ++- .../feature/archive/ArchiveDetailViewModel.kt | 31 +++++++-- .../archive/ArchiveDetailViewModelTest.kt | 63 ++++++++++++++++++- 6 files changed, 112 insertions(+), 31 deletions(-) diff --git a/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt b/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt index ad7e9020..c569e641 100644 --- a/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt +++ b/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt @@ -141,11 +141,11 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { entry(metadata = detailSlideTransition) { key -> // 대화방에 들렀다 돌아오면 이 컴포지션이 다시 만들어진다. 그때는 이미 비어 있어야 한다. val droppedCardId = remember { navigator.consumeDroppedCardId() } - val hasShreddedCard = remember { navigator.consumeShreddedCard() } + val shreddedCardId = remember { navigator.consumeShreddedCardId() } ArchiveDetailScreen( emotion = key.emotion, droppedCardId = droppedCardId, - hasShreddedCard = hasShreddedCard, + shreddedCardId = shreddedCardId, onBackClick = navigator::goBack, onNavigateToCardDelete = { cardId -> navigator.navigate(CardDeleteKey(key.emotion, cardId)) }, ) @@ -159,15 +159,16 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { ) } entry(metadata = detailSlideTransition) { key -> + val cardId = key.cardId CardDeleteScreen( emotion = key.emotion, - cardId = key.cardId, + cardId = cardId, onBackClick = navigator::goBack, // 칸을 통째로 비웠으면 돌아갈 자리가 비어 있으니 보관함 목록까지 걷어낸다. - onDeleteComplete = if (key.cardId == null) { + onDeleteComplete = if (cardId == null) { navigator::finishCurrentFlow } else { - navigator::finishShreddedCard + { navigator.finishShreddedCard(cardId) } }, ) } diff --git a/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt b/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt index 0b4d3a78..1c5f5de2 100644 --- a/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt +++ b/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt @@ -22,9 +22,9 @@ class Navigator(val state: NavigationState) { fun consumeDroppedCardId(): Long? = droppedCardId.also { droppedCardId = null } /** 위 카드 id 와 같은 이유로 key 에 싣지 않는다. */ - private var hasShreddedCard = false + private var shreddedCardId: Long? = null - fun consumeShreddedCard(): Boolean = hasShreddedCard.also { hasShreddedCard = false } + fun consumeShreddedCardId(): Long? = shreddedCardId.also { shreddedCardId = null } /** top-level key인지, 현재 탭인지, 상세 key인지에 따라 stack 갱신 규칙이 달라진다. */ fun navigate(key: NavKey) { @@ -57,11 +57,12 @@ class Navigator(val state: NavigationState) { * 카드 한 장을 파쇄하고 원래 보던 칸으로 돌아간다. * * 남은 종이는 그대로 보여야 하므로 [finishCurrentFlow] 처럼 보관함 첫 화면까지 걷어내지 않는다. - * 대신 그 칸의 ViewModel 이 살아남아 지운 카드를 그대로 들고 있으므로, 목록을 다시 받으라고 - * 신호를 남긴다. + * 대신 그 칸의 ViewModel 이 살아남아 지운 카드를 그대로 들고 있으므로, 어느 카드였는지를 + * 남긴다. 파쇄가 끝났다는 건 서버에서 이미 지워졌다는 뜻이라, 그 한 장만 빼면 목록이 서버와 + * 같아진다. 달·감정이 그대로인 목록을 통째로 다시 받을 이유가 없다. */ - fun finishShreddedCard() { - hasShreddedCard = true + fun finishShreddedCard(cardId: Long) { + shreddedCardId = cardId goBack() } diff --git a/app/src/test/kotlin/com/gamss/android/app/navigation/NavigatorTest.kt b/app/src/test/kotlin/com/gamss/android/app/navigation/NavigatorTest.kt index 1a0b309c..e8b43d93 100644 --- a/app/src/test/kotlin/com/gamss/android/app/navigation/NavigatorTest.kt +++ b/app/src/test/kotlin/com/gamss/android/app/navigation/NavigatorTest.kt @@ -10,8 +10,6 @@ import com.gamss.android.feature.chat.navigation.ChatKey import com.gamss.android.feature.chat.navigation.ChatRoomKey import com.gamss.android.feature.home.navigation.HomeKey import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue import org.junit.Test class NavigatorTest { @@ -72,15 +70,16 @@ class NavigatorTest { /** 한 장만 지웠으면 남은 종이를 봐야 하므로 보던 칸으로 돌아간다. */ @Test - fun 카드_한_장_파쇄를_마치면_보관함_상세로_돌아가_목록을_다시_받는다() { + fun 카드_한_장_파쇄를_마치면_보관함_상세로_돌아가_지운_카드를_알린다() { val navigator = navigator() navigator.openArchiveDetail() navigator.navigate(CardDeleteKey(EMOTION, CARD_ID)) - navigator.finishShreddedCard() + navigator.finishShreddedCard(CARD_ID) assertEquals(ArchiveDetailKey(EMOTION), navigator.state.currentKey) - assertTrue(navigator.consumeShreddedCard()) + // 어느 카드를 뺄지 알려야 목록을 통째로 다시 받지 않고 그 한 장만 지울 수 있다. + assertEquals(CARD_ID, navigator.consumeShreddedCardId()) } /** 끝난 대화방이 남으면 뒤로 나갔을 때 이어 쓸 수 없는 방으로 돌아간다. */ @@ -110,17 +109,17 @@ class NavigatorTest { assertEquals(null, navigator.consumeDroppedCardId()) } - /** 신호를 비우지 않으면 그 칸에 다시 들어올 때마다 목록을 또 받는다. */ + /** 신호를 비우지 않으면 그 칸에 다시 들어올 때마다 같은 카드를 또 뺀다. */ @Test fun 파쇄_신호는_한_번만_읽힌다() { val navigator = navigator() navigator.openArchiveDetail() navigator.navigate(CardDeleteKey(EMOTION, CARD_ID)) - navigator.finishShreddedCard() + navigator.finishShreddedCard(CARD_ID) - navigator.consumeShreddedCard() + navigator.consumeShreddedCardId() - assertFalse(navigator.consumeShreddedCard()) + assertEquals(null, navigator.consumeShreddedCardId()) } private companion object { diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt index c548d33f..d1f77755 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailScreen.kt @@ -56,7 +56,7 @@ import com.gamss.android.core.designsystem.R as DesignSystemR fun ArchiveDetailScreen( emotion: EmotionCharacter, droppedCardId: Long?, - hasShreddedCard: Boolean, + shreddedCardId: Long?, onBackClick: () -> Unit, onNavigateToCardDelete: (Long?) -> Unit, viewModel: ArchiveDetailViewModel = hiltViewModel(), @@ -66,9 +66,11 @@ fun ArchiveDetailScreen( val shareChooserTitle = stringResource(R.string.archive_card_share_chooser_title) val conversationLoadFailedMessage = stringResource(R.string.archive_conversation_load_error) - // 다시 받지 않으면 파쇄 화면에서 지운 카드가 목록에 그대로 남는다. + // 방금 버린 카드는 목록에 아직 없어 다시 받아야 한다. 파쇄한 카드는 서버에서 이미 지워진 + // 뒤라, 남은 목록을 그대로 두고 그 한 장만 뺀다. LaunchedEffect(emotion) { - viewModel.load(emotion, force = droppedCardId != null || hasShreddedCard) + viewModel.load(emotion, force = droppedCardId != null) + if (shreddedCardId != null) viewModel.removeCard(shreddedCardId) } // Navigator 가 한 번만 내주지만 이 화면은 그 값을 파라미터로 계속 들고 있다. 달을 바꾸면 종이 diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt index e0f45ef9..f0c1bb17 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/ArchiveDetailViewModel.kt @@ -28,13 +28,14 @@ class ArchiveDetailViewModel @Inject constructor( /** * 보던 달의 카드를 받아 온다. * - * @param force 같은 감정이어도 다시 받는다. 방금 버린 카드를 보고 들어왔을 때와 파쇄 화면에서 - * 돌아왔을 때 쓴다. 그 칸의 ViewModel 이 back stack 에 살아남아 지운 카드를 그대로 들고 있다. + * @param force 같은 감정이어도 다시 받는다. 방금 버린 카드를 보고 들어왔을 때 쓴다. 그 칸의 + * ViewModel 이 back stack 에 살아남아 새 카드가 빠진 목록을 그대로 들고 있다. */ fun load(emotion: EmotionCharacter, force: Boolean = false) = intent { if (!force && state.emotion == emotion) return@intent - // 다시 받는 동안 지난 목록을 남겨 두면 이미 지운 종이를 눌러 그 카드로 파쇄까지 들어갈 수 있다. + // 다시 받는 동안 지난 목록을 남겨 두면 방금 버린 카드가 없는 더미를 먼저 쏟는다. 그 사이 + // 낙하 신호가 소비돼, 목록이 도착해도 그 한 장만 떨어지는 연출이 나오지 않는다. reduce { state.copy(emotion = emotion, cards = ArchiveCards.Loading) } loadMonth(emotion, state.yearMonth) } @@ -88,8 +89,8 @@ class ArchiveDetailViewModel @Inject constructor( } /** - * 한 장 버리기도 되돌릴 수 없어 파쇄 화면을 거친다. 지운 뒤 목록을 다시 받는 일은 그 화면에서 - * 돌아올 때 [load] 가 맡는다. + * 한 장 버리기도 되돌릴 수 없어 파쇄 화면을 거친다. 지운 카드를 목록에서 빼는 일은 그 화면에서 + * 돌아올 때 [removeCard] 가 맡는다. */ fun discardSelectedCard() = intent { val card = state.selectedCard ?: return@intent @@ -97,6 +98,26 @@ class ArchiveDetailViewModel @Inject constructor( postSideEffect(ArchiveDetailSideEffect.OpenCardDelete(cardId = card.id)) } + /** + * 파쇄 화면에서 지우고 온 카드를 목록에서 뺀다. + * + * 파쇄가 끝났다는 건 서버에서 이미 지워졌다는 뜻이고, 카드를 날짜·순번이 아니라 id 로 들고 + * 있으므로 그 한 장만 빼도 남은 목록이 서버와 어긋나지 않는다. 통째로 다시 받으면 남은 종이가 + * 사라졌다 다시 쌓이고, 조회가 실패하면 지운 카드와 상관없는 나머지까지 못 보게 된다. + * + * 목록을 아직 못 받았거나 그 사이 달을 옮겨 그 카드가 없으면 뺄 것이 없어 그대로 둔다. + */ + fun removeCard(cardId: Long) = intent { + reduce { + when (val loaded = state.cards) { + is ArchiveCards.Loaded -> + state.copy(cards = ArchiveCards.Loaded(loaded.cards.filterNot { it.id == cardId })) + + else -> state + } + } + } + /** * 대화보기는 채팅방으로 나가지 않는다. 이미 종료된 대화라 이어 쓸 수 없어, 같은 자리에서 카드를 * 뒤집어 그 대화 기록만 보여 준다. diff --git a/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt b/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt index c7bb4e90..788ff223 100644 --- a/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt +++ b/feature/archive/src/test/java/com/gamss/android/feature/archive/ArchiveDetailViewModelTest.kt @@ -124,7 +124,10 @@ class ArchiveDetailViewModelTest { assertEquals(1, repository.requests.size) } - /** 파쇄 화면에서 돌아오는 길이 이 스위치에 걸려 있다. 건너뛰면 지운 카드가 목록에 남는다. */ + /** + * 방금 버린 카드를 보고 들어오는 길이 이 스위치에 걸려 있다. 건너뛰면 그 카드가 빠진 지난 + * 목록이 그대로 남아 낙하할 종이를 못 찾는다. + */ @Test fun `force 면 같은 감정이어도 목록을 비우고 다시 받는다`() = runTest { val repository = FakeCardRepository(AppResult.Success(listOf(angerCard))) @@ -136,7 +139,7 @@ class ArchiveDetailViewModelTest { expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } containerHost.load(EmotionCharacter.ANGER, force = true) - // 다시 받는 동안 지운 종이가 남아 눌리지 않도록 목록을 먼저 비운다. + // 다시 받는 동안 지난 더미를 먼저 쏟아 낙하 신호를 헛되게 쓰지 않도록 목록을 비운다. expectState { copy(cards = ArchiveCards.Loading) } expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } } @@ -181,7 +184,61 @@ class ArchiveDetailViewModelTest { expectSideEffect(ArchiveDetailSideEffect.OpenCardDelete(cardId = angerCard.id)) } - // 다시 받아 오는 일은 파쇄 화면에서 돌아올 때 force load 가 맡는다. + // 목록에서 빼는 일은 파쇄 화면에서 돌아올 때 removeCard 가 맡는다. + assertEquals(1, repository.requests.size) + } + + /** 파쇄 화면에서 돌아오는 길이 여기에 걸려 있다. 건너뛰면 지운 카드가 목록에 그대로 남는다. */ + @Test + fun `파쇄하고 돌아오면 목록을 다시 받지 않고 그 카드만 뺀다`() = runTest { + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard, joyCard))) + val viewModel = viewModel(repository) + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard, joyCard))) } + + containerHost.removeCard(angerCard.id) + expectState { copy(cards = ArchiveCards.Loaded(listOf(joyCard))) } + } + + // 남은 종이가 사라졌다 다시 쌓이지 않고, 조회가 실패해 나머지까지 못 보게 되는 일도 없다. + assertEquals(1, repository.requests.size) + } + + @Test + fun `마지막 한 장을 파쇄하면 목록이 빈다`() = runTest { + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard))) + val viewModel = viewModel(repository) + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } + + containerHost.removeCard(angerCard.id) + expectState { copy(cards = ArchiveCards.Loaded(emptyList())) } + } + } + + /** 파쇄하고 돌아오는 사이 달을 옮겼으면 지운 카드가 지금 목록에 없다. 그때는 손대지 않는다. */ + @Test + fun `목록에 없는 카드를 빼도 보고 있는 목록은 그대로다`() = runTest { + val repository = FakeCardRepository(AppResult.Success(listOf(angerCard))) + val viewModel = viewModel(repository) + val testScope = this + + viewModel.test(this) { + containerHost.load(EmotionCharacter.ANGER) + expectState { copy(emotion = EmotionCharacter.ANGER) } + expectState { copy(cards = ArchiveCards.Loaded(listOf(angerCard))) } + + containerHost.removeCard(staleCard.id) + testScope.runCurrent() + expectNoItems() + } + assertEquals(1, repository.requests.size) }