Skip to content
Merged
18 changes: 1 addition & 17 deletions .github/ci-expected-failures.json
Original file line number Diff line number Diff line change
@@ -1,22 +1,6 @@
{
"schemaVersion": 1,
"unitTests": [
{
"task": ":feature:timeletter:data:testDebugUnitTest",
"className": "com.afternote.feature.timeletter.data.dto.TimeLetterDtoContractTest",
"tests": [
"발신 응답의 명시적 null과 빈 배열은 정상 파싱된다",
"발신 응답에는 존재하지 않는 deliveredAt 없이 정상 파싱된다",
"수신 응답의 기본값 제거 대상 키가 빠지면 실패한다",
"수신 응답의 조건부 null과 공개 전 빈 blocks는 정상 파싱된다"
],
"issues": [
790,
873
],
"reason": "타임레터 DTO 누락 키 계약 게이트 — 구현 머지 전까지 의도된 실패"
}
],
"unitTests": [],
"androidTests": [],
"screenshotModules": []
}
5 changes: 5 additions & 0 deletions .github/scripts/ci-expected-failures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export function validateExpectedFailuresConfig(config) {
if (!config || typeof config !== "object" || Array.isArray(config)) {
throw new Error("기대 실패 목록 최상위 값은 객체여야 합니다.");
}
// 파일이 사고로 `{}`(또는 다른 스키마)로 대체돼도 이후 루프는 빈 배열을 순회할 뿐이라
// 조용히 통과한다 — schemaVersion 을 강제해 "다 닫혀서 0건"과 "사고로 0건"을 구분한다.
if (config.schemaVersion !== 1) {
throw new Error(`지원하지 않는 기대 실패 목록 schemaVersion 입니다: ${config.schemaVersion}`);
}
for (const [index, entry] of (config.unitTests ?? []).entries()) {
const label = `unitTests[${index}]`;
requireNonEmptyString(entry?.task, `${label}.task`);
Expand Down
59 changes: 49 additions & 10 deletions .github/scripts/ci-expected-failures.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,29 +81,52 @@ test("저장소의 기대 실패 목록은 스키마를 지키고 추적 이슈
assert.ok(entry.issues.length > 0);
assert.ok(entry.reason.length > 0);
}
assert.ok((config.unitTests?.length ?? 0) + (config.screenshotModules?.length ?? 0) > 0);
// 목록이 전부 비는 것 자체는 정상 상태다(모든 기대 실패 게이트가 닫힘) — 파일이 사고로
// 비워지는 사고는 schemaVersion 강제(아래 스키마 위반 테스트)가 잡는다.
});

test("스키마 위반은 로드 시점에 거부된다", () => {
assert.throws(() => validateExpectedFailuresConfig(null), /객체여야/);
assert.throws(() => validateExpectedFailuresConfig({}), /schemaVersion/);
assert.throws(() => validateExpectedFailuresConfig({ schemaVersion: 2 }), /schemaVersion/);
assert.throws(
() => validateExpectedFailuresConfig({ unitTests: [{ ...sampleConfig.unitTests[0], task: ":feature:x:assemble" }] }),
() =>
validateExpectedFailuresConfig({
schemaVersion: 1,
unitTests: [{ ...sampleConfig.unitTests[0], task: ":feature:x:assemble" }],
}),
/testDebugUnitTest/,
);
assert.throws(
() => validateExpectedFailuresConfig({ unitTests: [{ ...sampleConfig.unitTests[0], tests: [] }] }),
() =>
validateExpectedFailuresConfig({
schemaVersion: 1,
unitTests: [{ ...sampleConfig.unitTests[0], tests: [] }],
}),
/tests/,
);
assert.throws(
() => validateExpectedFailuresConfig({ unitTests: [{ ...sampleConfig.unitTests[0], issues: [] }] }),
() =>
validateExpectedFailuresConfig({
schemaVersion: 1,
unitTests: [{ ...sampleConfig.unitTests[0], issues: [] }],
}),
/issues/,
);
assert.throws(
() => validateExpectedFailuresConfig({ screenshotModules: [{ module: "feature:setting", issues: [1], reason: "r" }] }),
() =>
validateExpectedFailuresConfig({
schemaVersion: 1,
screenshotModules: [{ module: "feature:setting", issues: [1], reason: "r" }],
}),
/project path/,
);
assert.throws(
() => validateExpectedFailuresConfig({ screenshotModules: [{ module: ":feature:setting:presentation", issues: [1], reason: " " }] }),
() =>
validateExpectedFailuresConfig({
schemaVersion: 1,
screenshotModules: [{ module: ":feature:setting:presentation", issues: [1], reason: " " }],
}),
/reason/,
);
});
Expand Down Expand Up @@ -243,19 +266,35 @@ test("기대 실패 목록·스크립트 변경은 full validation 을 강제한

test("androidTest 스키마: FQCN·테스트 목록·추적 이슈가 강제된다", () => {
assert.throws(
() => validateExpectedFailuresConfig({ androidTests: [{ ...sampleConfig.androidTests[0], className: "not a class" }] }),
() =>
validateExpectedFailuresConfig({
schemaVersion: 1,
androidTests: [{ ...sampleConfig.androidTests[0], className: "not a class" }],
}),
/FQCN/,
);
assert.throws(
() => validateExpectedFailuresConfig({ androidTests: [{ ...sampleConfig.androidTests[0], tests: [] }] }),
() =>
validateExpectedFailuresConfig({
schemaVersion: 1,
androidTests: [{ ...sampleConfig.androidTests[0], tests: [] }],
}),
/비어 있지 않은 배열/,
);
assert.throws(
() => validateExpectedFailuresConfig({ androidTests: [{ ...sampleConfig.androidTests[0], issues: [] }] }),
() =>
validateExpectedFailuresConfig({
schemaVersion: 1,
androidTests: [{ ...sampleConfig.androidTests[0], issues: [] }],
}),
/추적 이슈/,
);
assert.throws(
() => validateExpectedFailuresConfig({ androidTests: [{ ...sampleConfig.androidTests[0], devices: [] }] }),
() =>
validateExpectedFailuresConfig({
schemaVersion: 1,
androidTests: [{ ...sampleConfig.androidTests[0], devices: [] }],
}),
/비어 있지 않은 배열/,
);
assert.doesNotThrow(() => validateExpectedFailuresConfig(sampleConfig));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,6 @@ private fun completionCreatedLetter(call: FakeTimeLetterRepository.CreateCall):
id = 901L,
title = call.title,
sendAt = call.sendAt,
deliveredAt = null,
status = call.status,
blocks = emptyList(),
receiverIds = call.receiverIds,
Expand All @@ -751,7 +750,6 @@ private fun completionDetailLetter(): TimeLetter =
id = 509L,
title = "상세 route 편지",
sendAt = "2026-10-09T09:40:00",
deliveredAt = null,
status = TimeLetterStatus.SCHEDULED,
blocks =
listOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ internal object TimeLetterMockFixtures {
"""
{"status":200,"code":0,"message":null,"data":{
"timeLetters":[
{"id":1,"title":"미래의 나에게","sendAt":"2030-01-01T00:00:00","deliveredAt":null,"status":"SCHEDULED","blocks":[{"id":1,"blockType":"TEXT","blockOrder":1,"textContent":"지금 이 순간을 잊지 마."}],"receiverIds":[1]},
{"id":2,"title":"10년 후의 나에게","sendAt":"2035-06-15T00:00:00","deliveredAt":null,"status":"SCHEDULED","blocks":[{"id":2,"blockType":"TEXT","blockOrder":1,"textContent":"지금보다 더 행복하길 바라."}],"receiverIds":[2]}
{"id":1,"title":"미래의 나에게","sendAt":"2030-01-01T00:00:00","status":"SCHEDULED","blocks":[{"id":1,"blockType":"TEXT","blockOrder":1,"textContent":"지금 이 순간을 잊지 마.","url":null,"mimeType":null}],"receiverIds":[1]},
{"id":2,"title":"10년 후의 나에게","sendAt":"2035-06-15T00:00:00","status":"SCHEDULED","blocks":[{"id":2,"blockType":"TEXT","blockOrder":1,"textContent":"지금보다 더 행복하길 바라.","url":null,"mimeType":null}],"receiverIds":[2]}
],
"totalCount":2
}}
Expand All @@ -16,20 +16,20 @@ internal object TimeLetterMockFixtures {
"""
{"status":200,"code":0,"message":null,"data":{
"timeLetters":[
{"id":3,"title":"임시저장 레터","sendAt":null,"deliveredAt":null,"status":"DRAFT","blocks":[],"receiverIds":[]}
{"id":3,"title":"임시저장 레터","sendAt":null,"status":"DRAFT","blocks":[],"receiverIds":[]}
],
"totalCount":1
}}
""".trimIndent()

val MOCK_CREATE_JSON =
"""
{"status":200,"code":0,"message":null,"data":{"id":99,"title":"새 타임레터","sendAt":null,"deliveredAt":null,"status":"DRAFT","blocks":[],"receiverIds":[]}}
{"status":200,"code":0,"message":null,"data":{"id":99,"title":"새 타임레터","sendAt":null,"status":"DRAFT","blocks":[],"receiverIds":[]}}
""".trimIndent()

val MOCK_UPDATE_JSON =
"""
{"status":200,"code":0,"message":null,"data":{"id":99,"title":"수정된 타임레터","sendAt":"2030-01-01T00:00:00","deliveredAt":null,"status":"SCHEDULED","blocks":[{"id":1,"blockType":"TEXT","blockOrder":1,"textContent":"수정된 내용"}],"receiverIds":[1]}}
{"status":200,"code":0,"message":null,"data":{"id":99,"title":"수정된 타임레터","sendAt":"2030-01-01T00:00:00","status":"SCHEDULED","blocks":[{"id":1,"blockType":"TEXT","blockOrder":1,"textContent":"수정된 내용","url":null,"mimeType":null}],"receiverIds":[1]}}
""".trimIndent()

val MOCK_DELETE_JSON =
Expand All @@ -39,6 +39,6 @@ internal object TimeLetterMockFixtures {

fun detailJson(id: Long): String =
"""
{"status":200,"code":0,"message":null,"data":{"id":$id,"title":"타임레터 #$id","sendAt":"2030-01-01T00:00:00","deliveredAt":null,"status":"SCHEDULED","blocks":[{"id":1,"blockType":"TEXT","blockOrder":1,"textContent":"내용입니다."}],"receiverIds":[1]}}
{"status":200,"code":0,"message":null,"data":{"id":$id,"title":"타임레터 #$id","sendAt":"2030-01-01T00:00:00","status":"SCHEDULED","blocks":[{"id":1,"blockType":"TEXT","blockOrder":1,"textContent":"내용입니다.","url":null,"mimeType":null}],"receiverIds":[1]}}
""".trimIndent()
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ import kotlinx.serialization.Serializable
data class ReceivedTimeLetterDto(
@SerialName("id") val id: Long,
@SerialName("timeLetterReceiverId") val timeLetterReceiverId: Long,
@SerialName("title") val title: String? = null,
@SerialName("blocks") val blocks: List<TimeLetterBlockDto> = emptyList(),
@SerialName("sendAt") val sendAt: String? = null,
@SerialName("title") val title: String?,
@SerialName("blocks") val blocks: List<TimeLetterBlockDto>,
@SerialName("sendAt") val sendAt: String?,
@SerialName("status") val status: TimeLetterStatusDto,
@SerialName("senderName") val senderName: String? = null,
@SerialName("deliveredAt") val deliveredAt: String? = null,
@SerialName("createdAt") val createdAt: String? = null,
@SerialName("isRead") val isRead: Boolean,
@SerialName("senderName") val senderName: String?,
@SerialName("deliveredAt") val deliveredAt: String?,
@SerialName("createdAt") val createdAt: String?,
// 공개 전 미리보기에선 읽음 여부가 아직 정해지지 않아 서버가 명시적 null을 보낼 수 있다(#790).
@SerialName("isRead") val isRead: Boolean?,
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,13 @@ data class TimeLetterDeleteRequestDto(
@SerialName("timeLetterIds") val timeLetterIds: List<Long>,
)

// deliveredAt 필드가 없다 — TimeLetterResponse(발신 응답) 계약엔 이 키가 아예 없다(#790).
// 배달 시각은 ReceivedTimeLetterDto 쪽(수신 응답)에만 존재한다.
@Serializable
data class TimeLetterDto(
@SerialName("id") val id: Long,
@SerialName("title") val title: String?,
@SerialName("sendAt") val sendAt: String?,
@SerialName("deliveredAt") val deliveredAt: String?,
@SerialName("status") val status: TimeLetterStatusDto,
@SerialName("blocks") val blocks: List<TimeLetterBlockDto>,
@SerialName("receiverIds") val receiverIds: List<Long>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ fun ReceivedTimeLetterDto.toDomain(): ReceivedTimeLetter =
senderName = senderName,
deliveredAt = deliveredAt,
createdAt = createdAt,
isRead = isRead,
// 공개 전엔 서버가 isRead를 null로 보낼 수 있다 — 도메인은 non-null 계약이라 미확정 상태를
// 안전한 쪽(false=안 읽음)으로 접어 화면이 잘못 "읽음"으로 표시되지 않게 한다.
isRead = isRead ?: false,
)

fun ReceivedTimeLetterListDto.toDomain(): ReceivedTimeLetterList =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ fun TimeLetterDto.toDomain(): TimeLetter =
id = id,
title = title,
sendAt = sendAt,
deliveredAt = deliveredAt,
status = status.toDomain(),
blocks = blocks.map { it.toDomain() },
receiverIds = receiverIds,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ data class TimeLetter(
val id: Long,
val title: String?,
val sendAt: String?,
val deliveredAt: String?,
val status: TimeLetterStatus,
val blocks: List<TimeLetterBlock>,
val receiverIds: List<Long>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ class FakeTimeLetterRepositoryTest {
id = 1L,
title = "편지",
sendAt = null,
deliveredAt = null,
status = status,
blocks = emptyList(),
receiverIds = listOf(7L),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ class FakeTimeLetterRepository(
id = id,
title = title,
sendAt = sendAt,
deliveredAt = null,
status = status,
blocks = blocks.mapIndexed { index, block -> block.toStoredBlock(id, index) },
receiverIds = receiverIds,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,6 @@ private fun DraftLetterItemPreview() {
id = 1L,
title = "미래의 나에게",
sendAt = "2026-12-31T00:00:00",
deliveredAt = null,
status = TimeLetterStatus.DRAFT,
blocks = emptyList(),
receiverIds = listOf(1L),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,6 @@ private fun TimeLetterBlockItemPrev() {
id = 1L,
title = "미래의 나에게",
sendAt = "2026-12-31T00:00:00",
deliveredAt = null,
status = TimeLetterStatus.SCHEDULED,
blocks = emptyList(),
receiverIds = listOf(1L),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@ private val previewLetters =
id = 1L,
title = "미래의 나에게",
sendAt = "2026-12-31T00:00:00",
deliveredAt = null,
status = TimeLetterStatus.SCHEDULED,
blocks = emptyList(),
receiverIds = listOf(1L),
Expand All @@ -160,7 +159,6 @@ private val previewLetters =
id = 2L,
title = "10년 후의 나에게",
sendAt = "2035-01-01T00:00:00",
deliveredAt = null,
status = TimeLetterStatus.SCHEDULED,
blocks = emptyList(),
receiverIds = listOf(2L),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@ private fun TimeLetterItemPreview() {
id = 1L,
title = "미래의 나에게",
sendAt = "2026-12-31T00:00:00",
deliveredAt = null,
status = TimeLetterStatus.SCHEDULED,
blocks = emptyList(),
receiverIds = listOf(1L),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,6 @@ private fun ReceivedTimeLetter.toTimeLetter() =
id = id,
title = title,
sendAt = deliveredAt,
deliveredAt = deliveredAt,
status = status,
blocks = blocks,
receiverIds = listOf(id),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,30 @@ private fun DraftLetterScreenPreview() {
DraftLetterUiState.Success(
drafts =
listOf(
TimeLetter(1L, "첫 번째 레터", "2026-12-25T00:00:00", null, TimeLetterStatus.DRAFT, emptyList(), listOf(1L)),
TimeLetter(2L, "두 번째 레터", null, null, TimeLetterStatus.DRAFT, emptyList(), listOf(2L)),
TimeLetter(
id = 1L,
title = "첫 번째 레터",
sendAt = "2026-12-25T00:00:00",
status = TimeLetterStatus.DRAFT,
blocks = emptyList(),
receiverIds = listOf(1L),
),
TimeLetter(
id = 2L,
title = "두 번째 레터",
sendAt = "2026-06-01T00:00:00",
status = TimeLetterStatus.DRAFT,
blocks = emptyList(),
receiverIds = listOf(2L),
),
TimeLetter(
id = 3L,
title = null,
sendAt = null,
status = TimeLetterStatus.DRAFT,
blocks = emptyList(),
receiverIds = emptyList(),
),
),
receiverNameMap = mapOf(1L to "김지은", 2L to "이현우"),
),
Expand All @@ -207,3 +229,40 @@ private fun DraftLetterScreenPreview() {
onDeleteSelected = {},
)
}

@Preview(showBackground = true, name = "수정 모드 - 항목 선택됨")
@Composable
private fun DraftLetterScreenEditModePreview() {
DraftLetterContent(
uiState =
DraftLetterUiState.Success(
drafts =
listOf(
TimeLetter(
id = 1L,
title = "첫 번째 레터",
sendAt = "2026-12-25T00:00:00",
status = TimeLetterStatus.DRAFT,
blocks = emptyList(),
receiverIds = listOf(1L),
),
TimeLetter(
id = 2L,
title = "두 번째 레터",
sendAt = "2026-06-01T00:00:00",
status = TimeLetterStatus.DRAFT,
blocks = emptyList(),
receiverIds = listOf(2L),
),
),
isEditMode = true,
selectedIds = setOf(1L),
),
onBackClick = {},
onOpenDraft = {},
onEditCompleteClick = {},
onToggleSelection = {},
onDeleteAll = {},
onDeleteSelected = {},
)
}
Loading
Loading