diff --git a/.github/ISSUE_TEMPLATE/issue.yml b/.github/ISSUE_TEMPLATE/issue.yml index 8f42dfe6cd..5c7f278246 100644 --- a/.github/ISSUE_TEMPLATE/issue.yml +++ b/.github/ISSUE_TEMPLATE/issue.yml @@ -33,7 +33,8 @@ body: - timeletter — 타임레터 - mindrecord — 마음의 기록 - home — 홈 - - platform — Android 앱·CI·빌드·릴리스·저장소 운영 + - app — 앱 셸·루트 네비게이션·앱 모듈 공통 + - platform — CI·빌드·릴리스·저장소 운영 validations: required: true diff --git a/.github/ci-expected-failures.json b/.github/ci-expected-failures.json index ac499f97b3..f931a54b1a 100644 --- a/.github/ci-expected-failures.json +++ b/.github/ci-expected-failures.json @@ -1,37 +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": [ - { - "module": ":feature:setting:presentation", - "issues": [ - 1360 - ], - "reason": "설정 화면 screenshotTest용 Content 경계 분리 대기 — compileDebugScreenshotTestKotlin 실패" - }, - { - "module": ":feature:timeletter:presentation", - "issues": [ - 1361 - ], - "reason": "목록 화면 screenshotTest용 Content 경계 분리 대기 — compileDebugScreenshotTestKotlin 실패" - } - ] + "screenshotModules": [] } diff --git a/.github/scripts/audit-org-action-pinning.mjs b/.github/scripts/audit-org-action-pinning.mjs index 266668d55f..f2eed6f16f 100644 --- a/.github/scripts/audit-org-action-pinning.mjs +++ b/.github/scripts/audit-org-action-pinning.mjs @@ -1,12 +1,13 @@ #!/usr/bin/env node // 조직(orgs/) 하위 전 저장소의 워크플로·composite action 이 쓰는 `uses:` 를 -// 전수로 모아 floating(태그·브랜치) 참조와 40자리 SHA 고정 참조로 가른다. +// 전수로 모아 floating 액션, 40자리 SHA 고정 액션, 재사용 워크플로로 가른다. // // `orgs//actions/permissions` 의 `sha_pinning_required` 를 켜면 조직 하위 -// 저장소 전부에 즉시 적용된다. floating 참조가 하나라도 남아 있으면 그 저장소의 +// 저장소 전부에 즉시 적용된다. floating 액션이 하나라도 남아 있으면 그 저장소의 // run 은 job 이 만들어지기 전에 `startup_failure` 로 죽고 로그에 사유가 남지 않는다. -// 그래서 켜기 전에 이 감사를 돌려 0건인지 확인한다. +// 그래서 켜기 전에 이 감사를 돌려 0건인지 확인한다. 원격 재사용 워크플로는 +// 이 SHA 강제 정책의 예외이며 태그 참조도 허용된다. // // 사용법: // GH_TOKEN=$(gh auth token) node .github/scripts/audit-org-action-pinning.mjs \ @@ -26,27 +27,39 @@ const PAGE_SIZE = 100; const WORKFLOW_PATH = /^\.github\/workflows\/[^/]+\.ya?ml$/; const ACTION_MANIFEST_PATH = /(?:^|\/)action\.ya?ml$/; +const REMOTE_REUSABLE_WORKFLOW = /^[^/\s@]+\/[^/\s@]+\/\.github\/workflows\/[^/\s@]+\.ya?ml$/; /** - * 워크플로·action 매니페스트 원문에서 `uses:` 값을 순서대로 뽑는다. + * 워크플로·action 매니페스트 원문에서 `uses:` 값과 호출 위치를 순서대로 뽑는다. * * YAML 파서를 쓰지 않는 이유는 `uses:` 가 step 시퀀스·job 레벨(reusable workflow) - * 양쪽에 나타나고, 주석 처리된 줄은 정책 대상이 아니기 때문이다. 줄 단위로 보는 - * 편이 정책이 보는 범위와 정확히 겹친다. + * 양쪽에 나타나기 때문이다. 들여쓰기의 mapping 경로로 jobs..uses만 구분하고, + * step이나 composite action의 같은 경로 문자열에는 워크플로 예외를 적용하지 않는다. */ -export function extractActionReferences(source) { +export function extractActionReferences(source, sourcePath = "") { const references = []; + const parents = []; for (const line of String(source ?? "").split("\n")) { - if (/^\s*#/.test(line)) { + if (/^\s*(?:#.*)?$/.test(line)) { continue; } + const indent = line.search(/\S/); + while (parents.length > 0 && parents.at(-1).indent >= indent) { + parents.pop(); + } const match = /^\s*(?:-\s+)?uses:\s*(.+?)\s*$/.exec(line); - if (!match) { - continue; + if (match) { + const reference = stripInlineComment(match[1]); + const reusableWorkflow = WORKFLOW_PATH.test(sourcePath) && + !line.trimStart().startsWith("-") && parents.length === 2 && + parents[0].key === "jobs" && parents[0].indent === 0; + if (reference.length > 0) { + references.push({ reference, reusableWorkflow }); + } } - const value = stripInlineComment(match[1]); - if (value.length > 0) { - references.push(value); + const mapping = /^\s*(?:([A-Za-z0-9_-]+)|["']([A-Za-z0-9_-]+)["'])\s*:/.exec(line); + if (mapping) { + parents.push({ indent, key: mapping[1] ?? mapping[2] }); } } return references; @@ -72,10 +85,11 @@ function stripInlineComment(value) { * SHA 고정 정책의 대상이 아니다. * - `docker`: `docker://` 참조. 액션 레지스트리가 아니라 이미지라 대상이 아니다. * - `expression`: `${{ }}` 가 섞인 참조. 정적으로 판정할 수 없으니 사람이 본다. + * - `reusable-workflow`: 버전이 있는 원격 재사용 워크플로. SHA 강제 정책의 예외다. * - `pinned`: `@` 뒤가 40자리 소문자 hex. * - `floating`: 그 밖(태그 `@v4`·브랜치 `@main`·짧은 SHA). 정책을 켜면 막힌다. */ -export function classifyActionReference(reference) { +export function classifyActionReference(reference, { reusableWorkflow = false } = {}) { const value = String(reference ?? "").trim(); if (value.length === 0) { return { reference: value, kind: "empty", action: null, version: null }; @@ -95,6 +109,9 @@ export function classifyActionReference(reference) { } const action = value.slice(0, separator); const version = value.slice(separator + 1); + if (reusableWorkflow && version.length > 0 && REMOTE_REUSABLE_WORKFLOW.test(action)) { + return { reference: value, kind: "reusable-workflow", action, version }; + } const kind = /^[0-9a-f]{40}$/.test(version) ? "pinned" : "floating"; return { reference: value, kind, action, version }; } @@ -108,7 +125,7 @@ export function classifyActionReference(reference) { export function collectExternalActionNames(entries) { const names = new Set(); for (const entry of entries) { - if (entry.kind === "pinned" || entry.kind === "floating" || entry.kind === "unversioned") { + if (["pinned", "floating", "unversioned", "reusable-workflow"].includes(entry.kind)) { names.add(entry.action); } } @@ -125,6 +142,7 @@ export function summarizeAudit(entries) { total: 0, pinned: 0, floating: 0, + reusableWorkflows: 0, local: 0, other: 0, floatingReferences: [], @@ -139,6 +157,8 @@ export function summarizeAudit(entries) { bucket.floatingReferences.push(entry); } else if (entry.kind === "local") { bucket.local += 1; + } else if (entry.kind === "reusable-workflow") { + bucket.reusableWorkflows += 1; } else { bucket.other += 1; } @@ -152,6 +172,7 @@ export function summarizeAudit(entries) { references: entries.length, pinned: repositories.reduce((sum, item) => sum + item.pinned, 0), floating: repositories.reduce((sum, item) => sum + item.floating, 0), + reusableWorkflows: repositories.reduce((sum, item) => sum + item.reusableWorkflows, 0), local: repositories.reduce((sum, item) => sum + item.local, 0), other: repositories.reduce((sum, item) => sum + item.other, 0), }, @@ -259,13 +280,13 @@ async function scanRevision(apiUrl, repository, revision, label, token, blobCach await requestText(`${apiUrl}/repos/${repository}/git/blobs/${blob.sha}`, token), ); } - for (const reference of extractActionReferences(blobCache.get(cacheKey))) { + for (const { reference, reusableWorkflow } of extractActionReferences(blobCache.get(cacheKey), blob.path)) { entries.push({ repository, revision, revisionLabel: label, path: blob.path, - ...classifyActionReference(reference), + ...classifyActionReference(reference, { reusableWorkflow }), }); } } @@ -357,10 +378,10 @@ async function main() { console.error( `scanned ${scannedRevisions.length} revisions across ${repositories.length} repositories: ` + `${totals.references} uses (${totals.pinned} pinned, ${totals.floating} floating, ` + - `${totals.local} local, ${totals.other} other)`, + `${totals.reusableWorkflows} reusable workflows, ${totals.local} local, ${totals.other} other)`, ); if (!compatible) { - console.error("floating references remain; sha_pinning_required would break these runs"); + console.error("floating action references remain; sha_pinning_required would break these runs"); process.exitCode = 1; } } diff --git a/.github/scripts/audit-org-action-pinning.test.mjs b/.github/scripts/audit-org-action-pinning.test.mjs index f8a8fa5f0f..4a6c3dd8a6 100644 --- a/.github/scripts/audit-org-action-pinning.test.mjs +++ b/.github/scripts/audit-org-action-pinning.test.mjs @@ -25,7 +25,7 @@ test("collects uses from step sequences, job-level reusable workflows, and quote " - uses: ./.github/actions/setup-ci-config", ].join("\n"); - assert.deepEqual(extractActionReferences(source), [ + assert.deepEqual(extractActionReferences(source).map((entry) => entry.reference), [ "./.github/workflows/lint.yml", PINNED, "actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c", @@ -40,7 +40,7 @@ test("ignores commented-out uses lines and run steps that merely mention uses", ` - uses: ${PINNED}`, ].join("\n"); - assert.deepEqual(extractActionReferences(source), [PINNED]); + assert.deepEqual(extractActionReferences(source).map((entry) => entry.reference), [PINNED]); }); test("separates SHA-pinned references from every floating form the policy rejects", () => { @@ -65,6 +65,80 @@ test("exempts the reference kinds the pinning policy does not govern", () => { assert.equal(classifyActionReference("actions/checkout").kind, "unversioned"); }); +test("keeps remote reusable workflows compatible and in the allow-list for every supported ref", () => { + for (const file of ["build.yml", "build.yaml"]) { + for (const version of ["v1", "main", "release/v1", "a".repeat(40)]) { + const action = `octo-org/another-repo/.github/workflows/${file}`; + const reference = `${action}@${version}`; + const classified = classifyActionReference(reference, { reusableWorkflow: true }); + const summary = summarizeAudit([{ repository: "fixture", ...classified }]); + + assert.deepEqual(classified, { reference, kind: "reusable-workflow", action, version }); + assert.equal(summary.compatible, true); + assert.equal(summary.totals.floating, 0); + assert.equal(summary.totals.reusableWorkflows, 1); + assert.equal(summary.repositories[0].reusableWorkflows, 1); + assert.deepEqual(summary.externalActions, [action]); + } + } +}); + +test("still blocks floating actions when reusable workflows appear in the same audit", () => { + const summary = summarizeAudit([ + "octo-org/another-repo/.github/workflows/build.yml@v1", + "gradle/actions/setup-gradle@v6", + ].map((reference) => ({ repository: "fixture", ...classifyActionReference(reference, { reusableWorkflow: true }) }))); + + assert.equal(summary.compatible, false); + assert.equal(summary.totals.reusableWorkflows, 1); + assert.equal(summary.totals.floating, 1); + assert.deepEqual(summary.repositories[0].floatingReferences.map((entry) => entry.reference), [ + "gradle/actions/setup-gradle@v6", + ]); +}); + +test("does not exempt malformed workflow paths or an empty version", () => { + for (const reference of [ + "octo-org/another-repo/.github/workflows/build.yml@", + "octo-org/another-repo/.github/workflows/nested/build.yml@v1", + "octo-org/another-repo/.github/workflows/build.yml/action.yml@v1", + "octo-org/another-repo/.github/workflows/build.txt@v1", + "octo-org/another-repo/sub/.github/workflows/build.yml@v1", + ]) { + const entry = classifyActionReference(reference, { reusableWorkflow: true }); + assert.equal(entry.kind, "floating", reference); + assert.equal(summarizeAudit([{ repository: "fixture", ...entry }]).compatible, false); + } +}); + +test("applies the exception only at jobs..uses in workflow files", () => { + const reference = "octo-org/another-repo/.github/workflows/build.yml@main"; + const source = [ + "jobs:", + " reuse:", + ` uses: ${reference}`, + " action:", + " steps:", + ` - uses: ${reference}`, + " - name: another action", + ` uses: ${reference}`, + " 'second-reuse':", + ` uses: ${reference}`, + ].join("\n"); + const entries = extractActionReferences(source, ".github/workflows/ci.yml").map((entry) => ({ + repository: "fixture", ...classifyActionReference(entry.reference, entry), + })); + assert.deepEqual(entries.map((entry) => entry.kind), [ + "reusable-workflow", "floating", "floating", "reusable-workflow", + ]); + assert.equal(summarizeAudit(entries).compatible, false); + + const composite = extractActionReferences(`runs:\n using: composite\n steps:\n - uses: ${reference}`, "action.yml"); + assert.equal(classifyActionReference(composite[0].reference, composite[0]).kind, "floating"); + assert.equal(classifyActionReference(reference).kind, "floating"); + assert.ok(extractActionReferences(source, "action.yml").every((entry) => !entry.reusableWorkflow)); +}); + test("keeps the subpath when naming an action, because allow-list wildcards stop at the slash", () => { const reference = classifyActionReference("gradle/actions/setup-gradle@v6"); assert.equal(reference.action, "gradle/actions/setup-gradle"); @@ -100,7 +174,9 @@ test("summarizes per repository and reports incompatibility when any floating re const summary = summarizeAudit(entries); assert.equal(summary.compatible, false); - assert.deepEqual(summary.totals, { references: 3, pinned: 1, floating: 1, local: 1, other: 0 }); + assert.deepEqual(summary.totals, { + references: 3, pinned: 1, floating: 1, reusableWorkflows: 0, local: 1, other: 0, + }); assert.deepEqual( summary.repositories.map((item) => [item.repository, item.pinned, item.floating]), [ diff --git a/.github/scripts/awaiting-author-policy.test.mjs b/.github/scripts/awaiting-author-policy.test.mjs index 4bd5048dba..1e4b7103e2 100644 --- a/.github/scripts/awaiting-author-policy.test.mjs +++ b/.github/scripts/awaiting-author-policy.test.mjs @@ -2,12 +2,17 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { judgeAwaitingAuthor } from "./label-awaiting-author-prs.mjs"; +import { + judgeAwaitingAuthor, + parseReviewGateExemptAuthors, + planAwaitingAuthorLabels, +} from "./label-awaiting-author-prs.mjs"; const guard = await readFile(new URL("../workflows/review-debt-guard.yml", import.meta.url), "utf8"); const reconcile = await readFile(new URL("../workflows/conflict-label.yml", import.meta.url), "utf8"); const script = await readFile(new URL("./label-awaiting-author-prs.mjs", import.meta.url), "utf8"); const authorDebtCheck = await readFile(new URL("./check-author-debt.mjs", import.meta.url), "utf8"); +const exemptAuthors = parseReviewGateExemptAuthors(guard); test("가드는 작성자 무조치를 공통 판정 CLI 로 검사한다", () => { // 라벨은 주기 리컨사일 결과라 입장 시점에 스테일할 수 있다. 가드는 live PR 목록을 읽는 @@ -18,6 +23,49 @@ test("가드는 작성자 무조치를 공통 판정 CLI 로 검사한다", () = assert.match(script, /export function findAuthorDebts/); }); +test("라벨 판정은 입장 가드에 선언된 작성자 면제 목록을 직접 읽는다", () => { + assert.match(guard, /^\s*REVIEW_GATE_EXEMPT_AUTHORS:/m); + assert.match(guard, /\$REVIEW_GATE_EXEMPT_AUTHORS/); + assert.match(script, /new URL\("\.\.\/workflows\/review-debt-guard\.yml", import\.meta\.url\)/); + assert.doesNotMatch(script, /review-gate-policy\.json/); + const mainAt = script.indexOf("async function main()"); + assert.ok(mainAt > 0); + const main = script.slice(mainAt); + assert.match(main, /parseReviewGateExemptAuthors\(/); + assert.match(main, /planAwaitingAuthorLabels\(\{[^}]*\bexemptAuthors\b/); +}); + +test("workflow 파일이 없는 가드 checkout에서도 작성자 빚 판정을 import할 수 있다", async () => { + // data URL 에는 상대 workflow 경로가 없다. 모듈을 불러오는 순간 목록을 읽으면 + // scripts 만 checkout 하는 오래된 가드와 마찬가지로 import가 실패한다. + const module = await import(`data:text/javascript;base64,${Buffer.from(script).toString("base64")}`); + + assert.equal(typeof module.findAuthorDebts, "function"); + assert.equal(typeof module.judgeAwaitingAuthor, "function"); +}); + +test("가드의 면제 목록에 있는 작성자는 라벨 계획에서도 빠진다", () => { + const pullRequests = exemptAuthors.map((login, index) => ({ + number: index + 1, + isDraft: false, + author: { login: login.toUpperCase() }, + labels: { nodes: [{ name: "awaiting-author" }] }, + reviews: { + nodes: [{ + state: "CHANGES_REQUESTED", + submittedAt: "2026-08-29T00:00:00Z", + authorCanPushToRepository: true, + author: { login: "reviewer" }, + }], + }, + })); + const plan = planAwaitingAuthorLabels({ pullRequests, exemptAuthors }); + + assert.deepEqual(plan.toLabel, []); + assert.deepEqual(plan.toUnlabel.map((entry) => entry.number), pullRequests.map(({ number }) => number)); + assert.ok(plan.toUnlabel.every(({ reason }) => reason === "리뷰 게이트 면제 작성자")); +}); + test("리뷰어 빚과 작성자 빚은 한 번의 안내와 종료로 합친다", () => { // 두 경로가 각각 댓글·close 를 수행하면 같은 opened 이벤트에서 중복 코멘트와 경합이 // 생긴다. 두 TSV 를 모두 만든 뒤 한 번만 최종 결정을 내려야 한다. diff --git a/.github/scripts/check-author-debt.mjs b/.github/scripts/check-author-debt.mjs index 7976ae66eb..954394d4da 100644 --- a/.github/scripts/check-author-debt.mjs +++ b/.github/scripts/check-author-debt.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node -// 새 PR 작성자가 변경요청을 받은 자기 PR 을 방치하고 있는지 live 상태로 검사한다. +// 새 PR 을 여는 사람이 변경요청을 받은 «자기가 담당하는» PR 을 방치하고 있는지 live 상태로 +// 검사한다. 담당은 어사인이고 어사인이 비면 작성자다(#1974) — AUTHOR 는 여는 사람의 로그인이지 +// «작성자 필터» 가 아니다. // 라벨은 스테일할 수 있으므로 입장 판정에 사용하지 않는다. 열린 PR 을 한 번 조회한 뒤 // label-awaiting-author-prs.mjs 의 SSOT 판정을 재사용하고, 워크플로가 댓글과 close 를 한 번만 // 처리할 수 있도록 결과만 TSV 로 넘긴다. diff --git a/.github/scripts/check-author-debt.test.mjs b/.github/scripts/check-author-debt.test.mjs index 6b6abf07cc..12e062a644 100644 --- a/.github/scripts/check-author-debt.test.mjs +++ b/.github/scripts/check-author-debt.test.mjs @@ -39,10 +39,20 @@ function graphqlApi(pullRequests) { return async (apiPath, options = {}) => { assert.equal(apiPath, "/graphql"); assert.equal(options.method, "POST"); - assert.equal( - options.body.variables.searchQuery, - "repo:Afternote/Afternote-FE is:pr is:open author:author", + // 담당은 어사인이 정본이고 어사인이 비면 작성자라 두 축을 모두 검색한다(#1974). + const query = options.body.variables.searchQuery; + assert.ok( + query === "repo:Afternote/Afternote-FE is:pr is:open assignee:author" || + query === "repo:Afternote/Afternote-FE is:pr is:open author:author", + query, ); + if (query.endsWith("author:author")) { + return { + data: { + search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }, + }; + } return { data: { search: { diff --git a/.github/scripts/ci-expected-failures.mjs b/.github/scripts/ci-expected-failures.mjs index 97ef17bfef..70ad349e76 100644 --- a/.github/scripts/ci-expected-failures.mjs +++ b/.github/scripts/ci-expected-failures.mjs @@ -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`); diff --git a/.github/scripts/ci-expected-failures.test.mjs b/.github/scripts/ci-expected-failures.test.mjs index 0dec8a3589..d7cced8530 100644 --- a/.github/scripts/ci-expected-failures.test.mjs +++ b/.github/scripts/ci-expected-failures.test.mjs @@ -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/, ); }); @@ -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)); diff --git a/.github/scripts/ci-test-plan.mjs b/.github/scripts/ci-test-plan.mjs index 28fa7243bc..974eea09c0 100644 --- a/.github/scripts/ci-test-plan.mjs +++ b/.github/scripts/ci-test-plan.mjs @@ -252,11 +252,7 @@ export async function validateCiTestPlanImpact( continue; } const declaredMethods = declaredMethodsByPath.get(testPath) ?? new Set(); - const testMethods = [ - ...source.matchAll( - /@Test(?:\s*\([^)]*\))?\s*(?:@[\w:.]+(?:\([^\n]*\))?\s*)*fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/g, - ), - ].map((match) => match[1]); + const testMethods = kotlinTestMethods(kotlinCodeWithoutLiterals(source)).map((match) => match.name); const omitted = [...new Set(testMethods)].filter((method) => !declaredMethods.has(method)); if (omitted.length > 0) { throw new Error( @@ -266,8 +262,265 @@ export async function validateCiTestPlanImpact( } } -function escapeRegex(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +// 이름 자리의 키워드는 이름이 아니다. `companion object` 다음 줄의 `class Inner` 를 삼키지 않게 한다. +// 이름 없는 companion 은 JVM 이름이 `Companion` 이다. interface·companion 은 중첩 class 의 바깥 이름으로 쓰인다. +const CLASS_DECLARATION_PATTERN = + /(? { + for (let index = from; index < to; index += 1) { + if (code[index] !== "\n") code[index] = " "; + } + }; + // `${...}` 안은 다시 코드이고 그 안에 문자열이 또 올 수 있어, 돌아갈 문자열 종류와 중괄호 깊이를 쌓는다. + // 템플릿 안의 리터럴·주석은 바깥 리터럴을 지울 때 함께 지워진다. + const templates = []; + let mode = "code"; + // `$$"..."` 처럼 여는 따옴표 앞 `$` 개수만큼 이어져야 템플릿이 열린다(Kotlin 2.2 다중 달러 보간). + let interpolation = 1; + let literalStart = 0; + let commentStart = 0; + let commentDepth = 0; + let index = 0; + const enterLiteral = (literalMode, width) => { + let dollars = 0; + while (source[index - 1 - dollars] === "$") dollars += 1; + interpolation = Math.max(dollars, 1); + if (templates.length === 0) literalStart = index + width; + mode = literalMode; + index += width; + }; + const leaveLiteral = (contentEnd, width) => { + if (templates.length === 0) blank(literalStart, contentEnd); + mode = "code"; + index = contentEnd + width; + }; + + while (index < source.length) { + const char = source[index]; + const next = source[index + 1]; + if (mode === "comment") { + if (char === "/" && next === "*") { + commentDepth += 1; + index += 2; + } else if (char === "*" && next === "/") { + commentDepth -= 1; + index += 2; + if (commentDepth === 0) { + if (templates.length === 0) blank(commentStart, index); + mode = "code"; + } + } else { + index += 1; + } + } else if (mode === "string" || mode === "raw") { + if (char === "$") { + let run = 1; + while (source[index + run] === "$") run += 1; + if (source[index + run] === "{" && run >= interpolation) { + templates.push({ mode, interpolation, depth: 0 }); + mode = "code"; + index += run + 1; + } else { + index += run; + } + } else if (mode === "string" && char === "\\") { + index += 2; + } else if (mode === "string" && char === '"') { + leaveLiteral(index, 1); + } else if (mode === "raw" && source.startsWith('"""', index)) { + // `""""` 처럼 따옴표가 셋보다 많으면 마지막 셋이 닫고 앞쪽은 내용이다. + let end = index + 3; + while (source[end] === '"') end += 1; + leaveLiteral(end - 3, 3); + } else { + index += 1; + } + } else if (char === "/" && next === "/") { + let lineEnd = index; + while (lineEnd < source.length && source[lineEnd] !== "\n" && source[lineEnd] !== "\r") lineEnd += 1; + if (templates.length === 0) blank(index, lineEnd); + index = lineEnd; + } else if (char === "/" && next === "*") { + commentStart = index; + commentDepth = 1; + mode = "comment"; + index += 2; + } else if (source.startsWith('"""', index)) { + enterLiteral("raw", 3); + } else if (char === '"') { + enterLiteral("string", 1); + } else if (char === "'" || char === "`") { + let end = index + 1; + while (end < source.length && source[end] !== char && source[end] !== "\n" && source[end] !== "\r") { + end += char === "'" && source[end] === "\\" ? 2 : 1; + } + if (templates.length === 0) blank(index + 1, end); + index = end + 1; + } else if (templates.length > 0 && char === "{") { + templates.at(-1).depth += 1; + index += 1; + } else if (templates.length > 0 && char === "}") { + const template = templates.at(-1); + if (template.depth === 0) { + templates.pop(); + mode = template.mode; + interpolation = template.interpolation; + } else { + template.depth -= 1; + } + index += 1; + } else { + index += 1; + } + } + if (templates.length > 0 || mode === "string" || mode === "raw") { + blank(literalStart, source.length); + } else if (mode === "comment") { + blank(commentStart, source.length); + } + return code.join(""); +} + +// `@Test`·`@Test(...)` 뒤에 annotation 을 건너 이어지는 `fun 이름(` 을 찾는다(method 를 주면 그 이름만). +// annotation 인자는 같은 줄의 `)` 가운데 뒤가 `fun 이름(` 까지 이어지는 가장 먼 것에서 닫는다. 옛 정규식 +// `@Test(?:\s*\([^)]*\))?\s*(?:@[\w:.]+(?:\([^\n]*\))?\s*)*fun\s+(이름)\s*\(` 가 역추적으로 고르던 결과와 +// 같다. 그 정규식은 인자가 이어지면 짧은 입력에도 지수 시간이 걸려, 뒤에서부터 위치마다 한 번씩 판정한다(#2218). +function kotlinTestMethods(code, method) { + const nextCode = new Int32Array(code.length + 1).fill(code.length); + for (let index = code.length - 1; index >= 0; index -= 1) { + nextCode[index] = /\s/.test(code[index]) ? nextCode[index + 1] : index; + } + // tails[i]: 공백 아닌 i 에서 annotation 을 건너 닿는 `fun 이름(` 의 { name, end }. 못 닿으면 null. + const tails = new Array(code.length + 1).fill(null); + const tailAfter = (index) => tails[nextCode[index]]; + const declaration = (index) => { + if (!code.startsWith("fun", index) || !/\s/.test(code[index + 3] ?? "")) return null; + const nameStart = nextCode[index + 3]; + if (!/[A-Za-z_]/.test(code[nameStart] ?? "")) return null; + let nameEnd = nameStart + 1; + while (/[A-Za-z0-9_]/.test(code[nameEnd] ?? "")) nameEnd += 1; + const name = code.slice(nameStart, nameEnd); + const open = nextCode[nameEnd]; + return code[open] === "(" && (method === undefined || name === method) ? { name, end: open + 1 } : null; + }; + // 뒤에서 앞으로 훑으므로 한 줄에서 처음 찾은, 뒤가 이어지는 `)` 가 가장 멀다. + let farthestClose = -1; + for (let index = code.length - 1; index >= 0; index -= 1) { + const char = code[index]; + if (char === "\n") { + farthestClose = -1; + } else if (char === ")") { + if (farthestClose === -1 && tailAfter(index + 1)) farthestClose = index; + } else if (char === "f") { + tails[index] = declaration(index); + } else if (char === "@") { + let nameEnd = index + 1; + while (/[\w:.]/.test(code[nameEnd] ?? "")) nameEnd += 1; + if (nameEnd === index + 1) continue; + if (code[nameEnd] === "(") { + tails[index] = farthestClose === -1 ? null : tailAfter(farthestClose + 1); + } else { + // 옛 정규식은 `@Afun x(` 처럼 이름 끝의 `fun` 도 되돌려 키워드로 읽었다. + tails[index] = tailAfter(nameEnd) ?? (nameEnd - index > 4 ? tails[nameEnd - 3] : null); + } + } + } + + const matches = []; + let argumentsClose = -1; + let start = code.indexOf("@Test"); + while (start !== -1) { + const next = nextCode[start + 5]; + let tail; + if (code[next] === "(") { + // `@Test(...)` 인자는 줄을 넘어서도 첫 `)` 에서 닫는다. 찾은 자리는 뒤 `@Test` 도 같이 쓴다. + if (argumentsClose < next) { + argumentsClose = code.indexOf(")", next); + if (argumentsClose === -1) argumentsClose = code.length; + } + tail = argumentsClose < code.length ? tailAfter(argumentsClose + 1) : null; + } else { + tail = tails[next]; + } + if (tail) matches.push({ index: start, name: tail.name }); + start = code.indexOf("@Test", tail ? tail.end : start + 1); + } + return matches; +} + +function braceBlocks(code) { + const blocks = []; + const open = []; + for (let index = 0; index < code.length; index += 1) { + if (code[index] === "{") { + open.push(blocks.length); + blocks.push({ start: index, end: code.length }); + } else if (code[index] === "}" && open.length > 0) { + blocks[open.pop()].end = index; + } + } + return blocks; +} + +function innermostBlock(blocks, position) { + let found = null; + for (const block of blocks) { + if (block.start >= position) break; + if (block.end > position) found = block; + } + return found; +} + +// 선언 뒤 괄호 밖 첫 `{` 가 본문이다. 그 전에 다음 선언이 시작되면 본문 없는 class 다. +function classBodyStart(code, from) { + let parentheses = 0; + for (let index = from; index < code.length; index += 1) { + const char = code[index]; + if (char === "(") { + parentheses += 1; + } else if (char === ")") { + parentheses -= 1; + if (parentheses < 0) return -1; + } else if (parentheses > 0) { + continue; + } else if (char === "{") { + return index; + } else if (char === "}" || char === ";" || char === "=") { + return -1; + } else if (/[A-Za-z_]/.test(char) && !/[A-Za-z0-9_]/.test(code[index - 1])) { + WORD_PATTERN.lastIndex = index; + const word = WORD_PATTERN.exec(code)[0]; + if (CLASS_HEADER_END_WORDS.has(word)) return -1; + index += word.length - 1; + } + } + return -1; +} + +// selector 가 쓰는 JVM 이름(`Outer$Inner`)으로 class 를 모으고, 본문이 있으면 여는 `{` 위치로 찾게 둔다. +function kotlinClassBodies(code, blocks) { + const binaryNames = new Set(); + const binaryNameByBodyStart = new Map(); + for (const match of code.matchAll(CLASS_DECLARATION_PATTERN)) { + const parent = innermostBlock(blocks, match.index); + const outerName = parent === null ? "" : binaryNameByBodyStart.get(parent.start); + // 함수 본문 같은 class 밖 블록에 선언된 local class 는 selector 로 가리킬 수 없다. + if (outerName === undefined) continue; + const name = match[1] ?? "Companion"; + const binaryName = outerName ? `${outerName}$${name}` : name; + binaryNames.add(binaryName); + const bodyStart = classBodyStart(code, match.index + match[0].length); + if (bodyStart !== -1) binaryNameByBodyStart.set(bodyStart, binaryName); + } + return { binaryNames, binaryNameByBodyStart }; } export async function validateCiTestPlanSources(plan, { root = process.cwd() } = {}) { @@ -292,7 +545,7 @@ export async function validateCiTestPlanSources(plan, { root = process.cwd() } = const source = await fs.readFile(absolute, "utf8"); const [qualifiedClass, method] = test.selector.split("#", 2); const classParts = qualifiedClass.split("."); - const declaredClass = classParts.at(-1).split("$").at(-1); + const binaryName = classParts.at(-1); const expectedPackage = classParts.slice(0, -1).join("."); const actualPackage = /^\s*package\s+([A-Za-z_][A-Za-z0-9_.]*)/m.exec(source)?.[1] ?? ""; if (actualPackage !== expectedPackage) { @@ -300,16 +553,28 @@ export async function validateCiTestPlanSources(plan, { root = process.cwd() } = `선택 테스트 package가 selector와 다릅니다: ${test.path} (${actualPackage} != ${expectedPackage})`, ); } - if (!new RegExp(`\\b(?:class|object)\\s+${escapeRegex(declaredClass)}\\b`).test(source)) { + const code = kotlinCodeWithoutLiterals(source); + const blocks = braceBlocks(code); + const { binaryNames, binaryNameByBodyStart } = kotlinClassBodies(code, blocks); + if (!binaryNames.has(binaryName)) { throw new Error(`선택 테스트 class가 파일에 없습니다: ${test.selector}`); } - const methodPattern = new RegExp( - `@Test(?:\\s*\\([^)]*\\))?\\s*` + - `(?:@[\\w:.]+(?:\\([^\\n]*\\))?\\s*)*` + - `fun\\s+${escapeRegex(method)}\\s*\\(`, + // 한 파일에 class 가 여럿이면 메서드가 파일에 있어도 다른 class 소속일 수 있다(#2153). + // 그러면 계측 실행기는 0건을 돌리므로 @Test 를 바로 감싸는 블록이 selector class 본문이어야 한다. + const owners = kotlinTestMethods(code, method).map((match) => + binaryNameByBodyStart.get(innermostBlock(blocks, match.index)?.start), ); - if (!methodPattern.test(source)) { + if (owners.length === 0) { throw new Error(`선택 테스트 @Test 메서드가 파일에 없습니다: ${test.selector}`); } + if (!owners.includes(binaryName)) { + const actual = [...new Set(owners.filter(Boolean))].map( + (owner) => `${expectedPackage}.${owner}#${method}`, + ); + throw new Error( + `선택 테스트 @Test 메서드가 selector class 본문에 없습니다: ${test.selector}` + + (actual.length > 0 ? ` (메서드가 있는 class: ${actual.join(", ")})` : ""), + ); + } } } diff --git a/.github/scripts/ci-test-plan.test.mjs b/.github/scripts/ci-test-plan.test.mjs index a601702ca1..9ee6ae97c3 100644 --- a/.github/scripts/ci-test-plan.test.mjs +++ b/.github/scripts/ci-test-plan.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -79,6 +80,286 @@ test("선택 selector의 package, class, @Test method를 현재 revision에서 ); }); +async function writeAndroidTestSource(testPath, lines) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "ci-test-plan-scope-")); + await fs.mkdir(path.dirname(path.join(root, testPath)), { recursive: true }); + await fs.writeFile(path.join(root, testPath), `${lines.join("\n")}\n`); + return root; +} + +function selectedPlan(testPath, selector) { + return { + androidTest: { + mode: "selected", + reason: "selector가 가리키는 class 본문 확인", + tests: [{ path: testPath, selector, device: "api34" }], + }, + }; +} + +test("#2153: 같은 파일의 다른 class에 있는 @Test 메서드를 selector class에 붙이면 거절한다", async () => { + const testPath = "app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt"; + const root = await writeAndroidTestSource(testPath, [ + "package com.afternote.afternote_fe", + "", + "@HiltAndroidTest", + "@RunWith(AndroidJUnit4::class)", + "class AppAndReceiverCompletionAndroidTest {", + " @get:Rule(order = 0)", + " val hiltRule = HiltAndroidRule(this)", + "", + " @Test", + " fun invalidCredentials_correctedPasswordThenRetry_entersHomeWithoutReportingUserError() = Unit", + "}", + "", + "@HiltAndroidTest", + "@RunWith(AndroidJUnit4::class)", + "class ReceiverRuntimeCompletionAndroidTest {", + " @Test", + " fun homeActions_routeImplementedEntryPointsToExactDestinations() {", + " composeRule.waitUntil { true }", + " }", + "}", + ]); + const method = "homeActions_routeImplementedEntryPointsToExactDestinations"; + const validate = (selector) => + validateCiTestPlanSources(selectedPlan(testPath, selector), { root }); + + await assert.rejects( + validate(`com.afternote.afternote_fe.AppAndReceiverCompletionAndroidTest#${method}`), + (error) => + /class 본문/.test(error.message) && + error.message.includes(`com.afternote.afternote_fe.ReceiverRuntimeCompletionAndroidTest#${method}`), + ); + await assert.doesNotReject(validate(`com.afternote.afternote_fe.ReceiverRuntimeCompletionAndroidTest#${method}`)); + await assert.doesNotReject( + validate( + "com.afternote.afternote_fe.AppAndReceiverCompletionAndroidTest#invalidCredentials_correctedPasswordThenRetry_entersHomeWithoutReportingUserError", + ), + ); +}); + +test("class 본문의 중괄호 짝은 문자열·문자·주석 속 중괄호를 세지 않는다", async () => { + const testPath = "app/src/androidTest/java/com/example/BraceNoiseTest.kt"; + const root = await writeAndroidTestSource(testPath, [ + "package com.example", + "", + "class BraceNoiseTest {", + ' private val json = "{\\"a\\": \\"}\\"}"', + " private val brace = '}'", + " private val quote = '\\''", + ' private val raw = """}}} "" """', + ' private val rawTail = """}""""', + ' private val template = "${listOf("}").first()} $brace"', + ' private val typed = $$"${"', + " private val command = $$\"sh -c 'echo ${PATH//:/ }'\"", + ' private val schema = $$"""{"$schema": "$${listOf("}").first()}"}"""', + " // }", + " /* } /* 중첩 } */ } */", + " // @Test fun owned() = Unit", + "", + " @Test", + " fun afterNoise() = Unit", + "}", + "", + "class NextTest {", + " @Test", + " fun owned() = Unit", + "}", + ]); + const validate = (selector) => + validateCiTestPlanSources(selectedPlan(testPath, selector), { root }); + + await assert.doesNotReject(validate("com.example.BraceNoiseTest#afterNoise")); + await assert.doesNotReject(validate("com.example.NextTest#owned")); + await assert.rejects(validate("com.example.BraceNoiseTest#owned"), /class 본문/); + await assert.rejects(validate("com.example.NextTest#afterNoise"), /class 본문/); + + // Kotlin 은 CR 하나도 줄바꿈으로 본다. 줄 주석이 파일 끝까지 먹으면 뒤 class 가 사라진다. + const carriageReturnPath = "app/src/androidTest/java/com/example/CarriageReturnTest.kt"; + await fs.mkdir(path.dirname(path.join(root, carriageReturnPath)), { recursive: true }); + await fs.writeFile( + path.join(root, carriageReturnPath), + "package com.example\rclass CarriageReturnTest {\r // }\r @Test\r fun owned() = Unit\r}\r", + ); + await assert.doesNotReject( + validateCiTestPlanSources(selectedPlan(carriageReturnPath, "com.example.CarriageReturnTest#owned"), { root }), + ); +}); + +test("중첩 class는 Outer$Inner로만 가리키고 본문 없는 class는 메서드를 갖지 않는다", async () => { + const testPath = "app/src/androidTest/java/com/example/OuterTest.kt"; + const root = await writeAndroidTestSource(testPath, [ + "package com.example", + "", + "private data class Fixture(val id: Int = 0)", + "", + "class OuterTest {", + " @Test", + " fun outerOwned() = Unit", + "", + " companion object", + "", + " class InnerTest {", + " @Test", + " fun innerOwned() = Unit", + " }", + "}", + "", + "interface ScreenSuite {", + " class LoginScreenTest {", + " @Test", + " fun inInterface() = Unit", + " }", + "}", + "", + "class SuiteTest {", + " companion object {", + " class CaseTest {", + " @Test", + " fun inCompanion() = Unit", + " }", + " }", + "}", + "", + "private val init = Runnable {}", + "", + "class DelegatingTest : Runnable by init {", + " @Test", + " fun delegated() = Unit", + "}", + ]); + const validate = (selector) => + validateCiTestPlanSources(selectedPlan(testPath, selector), { root }); + + await assert.doesNotReject(validate("com.example.OuterTest#outerOwned")); + await assert.doesNotReject(validate("com.example.OuterTest$InnerTest#innerOwned")); + await assert.doesNotReject(validate("com.example.ScreenSuite$LoginScreenTest#inInterface")); + await assert.doesNotReject(validate("com.example.SuiteTest$Companion$CaseTest#inCompanion")); + await assert.doesNotReject(validate("com.example.DelegatingTest#delegated")); + await assert.rejects(validate("com.example.SuiteTest#inCompanion"), /class 본문/); + await assert.rejects(validate("com.example.OuterTest#innerOwned"), /class 본문/); + await assert.rejects(validate("com.example.InnerTest#innerOwned"), /class가 파일에 없습니다/); + await assert.rejects(validate("com.example.Fixture#outerOwned"), /class 본문/); +}); + +test("변경 파일 검사와 selector 검사는 주석 속 @Test를 똑같이 세지 않는다", async () => { + const testPath = "app/src/androidTest/java/com/example/RuntimeTest.kt"; + const root = await writeAndroidTestSource(testPath, [ + "package com.example", + "", + "class RuntimeTest {", + " @Test", + " fun works() = Unit", + "", + " // @Test fun flakyOnApi30() = Unit", + "}", + ]); + const plan = selectedPlan(testPath, "com.example.RuntimeTest#works"); + + await assert.doesNotReject(validateCiTestPlanImpact(plan, [testPath], { root })); + await assert.doesNotReject(validateCiTestPlanSources(plan, { root })); + await assert.rejects( + validateCiTestPlanSources(selectedPlan(testPath, "com.example.RuntimeTest#flakyOnApi30"), { root }), + /@Test 메서드가 파일에 없습니다/, + ); +}); + +test("@Test 인자와 뒤따르는 annotation을 한 줄 인자까지 건너 메서드와 소유 class를 찾는다", async () => { + const testPath = "app/src/androidTest/java/com/example/AnnotatedTest.kt"; + const root = await writeAndroidTestSource(testPath, [ + "package com.example", + "", + "class AnnotatedTest {", + " @Test(timeout = 1_000)", + " fun withTimeout() = Unit", + "", + " @Test", + " @androidx.test.filters.SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)", + " @FlakyTest(bugId = listOf(1).first())", + " fun stacked() = Unit", + "", + ' @Test @Suppress("UNUSED(") @Config(sdk = [30]) fun sameLine() { check(true) }', + "", + " @Test", + " @MediumTest @Ignore", + " fun", + " spaced ()", + "}", + "", + "class OtherTest {", + " @Test @MediumTest fun other() = Unit", + "}", + ]); + const declared = selectedPlan(testPath, "com.example.AnnotatedTest#withTimeout"); + const validate = (selector) => + validateCiTestPlanSources(selectedPlan(testPath, selector), { root }); + + await assert.rejects( + validateCiTestPlanImpact(declared, [testPath], { root }), + (error) => error.message.endsWith(`${testPath}#stacked, #sameLine, #spaced, #other`), + ); + for (const method of ["withTimeout", "stacked", "sameLine", "spaced"]) { + await assert.doesNotReject(validate(`com.example.AnnotatedTest#${method}`)); + } + await assert.doesNotReject(validate("com.example.OtherTest#other")); + await assert.rejects( + validate("com.example.AnnotatedTest#other"), + (error) => /class 본문/.test(error.message) && error.message.includes("com.example.OtherTest#other"), + ); + await assert.rejects(validate("com.example.OtherTest#stacked"), /class 본문/); +}); + +// 정규식 역추적은 이벤트 루프를 막아 node:test 시간 제한이 듣지 않는다. 자식 프로세스를 OS가 끊게 한다. +function importInChild(moduleName, body, timeout = 10_000) { + const moduleUrl = new URL(moduleName, import.meta.url).href; + const execution = spawnSync( + process.execPath, + ["--input-type=module", "--eval", `import * as target from ${JSON.stringify(moduleUrl)};\n${body}`], + { encoding: "utf8", timeout, killSignal: "SIGKILL" }, + ); + assert.equal(execution.signal, null, `${timeout}ms 안에 끝나지 않았습니다.`); + assert.equal(execution.status, 0, execution.stderr); + return JSON.parse(execution.stdout); +} + +test("#2218: 닫히지 않는 annotation 인자가 이어져도 @Test 검사가 유한 시간에 끝난다", async () => { + const testPath = "app/src/androidTest/java/com/example/RuntimeTest.kt"; + const root = await writeAndroidTestSource(testPath, [ + "package com.example", + "", + "class RuntimeTest {", + " @Test", + " fun works() = Unit", + "", + ` @Test@.(${")@.(".repeat(40)}`, + "}", + ]); + const plan = (method) => selectedPlan(testPath, `com.example.RuntimeTest#${method}`); + const outcomes = importInChild( + "./ci-test-plan.mjs", + `const [root, testPath, works, other, missing] = ${JSON.stringify([ + root, + testPath, + plan("works"), + plan("other"), + plan("missing"), + ])}; + const settle = (promise) => promise.then(() => "ok", (error) => error.message); + console.log(JSON.stringify({ + impact: await settle(target.validateCiTestPlanImpact(works, [testPath], { root })), + omitted: await settle(target.validateCiTestPlanImpact(other, [testPath], { root })), + sources: await settle(target.validateCiTestPlanSources(works, { root })), + missing: await settle(target.validateCiTestPlanSources(missing, { root })), + }));`, + ); + + assert.equal(outcomes.impact, "ok"); + assert.ok(outcomes.omitted.endsWith(`${testPath}#works`), outcomes.omitted); + assert.equal(outcomes.sources, "ok"); + assert.match(outcomes.missing, /@Test 메서드가 파일에 없습니다/); +}); + test("기존 PR도 계획이 없으면 실패한다", () => { const pullRequest = { number: 3, created_at: "2026-08-01T00:00:00Z", body: "old" }; assert.equal(inspectPullRequestCiTestPlan(pullRequest).valid, false); diff --git a/.github/scripts/classify-android-managed-device-failure.mjs b/.github/scripts/classify-android-managed-device-failure.mjs index 4c94d0eb75..543b7d5809 100644 --- a/.github/scripts/classify-android-managed-device-failure.mjs +++ b/.github/scripts/classify-android-managed-device-failure.mjs @@ -9,6 +9,20 @@ const INFRASTRUCTURE_PATTERNS = [ ["managed-device-unavailable", /managed[ -]device[^\n]*(?:failed|timeout|timed out|unavailable)/i], ]; +// 저장소가 429 로 거절해 설정 단계 의존성 해석이 실패했고 :app 태스크가 하나도 돌지 않았다면 +// PR 코드가 실행되기 전에 끝난 인프라 실패다(#2157). 429 없는 해석 실패는 없는 좌표일 수 있어 +// 재실행으로 풀리지 않으므로 넣지 않는다. +const DEPENDENCY_RESOLUTION_FAILURE = /Could not resolve all files for configuration/; +const DEPENDENCY_REPOSITORY_RATE_LIMIT = /Received status code 429 from server/; +const APP_TASK_EXECUTED = /^> Task :app:/m; + +function dependencyResolutionSignals(log) { + const rateLimitedBeforeAppTasks = DEPENDENCY_RESOLUTION_FAILURE.test(log) && + DEPENDENCY_REPOSITORY_RATE_LIMIT.test(log) && + !APP_TASK_EXECUTED.test(log); + return rateLimitedBeforeAppTasks ? ["dependency-repository-rate-limited"] : []; +} + const BOOT_PHASE_PATTERNS = [ ["managed-device-setup-task", /> Task [^\n]*:pixel2Api(?:30|34)(?:Setup|Check)\b/im], ["api34-managed-device-task", /> Task [^\n]*:pixel2Api34DebugAndroidTest\b/im], @@ -36,7 +50,10 @@ export function classifyAndroidManagedDeviceFailure({ const normalizedDevice = String(device ?? "").trim().toLowerCase(); const normalizedExitCode = String(exitCode ?? "").trim(); const normalizedOutcome = String(outcome ?? "").trim().toLowerCase(); - const infrastructureSignals = matchingSignals(log, INFRASTRUCTURE_PATTERNS); + const infrastructureSignals = [ + ...matchingSignals(log, INFRASTRUCTURE_PATTERNS), + ...dependencyResolutionSignals(log), + ]; const bootPhaseSignals = matchingSignals(log, BOOT_PHASE_PATTERNS); const testExecutionSignals = matchingSignals(log, TEST_EXECUTION_PATTERNS); const timedOut = normalizedExitCode === "124" || diff --git a/.github/scripts/classify-android-managed-device-failure.test.mjs b/.github/scripts/classify-android-managed-device-failure.test.mjs index dd157b3603..a0176e01d1 100644 --- a/.github/scripts/classify-android-managed-device-failure.test.mjs +++ b/.github/scripts/classify-android-managed-device-failure.test.mjs @@ -115,6 +115,97 @@ test("retries an explicit emulator boot failure before tests start", () => { assert.equal(result.reason, "explicit-infrastructure-failure"); }); +// PR #2154 run 35843330616 API 34 로그에서 뜬 줄이다. Maven Central 이 429 로 거절해 설정 +// 단계에서 끝났고, :build-logic 태스크만 돌았을 뿐 :app 태스크와 에뮬레이터 기동은 없었다. +const DEPENDENCY_RATE_LIMIT_LOG = [ + "=== selected invocation: 전체 목록 3개 ===", + "> Task :build-logic:compileKotlin", + "> Task :build-logic:jar", + "> Configure project :app", + "FAILURE: Build failed with an exception.", + "> Could not resolve all files for configuration ':app:unified-test-platform-android-test-plugin-host-additional-test-output'.", + " > Could not resolve com.google.protobuf:protobuf-java:4.28.3.", + " > Could not HEAD 'https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/plugin/compose/org.jetbrains.kotlin.plugin.compose.gradle.plugin/2.4.10/org.jetbrains.kotlin.plugin.compose.gradle.plugin-2.4.10.jar'. Received status code 429 from server: Too Many Requests", + "BUILD FAILED in 34s", +].join("\n"); + +test("retries the API 34 configuration-phase dependency rate limit observed on PR 2154", () => { + // 그 run 은 Gradle 실패에서 스텝이 바로 빠져 outcome=failure·exitCode="" 였다(#2157). + // 종료 코드를 기록하게 고친 뒤에는 outcome=success·exitCode=1 로 온다. + for (const [outcome, exitCode] of [["failure", ""], ["success", "1"]]) { + const result = classifyAndroidManagedDeviceFailure({ + device: "api34", + outcome, + exitCode, + log: DEPENDENCY_RATE_LIMIT_LOG, + testResultCount: 0, + }); + + assert.equal(result.retryable, true); + assert.equal(result.reason, "explicit-infrastructure-failure"); + assert.deepEqual(result.infrastructureSignals, ["dependency-repository-rate-limited"]); + } +}); + +test("does not retry a dependency rate limit once an :app task has run", () => { + const result = classifyAndroidManagedDeviceFailure({ + device: "api34", + outcome: "success", + exitCode: "1", + // 로그 첫 줄이 아닌 자리에 둬야 줄 단위 판정(/m)이 빠지는 회귀를 잡는다. + log: DEPENDENCY_RATE_LIMIT_LOG.replace( + "> Configure project :app", + "> Configure project :app\n> Task :app:preBuild UP-TO-DATE", + ), + testResultCount: 0, + }); + + assert.equal(result.retryable, false); + assert.equal(result.reason, "not-proven-infrastructure-failure"); + assert.deepEqual(result.infrastructureSignals, []); +}); + +test("does not retry a dependency failure that lacks either resolution failure or 429", () => { + for (const log of [ + // PR 이 없는 좌표를 넣은 경우다. 다시 돌려도 같은 실패가 난다. + [ + "> Could not resolve all files for configuration ':app:debugRuntimeClasspath'.", + " > Could not find com.example:missing:1.0.", + ].join("\n"), + // 429 가 찍혔어도 해석 실패로 끝나지 않았으면 빌드를 멈춘 원인이 아니다. + [ + "Could not HEAD 'https://repo.maven.apache.org/maven2/x.pom'. Received status code 429 from server: Too Many Requests", + "> Task :feature:afternote:presentation:compileDebugKotlin FAILED", + ].join("\n"), + ]) { + const result = classifyAndroidManagedDeviceFailure({ + device: "api34", + outcome: "success", + exitCode: "1", + log, + testResultCount: 0, + }); + + assert.equal(result.retryable, false); + assert.equal(result.reason, "not-proven-infrastructure-failure"); + assert.deepEqual(result.infrastructureSignals, []); + } +}); + +test("keeps API 30 dependency rate limits on the timeout-only recovery policy", () => { + const result = classifyAndroidManagedDeviceFailure({ + device: "api30", + outcome: "success", + exitCode: "1", + log: DEPENDENCY_RATE_LIMIT_LOG, + testResultCount: 0, + }); + + assert.equal(result.retryable, false); + assert.equal(result.reason, "not-proven-infrastructure-failure"); + assert.deepEqual(result.infrastructureSignals, ["dependency-repository-rate-limited"]); +}); + test("does not retry a successful managed-device run", () => { const result = classifyAndroidManagedDeviceFailure({ device: "api34", diff --git a/.github/scripts/collect-dependency-audit.mjs b/.github/scripts/collect-dependency-audit.mjs index 33673cbb50..a1e136a513 100644 --- a/.github/scripts/collect-dependency-audit.mjs +++ b/.github/scripts/collect-dependency-audit.mjs @@ -85,11 +85,38 @@ function stripTomlComment(line) { return line; } +function quotedTomlString(value, start) { + if (value[start] !== '"') return null; + for (let index = start + 1; index < value.length; index += 1) { + if (value[index] === "\\") { + index += 1; + } else if (value[index] === '"') { + return { value: value.slice(start + 1, index), end: index + 1 }; + } + } + return null; +} + function parseInlineTable(value) { const fields = {}; - const matcher = /([A-Za-z0-9_.-]+)\s*=\s*"((?:\\.|[^"])*)"/g; - for (const match of value.matchAll(matcher)) { - fields[match[1]] = match[2].replaceAll('\\"', '"').replaceAll("\\\\", "\\"); + let index = 0; + while (index < value.length) { + if (!/[A-Za-z0-9_.-]/.test(value[index])) { + index += 1; + continue; + } + const start = index; + while (index < value.length && /[A-Za-z0-9_.-]/.test(value[index])) index += 1; + const key = value.slice(start, index); + while (index < value.length && /\s/.test(value[index])) index += 1; + if (value[index] !== "=") continue; + index += 1; + while (index < value.length && /\s/.test(value[index])) index += 1; + if (value[index] !== '"') continue; + const quoted = quotedTomlString(value, index); + if (quoted === null) break; + fields[key] = quoted.value.replaceAll('\\"', '"').replaceAll("\\\\", "\\"); + index = quoted.end; } return fields; } @@ -118,7 +145,8 @@ export function parseVersionCatalog(content) { } const [, alias, rawValue] = assignment; if (section === "versions") { - const version = /^"((?:\\.|[^"])*)"$/.exec(rawValue)?.[1]; + const quoted = quotedTomlString(rawValue, 0); + const version = quoted?.end === rawValue.length ? quoted.value : undefined; if (version !== undefined) { catalog.versions[alias] = version; } @@ -383,12 +411,8 @@ async function fetchCoordinateFile(entry, suffix, fetchImpl) { } function decodeXml(value) { - return String(value) - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("&", "&") - .replaceAll(""", '"') - .replaceAll("'", "'"); + const entities = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" }; + return String(value).replace(/&(lt|gt|amp|quot|apos);/g, (_, entity) => entities[entity]); } export function parseBomPom(content) { diff --git a/.github/scripts/collect-dependency-audit.test.mjs b/.github/scripts/collect-dependency-audit.test.mjs index dcf79ec45c..18d9678881 100644 --- a/.github/scripts/collect-dependency-audit.test.mjs +++ b/.github/scripts/collect-dependency-audit.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import test from "node:test"; import { @@ -37,6 +38,62 @@ test("parses version catalog coordinates and refs", () => { assert.equal(catalog.plugins["kotlin-compose"].id, "org.jetbrains.kotlin.plugin.compose"); }); +test("reads escaped quotes, backslashes and # inside TOML strings", () => { + const catalog = parseVersionCatalog(String.raw` +[versions] +hash = "1.0#2" # comment +quoted = "1.0\"rc\\" + +[libraries] +escaped = { module = "g:a\"b#c", version = "1\\2", version.ref = "hash" } # "comment" +split = { group = "g", name = "n\\" } + +[plugins] +escaped = { id = "x.y", version = "3\"4" } +`); + assert.deepEqual(catalog.versions, { hash: "1.0#2", quoted: String.raw`1.0\"rc\\` }); + assert.deepEqual(catalog.libraries.escaped, { + alias: "escaped", + coordinate: 'g:a"b#c', + version: "1\\2", + versionRef: "hash", + }); + assert.equal(catalog.libraries.split.coordinate, "g:n\\"); + assert.equal(catalog.plugins.escaped.version, '3"4'); +}); + +// 정규식 역추적은 이벤트 루프를 막아 node:test 시간 제한이 듣지 않는다. 자식 프로세스를 OS가 끊게 한다. +function importInChild(moduleName, body, timeout = 10_000) { + const moduleUrl = new URL(moduleName, import.meta.url).href; + const execution = spawnSync( + process.execPath, + ["--input-type=module", "--eval", `import * as target from ${JSON.stringify(moduleUrl)};\n${body}`], + { encoding: "utf8", timeout, killSignal: "SIGKILL" }, + ); + assert.equal(execution.signal, null, `${timeout}ms 안에 끝나지 않았습니다.`); + assert.equal(execution.status, 0, execution.stderr); + return JSON.parse(execution.stdout); +} + +test("#2218: unterminated TOML strings finish in bounded time without becoming values", () => { + const unterminated = "\\!".repeat(48); + const parsed = importInChild( + "./collect-dependency-audit.mjs", + `const [libraries, versions] = ${JSON.stringify([ + `[libraries]\na = { module = "${unterminated}`, + `[versions]\na = "${unterminated}`, + ])}; + console.log(JSON.stringify({ + library: target.parseVersionCatalog(libraries).libraries.a, + versions: target.parseVersionCatalog(versions).versions, + }));`, + ); + assert.deepEqual(parsed, { + library: { alias: "a", coordinate: null, version: null, versionRef: null }, + versions: {}, + }); +}); + test("finds generated accessors and convention-plugin string aliases only", () => { const catalog = parseVersionCatalog(catalogText); const usage = detectCatalogUsage(catalog, [ @@ -81,6 +138,28 @@ test("extracts BOM-managed versions", () => { assert.equal(managed["androidx.compose.runtime:runtime"], "1.11.4"); }); +test("decodes BOM property entities once and keeps unknown entities", () => { + const managed = parseBomPom(` + + + 1.0&quot;&apos;&lt; + <>&"' + 1 2&3&amp; + + + gescaped\${escaped.version} + gbasic\${basic.version} + gunknown\${unknown.version} + + + `); + assert.deepEqual(managed, { + "g:escaped": "1.0"'<", + "g:basic": "<>&\"'", + "g:unknown": "1 2&3&", + }); +}); + test("extracts selected Gradle versions after conflict resolution", () => { const dependencies = parseResolvedDependencies( "+--- androidx.compose.runtime:runtime:1.10.6 -> 1.11.4\n" + diff --git a/.github/scripts/create-ci-config.test.mjs b/.github/scripts/create-ci-config.test.mjs index cfa1e077e8..5e95a24297 100644 --- a/.github/scripts/create-ci-config.test.mjs +++ b/.github/scripts/create-ci-config.test.mjs @@ -126,16 +126,27 @@ test("keeps pull request validation secretless and release credentials isolated" } // pull_request_target은 default branch 정의에 쓰기 권한을 줄 수 있어 원칙적으로 금지한다. - // 유일한 예외는 닫힌 스택 멤버를 알리는 이 좁은 bridge이며, PR code/artifact/cache를 - // 실행하지 않고 default branch 정책과 API JSON만 읽는 불변식을 함께 고정한다. - assert.deepEqual(pullRequestTargetWorkflows, ["stack-integrity-notify.yml"]); - const stackNotifier = workflows.get("stack-integrity-notify.yml"); - assert.match(stackNotifier, /^permissions: \{\}$/m); - assert.match(stackNotifier, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); - assert.match(stackNotifier, /persist-credentials: false/); - assert.doesNotMatch(stackNotifier, /github\.event\.pull_request\.head\.(sha|ref)/); - assert.doesNotMatch(stackNotifier, /actions\/(download-artifact|cache)@/); - assert.doesNotMatch(stackNotifier, /\bsecrets(?:\.|\[)/); + // 예외는 PR code/artifact/cache를 실행하지 않고 default branch 정책과 API JSON만 읽는 좁은 + // bridge 셋뿐이다 — 닫힌 스택 멤버 알림, merge queue 방출 처리(#1892), 머지 순서 가드(#1977, + // PR 사본 YAML 로 판정하던 구멍과 재게시 guard 두 줄을 없앤다), dependabot PR 보정(#2112, + // 봇 PR 의 pull_request 토큰은 읽기 전용이라 이슈 생성·제목·본문 편집을 못 한다). 그 불변식을 + // 넷 모두에 고정한다. + const narrowBridges = [ + "dependabot-pr-gate.yml", + "merge-order-guard.yml", + "merge-queue-dequeue.yml", + "stack-integrity-notify.yml", + ]; + assert.deepEqual([...pullRequestTargetWorkflows].sort(), narrowBridges); + for (const bridgeName of narrowBridges) { + const bridge = workflows.get(bridgeName); + assert.match(bridge, /^permissions: \{\}$/m, bridgeName); + assert.match(bridge, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/, bridgeName); + assert.match(bridge, /persist-credentials: false/, bridgeName); + assert.doesNotMatch(bridge, /github\.event\.pull_request\.head\.(sha|ref)/, bridgeName); + assert.doesNotMatch(bridge, /actions\/(download-artifact|cache)@/, bridgeName); + assert.doesNotMatch(bridge, /\bsecrets(?:\.|\[)/, bridgeName); + } const actionReference = "uses: ./.github/actions/setup-ci-config"; for (const name of [ diff --git a/.github/scripts/dependabot-pr-gate.mjs b/.github/scripts/dependabot-pr-gate.mjs new file mode 100644 index 0000000000..58b0e7a7b0 --- /dev/null +++ b/.github/scripts/dependabot-pr-gate.mjs @@ -0,0 +1,208 @@ +// dependabot PR 이 열릴 때 Repository Quality 게이트를 스스로 지키게 만든다 (#2112). +// +// 봇은 이슈를 만들지 못하고 본문에 CI Test Plan 도 쓰지 않아, 열리는 순간 +// Repository Quality 와 기기 잡이 구조적으로 실패한다(#1397·#1699·#2106). 사람이 매번 +// 손으로 하던 세 가지 — 대표 이슈 신설, 제목 끝 `(#N)`, 본문의 `Refs #N` + CI Test Plan — +// 를 여기서 한다. mode 는 사람이 정하지 않는다. `ci-test-plan.mjs` 가 변경 파일로 판정한 +// 결과를 그대로 쓴다. 게이트 면제는 하지 않는다 — 봇 PR 도 워크플로 파일을 건드린다. +// +// 순수 함수(needsGate·buildPlan·buildIssue·applyGate)와 GitHub 호출(main)을 갈라 두어 +// 정책은 dependabot-pr-gate.test.mjs 가 잠근다. + +import { inspectAndroidTestImpact } from "./ci-test-plan.mjs"; + +export const GATE_MARKER = ""; +export const DEPENDABOT_LOGIN = "dependabot[bot]"; +export const ISSUE_LABELS = ["maintenance", "area:platform"]; +export const ISSUE_ASSIGNEES = ["1hyok"]; + +const TITLE_ISSUE_PATTERN = /\(#(\d+)\)\s*$/; + +/** 제목이 `(#N)` 으로 끝나고 본문에 이 게이트의 마커가 있으면 이미 처리된 PR 이다. */ +export function needsGate({ title, body }) { + const titled = TITLE_ISSUE_PATTERN.test(String(title ?? "")); + const marked = String(body ?? "").includes(GATE_MARKER); + return !(titled && marked); +} + +/** + * 변경 파일로 CI Test Plan 을 정한다. `ci-test-plan.mjs` 의 판정이 정본이다 — + * FULL_REQUIRED_PATHS 에 걸리면 full, 아니면 none. selected 는 쓰지 않는다: + * 봇 범프가 androidTest 소스를 바꾸는 일은 없고, 바꿨다면 사람이 봐야 한다. + */ +export function buildPlan(changedPaths) { + const impact = inspectAndroidTestImpact(changedPaths); + if (impact.selected.length > 0 && impact.full.length === 0) { + throw new Error( + `dependabot 범프가 androidTest 경계 경로를 건드렸습니다. 사람이 plan 을 정해야 합니다: ${impact.selected.join(", ")}`, + ); + } + if (impact.full.length > 0) { + return { + mode: "full", + reason: + `dependabot 범프가 FULL_REQUIRED_PATHS 에 드는 파일을 바꿨다(${impact.full.join(", ")}). ` + + "워크플로 자체가 바뀌므로 기기 레인 전량으로 검증한다.", + requiredPaths: impact.full, + }; + } + return { + mode: "none", + reason: "dependabot 범프가 바꾼 파일이 전부 androidTest 경계 밖이다. 앱 코드·기기 레인 정의를 건드리지 않는다.", + requiredPaths: [], + }; +} + +/** 대표 이슈. `.github/ISSUE_TEMPLATE/issue.yml` 양식이라 reconcile-issue-metadata 가 어사인·라벨을 맞춘다. */ +export function buildIssue({ title, prNumber, changedPaths, plan }) { + const summary = String(title ?? "").replace(/^chore\(deps\):\s*/i, "").trim() || `PR #${prNumber}`; + const files = [...new Set(changedPaths)].sort(); + const fileLines = files.map((filePath) => `- \`${filePath}\``).join("\n"); + const modeLine = + plan.mode === "full" + ? `androidTest mode=full 로 검증한다. FULL_REQUIRED_PATHS 에 드는 파일: ${plan.requiredPaths.map((p) => `\`${p}\``).join(", ")}.` + : "androidTest mode=none 이다. 변경 파일이 전부 androidTest 경계 밖이다."; + return { + title: `chore(ci): dependabot 범프 수용: ${summary}`, + labels: ISSUE_LABELS, + assignees: ISSUE_ASSIGNEES, + body: [ + "### 작업 유형", + "", + "maintenance — CI·빌드·의존성·저장소 운영 유지보수", + "", + "### 주 담당 모듈", + "", + "platform — CI·빌드·릴리스·저장소 운영", + "", + "### 개요", + "", + `dependabot 이 올린 PR #${prNumber}(${summary})의 대표 이슈다. dependabot-pr-gate 워크플로가 만들었다(#2112).`, + "", + "Repository Quality 가 모든 PR 에 제목 대표 이슈 번호와 본문 Refs 를 요구하는데 봇은 이슈를 만들지 못한다. 이 이슈로 연결해 수용한다.", + "", + `변경 파일 ${files.length}개:`, + fileLines, + "", + modeLine, + "", + "머지 전에 확인할 것:", + "- 태그 SHA 가 실제 릴리스와 같은지 (`gh api repos///git/ref/tags/`).", + "- PR 을 develop 과 합친 트리에 옛 SHA 가 실제 `uses:` 로 남지 않는지. 그룹 범프는 PR 이 열린 뒤 develop 에 들어온 참조를 놓친다.", + "", + "### 참고", + "", + "선례 #1397 · #2111. 봇 PR 을 게이트에서 면제하지 않는 결정은 #2112.", + ].join("\n"), + }; +} + +/** 제목 끝에 `(#N)`, 본문 맨 위에 `Refs #N` 과 CI Test Plan 을 붙인다. 봇 본문은 그대로 아래 둔다. */ +export function applyGate({ title, body, issueNumber, plan }) { + const baseTitle = String(title ?? "").replace(TITLE_ISSUE_PATTERN, "").trim(); + const planJson = JSON.stringify( + { androidTest: { mode: plan.mode, reason: plan.reason, tests: [] } }, + null, + 2, + ); + const original = String(body ?? "").replace(GATE_MARKER, "").trim(); + const nextBody = [ + GATE_MARKER, + `Refs #${issueNumber}`, + "", + "## CI Test Plan", + "", + "```json", + planJson, + "```", + "", + "---", + "", + original, + ].join("\n"); + return { title: `${baseTitle} (#${issueNumber})`, body: nextBody }; +} + +async function github(url, token, init = {}) { + const response = await fetch(url, { + ...init, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + ...(init.body ? { "Content-Type": "application/json" } : {}), + ...(init.headers ?? {}), + }, + }); + if (!response.ok) { + throw new Error(`${init.method ?? "GET"} ${url} -> ${response.status} ${await response.text()}`); + } + return response.json(); +} + +async function listChangedPaths(apiUrl, repository, prNumber, token) { + const paths = []; + for (let page = 1; page <= 30; page += 1) { + const files = await github( + `${apiUrl}/repos/${repository}/pulls/${prNumber}/files?per_page=100&page=${page}`, + token, + ); + for (const file of files) paths.push(file.filename); + if (files.length < 100) break; + } + return paths; +} + +async function main() { + const token = process.env.GITHUB_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + const prNumber = Number(process.env.PR_NUMBER); + const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com"; + if (!token || !repository || !Number.isInteger(prNumber) || prNumber <= 0) { + throw new Error("GITHUB_TOKEN, GITHUB_REPOSITORY, PR_NUMBER are required"); + } + + // 이벤트 페이로드가 아니라 live 상태를 정본으로 삼는다. 대기 중 사람이 먼저 고쳤을 수 있다. + const pullRequest = await github(`${apiUrl}/repos/${repository}/pulls/${prNumber}`, token); + if (pullRequest.user?.login !== DEPENDABOT_LOGIN) { + console.log(`#${prNumber} 작성자가 ${pullRequest.user?.login} 이라 건너뜀`); + return; + } + if (pullRequest.state !== "open") { + console.log(`#${prNumber} 이 ${pullRequest.state} 라 건너뜀`); + return; + } + if (!needsGate(pullRequest)) { + console.log(`#${prNumber} 은 이미 대표 이슈와 CI Test Plan 이 있음`); + return; + } + + const changedPaths = await listChangedPaths(apiUrl, repository, prNumber, token); + const plan = buildPlan(changedPaths); + + // 제목에 이슈 번호가 이미 있으면(사람이 붙였거나 앞선 실행이 본문 갱신 전에 끊겼거나) 재사용한다. + let issueNumber = Number(TITLE_ISSUE_PATTERN.exec(pullRequest.title ?? "")?.[1]); + if (!issueNumber) { + const issue = buildIssue({ title: pullRequest.title, prNumber, changedPaths, plan }); + const created = await github(`${apiUrl}/repos/${repository}/issues`, token, { + method: "POST", + body: JSON.stringify(issue), + }); + issueNumber = created.number; + console.log(`대표 이슈 #${issueNumber} 생성`); + } + + const next = applyGate({ title: pullRequest.title, body: pullRequest.body, issueNumber, plan }); + await github(`${apiUrl}/repos/${repository}/pulls/${prNumber}`, token, { + method: "PATCH", + body: JSON.stringify(next), + }); + console.log(`#${prNumber} 제목·본문 갱신: Refs #${issueNumber}, androidTest mode=${plan.mode}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + console.error(`::error::${error.message}`); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/dependabot-pr-gate.test.mjs b/.github/scripts/dependabot-pr-gate.test.mjs new file mode 100644 index 0000000000..1a1aa9c31b --- /dev/null +++ b/.github/scripts/dependabot-pr-gate.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { inspectCiTestPlan } from "./ci-test-plan.mjs"; +import { + DEPENDABOT_LOGIN, + GATE_MARKER, + ISSUE_ASSIGNEES, + ISSUE_LABELS, + applyGate, + buildIssue, + buildPlan, + needsGate, +} from "./dependabot-pr-gate.mjs"; + +const BOT_TITLE = "chore(deps): bump the github-actions-updates group with 3 updates"; +const BOT_BODY = "Bumps the github-actions-updates group with 3 updates: actions/setup-java ..."; + +test("처리되지 않은 봇 PR 만 게이트 대상이다", () => { + assert.equal(needsGate({ title: BOT_TITLE, body: BOT_BODY }), true); + assert.equal(needsGate({ title: `${BOT_TITLE} (#2111)`, body: BOT_BODY }), true, "제목만 고친 것은 본문이 아직 비었다"); + assert.equal(needsGate({ title: BOT_TITLE, body: `${GATE_MARKER}\n${BOT_BODY}` }), true, "본문만 고친 것은 제목이 아직 비었다"); + assert.equal(needsGate({ title: `${BOT_TITLE} (#2111)`, body: `${GATE_MARKER}\n${BOT_BODY}` }), false); + assert.equal(needsGate({ title: null, body: null }), true); +}); + +test("mode 는 ci-test-plan 의 판정을 그대로 따른다: 기기 레인 정의를 건드리면 full, 아니면 none", () => { + const full = buildPlan([".github/workflows/android-managed-device.yml", ".github/workflows/lint.yml"]); + assert.equal(full.mode, "full"); + assert.deepEqual(full.requiredPaths, [".github/workflows/android-managed-device.yml"]); + assert.match(full.reason, /android-managed-device\.yml/); + + const none = buildPlan([".github/workflows/codeql.yml", ".github/workflows/lint.yml"]); + assert.equal(none.mode, "none"); + assert.deepEqual(none.requiredPaths, []); + + const gradle = buildPlan(["gradle/libs.versions.toml"]); + assert.equal(gradle.mode, "full", "gradle/ 접두사도 full 이다"); +}); + +test("봇 범프가 androidTest 경계 경로를 건드리면 자동으로 정하지 않고 실패한다", () => { + assert.throws( + () => buildPlan(["app/src/main/AndroidManifest.xml"]), + /사람이 plan 을 정해야 합니다/, + ); +}); + +test("대표 이슈는 issue.yml 양식이고 platform maintenance 로 1hyok 에게 간다", () => { + const plan = buildPlan([".github/workflows/codeql.yml"]); + const issue = buildIssue({ title: BOT_TITLE, prNumber: 2106, changedPaths: [".github/workflows/codeql.yml"], plan }); + + assert.equal(issue.title, "chore(ci): dependabot 범프 수용: bump the github-actions-updates group with 3 updates"); + assert.deepEqual(issue.labels, ISSUE_LABELS); + assert.deepEqual(issue.assignees, ISSUE_ASSIGNEES); + assert.deepEqual(ISSUE_LABELS, ["maintenance", "area:platform"]); + assert.deepEqual(ISSUE_ASSIGNEES, ["1hyok"]); + assert.match(issue.body, /^### 작업 유형\n\nmaintenance — /); + assert.match(issue.body, /### 주 담당 모듈\n\nplatform — /); + assert.match(issue.body, /PR #2106/); + assert.match(issue.body, /- `\.github\/workflows\/codeql\.yml`/); + assert.match(issue.body, /mode=none/); + assert.match(issue.body, /### 참고/); +}); + +test("제목은 (#N) 으로 정확히 한 번 끝나고 본문은 Refs 와 유효한 CI Test Plan 으로 시작한다", () => { + const plan = buildPlan([".github/workflows/android-managed-device.yml"]); + const next = applyGate({ title: BOT_TITLE, body: BOT_BODY, issueNumber: 2111, plan }); + + assert.equal(next.title, `${BOT_TITLE} (#2111)`); + assert.equal((next.title.match(/\(#\d+\)/g) ?? []).length, 1); + assert.ok(next.body.startsWith(`${GATE_MARKER}\nRefs #2111\n`)); + assert.ok(next.body.endsWith(BOT_BODY), "봇 본문은 그대로 아래에 남는다"); + assert.equal(needsGate(next), false, "한 번 적용한 결과는 다시 대상이 아니다"); + + const inspected = inspectCiTestPlan(next.body, { pullRequestNumber: 2106 }); + assert.deepEqual(inspected.errors ?? [], [], JSON.stringify(inspected)); + assert.equal(inspected.plan?.androidTest?.mode ?? inspected.androidTest?.mode, "full"); +}); + +test("이미 (#N) 이 붙은 제목에 다시 적용해도 번호가 겹치지 않는다", () => { + const plan = buildPlan([".github/workflows/codeql.yml"]); + const once = applyGate({ title: BOT_TITLE, body: BOT_BODY, issueNumber: 2111, plan }); + const twice = applyGate({ title: once.title, body: once.body, issueNumber: 2111, plan }); + assert.equal(twice.title, once.title); + assert.equal((twice.body.match(/Refs #2111/g) ?? []).length, 2, "본문 재적용은 앞선 머리말을 봇 본문으로 취급한다"); + assert.equal((twice.body.match(new RegExp(GATE_MARKER.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"), "g")) ?? []).length, 1); +}); + +test("워크플로는 봇 PR 에만 돌고 PR 코드를 체크아웃하지 않는다", async () => { + const source = await readFile(new URL("../workflows/dependabot-pr-gate.yml", import.meta.url), "utf8"); + + assert.match(source, /pull_request_target:/); + assert.match(source, new RegExp(`if: github\\.event\\.pull_request\\.user\\.login == '${DEPENDABOT_LOGIN.replace(/[[\]]/g, "\\$&")}'`)); + assert.match(source, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); + assert.doesNotMatch(source, /pull_request\.head/, "PR 쪽 ref 를 참조하지 않는다"); + assert.match(source, /persist-credentials: false/); + assert.match(source, /sparse-checkout: \.github\/scripts/); + assert.match(source, /timeout-minutes: 5/); + assert.match(source, /^permissions: \{\}/m); + assert.match(source, /issues: write/); + assert.match(source, /pull-requests: write/); + assert.match(source, /run: node \.github\/scripts\/dependabot-pr-gate\.mjs/); +}); diff --git a/.github/scripts/handle-merge-queue-dequeue.mjs b/.github/scripts/handle-merge-queue-dequeue.mjs new file mode 100644 index 0000000000..1ff578ce31 --- /dev/null +++ b/.github/scripts/handle-merge-queue-dequeue.mjs @@ -0,0 +1,291 @@ +#!/usr/bin/env node + +// merge queue 에서 방출된 PR 을 자동 처리한다 (#1892). +// +// 큐(#1477)는 투입한 뒤의 결과가 «보러 가야만» 보였다. 방출은 두 갈래다. +// - merge group CI 실패 — PR 화면엔 «removed from the merge queue» 만 남고 실패 job 은 Actions 탭에서 +// event:merge_group 으로 따로 찾아야 했다. → 실패 job 링크를 PR 코멘트로 남긴다. 재투입하지 않는다. +// - 조용한 방출 — 앞 건이 머지되며 base 가 움직이면 실패 흔적 없이 큐에서 빠진다(0830 실측 #1509: +// mergeQueueEntry null · mergeStateStatus CLEAN · merge_group run 전량 success). → 같은 head SHA 당 +// 한 번만 재투입한다. 0902 에 감시 루프가 90초마다 맹목 재투입해 30분을 헛돈 사고(#1638·#1639)가 +// «한 번까지» 규칙의 근거다. 횟수는 이 스크립트가 남기는 마커 코멘트로 센다. +// +// 판정 순서는 «실패 job 이 있는가» 가 먼저다. payload 의 reason 문자열은 GitHub 이 열거값을 문서화하지 +// 않아 정본으로 삼지 않는다 — 실패 job 은 데이터로 확인하고, reason 은 «사람이 뺐다·이미 머지됐다» 를 +// 거르는 데만 쓴다. 그 밖의 reason 은 전부 조용한 방출로 본다. + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +export const REQUEUE_MARKER_PREFIX = "`; +} + +/** 같은 head 에서 이미 재투입한 횟수 — 마커 코멘트 수다. 새 커밋이 올라오면 head 가 바뀌어 0 부터다. */ +export function countRequeuesForHead(comments, headSha) { + const marker = requeueMarker(headSha); + return comments.filter((comment) => typeof comment.body === "string" && comment.body.includes(marker)).length; +} + +/** + * 판정표. + * 1. 실패 job 이 있다 → comment-failure (재투입 없음) + * 2. reason 이 MANUAL·ALREADY_MERGED·MERGE → none + * 3. 같은 head 에서 이미 재투입했다 → comment-give-up + * 4. 그 밖 → requeue + */ +export function decide({ reason, failedJobs, requeueCount }) { + if (failedJobs.length > 0) return "comment-failure"; + if (NO_ACTION_REASONS.has(String(reason ?? "").toUpperCase())) return "none"; + if (requeueCount >= 1) return "comment-give-up"; + return "requeue"; +} + +const shortSha = (sha) => sha.slice(0, 7); + +export function renderFailureComment({ reason, headSha, failedJobs, repository, number }) { + const lines = failedJobs.map((job) => `- [${job.runName} / ${job.jobName}](${job.jobUrl}) — ${job.conclusion}`); + return [ + "### merge queue 방출 — merge group CI 실패", + "", + `사유 \`${reason}\` · head \`${shortSha(headSha)}\`. 재투입하지 않았다.`, + "", + "실패한 job:", + ...lines, + "", + `고친 뒤 \`gh pr merge ${number} --repo ${repository}\` 로 다시 투입한다.`, + ].join("\n"); +} + +export function renderRequeueComment({ reason, headSha }) { + return [ + requeueMarker(headSha), + "### merge queue 방출 — 재투입했다", + "", + `사유 \`${reason}\` · head \`${shortSha(headSha)}\` · merge group 실패 job 없음 → 조용한 방출로 보고 한 번 재투입했다.`, + "같은 head 에서 또 방출되면 재투입하지 않고 여기에 남긴다.", + ].join("\n"); +} + +export function renderGiveUpComment({ reason, headSha }) { + return [ + "### merge queue 방출 — 같은 head 두 번째, 재투입하지 않는다", + "", + `사유 \`${reason}\` · head \`${shortSha(headSha)}\`. 실패 job 은 없다.`, + "`gh run list --event merge_group` 으로 merge group run 을 확인하고 수동으로 투입한다.", + ].join("\n"); +} + +export function createApi(token, { fetchImpl = globalThis.fetch } = {}) { + if (typeof fetchImpl !== "function") { + throw new TypeError("fetch 구현이 필요합니다."); + } + + return async function api(apiPath, { method = "GET", body } = {}) { + const response = await fetchImpl(`https://api.github.com${apiPath}`, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "x-github-api-version": "2022-11-28", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!response.ok) { + const detail = await response.text(); + throw new Error(`GitHub API ${method} ${apiPath} 실패: ${response.status} ${detail}`); + } + if (response.status === 204) return null; + return response.json(); + }; +} + +async function graphql(api, query, variables) { + const payload = await api("/graphql", { method: "POST", body: { query, variables } }); + if (payload?.errors?.length) { + throw new Error(`GraphQL 실패: ${JSON.stringify(payload.errors)}`); + } + return payload.data; +} + +/** 이벤트는 runner 대기 중 순서가 뒤집힐 수 있으므로 payload 가 아니라 live 상태를 정본으로 삼는다. */ +export async function fetchLivePullRequest(api, repository, number) { + const [owner, name] = repository.split("/"); + const data = await graphql( + api, + `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + id + state + headRefOid + baseRefName + mergeQueueEntry { state position } + timelineItems(last: 5, itemTypes: [ADDED_TO_MERGE_QUEUE_EVENT]) { + nodes { ... on AddedToMergeQueueEvent { createdAt } } + } + } + } + }`, + { owner, name, number }, + ); + return data.repository.pullRequest; +} + +/** 마지막으로 큐에 들어간 시각 — 그 뒤의 merge_group run 만 이번 방출의 근거다. */ +export function lastEnqueuedAt(pullRequest) { + const stamps = (pullRequest.timelineItems?.nodes ?? []).map((node) => node?.createdAt).filter(Boolean); + return stamps.length > 0 ? stamps[stamps.length - 1] : undefined; +} + +export async function fetchQueueFailedJobs(api, repository, { baseRef, number, since }) { + const prefix = queueBranchPrefix(baseRef, number); + const listed = await api(`/repos/${repository}/actions/runs?event=merge_group&per_page=100`); + const runs = (listed.workflow_runs ?? []) + .filter((run) => typeof run.head_branch === "string" && run.head_branch.startsWith(prefix)) + .filter((run) => !since || typeof run.created_at !== "string" || run.created_at >= since) + .slice(0, QUEUE_RUNS_TO_INSPECT); + const jobsByRunId = new Map(); + for (const run of runs) { + const jobs = await api(`/repos/${repository}/actions/runs/${run.id}/jobs?per_page=100`); + jobsByRunId.set(run.id, jobs.jobs ?? []); + } + return collectFailedJobs({ runs, jobsByRunId, baseRef, number, since }); +} + +export async function fetchComments(api, repository, number) { + const comments = []; + for (let page = 1; page <= 5; page += 1) { + const batch = await api(`/repos/${repository}/issues/${number}/comments?per_page=100&page=${page}`); + comments.push(...batch); + if (batch.length < 100) break; + } + return comments; +} + +export async function postComment(api, repository, number, body) { + await api(`/repos/${repository}/issues/${number}/comments`, { method: "POST", body: { body } }); +} + +export async function enqueue(api, pullRequestId) { + const data = await graphql( + api, + `mutation($pullRequestId: ID!) { + enqueuePullRequest(input: { pullRequestId: $pullRequestId }) { + mergeQueueEntry { state position } + } + }`, + { pullRequestId }, + ); + return data.enqueuePullRequest.mergeQueueEntry; +} + +export async function handleDequeue({ api, repository, number, reason, dryRun = false, logger = console }) { + const live = await fetchLivePullRequest(api, repository, number); + if (!live) throw new Error(`#${number} 를 찾을 수 없습니다`); + if (live.state !== "OPEN") { + logger.log(`#${number} 는 ${live.state} — 아무것도 하지 않는다`); + return { action: "none", why: live.state }; + } + if (live.mergeQueueEntry) { + logger.log(`#${number} 는 이미 큐에 있다(${live.mergeQueueEntry.state}) — 아무것도 하지 않는다`); + return { action: "none", why: "already-queued" }; + } + + const headSha = live.headRefOid; + const since = lastEnqueuedAt(live); + const failedJobs = await fetchQueueFailedJobs(api, repository, { baseRef: live.baseRefName, number, since }); + const comments = await fetchComments(api, repository, number); + const requeueCount = countRequeuesForHead(comments, headSha); + const action = decide({ reason, failedJobs, requeueCount }); + logger.log(`#${number} reason=${reason} head=${shortSha(headSha)} 마지막투입=${since ?? "?"} 실패job=${failedJobs.length} 재투입횟수=${requeueCount} → ${action}`); + + if (dryRun) return { action, dryRun: true, failedJobs, requeueCount }; + + if (action === "comment-failure") { + await postComment(api, repository, number, renderFailureComment({ reason, headSha, failedJobs, repository, number })); + } else if (action === "comment-give-up") { + await postComment(api, repository, number, renderGiveUpComment({ reason, headSha })); + } else if (action === "requeue") { + // 마커를 먼저 남긴다 — 재투입 뒤 코멘트가 실패하면 다음 방출에서 두 번째 재투입이 나간다. + await postComment(api, repository, number, renderRequeueComment({ reason, headSha })); + const entry = await enqueue(api, live.id); + logger.log(`#${number} 재투입 → ${entry?.state ?? "?"} position=${entry?.position ?? "?"}`); + } + return { action, failedJobs, requeueCount }; +} + +async function main() { + const token = process.env.GITHUB_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + if (!token || !repository) { + throw new Error("GITHUB_TOKEN·GITHUB_REPOSITORY 가 필요합니다."); + } + let number = Number(process.env.PULL_REQUEST_NUMBER); + let reason = process.env.DEQUEUE_REASON; + if ((!Number.isInteger(number) || number <= 0 || !reason) && process.env.GITHUB_EVENT_PATH) { + const event = JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, "utf8")); + number = Number.isInteger(number) && number > 0 ? number : Number(event.number ?? event.pull_request?.number); + reason = reason || event.reason; + } + if (!Number.isInteger(number) || number <= 0) { + throw new Error("PULL_REQUEST_NUMBER 가 양의 정수여야 합니다."); + } + reason = reason || "UNKNOWN"; + + const api = createApi(token); + const result = await handleDequeue({ api, repository, number, reason, dryRun: process.env.DRY_RUN === "true" }); + const summary = `merge-queue-dequeue: #${number} reason=${reason} → ${result.action}${result.dryRun ? " (dry-run)" : ""}`; + console.log(summary); + if (process.env.GITHUB_STEP_SUMMARY) { + const { appendFile } = await import("node:fs/promises"); + await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedPath) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/handle-merge-queue-dequeue.test.mjs b/.github/scripts/handle-merge-queue-dequeue.test.mjs new file mode 100644 index 0000000000..f09f58d9a2 --- /dev/null +++ b/.github/scripts/handle-merge-queue-dequeue.test.mjs @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + NO_ACTION_REASONS, + collectFailedJobs, + countRequeuesForHead, + decide, + handleDequeue, + lastEnqueuedAt, + queueBranchPrefix, + renderFailureComment, + renderRequeueComment, + requeueMarker, +} from "./handle-merge-queue-dequeue.mjs"; + +const REPO = "Afternote/Afternote-FE"; +const HEAD = "0123456789abcdef0123456789abcdef01234567"; +const silent = { log() {} }; + +// ---- 판정표 (#1892) ---- + +test("실패 job 이 있으면 reason 과 무관하게 comment-failure — 재투입하지 않는다", () => { + const failedJobs = [{ jobName: "Run Unit Tests", conclusion: "failure" }]; + for (const reason of ["CI_FAILURE", "UNKNOWN_REMOVAL_REASON", "MANUAL", "ROLL_BACK"]) { + assert.equal(decide({ reason, failedJobs, requeueCount: 0 }), "comment-failure", reason); + } +}); + +test("MANUAL·ALREADY_MERGED·MERGE(D) 는 표기와 무관하게 무동작", () => { + for (const reason of [...NO_ACTION_REASONS, "merged", "manual"]) { + assert.equal(decide({ reason, failedJobs: [], requeueCount: 0 }), "none", reason); + } + assert.equal(decide({ reason: "failed_checks", failedJobs: [], requeueCount: 0 }), "requeue"); +}); + +test("실패 job 없는 방출은 같은 head 에서 한 번만 재투입하고, 두 번째는 comment-give-up", () => { + assert.equal(decide({ reason: "UNKNOWN_REMOVAL_REASON", failedJobs: [], requeueCount: 0 }), "requeue"); + assert.equal(decide({ reason: "UNKNOWN_REMOVAL_REASON", failedJobs: [], requeueCount: 1 }), "comment-give-up"); + assert.equal(decide({ reason: "ROLL_BACK", failedJobs: [], requeueCount: 3 }), "comment-give-up"); +}); + +// ---- 실패 job 수집 ---- + +test("이 PR 의 큐 브랜치 run 에서 failure·timed_out job 만 모은다 — 다른 PR 의 run, cancelled job 은 뺀다", () => { + const runs = [ + { id: 1, name: "Unit Test", html_url: "https://x/runs/1", head_branch: queueBranchPrefix("develop", 1509) + "aaaa" }, + { id: 2, name: "Screenshot", html_url: "https://x/runs/2", head_branch: queueBranchPrefix("develop", 1509) + "aaaa" }, + { id: 3, name: "Unit Test", html_url: "https://x/runs/3", head_branch: queueBranchPrefix("develop", 1510) + "aaaa" }, + { id: 4, name: "Lint", html_url: "https://x/runs/4", head_branch: "refs/pull/1509/merge" }, + ]; + const jobsByRunId = new Map([ + [1, [ + { name: "Run Unit Tests", conclusion: "failure", html_url: "https://x/jobs/11" }, + { name: "Kover", conclusion: "cancelled", html_url: "https://x/jobs/12" }, + ]], + [2, [{ name: "Validate", conclusion: "timed_out", html_url: "https://x/jobs/21" }]], + [3, [{ name: "Run Unit Tests", conclusion: "failure", html_url: "https://x/jobs/31" }]], + [4, [{ name: "ktlint", conclusion: "failure", html_url: "https://x/jobs/41" }]], + ]); + const failed = collectFailedJobs({ runs, jobsByRunId, baseRef: "develop", number: 1509 }); + assert.deepEqual(failed.map((job) => job.jobUrl), ["https://x/jobs/11", "https://x/jobs/21"]); + assert.equal(failed[0].runName, "Unit Test"); + assert.equal(failed[1].conclusion, "timed_out"); +}); + +test("마지막 투입 이전의 run 은 이번 방출의 근거가 아니다 — 큐 브랜치 이름은 base SHA 라 옛 실패가 같은 접두어로 잡힌다", () => { + const branch = queueBranchPrefix("develop", 1582) + "aaaa"; + const runs = [ + { id: 1, name: "Android Managed Device Test", html_url: "https://x/runs/1", head_branch: branch, created_at: "2026-09-04T08:06:21Z" }, + { id: 2, name: "Android Managed Device Test", html_url: "https://x/runs/2", head_branch: branch, created_at: "2026-09-04T12:00:00Z" }, + ]; + const jobsByRunId = new Map([ + [1, [{ name: "api34", conclusion: "failure", html_url: "https://x/jobs/1" }]], + [2, [{ name: "api34", conclusion: "success", html_url: "https://x/jobs/2" }]], + ]); + assert.equal(collectFailedJobs({ runs, jobsByRunId, baseRef: "develop", number: 1582 }).length, 1); + assert.equal(collectFailedJobs({ runs, jobsByRunId, baseRef: "develop", number: 1582, since: "2026-09-04T11:00:00Z" }).length, 0); + assert.equal(lastEnqueuedAt({ timelineItems: { nodes: [{ createdAt: "2026-09-04T08:06:00Z" }, { createdAt: "2026-09-04T11:00:00Z" }] } }), "2026-09-04T11:00:00Z"); + assert.equal(lastEnqueuedAt({ timelineItems: { nodes: [] } }), undefined); +}); + +// ---- 재투입 횟수 = 같은 head 의 마커 코멘트 수 ---- + +test("재투입 횟수는 같은 head 의 마커 코멘트만 센다 — 새 커밋이 올라오면 0 부터다", () => { + const comments = [ + { body: renderRequeueComment({ reason: "UNKNOWN_REMOVAL_REASON", headSha: HEAD }) }, + { body: "그냥 코멘트" }, + { body: renderRequeueComment({ reason: "ROLL_BACK", headSha: "f".repeat(40) }) }, + ]; + assert.equal(countRequeuesForHead(comments, HEAD), 1); + assert.equal(countRequeuesForHead(comments, "f".repeat(40)), 1); + assert.equal(countRequeuesForHead(comments, "0".repeat(40)), 0); + assert.ok(renderRequeueComment({ reason: "X", headSha: HEAD }).startsWith(requeueMarker(HEAD))); +}); + +test("실패 코멘트는 job 링크와 재투입 명령을 담고, 재투입했다고 말하지 않는다", () => { + const body = renderFailureComment({ + reason: "CI_FAILURE", + headSha: HEAD, + repository: REPO, + number: 1509, + failedJobs: [{ runName: "Unit Test", jobName: "Run Unit Tests", jobUrl: "https://x/jobs/11", conclusion: "failure" }], + }); + assert.match(body, /\[Unit Test \/ Run Unit Tests\]\(https:\/\/x\/jobs\/11\)/); + assert.match(body, /gh pr merge 1509 --repo Afternote\/Afternote-FE/); + assert.match(body, /재투입하지 않았다/); + assert.ok(!body.includes(requeueMarker(HEAD))); +}); + +// ---- handleDequeue 흐름: live 상태가 정본, 쓰기 순서 ---- + +function fakeApi({ live, runs = [], jobs = {}, comments = [] }) { + const calls = []; + const api = async (apiPath, options = {}) => { + calls.push({ apiPath, method: options.method ?? "GET", body: options.body }); + if (apiPath === "/graphql") { + const query = options.body.query; + if (query.includes("enqueuePullRequest")) { + return { data: { enqueuePullRequest: { mergeQueueEntry: { state: "QUEUED", position: 1 } } } }; + } + return { data: { repository: { pullRequest: live } } }; + } + if (apiPath.startsWith(`/repos/${REPO}/actions/runs?`)) return { workflow_runs: runs }; + const jobsMatch = /\/actions\/runs\/(\d+)\/jobs/.exec(apiPath); + if (jobsMatch) return { jobs: jobs[jobsMatch[1]] ?? [] }; + if (apiPath.includes("/comments") && (options.method ?? "GET") === "GET") return comments; + if (apiPath.includes("/comments") && options.method === "POST") return { id: 1 }; + throw new Error(`unexpected ${apiPath}`); + }; + api.calls = calls; + return api; +} + +const openLive = { + id: "PR_1", + state: "OPEN", + headRefOid: HEAD, + baseRefName: "develop", + mergeQueueEntry: null, + timelineItems: { nodes: [{ createdAt: "2026-09-04T08:00:00Z" }] }, +}; + +test("MERGED·CLOSED 거나 이미 큐에 있으면 payload 와 무관하게 아무것도 쓰지 않는다", async () => { + for (const live of [ + { ...openLive, state: "MERGED" }, + { ...openLive, state: "CLOSED" }, + { ...openLive, mergeQueueEntry: { state: "QUEUED", position: 2 } }, + ]) { + const api = fakeApi({ live }); + const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "UNKNOWN_REMOVAL_REASON", logger: silent }); + assert.equal(result.action, "none"); + assert.equal(api.calls.filter((call) => call.method === "POST" && call.apiPath !== "/graphql").length, 0); + assert.ok(!api.calls.some((call) => call.body?.query?.includes("enqueuePullRequest"))); + } +}); + +test("조용한 방출: 마커 코멘트를 먼저 남기고 그 다음 enqueuePullRequest 를 쏜다", async () => { + const api = fakeApi({ live: openLive }); + const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "UNKNOWN_REMOVAL_REASON", logger: silent }); + assert.equal(result.action, "requeue"); + const writes = api.calls.filter((call) => call.method === "POST" && (call.apiPath !== "/graphql" || call.body.query.includes("enqueuePullRequest"))); + assert.equal(writes.length, 2); + assert.match(writes[0].apiPath, /\/issues\/1509\/comments$/); + assert.ok(writes[0].body.body.startsWith(requeueMarker(HEAD))); + assert.equal(writes[1].apiPath, "/graphql"); +}); + +test("같은 head 의 두 번째 방출은 코멘트만 남기고 enqueuePullRequest 를 쏘지 않는다", async () => { + const api = fakeApi({ live: openLive, comments: [{ body: renderRequeueComment({ reason: "X", headSha: HEAD }) }] }); + const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "UNKNOWN_REMOVAL_REASON", logger: silent }); + assert.equal(result.action, "comment-give-up"); + assert.ok(!api.calls.some((call) => call.body?.query?.includes("enqueuePullRequest"))); + assert.equal(api.calls.filter((call) => call.method === "POST" && call.apiPath.endsWith("/comments")).length, 1); +}); + +test("CI 실패: 실패 job 링크 코멘트만, 재투입 없음", async () => { + const runs = [{ id: 7, name: "Unit Test", html_url: "https://x/runs/7", head_branch: queueBranchPrefix("develop", 1509) + "abc", created_at: "2026-09-04T08:05:00Z" }]; + const jobs = { 7: [{ name: "Run Unit Tests", conclusion: "failure", html_url: "https://x/jobs/71" }] }; + const api = fakeApi({ live: openLive, runs, jobs }); + const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "CI_FAILURE", logger: silent }); + assert.equal(result.action, "comment-failure"); + const comment = api.calls.find((call) => call.method === "POST" && call.apiPath.endsWith("/comments")); + assert.match(comment.body.body, /https:\/\/x\/jobs\/71/); + assert.ok(!api.calls.some((call) => call.body?.query?.includes("enqueuePullRequest"))); +}); + +test("dry-run 은 판정만 하고 아무것도 쓰지 않는다", async () => { + const api = fakeApi({ live: openLive }); + const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "ROLL_BACK", dryRun: true, logger: silent }); + assert.equal(result.action, "requeue"); + assert.equal(api.calls.filter((call) => call.method === "POST" && call.apiPath !== "/graphql").length, 0); + assert.ok(!api.calls.some((call) => call.body?.query?.includes("enqueuePullRequest"))); +}); + +// ---- 워크플로 정책 ---- + +test("워크플로는 default branch 정의로 dequeued 에만 반응하고, 테스트를 먼저 돌린 뒤 스크립트를 부른다", () => { + const source = readFileSync(new URL("../workflows/merge-queue-dequeue.yml", import.meta.url), "utf8"); + assert.match(source, /^on:\n\s{2}pull_request_target:\n\s{4}types: \[dequeued\]$/m); + assert.match(source, /^permissions: \{\}$/m); + assert.match(source, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); + assert.match(source, /persist-credentials: false/); + assert.ok(source.indexOf("handle-merge-queue-dequeue.test.mjs") < source.indexOf("node .github/scripts/handle-merge-queue-dequeue.mjs")); + assert.match(source, /DEQUEUE_REASON: \$\{\{ github\.event\.reason \}\}/); + assert.match(source, /cancel-in-progress: false/); +}); diff --git a/.github/scripts/label-awaiting-author-prs.mjs b/.github/scripts/label-awaiting-author-prs.mjs index b07c281cdd..05aaac2785 100644 --- a/.github/scripts/label-awaiting-author-prs.mjs +++ b/.github/scripts/label-awaiting-author-prs.mjs @@ -20,6 +20,7 @@ // 판단한 PR 에 라벨이 없거나, 반대로 리뷰어 몫인 PR 에 라벨이 붙는다. 커밋·응답·본문 편집을 // 포함해 두 판정이 공유하는 술어는 `awaiting-author-policy.test.mjs` 가 잠근다. +import { readFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; @@ -46,6 +47,7 @@ query($owner: String!, $name: String!, $cursor: String, $pageSize: Int!) { isDraft createdAt author { login } + assignees(first: 10) { nodes { login } } headRepository { nameWithOwner } labels(first: 50) { nodes { name } } reviews(last: 50) { @@ -98,6 +100,7 @@ query($searchQuery: String!, $cursor: String, $pageSize: Int!) { isDraft createdAt author { login } + assignees(first: 10) { nodes { login } } headRepository { nameWithOwner } labels(first: 50) { nodes { name } } reviews(last: 50) { @@ -139,10 +142,45 @@ query($searchQuery: String!, $cursor: String, $pageSize: Int!) { } }`; +// 판정 함수가 각 connection 에서 읽는 시각 필드. reviews 는 판정 기준 시각 자체를 구하는 +// 근거라 여기 넣지 않는다. +const ACTIVITY_TIMESTAMP_BY_CONNECTION = Object.freeze({ + commits: (node) => node?.commit?.committedDate, + comments: (node) => node?.createdAt, + userContentEdits: (node) => node?.editedAt, +}); + function sameLogin(a, b) { return typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase(); } +function isOneOf(login, owners) { + return (owners ?? []).some((owner) => sameLogin(login, owner)); +} + +function toOwners(author) { + return Array.isArray(author) ? author : [author]; +} + +/** + * PR 의 담당자 로그인 목록. + * + * PR 의 공은 «올린 사람» 이 아니라 «어사인된 사람» 에게 있다(#1974 결정, #1980). 변경요청에 + * 응답할 의무도, 그 미응답이 막는 «새 PR 열기» 도 담당자의 몫이다. 어사인이 비어 있는 동안만 + * 작성자가 담당자다 — pr-assign-author 가 빈 어사인에 작성자를 걸므로 잠깐 비는 창에서도 + * 결과가 같다. + */ +export function responsibleLogins(pullRequest) { + const assignees = (pullRequest?.assignees?.nodes ?? []) + .map((node) => node?.login) + .filter((login) => typeof login === "string" && login !== ""); + if (assignees.length > 0) { + return assignees; + } + const author = pullRequest?.author?.login; + return typeof author === "string" && author !== "" ? [author] : []; +} + /** * PR 전체에서 «최신 결정 리뷰» 를 고른다. * @@ -160,6 +198,28 @@ export function latestDecision(reviews) { return decisions.length > 0 ? decisions[decisions.length - 1] : null; } +/** + * `last: 50` 이 판정에 필요한 범위를 덮는지 본다. + * + * 판정은 최신 결정 시각 이후 항목만 세므로, 가져온 것 중 가장 오래된 항목이 그 시각보다 앞서면 + * 그 이후는 전부 들어와 있다. `reviews` 는 그 시각 자체를 구하는 근거라 여기서 제외한다. + */ +export function isCoveredSinceDecision(connectionName, nodes, since) { + const readTimestamp = ACTIVITY_TIMESTAMP_BY_CONNECTION[connectionName]; + if (!readTimestamp) { + return false; + } + // 최신 결정이 없거나 변경요청이 아니면 judgeAwaitingAuthor 가 이 connection 을 읽지 않는다. + if (since === null || since === undefined) { + return true; + } + if (!Array.isArray(nodes) || nodes.length === 0) { + return false; + } + const oldest = readTimestamp(nodes[0]); + return typeof oldest === "string" && oldest <= since; +} + /** * 판정 시각 이후 작성자가 올린 «실질» 커밋 수를 센다. * @@ -174,11 +234,12 @@ export function latestDecision(reviews) { * «빈 커밋» 으로 접으면 라벨이 없는 근거로 둔갑한다 — 놓치는 쪽이 잘못 붙이는 쪽보다 안전하다. */ export function countAuthorFixes({ commits, author, since }) { + const owners = toOwners(author); const nodes = (commits ?? []).map((node) => node?.commit).filter(Boolean); const ownEmails = new Set(); for (const commit of nodes) { - if (sameLogin(commit.author?.user?.login, author) && commit.author?.email) { + if (isOneOf(commit.author?.user?.login, owners) && commit.author?.email) { ownEmails.add(commit.author.email.toLowerCase()); } } @@ -194,7 +255,7 @@ export function countAuthorFixes({ commits, author, since }) { const login = commit.author?.user?.login; const email = commit.author?.email?.toLowerCase(); - const isAuthor = login ? sameLogin(login, author) : Boolean(email && ownEmails.has(email)); + const isAuthor = login ? isOneOf(login, owners) : Boolean(email && ownEmails.has(email)); if (!isAuthor) { continue; } @@ -220,16 +281,17 @@ export function countAuthorFixes({ commits, author, since }) { * `countAuthorBodyEdits` 에서 센다. */ export function countAuthorResponses({ comments, reviews, author, since }) { + const owners = toOwners(author); const issueComments = (comments ?? []).filter( (comment) => - sameLogin(comment?.author?.login, author) && + isOneOf(comment?.author?.login, owners) && typeof comment?.createdAt === "string" && comment.createdAt > since, ).length; const authorReviews = (reviews ?? []).filter( (review) => - sameLogin(review?.author?.login, author) && + isOneOf(review?.author?.login, owners) && typeof review?.submittedAt === "string" && review.submittedAt > since, ).length; @@ -246,9 +308,10 @@ export function countAuthorResponses({ comments, reviews, author, since }) { * 기록은 작성자 조치로 추정하지 않는다. */ export function countAuthorBodyEdits({ userContentEdits, author, since }) { + const owners = toOwners(author); return (userContentEdits ?? []).filter( (edit) => - sameLogin(edit?.editor?.login, author) && + isOneOf(edit?.editor?.login, owners) && typeof edit?.editedAt === "string" && edit.editedAt > since, ).length; @@ -262,11 +325,13 @@ export function countAuthorBodyEdits({ userContentEdits, author, since }) { */ export function judgeAwaitingAuthor(pullRequest, { repository } = {}) { const author = pullRequest?.author?.login ?? ""; + // 조치 주체는 담당자다. 어사인이 비어 있으면 작성자로 폴백한다. + const owners = responsibleLogins(pullRequest); if (pullRequest?.isDraft) { return { awaiting: false, reason: "draft" }; } - if (!author) { + if (!author || owners.length === 0) { return { awaiting: false, reason: "작성자 없음" }; } if (/\[bot\]$/i.test(author) || author === "dependabot" || author === "github-actions") { @@ -289,34 +354,36 @@ export function judgeAwaitingAuthor(pullRequest, { repository } = {}) { const since = decision.submittedAt; const fixes = countAuthorFixes({ commits: pullRequest?.commits?.nodes, - author, + author: owners, since, }); const responses = countAuthorResponses({ comments: pullRequest?.comments?.nodes, reviews: pullRequest?.reviews?.nodes, - author, + author: owners, since, }); const bodyEdits = countAuthorBodyEdits({ userContentEdits: pullRequest?.userContentEdits?.nodes, - author, + author: owners, since, }); if (fixes > 0 || responses > 0 || bodyEdits > 0) { return { awaiting: false, - reason: `작성자 조치 있음(${fixes}커밋 · 응답 ${responses}건 · 본문 편집 ${bodyEdits}건)`, + reason: `담당자 조치 있음(${fixes}커밋 · 응답 ${responses}건 · 본문 편집 ${bodyEdits}건)`, decidedAt: since, + owners, }; } return { awaiting: true, - reason: "변경요청 뒤 작성자 조치 없음", + reason: "변경요청 뒤 담당자 조치 없음", decidedAt: since, reviewer: decision.author?.login ?? "", + owners, }; } @@ -324,8 +391,9 @@ export function judgeAwaitingAuthor(pullRequest, { repository } = {}) { * 새 PR 작성자가 먼저 처리해야 할 다른 열린 PR 을 고른다. * * 열린 PR 목록은 호출자가 한 번 live 조회해 넘긴다. 현재 PR 은 이벤트 직후 그 목록에 이미 - * 포함되므로 번호로 명시적으로 제외한다. 작성자 login 은 GitHub 의 대소문자 비구분 규약에 - * 맞춰 비교하고, 실제 무조치 여부는 라벨이 아니라 `judgeAwaitingAuthor` 로 매번 다시 판정한다. + * 포함되므로 번호로 명시적으로 제외한다. «자기 PR» 은 작성자가 아니라 담당자(어사인, 비어 + * 있으면 작성자)로 가른다. login 은 GitHub 의 대소문자 비구분 규약에 맞춰 비교하고, 실제 + * 무조치 여부는 라벨이 아니라 `judgeAwaitingAuthor` 로 매번 다시 판정한다. */ export function findAuthorDebts({ pullRequests, repository, author, currentPullRequestNumber }) { if (!Array.isArray(pullRequests)) { @@ -345,7 +413,9 @@ export function findAuthorDebts({ pullRequests, repository, author, currentPullR if (Number(pullRequest?.number) === currentNumber) { continue; } - if (!sameLogin(pullRequest?.author?.login, author)) { + // 빚의 주인은 담당자다. 남이 올렸어도 내가 어사인된 PR 이면 내 빚이고, 내가 올렸어도 + // 남에게 어사인된 PR 이면 내 빚이 아니다. + if (!isOneOf(author, responsibleLogins(pullRequest))) { continue; } @@ -372,7 +442,7 @@ export function findAuthorDebts({ pullRequests, repository, author, currentPullR * 매 실행이 현재 집합을 다시 세우고 차이만 쓴다. 조치가 들어오면 다음 실행에서 라벨이 떨어지므로 * 스테일 라벨이 남지 않는다. */ -export function planAwaitingAuthorLabels({ pullRequests, repository, label = DEFAULT_LABEL }) { +export function planAwaitingAuthorLabels({ pullRequests, repository, label = DEFAULT_LABEL, exemptAuthors = [] }) { const toLabel = []; const toUnlabel = []; const unchanged = []; @@ -380,11 +450,17 @@ export function planAwaitingAuthorLabels({ pullRequests, repository, label = DEF for (const pullRequest of pullRequests ?? []) { const labels = (pullRequest?.labels?.nodes ?? []).map((node) => node?.name); const labeled = labels.includes(label); - const verdict = judgeAwaitingAuthor(pullRequest, { repository }); + // 면제는 담당자 기준이다. 담당자 전원이 면제 목록에 있을 때만 뺀다 — 면제자와 비면제자가 + // 함께 어사인된 PR 은 비면제자의 몫이 남아 있다. + const owners = responsibleLogins(pullRequest); + const exempt = owners.length > 0 && owners.every((login) => isOneOf(login, exemptAuthors)); + const verdict = exempt + ? { awaiting: false, reason: "리뷰 게이트 면제 작성자" } + : judgeAwaitingAuthor(pullRequest, { repository }); const entry = { number: pullRequest.number, title: pullRequest.title, - author: pullRequest?.author?.login ?? "", + author: owners.join(","), reason: verdict.reason, decidedAt: verdict.decidedAt, reviewer: verdict.reviewer, @@ -411,7 +487,7 @@ export function renderSummary({ plan, dryRun, label = DEFAULT_LABEL }) { lines.push("", `- 붙임 ${plan.toLabel.length}건 · 뗌 ${plan.toUnlabel.length}건`); if (plan.toLabel.length > 0) { - lines.push("", "| PR | 작성자 | 변경요청 | 리뷰어 |", "|---|---|---|---|"); + lines.push("", "| PR | 담당자 | 변경요청 | 리뷰어 |", "|---|---|---|---|"); for (const entry of plan.toLabel) { lines.push( `| #${entry.number} | @${entry.author} | ${entry.decidedAt?.slice(0, 10) ?? "-"} | @${entry.reviewer || "-"} |`, @@ -505,10 +581,12 @@ export async function fetchOpenPullRequests(api, repository) { } /** - * 입장 게이트용으로 한 작성자의 열린 PR 만 조회한다. + * 입장 게이트용으로 한 사람이 담당하는 열린 PR 만 조회한다. * * 라벨 리컨사일러는 전체 열린 PR 이 필요하지만, 새 PR 입장마다 그 전체의 하위 리뷰·커밋· - * 코멘트를 다시 읽을 필요는 없다. GitHub search 로 작성자를 서버에서 먼저 제한한다. + * 코멘트를 다시 읽을 필요는 없다. GitHub search 로 서버에서 먼저 제한한다. 담당은 어사인이 + * 정본이고 어사인이 비면 작성자이므로 `assignee:` 와 `author:` 두 검색을 합친다. 남에게 + * 어사인된 자기 작성 PR 은 `findAuthorDebts` 가 담당자 기준으로 걸러낸다. */ export async function fetchOpenPullRequestsByAuthor(api, repository, author) { const [owner, name] = repository.split("/"); @@ -520,11 +598,14 @@ export async function fetchOpenPullRequestsByAuthor(api, repository, author) { } const pullRequests = []; + const seen = new Set(); + + for (const qualifier of ["assignee", "author"]) { let cursor = null; for (;;) { const data = await graphql(api, OPEN_PULL_REQUESTS_BY_AUTHOR_QUERY, { - searchQuery: `repo:${owner}/${name} is:pr is:open author:${author}`, + searchQuery: `repo:${owner}/${name} is:pr is:open ${qualifier}:${author}`, cursor, pageSize: PULL_REQUEST_PAGE_SIZE, }); @@ -540,21 +621,39 @@ export async function fetchOpenPullRequestsByAuthor(api, repository, author) { if (!pullRequest || !Number.isSafeInteger(pullRequest.number)) { throw new Error("GraphQL 작성자 PR 검색 결과가 불완전합니다."); } + if (seen.has(pullRequest.number)) { + continue; + } + seen.add(pullRequest.number); // `last: 50` 앞에 더 많은 항목이 있으면 최신 결정 시각 이후의 작성자 이메일·응답· // 본문 편집을 완전하게 판정할 수 없다. 라벨은 다음 리컨사일에서 복구할 수 있지만, // 입장 가드가 불완전한 근거로 PR 을 닫는 것은 되돌리기 비용이 있으므로 판정 자체를 // 중단한다. + // + // 다만 완전성의 기준은 «전체 이력» 이 아니라 «판정이 실제로 읽는 범위» 다. 세 판정 + // 함수는 모두 최신 결정 시각(since) 이후 항목만 세고, judgeAwaitingAuthor 는 최신 + // 결정이 없거나 변경요청이 아니면 그 셋을 아예 부르지 않는다. `last: 50` 은 최신 + // 50건이므로 가져온 것 중 가장 오래된 항목이 since 보다 앞서면 since 이후는 전부 + // 확보한 것이고 판정은 완전하다 — 커밋이 몇백 건이든 상관없다. 이 구분이 없어서 + // 커밋 403건짜리 릴리스 PR(develop → main) 하나가 그 작성자의 새 PR 을 전부 막았다 + // (#1787). reviews 는 since 를 구하는 근거라 시각으로 대체 판정할 수 없어 그대로 둔다. + const decision = latestDecision(pullRequest.reviews?.nodes); + const since = decision?.state === "CHANGES_REQUESTED" ? decision.submittedAt : null; for (const connectionName of ["reviews", "commits", "comments", "userContentEdits"]) { const connection = pullRequest[connectionName]; if (!connection || !Array.isArray(connection.nodes)) { throw new Error(`GraphQL 작성자 PR ${connectionName} 응답이 불완전합니다.`); } - if (connection.pageInfo?.hasPreviousPage !== false) { - throw new Error( - `GraphQL 작성자 PR ${connectionName} 최근 50건이 완전하지 않습니다.`, - ); + if (connection.pageInfo?.hasPreviousPage === false) { + continue; } + if (isCoveredSinceDecision(connectionName, connection.nodes, since)) { + continue; + } + throw new Error( + `GraphQL 작성자 PR ${connectionName} 최근 50건이 완전하지 않습니다.`, + ); } pullRequests.push(pullRequest); } @@ -567,6 +666,7 @@ export async function fetchOpenPullRequestsByAuthor(api, repository, author) { throw new Error("GraphQL 작성자 PR 다음 페이지 cursor 가 없습니다."); } } + } return pullRequests; } @@ -622,6 +722,25 @@ export async function applyPlan(api, repository, plan, { dryRun = false, label = return failures; } +/** 가드의 한 줄 환경변수 선언을 읽는다. 형식이 달라지면 빈 면제 목록으로 숨기지 않는다. */ +export function parseReviewGateExemptAuthors(workflow) { + const lines = workflow.split(/\r?\n/); + const declarations = lines.flatMap((line, index) => { + const match = /^([ \t]+)REVIEW_GATE_EXEMPT_AUTHORS:[ \t]*(.*)$/.exec(line); + return match ? [{ index, indent: match[1].length, value: match[2].trim() }] : []; + }); + if (declarations.length !== 1) { + throw new Error("REVIEW_GATE_EXEMPT_AUTHORS 선언이 정확히 한 개 필요합니다."); + } + const { index, indent, value } = declarations[0]; + const nextLine = lines.slice(index + 1).find((line) => line.trim() && !line.trimStart().startsWith("#")); + const continued = nextLine && /^[ \t]*/.exec(nextLine)[0].length > indent; + if (continued || !/^[a-zA-Z0-9-]+(?:[ \t]+[a-zA-Z0-9-]+)*$/.test(value)) { + throw new Error("REVIEW_GATE_EXEMPT_AUTHORS는 인용·주석 없는 한 줄의 공백 구분 로그인 목록이어야 합니다."); + } + return value.split(/\s+/).map((login) => login.toLowerCase()); +} + async function main() { const token = process.env.GITHUB_TOKEN; const repository = process.env.GITHUB_REPOSITORY; @@ -633,8 +752,13 @@ async function main() { const dryRun = process.env.DRY_RUN === "true"; const api = createApi(token); + // 전체 checkout을 쓰는 라벨 CLI에서만 읽는다. 가드의 scripts-only checkout에서도 + // 상태 판정 함수를 import할 수 있어야 한다. 면제 목록의 정본은 기존 가드 선언이다. + const exemptAuthors = parseReviewGateExemptAuthors( + await readFile(new URL("../workflows/review-debt-guard.yml", import.meta.url), "utf8"), + ); const pullRequests = await fetchOpenPullRequests(api, repository); - const plan = planAwaitingAuthorLabels({ pullRequests, repository, label }); + const plan = planAwaitingAuthorLabels({ pullRequests, repository, label, exemptAuthors }); if (!dryRun && plan.toLabel.length > 0) { await ensureLabelExists(api, repository, label); diff --git a/.github/scripts/label-awaiting-author-prs.test.mjs b/.github/scripts/label-awaiting-author-prs.test.mjs index eef6aefdd4..10b4e9b437 100644 --- a/.github/scripts/label-awaiting-author-prs.test.mjs +++ b/.github/scripts/label-awaiting-author-prs.test.mjs @@ -2,19 +2,21 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - DEFAULT_LABEL, applyPlan, countAuthorBodyEdits, countAuthorFixes, countAuthorResponses, + DEFAULT_LABEL, ensureLabelExists, fetchOpenPullRequests, fetchOpenPullRequestsByAuthor, findAuthorDebts, judgeAwaitingAuthor, latestDecision, + parseReviewGateExemptAuthors, planAwaitingAuthorLabels, renderSummary, + responsibleLogins, } from "./label-awaiting-author-prs.mjs"; const REPO = "Afternote/Afternote-FE"; @@ -22,6 +24,48 @@ const REPO = "Afternote/Afternote-FE"; /** 테스트 출력이 CI 로그에서 실제 조작처럼 보이지 않도록 삼킨다. */ const silent = { log() {} }; +test("게이트 면제 목록은 공백을 나누고 로그인 대소문자를 정규화한다", () => { + const workflow = [ + "jobs:", + " check:", + " env:", + " REPO: Afternote/Afternote-FE", + " REVIEW_GATE_EXEMPT_AUTHORS: KoOnGmAi Example-User ", + " run: echo check", + ].join("\r\n"); + + assert.deepEqual(parseReviewGateExemptAuthors(workflow), ["koongmai", "example-user"]); +}); + +test("게이트 면제 목록이 없거나 두 번 선언되면 빈 목록으로 넘기지 않는다", () => { + for (const workflow of [ + "env:\n REPO: Afternote/Afternote-FE\n", + "# REVIEW_GATE_EXEMPT_AUTHORS: koongmai\n", + "env:\n REVIEW_GATE_EXEMPT_AUTHORS: koongmai\n REVIEW_GATE_EXEMPT_AUTHORS: other\n", + ]) { + assert.throws(() => parseReviewGateExemptAuthors(workflow), undefined, workflow); + } +}); + +test("게이트 면제 목록은 따옴표·주석·배열·여러 줄과 잘못된 로그인 형식을 거부한다", () => { + for (const value of [ + "", + '"koongmai"', + "'koongmai'", + "koongmai # comment", + "[koongmai]", + "|\n koongmai", + ">\n koongmai", + "\n koongmai", + "koongmai\n other", + "koongmai,other", + "invalid_login", + ]) { + const workflow = `env:\n REVIEW_GATE_EXEMPT_AUTHORS: ${value}\n`; + assert.throws(() => parseReviewGateExemptAuthors(workflow), undefined, value); + } +}); + function fakeApi({ responses = {}, failOn = null } = {}) { const calls = []; const api = async (apiPath, options = {}) => { @@ -117,6 +161,48 @@ test("변경요청 뒤 작성자가 아무것도 하지 않으면 대상이다", assert.equal(verdict.decidedAt, "2026-08-29T00:00:00Z"); }); +test("리뷰 게이트 면제 작성자는 대소문자와 무관하게 무조치 라벨 대상에서 빠진다", () => { + for (const login of ["koongmai", "KoOnGmAi"]) { + const plan = planAwaitingAuthorLabels({ + repository: REPO, + exemptAuthors: ["koongmai"], + pullRequests: [pullRequest({ + author: { login }, + reviews: { nodes: [changesRequested()] }, + })], + }); + + assert.deepEqual(plan.toLabel, [], login); + assert.equal(plan.unchanged[0].reason, "리뷰 게이트 면제 작성자"); + } +}); + +test("면제 계정과 일부만 같은 작성자는 여전히 무조치 라벨 대상이다", () => { + const plan = planAwaitingAuthorLabels({ + repository: REPO, + exemptAuthors: ["koongmai"], + pullRequests: [pullRequest({ + author: { login: "koongmai-other" }, + reviews: { nodes: [changesRequested()] }, + })], + }); + + assert.deepEqual(plan.toLabel.map((entry) => entry.number), [1]); +}); + +test("변경요청 리뷰어가 면제 계정이어도 다른 작성자의 무조치 라벨은 유지한다", () => { + const plan = planAwaitingAuthorLabels({ + repository: REPO, + exemptAuthors: ["koongmai"], + pullRequests: [pullRequest({ + reviews: { nodes: [changesRequested("2026-08-29T00:00:00Z", "koongmai")] }, + })], + }); + + assert.deepEqual(plan.toLabel.map((entry) => entry.number), [1]); + assert.equal(plan.toLabel[0].reviewer, "koongmai"); +}); + test("쓰기 권한이 없는 외부인의 변경요청은 작성자 빚을 만들지 못한다", () => { const verdict = judgeAwaitingAuthor( pullRequest({ @@ -182,6 +268,21 @@ test("작성자 빚은 같은 작성자의 다른 열린 PR 중 현재 무조치 author: { login: "someone-else" }, reviews: { nodes: [changesRequested()] }, }), + // 남이 올렸어도 내게 어사인된 PR 은 내 빚이다(#1974). + pullRequest({ + number: 24, + title: "fix(home): 인계받은 PR", + author: { login: "someone-else" }, + assignees: { nodes: [{ login: "author" }] }, + createdAt: "2026-08-18T10:00:00Z", + reviews: { nodes: [changesRequested("2026-08-29T00:00:00Z", "reviewer-2")] }, + }), + // 내가 올렸어도 남에게 어사인된 PR 은 내 빚이 아니다. + pullRequest({ + number: 25, + assignees: { nodes: [{ login: "someone-else" }] }, + reviews: { nodes: [changesRequested()] }, + }), ], }); @@ -192,9 +293,82 @@ test("작성자 빚은 같은 작성자의 다른 열린 PR 중 현재 무조치 reviewer: "reviewer-1", title: "fix(core): 기존 지적 반영", }, + { + number: 24, + createdDate: "2026-08-18", + reviewer: "reviewer-2", + title: "fix(home): 인계받은 PR", + }, ]); }); +test("담당자는 어사인이고, 어사인이 비어 있을 때만 작성자다", () => { + assert.deepEqual(responsibleLogins(pullRequest()), ["author"]); + assert.deepEqual( + responsibleLogins(pullRequest({ assignees: { nodes: [{ login: "a" }, { login: "b" }] } })), + ["a", "b"], + ); + assert.deepEqual(responsibleLogins(pullRequest({ assignees: { nodes: [] } })), ["author"]); +}); + +test("변경요청 뒤 조치는 작성자가 아니라 담당자의 것만 센다", () => { + const base = { + assignees: { nodes: [{ login: "Assignee" }] }, + reviews: { nodes: [changesRequested()] }, + }; + // 작성자의 커밋·코멘트는 담당자가 따로 있으면 조치가 아니다. + const authorOnly = judgeAwaitingAuthor( + pullRequest({ + ...base, + commits: { nodes: [commit({ date: "2026-08-29T12:00:00Z", login: "author" })] }, + comments: { nodes: [{ createdAt: "2026-08-29T12:00:00Z", author: { login: "author" } }] }, + }), + { repository: REPO }, + ); + assert.equal(authorOnly.awaiting, true); + assert.deepEqual(authorOnly.owners, ["Assignee"]); + + // 담당자의 코멘트 하나면 풀린다. 대소문자는 가리지 않는다. + const assigneeAnswered = judgeAwaitingAuthor( + pullRequest({ + ...base, + comments: { nodes: [{ createdAt: "2026-08-29T12:00:00Z", author: { login: "assignee" } }] }, + }), + { repository: REPO }, + ); + assert.equal(assigneeAnswered.awaiting, false); +}); + +test("면제는 담당자 전원이 면제일 때만 적용된다", () => { + const plan = planAwaitingAuthorLabels({ + repository: REPO, + exemptAuthors: ["exempt"], + pullRequests: [ + // 면제 작성자가 올렸어도 비면제자에게 어사인됐으면 그 사람의 몫이다. + pullRequest({ + number: 40, + author: { login: "exempt" }, + assignees: { nodes: [{ login: "someone" }] }, + reviews: { nodes: [changesRequested()] }, + }), + // 비면제 작성자가 올렸어도 면제자에게 어사인됐으면 뺀다. + pullRequest({ + number: 41, + assignees: { nodes: [{ login: "EXEMPT" }] }, + reviews: { nodes: [changesRequested()] }, + }), + // 면제자와 비면제자가 함께 어사인되면 비면제자의 몫이 남는다. + pullRequest({ + number: 42, + assignees: { nodes: [{ login: "exempt" }, { login: "someone" }] }, + reviews: { nodes: [changesRequested()] }, + }), + ], + }); + assert.deepEqual(plan.toLabel.map((entry) => entry.number), [40, 42]); + assert.equal(plan.unchanged.find((entry) => entry.number === 41)?.reason, "리뷰 게이트 면제 작성자"); +}); + test("작성자 빚 판정은 현재 PR 번호가 없거나 잘못되면 통과시키지 않는다", () => { assert.throws( () => findAuthorDebts({ pullRequests: [], repository: REPO, author: "author" }), @@ -202,18 +376,27 @@ test("작성자 빚 판정은 현재 PR 번호가 없거나 잘못되면 통과 ); }); -test("입장 게이트 조회는 GraphQL search 에서 같은 작성자의 열린 PR 만 페이지 처리한다", async () => { - const cursors = []; +test("입장 게이트 조회는 GraphQL search 로 어사인·작성자 두 축의 열린 PR 을 페이지 처리하고 합친다", async () => { + const calls = []; const api = async (apiPath, options) => { assert.equal(apiPath, "/graphql"); assert.match(options.body.query, /search\(query: \$searchQuery/); - assert.equal( - options.body.variables.searchQuery, - "repo:Afternote/Afternote-FE is:pr is:open author:AuThOr", - ); - - const cursor = options.body.variables.cursor; - cursors.push(cursor); + const { searchQuery, cursor } = options.body.variables; + calls.push([searchQuery, cursor]); + const byAssignee = searchQuery.endsWith("assignee:AuThOr"); + if (!byAssignee) { + assert.equal(searchQuery, "repo:Afternote/Afternote-FE is:pr is:open author:AuThOr"); + // 작성자 검색은 어사인 검색과 겹치는 30 을 다시 내려 준다 — 한 번만 남아야 한다. + return { + data: { + search: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [pullRequest({ number: 30 }), pullRequest({ number: 32 })], + }, + }, + }; + } + assert.equal(searchQuery, "repo:Afternote/Afternote-FE is:pr is:open assignee:AuThOr"); return { data: { search: { @@ -229,8 +412,12 @@ test("입장 게이트 조회는 GraphQL search 에서 같은 작성자의 열 const pullRequests = await fetchOpenPullRequestsByAuthor(api, REPO, "AuThOr"); - assert.deepEqual(cursors, [null, "next"]); - assert.deepEqual(pullRequests.map((pullRequest) => pullRequest.number), [30, 31]); + assert.deepEqual(calls, [ + ["repo:Afternote/Afternote-FE is:pr is:open assignee:AuThOr", null], + ["repo:Afternote/Afternote-FE is:pr is:open assignee:AuThOr", "next"], + ["repo:Afternote/Afternote-FE is:pr is:open author:AuThOr", null], + ]); + assert.deepEqual(pullRequests.map((pullRequest) => pullRequest.number), [30, 31, 32]); }); test("입장 게이트 조회는 불완전한 search 결과를 빈 목록으로 접지 않는다", async () => { @@ -273,9 +460,12 @@ test("두 GraphQL 조회는 결정 리뷰 권한과 하위 connection 절단 여 await fetchOpenPullRequests(api, REPO); await fetchOpenPullRequestsByAuthor(api, REPO, "author"); - assert.equal(queries.length, 2); + // 전체 조회 1 + 입장 조회(assignee·author) 2 + assert.equal(queries.length, 3); for (const query of queries) { assert.match(query, /authorCanPushToRepository/); + // 담당자 판정에 어사인이 필요하다. + assert.match(query, /assignees\(first: 10\) \{ nodes \{ login \} \}/); for (const connection of ["reviews", "commits", "comments", "userContentEdits"]) { assert.match( query, @@ -302,7 +492,12 @@ test("입장 게이트 조회는 잘린·누락된 하위 connection으로 판 ]; for (const [connection, value, expected] of cases) { - const candidate = pullRequest({ number: 40 }); + // 최신 판정이 변경요청이어야 판정이 이 connection 들을 실제로 읽는다. 그 상태에서만 + // 「잘린 데이터로 판정하지 않는다」 가 의미를 갖는다. + const candidate = pullRequest({ + number: 40, + reviews: { pageInfo: { hasPreviousPage: false }, nodes: [changesRequested()] }, + }); candidate[connection] = value; await assert.rejects( @@ -323,6 +518,102 @@ test("입장 게이트 조회는 잘린·누락된 하위 connection으로 판 } }); +test("판정이 읽지 않는 connection 은 잘려 있어도 입장을 막지 않는다", async () => { + // 릴리스 PR(develop → main)은 커밋이 수백 건이라 commits(last: 50) 이 항상 잘린다. + // 그런데 리뷰 판정이 없으면 judgeAwaitingAuthor 는 커밋을 읽지도 않는다. 이 구분이 없어 + // 커밋 403건짜리 PR 하나가 그 작성자의 새 PR 을 전부 막았다 (#1787). + const releasePullRequest = pullRequest({ + number: 1778, + reviews: { pageInfo: { hasPreviousPage: false }, nodes: [] }, + commits: { pageInfo: { hasPreviousPage: true }, nodes: [] }, + }); + + const fetched = await fetchOpenPullRequestsByAuthor( + async () => ({ + data: { + search: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [releasePullRequest], + }, + }, + }), + REPO, + "author", + ); + + assert.equal(fetched.length, 1); + assert.equal(fetched[0].number, 1778); +}); + +test("판정 시각 이후가 최근 50건 안에 다 들어오면 잘려 있어도 완전하다", async () => { + // last: 50 은 최신 50건이다. 가져온 것 중 가장 오래된 커밋이 판정 시각보다 앞서면 + // 그 이후 커밋은 전부 확보한 것이므로 판정에 공백이 없다. + const candidate = pullRequest({ + number: 41, + reviews: { + pageInfo: { hasPreviousPage: false }, + nodes: [changesRequested("2026-08-29T00:00:00Z")], + }, + commits: { + pageInfo: { hasPreviousPage: true }, + nodes: [ + { commit: { committedDate: "2026-08-28T00:00:00Z" } }, + { commit: { committedDate: "2026-08-30T00:00:00Z" } }, + ], + }, + }); + + const fetched = await fetchOpenPullRequestsByAuthor( + async () => ({ + data: { + search: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [candidate], + }, + }, + }), + REPO, + "author", + ); + + assert.equal(fetched.length, 1); +}); + +test("판정 시각 이후가 최근 50건을 넘치면 여전히 중단한다", async () => { + // 가장 오래된 커밋조차 판정 시각 뒤라면 그 사이에 못 본 커밋이 있을 수 있다. + // 이때는 불완전한 근거로 PR 을 닫지 않도록 판정을 멈춘다. + const candidate = pullRequest({ + number: 42, + reviews: { + pageInfo: { hasPreviousPage: false }, + nodes: [changesRequested("2026-08-29T00:00:00Z")], + }, + commits: { + pageInfo: { hasPreviousPage: true }, + nodes: [ + { commit: { committedDate: "2026-08-30T00:00:00Z" } }, + { commit: { committedDate: "2026-08-31T00:00:00Z" } }, + ], + }, + }); + + await assert.rejects( + fetchOpenPullRequestsByAuthor( + async () => ({ + data: { + search: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [candidate], + }, + }, + }), + REPO, + "author", + ), + /commits 최근 50건이 완전하지 않습니다/, + ); +}); + test("최신 판정이 승인이면 대상이 아니다", () => { // 승인 뒤 작성자가 손보는 것은 작성자 몫이지만, 그것은 «머지 가능한 상태» 이지 // «지적을 방치한 상태» 가 아니다. @@ -545,6 +836,52 @@ test("계획은 붙일 것과 뗄 것을 한 번에 낸다", () => { assert.deepEqual(plan.unchanged.map((entry) => entry.number), [12, 13]); }); +test("면제 작성자의 기존 라벨을 제거하고 다음 리컨사일에서도 다시 붙이지 않는다", () => { + const pullRequests = [ + pullRequest({ + number: 40, + author: { login: "KoOnGmAi" }, + labels: { nodes: [{ name: DEFAULT_LABEL }] }, + reviews: { nodes: [changesRequested()] }, + }), + pullRequest({ + number: 41, + author: { login: "koongmai" }, + reviews: { nodes: [changesRequested()] }, + }), + pullRequest({ + number: 42, + labels: { nodes: [{ name: DEFAULT_LABEL }] }, + reviews: { nodes: [changesRequested()] }, + }), + pullRequest({ number: 43, reviews: { nodes: [changesRequested()] } }), + ]; + + const plan = planAwaitingAuthorLabels({ repository: REPO, pullRequests, exemptAuthors: ["koongmai"] }); + + assert.deepEqual(plan.toUnlabel.map((entry) => entry.number), [40]); + assert.equal(plan.toUnlabel[0].reason, "리뷰 게이트 면제 작성자"); + assert.deepEqual(plan.toLabel.map((entry) => entry.number), [43]); + assert.deepEqual(plan.unchanged.map((entry) => entry.number), [41, 42]); + + // 첫 계획을 적용한 다음에도 면제 작성자의 무조치 상태는 그대로 남아 있다. + pullRequests[0].labels.nodes = []; + pullRequests[3].labels.nodes = [{ name: DEFAULT_LABEL }]; + const nextPlan = planAwaitingAuthorLabels({ repository: REPO, pullRequests, exemptAuthors: ["koongmai"] }); + + assert.deepEqual(nextPlan.toLabel, []); + assert.deepEqual(nextPlan.toUnlabel, []); + assert.deepEqual( + nextPlan.unchanged.map(({ number, labeled }) => ({ number, labeled })), + [ + { number: 40, labeled: false }, + { number: 41, labeled: false }, + { number: 42, labeled: true }, + { number: 43, labeled: true }, + ], + ); +}); + test("적용은 실패한 PR 만 보고하고 나머지를 계속 처리한다", async () => { const api = fakeApi({ failOn: (apiPath) => apiPath.includes("/issues/10/") }); const plan = { diff --git a/.github/scripts/label-issues-with-open-prs.mjs b/.github/scripts/label-issues-with-open-prs.mjs index bf585a6973..6ec755c526 100644 --- a/.github/scripts/label-issues-with-open-prs.mjs +++ b/.github/scripts/label-issues-with-open-prs.mjs @@ -19,7 +19,7 @@ import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import { extractTitleIssueNumber } from "./validate-pr-issue-link.mjs"; +import { extractClosingIssueNumbers, extractTitleIssueNumber } from "./validate-pr-issue-link.mjs"; export const DEFAULT_LABEL = "pr-open"; export const COMMENT_MARKER_PREFIX = "`; } +/** + * 이 PR 이 닫는 이슈 전부. + * + * 제목의 대표 이슈와 본문의 closing 참조를 합친다. 판정은 둘 다 `validate-pr-issue-link.mjs` 의 + * 파서를 그대로 쓴다 — 여기서 새 정규식을 만들면 게이트가 인정하는 링크와 라벨이 인정하는 링크가 + * 갈린다. 같은 번호가 제목과 본문에 겹쳐도 이슈마다 한 번만 센다. + * + * 본문에서는 closing 키워드만 줍는다. `Refs #N`·`Part of #N` 까지 주우면 참고로 언급했을 뿐인 + * 이슈에도 라벨이 붙어 라벨의 뜻이 무너진다. `extractClosingIssueNumbers` 가 긋는 그 경계는 + * merge-order-guard 가 본문에서 closing 이슈를 뽑는 패턴과 같은 모양이다 (#1189). + */ +function closingIssueNumbers(pullRequest, repository) { + const numbers = new Set(); + const titleIssueNumber = extractTitleIssueNumber(pullRequest.title); + if (titleIssueNumber !== null) { + numbers.add(titleIssueNumber); + } + for (const issueNumber of extractClosingIssueNumbers(pullRequest.body, repository)) { + numbers.add(issueNumber); + } + return [...numbers].sort((a, b) => a - b); +} + /** * 열린 PR 목록과 현재 라벨 보유 이슈로부터 라벨·코멘트 계획을 세운다. * - * 대표 이슈 판정은 `validate-pr-issue-link.mjs` 의 파서를 그대로 쓴다. Repository Quality 가 - * 모든 PR 제목에 `(#N)` 하나를 강제하므로 여기서 새 규칙을 만들면 두 판정이 갈린다. + * 제목의 `(#N)` 하나만 보면 한 PR 이 닫는 이슈 중 하나밖에 잡히지 않는다. Repository Quality 가 + * 제목에 `(#N)` 을 정확히 하나만 허용하기 때문이다. 본문에 `Closes` 를 둘 적은 PR 의 나머지 + * 이슈는 라벨도 링크 코멘트도 못 받아 이슈만 본 사람에게 미착수로 읽힌다 (#2082). base 가 기본 + * 브랜치가 아니면 Development 칸도 비어 있어 그 이슈엔 아무 신호도 남지 않는다. * - * 제목이 그 형식이 아닌 PR 은 건너뛴다 — 게이트가 이미 그 PR 을 막고 있고, 여기서 본문의 느슨한 - * `Refs #N` 까지 주우면 «참고로 언급한 이슈» 에까지 라벨이 붙어 라벨의 뜻이 무너진다. + * 닫는 이슈를 하나도 못 찾은 PR 은 건너뛴다 — 게이트가 이미 그 PR 을 막고 있다. */ export function planIssueLabelChanges({ pullRequests, labeledIssueNumbers, defaultBranch, + repository, label = DEFAULT_LABEL, }) { const byIssue = new Map(); const skipped = []; for (const pullRequest of pullRequests) { - const issueNumber = extractTitleIssueNumber(pullRequest.title); - if (issueNumber === null) { + const issueNumbers = closingIssueNumbers(pullRequest, repository); + if (issueNumbers.length === 0) { skipped.push(pullRequest); continue; } - if (!byIssue.has(issueNumber)) { - byIssue.set(issueNumber, []); + for (const issueNumber of issueNumbers) { + if (!byIssue.has(issueNumber)) { + byIssue.set(issueNumber, []); + } + byIssue.get(issueNumber).push(pullRequest); } - byIssue.get(issueNumber).push(pullRequest); } const labeled = new Set(labeledIssueNumbers); @@ -132,7 +160,7 @@ export function renderSummary({ plan, dryRun }) { `- 라벨 부착: ${plan.toLabel.length}건${formatIssues(plan.toLabel.map((entry) => entry.issueNumber))}`, `- 라벨 제거: ${plan.toUnlabel.length}건${formatIssues(plan.toUnlabel)}`, `- 링크 코멘트 대상(base≠기본 브랜치): ${plan.comments.length}건${formatIssues(plan.comments.map((entry) => entry.issueNumber))}`, - `- 대표 이슈 미판정: ${plan.skipped.length}건${formatIssues(plan.skipped.map((pullRequest) => pullRequest.number), "#")}`, + `- 닫는 이슈 미판정: ${plan.skipped.length}건${formatIssues(plan.skipped.map((pullRequest) => pullRequest.number), "#")}`, ].join("\n"); } @@ -147,6 +175,7 @@ function normalizePullRequest(node) { return { number: node.number, title: node.title, + body: node.body, baseRefName: node.baseRefName, isDraft: node.isDraft, }; @@ -386,7 +415,13 @@ async function main() { const pullRequests = await fetchOpenPullRequests(api, repository); const labeledIssueNumbers = await fetchLabeledIssueNumbers(api, repository, label); - const plan = planIssueLabelChanges({ pullRequests, labeledIssueNumbers, defaultBranch, label }); + const plan = planIssueLabelChanges({ + pullRequests, + labeledIssueNumbers, + defaultBranch, + repository, + label, + }); const candidates = [...new Set(plan.toLabel.map((entry) => entry.issueNumber))]; const realIssues = await resolveIssueNumbers(api, repository, candidates); diff --git a/.github/scripts/label-issues-with-open-prs.test.mjs b/.github/scripts/label-issues-with-open-prs.test.mjs index 1d3151e1ab..ce30e16656 100644 --- a/.github/scripts/label-issues-with-open-prs.test.mjs +++ b/.github/scripts/label-issues-with-open-prs.test.mjs @@ -8,6 +8,7 @@ import { commentMarker, ensureLabelExists, fetchLabeledIssueNumbers, + fetchOpenPullRequests, planIssueLabelChanges, renderLinkComment, renderSummary, @@ -41,6 +42,7 @@ function pullRequest(overrides = {}) { return { number: 100, title: "fix(ci): 무언가를 고친다 (#42)", + body: "", baseRefName: "develop", isDraft: false, ...overrides, @@ -52,6 +54,7 @@ function plan(overrides = {}) { pullRequests: [], labeledIssueNumbers: [], defaultBranch: "develop", + repository: "o/r", ...overrides, }); } @@ -87,9 +90,7 @@ test("사람이 손으로 붙인 라벨도 다음 실행에서 정본으로 되 assert.deepEqual(result.toLabel, []); }); -test("제목이 대표 이슈 형식이 아니면 건너뛴다", () => { - // 본문의 느슨한 `Refs #N` 까지 주우면 «참고로 언급했을 뿐인 이슈» 에도 라벨이 붙어 - // 라벨의 뜻이 무너진다. 판정은 Repository Quality 가 강제하는 제목 형식 하나로만 한다. +test("제목에도 본문에도 닫는 이슈가 없으면 건너뛴다", () => { const result = plan({ pullRequests: [pullRequest({ title: "fix(ci): 이슈 번호가 없다" })], }); @@ -98,6 +99,74 @@ test("제목이 대표 이슈 형식이 아니면 건너뛴다", () => { assert.deepEqual(result.skipped.map((item) => item.number), [100]); }); +test("본문의 Closes 두 건이 제목의 대표 이슈와 함께 전부 라벨을 받는다", () => { + // PR #2051 의 모양이다. 제목은 (#1281) 이고 본문에 Closes #1281·Closes #2045 가 나란히 있는데, + // 제목만 보던 동안 #2045 는 라벨도 링크 코멘트도 못 받아 미착수 조사에서 미착수로 읽혔다 (#2082). + const result = plan({ + pullRequests: [ + pullRequest({ + title: "fix(setting): 조회 상태와 재시도 연결 (#42)", + body: "- Closes #42\n- Closes #77\n\nFixes #99 도 함께 끝낸다.", + }), + ], + }); + + assert.deepEqual(result.toLabel.map((entry) => entry.issueNumber), [42, 77, 99]); +}); + +test("제목과 본문에 같은 번호가 겹쳐도 이슈마다 한 번만 처리한다", () => { + // 게이트가 대표 이슈를 본문에서도 Closes 로 걸라고 강제하므로 이 겹침이 정상 경로다. + // 같은 PR 을 두 번 세면 로그와 링크 코멘트가 그만큼 중복된다. + const result = plan({ + pullRequests: [pullRequest({ body: "Closes #42\nResolves #42" })], + }); + + assert.equal(result.toLabel.length, 1); + assert.deepEqual(result.toLabel[0].pullRequests.map((item) => item.number), [100]); +}); + +test("본문의 Refs 는 라벨을 붙이지 않는다", () => { + // 라벨의 뜻은 «이 이슈를 구현하는 PR 이 열려 있다» 다. 참고로 언급했을 뿐인 이슈까지 주우면 + // 그 뜻이 무너진다. 경계는 validate-pr-issue-link 의 closing 파서가 긋는다. + const result = plan({ + pullRequests: [pullRequest({ body: "Refs #77\nPart of #78\nRelated to #79" })], + }); + + assert.deepEqual(result.toLabel.map((entry) => entry.issueNumber), [42]); +}); + +test("코드 블록·백틱 안의 Closes 는 라벨을 붙이지 않는다", () => { + // 게이트가 인정하지 않는 링크를 라벨러만 인정하면 두 판정이 갈린다. 같은 파서를 쓰는 이유다. + const result = plan({ + pullRequests: [ + pullRequest({ + body: "설명에서 `Closes #77` 을 예로 든다.\n\n```\nCloses #78\n```\n\n", + }), + ], + }); + + assert.deepEqual(result.toLabel.map((entry) => entry.issueNumber), [42]); +}); + +test("다른 저장소를 닫는 참조는 라벨을 붙이지 않는다", () => { + const result = plan({ + pullRequests: [pullRequest({ body: "Closes Afternote/Afternote-BE#77" })], + }); + + assert.deepEqual(result.toLabel.map((entry) => entry.issueNumber), [42]); +}); + +test("제목이 대표 이슈 형식이 아니어도 본문의 Closes 는 줍는다", () => { + // 게이트가 이런 PR 을 막지만 리컨사일러는 게이트를 통과하지 못한 PR 까지 훑는다. 그 사이에도 + // «누가 이미 손대고 있다» 는 사실은 참이므로 이슈 쪽에 드러내는 편이 오인을 줄인다. + const result = plan({ + pullRequests: [pullRequest({ title: "fix(ci): 번호가 없다", body: "Closes #77" })], + }); + + assert.deepEqual(result.toLabel.map((entry) => entry.issueNumber), [77]); + assert.deepEqual(result.skipped, []); +}); + test("제목 끝이 아닌 (#N) 은 대표 이슈로 보지 않는다", () => { const result = plan({ pullRequests: [pullRequest({ title: "fix(ci): (#42) 를 언급만 한다" })], @@ -141,6 +210,51 @@ test("base 가 기본 브랜치가 아니면 링크 코멘트를 계획한다", ); }); +test("본문 Closes 로만 걸린 이슈도 링크 코멘트를 받는다", () => { + // 스택 PR 은 closingIssuesReferences 가 비어 Development 칸도 비는다. 라벨러가 유일한 + // 신호이므로 대표 이슈만 코멘트를 받으면 나머지 이슈엔 아무것도 남지 않는다. + const result = plan({ + pullRequests: [ + pullRequest({ body: "Closes #42\nCloses #77", baseRefName: "fix/579-receiver-reentry" }), + ], + }); + + assert.deepEqual( + result.comments.map((entry) => [entry.issueNumber, entry.pullRequest.number]), + [[42, 100], [77, 100]], + ); +}); + +test("열린 PR 조회가 본문까지 가져온다", async () => { + // 본문의 Closes 를 읽는 것이 계획의 절반이다. GraphQL 질의에서 body 가 빠지면 undefined 가 + // 내려와 아무 에러 없이 제목만 보던 때로 돌아간다. + const api = fakeApi({ + responses: { + "/graphql": { + data: { + repository: { + pullRequests: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [{ + number: 100, + title: "fix(ci): 무언가 (#42)", + body: "Closes #42\nCloses #77", + baseRefName: "develop", + isDraft: false, + }], + }, + }, + }, + }, + }, + }); + + const [fetched] = await fetchOpenPullRequests(api, "o/r"); + + assert.match(api.calls[0].body.query, /\bbody\b/); + assert.equal(fetched.body, "Closes #42\nCloses #77"); +}); + test("링크 코멘트는 PR 상태를 문장으로 박지 않는다", () => { // GitHub 이 `#100` 을 open·merged·closed 배지와 함께 렌더한다. 상태를 글로 적으면 PR 이 // 닫힌 뒤 코멘트만 거짓말을 하게 되고, 그걸 고치려면 실행마다 코멘트를 다시 쓰게 된다. @@ -288,7 +402,7 @@ test("요약은 부착·제거·코멘트·미판정을 모두 센다", () => { assert.match(summary, /라벨 부착: 1건 — #42/); assert.match(summary, /라벨 제거: 1건 — #999/); assert.match(summary, /링크 코멘트 대상\(base≠기본 브랜치\): 1건 — #42/); - assert.match(summary, /대표 이슈 미판정: 1건 — #101/); + assert.match(summary, /닫는 이슈 미판정: 1건 — #101/); }); test("리컨사일 워크플로가 이 스크립트와 테스트를 실제로 부른다", () => { diff --git a/.github/scripts/label-sibling-conflicts.mjs b/.github/scripts/label-sibling-conflicts.mjs new file mode 100644 index 0000000000..73dce2ac82 --- /dev/null +++ b/.github/scripts/label-sibling-conflicts.mjs @@ -0,0 +1,571 @@ +#!/usr/bin/env node + +// 열린 PR 끼리 같은 파일을 서로 다르게 고친 «형제 충돌» 에 라벨을 붙인다 (#1750). +// +// `conflict` 라벨(#1028)은 PR 과 base 의 충돌만 본다. 열린 PR 둘이 같은 파일을 서로 다르게 +// 고쳐도 한쪽이 base 에 들어가기 전까지는 어디서도 신호가 없고, 들어간 뒤에야 나머지 쪽이 +// CONFLICTING 이 된다. merge-order-guard 는 이슈 blocked_by 만 보고, 스택은 의존 관계용이라 +// 독립 이슈 둘을 묶을 자리가 아니다. 머지 자체는 GitHub 이 막으므로(나중 쪽이 merge queue 에 +// 못 들어간다) 비어 있는 것은 «머지 전에 미리 아는 것» 뿐이다. +// +// 이 스크립트는 그 알림이다 — 차단이 아니다. 둘 다 옳은 PR 이라 어느 쪽을 red 로 만들지 정할 +// 근거가 없고, 작성자가 손쓸 수 없는 red 는 #1059 가 걷어낸 상태다. +// +// 판정. 두 head 를 `git merge-tree --write-tree` 로 합쳐 충돌 파일을 구하고, 그중 **두 PR 의 +// 자기 커밋이 모두 고친 파일** 만 남긴다. 한쪽만 고친 파일에서 난 충돌은 상대가 품고 있는 trunk +// 커밋과의 충돌이라 `conflict` 라벨 몫이다. 스택 PR 의 «자기 커밋» 은 부모 PR head 와의 공통 +// 조상부터의 diff 이고, 부모 체인이 이미 같은 상대와 부딪히는 파일은 자식에서 뺀다 — 충돌은 체인의 +// 가장 아래 PR 한 곳에 귀속되고, 그 PR 이 풀면 다음 실행이 자식을 다시 판정한다. 같은 체인 +// (부모·자식)은 비교하지 않는다 — 그쪽은 base 축이다. +// +// 한계. 파일 이름이 겹치지 않는 충돌(한쪽이 디렉터리를 옮기고 다른 쪽이 옛 자리에 파일을 더한 +// «file location» 충돌)은 잡지 않는다. 내용 충돌만 본다. +// +// 이벤트가 아니라 리컨사일러인 이유는 다른 라벨 스크립트와 같다. 매 실행이 «지금 충돌하는 쌍» 을 +// 다시 계산해 차이만 쓰므로 붙이기와 떼기가 갈라지지 않고 스테일 라벨이 남지 않는다. 코멘트는 +// PR 당 하나를 마커로 찾아 현재 상태로 고쳐 쓴다 — 쌍마다 새로 달면 상대가 바뀔 때마다 쌓여 +// 소음이 되고, 한 번만 달면 상대·파일이 바뀐 뒤 낡은 안내가 남는다. + +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +export const DEFAULT_LABEL = "sibling-conflict"; +export const COMMENT_MARKER = ""; + +const LABEL_COLOR = "F9D0C4"; +const LABEL_DESCRIPTION = "열린 형제 PR 과 같은 파일에서 충돌 — 나중에 머지되는 쪽이 base 를 병합해야 한다. 해소되면 자동으로 떨어진다"; +const PULL_REQUEST_PAGE_SIZE = 50; +const COMMENT_PAGE_SIZE = 100; + +const OPEN_PULL_REQUESTS_QUERY = ` +query($owner: String!, $name: String!, $cursor: String, $pageSize: Int!) { + repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, first: $pageSize, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + number + baseRefName + headRefName + headRefOid + isDraft + headRepository { nameWithOwner } + labels(first: 30) { nodes { name } } + } + } + } +}`; + +/** + * `git merge-tree --write-tree --name-only -z` 출력을 트리 OID 와 충돌 파일 목록으로 가른다. + * + * 형식은 `NUL NUL ... NUL ` 다. 충돌 파일 구간은 빈 항목으로 + * 끝나고 그 뒤는 사람이 읽는 메시지라 버린다. `--name-only` 라 같은 파일이 stage 마다 반복되지 않는다. + */ +export function parseMergeTreeOutput(stdout) { + const parts = stdout.split("\0"); + const tree = (parts[0] ?? "").trim(); + const conflictedFiles = []; + for (const part of parts.slice(1)) { + if (part === "") { + break; + } + conflictedFiles.push(part); + } + return { tree, conflictedFiles }; +} + +/** + * 판정에 필요한 git 질의만 노출한다. 테스트는 임시 저장소에 실제 git 을 돌린다. + */ +export function createGit({ cwd = process.cwd(), spawn = spawnSync } = {}) { + function run(args) { + const result = spawn("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + if (result.error) { + throw result.error; + } + return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; + } + + return { + commitExists(ref) { + return run(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).status === 0; + }, + mergeBase(a, b) { + const result = run(["merge-base", a, b]); + return result.status === 0 ? result.stdout.trim() : null; + }, + isAncestor(ancestor, descendant) { + return run(["merge-base", "--is-ancestor", ancestor, descendant]).status === 0; + }, + changedFiles(from, to) { + const result = run(["diff", "--name-only", "-z", from, to]); + if (result.status !== 0) { + throw new Error(`git diff ${from} ${to} 실패: ${result.stderr.trim()}`); + } + return result.stdout.split("\0").filter(Boolean); + }, + /** 종료 코드 0 은 깨끗, 1 은 충돌, 그 밖은 오류(공통 조상 없음 등)다. */ + mergeTreeConflicts(a, b) { + const result = run(["merge-tree", "--write-tree", "--name-only", "-z", a, b]); + if (result.status === 0) { + return { conflictedFiles: [], error: null }; + } + if (result.status === 1) { + return { conflictedFiles: parseMergeTreeOutput(result.stdout).conflictedFiles, error: null }; + } + return { conflictedFiles: [], error: result.stderr.trim() || `exit ${result.status}` }; + }, + fetchPullRequestHead(number) { + return run(["fetch", "--quiet", "origin", `refs/pull/${number}/head`]).status === 0; + }, + }; +} + +function sortedPair(a, b) { + return a < b ? [a, b] : [b, a]; +} + +/** + * 열린 PR 들 사이의 형제 충돌 쌍을 찾는다. + * + * 각 PR 의 «자기 파일» 은 upstream(부모 PR head, 없으면 trunk)과의 공통 조상부터 head 까지의 + * diff 다. 쌍은 자기 파일이 겹칠 때만 merge-tree 를 돌리고, 충돌 파일 중 양쪽 자기 파일에 모두 + * 든 것만 남긴다. 같은 체인은 부모 링크와 git 조상 관계 두 축으로 걸러 낸다 — base 를 develop 로 + * 옮긴 뒤 아직 rebase 하지 않은 자식은 부모 링크가 끊겨도 커밋 조상으로는 이어져 있다. + */ +export function detectSiblingConflicts({ pullRequests, trunk, git }) { + const byHead = new Map(); + for (const pullRequest of pullRequests) { + if (pullRequest.sameRepository !== false) { + byHead.set(pullRequest.headRefName, pullRequest); + } + } + + const parentOf = (pullRequest) => { + if (pullRequest.sameRepository === false) { + return null; + } + const parent = byHead.get(pullRequest.baseRefName); + return parent && parent.number !== pullRequest.number ? parent : null; + }; + + const isChainAncestor = (ancestor, pullRequest) => { + const seen = new Set(); + for (let current = parentOf(pullRequest); current && !seen.has(current.number); current = parentOf(current)) { + if (current.number === ancestor.number) { + return true; + } + seen.add(current.number); + } + return false; + }; + + const eligible = []; + const skipped = []; + for (const pullRequest of pullRequests) { + if (!git.commitExists(pullRequest.headRefOid)) { + skipped.push({ number: pullRequest.number, reason: `head ${pullRequest.headRefOid.slice(0, 7)} 을 찾지 못함` }); + continue; + } + const parent = parentOf(pullRequest); + const upstreamLabel = parent && git.commitExists(parent.headRefOid) ? `#${parent.number}` : trunk; + const upstream = parent && git.commitExists(parent.headRefOid) ? parent.headRefOid : trunk; + const ownBase = git.mergeBase(upstream, pullRequest.headRefOid); + if (!ownBase) { + skipped.push({ number: pullRequest.number, reason: `${upstreamLabel} 와 공통 조상 없음` }); + continue; + } + eligible.push({ ...pullRequest, ownFiles: new Set(git.changedFiles(ownBase, pullRequest.headRefOid)) }); + } + + // merge-tree 는 쌍마다 한 번이면 된다 — 부모 체인 제외에서 같은 쌍을 다시 묻는다. + const rawCache = new Map(); + const rawConflicts = (a, b) => { + const key = a.headRefOid < b.headRefOid ? `${a.headRefOid}:${b.headRefOid}` : `${b.headRefOid}:${a.headRefOid}`; + if (!rawCache.has(key)) { + rawCache.set(key, git.mergeTreeConflicts(a.headRefOid, b.headRefOid)); + } + return rawCache.get(key); + }; + const availableParentOf = (pullRequest) => { + const parent = parentOf(pullRequest); + return parent && git.commitExists(parent.headRefOid) ? parent : null; + }; + // 부모 체인이 같은 상대와 이미 부딪히는 파일은 자식의 것이 아니다. + const withoutInherited = (files, self, other) => { + const parent = availableParentOf(self); + if (!parent || files.length === 0) { + return files; + } + const inherited = new Set(rawConflicts(parent, other).conflictedFiles); + return files.filter((file) => !inherited.has(file)); + }; + + const pairs = []; + const skippedPairs = []; + for (let i = 0; i < eligible.length; i += 1) { + for (let j = i + 1; j < eligible.length; j += 1) { + const a = eligible[i]; + const b = eligible[j]; + const overlap = [...a.ownFiles].filter((file) => b.ownFiles.has(file)); + if (overlap.length === 0) { + continue; + } + if (isChainAncestor(a, b) || isChainAncestor(b, a)) { + continue; + } + if (git.isAncestor(a.headRefOid, b.headRefOid) || git.isAncestor(b.headRefOid, a.headRefOid)) { + continue; + } + const { conflictedFiles, error } = rawConflicts(a, b); + if (error) { + skippedPairs.push({ numbers: sortedPair(a.number, b.number), reason: error }); + continue; + } + let files = conflictedFiles.filter((file) => a.ownFiles.has(file) && b.ownFiles.has(file)).sort(); + files = withoutInherited(files, a, b); + files = withoutInherited(files, b, a); + if (files.length > 0) { + pairs.push({ numbers: sortedPair(a.number, b.number), files }); + } + } + } + + pairs.sort((left, right) => left.numbers[0] - right.numbers[0] || left.numbers[1] - right.numbers[1]); + return { pairs, skipped, skippedPairs }; +} + +/** + * 충돌 쌍을 라벨·코멘트 계획으로 바꾼다. 라벨은 «지금 어떤 쌍에든 든 PR» 집합과의 차이만 쓰고, + * 코멘트는 그 집합의 PR 마다 현재 상대 목록 하나를 계획한다. 집합에서 빠진 PR 은 라벨을 떼면서 + * 남아 있는 코멘트를 «해소됨» 으로 고쳐 쓴다. + */ +export function planSiblingLabelChanges({ pullRequests, pairs, label = DEFAULT_LABEL }) { + const involved = new Map(); + for (const pair of pairs) { + const [a, b] = pair.numbers; + for (const [self, sibling] of [[a, b], [b, a]]) { + if (!involved.has(self)) { + involved.set(self, []); + } + involved.get(self).push({ sibling, files: pair.files }); + } + } + + const toLabel = []; + const toUnlabel = []; + const comments = []; + for (const pullRequest of pullRequests) { + const labeled = (pullRequest.labels ?? []).includes(label); + const entries = involved.get(pullRequest.number); + if (entries) { + if (!labeled) { + toLabel.push(pullRequest); + } + comments.push({ + number: pullRequest.number, + siblings: [...entries].sort((left, right) => left.sibling - right.sibling), + }); + continue; + } + if (labeled) { + toUnlabel.push(pullRequest); + } + } + + return { toLabel, toUnlabel, comments }; +} + +export function renderSiblingComment({ siblings, label = DEFAULT_LABEL }) { + return [ + COMMENT_MARKER, + "열린 PR 중 이 PR 과 같은 파일을 서로 다르게 고친 것이 있다. **어느 쪽이 먼저 머지되든 나중 쪽은 base 를 병합해야 통과한다** (#1750).", + "", + ...siblings.map((entry) => `- #${entry.sibling} — ${entry.files.map((file) => `\`${file}\``).join(", ")}`), + "", + "머지 자체는 GitHub 이 막는다 — 한쪽이 들어가면 다른 쪽은 CONFLICTING 이 되어 merge queue 에 못 들어간다.", + "이 라벨은 그 전에 순서를 정하라는 신호다. 먼저 갈 쪽을 정하고, 나중 쪽은 그 머지 뒤에 base 를 병합한다.", + "상대 PR 에도 같은 안내가 달린다.", + "", + `이 코멘트는 리컨사일러가 매 실행 현재 상태로 고쳐 쓴다. 해소되면 \`${label}\` 라벨은 자동으로 떨어진다.`, + ].join("\n"); +} + +export function renderResolvedComment() { + return [ + COMMENT_MARKER, + "형제 충돌이 해소됐다 — 지금은 이 PR 과 같은 파일을 서로 다르게 고친 열린 PR 이 없다 (#1750).", + "다시 생기면 이 코멘트를 현재 상태로 고쳐 쓴다.", + ].join("\n"); +} + +export function renderSummary({ pairs, plan, skipped = [], skippedPairs = [], label, dryRun }) { + const lines = [ + `## 형제 충돌 PR 라벨 (\`${label}\`)${dryRun ? " — dry run" : ""}`, + "", + `- 충돌 쌍: ${pairs.length}건`, + ...pairs.map((pair) => ` - #${pair.numbers[0]} ↔ #${pair.numbers[1]} — ${pair.files.map((file) => `\`${file}\``).join(", ")}`), + `- 라벨 부착: ${plan.toLabel.length}건${formatNumbers(plan.toLabel)}`, + `- 라벨 제거: ${plan.toUnlabel.length}건${formatNumbers(plan.toUnlabel)}`, + `- 코멘트 계획: ${plan.comments.length}건`, + `- 판정 보류: ${skipped.length}건${skipped.map((item) => ` — #${item.number} (${item.reason})`).join("")}`, + ]; + if (skippedPairs.length > 0) { + lines.push(`- 비교 실패: ${skippedPairs.length}건${skippedPairs.map((item) => ` — #${item.numbers[0]} ↔ #${item.numbers[1]} (${item.reason})`).join("")}`); + } + return lines.join("\n"); +} + +function formatNumbers(pullRequests) { + if (pullRequests.length === 0) { + return ""; + } + return ` — ${pullRequests.map((pullRequest) => `#${pullRequest.number}`).join(", ")}`; +} + +export function normalizePullRequest(node, repository) { + return { + number: node.number, + baseRefName: node.baseRefName, + headRefName: node.headRefName, + headRefOid: node.headRefOid, + isDraft: node.isDraft, + sameRepository: (node.headRepository?.nameWithOwner ?? "").toLowerCase() === repository.toLowerCase(), + labels: (node.labels?.nodes ?? []).map((item) => item.name), + }; +} + +function createApi(token) { + return async function api(apiPath, { method = "GET", body, allowNotFound = false } = {}) { + const response = await fetch(`https://api.github.com${apiPath}`, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "x-github-api-version": "2022-11-28", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (allowNotFound && response.status === 404) { + return null; + } + if (!response.ok) { + const detail = await response.text(); + throw new Error(`GitHub API ${method} ${apiPath} 실패: ${response.status} ${detail}`); + } + if (response.status === 204) { + return null; + } + return response.json(); + }; +} + +async function graphql(api, query, variables) { + const payload = await api("/graphql", { method: "POST", body: { query, variables } }); + if (payload?.errors?.length) { + throw new Error(`GraphQL 실패: ${JSON.stringify(payload.errors)}`); + } + return payload.data; +} + +export async function fetchOpenPullRequests(api, repository) { + const [owner, name] = repository.split("/"); + const pullRequests = []; + let cursor = null; + + for (;;) { + const data = await graphql(api, OPEN_PULL_REQUESTS_QUERY, { + owner, + name, + cursor, + pageSize: PULL_REQUEST_PAGE_SIZE, + }); + const page = data.repository.pullRequests; + pullRequests.push(...page.nodes.map((node) => normalizePullRequest(node, repository))); + + if (!page.pageInfo.hasNextPage) { + return pullRequests; + } + cursor = page.pageInfo.endCursor; + } +} + +/** + * 전체 히스토리 checkout 은 저장소 안 브랜치를 모두 가져오지만, 조회 직전에 push 된 커밋이나 포크 + * PR 의 head 는 없을 수 있다. 없는 것만 `refs/pull/N/head` 로 채운다 — 실패해도 판정 보류로 남길 + * 뿐 실행을 멈추지 않는다. + */ +export function ensureHeadsAvailable(pullRequests, git, logger = console) { + for (const pullRequest of pullRequests) { + if (git.commitExists(pullRequest.headRefOid)) { + continue; + } + if (!git.fetchPullRequestHead(pullRequest.number)) { + logger.log(`#${pullRequest.number} head 를 가져오지 못했다 — 판정 보류`); + } + } +} + +export async function ensureLabelExists(api, repository, label) { + const existing = await api(`/repos/${repository}/labels/${encodeURIComponent(label)}`, { + allowNotFound: true, + }); + if (existing) { + return; + } + await api(`/repos/${repository}/labels`, { + method: "POST", + body: { name: label, color: LABEL_COLOR, description: LABEL_DESCRIPTION }, + }); +} + +async function findMarkerComment(api, repository, number) { + for (let page = 1; ; page += 1) { + const comments = await api(`/repos/${repository}/issues/${number}/comments?per_page=${COMMENT_PAGE_SIZE}&page=${page}`); + const found = (comments ?? []).find((comment) => (comment.body ?? "").includes(COMMENT_MARKER)); + if (found) { + return { id: found.id, body: found.body ?? "" }; + } + if (!comments || comments.length < COMMENT_PAGE_SIZE) { + return null; + } + } +} + +const normalizeBody = (body) => body.replace(/\r\n/g, "\n").trimEnd(); + +/** 마커 코멘트가 없으면 만들고, 있으면 내용이 다를 때만 고쳐 쓴다. 같으면 아무것도 쓰지 않는다. */ +async function upsertMarkerComment(api, repository, number, body) { + const existing = await findMarkerComment(api, repository, number); + if (!existing) { + await api(`/repos/${repository}/issues/${number}/comments`, { method: "POST", body: { body } }); + return "created"; + } + if (normalizeBody(existing.body) === normalizeBody(body)) { + return "unchanged"; + } + await api(`/repos/${repository}/issues/comments/${existing.id}`, { method: "PATCH", body: { body } }); + return "updated"; +} + +// `logger` 를 받는 이유: 테스트가 실제 동작처럼 보이는 줄을 CI 로그에 찍지 않게 하기 위해서다. +export async function applyPlan(api, repository, plan, { label, dryRun, logger = console }) { + const failures = []; + + for (const pullRequest of plan.toLabel) { + try { + if (dryRun) { + logger.log(`[dry-run] #${pullRequest.number} 라벨 부착`); + continue; + } + await api(`/repos/${repository}/issues/${pullRequest.number}/labels`, { + method: "POST", + body: { labels: [label] }, + }); + logger.log(`#${pullRequest.number} 라벨 부착`); + } catch (error) { + failures.push(`#${pullRequest.number} 라벨 부착 실패: ${error.message}`); + } + } + + for (const comment of plan.comments) { + const siblings = comment.siblings.map((entry) => `#${entry.sibling}`).join(", "); + try { + if (dryRun) { + logger.log(`[dry-run] #${comment.number} 코멘트 (상대 ${siblings})`); + continue; + } + const outcome = await upsertMarkerComment( + api, + repository, + comment.number, + renderSiblingComment({ siblings: comment.siblings, label }), + ); + if (outcome !== "unchanged") { + logger.log(`#${comment.number} 코멘트 ${outcome === "created" ? "작성" : "갱신"} (상대 ${siblings})`); + } + } catch (error) { + failures.push(`#${comment.number} 코멘트 실패 (상대 ${siblings}): ${error.message}`); + } + } + + for (const pullRequest of plan.toUnlabel) { + try { + if (dryRun) { + logger.log(`[dry-run] #${pullRequest.number} 라벨 제거`); + continue; + } + await api(`/repos/${repository}/issues/${pullRequest.number}/labels/${encodeURIComponent(label)}`, { + method: "DELETE", + allowNotFound: true, + }); + // 남아 있는 안내를 «해소됨» 으로 고쳐 써야 낡은 상대 목록이 PR 에 남지 않는다. 없으면 만들지 않는다. + const existing = await findMarkerComment(api, repository, pullRequest.number); + if (existing && normalizeBody(existing.body) !== normalizeBody(renderResolvedComment())) { + await api(`/repos/${repository}/issues/comments/${existing.id}`, { + method: "PATCH", + body: { body: renderResolvedComment() }, + }); + } + logger.log(`#${pullRequest.number} 라벨 제거`); + } catch (error) { + failures.push(`#${pullRequest.number} 라벨 제거 실패: ${error.message}`); + } + } + + return failures; +} + +async function main() { + const token = process.env.GITHUB_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + const defaultBranch = process.env.GITHUB_DEFAULT_BRANCH; + if (!token || !repository || !defaultBranch) { + throw new Error("GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_DEFAULT_BRANCH 가 필요합니다."); + } + + const label = process.env.SIBLING_CONFLICT_LABEL ?? DEFAULT_LABEL; + const dryRun = process.env.DRY_RUN === "true"; + const trunk = process.env.TRUNK_REF ?? `origin/${defaultBranch}`; + const api = createApi(token); + const git = createGit(); + + if (!git.commitExists(trunk)) { + throw new Error(`trunk '${trunk}' 를 찾지 못했다 — 전체 히스토리 checkout 이 필요하다.`); + } + + const pullRequests = await fetchOpenPullRequests(api, repository); + ensureHeadsAvailable(pullRequests, git); + const { pairs, skipped, skippedPairs } = detectSiblingConflicts({ pullRequests, trunk, git }); + const plan = planSiblingLabelChanges({ pullRequests, pairs, label }); + + if (!dryRun && plan.toLabel.length > 0) { + await ensureLabelExists(api, repository, label); + } + + const failures = await applyPlan(api, repository, plan, { label, dryRun }); + const summary = renderSummary({ pairs, plan, skipped, skippedPairs, label, dryRun }); + console.log(summary); + + if (process.env.GITHUB_STEP_SUMMARY) { + const { appendFile } = await import("node:fs/promises"); + await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); + } + + if (failures.length > 0) { + // 개별 실패가 나머지 PR 처리를 막지 않게 모아 두었다가 여기서 한 번에 드러낸다. + throw new Error(failures.join("\n")); + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedPath) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/label-sibling-conflicts.test.mjs b/.github/scripts/label-sibling-conflicts.test.mjs new file mode 100644 index 0000000000..197660864f --- /dev/null +++ b/.github/scripts/label-sibling-conflicts.test.mjs @@ -0,0 +1,585 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + COMMENT_MARKER, + DEFAULT_LABEL, + applyPlan, + createGit, + detectSiblingConflicts, + ensureHeadsAvailable, + ensureLabelExists, + normalizePullRequest, + parseMergeTreeOutput, + planSiblingLabelChanges, + renderResolvedComment, + renderSiblingComment, + renderSummary, +} from "./label-sibling-conflicts.mjs"; + +const workflow = await readFile(new URL("../workflows/conflict-label.yml", import.meta.url), "utf8"); + +/** 테스트 출력이 CI 로그에서 실제 조작처럼 보이지 않도록 삼킨다. */ +const silent = { log() {} }; + +function fakeApi({ responses = {}, failOn = null } = {}) { + const calls = []; + const api = async (apiPath, options = {}) => { + calls.push({ apiPath, method: options.method ?? "GET", body: options.body }); + if (failOn && failOn(apiPath, options)) { + throw new Error("boom"); + } + for (const [pattern, value] of Object.entries(responses)) { + if (apiPath.startsWith(pattern)) { + return typeof value === "function" ? value(apiPath, options) : value; + } + } + return options.allowNotFound ? null : {}; + }; + api.calls = calls; + return api; +} + +function pullRequest(overrides = {}) { + return { + number: 1, + baseRefName: "develop", + headRefName: "feature/x", + headRefOid: "0".repeat(40), + isDraft: false, + sameRepository: true, + labels: [], + ...overrides, + }; +} + +const writes = (api) => api.calls.filter((call) => call.method !== "GET"); + +// ---- 실제 git 저장소 픽스처 ---- +// +// 판정의 정본은 git 의 3-way merge 다. 가짜 git 으로는 «같은 파일을 고쳤지만 깨끗이 합쳐진다» +// 같은 경계를 흉내낼 수 없으므로 임시 저장소를 만들어 실제 merge-tree 를 돌린다. + +const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "test", + GIT_COMMITTER_EMAIL: "test@example.com", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", +}; + +async function makeRepository() { + const dir = await mkdtemp(path.join(os.tmpdir(), "sibling-conflict-")); + const run = (args) => { + const result = spawnSync("git", args, { cwd: dir, encoding: "utf8", env: gitEnv }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} 실패: ${result.stderr}`); + } + return result.stdout.trim(); + }; + run(["init", "-q", "-b", "develop"]); + return { + dir, + run, + write: (name, content) => writeFile(path.join(dir, name), content), + commit: (message) => { + run(["add", "-A"]); + run(["commit", "-q", "-m", message]); + return run(["rev-parse", "HEAD"]); + }, + checkout: (branch, from) => run(from ? ["checkout", "-q", "-b", branch, from] : ["checkout", "-q", branch]), + git: createGit({ cwd: dir }), + cleanup: () => rm(dir, { recursive: true, force: true }), + }; +} + +const LINES = ["a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8"]; +const aText = (first = "a1", last = "a8") => [first, ...LINES.slice(1, -1), last].join("\n") + "\n"; + +/** + * 픽스처. + * + * develop D0 : a.txt(a1..a8) · b.txt · c.txt + * feat/x (D0) a1→x #10 base develop + * feat/y (D0) a1→y #11 base develop → #10 ↔ #11 은 a.txt 충돌 + * feat/v (D0) a8→v #12 base develop → #10 과 같은 파일이지만 깨끗이 합쳐진다 + * feat/z (D0) b.txt→z #13 base develop + * feat/x-child(feat/x) a8→xc8 · c.txt→xc #14 base feat/x → a.txt 의 a1 충돌은 #10 것, a8 충돌은 #14 것 + * develop D1 : a1→d → #10·#11 과 부딪히는 trunk 전진 + * feat/w (D1) b.txt→w #15 base develop → #13 ↔ #15 는 b.txt 충돌, + * #11 과는 a.txt 에서 부딪히지만 w 의 자기 파일이 아니다 + */ +async function makeFixture() { + const repo = await makeRepository(); + await repo.write("a.txt", aText()); + await repo.write("b.txt", "b\n"); + await repo.write("c.txt", "c\n"); + const d0 = await repo.commit("D0"); + + const heads = {}; + repo.checkout("feat/x", d0); + await repo.write("a.txt", aText("x")); + heads.x = await repo.commit("x"); + + repo.checkout("feat/y", d0); + await repo.write("a.txt", aText("y")); + heads.y = await repo.commit("y"); + + repo.checkout("feat/v", d0); + await repo.write("a.txt", aText("a1", "v")); + heads.v = await repo.commit("v"); + + repo.checkout("feat/z", d0); + await repo.write("b.txt", "z\n"); + heads.z = await repo.commit("z"); + + repo.checkout("feat/x-child", "feat/x"); + await repo.write("a.txt", aText("x", "xc8")); + await repo.write("c.txt", "xc\n"); + heads.xChild = await repo.commit("x-child"); + + repo.checkout("develop"); + await repo.write("a.txt", aText("d")); + const d1 = await repo.commit("D1"); + + repo.checkout("feat/w", d1); + await repo.write("b.txt", "w\n"); + heads.w = await repo.commit("w"); + repo.checkout("develop"); + + const pullRequests = [ + pullRequest({ number: 10, headRefName: "feat/x", headRefOid: heads.x }), + pullRequest({ number: 11, headRefName: "feat/y", headRefOid: heads.y }), + pullRequest({ number: 12, headRefName: "feat/v", headRefOid: heads.v }), + pullRequest({ number: 13, headRefName: "feat/z", headRefOid: heads.z }), + pullRequest({ number: 14, headRefName: "feat/x-child", headRefOid: heads.xChild, baseRefName: "feat/x" }), + pullRequest({ number: 15, headRefName: "feat/w", headRefOid: heads.w }), + ]; + return { repo, heads, pullRequests }; +} + +function pairNumbers(result) { + return result.pairs.map((pair) => pair.numbers); +} + +const hasPair = (result, a, b) => pairNumbers(result).some((numbers) => numbers[0] === a && numbers[1] === b); + +test("형제 충돌 쌍은 양쪽 자기 커밋이 모두 고친 파일에서만 잡힌다", async () => { + const { repo, pullRequests } = await makeFixture(); + try { + const result = detectSiblingConflicts({ pullRequests, trunk: "develop", git: repo.git }); + + assert.deepEqual(result.pairs, [ + { numbers: [10, 11], files: ["a.txt"] }, + { numbers: [12, 14], files: ["a.txt"] }, + { numbers: [13, 15], files: ["b.txt"] }, + ]); + assert.deepEqual(result.skipped, []); + assert.deepEqual(result.skippedPairs, []); + } finally { + await repo.cleanup(); + } +}); + +test("같은 파일을 고쳐도 깨끗이 합쳐지면 충돌이 아니다", async () => { + // 파일 겹침은 merge-tree 를 돌릴 조건일 뿐이고, 판정은 3-way merge 결과다. + const { repo, pullRequests } = await makeFixture(); + try { + const result = detectSiblingConflicts({ pullRequests, trunk: "develop", git: repo.git }); + assert.equal(hasPair(result, 10, 12), false); + } finally { + await repo.cleanup(); + } +}); + +test("스택 자식은 부모의 충돌을 물려받지 않고 자기 충돌만 갖는다", async () => { + // #14 의 head 는 #10 의 커밋을 품고 있어 #11 과 합치면 a.txt 의 a1 이 충돌한다. 그것은 #10 의 + // 것이다 — 부모 체인이 #11 과 이미 부딪히는 파일이라 #14 에서는 뺀다. 반면 #12 와의 a8 충돌은 + // #14 의 자기 커밋이 만든 것이고 부모는 #12 와 부딪히지 않으므로 #14 에 남는다. + const { repo, pullRequests } = await makeFixture(); + try { + const result = detectSiblingConflicts({ pullRequests, trunk: "develop", git: repo.git }); + assert.equal(hasPair(result, 11, 14), false); + assert.equal(hasPair(result, 12, 14), true); + } finally { + await repo.cleanup(); + } +}); + +test("trunk 전진이 만든 충돌은 형제 충돌이 아니다", async () => { + // #15 는 D1 에서 갈라져 a1→d 를 품고 있다. #11 과 합치면 a.txt 가 충돌하지만, 그것은 #11 과 + // develop 의 충돌(= conflict 라벨 몫)이지 #15 의 커밋이 만든 것이 아니다. + const { repo, pullRequests } = await makeFixture(); + try { + const result = detectSiblingConflicts({ pullRequests, trunk: "develop", git: repo.git }); + assert.equal(hasPair(result, 11, 15), false); + } finally { + await repo.cleanup(); + } +}); + +test("head 를 찾지 못한 PR 은 보류로 남기고 나머지 판정은 계속한다", async () => { + const { repo, pullRequests } = await makeFixture(); + try { + const result = detectSiblingConflicts({ + pullRequests: [...pullRequests, pullRequest({ number: 16, headRefName: "feat/fork", headRefOid: "f".repeat(40) })], + trunk: "develop", + git: repo.git, + }); + + assert.deepEqual(result.skipped.map((item) => item.number), [16]); + assert.match(result.skipped[0].reason, /찾지 못함/); + assert.deepEqual(pairNumbers(result), [[10, 11], [12, 14], [13, 15]]); + } finally { + await repo.cleanup(); + } +}); + +test("base 를 옮긴 뒤 rebase 하지 않은 자식은 커밋 조상으로 같은 체인임을 가린다", async () => { + // 부모 링크(base 브랜치명)가 끊겨도 #14 의 head 는 #10 의 head 를 조상으로 갖는다. + // 그 둘 사이의 겹침은 base 축이지 형제 축이 아니다. + const { repo, pullRequests } = await makeFixture(); + try { + const retargeted = pullRequests.map((item) => (item.number === 14 ? { ...item, baseRefName: "develop" } : item)); + const result = detectSiblingConflicts({ pullRequests: retargeted, trunk: "develop", git: repo.git }); + assert.equal(hasPair(result, 10, 14), false); + } finally { + await repo.cleanup(); + } +}); + +test("공통 조상이 없는 PR 은 자기 파일을 셀 수 없어 보류로 남긴다", async () => { + const { repo, pullRequests } = await makeFixture(); + try { + repo.run(["checkout", "-q", "--orphan", "feat/orphan"]); + await repo.write("a.txt", "orphan\n"); + const orphan = await repo.commit("orphan"); + repo.checkout("develop"); + + const result = detectSiblingConflicts({ + pullRequests: [...pullRequests, pullRequest({ number: 17, headRefName: "feat/orphan", headRefOid: orphan })], + trunk: "develop", + git: repo.git, + }); + + assert.deepEqual(result.skipped.map((item) => item.number), [17]); + assert.match(result.skipped[0].reason, /공통 조상 없음/); + assert.equal(result.pairs.length, 3); + } finally { + await repo.cleanup(); + } +}); + +test("merge-tree 결과를 종료 코드로 가른다", async () => { + const { repo, heads } = await makeFixture(); + try { + assert.deepEqual(repo.git.mergeTreeConflicts(heads.x, heads.v), { conflictedFiles: [], error: null }); + assert.deepEqual(repo.git.mergeTreeConflicts(heads.x, heads.y), { conflictedFiles: ["a.txt"], error: null }); + + repo.run(["checkout", "-q", "--orphan", "feat/orphan"]); + await repo.write("a.txt", "orphan\n"); + const orphan = await repo.commit("orphan"); + const unrelated = repo.git.mergeTreeConflicts(heads.x, orphan); + assert.deepEqual(unrelated.conflictedFiles, []); + assert.ok(unrelated.error); + } finally { + await repo.cleanup(); + } +}); + +test("merge-tree -z 출력에서 충돌 파일 구간만 읽는다", () => { + const stdout = ["abc123", "dir/one.kt", "two.txt", "", "1", "dir/one.kt", "Auto-merging", "Auto-merging dir/one.kt\n"].join("\0"); + assert.deepEqual(parseMergeTreeOutput(stdout), { tree: "abc123", conflictedFiles: ["dir/one.kt", "two.txt"] }); + assert.deepEqual(parseMergeTreeOutput("abc123\0"), { tree: "abc123", conflictedFiles: [] }); + assert.deepEqual(parseMergeTreeOutput("abc123\n"), { tree: "abc123", conflictedFiles: [] }); +}); + +test("없는 head 만 refs/pull/N/head 로 채운다", () => { + const fetched = []; + const git = { + commitExists: (oid) => oid.startsWith("1"), + fetchPullRequestHead: (number) => { + fetched.push(number); + return number !== 30; + }, + }; + const lines = []; + + ensureHeadsAvailable( + [ + pullRequest({ number: 20, headRefOid: "1".repeat(40) }), + pullRequest({ number: 21, headRefOid: "2".repeat(40) }), + pullRequest({ number: 30, headRefOid: "3".repeat(40) }), + ], + git, + { log: (line) => lines.push(line) }, + ); + + assert.deepEqual(fetched, [21, 30]); + assert.deepEqual(lines, ["#30 head 를 가져오지 못했다 — 판정 보류"]); +}); + +test("포크 PR 은 같은 저장소가 아니라고 표시한다", () => { + const own = normalizePullRequest({ number: 1, headRepository: { nameWithOwner: "Org/Repo" }, labels: { nodes: [] } }, "org/repo"); + const fork = normalizePullRequest({ number: 2, headRepository: { nameWithOwner: "someone/repo" }, labels: { nodes: [{ name: "x" }] } }, "org/repo"); + + assert.equal(own.sameRepository, true); + assert.equal(fork.sameRepository, false); + assert.deepEqual(fork.labels, ["x"]); +}); + +test("충돌 쌍의 양쪽에 라벨을 붙이고 PR 마다 상대 목록 코멘트를 계획한다", () => { + const plan = planSiblingLabelChanges({ + pullRequests: [pullRequest({ number: 10 }), pullRequest({ number: 11 }), pullRequest({ number: 12 })], + pairs: [{ numbers: [10, 11], files: ["a.txt"] }], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toLabel.map((item) => item.number), [10, 11]); + assert.deepEqual(plan.toUnlabel, []); + assert.deepEqual(plan.comments, [ + { number: 10, siblings: [{ sibling: 11, files: ["a.txt"] }] }, + { number: 11, siblings: [{ sibling: 10, files: ["a.txt"] }] }, + ]); +}); + +test("이미 라벨이 붙은 PR 은 다시 붙이지 않고, 쌍에서 빠진 PR 에서는 뗀다", () => { + const plan = planSiblingLabelChanges({ + pullRequests: [ + pullRequest({ number: 10, labels: [DEFAULT_LABEL] }), + pullRequest({ number: 11 }), + pullRequest({ number: 12, labels: [DEFAULT_LABEL, "bug"] }), + ], + pairs: [{ numbers: [10, 11], files: ["a.txt"] }], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toLabel.map((item) => item.number), [11]); + assert.deepEqual(plan.toUnlabel.map((item) => item.number), [12]); + // 라벨이 이미 있어도 코멘트는 현재 상태로 다시 계획한다 — 상대 목록이 바뀌었을 수 있다. + assert.deepEqual(plan.comments.map((item) => item.number), [10, 11]); +}); + +test("한 PR 이 여러 형제와 충돌하면 상대를 번호순으로 한 코멘트에 모은다", () => { + const plan = planSiblingLabelChanges({ + pullRequests: [pullRequest({ number: 10 }), pullRequest({ number: 11 }), pullRequest({ number: 12 })], + pairs: [ + { numbers: [10, 12], files: ["b.txt"] }, + { numbers: [10, 11], files: ["a.txt"] }, + ], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.comments.find((item) => item.number === 10).siblings, [ + { sibling: 11, files: ["a.txt"] }, + { sibling: 12, files: ["b.txt"] }, + ]); +}); + +test("코멘트에 마커·상대 번호·충돌 파일·추적 이슈가 들어간다", () => { + const body = renderSiblingComment({ + siblings: [ + { sibling: 1608, files: ["dir/AfternoteHomeScreen.kt"] }, + { sibling: 1626, files: ["x.kt", "y.kt"] }, + ], + }); + + assert.ok(body.startsWith(COMMENT_MARKER)); + assert.ok(body.includes("- #1608 — `dir/AfternoteHomeScreen.kt`")); + assert.ok(body.includes("- #1626 — `x.kt`, `y.kt`")); + assert.ok(body.includes("#1750")); + assert.ok(body.includes("자동으로 떨어진다")); + + const resolved = renderResolvedComment(); + assert.ok(resolved.startsWith(COMMENT_MARKER)); + assert.ok(resolved.includes("해소")); +}); + +test("라벨을 붙이고 마커 코멘트가 없으면 만든다", async () => { + const api = fakeApi({ responses: { "/repos/o/r/issues/40/comments": [], "/repos/o/r/issues/41/comments": [] } }); + + const failures = await applyPlan( + api, + "o/r", + { + toLabel: [{ number: 40 }, { number: 41 }], + toUnlabel: [], + comments: [ + { number: 40, siblings: [{ sibling: 41, files: ["a.txt"] }] }, + { number: 41, siblings: [{ sibling: 40, files: ["a.txt"] }] }, + ], + }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, + ); + + assert.deepEqual(failures, []); + assert.deepEqual(writes(api).map((call) => [call.method, call.apiPath]), [ + ["POST", "/repos/o/r/issues/40/labels"], + ["POST", "/repos/o/r/issues/41/labels"], + ["POST", "/repos/o/r/issues/40/comments"], + ["POST", "/repos/o/r/issues/41/comments"], + ]); + assert.ok(writes(api)[2].body.body.includes("- #41 — `a.txt`")); +}); + +test("마커 코멘트가 있고 내용이 다르면 고쳐 쓴다", async () => { + const api = fakeApi({ + responses: { "/repos/o/r/issues/42/comments": [{ id: 7, body: `${COMMENT_MARKER}\r\n옛 상대 목록` }] }, + }); + + await applyPlan( + api, + "o/r", + { toLabel: [], toUnlabel: [], comments: [{ number: 42, siblings: [{ sibling: 43, files: ["a.txt"] }] }] }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, + ); + + assert.deepEqual(writes(api).map((call) => [call.method, call.apiPath]), [["PATCH", "/repos/o/r/issues/comments/7"]]); + assert.ok(writes(api)[0].body.body.includes("- #43 — `a.txt`")); +}); + +test("마커 코멘트가 현재 상태와 같으면 아무것도 쓰지 않는다", async () => { + const siblings = [{ sibling: 43, files: ["a.txt"] }]; + const current = renderSiblingComment({ siblings, label: DEFAULT_LABEL }).replace(/\n/g, "\r\n"); + const api = fakeApi({ responses: { "/repos/o/r/issues/42/comments": [{ id: 7, body: current }] } }); + + await applyPlan( + api, + "o/r", + { toLabel: [], toUnlabel: [], comments: [{ number: 42, siblings }] }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, + ); + + assert.deepEqual(writes(api), []); +}); + +test("해소된 PR 에서 라벨을 떼고 남은 안내를 «해소됨» 으로 고쳐 쓴다", async () => { + const api = fakeApi({ + responses: { "/repos/o/r/issues/45/comments": [{ id: 9, body: `${COMMENT_MARKER}\n옛 상대 목록` }] }, + }); + + await applyPlan( + api, + "o/r", + { toLabel: [], toUnlabel: [{ number: 45 }], comments: [] }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, + ); + + assert.deepEqual(writes(api).map((call) => [call.method, call.apiPath]), [ + ["DELETE", "/repos/o/r/issues/45/labels/sibling-conflict"], + ["PATCH", "/repos/o/r/issues/comments/9"], + ]); + assert.equal(writes(api)[1].body.body, renderResolvedComment()); +}); + +test("해소된 PR 에 안내가 없으면 새로 만들지 않는다", async () => { + const api = fakeApi({ responses: { "/repos/o/r/issues/46/comments": [] } }); + + await applyPlan( + api, + "o/r", + { toLabel: [], toUnlabel: [{ number: 46 }], comments: [] }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, + ); + + assert.deepEqual(writes(api).map((call) => [call.method, call.apiPath]), [ + ["DELETE", "/repos/o/r/issues/46/labels/sibling-conflict"], + ]); +}); + +test("dry run 은 아무것도 쓰지 않는다", async () => { + const api = fakeApi(); + + await applyPlan( + api, + "o/r", + { + toLabel: [{ number: 46 }], + toUnlabel: [{ number: 47 }], + comments: [{ number: 46, siblings: [{ sibling: 48, files: ["a.txt"] }] }], + }, + { label: DEFAULT_LABEL, dryRun: true, logger: silent }, + ); + + assert.deepEqual(api.calls, []); +}); + +test("한 PR 이 실패해도 나머지를 처리하고 실패를 모아 돌려준다", async () => { + const api = fakeApi({ + responses: { "/repos/o/r/issues/51/comments": [] }, + failOn: (apiPath) => apiPath === "/repos/o/r/issues/50/labels", + }); + + const failures = await applyPlan( + api, + "o/r", + { + toLabel: [{ number: 50 }, { number: 51 }], + toUnlabel: [], + comments: [{ number: 51, siblings: [{ sibling: 50, files: ["a.txt"] }] }], + }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, + ); + + assert.equal(failures.length, 1); + assert.match(failures[0], /#50 라벨 부착 실패/); + assert.ok(api.calls.some((call) => call.apiPath === "/repos/o/r/issues/51/comments" && call.method === "POST")); +}); + +test("라벨이 없으면 만들고, 있으면 만들지 않는다", async () => { + const missing = fakeApi(); + await ensureLabelExists(missing, "o/r", DEFAULT_LABEL); + const created = missing.calls.filter((call) => call.method === "POST"); + assert.equal(created.length, 1); + assert.equal(created[0].body.name, DEFAULT_LABEL); + + const existing = fakeApi({ responses: { "/repos/o/r/labels/sibling-conflict": { name: DEFAULT_LABEL } } }); + await ensureLabelExists(existing, "o/r", DEFAULT_LABEL); + assert.equal(existing.calls.filter((call) => call.method === "POST").length, 0); +}); + +test("요약에 쌍·파일·건수·보류 사유가 남는다", () => { + const pairs = [{ numbers: [10, 11], files: ["a.txt"] }]; + const plan = planSiblingLabelChanges({ + pullRequests: [pullRequest({ number: 10 }), pullRequest({ number: 11 }), pullRequest({ number: 12, labels: [DEFAULT_LABEL] })], + pairs, + label: DEFAULT_LABEL, + }); + + const summary = renderSummary({ + pairs, + plan, + skipped: [{ number: 16, reason: "head 를 찾지 못함" }], + skippedPairs: [{ numbers: [10, 17], reason: "no merge base" }], + label: DEFAULT_LABEL, + dryRun: true, + }); + + assert.ok(summary.includes("dry run")); + assert.ok(summary.includes("#10 ↔ #11 — `a.txt`")); + assert.ok(summary.includes("라벨 부착: 2건 — #10, #11")); + assert.ok(summary.includes("라벨 제거: 1건 — #12")); + assert.ok(summary.includes("코멘트 계획: 2건")); + assert.ok(summary.includes("판정 보류: 1건 — #16 (head 를 찾지 못함)")); + assert.ok(summary.includes("비교 실패: 1건 — #10 ↔ #17 (no merge base)")); +}); + +test("리컨사일 워크플로가 전체 히스토리 checkout 으로 이 스크립트와 테스트를 부른다", () => { + const job = /^ sibling-conflicts:\n([\s\S]*?)(?=^ \w[\w-]*:\n|(?![\s\S]))/m.exec(workflow)?.[1]; + assert.ok(job, "sibling-conflicts job 이 있어야 한다"); + assert.match(job, /^ timeout-minutes: [1-9]\d*$/m); + assert.match(job, /^ fetch-depth: 0$/m); + assert.match(job, /^ persist-credentials: false$/m); + assert.match(job, /node --test \.github\/scripts\/label-sibling-conflicts\.test\.mjs/); + assert.match(job, /run: node \.github\/scripts\/label-sibling-conflicts\.mjs$/m); + assert.match(job, /GITHUB_DEFAULT_BRANCH: \$\{\{ github\.event\.repository\.default_branch \}\}/); + assert.match(job, /DRY_RUN: \$\{\{ inputs\.dry_run == true && 'true' \|\| 'false' \}\}/); + assert.match(job, /^ issues: write$/m); + assert.match(job, /^ pull-requests: write$/m); +}); diff --git a/.github/scripts/main-release-gate-policy.test.mjs b/.github/scripts/main-release-gate-policy.test.mjs new file mode 100644 index 0000000000..af572d4262 --- /dev/null +++ b/.github/scripts/main-release-gate-policy.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const workflowDirectory = new URL("../workflows/", import.meta.url); + +// Repository ruleset 21653076 (`required-checks (main)`) 의 required context 와 함께 바꿔야 하는 +// 외부 계약이다. 잡 이름을 고치면 룰셋에서도 같이 고쳐야 main 게이트가 조용히 사라진다. +const REQUIRED_MAIN_GATE_CONTEXTS = [ + "Build and verify release AAB", + "Check MOCK_CLEANUP TODOs", +]; + +async function workflow(name) { + return readFile(new URL(name, workflowDirectory), "utf8"); +} + +function withoutComments(source) { + return source + .split("\n") + .filter((line) => !/^\s*#/.test(line)) + .join("\n"); +} + +function triggerBlock(source) { + const start = source.indexOf("on:\n"); + const end = source.indexOf("\npermissions:", start); + assert.notEqual(start, -1, "on: 블록이 없다"); + assert.notEqual(end, -1, "permissions: 블록이 없다"); + return source.slice(start, end); +} + +test("mock cleanup blocks the merge instead of reporting after it", async () => { + const trigger = triggerBlock(withoutComments(await workflow("mock-cleanup-check.yml"))); + + // #684 는 이 게이트를 push(main) 으로 옮겼다. 근거는 「main 이 직접 push 로 갱신된다」였고 + // 그 전제는 낡았다 — 지금 main 은 PR 로 갱신된다 (#1877). PR 에서 돌지 않으면 이미 main 이 + // 그 커밋을 가진 뒤에 red 를 내는 사후 경보가 된다. + assert.match( + trigger, + /^\s{2}pull_request:\n\s{4}branches:\s*\[main\]$/m, + "머지 전에 막으려면 main 대상 PR 에서 돌아야 한다", + ); + + // 룰셋을 우회한 직접 push 도 여전히 잡아야 한다. + assert.match( + trigger, + /^\s{2}push:\n\s{4}branches:\s*\[main\]$/m, + "직접 push 안전망을 잃으면 안 된다", + ); +}); + +test("mock cleanup never widens to develop", async () => { + const trigger = triggerBlock(withoutComments(await workflow("mock-cleanup-check.yml"))); + + // develop 에는 진행 중 마커가 상주할 수 있어, 넓히면 전 PR 이 상시 red 가 된다 (#684). + assert.doesNotMatch(trigger, /develop/, "develop 으로 넓히는 것은 #684 가 오답으로 못 박았다"); +}); + +test("mock cleanup fails closed on a marker", async () => { + const source = await workflow("mock-cleanup-check.yml"); + + // 경고만 내고 success 로 끝나던 것이 #684 의 결함 셋 중 하나였다. + assert.match(source, /exit 1/, "마커를 찾으면 실패해야 게이트다"); + assert.doesNotMatch(source, /::warning::/, "경고로 되돌아가면 아무것도 막지 못한다"); +}); + +test("mock cleanup excludes directories by name, not by path", async () => { + const source = await workflow("mock-cleanup-check.yml"); + + // `--exclude-dir` 은 경로가 아니라 디렉터리 이름 glob 을 받는다. `src/test` 처럼 경로를 + // 넘기면 아무것도 제외되지 않는다 (#684). + for (const name of ["debug", "test", "androidTest"]) { + assert.match( + source, + new RegExp(`--exclude-dir="${name}"`), + `--exclude-dir 은 디렉터리 이름이어야 한다: ${name}`, + ); + } + assert.doesNotMatch(source, /--exclude-dir="[^"]*\//, "경로를 넘기면 제외가 무력해진다"); +}); + +test("release AAB preflight keeps running on main pull requests", async () => { + const trigger = triggerBlock(withoutComments(await workflow("release-aab-preflight.yml"))); + + // 필수 체크로 승격하려면 main 대상 PR 에서 반드시 결과가 나와야 한다. + assert.match(trigger, /^\s{2}pull_request:\n\s{4}branches:\s*\[main\]$/m); +}); + +test("both main gates keep the job names the ruleset requires", async () => { + const sources = [ + await workflow("release-aab-preflight.yml"), + await workflow("mock-cleanup-check.yml"), + ].join("\n"); + + for (const context of REQUIRED_MAIN_GATE_CONTEXTS) { + assert.match( + sources, + new RegExp(`^\\s{4}name:\\s*${context}$`, "m"), + `ruleset 21653076 이 요구하는 context 다 — 이름을 바꾸면 룰셋도 함께 고칠 것: ${context}`, + ); + } +}); diff --git a/.github/scripts/merge-order-guard-policy.test.mjs b/.github/scripts/merge-order-guard-policy.test.mjs index 31cf1b7e00..6aa57dae00 100644 --- a/.github/scripts/merge-order-guard-policy.test.mjs +++ b/.github/scripts/merge-order-guard-policy.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; @@ -156,7 +156,7 @@ test("closing a blocker refreshes PRs found only through unlinked close keywords const heads = collectRefreshHeads( [ { - headRefName: "linked", + number: 101, title: "fix: linked", body: "", closingIssuesReferences: { @@ -166,13 +166,13 @@ test("closing a blocker refreshes PRs found only through unlinked close keywords }, }, { - headRefName: "text-only", + number: 102, title: "fix: text only", body: "Closes #1176", closingIssuesReferences: { nodes: [] }, }, { - headRefName: "foreign", + number: 103, title: "fix: foreign", body: "Closes other/repo#1176", closingIssuesReferences: { @@ -180,7 +180,7 @@ test("closing a blocker refreshes PRs found only through unlinked close keywords }, }, { - headRefName: "mention-only", + number: 104, title: "docs: mention #1176", body: "Related to #1176", closingIssuesReferences: { nodes: [] }, @@ -189,13 +189,13 @@ test("closing a blocker refreshes PRs found only through unlinked close keywords [1176], ); - assert.deepEqual(heads, ["linked", "text-only"]); + assert.deepEqual(heads, ["101", "102"]); }); test("refresh queries every open PR page with the same repository-aware closing data", () => { assert.match(guard, /gh api graphql --paginate --slurp/); assert.match(guard, /pullRequests\(states:OPEN,first:100,after:\$endCursor\)/); - assert.match(guard, /nodes\{headRefName title body closingIssuesReferences/); + assert.match(guard, /nodes\{number headRefOid title body closingIssuesReferences/); assert.match(guard, /pageInfo\{hasNextPage endCursor\}/); }); @@ -203,7 +203,7 @@ test("refresh passes large open-PR payloads through a file instead of one enviro const heads = collectRefreshHeads( [ { - headRefName: "large-body", + number: 105, title: "fix: large body", body: `Closes #1176\n${"x".repeat(160 * 1024)}`, closingIssuesReferences: { nodes: [] }, @@ -212,7 +212,7 @@ test("refresh passes large open-PR payloads through a file instead of one enviro [1176], ); - assert.deepEqual(heads, ["large-body"]); + assert.deepEqual(heads, ["105"]); assert.match(guard, /> "\$prs_file"/); assert.doesNotMatch(guard, /PRS_JSON=/); }); @@ -231,7 +231,7 @@ test("merge-group guard queries complete native-stack membership and fails close }); test("ordinary PR guard stays non-stale while merge queue performs the live verdict", () => { - assert.match(guard, /^\s{4}types: \[opened, reopened, synchronize, edited\]$/m); + assert.match(guard, /^\s{2}pull_request_target:\n\s{4}types: \[opened, reopened, synchronize, edited\]$/m); assert.doesNotMatch(guard, /statuses: write/); const start = guard.indexOf('if [ "$EVENT_NAME" = "merge_group" ]'); const end = guard.indexOf(" # GitHub 가 close keyword", start); @@ -263,6 +263,9 @@ test("declares the repository code owner for GitHub automation and policy change test("live CLOSED warns upper PRs while OPEN or MERGED resolves the same bot comment", () => { assert.match(stackNotify, /issues: write/); + // 코멘트 대상이 PR 이면 GitHub 은 pull-requests 쓰기를 요구한다. 이 칸이 read 로 + // 머물러 안내 코멘트가 403 으로 죽은 적이 있다(#2068). + assert.match(stackNotify, /pull-requests: write/); assert.match(stackNotify, /live_state=\$\(jq -r '\.state'/); assert.match(stackNotify, /CLOSED\)[\s\S]*notice_state="active"/); assert.match(stackNotify, /OPEN\)[\s\S]*notice_state="resolved"/); @@ -320,3 +323,185 @@ printf '%s %s' "$PR_NUMBER" "$BASE_REF"`, assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout, "1477 develop"); }); + +function shellStep(name) { + const start = guard.indexOf(` - name: ${name}\n`); + assert.ok(start >= 0, name); + const run = guard.indexOf(" run: |\n", start) + " run: |\n".length; + const lines = guard.slice(run).split("\n"); + const body = []; + for (const line of lines) { + if (line && !line.startsWith(" ")) break; + body.push(line.slice(10)); + } + return body.join("\n"); +} + +function runGuard({ base = "feature/parent", blockers = [], apiFailure = false, event = "pull_request" } = {}) { + const script = shellStep("머지 큐 스택 무결성·closing 이슈 blocked_by 검사"); + const fake = `gh() { + if [[ "$*" == *stackEntry* ]]; then printf '%s' '{"stackEntry":null}'; + elif [[ "$*" == *graphql* ]]; then printf '%s' "$FIXTURE_PR"; + elif [[ "$*" == *dependencies/blocked_by* ]]; then + [ "$FIXTURE_API_FAILURE" != "true" ] || return 42 + printf '%s' "$FIXTURE_OPEN_BLOCKERS" + else echo "Unexpected gh call: $*" >&2; return 99; fi + } +`; + return spawnSync("bash", ["-c", fake + script], { + encoding: "utf8", + env: { ...process.env, EVENT_NAME: event, PR_NUMBER: "1930", BASE_REF: base, + GITHUB_REPOSITORY: "Afternote/Afternote-FE", FIXTURE_API_FAILURE: String(apiFailure), + FIXTURE_OPEN_BLOCKERS: blockers.filter((b) => b.state === "open").map((b) => b.number).join("\n"), + FIXTURE_PR: JSON.stringify({ baseRefName: base, title: "fix: guard", body: "", + closingIssuesReferences: { nodes: [{ number: 1920, repository: { nameWithOwner: "Afternote/Afternote-FE" } }] } }) }, + }); +} + +for (const base of ["develop", "main", "feature/parent"]) { + test(`open blockers fail the executable guard for ${base}`, () => { + const result = runGuard({ base, blockers: [{ number: 708, state: "open" }] }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stdout, /::error::#1920.*#708/); + }); + test(`closed or absent blockers pass the executable guard for ${base}`, () => { + for (const blockers of [[], [{ number: 708, state: "closed" }]]) { + const result = runGuard({ base, blockers }); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(result.stdout, /::error::/); + } + }); +} + +test("dependency API failure cannot become a green stacked guard", () => { + const result = runGuard({ apiFailure: true }); + assert.equal(result.status, 42, result.stderr); +}); + +test("merge queue still fails on an open issue blocker", () => { + const result = runGuard({ event: "merge_group", base: "develop", blockers: [{ number: 708, state: "open" }] }); + assert.equal(result.status, 1, result.stderr); +}); + +test("blocker changes rerun the trusted pull_request_target run and dispatch only when none exists (#1983)", () => { + // #1955 는 rerun 이 PR 사본의 옛 워크플로 코드를 돌린다는 이유로 dispatch 재게시로 바꿨다. + // #1977 뒤 PR HEAD 의 run 은 base YAML·default-branch 스크립트로 도는 run 이라 재실행이 곧 + // 최신 판정이고, check-run 재게시가 임의 suite 에 얹혀 무관한 워크플로 이름 아래 guard 가 + // 또 뜨는 일을 피한다. rerun 대상은 pull_request_target run 으로만 좁힌다. + assert.match(guard, /gh run list --workflow merge-order-guard.yml --event pull_request_target \\\n\s+--commit "\$sha" --status completed --limit 1/); + assert.match(guard, /\[ -n "\$run_id" \] && gh run rerun "\$run_id"/); + assert.doesNotMatch(guard, /gh run list --workflow merge-order-guard.yml --branch/); + assert.match(guard, /gh workflow run merge-order-guard.yml --ref "\$DEFAULT_BRANCH" -f pull_request_number="\$pr"/); + assert.match(guard, /github.ref_name == github.event.repository.default_branch/); + assert.match(guard, /needs.guard.outputs.target_sha/); + // HEAD SHA 는 이미 받은 GraphQL 페이지에서 읽는다 — PR 마다 REST 왕복을 더하지 않는다 (#1465). + assert.match(guard, /nodes\{number headRefOid title body closingIssuesReferences/); + assert.doesNotMatch(guard.slice(guard.indexOf("dispatch_failed=0")), /gh pr view/); +}); + +test("dispatch publication refuses changed or closed HEAD and publishes failure on the captured HEAD", () => { + const script = shellStep("Publish trusted guard verdict on the captured PR HEAD"); + const directory = mkdtempSync(join(tmpdir(), "merge-order-publish-")); + try { + for (const [state, head, result, expected] of [ + ["OPEN", "original", "failure", "failure"], + ["OPEN", "original", "success", "success"], + ["OPEN", "original", "cancelled", "failure"], + ["OPEN", "changed", "success", null], + ["CLOSED", "original", "success", null], + ]) { + const run = spawnSync("bash", ["-c", `gh() { + if [ "$1" = "pr" ]; then printf '%s' "$FIXTURE_CURRENT"; + elif [ "$1" = "api" ]; then cat "$RUNNER_TEMP/guard-check.json" >&2; + else return 99; fi + } +` + script], { encoding: "utf8", env: { ...process.env, + RUNNER_TEMP: directory, PR_NUMBER: "1930", TARGET_SHA: "original", GUARD_RESULT: result, + GITHUB_SERVER_URL: "https://github.com", GITHUB_REPOSITORY: "Afternote/Afternote-FE", GITHUB_RUN_ID: "123", + FIXTURE_CURRENT: JSON.stringify({ state, headRefOid: head }) } }); + assert.equal(run.status, 0, run.stderr); + if (expected === null) assert.equal(run.stderr, ""); + else { + const check = JSON.parse(run.stderr); + assert.equal(check.name, "guard"); + assert.equal(check.head_sha, "original"); + assert.equal(check.conclusion, expected); + } + } + } finally { rmSync(directory, { recursive: true, force: true }); } +}); + + +function runRefreshLoop(ghMock, prs) { + const start = guard.indexOf(" dispatch_failed=0"); + assert.ok(start >= 0); + const script = guard.slice(start).split("\n").map((line) => line.slice(10)).join("\n"); + const directory = mkdtempSync(join(tmpdir(), "merge-order-refresh-loop-")); + const prsFile = join(directory, "open-prs.json"); + writeFileSync(prsFile, JSON.stringify([{ data: { repository: { pullRequests: { nodes: prs } } } }])); + try { + return spawnSync("bash", ["-c", `set -euo pipefail +heads="${prs.map((pr) => pr.number).join(" ")}" +DEFAULT_BRANCH=develop +prs_file="${prsFile}" +${ghMock} +` + script], { encoding: "utf8" }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test("one failed refresh dispatch does not prevent the remaining PRs from refreshing", () => { + const result = runRefreshLoop(`gh() { + [ "$1 $2" != "run list" ] || return 0 + echo "$*"; [[ "$*" != *pull_request_number=101* ]]; }`, + [{ number: 101, headRefOid: "aaa" }, { number: 102, headRefOid: "bbb" }]); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stdout, /pull_request_number=101/); + assert.match(result.stdout, /pull_request_number=102/); +}); + +test("refresh reruns the existing pull_request_target run in place instead of publishing a new check-run", () => { + const result = runRefreshLoop(`gh() { + case "$1 $2" in + "run list") [[ "$*" == *"--event pull_request_target"* && "$*" == *"--commit aaa"* ]] && echo 555; return 0 ;; + "run rerun") echo "rerun $3"; return 0 ;; + *) echo "$*"; return 0 ;; + esac }`, + [{ number: 101, headRefOid: "aaa" }]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^rerun 555$/m); + assert.doesNotMatch(result.stdout, /workflow run/); +}); + +test("refresh falls back to dispatch when no pull_request_target run exists or the rerun is refused", () => { + const result = runRefreshLoop(`gh() { + case "$1 $2" in + "run list") [[ "$*" == *"--commit aaa"* ]] && echo 555; return 0 ;; + "run rerun") echo "rerun refused" >&2; return 1 ;; + *) echo "$*"; return 0 ;; + esac }`, + [{ number: 101, headRefOid: "aaa" }, { number: 102, headRefOid: "bbb" }, { number: 103 }]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /pull_request_number=101/); + assert.match(result.stdout, /pull_request_number=102/); + assert.match(result.stdout, /pull_request_number=103/); +}); + +test("PR HEAD guard runs default-branch policy once, so no second verdict is republished (#1977)", () => { + // pull_request 로 돌면 PR 사본 YAML 이 판정해 낡은 브랜치는 옛 정책이고 PR 이 가드를 고칠 수 + // 있다. 종전 처방(inline 종료 → 최신 정책 dispatch → check-run 재게시)은 GITHUB_TOKEN 의 + // check-run 이 임의 suite 에 얹혀 무관한 워크플로 이름 아래 guard 가 두 줄로 떴다. + assert.doesNotMatch(guard, /^\s{2}pull_request:$/m); + assert.doesNotMatch(guard, /github\.event_name == 'pull_request'/); + assert.match(guard, /github\.event_name == 'pull_request_target'/); + assert.doesNotMatch(guard, /github\.event\.pull_request\.head\.(sha|ref)/); + assert.doesNotMatch(guard, /actions\/checkout[\s\S]*?ref: \$\{\{ github\.event\.pull_request/); + assert.match(guard, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); + assert.ok( + !existsSync(new URL("../workflows/refresh-merge-order-policy.yml", import.meta.url)), + "inline 종료 뒤 최신 정책을 다시 dispatch 하는 refresh 워크플로는 pull_request_target 이 대체했다", + ); + // dispatch 재게시는 이벤트가 나지 않는 SHA(토큰 커밋·선행 이슈 닫힘) 전용으로만 남는다. + assert.match(guard, /github\.event_name == 'workflow_dispatch' &&\n\s+github\.ref_name == github\.event\.repository\.default_branch/); +}); \ No newline at end of file diff --git a/.github/scripts/play-internal-track.mjs b/.github/scripts/play-internal-track.mjs index 436fd2360e..e41b4d594f 100644 --- a/.github/scripts/play-internal-track.mjs +++ b/.github/scripts/play-internal-track.mjs @@ -145,14 +145,15 @@ export async function readLatestVersionCode(client, { log = console.log } = {}) const tracks = await client.listTracks(editId); return maxVersionCode(bundles, tracks); } finally { - // 읽기 전용 조회다. commit 하지 않고 반드시 되돌린다. + // 읽기 전용 조회다. commit 대신 삭제를 시도한다. await discardEdit(client, editId, log); } } /** * edit 생성 → bundle 업로드 → internal track 갱신 → commit. 각 단계의 응답을 다음 단계의 전제로 - * 다시 확인하고, 어느 단계에서 실패하든 edit 를 정리한 뒤 원인을 그대로 올린다. + * 다시 확인한다. 포착한 예외에서는 edit 삭제를 시도하고 원인을 그대로 올린다. + * 취소·타임아웃으로 프로세스가 종료되면 이 catch 는 실행되지 않을 수 있다. */ export async function publishInternalBundle( client, @@ -165,11 +166,13 @@ export async function publishInternalBundle( const editId = await client.insertEdit(); try { - // 같은 versionCode 재업로드는 Play 도 거절하지만, 그 거절은 수십 MB 를 올린 뒤에 온다. - // 업로드 전에 목록으로 먼저 막는다. - const existing = maxVersionCodeSet(await client.listBundles(editId)); - if (existing.has(expected)) { - throw new Error(`versionCode ${expected} 는 이미 Play 에 올라가 있다 — 재사용할 수 없다.`); + // 빌드 사이 Console 업로드·승격이 끼었을 수 있으므로 probe 의 값을 재사용하지 않는다. + // 새 edit 에서 bundle 과 모든 track 을 조회해 수십 MB 를 전송하기 전에 다시 막는다. + const bundles = await client.listBundles(editId); + const tracks = await client.listTracks(editId); + const latest = maxVersionCode(bundles, tracks); + if (expected <= latest) { + throw new Error(`versionCode ${expected} 는 Play 현재 최대 versionCode ${latest} 보다 커야 한다.`); } const uploaded = await client.uploadBundle(editId, bundle); @@ -210,10 +213,6 @@ export async function publishInternalBundle( } } -function maxVersionCodeSet(bundlesResponse) { - return new Set((bundlesResponse?.bundles ?? []).map((bundle) => toVersionCode(bundle.versionCode))); -} - async function writeOutput(entries) { const outputPath = process.env.GITHUB_OUTPUT; if (!outputPath) { diff --git a/.github/scripts/play-internal-track.test.mjs b/.github/scripts/play-internal-track.test.mjs index 3646c96322..6e29fcf528 100644 --- a/.github/scripts/play-internal-track.test.mjs +++ b/.github/scripts/play-internal-track.test.mjs @@ -26,7 +26,7 @@ function response(status, body) { * 실제 Play 서버 대신 이 fake 가 edit 수명주기를 그대로 흉내낸다. 각 라우트가 무엇을 돌려줄지 * 테스트가 정하고, 호출 순서는 calls 로 확인한다. */ -function fakePlay({ bundles = [], tracks = [], failAt, uploadVersionCode } = {}) { +function fakePlay({ bundles = [], tracks = [], failAt, failDelete = false, uploadVersionCode } = {}) { const calls = []; const state = { committed: false, deleted: false }; @@ -41,6 +41,9 @@ function fakePlay({ bundles = [], tracks = [], failAt, uploadVersionCode } = {}) return response(200, { id: "edit-1" }); } if (route.startsWith("DELETE ")) { + if (failDelete) { + return response(503, { error: { message: "cleanup unavailable" } }); + } state.deleted = true; return response(204, ""); } @@ -140,9 +143,13 @@ test("게시는 edit → upload → internal track → commit 순서로만 진 assert.equal(play.state.deleted, false); const uploadIndex = play.calls.findIndex((call) => call.includes("uploadType=media")); + const bundlesIndex = play.calls.findIndex((call) => call.endsWith("/bundles")); + const tracksIndex = play.calls.findIndex((call) => call.endsWith("/tracks")); const trackIndex = play.calls.findIndex((call) => call.startsWith("PUT ")); const commitIndex = play.calls.findIndex((call) => call.endsWith(":commit")); assert.ok(uploadIndex > 0); + assert.ok(bundlesIndex > 0 && bundlesIndex < uploadIndex); + assert.ok(tracksIndex > 0 && tracksIndex < uploadIndex); assert.ok(uploadIndex < trackIndex); assert.ok(trackIndex < commitIndex); }); @@ -157,13 +164,78 @@ test("같은 versionCode 재업로드는 업로드 전에 막힌다", async () = releaseNotes: "note", log: silent, }), - /이미 Play 에 올라가 있다/, + /현재 최대 versionCode 202 보다 커야/, ); assert.ok(!play.calls.some((call) => call.includes("uploadType=media")), "업로드까지 가면 안 된다"); assert.equal(play.state.deleted, true); }); +for (const [name, metadata] of [ + ["더 큰 bundle", { bundles: [{ versionCode: 203 }] }], + ["같은 track 값", { tracks: [{ track: "internal", releases: [{ versionCodes: ["202"] }] }] }], + ["다른 track 의 더 큰 값", { + bundles: [{ versionCode: 100 }], + tracks: [ + { track: "internal", releases: [{ versionCodes: ["101"] }] }, + { track: "production", releases: [{ versionCodes: ["203"] }] }, + ], + }], +]) { + test(`업로드 직전 ${name} 이 있으면 AAB 전송 전에 멈추고 edit 를 정리한다`, async () => { + const play = fakePlay({ ...metadata, uploadVersionCode: 202 }); + await assert.rejects( + () => publishInternalBundle(play.client, { + bundle: Buffer.from("aab"), versionCode: 202, releaseNotes: "note", log: silent, + }), + /현재 최대 versionCode 20[23] 보다 커야/, + ); + assert.ok(!play.calls.some((call) => call.includes("uploadType=media"))); + assert.equal(play.state.committed, false); + assert.equal(play.state.deleted, true); + }); +} + +test("업로드 직전 tracks 조회가 실패하면 전송하지 않고 edit 를 정리한다", async () => { + const play = fakePlay({ failAt: "/tracks", uploadVersionCode: 202 }); + await assert.rejects( + () => publishInternalBundle(play.client, { + bundle: Buffer.from("aab"), versionCode: 202, releaseNotes: "note", log: silent, + }), + /GET .*\/tracks 실패 \(403\)/, + ); + assert.ok(!play.calls.some((call) => call.includes("uploadType=media"))); + assert.equal(play.state.deleted, true); +}); + +test("업로드 직전 두 목록의 최댓값보다 크면 게시한다", async () => { + const play = fakePlay({ + bundles: [{ versionCode: 200 }], + tracks: [{ track: "production", releases: [{ versionCodes: ["201"] }] }], + uploadVersionCode: 202, + }); + await publishInternalBundle(play.client, { + bundle: Buffer.from("aab"), versionCode: 202, releaseNotes: "note", log: silent, + }); + assert.equal(play.state.committed, true); + assert.equal(play.state.deleted, false); +}); + +test("정리 실패는 경고를 남기고 원래 게시 오류를 보존한다", async () => { + const play = fakePlay({ failAt: ":commit", failDelete: true, uploadVersionCode: 202 }); + const logs = []; + await assert.rejects( + () => publishInternalBundle(play.client, { + bundle: Buffer.from("aab"), versionCode: 202, releaseNotes: "note", log: (line) => logs.push(line), + }), + /:commit 실패 \(403\)/, + ); + assert.ok(play.calls.some((call) => call.startsWith("DELETE "))); + assert.equal(play.state.deleted, false); + assert.equal(logs.length, 1); + assert.match(logs[0], /::warning::미완료 edit edit-1 정리 실패: .*\(503\)/); +}); + test("업로드된 versionCode 가 빌드한 값과 다르면 track 을 건드리지 않는다", async () => { const play = fakePlay({ uploadVersionCode: 999 }); await assert.rejects( diff --git a/.github/scripts/play-publish-guards-mutation.test.mjs b/.github/scripts/play-publish-guards-mutation.test.mjs new file mode 100644 index 0000000000..fd69464704 --- /dev/null +++ b/.github/scripts/play-publish-guards-mutation.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; + +const repository = new URL("../../", import.meta.url); +const publisher = ".github/scripts/play-internal-track.mjs"; +const verifier = "scripts/verify-play-release-bundle.sh"; +const workflow = ".github/workflows/release-play-internal.yml"; +const publisherTests = ".github/scripts/play-internal-track.test.mjs"; +const verifierTests = ".github/scripts/verify-play-release-bundle.test.mjs"; +const policyTests = ".github/scripts/release-play-internal-policy.test.mjs"; +const files = [publisher, verifier, workflow, publisherTests, verifierTests, policyTests, + "scripts/jarsigner-verification-policy.sh", "build-logic/src/main/kotlin/VersionCode.kt"]; +const originals = new Map(await Promise.all(files.map(async (file) => [file, await readFile(new URL(file, repository), "utf8")]))); + +// 실제 회귀 테스트를 복제된 소스에 다시 실행한다. 의도한 가드를 제거했을 때 테스트가 빨간불이 +// 되는지 확인하며, 원본 checkout 과 Play 서버는 건드리지 않는다. +for (const [name, file, before, after, suite, pattern] of [ + ["publish 의 tracks 조회 제거", publisher, + "const tracks = await client.listTracks(editId);\n const latest =", "const tracks = {};\n const latest =", + publisherTests, "업로드 직전 다른 track"], + ["publish 의 bundles 조회 제거", publisher, + "const latest = maxVersionCode(bundles, tracks);", "const latest = maxVersionCode({}, tracks);", + publisherTests, "업로드 직전 더 큰 bundle"], + ["단조 증가를 동등값만 차단하도록 축소", publisher, + "if (expected <= latest)", "if (expected === latest)", publisherTests, "업로드 직전 더 큰 bundle"], + ["같은 versionCode 허용", publisher, + "if (expected <= latest)", "if (expected < latest)", publisherTests, "업로드 직전 같은 track"], + ["edit 삭제 시도 제거", publisher, + "await client.deleteEdit(editId);", "void editId;", publisherTests, "정리 실패"], + ["삭제 실패로 원래 오류 덮기", publisher, + 'log(`::warning::미완료 edit ${editId} 정리 실패: ${error.message}`);', "throw error;", publisherTests, "정리 실패"], + ["manifest 불일치 허용", verifier, + '[[ "${manifest_version_code}" != "${expected_version_code}" ]]', "false", verifierTests, "manifest versionCode 누락"], + ["bundletool digest 불일치 허용", verifier, + '[[ "$(sha256_file "${bundletool_jar}")" != "${bundletool_sha256}" ]]', "false", verifierTests, "bundletool SHA256"], + ["검증 step 의 주입값 제거", workflow, + "- name: Verify the AAB signature and contents\n env:\n AFTERNOTE_VERSION_CODE: ${{ steps.version_code.outputs.version_code }}", + "- name: Verify the AAB signature and contents", policyTests, "서명 검증기가"], +]) { + test(`Play publish 회귀 검사가 «${name}» 변이를 거부한다`, async () => { + const source = originals.get(file); + assert.equal(source.split(before).length - 1, 1, "변이가 정확히 한 곳에 적용되어야 한다"); + const root = await mkdtemp(join(tmpdir(), "afternote-play-mutation-")); + try { + for (const [name, content] of originals) { + const output = join(root, name); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, name === file ? content.replace(before, after) : content); + } + const env = { ...process.env }; + // 부모 node:test worker 문맥을 물려주면 자식 runner 가 선택한 테스트를 건너뛴다. + delete env.NODE_TEST_CONTEXT; + const result = spawnSync(process.execPath, ["--test", `--test-name-pattern=${pattern}`, join(root, suite)], + { env, encoding: "utf8", timeout: 30_000 }); + assert.equal(result.error, undefined, result.error?.message); + assert.notEqual(result.status, 0, `가드가 사라졌는데 회귀 검사가 통과했다:\n${result.stdout}${result.stderr}`); + assert.match(result.stdout + result.stderr, /AssertionError|ERR_ASSERTION/, "실행 장애가 아닌 회귀 단언이 변이를 거부해야 한다"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} diff --git a/.github/scripts/pr-gate-policy.test.mjs b/.github/scripts/pr-gate-policy.test.mjs index a3ce83ef94..51c8988473 100644 --- a/.github/scripts/pr-gate-policy.test.mjs +++ b/.github/scripts/pr-gate-policy.test.mjs @@ -117,13 +117,26 @@ test("stale runs are cancelled per pull request", async () => { }); test("token-authored commits preserve the pull request context on manual dispatch", async () => { + // workflow_dispatch 의 type: number 는 입력을 «검증» 할 뿐 런타임 값은 문자열로 온다. + // 그 문자열이 reusable workflow 의 number 입력에 그대로 들어가면 run 이 job 0개로 + // 죽어 로그도 annotation 도 남지 않는다 — 브리지가 8/27~9/3 동안 33건 연속 실패한 + // 원인이다 (#1850). fromJSON 없이 raw 로 되돌리면 그 침묵하는 실패가 그대로 돌아온다. const entry = await readWorkflow(ENTRY_WORKFLOW); assert.match(entry, /^\s{2}workflow_dispatch:\n\s{4}inputs:\n\s{6}pull_request_number:/m); assert.equal( - (entry.match(/pull_request_number: \$\{\{ inputs\.pull_request_number \|\| github\.event\.pull_request\.number \|\| 0 \}\}/g) ?? []).length, + ( + entry.match( + /pull_request_number: \$\{\{ fromJSON\(inputs\.pull_request_number \|\| '0'\) \|\| github\.event\.pull_request\.number \|\| 0 \}\}/g, + ) ?? [] + ).length, VALIDATION_WORKFLOWS.length, ); + assert.equal( + (entry.match(/pull_request_number: \$\{\{ inputs\.pull_request_number/g) ?? []).length, + 0, + "dispatch 입력을 number 로 되살리지 않고 reusable workflow 에 넘기면 startup failure 다", + ); }); test("merge queue groups revalidate every required context", async () => { @@ -147,7 +160,7 @@ test("merge group validation falls back to the full suite without a pull request (entry.match(/\|\| github\.event\.pull_request\.number \|\| 0 \}\}/g) ?? []).length, VALIDATION_WORKFLOWS.length, ); - for (const gate of ["Require linked Issue", "Validate CI Test Plan"]) { + for (const gate of ["Require linked Issue", "Require module owner", "Validate CI Test Plan", "Reject test-only production declarations"]) { assert.match( repositoryQuality, new RegExp(`- name: ${gate}\\n\\s+if: inputs\\.pull_request_number > 0`), @@ -243,6 +256,11 @@ test("repository quality owns fail-closed paginated impact classification and PR ); assert.match(repositoryQuality, /pull_request_json=%s\\n' "\$pull_request_file"/); assert.doesNotMatch(unitTest, /Validate CI Test Plan/); + // #1895 — 새 프로덕션 함수의 main 참조 게이트는 PR files API 의 patch 로 판정하므로 files_json 을 받는다. + assert.match( + repositoryQuality, + /validate-test-only-production-declarations\.mjs\n\s+"\$\{\{ steps\.changed-files\.outputs\.pull_request_json \}\}"\n\s+"\$\{\{ steps\.classify-documentation-changes\.outputs\.files_json \}\}"/, + ); }); test("editing CI Test Plan retriggers every required validation context", async () => { diff --git a/.github/scripts/pr-issue-link-policy.test.mjs b/.github/scripts/pr-issue-link-policy.test.mjs index 69a445cb95..16841abde3 100644 --- a/.github/scripts/pr-issue-link-policy.test.mjs +++ b/.github/scripts/pr-issue-link-policy.test.mjs @@ -276,7 +276,7 @@ test("unrelated labels keep the assignee rejection and point at the exempt label pullRequest: pullRequest({ title: "change (#1228)", body: "Closes #1228", - labels: [{ name: "review-debt-exempt" }, "bug"], + labels: [{ name: "documentation" }, "bug"], }), repository: "Afternote/Afternote-FE", loadIssue: issueLoader(new Map([ diff --git a/.github/scripts/publish-dependency-audit-issues.mjs b/.github/scripts/publish-dependency-audit-issues.mjs index 6d359894c8..fb988fc050 100644 --- a/.github/scripts/publish-dependency-audit-issues.mjs +++ b/.github/scripts/publish-dependency-audit-issues.mjs @@ -271,7 +271,7 @@ export function renderIssueBody(finding) { "", "### 주 담당 모듈", "", - "platform — Android 앱·CI·빌드·릴리스·저장소 운영", + "platform — CI·빌드·릴리스·저장소 운영", "", "### 개요", "", diff --git a/.github/scripts/qa-distribution-decision.mjs b/.github/scripts/qa-distribution-decision.mjs new file mode 100644 index 0000000000..88f41ea71e --- /dev/null +++ b/.github/scripts/qa-distribution-decision.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { sourceShaForDistributionRun, sortDistributionRuns } from "./collect-release-scope-context.mjs"; + +// QA 배포 권고 기준이다. 배포 승인이나 빌드 성공을 뜻하지 않는다. +export const POLICY = Object.freeze({ runtimePullRequests: 5, moduleGroups: 3, pendingDays: 7 }); +const SHA = /^[a-f0-9]{40}$/; +const DAY = 86_400_000; +const DELIVERY_PATH = /^\.github\/(?:workflows\/release-distribution\.ya?ml|actions\/(?:setup-release-config|cleanup-release-config)\/|scripts\/(?:(?:resolve-firebase-version-code|version-code-bands|verify-release-attestation)\.mjs|render-distribution-release-notes\.sh)$)/; +const NEUTRAL_PATH = /^(?:\.github\/|\.agents\/|\.claude\/|\.codex\/|docs\/|konsist\/|baselineprofile\/|scripts\/|\.idea\/)|(?:^|\/)src\/(?:test[^/]*|androidTest[^/]*|screenshotTest[^/]*|debug)\/|(?:^|\/)(?:README[^/]*|AGENTS\.md|CLAUDE\.md|LICENSE[^/]*|\.gitignore|\.editorconfig|\.gitattributes|\.mcp\.json)$|\.(?:md|mdx)$/i; + +class CollectionError extends Error {} + +export function requiresAppQa(file) { + return DELIVERY_PATH.test(file) || !NEUTRAL_PATH.test(file); +} + +function moduleGroup(file) { + if (DELIVERY_PATH.test(file)) return "release"; + const feature = /^feature\/([^/]+)\//.exec(file); + if (feature) return `feature/${feature[1]}`; + if (/^(?:gradle\/|build-logic\/|[^/]*gradle[^/]*$)/.test(file)) return "build"; + return file.split("/")[0]; +} + +export function decideQaDistribution(context, now = Date.now()) { + if (!Number.isFinite(now)) throw new CollectionError("Invalid evaluation time"); + const runtimePaths = context.changedPaths.filter(requiresAppQa); + const result = { + ...context, + evaluatedAt: new Date(now).toISOString(), + policy: POLICY, + runtimePaths, + decision: "hold", + reasons: [], + readyToDistribute: "not_evaluated", + }; + if (runtimePaths.length === 0) { + result.reasons.push("마지막 배포 이후 앱·배포 산출물에 영향을 줄 파일 차이가 없습니다."); + return result; + } + if (context.unmappedCommits?.length || !context.runtimePullRequests?.length) { + result.decision = "unknown"; + result.reasons.push("앱 변경과 머지 PR의 연결을 완전히 확인하지 못했습니다."); + return result; + } + const timestamps = context.runtimePullRequests.map((pr) => Date.parse(pr.mergedAt)); + if (timestamps.some((date) => !Number.isFinite(date) || date > now)) { + throw new CollectionError("Invalid merge time in pending PRs"); + } + result.oldestPendingDays = Math.floor((now - Math.min(...timestamps)) / DAY); + const immediate = context.runtimePullRequests.filter((pr) => /^(?:fix|feat|security)(?:\([^)]*\))?!?:/i.test(pr.title)); + if (immediate.length) { + result.reasons.push(`앱 변경이 남아 있는 기능·버그·보안 PR ${immediate.length}건이 있습니다.`); + } + // 경로로 위험도를 추측하지 않는다. 실제 릴리스 조립 설정 변경은 산출물 QA 대상으로 표시한다. + if (runtimePaths.some((file) => DELIVERY_PATH.test(file))) { + result.reasons.push("실제 Firebase 릴리스 조립 경로가 변경됐습니다."); + } + if (context.runtimePullRequests.length >= POLICY.runtimePullRequests) { + result.reasons.push(`미배포 앱 변경 PR ${context.runtimePullRequests.length}건이 묶음 기준 ${POLICY.runtimePullRequests}건에 도달했습니다.`); + } + result.moduleGroups = [...new Set(runtimePaths.map(moduleGroup))].sort(); + if (result.moduleGroups.length >= POLICY.moduleGroups) { + result.reasons.push(`앱 영향 모듈군 ${result.moduleGroups.length}개가 묶음 기준 ${POLICY.moduleGroups}개에 도달했습니다.`); + } + if (result.oldestPendingDays >= POLICY.pendingDays) { + result.reasons.push(`가장 오래된 미배포 앱 변경이 ${result.oldestPendingDays}일 대기했습니다.`); + } + if (result.reasons.length) result.decision = "deploy"; + else result.reasons.push("앱 변경이 있으나 즉시 권고·누적량·최대 대기 기준에 아직 도달하지 않았습니다."); + return result; +} + +function command(binary, args) { + try { + return execFileSync(binary, args, { encoding: "utf8", maxBuffer: 128 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }).trim(); + } catch (error) { + const status = Number.isInteger(error.status) ? error.status : null; + throw Object.assign(new CollectionError(`${binary === "gh" ? "GitHub API" : "Git history"} read failed (exit ${status ?? "unknown"})`), { status }); + } +} + +function githubApi(endpoint, paginate = false) { + const args = ["api", ...(paginate ? ["--paginate", "--slurp"] : []), endpoint]; + return JSON.parse(command("gh", args)); +} + +function pages(items, field) { + if (!Array.isArray(items)) throw new CollectionError("Missing paginated response"); + return items.flatMap((page) => { + const values = field ? page[field] : page; + if (!Array.isArray(values)) throw new CollectionError("Invalid paginated response"); + return values; + }); +} + +function gitText(args) { + return command("git", args); +} + +function isAncestor(ancestor, head, git) { + if (!SHA.test(ancestor ?? "") || !SHA.test(head ?? "")) throw new CollectionError("Invalid commit SHA"); + try { + git(["merge-base", "--is-ancestor", ancestor, head]); + return true; + } catch (error) { + if (error.status === 1) return false; + throw error; + } +} + +function changedPaths(base, head, git) { + return git(["diff", "--no-renames", "--name-only", "-z", base, head, "--"]).split("\0").filter(Boolean); +} + +function changedBlobs(base, head, git) { + const fields = git(["diff", "--raw", "--no-abbrev", "--no-renames", "-z", base, head, "--"]).split("\0").filter(Boolean); + if (fields.length % 2) throw new CollectionError("Incomplete raw Git diff"); + const entries = []; + for (let i = 0; i < fields.length; i += 2) { + const match = /^:(\d{6}) (\d{6}) ([a-f0-9]{40}) ([a-f0-9]{40}) [AMDT]$/.exec(fields[i]); + if (!match) throw new CollectionError("Unsupported raw Git diff"); + entries.push({ file: fields[i + 1], before: `${match[1]}:${match[3]}`, after: `${match[2]}:${match[4]}` }); + } + return entries; +} + +export async function collectQaDistributionContext({ repository, headSha, api = githubApi, git = gitText }) { + if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? "") || !SHA.test(headSha ?? "")) { + throw new CollectionError("Repository and exact develop SHA are required"); + } + git(["cat-file", "-e", `${headSha}^{commit}`]); + const root = `repos/${repository}`; + const runs = pages(await api(`${root}/actions/workflows/release-distribution.yml/runs?status=success&branch=main&event=push&per_page=100`, true), "workflow_runs"); + const latest = sortDistributionRuns(runs.filter((run) => run.conclusion === "success" && run.head_branch === "main" && run.event === "push"))[0]; + if (!latest) throw new CollectionError("No successful main Firebase distribution baseline"); + const associated = pages(await api(`${root}/commits/${latest.head_sha}/pulls?per_page=100`, true)); + const sourceSha = sourceShaForDistributionRun(latest, associated); + if (!SHA.test(sourceSha ?? "")) throw new CollectionError("Successful distribution has no develop release PR source SHA"); + if (!isAncestor(sourceSha, headSha, git)) throw new CollectionError("Deployed source is not an ancestor of this develop revision"); + const context = { + repository, + headSha, + baseline: { runId: latest.id, sourceSha, mainSha: latest.head_sha, completedAt: latest.updated_at, url: latest.html_url }, + changedPaths: changedPaths(sourceSha, headSha, git), + pendingPullRequestCount: null, + runtimePullRequests: [], + unmappedCommits: [], + }; + const remaining = new Set(context.changedPaths.filter(requiresAppQa)); + if (remaining.size === 0) return context; + + // gh follows every Link page. No 50-PR or 1,000-closed-PR truncation. + const pulls = pages(await api(`${root}/pulls?state=closed&base=develop&sort=updated&direction=desc&per_page=100`, true)); + const firstParentHistory = git(["rev-list", "--first-parent", headSha]).split("\n").filter(Boolean); + const boundary = firstParentHistory.indexOf(sourceSha); + if (boundary < 0) throw new CollectionError("Deployed source is outside develop first-parent history"); + const directCommits = firstParentHistory.slice(0, boundary).reverse(); + const direct = new Set(directCommits); + const pending = pulls.filter((pr) => pr.merged_at && pr.base?.ref === "develop" && direct.has(pr.merge_commit_sha)); + if (new Set(pending.map((pr) => pr.number)).size !== pending.length) throw new CollectionError("Duplicate PRs in paginated response"); + context.pendingPullRequestCount = pending.length; + const byCommit = new Map(pending.map((pr) => [pr.merge_commit_sha, pr])); + const baselineBlobs = new Map(); + const contributors = new Map(); + for (const sha of directCommits) { + if (!SHA.test(sha)) throw new CollectionError("Invalid commit in develop history"); + for (const entry of changedBlobs(`${sha}^1`, sha, git)) { + if (!remaining.has(entry.file)) continue; + if (!baselineBlobs.has(entry.file)) baselineBlobs.set(entry.file, entry.before); + const history = contributors.get(entry.file) ?? []; + // A full return to the deployed blob removes earlier feature/revert history. + contributors.set(entry.file, entry.after === baselineBlobs.get(entry.file) ? [] : [...history, sha]); + } + } + if ([...remaining].some((file) => !contributors.get(file)?.length)) throw new CollectionError("Net app diff has no complete contributing history"); + const pathsByCommit = new Map(); + for (const [file, commits] of contributors) for (const sha of commits) { + pathsByCommit.set(sha, [...(pathsByCommit.get(sha) ?? []), file]); + } + const runtimeByPr = new Map(); + for (const [sha, changed] of pathsByCommit) { + let pr = byCommit.get(sha); + if (!pr) { + // Rebase merges keep several first-parent commits, but merge_commit_sha names only the last. + const related = pages(await api(`${root}/commits/${sha}/pulls?per_page=100`, true)) + .filter((candidate) => candidate.merged_at && candidate.base?.ref === "develop" && direct.has(candidate.merge_commit_sha)); + if (related.length === 1) pr = related[0]; + } + if (!pr) { + context.unmappedCommits.push(sha); + continue; + } + const prior = runtimeByPr.get(pr.number); + runtimeByPr.set(pr.number, { number: pr.number, title: pr.title, mergedAt: pr.merged_at, mergeSha: pr.merge_commit_sha, + paths: [...new Set([...(prior?.paths ?? []), ...changed])] }); + } + context.runtimePullRequests = [...runtimeByPr.values()].sort((left, right) => left.number - right.number); + return context; +} + +const LABELS = { deploy: "배포 필요", hold: "묶음 대기", unknown: "판정 불가" }; + +export function renderQaDistributionSummary(result) { + const lines = [ + `## QA 배포 판정: ${LABELS[result.decision]}`, + "", + ...result.reasons.map((reason) => `- ${reason}`), + "", + `- 판정 대상: \`${result.headSha ?? "확인 실패"}\``, + `- 마지막 배포의 develop SHA: \`${result.baseline?.sourceSha ?? "확인 실패"}\``, + `- 앱 변경 PR: ${result.runtimePullRequests?.length ?? "확인 실패"}건`, + `- 순 변경 파일: ${result.changedPaths?.length ?? "확인 실패"}개`, + "", + "이 결과는 QA 배포 필요성에 대한 권고입니다. 배포 가능 여부는 검사하지 않았습니다. 릴리스 PR의 필수 CI와 QA를 통과한 뒤 main으로 승격합니다.", + "PR 유형과 파일 단위 변경 이력을 사용합니다. 실제 사용자 영향·위험도를 의미 분석하거나 부분 revert의 줄 단위 기여를 확정하지 않습니다.", + "PR·이슈 댓글 작성과 자동 배포는 수행하지 않습니다.", + ]; + if (result.baseline) lines.push("", `[기준 배포 실행](${result.baseline.url})`); + if (result.runtimePullRequests?.length) { + lines.push("", `앱 변경 PR: ${result.runtimePullRequests.map((pr) => `[#${pr.number}](https://github.com/${result.repository}/pull/${pr.number})`).join(", ")}`); + } + return `${lines.join("\n")}\n`; +} + +export async function runQaDistributionDecision({ env = process.env, collect = collectQaDistributionContext, now = Date.now() } = {}) { + const outputDirectory = env.QA_DISTRIBUTION_OUTPUT_DIR; + if (!outputDirectory) throw new CollectionError("QA_DISTRIBUTION_OUTPUT_DIR is required"); + let result; + try { + result = decideQaDistribution(await collect({ repository: env.GITHUB_REPOSITORY, headSha: env.QA_DISTRIBUTION_HEAD_SHA }), now); + } catch (error) { + // gh/git errors may include response bodies. Keep public artifacts free of raw API/token data. + result = { decision: "unknown", headSha: SHA.test(env.QA_DISTRIBUTION_HEAD_SHA ?? "") ? env.QA_DISTRIBUTION_HEAD_SHA : null, + evaluatedAt: new Date(now).toISOString(), readyToDistribute: "not_evaluated", + reasons: [error instanceof CollectionError ? error.message : "배포 기준점 또는 전체 변경 범위 수집에 실패했습니다. API 접근·전체 Git 이력·배포 PR 연결을 확인하세요."] }; + } + await fs.mkdir(outputDirectory, { recursive: true }); + const summary = renderQaDistributionSummary(result); + await fs.writeFile(path.join(outputDirectory, "decision.json"), `${JSON.stringify(result, null, 2)}\n`); + await fs.writeFile(path.join(outputDirectory, "summary.md"), summary); + if (env.GITHUB_STEP_SUMMARY) await fs.appendFile(env.GITHUB_STEP_SUMMARY, summary); + console.log(`QA 배포 판정: ${LABELS[result.decision]}`); + return result; +} + +if (import.meta.url === (process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : "")) { + runQaDistributionDecision().then((result) => { + if (result.decision === "unknown") process.exitCode = 1; + }).catch((error) => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/.github/scripts/qa-distribution-decision.test.mjs b/.github/scripts/qa-distribution-decision.test.mjs new file mode 100644 index 0000000000..c63f8b80ac --- /dev/null +++ b/.github/scripts/qa-distribution-decision.test.mjs @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { collectQaDistributionContext, decideQaDistribution, runQaDistributionDecision } from "./qa-distribution-decision.mjs"; + +const headSha = "a".repeat(40); +const sourceSha = "b".repeat(40); +const mainSha = "c".repeat(40); +const NOW = Date.parse("2026-10-03T00:00:00Z"); +const APP = "feature/setting/presentation/src/main/kotlin/Profile.kt"; + +function context(overrides = {}) { + return { + headSha, + changedPaths: [APP], + runtimePullRequests: [{ number: 1, title: "refactor(setting): profile state", mergedAt: "2026-10-02T00:00:00Z" }], + unmappedCommits: [], + ...overrides, + }; +} + +test("CI and test churn does not recommend an APK, even after many PRs", () => { + const result = decideQaDistribution(context({ + changedPaths: [".github/workflows/lint.yml", "docs/release/distribution.md", "feature/setting/src/test/kotlin/ProfileTest.kt"], + runtimePullRequests: Array.from({ length: 100 }, (_, number) => ({ number, title: "fix(ci): check", mergedAt: "2026-08-01T00:00:00Z" })), + }), NOW); + assert.equal(result.decision, "hold"); +}); + +test("a runtime fix is due, but a data path alone does not claim high risk", () => { + assert.equal(decideQaDistribution(context({ runtimePullRequests: [{ number: 1, title: "fix(setting): restore photo", mergedAt: "2026-10-02T00:00:00Z" }] }), NOW).decision, "deploy"); + const result = decideQaDistribution(context({ changedPaths: ["core/data/src/main/kotlin/Mapper.kt"] }), NOW); + assert.equal(result.decision, "hold"); + assert.equal(result.readyToDistribute, "not_evaluated"); +}); + +test("an old unshipped app change reaches its cap without another merge", () => { + const pending = context({ runtimePullRequests: [{ number: 1, title: "refactor(setting): profile state", mergedAt: "2026-09-26T00:00:00Z" }] }); + assert.equal(decideQaDistribution(pending, NOW - 1).decision, "hold"); + assert.equal(decideQaDistribution(pending, NOW).decision, "deploy"); +}); + +test("the pending app batch is bounded independently from raw commits", () => { + const runtimePullRequests = Array.from({ length: 5 }, (_, number) => ({ number, title: "refactor(setting): simplify", mergedAt: "2026-10-02T00:00:00Z" })); + assert.equal(decideQaDistribution(context({ runtimePullRequests }), NOW).decision, "deploy"); + assert.equal(decideQaDistribution(context({ changedPaths: [APP, "feature/home/src/main/kotlin/Home.kt", "feature/receiver/src/main/kotlin/List.kt"] }), NOW).decision, "deploy"); +}); + +test("unassociated runtime changes cannot masquerade as a small safe batch", () => { + assert.equal(decideQaDistribution(context({ unmappedCommits: [headSha] }), NOW).decision, "unknown"); + assert.equal(decideQaDistribution(context({ runtimePullRequests: [] }), NOW).decision, "unknown"); +}); + +test("invalid or future merge timestamps fail instead of silently holding", () => { + for (const mergedAt of [null, "not-a-date", "2027-01-01T00:00:00Z"]) { + assert.throws(() => decideQaDistribution(context({ runtimePullRequests: [{ title: "fix: change", mergedAt }] }), NOW)); + } +}); + +function collectorFixture(count = 1, netPaths = [APP]) { + const commits = Array.from({ length: count }, (_, i) => (i + 1).toString(16).padStart(40, "0")); + const pulls = commits.map((sha, i) => ({ number: i + 1, title: "refactor(setting): state", merged_at: "2026-10-02T00:00:00Z", base: { ref: "develop" }, merge_commit_sha: sha })); + const calls = []; + async function api(endpoint, paginate) { + assert.equal(paginate, true); + calls.push(endpoint); + if (endpoint.includes("/actions/")) return [{ workflow_runs: [{ id: 7, head_sha: mainSha, head_branch: "main", event: "push", conclusion: "success", updated_at: "2026-09-03T00:00:00Z", html_url: "https://example.com/run" }] }]; + if (endpoint.includes("/commits/")) return [[{ merged_at: "2026-09-03T00:00:00Z", base: { ref: "main" }, head: { ref: "develop", sha: sourceSha } }]]; + return [pulls.slice(0, 50), pulls.slice(50)]; + } + function git(args) { + if (args[0] === "rev-list") return [...commits].reverse().concat(sourceSha).join("\n"); + if (args.includes("--raw")) { + const index = commits.indexOf(args.at(-2)); + return `:100644 100644 ${String(index).padStart(40, "0")} ${String(index + 1).padStart(40, "0")} M\0${APP}\0`; + } + if (args[0] === "diff") return (args.includes(sourceSha) ? netPaths : [APP]).join("\0") + "\0"; + return ""; + } + return { repository: "example/android", headSha, api, git, calls, commits, pulls }; +} + +test("maps a successful main deployment to its develop source and collects over 50 PRs", async () => { + const fixture = collectorFixture(75); + const collected = await collectQaDistributionContext(fixture); + assert.equal(collected.baseline.sourceSha, sourceSha); + assert.equal(collected.baseline.mainSha, mainSha); + assert.equal(collected.pendingPullRequestCount, 75); + assert.equal(collected.runtimePullRequests.length, 75); + assert.equal(collected.unmappedCommits.length, 0); + assert.equal(decideQaDistribution(collected, NOW).decision, "deploy"); +}); + +test("a fully reverted runtime batch has no remaining APK change", async () => { + const fixture = collectorFixture(75, []); + const collected = await collectQaDistributionContext(fixture); + assert.equal(decideQaDistribution(collected, NOW).decision, "hold"); + assert.equal(fixture.calls.length, 2); +}); + +test("a reverted old feature cannot make a fresh same-file refactor immediately due", async () => { + const fixture = collectorFixture(3); + fixture.pulls[0].title = "feat(setting): old feature"; + fixture.pulls[0].merged_at = "2026-09-03T00:00:00Z"; + fixture.pulls[1].title = "revert: remove old feature"; + const git = fixture.git; + fixture.git = (args) => { + if (!args.includes("--raw")) return git(args); + const index = fixture.commits.indexOf(args.at(-2)); + const [before, after] = [[0, 1], [1, 0], [0, 2]][index]; + return `:100644 100644 ${String(before).padStart(40, "0")} ${String(after).padStart(40, "0")} M\0${APP}\0`; + }; + const result = decideQaDistribution(await collectQaDistributionContext(fixture), NOW); + assert.deepEqual(result.runtimePullRequests.map((pr) => pr.number), [3]); + assert.equal(result.decision, "hold"); +}); + +test("all rebase-merged commits resolve to one PR instead of permanent unknown", async () => { + const fixture = collectorFixture(2); + const api = fixture.api; + const rebasedPr = fixture.pulls[1]; + fixture.api = (endpoint, paginate) => { + if (endpoint.includes(`/commits/${fixture.commits[0]}/`)) return [[rebasedPr]]; + if (endpoint.includes("/pulls?state=")) return [[rebasedPr]]; + return api(endpoint, paginate); + }; + const collected = await collectQaDistributionContext(fixture); + assert.deepEqual(collected.unmappedCommits, []); + assert.deepEqual(collected.runtimePullRequests.map((pr) => pr.number), [2]); + assert.equal(decideQaDistribution(collected, NOW).decision, "hold"); +}); + +test("Firebase versionCode producers are release changes, personal tool config is neutral", () => { + for (const file of ["resolve-firebase-version-code.mjs", "version-code-bands.mjs"]) { + assert.equal(decideQaDistribution(context({ changedPaths: [`.github/scripts/${file}`] }), NOW).decision, "deploy"); + } + assert.equal(decideQaDistribution(context({ changedPaths: [".mcp.json"] }), NOW).decision, "hold"); +}); + +test("a divergent deployed baseline is an error, not an empty pending batch", async () => { + const fixture = collectorFixture(); + const git = fixture.git; + fixture.git = (args) => { + if (args[0] === "merge-base") throw Object.assign(new Error("not ancestor"), { status: 1 }); + return git(args); + }; + await assert.rejects(collectQaDistributionContext(fixture), /not an ancestor/); +}); + +test("a pagination failure produces unknown artifacts with no raw API response", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "qa-decision-")); + try { + const fixture = collectorFixture(); + const api = fixture.api; + fixture.api = (endpoint, paginate) => { + if (endpoint.includes("/pulls?state=")) throw new Error("secret response body"); + return api(endpoint, paginate); + }; + const result = await runQaDistributionDecision({ + env: { GITHUB_REPOSITORY: fixture.repository, QA_DISTRIBUTION_HEAD_SHA: headSha, QA_DISTRIBUTION_OUTPUT_DIR: directory }, + collect: () => collectQaDistributionContext(fixture), now: NOW, + }); + assert.equal(result.decision, "unknown"); + const json = await readFile(path.join(directory, "decision.json"), "utf8"); + const summary = await readFile(path.join(directory, "summary.md"), "utf8"); + assert.doesNotMatch(json + summary, /secret response body/); + assert.match(summary, /판정 불가/); + assert.equal(JSON.parse(json).readyToDistribute, "not_evaluated"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("the workflow publishes artifacts with read-only GitHub permissions", async () => { + const workflow = await readFile(new URL("../workflows/qa-distribution-decision.yml", import.meta.url), "utf8"); + assert.doesNotMatch(workflow, /pull_request_target|issues:\s*write|pull-requests:\s*write|contents:\s*write|checks:\s*write/); + assert.match(workflow, /branches: \[develop\]/); + assert.match(workflow, /schedule:/); + assert.match(workflow, /workflow_run:/); + assert.match(workflow, /github.run_attempt == 1/); + assert.doesNotMatch(workflow, /^concurrency:/m); + assert.match(workflow, /^ concurrency:/m); + assert.match(workflow, /if: always\(\)/); + assert.match(workflow, /actions\/upload-artifact@[a-f0-9]{40}/); +}); diff --git a/.github/scripts/reconcile-issue-metadata.mjs b/.github/scripts/reconcile-issue-metadata.mjs index 2df52d3e04..7f6505ff07 100644 --- a/.github/scripts/reconcile-issue-metadata.mjs +++ b/.github/scripts/reconcile-issue-metadata.mjs @@ -40,21 +40,44 @@ export const AREA_LABEL_BY_MODULE = Object.freeze({ timeletter: "area:timeletter", mindrecord: "area:mindrecord", home: "area:home", + app: "area:app", platform: "area:platform", }); export const ASSIGNEE_BY_MODULE = Object.freeze({ afternote: "1hyok", - onboarding: "1hyok", + onboarding: "Sadturtleman", core: "1hyok", receiver: "1hyok", - setting: "koongmai", + setting: "1hyok", timeletter: "koongmai", mindrecord: "Sadturtleman", home: "Sadturtleman", + app: "1hyok", platform: "1hyok", }); +// #1910: 담당 이관은 모듈마다 소급 범위를 고를 수 있다. 경계(fromIssue)를 두면 그 번호 미만의 +// 열린 이슈는 before 가 계속 맡고, 경계가 없는 모듈은 스케줄 리컨사일이 열린 이슈 전체를 새 +// 담당자에게 옮긴다. 온보딩은 #1910 에서 경계를 두었다가 온보딩 전체를 준혁이 맡기로 하면서 +// 경계를 걷어 소급했다(0911). 그 결과 이관 전 이슈로 열어 둔 PR 은 작성자와 담당자가 달라 +// validate-pr-issue-link 가 빨개지고, 리뷰어가 풀어야 한다. +export const HANDOVER_BY_MODULE = Object.freeze({}); + +/** 이관 경계를 반영한 담당자. 경계가 없는 모듈은 지도 그대로다. */ +export function assigneeForIssue(moduleKey, issueNumber) { + const handover = HANDOVER_BY_MODULE[moduleKey]; + if (!handover) { + return ASSIGNEE_BY_MODULE[moduleKey]; + } + const number = Number(issueNumber); + if (!Number.isSafeInteger(number)) { + // 번호를 모르면 경계를 판정할 수 없다. 어느 쪽으로도 접지 않고 실행을 실패시킨다. + throw new Error(`이관 경계를 판정할 이슈 번호가 없습니다: ${moduleKey} / ${issueNumber}`); + } + return number < handover.fromIssue ? handover.before : ASSIGNEE_BY_MODULE[moduleKey]; +} + export const GUARD_COMMENT_MARKER = ""; // #1407: bug 이슈는 조직 이슈 필드 Priority 로 심각도를 지정해야 한다. Issue Form 은 필드 값을 @@ -126,13 +149,13 @@ export function expectedMetadata(issue) { const moduleKey = optionKey(moduleValue); const expectedLabel = TYPE_LABEL_BY_KEY[typeKey]; const expectedAreaLabel = AREA_LABEL_BY_MODULE[moduleKey]; - const expectedAssignee = ASSIGNEE_BY_MODULE[moduleKey]; + const mappedAssignee = ASSIGNEE_BY_MODULE[moduleKey]; const reasons = []; if (!expectedLabel) { reasons.push("`작업 유형`이 없거나 허용된 값이 아닙니다."); } - if (!expectedAssignee || !expectedAreaLabel) { + if (!mappedAssignee || !expectedAreaLabel) { reasons.push("`주 담당 모듈`이 없거나 허용된 값이 아닙니다."); } if (reasons.length > 0) { @@ -144,7 +167,7 @@ export function expectedMetadata(issue) { moduleKey, expectedLabel, expectedAreaLabel, - expectedAssignee, + expectedAssignee: assigneeForIssue(moduleKey, issue.number), }; } diff --git a/.github/scripts/reconcile-issue-metadata.test.mjs b/.github/scripts/reconcile-issue-metadata.test.mjs index 5040944f76..93bbc1be71 100644 --- a/.github/scripts/reconcile-issue-metadata.test.mjs +++ b/.github/scripts/reconcile-issue-metadata.test.mjs @@ -6,11 +6,13 @@ import { AREA_LABEL_BY_MODULE, ASSIGNEE_BY_MODULE, GUARD_COMMENT_MARKER, + HANDOVER_BY_MODULE, LEGACY_ISSUE_MAX, PRIORITY_COMMENT_MARKER, PRIORITY_FIELD_NAME, PRIORITY_OPTION_GUIDE, TYPE_LABELS, + assigneeForIssue, inspectIssue, priorityFieldState, readFormSection, @@ -89,9 +91,18 @@ test("reads exact issue-form sections with CRLF input", () => { assert.equal(readFormSection(body, "주 담당 모듈"), "setting — 선택한 주 담당 모듈"); }); +// 이관 경계 위의 번호. 지도는 앞으로의 담당을 말하므로 경계를 넘긴 이슈로 대조한다. +const AFTER_HANDOVER = Math.max( + LEGACY_ISSUE_MAX, + ...Object.values(HANDOVER_BY_MODULE).map((handover) => handover.fromIssue), +) + 1; + test("maps every primary module to the repository owner table", () => { for (const [module, assignee] of Object.entries(ASSIGNEE_BY_MODULE)) { - const inspection = inspectIssue(issue({ body: formBody("enhancement", module) })); + const inspection = inspectIssue(issue({ + number: AFTER_HANDOVER, + body: formBody("enhancement", module), + })); assert.equal(inspection.status, "valid", module); assert.equal(inspection.expectedAssignee, assignee, module); assert.equal(inspection.expectedLabel, "enhancement", module); @@ -99,6 +110,57 @@ test("maps every primary module to the repository owner table", () => { } }); +test("a module handover leaves issues opened before the decision with the previous assignee", () => { + // #1910: 온보딩은 이 결정 전에 열린 이슈를 옮기지 않는다. 옮기면 그 이슈로 열어 둔 PR 이 + // validate-pr-issue-link 에서 작성자와 담당자가 다르다는 이유로 빨개진다. + for (const [module, handover] of Object.entries(HANDOVER_BY_MODULE)) { + assert.ok(ASSIGNEE_BY_MODULE[module], `${module} 이 지도에 없다`); + assert.notEqual(handover.before, ASSIGNEE_BY_MODULE[module], module); + assert.ok(handover.fromIssue > LEGACY_ISSUE_MAX, `${module} 경계가 legacy 스킵 안에 있다`); + + assert.equal( + assigneeForIssue(module, handover.fromIssue - 1), + handover.before, + module, + ); + assert.equal( + assigneeForIssue(module, handover.fromIssue), + ASSIGNEE_BY_MODULE[module], + module, + ); + // 경계는 이슈 하나가 아니라 판정 전체를 통과해야 한다. + assert.equal( + inspectIssue(issue({ + number: handover.fromIssue - 1, + body: formBody("enhancement", module), + })).expectedAssignee, + handover.before, + module, + ); + } +}); + +test("a module without a handover entry moves its open issues right away", () => { + // 설정은 경계를 두지 않는다. 이미 열려 있는 이슈도 다음 리컨사일에서 새 담당자로 옮겨진다. + assert.equal(HANDOVER_BY_MODULE.setting, undefined); + assert.equal(assigneeForIssue("setting", LEGACY_ISSUE_MAX + 1), ASSIGNEE_BY_MODULE.setting); + const inspection = inspectIssue(issue({ + number: LEGACY_ISSUE_MAX + 1, + body: formBody("enhancement", "setting"), + assignees: [{ login: "koongmai" }], + })); + assert.equal(inspection.needsUpdate, true); + assert.deepEqual(inspection.assignees, [ASSIGNEE_BY_MODULE.setting]); +}); + +test("an unknown issue number fails the handover judgement instead of guessing", () => { + // 경계 판정을 못 하면 어느 쪽으로도 접지 않는다. 접으면 옛 이슈가 조용히 새 담당자에게 간다. + for (const module of Object.keys(HANDOVER_BY_MODULE)) { + assert.throws(() => assigneeForIssue(module, undefined), /이슈 번호/); + assert.throws(() => assigneeForIssue(module, "1910번"), /이슈 번호/); + } +}); + test("maps every work type to exactly one classification label", () => { for (const label of TYPE_LABELS) { const inspection = inspectIssue(issue({ body: formBody(label, "core") })); diff --git a/.github/scripts/recover-base-deleted-pull-requests.mjs b/.github/scripts/recover-base-deleted-pull-requests.mjs new file mode 100644 index 0000000000..3a1631225a --- /dev/null +++ b/.github/scripts/recover-base-deleted-pull-requests.mjs @@ -0,0 +1,426 @@ +#!/usr/bin/env node + +// base 브랜치 삭제로 자동 폐쇄된 PR 을 되살린다 (#2076). +// +// 브랜치를 지우면 그 브랜치를 base 로 하던 열린 PR 이 GitHub 정책에 따라 즉시 닫힌다. 닫는 +// 코멘트도 리뷰도 없이 base_ref_deleted 다음 closed 두 이벤트만 남는다. 2026-09-11 22:17:52Z 에 +// feat/1762-draft-resume 가 지워지며 1초 뒤 승인까지 받은 #1626 이 그렇게 사라졌고, 그 위에 +// 쌓여 있던 일곱 건이 develop 에 도달할 경로를 잃은 채 이틀 동안 아무에게도 보이지 않았다. +// +// 판정 기준은 "닫힌 시각이 삭제와 가깝다" 가 아니라 "그 닫힘의 원인이 base 삭제다" 이다. PR +// 타임라인에서 마지막 closed 바로 앞에 base_ref_deleted 가 붙어 있는 경우만 되살린다. 사람이 +// 손으로 닫은 PR, 이미 머지된 PR, 다른 base 의 PR 은 어느 것도 이 서명을 갖지 않는다. +// +// 되살릴 수 없는 경우(같은 이름의 브랜치가 다시 올라오지 않아 base 가 없는 경우)에는 조용히 +// 지나가지 않고 추적 이슈를 열어 끊긴 사슬을 기록한다. + +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +/** base_ref_deleted 와 closed 사이의 허용 간격. 실측은 1초였다. */ +export const AUTO_CLOSE_WINDOW_SECONDS = 120; +/** 이번 삭제와 무관한 옛 폐쇄를 되살리지 않도록 두는 조회 창. */ +export const RECENT_CLOSE_WINDOW_MINUTES = 60; +export const REOPEN_MARKER_PREFIX = "`; +} + +export function trackingMarker(ref) { + return `${TRACKING_MARKER_PREFIX}${ref} -->`; +} + +function parseTime(value) { + const parsed = Date.parse(String(value ?? "")); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * 이 PR 이 이번 base 삭제로 닫혔는가. + * + * 네 가지를 모두 만족해야 한다. base 가 지워진 그 브랜치이고, 머지되지 않았고, 지금 닫혀 있고, + * 마지막 closed 이벤트 바로 앞이 base_ref_deleted 다. 마지막 조건이 이 판정의 본체다. 나머지 + * 셋만으로는 브랜치를 지우기 직전에 사람이 손으로 닫은 PR 과 구별되지 않는다. + */ +export function autoClosedByBaseDeletion({ pullRequest, timeline = [], ref, observedAt }) { + const observed = parseTime(observedAt); + if (observed === null) { + throw new Error("observedAt 이 ISO 8601 시각이어야 합니다."); + } + if (pullRequest?.base?.ref !== ref) { + return { eligible: false, reason: `base 가 ${pullRequest?.base?.ref ?? "없음"}` }; + } + if (pullRequest.merged_at) { + return { eligible: false, reason: "이미 머지된 PR" }; + } + if (pullRequest.state !== "closed") { + return { eligible: false, reason: `상태가 ${pullRequest.state}` }; + } + + const closedAt = parseTime(pullRequest.closed_at); + if (closedAt === null) { + return { eligible: false, reason: "closed_at 이 없음" }; + } + if (closedAt < observed - RECENT_CLOSE_WINDOW_MINUTES * 60_000) { + return { eligible: false, reason: "이번 삭제와 무관한 옛 폐쇄" }; + } + + // committed 같은 일부 타임라인 항목에는 created_at 이 없다. 시각으로 줄을 세울 수 없는 + // 항목은 순서 판정에서 뺀다. + const events = timeline + .filter((item) => item && typeof item.event === "string" && parseTime(item.created_at) !== null) + .map((item) => ({ event: item.event, at: parseTime(item.created_at) })) + .sort((left, right) => left.at - right.at); + + const closedIndex = events.findLastIndex((item) => item.event === "closed"); + if (closedIndex < 0) { + return { eligible: false, reason: "closed 이벤트가 없음" }; + } + const deletion = events[closedIndex - 1]; + if (!deletion || deletion.event !== "base_ref_deleted") { + return { eligible: false, reason: "base 삭제가 아닌 폐쇄" }; + } + if (events[closedIndex].at - deletion.at > AUTO_CLOSE_WINDOW_SECONDS * 1000) { + return { eligible: false, reason: "base 삭제와 폐쇄 사이가 너무 멀다" }; + } + + return { eligible: true, reason: "base 삭제로 자동 폐쇄됨" }; +} + +export function renderReopenComment({ ref, deletedBy, runUrl }) { + return [ + reopenMarker(ref), + "### base 브랜치 삭제로 닫혔던 PR 을 다시 열었다", + "", + `base 브랜치 \`${ref}\` 가 삭제되면서 이 PR 이 GitHub 정책에 따라 자동으로 닫혔다. 그 닫힘에는 리뷰도 코멘트도 남지 않는다.`, + "같은 이름의 브랜치가 다시 올라와 base 가 살아 있어 이 워크플로가 PR 을 다시 열었다.", + "", + "리뷰 승인과 스택 연결은 닫히기 전 그대로가 아닐 수 있다. 머지 전에 base 와 스택 순서를 확인한다.", + "이 reopen 은 GITHUB_TOKEN 이 했다. 그 이벤트로는 새 워크플로 run 이 만들어지지 않으니, reopened 에 걸린 알림이나 체크가 필요하면 커밋을 다시 밀거나 run 을 재실행한다.", + "", + `삭제한 사람: @${deletedBy}`, + `복구 run: ${runUrl}`, + ].join("\n"); +} + +function renderLostPullRequest({ pullRequest, dependents }) { + const lines = [ + `- #${pullRequest.number} ${pullRequest.title}`, + ` - head \`${pullRequest.head?.ref ?? "?"}\` · 닫힌 시각 \`${pullRequest.closed_at}\``, + ` - 이 PR 이 기록한 base SHA \`${pullRequest.base?.sha ?? "?"}\``, + ]; + if (dependents.length > 0) { + lines.push(` - 이 PR 의 head 를 base 로 하던 열린 PR: ${dependents.map((number) => `#${number}`).join(", ")}`); + } + return lines; +} + +export function renderTrackingIssue({ ref, repository, deletedBy, runUrl, lost }) { + const numbers = lost.map(({ pullRequest }) => `#${pullRequest.number}`).join(", "); + const restoreSha = lost[0]?.pullRequest?.base?.sha ?? "<복구할 SHA>"; + const reopenCommands = lost.map( + ({ pullRequest }) => `gh pr reopen ${pullRequest.number} --repo ${repository}`, + ); + + return { + title: `maintenance(platform): 삭제된 base 브랜치 ${ref} 의 PR 을 되살리지 못했다`, + labels: TRACKING_ISSUE_LABELS, + body: [ + trackingMarker(ref), + "### 작업 유형", + "", + TRACKING_ISSUE_TYPE_OPTION, + "", + "### 주 담당 모듈", + "", + TRACKING_ISSUE_MODULE_OPTION, + "", + "### 개요", + "", + `브랜치 \`${ref}\` 가 삭제되면서 이 브랜치를 base 로 하던 PR 이 자동으로 닫혔다. 대상: ${numbers}`, + "같은 이름의 브랜치가 다시 올라오지 않아 base 가 없고, base 없이는 reopen 이 거절된다. 그래서 자동 복구 대신 이 기록을 남긴다.", + "", + "#### 끊긴 지점", + "", + ...lost.flatMap(renderLostPullRequest), + "", + "#### 되살리는 법", + "", + "1. 같은 이름으로 브랜치를 다시 올린다. 아래 SHA 는 닫힌 PR 이 마지막으로 기록한 base 라 브랜치의 최종 상태보다 뒤처져 있을 수 있으니 먼저 확인한다.", + "", + "```", + `git push origin ${restoreSha}:refs/heads/${ref}`, + "```", + "", + "2. 닫힌 PR 을 다시 연다.", + "", + "```", + ...reopenCommands, + "```", + "", + "3. 스택이 있었다면 `gh stack link --base develop <아래 PR> <위 PR>` 로 순서를 다시 건다.", + "", + "#### 완료 조건", + "", + "닫힌 PR 이 다시 열려 원래 사슬로 돌아가거나, 되살리지 않기로 하고 그 결정이 이 이슈에 남는다.", + "", + "### 참고", + "", + `삭제한 사람: @${deletedBy}`, + `감지 run: ${runUrl}`, + ].join("\n"), + }; +} + +export function createApi(token, { fetchImpl = globalThis.fetch } = {}) { + if (typeof fetchImpl !== "function") { + throw new TypeError("fetch 구현이 필요합니다."); + } + + return async function api(apiPath, { method = "GET", body } = {}) { + const response = await fetchImpl(`https://api.github.com${apiPath}`, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "x-github-api-version": "2022-11-28", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!response.ok) { + const detail = await response.text(); + const error = new Error(`GitHub API ${method} ${apiPath} 실패: ${response.status} ${detail}`); + error.status = response.status; + throw error; + } + if (response.status === 204) return null; + return response.json(); + }; +} + +function encodeRef(ref) { + return ref.split("/").map(encodeURIComponent).join("/"); +} + +// unref 를 걸면 이 타이머가 이벤트 루프를 붙잡지 않아 대기 중에 프로세스가 조용히 끝난다. +const defaultSleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +export async function fetchPullRequestsForBase(api, repository, ref) { + // 방금 닫힌 PR 이 앞에 오도록 최근 갱신순으로 받는다. + return api( + `/repos/${repository}/pulls?state=all&base=${encodeRef(ref)}&per_page=100&sort=updated&direction=desc`, + ); +} + +export async function fetchTimeline(api, repository, number) { + const events = []; + for (let page = 1; page <= TIMELINE_PAGE_LIMIT; page += 1) { + const batch = await api(`/repos/${repository}/issues/${number}/timeline?per_page=100&page=${page}`); + events.push(...batch); + if (batch.length < 100) break; + } + return events; +} + +/** 이 PR 의 head 를 base 로 하던 열린 PR. 삭제로 끊긴 사슬의 위쪽이다. */ +export async function fetchDependents(api, repository, headRef) { + if (!headRef) return []; + const dependents = await api(`/repos/${repository}/pulls?state=open&base=${encodeRef(headRef)}&per_page=100`); + return dependents.map((pullRequest) => pullRequest.number); +} + +/** + * base 삭제로 닫힌 PR 을 모은다. + * + * 삭제 이벤트가 자동 폐쇄보다 먼저 도착할 수 있다. 같은 base 의 PR 이 아직 열려 있는 동안에는 + * 폐쇄가 아직 반영되지 않았다고 보고 짧게 다시 본다. + */ +export async function collectAutoClosedPullRequests({ + api, + repository, + ref, + observedAt, + sleep = defaultSleep, + logger = console, +}) { + let lost = []; + for (let attempt = 1; attempt <= AUTO_CLOSE_SETTLE_ATTEMPTS; attempt += 1) { + const pullRequests = await fetchPullRequestsForBase(api, repository, ref); + lost = []; + for (const pullRequest of pullRequests) { + if (pullRequest.base?.ref !== ref || pullRequest.state !== "closed" || pullRequest.merged_at) { + continue; + } + const timeline = await fetchTimeline(api, repository, pullRequest.number); + const verdict = autoClosedByBaseDeletion({ pullRequest, timeline, ref, observedAt }); + logger.log(`#${pullRequest.number}: ${verdict.reason}`); + if (verdict.eligible) { + lost.push({ pullRequest, dependents: await fetchDependents(api, repository, pullRequest.head?.ref) }); + } + } + + const stillOpen = pullRequests.filter( + (pullRequest) => pullRequest.base?.ref === ref && pullRequest.state === "open", + ); + if (lost.length > 0 || stillOpen.length === 0 || attempt === AUTO_CLOSE_SETTLE_ATTEMPTS) { + break; + } + logger.log(`base \`${ref}\` 의 PR ${stillOpen.length}건이 아직 열려 있다. 자동 폐쇄 반영을 기다린다`); + await sleep(AUTO_CLOSE_SETTLE_DELAY_MS); + } + return lost; +} + +/** + * base 가 같은 이름으로 다시 올라왔는가. + * + * 이름을 옮기는 중이었다면 삭제 직후에 같은 이름이 다시 밀린다. 그 push 가 이 워크플로보다 + * 늦을 수 있어 한 번만 보고 없다고 판정하지 않는다. 404 가 아닌 실패는 그대로 던진다. + */ +export async function waitForBaseRef({ + api, + repository, + ref, + attempts = BASE_REF_RESTORE_ATTEMPTS, + delayMs = BASE_REF_RESTORE_DELAY_MS, + sleep = defaultSleep, + logger = console, +}) { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + await api(`/repos/${repository}/git/ref/heads/${encodeRef(ref)}`); + return true; + } catch (error) { + if (error.status !== 404) throw error; + } + if (attempt < attempts) { + logger.log(`base \`${ref}\` 가 아직 없다. 다시 올라오기를 기다린다 (${attempt}/${attempts})`); + await sleep(delayMs); + } + } + return false; +} + +async function findTrackingIssue(api, repository, ref) { + const marker = trackingMarker(ref); + for (let page = 1; page <= ISSUE_PAGE_LIMIT; page += 1) { + const batch = await api(`/repos/${repository}/issues?state=open&per_page=100&page=${page}`); + const found = batch.find((issue) => !issue.pull_request && String(issue.body ?? "").includes(marker)); + if (found) return found; + if (batch.length < 100) break; + } + return null; +} + +export async function recoverBaseDeletedPullRequests({ + api, + repository, + ref, + refType, + deletedBy, + runUrl, + observedAt = new Date().toISOString(), + sleep = defaultSleep, + logger = console, + dryRun = false, +}) { + if (refType !== "branch") { + logger.log(`${refType} 삭제는 대상이 아니다`); + return { action: "none", why: "ref-type" }; + } + + const lost = await collectAutoClosedPullRequests({ api, repository, ref, observedAt, sleep, logger }); + if (lost.length === 0) { + logger.log(`base \`${ref}\` 삭제로 닫힌 PR 없음`); + return { action: "none", why: "no-auto-closed-pull-request" }; + } + + const numbers = lost.map(({ pullRequest }) => pullRequest.number); + const baseRestored = await waitForBaseRef({ api, repository, ref, sleep, logger }); + if (dryRun) { + return { action: baseRestored ? "reopen" : "track", dryRun: true, pullRequests: numbers }; + } + + if (baseRestored) { + const comment = renderReopenComment({ ref, deletedBy, runUrl }); + for (const number of numbers) { + // 코멘트보다 reopen 이 먼저다. 코멘트가 실패해도 PR 은 열려 있어야 한다. + await api(`/repos/${repository}/pulls/${number}`, { method: "PATCH", body: { state: "open" } }); + await api(`/repos/${repository}/issues/${number}/comments`, { method: "POST", body: { body: comment } }); + logger.log(`#${number} 를 다시 열고 경위를 남겼다`); + } + return { action: "reopen", pullRequests: numbers }; + } + + const existing = await findTrackingIssue(api, repository, ref); + const issue = renderTrackingIssue({ ref, repository, deletedBy, runUrl, lost }); + if (existing) { + await api(`/repos/${repository}/issues/${existing.number}`, { + method: "PATCH", + body: { title: issue.title, body: issue.body }, + }); + logger.log(`추적 이슈 #${existing.number} 를 갱신했다`); + return { action: "track", issueNumber: existing.number, pullRequests: numbers, reused: true }; + } + + const created = await api(`/repos/${repository}/issues`, { + method: "POST", + body: { title: issue.title, body: issue.body, labels: issue.labels }, + }); + logger.log(`추적 이슈 #${created.number} 를 열었다`); + return { action: "track", issueNumber: created.number, pullRequests: numbers }; +} + +async function main() { + const token = process.env.GITHUB_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + const ref = process.env.DELETED_REF; + const refType = process.env.DELETED_REF_TYPE; + if (!token || !repository || !ref || !refType) { + throw new Error("GITHUB_TOKEN·GITHUB_REPOSITORY·DELETED_REF·DELETED_REF_TYPE 가 필요합니다."); + } + + const result = await recoverBaseDeletedPullRequests({ + api: createApi(token), + repository, + ref, + refType, + deletedBy: process.env.DELETED_BY || "unknown", + runUrl: process.env.RUN_URL || "", + dryRun: process.env.DRY_RUN === "true", + }); + + const summary = `base-delete-recovery: ${ref} → ${result.action}${ + result.pullRequests?.length ? ` (${result.pullRequests.map((number) => `#${number}`).join(", ")})` : "" + }${result.issueNumber ? ` 추적 이슈 #${result.issueNumber}` : ""}`; + console.log(summary); + if (process.env.GITHUB_STEP_SUMMARY) { + const { appendFile } = await import("node:fs/promises"); + await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedPath) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/recover-base-deleted-pull-requests.test.mjs b/.github/scripts/recover-base-deleted-pull-requests.test.mjs new file mode 100644 index 0000000000..5d90d5e046 --- /dev/null +++ b/.github/scripts/recover-base-deleted-pull-requests.test.mjs @@ -0,0 +1,471 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { ASSIGNEE_BY_MODULE, inspectIssue } from "./reconcile-issue-metadata.mjs"; +import { + RECENT_CLOSE_WINDOW_MINUTES, + TRACKING_ISSUE_LABELS, + TRACKING_ISSUE_MODULE_OPTION, + TRACKING_ISSUE_TYPE_OPTION, + autoClosedByBaseDeletion, + recoverBaseDeletedPullRequests, + renderReopenComment, + renderTrackingIssue, + reopenMarker, + trackingMarker, + waitForBaseRef, +} from "./recover-base-deleted-pull-requests.mjs"; + +const REPO = "Afternote/Afternote-FE"; +const REF = "feat/1762-draft-resume"; +// 2026-09-11 실사고의 시각. 삭제 1초 뒤에 closed 가 붙었다. +const DELETED_AT = "2026-09-11T22:17:52Z"; +const CLOSED_AT = "2026-09-11T22:17:53Z"; +const OBSERVED_AT = "2026-09-11T22:18:30Z"; +const RUN_URL = `https://github.com/${REPO}/actions/runs/900`; +const silent = { log() {} }; +const noWait = async () => {}; + +function timeline(...events) { + return events.map(([event, created_at]) => ({ event, created_at })); +} + +const AUTO_CLOSED_TIMELINE = timeline( + ["assigned", "2026-09-11T07:27:16Z"], + ["added_to_stack", "2026-09-11T08:00:46Z"], + // committed 항목에는 created_at 이 없다. 순서 판정이 여기서 멈추면 안 된다. + ["committed", undefined], + ["base_ref_deleted", DELETED_AT], + ["closed", CLOSED_AT], +); + +function lostPullRequest(overrides = {}) { + return { + number: 1626, + title: "feat: 임시 저장 이어쓰기", + state: "closed", + merged_at: null, + closed_at: CLOSED_AT, + base: { ref: REF, sha: "1d16dcd67e2ab4f1c0d5f9a8b7c6d5e4f3a2b1c0" }, + head: { ref: "feat/1762-draft-resume-editor" }, + ...overrides, + }; +} + +function fakeApi({ + pullRequests = [], + timelines = {}, + dependents = {}, + baseRefExists = false, + baseRefError, + issues = [], +} = {}) { + const calls = []; + const api = async (apiPath, options = {}) => { + const method = options.method ?? "GET"; + calls.push({ apiPath, method, body: options.body }); + + const timelineMatch = /\/issues\/(\d+)\/timeline/.exec(apiPath); + if (timelineMatch) return timelines[timelineMatch[1]] ?? []; + if (method === "GET" && apiPath.startsWith(`/repos/${REPO}/pulls?state=all`)) return pullRequests; + if (method === "GET" && apiPath.startsWith(`/repos/${REPO}/pulls?state=open&base=`)) { + const base = decodeURIComponent(/base=([^&]+)/.exec(apiPath)[1]); + return (dependents[base] ?? []).map((number) => ({ number })); + } + if (apiPath.startsWith(`/repos/${REPO}/git/ref/heads/`)) { + if (baseRefError) throw baseRefError; + if (baseRefExists) return { ref: `refs/heads/${REF}` }; + const notFound = new Error("Not Found"); + notFound.status = 404; + throw notFound; + } + if (method === "PATCH" && /\/pulls\/\d+$/.test(apiPath)) return { number: 1626, state: "open" }; + if (method === "POST" && /\/issues\/\d+\/comments$/.test(apiPath)) return { id: 7 }; + if (method === "GET" && apiPath.startsWith(`/repos/${REPO}/issues?state=open`)) return issues; + if (method === "POST" && apiPath === `/repos/${REPO}/issues`) return { number: 2099 }; + if (method === "PATCH" && /\/issues\/\d+$/.test(apiPath)) return { number: 2050 }; + throw new Error(`unexpected ${method} ${apiPath}`); + }; + api.calls = calls; + api.writes = () => calls.filter((call) => call.method !== "GET"); + return api; +} + +function recover(api, overrides = {}) { + return recoverBaseDeletedPullRequests({ + api, + repository: REPO, + ref: REF, + refType: "branch", + deletedBy: "1hyok", + runUrl: RUN_URL, + observedAt: OBSERVED_AT, + sleep: noWait, + logger: silent, + ...overrides, + }); +} + +// ---- 판정: base_ref_deleted 다음 closed 라는 서명 (#2076) ---- + +test("base 삭제 직후 닫힌 미병합 PR 만 되살릴 대상이다", () => { + const verdict = autoClosedByBaseDeletion({ + pullRequest: lostPullRequest(), + timeline: AUTO_CLOSED_TIMELINE, + ref: REF, + observedAt: OBSERVED_AT, + }); + + assert.equal(verdict.eligible, true); +}); + +test("이미 머지된 PR 의 base 삭제는 정상 정리다. 되살리지 않는다", () => { + // 머지 뒤 브랜치를 지우는 일상적인 청소가 이 워크플로를 깨우지 않아야 한다. + const merged = lostPullRequest({ + merged_at: "2026-09-11T20:00:00Z", + closed_at: "2026-09-11T20:00:00Z", + }); + const verdict = autoClosedByBaseDeletion({ + pullRequest: merged, + timeline: timeline( + ["merged", "2026-09-11T20:00:00Z"], + ["closed", "2026-09-11T20:00:00Z"], + ["base_ref_deleted", DELETED_AT], + ), + ref: REF, + observedAt: OBSERVED_AT, + }); + + assert.equal(verdict.eligible, false); + assert.match(verdict.reason, /머지/); +}); + +test("사람이 닫은 PR 은 그 뒤에 브랜치가 지워져도 되살리지 않는다", () => { + const verdict = autoClosedByBaseDeletion({ + pullRequest: lostPullRequest({ closed_at: "2026-09-11T22:17:40Z" }), + timeline: timeline(["closed", "2026-09-11T22:17:40Z"], ["base_ref_deleted", DELETED_AT]), + ref: REF, + observedAt: OBSERVED_AT, + }); + + assert.equal(verdict.eligible, false); + assert.match(verdict.reason, /base 삭제가 아닌 폐쇄/); +}); + +test("다른 base 의 PR, 아직 열린 PR, 창 밖의 옛 폐쇄는 모두 대상이 아니다", () => { + const cases = [ + [lostPullRequest({ base: { ref: "develop", sha: "0" } }), AUTO_CLOSED_TIMELINE, /base 가 develop/], + [lostPullRequest({ state: "open", closed_at: null }), AUTO_CLOSED_TIMELINE, /상태가 open/], + [ + lostPullRequest({ closed_at: "2026-09-11T20:00:00Z" }), + timeline(["base_ref_deleted", "2026-09-11T19:59:59Z"], ["closed", "2026-09-11T20:00:00Z"]), + /옛 폐쇄/, + ], + ]; + + for (const [pullRequest, events, reason] of cases) { + const verdict = autoClosedByBaseDeletion({ + pullRequest, + timeline: events, + ref: REF, + observedAt: OBSERVED_AT, + }); + assert.equal(verdict.eligible, false, reason.source); + assert.match(verdict.reason, reason); + } + assert.equal(RECENT_CLOSE_WINDOW_MINUTES, 60); +}); + +test("base 삭제와 폐쇄 사이가 벌어져 있으면 같은 원인으로 보지 않는다", () => { + const verdict = autoClosedByBaseDeletion({ + pullRequest: lostPullRequest({ closed_at: "2026-09-11T22:25:00Z" }), + timeline: timeline(["base_ref_deleted", DELETED_AT], ["closed", "2026-09-11T22:25:00Z"]), + ref: REF, + observedAt: OBSERVED_AT, + }); + + assert.equal(verdict.eligible, false); + assert.match(verdict.reason, /너무 멀다/); +}); + +// ---- 분기 1: base 가 살아 있으면 다시 열고 경위를 남긴다 ---- + +test("base 가 같은 이름으로 돌아와 있으면 PR 을 다시 열고 경위 코멘트를 남긴다", async () => { + const api = fakeApi({ + pullRequests: [lostPullRequest()], + timelines: { 1626: AUTO_CLOSED_TIMELINE }, + dependents: { "feat/1762-draft-resume-editor": [1839, 1837] }, + baseRefExists: true, + }); + + const result = await recover(api); + + assert.equal(result.action, "reopen"); + assert.deepEqual(result.pullRequests, [1626]); + const writes = api.writes(); + assert.equal(writes.length, 2); + // reopen 이 먼저다. 코멘트가 실패해도 PR 은 열려 있어야 한다. + assert.deepEqual(writes[0], { + apiPath: `/repos/${REPO}/pulls/1626`, + method: "PATCH", + body: { state: "open" }, + }); + assert.equal(writes[1].apiPath, `/repos/${REPO}/issues/1626/comments`); + assert.ok(writes[1].body.body.startsWith(reopenMarker(REF))); + assert.match(writes[1].body.body, /base 브랜치 `feat\/1762-draft-resume` 가 삭제되면서/); + assert.match(writes[1].body.body, /@1hyok/); + assert.ok(writes[1].body.body.includes(RUN_URL)); + assert.ok(!api.calls.some((call) => call.method === "POST" && call.apiPath === `/repos/${REPO}/issues`)); +}); + +test("삭제 이벤트가 자동 폐쇄보다 먼저 도착하면 반영을 기다렸다가 다시 본다", async () => { + let attempt = 0; + const api = fakeApi({ + timelines: { 1626: AUTO_CLOSED_TIMELINE }, + baseRefExists: true, + }); + const listing = api; + const wrapped = async (apiPath, options) => { + if ((options?.method ?? "GET") === "GET" && apiPath.startsWith(`/repos/${REPO}/pulls?state=all`)) { + attempt += 1; + listing.calls.push({ apiPath, method: "GET" }); + return attempt === 1 ? [lostPullRequest({ state: "open", closed_at: null })] : [lostPullRequest()]; + } + return listing(apiPath, options); + }; + wrapped.calls = listing.calls; + + const result = await recoverBaseDeletedPullRequests({ + api: wrapped, + repository: REPO, + ref: REF, + refType: "branch", + deletedBy: "1hyok", + runUrl: RUN_URL, + observedAt: OBSERVED_AT, + sleep: noWait, + logger: silent, + }); + + assert.equal(attempt, 2); + assert.equal(result.action, "reopen"); +}); + +// ---- 분기 2: base 가 없으면 추적 이슈로 기록한다 ---- + +test("base 가 돌아오지 않으면 되살리는 대신 끊긴 사슬을 이슈로 남긴다", async () => { + const api = fakeApi({ + pullRequests: [lostPullRequest()], + timelines: { 1626: AUTO_CLOSED_TIMELINE }, + dependents: { "feat/1762-draft-resume-editor": [1839, 1837] }, + baseRefExists: false, + }); + + const result = await recover(api); + + assert.equal(result.action, "track"); + assert.equal(result.issueNumber, 2099); + const writes = api.writes(); + assert.equal(writes.length, 1); + assert.equal(writes[0].apiPath, `/repos/${REPO}/issues`); + assert.deepEqual(writes[0].body.labels, TRACKING_ISSUE_LABELS); + assert.ok(writes[0].body.body.startsWith(trackingMarker(REF))); + assert.match(writes[0].body.title, /삭제된 base 브랜치 feat\/1762-draft-resume/); + assert.match(writes[0].body.body, /#1626/); + assert.match(writes[0].body.body, /이 PR 의 head 를 base 로 하던 열린 PR: #1839, #1837/); + assert.match(writes[0].body.body, /git push origin 1d16dcd67e2ab4f1c0d5f9a8b7c6d5e4f3a2b1c0:refs\/heads\/feat\/1762-draft-resume/); + assert.match(writes[0].body.body, /gh pr reopen 1626 --repo Afternote\/Afternote-FE/); + assert.ok(!api.calls.some((call) => call.method === "PATCH" && call.apiPath.includes("/pulls/"))); +}); + +test("같은 브랜치의 추적 이슈가 이미 열려 있으면 새로 만들지 않고 갱신한다", async () => { + const api = fakeApi({ + pullRequests: [lostPullRequest()], + timelines: { 1626: AUTO_CLOSED_TIMELINE }, + baseRefExists: false, + issues: [ + { number: 2050, body: `${trackingMarker(REF)}\n옛 기록` }, + { number: 2051, body: trackingMarker("feat/other"), pull_request: {} }, + ], + }); + + const result = await recover(api); + + assert.equal(result.action, "track"); + assert.equal(result.issueNumber, 2050); + assert.equal(result.reused, true); + const writes = api.writes(); + assert.equal(writes.length, 1); + assert.equal(writes[0].method, "PATCH"); + assert.equal(writes[0].apiPath, `/repos/${REPO}/issues/2050`); +}); + +test("base 조회가 404 가 아닌 이유로 실패하면 조용히 추적 이슈로 흘러가지 않는다", async () => { + const serverError = new Error("Server Error"); + serverError.status = 500; + const api = fakeApi({ + pullRequests: [lostPullRequest()], + timelines: { 1626: AUTO_CLOSED_TIMELINE }, + baseRefError: serverError, + }); + + await assert.rejects(() => recover(api), /Server Error/); + assert.deepEqual(api.writes(), []); +}); + +test("base 는 여러 번 확인한다. 이름을 옮기는 push 가 삭제보다 늦게 도착한다", async () => { + let attempt = 0; + const api = async () => { + attempt += 1; + if (attempt < 3) { + const notFound = new Error("Not Found"); + notFound.status = 404; + throw notFound; + } + return { ref: `refs/heads/${REF}` }; + }; + + assert.equal( + await waitForBaseRef({ api, repository: REPO, ref: REF, sleep: noWait, logger: silent }), + true, + ); + assert.equal(attempt, 3); +}); + +// ---- 정상 정리에는 반응하지 않는다 ---- + +test("머지된 PR 의 base 브랜치를 지우는 정상 정리에는 아무것도 하지 않는다", async () => { + const api = fakeApi({ + pullRequests: [ + lostPullRequest({ number: 1600, merged_at: "2026-09-11T20:00:00Z", closed_at: "2026-09-11T20:00:00Z" }), + ], + baseRefExists: true, + }); + + const result = await recover(api); + + assert.equal(result.action, "none"); + assert.equal(result.why, "no-auto-closed-pull-request"); + assert.deepEqual(api.writes(), []); + // 머지된 PR 은 타임라인을 물어볼 필요도 없다. + assert.ok(!api.calls.some((call) => call.apiPath.includes("/timeline"))); +}); + +test("태그 삭제와 base 가 다른 PR 만 있는 삭제에는 아무것도 하지 않는다", async () => { + const tagApi = fakeApi(); + assert.equal((await recover(tagApi, { refType: "tag" })).action, "none"); + assert.deepEqual(tagApi.calls, []); + + const otherBaseApi = fakeApi({ + pullRequests: [lostPullRequest({ number: 1700, base: { ref: "develop", sha: "0" } })], + }); + assert.equal((await recover(otherBaseApi)).action, "none"); + assert.deepEqual(otherBaseApi.writes(), []); +}); + +// ---- 문구 ---- + +test("경위 코멘트는 마커로 시작하고 닫힌 원인과 확인할 것을 적는다", () => { + const body = renderReopenComment({ ref: REF, deletedBy: "1hyok", runUrl: RUN_URL }); + + assert.ok(body.startsWith(reopenMarker(REF))); + assert.match(body, /자동으로 닫혔다/); + assert.match(body, /머지 전에 base 와 스택 순서를 확인한다/); + // GITHUB_TOKEN 이 일으킨 이벤트로는 새 run 이 만들어지지 않는다. 그 사실을 읽는 사람에게 남긴다. + assert.match(body, /새 워크플로 run 이 만들어지지 않으니/); +}); + +test("추적 이슈는 복구 명령과 사슬을 함께 적는다", () => { + const issue = renderTrackingIssue({ + ref: REF, + repository: REPO, + deletedBy: "1hyok", + runUrl: RUN_URL, + lost: [{ pullRequest: lostPullRequest(), dependents: [1839] }], + }); + + assert.match(issue.title, /^maintenance\(platform\): /); + assert.ok(issue.body.startsWith(trackingMarker(REF))); + assert.match(issue.body, /## 끊긴 지점/); + assert.match(issue.body, /gh stack link --base develop/); + assert.deepEqual(issue.labels, TRACKING_ISSUE_LABELS); +}); + +test("추적 이슈는 이슈 양식 판정을 통과한다. 열리자마자 닫히면 기록이 아니다", () => { + // issue-metadata-guard 는 작업 유형·주 담당 모듈을 읽지 못한 이슈를 자동으로 닫는다. + // 되살리지 못한 사슬의 유일한 기록이 그렇게 사라지면 안 된다. + const issue = renderTrackingIssue({ + ref: REF, + repository: REPO, + deletedBy: "1hyok", + runUrl: RUN_URL, + lost: [{ pullRequest: lostPullRequest(), dependents: [1839] }], + }); + const inspection = inspectIssue({ + number: 2100, + body: issue.body, + labels: issue.labels.map((name) => ({ name })), + assignees: [], + }); + + assert.equal(inspection.status, "valid"); + assert.deepEqual([...inspection.labels].sort(), [...TRACKING_ISSUE_LABELS].sort()); + assert.deepEqual(inspection.assignees, [ASSIGNEE_BY_MODULE.platform]); +}); + +test("양식 선택지는 issue.yml 의 원문 그대로다", () => { + const template = readFileSync(new URL("../ISSUE_TEMPLATE/issue.yml", import.meta.url), "utf8"); + + assert.ok(template.includes(`- ${TRACKING_ISSUE_TYPE_OPTION}`), TRACKING_ISSUE_TYPE_OPTION); + assert.ok(template.includes(`- ${TRACKING_ISSUE_MODULE_OPTION}`), TRACKING_ISSUE_MODULE_OPTION); +}); + +// ---- 워크플로 정책 ---- + +test("워크플로는 delete 이벤트를 default branch 정의로 받고, 테스트를 먼저 돌린 뒤 스크립트를 부른다", () => { + const source = readFileSync(new URL("../workflows/base-delete-recovery.yml", import.meta.url), "utf8"); + + assert.match(source, /^on:\n\s{2}delete:\n$/m); + assert.match(source, /^permissions: \{\}$/m); + assert.match(source, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); + assert.match(source, /persist-credentials: false/); + assert.match(source, /cancel-in-progress: false/); + assert.ok( + source.indexOf("recover-base-deleted-pull-requests.test.mjs") < + source.indexOf("node .github/scripts/recover-base-deleted-pull-requests.mjs"), + ); + // job 안에서 이 테스트가 돌려면 이 파일이 읽는 디렉터리가 모두 체크아웃돼 있어야 한다. + // 새 파일을 읽기 시작하면 그 디렉터리도 여기서 요구된다. + const sparseCheckout = /sparse-checkout: \|\n((?:\s+\.github\/\S+\n)+)/.exec(source); + assert.ok(sparseCheckout, "sparse-checkout 목록을 읽지 못했다"); + const testSource = readFileSync(new URL(import.meta.url), "utf8"); + const required = new Set([".github/scripts"]); + for (const [, directory] of testSource.matchAll(/new URL\("\.\.\/([^/"]+)\//g)) { + required.add(`.github/${directory}`); + } + assert.ok(required.has(".github/workflows") && required.has(".github/ISSUE_TEMPLATE"), [...required].join(", ")); + for (const directory of required) { + assert.ok(sparseCheckout[1].includes(directory), directory); + } + assert.match(source, /DELETED_REF: \$\{\{ github\.event\.ref \}\}/); + assert.match(source, /DELETED_REF_TYPE: \$\{\{ github\.event\.ref_type \}\}/); + // 판정과 문구는 스크립트에 있다. 주석은 이력이라 남기고 실행되는 줄만 본다. + const executable = source + .split("\n") + .filter((line) => !/^\s*#/.test(line)) + .join("\n"); + assert.doesNotMatch(executable, /base_ref_deleted|closed_at|gh api|gh pr /); +}); + +test("PR 을 다시 열고 코멘트하려면 pull-requests: write 가 필요하다 (#2068)", () => { + // 같은 사고에서 stack-integrity-notify 는 issues: write · pull-requests: read 로 돌다가 + // PR 코멘트 POST 가 403 으로 죽었다. 새 워크플로는 처음부터 맞는 권한으로 시작한다. + const source = readFileSync(new URL("../workflows/base-delete-recovery.yml", import.meta.url), "utf8"); + const job = source.slice(source.indexOf(" permissions:")); + + assert.match(job, /^\s{6}contents: read$/m); + assert.match(job, /^\s{6}issues: write$/m); + assert.match(job, /^\s{6}pull-requests: write$/m); + assert.doesNotMatch(job, /^\s{6}(actions|contents|statuses|packages): write$/m); +}); diff --git a/.github/scripts/release-aab-negative-fixtures.test.mjs b/.github/scripts/release-aab-negative-fixtures.test.mjs new file mode 100644 index 0000000000..8d825e873f --- /dev/null +++ b/.github/scripts/release-aab-negative-fixtures.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; + +const repository = new URL("../../", import.meta.url); +const bundleRelative = "app/build/outputs/bundle/release/app-release.aab"; +const mappingRelative = "app/build/outputs/mapping/release/mapping.txt"; + +async function executable(path, source) { + await writeFile(path, source); + await chmod(path, 0o755); +} + +// Run the actual fixture suite and verifier against a tiny ZIP. Gradle/JDK/bundletool commands +// are doubles: ordinary PRs need no Android build or signing key to prove fixture coverage. +async function runFixtures(context, mutateVerifier = (source) => source) { + const root = await mkdtemp(join(tmpdir(), "afternote-fixture-policy-")); + context.after(() => rm(root, { recursive: true, force: true })); + for (const directory of ["scripts", ".github/scripts", "bin", "tmp", + dirname(bundleRelative), dirname(mappingRelative), "archive/base/manifest", "archive/base/dex"]) { + await mkdir(join(root, directory), { recursive: true }); + } + for (const path of ["scripts/jarsigner-verification-policy.sh", + "scripts/test-jarsigner-verification-policy.sh", + ".github/scripts/test-release-aab-negative-fixtures.sh"]) { + await copyFile(new URL(path, repository), join(root, path)); + await chmod(join(root, path), 0o755); + } + const verifier = mutateVerifier(await readFile( + new URL("scripts/verify-play-release-bundle.sh", repository), "utf8", + )).replace('source "${script_dir}/jarsigner-verification-policy.sh"', + 'source "${script_dir}/jarsigner-verification-policy.sh"\nprintf "%s\\n" "${repo_root##*/}" >> "$FIXTURE_TRACE"'); + await executable(join(root, "scripts/verify-play-release-bundle.sh"), verifier); + await writeFile(join(root, "app/proguard-rules.pro"), "# original rules\n"); + await writeFile(join(root, mappingRelative), "fixture mapping\n"); + for (const entry of ["BundleConfig.pb", "base/manifest/AndroidManifest.xml", + "base/resources.pb", "base/dex/classes.dex"]) { + await writeFile(join(root, "archive", entry), "fixture contents\n"); + } + // The signature fixture must replace an entry even when its source mtime is not newer. + // ZIP stores timestamps in two-second ticks; fast runners can otherwise skip the mutation. + const archiveTime = new Date(Date.now() + 60_000); + await utimes(join(root, "archive/base/resources.pb"), archiveTime, archiveTime); + const archive = spawnSync("zip", ["-q", "-r", join(root, bundleRelative), "."], + { cwd: join(root, "archive"), encoding: "utf8" }); + assert.equal(archive.status, 0, archive.stderr); + await executable(join(root, "gradlew"), `#!/usr/bin/env bash +set -euo pipefail +grep -Fq -- '-afternote-invalid-keep-rule' "$(dirname "$0")/app/proguard-rules.pro" +echo 'unknown option: -afternote-invalid-keep-rule' >&2 +exit 1 +`); + await executable(join(root, "bin/jarsigner"), `#!/usr/bin/env bash +set -euo pipefail +if unzip -p "\${@: -1}" base/resources.pb | grep -Fq tampered; then + echo 'java.lang.SecurityException: digest error' >&2 + exit 1 +fi +echo 'jar verified.' +`); + await executable(join(root, "bin/keytool"), "#!/usr/bin/env bash\necho 'SHA256: AA:BB'\n"); + // A bypassed guard can reach manifest verification. Keep that path deterministic and + // offline so a download failure cannot masquerade as rejection of a broken verifier. + const bundletool = join(root, "bundletool.jar"); + await writeFile(bundletool, "fixture bundletool\n"); + await executable(join(root, "bin/shasum"), `#!/usr/bin/env bash +echo 'a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29' +`); + await executable(join(root, "bin/java"), `#!/usr/bin/env bash +set -euo pipefail +[[ "$1" == -jar && "$2" == "$BUNDLETOOL_JAR" && "$3" == dump && "$4" == manifest ]] +[[ "$5" == --bundle=* && "$6" == --module=base && "$7" == --xpath=/manifest/@android:versionCode ]] +echo 1 +`); + await executable(join(root, "bin/curl"), "#!/usr/bin/env bash\necho 'Unexpected download in fixture test' >&2\nexit 90\n"); + const trace = join(root, "trace"); + await writeFile(trace, ""); + const result = spawnSync("bash", [".github/scripts/test-release-aab-negative-fixtures.sh"], { + cwd: root, + encoding: "utf8", + timeout: 20_000, + env: { ...process.env, PATH: `${join(root, "bin")}:${process.env.PATH}`, + TMPDIR: join(root, "tmp"), FIXTURE_TRACE: trace, + AFTERNOTE_VERSION_CODE: "1", BUNDLETOOL_JAR: bundletool }, + }); + assert.equal(result.error, undefined); + assert.equal(await readFile(join(root, "app/proguard-rules.pro"), "utf8"), "# original rules\n"); + return { ...result, trace: (await readFile(trace, "utf8")).trim().split("\n") }; +} + +test("negative fixtures execute every AAB verifier failure branch", async (context) => { + const result = await runFixtures(context); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.deepEqual(result.trace.slice(1), [ + "empty-bundle", "missing-mapping", "missing-entry", "invalid-signature", + ]); +}); + +for (const [name, fixture, failure, from, to] of [ + ["empty AAB", "empty-bundle", "Empty-AAB", 'if [[ ! -s "${bundle_path}" ]]; then', 'if false; then'], + ["missing mapping", "missing-mapping", "Missing-mapping", 'if [[ ! -s "${mapping_path}" ]]; then', 'if false; then'], + ["required entry", "missing-entry", "Missing-entry", 'if ! grep -Fqx "${required_entry}" <<<"${bundle_entries}"; then', 'if false; then'], + ["signature", "invalid-signature", "Invalid-signature", 'verify_jarsigner_result "${strict_status}" "${verification_output}"', ":"], +]) { + test(`negative fixtures reject a verifier mutation that bypasses ${name}`, async (context) => { + const result = await runFixtures(context, (source) => { + assert.ok(source.includes(from), `missing mutation target: ${name}`); + return source.replace(from, to); + }); + assert.notEqual(result.status, 0, `fixture suite accepted bypassed ${name}\n${result.stdout}`); + assert.equal(result.trace.at(-1), fixture, `mutation failed before reaching ${fixture}`); + assert.ok(result.stderr.includes(`${failure} fixture did not fail closed:`), result.stderr); + }); +} diff --git a/.github/scripts/release-aab-preflight.test.mjs b/.github/scripts/release-aab-preflight.test.mjs index 184dab7d5e..4f5c4ff100 100644 --- a/.github/scripts/release-aab-preflight.test.mjs +++ b/.github/scripts/release-aab-preflight.test.mjs @@ -14,6 +14,27 @@ import { const scriptsDirectory = dirname(fileURLToPath(import.meta.url)); const repositoryRoot = resolve(scriptsDirectory, "../.."); +test("baseline lookup requests the latest successful run of this workflow only", async () => { + const workflow = await readFile( + join(repositoryRoot, ".github/workflows/release-aab-preflight.yml"), "utf8", + ); + const restore = workflow.slice( + workflow.indexOf("- name: Restore the previous public size report"), + workflow.indexOf("- name: Build, verify, and report release AAB"), + ); + assert.match(restore, /gh api "repos\/\$GITHUB_REPOSITORY\/actions\/workflows\/release-aab-preflight\.yml\/runs\?status=success&per_page=1"/); + assert.doesNotMatch(restore, /--paginate|actions\/runs\?status=/); +}); + +test("ordinary PR script validation executes the jarsigner policy fixtures before Gradle", async () => { + const workflow = await readFile( + join(repositoryRoot, ".github/workflows/unit-test.yml"), "utf8", + ); + assert.match(workflow, /- name: Run jarsigner policy fixtures\n\s+if: inputs\.run_node_tests\n\s+run: bash scripts\/test-jarsigner-verification-policy\.sh/); + assert.ok(workflow.indexOf("run: bash scripts/test-jarsigner-verification-policy.sh") < + workflow.indexOf("- name: Set up JDK 21")); +}); + function currentMetrics(overrides = {}) { return { sourceSha: "current-sha", diff --git a/.github/scripts/release-distribution-policy.test.mjs b/.github/scripts/release-distribution-policy.test.mjs index 05feb722e6..74ec725ae5 100644 --- a/.github/scripts/release-distribution-policy.test.mjs +++ b/.github/scripts/release-distribution-policy.test.mjs @@ -74,3 +74,16 @@ test("Firebase credential 은 빌드 단계까지 내려오지 않는다", () => assert.doesNotMatch(buildStep, /GOOGLE_APPLICATION_CREDENTIALS/); assert.match(workflow, /GOOGLE_APPLICATION_CREDENTIALS/); }); + +test("Firebase 대역 versionCode 는 빌드 전에 정해져 빌드 단계로만 들어간다", () => { + const resolve = indexOf("- name: Resolve the Firebase-band versionCode"); + const build = indexOf("- name: Build signed release APK"); + assert.ok(resolve < build, "versionCode 는 빌드가 읽기 전에 정해져야 한다"); + + const buildStep = workflow.slice(build, indexOf("- name: Attest the exact signed APK")); + assert.match(buildStep, /AFTERNOTE_VERSION_CODE: \$\{\{ steps\.version_code\.outputs\.version_code \}\}/); + + // AFTERNOTE_STORE_DISTRIBUTED_BUILD 는 Play 배포 워크플로만 넘긴다. 이 채널의 QA 빌드에 + // 들어가면 ForceUpdateGate 의 조기 반환이 풀려 강제 업데이트 팝업이 켜진다 (#1539). + assert.doesNotMatch(buildStep, /AFTERNOTE_STORE_DISTRIBUTED_BUILD/); +}); diff --git a/.github/scripts/release-play-internal-policy.test.mjs b/.github/scripts/release-play-internal-policy.test.mjs index 53e5ec331e..fee2ad468e 100644 --- a/.github/scripts/release-play-internal-policy.test.mjs +++ b/.github/scripts/release-play-internal-policy.test.mjs @@ -99,6 +99,58 @@ test("업로드되는 AAB 는 attest 와 digest 재확인을 통과한 그 파 ); }); +test("서명 검증기가 빌드와 attest 사이에서 실제로 불린다", () => { + // 스텝 이름만 잠그면 run 을 echo 로 바꿔도 통과한다. 부르는 명령과 자리를 함께 박는다. + // attest 뒤로 밀리면 attestation 이 «검증되지 않은 파일» 에 붙으므로 순서가 계약이다. + const build = indexOf("./gradlew :app:bundleRelease"); + const verifyBundle = indexOf("run: bash scripts/verify-play-release-bundle.sh --skip-build"); + const attest = indexOf("actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6"); + + assert.ok(build < verifyBundle, "검증은 서명이 끝난 AAB 에 대고 한다"); + assert.ok(verifyBundle < attest, "attest 는 검증을 통과한 파일에만 붙는다"); + const verificationStep = workflow.slice( + indexOf("- name: Verify the AAB signature and contents"), + indexOf("- name: Attest the exact signed AAB"), + ); + assert.match( + verificationStep, + /AFTERNOTE_VERSION_CODE: \$\{\{ steps\.version_code\.outputs\.version_code \}\}/, + "manifest 검증에도 빌드와 같은 주입값이 필요하다", + ); +}); + +test("로컬·Play 검증과 preflight 는 같은 bundletool 버전과 SHA256 을 고정한다", async () => { + const [verifier, preflight] = await Promise.all([ + readFile(new URL("../../scripts/verify-play-release-bundle.sh", import.meta.url), "utf8"), + readFile(new URL("../workflows/release-aab-preflight.yml", import.meta.url), "utf8"), + ]); + const version = /^readonly bundletool_version="([0-9.]+)"$/m.exec(verifier)?.[1]; + const sha256 = /^readonly bundletool_sha256="([0-9a-f]{64})"$/m.exec(verifier)?.[1]; + assert.ok(version && sha256, "로컬 검증기도 버전과 digest 를 상수로 고정해야 한다"); + assert.equal(version, /BUNDLETOOL_VERSION: "([0-9.]+)"/.exec(preflight)?.[1]); + assert.equal(sha256, /BUNDLETOOL_SHA256: ([0-9a-f]{64})/.exec(preflight)?.[1]); +}); + +test("attestation 은 이 commit·이 ref·GitHub 호스티드 러너로만 검증된다", () => { + // release-distribution 경로가 이미 같은 세 플래그를 잠근다. Play 경로만 비어 있었다. + // 이 셋이 빠지면 같은 워크플로가 다른 commit 에서 만든 attestation 도 통과한다. + assert.match(workflow, /--repo "\$GITHUB_REPOSITORY"/); + assert.match(workflow, /--source-digest "\$GITHUB_SHA"/); + assert.match(workflow, /--source-ref "\$GITHUB_REF"/); + assert.match(workflow, /--deny-self-hosted-runners/); +}); + +test("PLAY_PACKAGE_NAME 은 비었는지가 아니라 applicationId 와 같은지로 판정된다", () => { + const guard = indexOf("- name: Verify Play publishing configuration is set"); + const build = indexOf("./gradlew :app:bundleRelease"); + const guardStep = workflow.slice(guard, indexOf("- name: Set up JDK 21")); + + assert.match(guardStep, /app\/build\.gradle\.kts/, "applicationId 를 코드에서 읽어 온다"); + assert.match(guardStep, /"\$PLAY_PACKAGE_NAME" != "\$application_id"/); + assert.match(guardStep, /applicationId 를 읽지 못했다/, "읽기 실패도 fail-closed 다"); + assert.ok(guard < build, "대조는 40분 빌드 전에 끝난다"); +}); + test("Play 토큰은 빌드 단계로 내려오지 않고 업로드 직전에 다시 발급된다", () => { const buildStep = workflow.slice( indexOf("- name: Build the signed release AAB"), @@ -134,11 +186,19 @@ test("AAB 와 R8 mapping 은 Actions artifact 로 게시되지 않고 러너에 assert.doesNotMatch(workflow, /actions\/upload-artifact/); assert.doesNotMatch(workflow, /actions\/download-artifact/); - const cleanup = workflow.slice(indexOf("- name: Remove private release outputs")); - assert.match(cleanup, /app\/build\/outputs\/bundle\/release\/app-release\.aab/); - assert.match(cleanup, /app\/build\/outputs\/mapping\/release\/mapping\.txt/); - assert.equal((workflow.match(/if: always\(\)/g) ?? []).length, 2); - assert.match(workflow, /uses: \.\/\.github\/actions\/cleanup-release-config/); + // 개수만 세면 always 를 떼어 다른 스텝에 붙여도 통과한다. 두 정리 스텝 각각의 슬라이스 안에서 본다. + const outputsCleanup = workflow.slice( + indexOf("- name: Remove private release outputs"), + indexOf("- name: Remove release build configuration"), + ); + const configCleanup = workflow.slice(indexOf("- name: Remove release build configuration")); + + assert.match(outputsCleanup, /if: always\(\)/, "실패·취소에도 산출물을 지운다"); + assert.match(outputsCleanup, /app\/build\/outputs\/bundle\/release\/app-release\.aab/); + assert.match(outputsCleanup, /app\/build\/outputs\/mapping\/release\/mapping\.txt/); + + assert.match(configCleanup, /if: always\(\)/, "실패·취소에도 keystore 설정을 지운다"); + assert.match(configCleanup, /uses: \.\/\.github\/actions\/cleanup-release-config/); }); test("같은 앱에 두 run 이 겹치지 않고 중도 취소되지도 않는다", () => { diff --git a/.github/scripts/remote-dev-workflows-policy.test.mjs b/.github/scripts/remote-dev-workflows-policy.test.mjs index fbe7b95f27..385940e4e4 100644 --- a/.github/scripts/remote-dev-workflows-policy.test.mjs +++ b/.github/scripts/remote-dev-workflows-policy.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { copyFile, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { execFileSync, spawnSync } from "node:child_process"; +import { copyFile, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; @@ -294,6 +294,124 @@ test("managed device fails fast per lane and preserves only bounded infrastructu assert.match(source, /exit 124/); }); +const MANAGED_DEVICE_LANES = { + api30: { artifact: "api30", task: "pixel2Api30DebugAndroidTest" }, + api34: { artifact: "accessibility-api34", task: "pixel2Api34DebugAndroidTest" }, +}; + +// bash 4.4 미만(macOS 기본 3.2)은 set -u 에서 빈 배열 확장을 unbound 로 본다. 러너는 bash 5 라 +// 인자 배열이 둘 다 비는 API 30 full invocation 은 거기서만 돌려 볼 수 있다. +const [bashMajor, bashMinor] = spawnSync( + "bash", + ["-c", 'echo "${BASH_VERSINFO[0]} ${BASH_VERSINFO[1]}"'], + { encoding: "utf8" }, +).stdout.trim().split(" ").map(Number); +const bashExpandsEmptyArrays = bashMajor > 4 || (bashMajor === 4 && bashMinor >= 4); + +function managedDeviceGradleScript(source) { + const start = source.indexOf(" - name: Run managed-device androidTest\n"); + assert.ok(start >= 0, "managed-device Gradle step must stay extractable for policy tests"); + const run = source.indexOf(" run: |\n", start) + " run: |\n".length; + const body = []; + for (const line of source.slice(run).split("\n")) { + if (line && !line.startsWith(" ")) break; + body.push(line.slice(10)); + } + return body.join("\n"); +} + +// 스텝 스크립트를 가짜 gradlew 로 그대로 돌린다. GNU timeout 이 없는 macOS 에서도 돌도록 +// timeout 은 옵션과 시간만 벗기고 나머지 명령을 실행하는 가짜로 바꾼다. +async function runManagedDeviceGradleStep({ gradlew, device = "api34", selectors = [] }) { + const script = managedDeviceGradleScript(await readWorkflow("android-managed-device.yml")); + const { artifact, task } = MANAGED_DEVICE_LANES[device]; + const directory = await mkdtemp(path.join(tmpdir(), "managed-device-gradle-")); + try { + const bin = path.join(directory, "bin"); + await mkdir(bin); + await writeFile( + path.join(bin, "timeout"), + '#!/usr/bin/env bash\nwhile [[ "$1" == --* ]]; do shift; done\nshift\nexec "$@"\n', + { mode: 0o755 }, + ); + await writeFile(path.join(directory, "gradlew"), `#!/usr/bin/env bash\n${gradlew}\n`, { mode: 0o755 }); + const outputPath = path.join(directory, "github-output"); + await writeFile(outputPath, ""); + const result = spawnSync("bash", ["-c", script], { + cwd: directory, + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + RUNNER_TEMP: directory, + GITHUB_OUTPUT: outputPath, + ARTIFACT_KEY: artifact, + DEVICE_TASK: task, + GRADLE_TIMEOUT_MINUTES: "12", + TEST_SELECTORS_JSON: JSON.stringify(selectors), + }, + }); + const resultsRoot = path.join(directory, "app/build/outputs/androidTest-results/managedDevice"); + const resultXml = await readdir(resultsRoot, { recursive: true }) + .then((files) => files.filter((file) => file.endsWith(".xml")).sort()) + .catch(() => []); + return { + ...result, + resultXml, + outputs: await readFile(outputPath, "utf8"), + gradleLog: await readFile(path.join(directory, "android-test-logs", `${artifact}.log`), "utf8"), + }; + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +// #2157: run_gradle 이 함수 안에서 set -e 를 되살리면 호출부의 set +e 가 무너져, Gradle 이 +// 실패하는 순간 스텝이 exit_code 를 쓰기 전에 빠지고 Restore 가 그 실패를 124 로 바꾼다. +test("managed-device Gradle step records a failing selected invocation instead of aborting", async () => { + const result = await runManagedDeviceGradleStep({ + gradlew: 'echo "FAILURE: Build failed with an exception."; exit 1', + selectors: ["com.example.SmokeTest#first", "com.example.SmokeTest#second"], + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.outputs, /^exit_code=1$/m); + assert.match(result.stdout, /Managed-device Gradle exit code: 1/); + assert.doesNotMatch(result.gradleLog, /selected retry/); +}); + +test("managed-device Gradle step records a failing retry of a dropped selector", async () => { + const result = await runManagedDeviceGradleStep({ + gradlew: [ + "results=app/build/outputs/androidTest-results/managedDevice", + 'if [[ "$*" == *"class=com.example.SmokeTest#first,com.example.SmokeTest#second"* ]]; then', + ' mkdir -p "$results"', + " echo '' > \"$results/first.xml\"", + " exit 0", + "fi", + "exit 1", + ].join("\n"), + selectors: ["com.example.SmokeTest#first", "com.example.SmokeTest#second"], + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.gradleLog, /selected retry 1\/1: com\.example\.SmokeTest#second/); + assert.match(result.outputs, /^exit_code=1$/m); + // 재실행이 XML 없이 실패해도 1차 결과는 합본에 한 번만 남는다. + assert.deepEqual(result.resultXml, [path.join("chunks", "invocation-0", "first.xml")]); +}); + +test( + "managed-device Gradle step records a failing full invocation", + { skip: !bashExpandsEmptyArrays && "bash 4.4 미만은 빈 인자 배열에서 set -u 로 멈춘다" }, + async () => { + const result = await runManagedDeviceGradleStep({ gradlew: "exit 1", device: "api30" }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.outputs, /^exit_code=1$/m); + }, +); + test("managed-device recovery reruns only one validated first-attempt infrastructure failure", async () => { const source = await readWorkflow("android-managed-device-retry.yml"); @@ -406,5 +524,5 @@ test("required checks expose manual dispatch for token-authored commits", async assert.match(validation, /^\s{2}workflow_dispatch:$/m); assert.match(guard, /^\s{2}workflow_dispatch:\n\s{4}inputs:/m); - assert.match(guard, /github\.event_name == 'pull_request' \|\| github\.event_name == 'workflow_dispatch'/); + assert.match(guard, /github\.event_name == 'pull_request_target' \|\| github\.event_name == 'workflow_dispatch'/); }); diff --git a/.github/scripts/resolve-firebase-version-code.mjs b/.github/scripts/resolve-firebase-version-code.mjs new file mode 100644 index 0000000000..120d01f848 --- /dev/null +++ b/.github/scripts/resolve-firebase-version-code.mjs @@ -0,0 +1,52 @@ +import { appendFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + ATTEMPT_STRIDE, + FIREBASE_BAND_MAX, + FIREBASE_BAND_MIN, + parseInteger, +} from "./version-code-bands.mjs"; + +// Play 쪽 resolver 와 달리 원장 대조가 없다. Firebase App Distribution 은 versionCode 로 +// 빌드를 구분하지 않아 대조할 최댓값 자체가 없기 때문이다. 단조 증가는 이 워크플로가 +// run_number 를 단독으로 소유하는 것으로 성립한다. release-distribution.yml 의 트리거는 +// push: [main] 하나뿐이다. +export function resolveFirebaseVersionCode(runNumberRaw, runAttemptRaw) { + const runNumber = parseInteger("GITHUB_RUN_NUMBER", runNumberRaw, { minimum: 1n }); + const runAttempt = parseInteger("GITHUB_RUN_ATTEMPT", runAttemptRaw, { minimum: 1n }); + + if (runAttempt >= ATTEMPT_STRIDE) { + throw new Error(`GITHUB_RUN_ATTEMPT must be lower than ${ATTEMPT_STRIDE}.`); + } + + const candidate = runNumber * ATTEMPT_STRIDE + runAttempt; + if (candidate < FIREBASE_BAND_MIN || candidate > FIREBASE_BAND_MAX) { + throw new Error( + `Resolved versionCode ${candidate} must stay within the Firebase band ` + + `${FIREBASE_BAND_MIN}-${FIREBASE_BAND_MAX}.`, + ); + } + return Number(candidate); +} + +async function main() { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) { + throw new Error("GITHUB_OUTPUT is required."); + } + const versionCode = resolveFirebaseVersionCode( + process.env.GITHUB_RUN_NUMBER, + process.env.GITHUB_RUN_ATTEMPT, + ); + await appendFile(outputPath, `version_code=${versionCode}\n`, "utf8"); + console.log(`Resolved Firebase App Distribution versionCode ${versionCode}.`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(`::error::${error.message}`); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/resolve-firebase-version-code.test.mjs b/.github/scripts/resolve-firebase-version-code.test.mjs new file mode 100644 index 0000000000..d7de7887e4 --- /dev/null +++ b/.github/scripts/resolve-firebase-version-code.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveFirebaseVersionCode } from "./resolve-firebase-version-code.mjs"; +import { resolvePlayVersionCode } from "./resolve-play-version-code.mjs"; +import { FIREBASE_BAND_MAX, PLAY_BAND_FLOOR } from "./version-code-bands.mjs"; + +test("derives distinct increasing codes for runs and reruns", () => { + const first = resolveFirebaseVersionCode("1", "1"); + const rerun = resolveFirebaseVersionCode("1", "2"); + const nextRun = resolveFirebaseVersionCode("2", "1"); + + assert.equal(first, 101); + assert.ok(first < rerun); + assert.ok(rerun < nextRun); +}); + +test("rejects invalid run metadata and excessive attempts", () => { + assert.throws(() => resolveFirebaseVersionCode("0", "1"), /must be at least 1/); + assert.throws(() => resolveFirebaseVersionCode("1", "0"), /must be at least 1/); + assert.throws(() => resolveFirebaseVersionCode("1", "100"), /must be lower than 100/); + assert.throws(() => resolveFirebaseVersionCode("1.5", "1"), /base-10 integer/); +}); + +test("stops at the Firebase band ceiling instead of entering the Play band", () => { + assert.equal(resolveFirebaseVersionCode("9999999", "99"), Number(FIREBASE_BAND_MAX)); + assert.throws( + () => resolveFirebaseVersionCode("10000000", "1"), + /must stay within the Firebase band/, + ); +}); + +// 완료 조건: 두 채널이 같은 값을 낼 수 없다. 산출 가능한 Firebase 최댓값과 Play 최솟값을 +// 직접 비교해야 가드가 의미를 갖는다. 한쪽 대역 상수만 움직여도 여기서 걸린다. +test("the two channels cannot produce the same versionCode", () => { + const largestFirebase = BigInt(resolveFirebaseVersionCode("9999999", "99")); + const smallestPlay = BigInt(resolvePlayVersionCode("1", "1", "0")); + + assert.ok(largestFirebase < PLAY_BAND_FLOOR); + assert.ok(smallestPlay >= PLAY_BAND_FLOOR); + assert.ok(largestFirebase < smallestPlay); +}); diff --git a/.github/scripts/resolve-play-version-code.mjs b/.github/scripts/resolve-play-version-code.mjs index 77ecd0d0b3..19a07ab7f5 100644 --- a/.github/scripts/resolve-play-version-code.mjs +++ b/.github/scripts/resolve-play-version-code.mjs @@ -2,19 +2,9 @@ import { appendFile } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -const ATTEMPT_STRIDE = 100n; -const MAX_PLAY_VERSION_CODE = 2_100_000_000n; +import { ATTEMPT_STRIDE, PLAY_BAND_FLOOR, parseInteger } from "./version-code-bands.mjs"; -function parseInteger(name, rawValue, { minimum }) { - if (typeof rawValue !== "string" || !/^(0|[1-9][0-9]*)$/.test(rawValue)) { - throw new Error(`${name} must be a base-10 integer.`); - } - const value = BigInt(rawValue); - if (value < minimum) { - throw new Error(`${name} must be at least ${minimum}.`); - } - return value; -} +const MAX_PLAY_VERSION_CODE = 2_100_000_000n; export function resolvePlayVersionCode(runNumberRaw, runAttemptRaw, latestVersionCodeRaw) { const runNumber = parseInteger("GITHUB_RUN_NUMBER", runNumberRaw, { minimum: 1n }); @@ -27,7 +17,9 @@ export function resolvePlayVersionCode(runNumberRaw, runAttemptRaw, latestVersio throw new Error(`GITHUB_RUN_ATTEMPT must be lower than ${ATTEMPT_STRIDE}.`); } - const candidate = runNumber * ATTEMPT_STRIDE + runAttempt; + // 대역 하한을 더해 Firebase 수열(101 ~ 999,999,999)과 겹치지 않게 한다. Play 원장이 비어 + // 있는 지금은 이 하한을 넣어도 단조 증가가 깨지지 않는다. + const candidate = PLAY_BAND_FLOOR + runNumber * ATTEMPT_STRIDE + runAttempt; if (candidate > MAX_PLAY_VERSION_CODE) { throw new Error(`Resolved versionCode exceeds Google Play maximum ${MAX_PLAY_VERSION_CODE}.`); } diff --git a/.github/scripts/resolve-play-version-code.test.mjs b/.github/scripts/resolve-play-version-code.test.mjs index 81b362ac53..c8ce111a8e 100644 --- a/.github/scripts/resolve-play-version-code.test.mjs +++ b/.github/scripts/resolve-play-version-code.test.mjs @@ -2,20 +2,25 @@ import assert from "node:assert/strict"; import test from "node:test"; import { resolvePlayVersionCode } from "./resolve-play-version-code.mjs"; +import { PLAY_BAND_FLOOR } from "./version-code-bands.mjs"; test("derives distinct increasing codes for runs and reruns", () => { const first = resolvePlayVersionCode("1", "1", "0"); const rerun = resolvePlayVersionCode("1", "2", String(first)); const nextRun = resolvePlayVersionCode("2", "1", String(rerun)); - assert.equal(first, 101); + assert.equal(first, 1_000_000_101); assert.ok(first < rerun); assert.ok(rerun < nextRun); }); +test("keeps even the smallest run inside the Play band", () => { + assert.ok(BigInt(resolvePlayVersionCode("1", "1", "0")) >= PLAY_BAND_FLOOR); +}); + test("fails when the candidate is not greater than Play", () => { assert.throws( - () => resolvePlayVersionCode("10", "1", "1001"), + () => resolvePlayVersionCode("10", "1", "1000001001"), /must be greater than current Play versionCode/, ); }); @@ -28,9 +33,9 @@ test("rejects invalid run metadata and excessive attempts", () => { }); test("fails before exceeding the Google Play maximum", () => { - assert.equal(resolvePlayVersionCode("20999999", "99", "0"), 2_099_999_999); + assert.equal(resolvePlayVersionCode("10999999", "99", "0"), 2_099_999_999); assert.throws( - () => resolvePlayVersionCode("21000000", "1", "0"), + () => resolvePlayVersionCode("11000000", "1", "0"), /exceeds Google Play maximum/, ); }); diff --git a/.github/scripts/review-debt-policy.test.mjs b/.github/scripts/review-debt-policy.test.mjs index 9449395e49..1c507ddb5c 100644 --- a/.github/scripts/review-debt-policy.test.mjs +++ b/.github/scripts/review-debt-policy.test.mjs @@ -40,9 +40,21 @@ test("the debt sweep still fails closed when open pull requests cannot be listed assert.match(guard, /if ! open_prs=\$\(gh api[^\n]*\n(?:[^\n]*\n)*?\s*exit 1/); }); -test("a pull request is never counted as debt against its own author", () => { - // 자기 PR 은 자신이 리뷰할 수 없다. - assert.match(guard, /select\(\.user\.login != \\"\$AUTHOR\\"\)/); +test("a pull request is never counted as debt against its own responsible person", () => { + // «자기 PR» 은 작성자가 아니라 담당자(어사인, 비어 있으면 작성자) 기준이다(#1974). + // 작성자 로그인만 보면 남이 올려 내게 어사인된 PR 을 내가 리뷰해야 하는 것으로 세고, + // 내가 올려 남에게 어사인된 PR 을 내 리뷰 목록에서 뺀다. + assert.doesNotMatch(guard, /select\(\.user\.login != \\"\$AUTHOR\\"\)/); + assert.match(guard, /if \(\.assignees \| length\) > 0 then \[\.assignees\[\]\.login\] else \[\.user\.login\] end/); + assert.match(guard, /select\(\(\\\$owners \| index\(\\"\$author_lc\\"\)\) == null\)/); +}); + +test("the request-all job excludes the responsible person, not only the author", () => { + // GitHub 은 작성자만 리뷰어에서 자동으로 뺀다. 어사인된 담당자는 직접 빼야 한다. + assert.match(requestAll, /ASSIGNEES: \$\{\{ join\(github\.event\.pull_request\.assignees\.\*\.login, ' '\) \}\}/); + assert.match(requestAll, /owners_lc=\$\(printf '%s' "\$\{ASSIGNEES:-\$AUTHOR\}"/); + assert.match(requestAll, /case " \$owners_lc " in\n\s*\*" \$u_lc "\*\) continue ;;/); + assert.doesNotMatch(requestAll, /\[ "\$u" != "\$AUTHOR" \] \|\| continue/); }); test("the dead review_request_removed path is gone", () => { @@ -74,8 +86,10 @@ test("a reviewer's own commit is never counted as the author's fix", () => { // 된다. 8/28 에 리뷰어가 건 +0/-0 커밋 하나로 koongmai PR 3건(#1379·#1365·#882)이 // 전부 가짜 빚이 됐고, 재트리거한 리뷰어가 그 대가로 자기 PR 을 못 열었다 (#1459). assert.match(guard, /\(\.author\.login \/\/ ""\)/); - assert.match(guard, /login == target/); - // 계정이 연결되지 않은 커밋은 login 이 비어 가릴 수 없다. 같은 PR 에서 작성자 + // 담당자는 여럿일 수 있어 집합으로 가린다. + assert.match(guard, /login in target/); + assert.match(guard, /-v owners="\$pr_owners"/); + // 계정이 연결되지 않은 커밋은 login 이 비어 가릴 수 없다. 같은 PR 에서 담당자 // 것으로 확인된 커밋의 이메일을 폴백 신원으로 쓴다. assert.match(guard, /\(\.commit\.author\.email \/\/ ""\)/); assert.match(guard, /email in own/); @@ -118,7 +132,7 @@ test("a fix delivered by a merge commit still counts as a response", () => { // 채로 빚에서 빠져 있었다. 커밋과 함께 작성자의 응답을 본다 (#1450). assert.match(guard, /issues\/\$pn\/comments/); assert.match(guard, /pulls\/\$pn\/comments/); - assert.match(guard, /select\(\.user\.login == \\"\$pr_author\\"\)/); + // 담당자·시각 판정은 review-debt-query.test.mjs에서 실제 jq로 검증한다. assert.match(guard, /\[ "\$\{fixed:-0\}" -gt 0 \] \|\| \[ "\$responses" -gt 0 \]/); }); @@ -138,10 +152,17 @@ test("rerequests are automated so silence cannot pass the guard", () => { assert.match(requestAll, /github\.event\.action == 'synchronize'/); assert.match(requestAll, /github\.event\.action == 'edited'/); assert.match(requestAll, /github\.event\.changes\.body != null/); + // 본문 편집은 담당자(어사인, 비어 있으면 작성자)의 것만 조치다(#1974). + assert.match( + requestAll, + /contains\(github\.event\.pull_request\.assignees\.\*\.login, github\.event\.sender\.login\)/, + ); assert.match( requestAll, - /github\.event\.sender\.login == github\.event\.pull_request\.user\.login/, + /github\.event\.pull_request\.assignees\[0\] == null &&\n\s*github\.event\.sender\.login == github\.event\.pull_request\.user\.login/, ); + // 담당자가 낸 변경요청은 되살리지 않는다. + assert.match(requestAll, /\*" \$blocked_lc "\*\) echo "변경요청을 낸 사람이 담당자 본인/); assert.match(requestAll, /github\.event\.pull_request\.state == 'open'/); assert.match(requestAll, /--add-reviewer "\$blocked"/); // 기존 전원 요청은 반영 커밋이나 본문 편집마다 다시 돌지 않는다. @@ -169,7 +190,7 @@ test("author body edits are durable review evidence and fail closed when truncat assert.match(guard, /editor\{login\}/); assert.match(guard, /\.editor\.login/); assert.match(guard, /\.editedAt > \$cutoff/); - assert.match(guard, /ascii_downcase\) == \(\$author \| ascii_downcase/); + assert.match(guard, /--arg owners ",\$pr_owners,"/); assert.match(guard, /pageInfo\.hasPreviousPage != false/); assert.match(guard, /\[ "\$body_edits" -gt 0 \]/); }); @@ -184,3 +205,45 @@ test("the rerequest job and the guard judge by the same latest decision", () => // 봇·fork 는 토큰이 read-only 라 요청을 걸 수 없다. assert.match(requestAll, /\[ "\$HEAD_REPO" != "\$REPO" \]/); }); + +function spaceSeparatedEnv(workflow, name) { + const match = new RegExp(`^\\s*${name}: ([^\\n#]+)$`, "m").exec(workflow); + assert.ok(match, `${name} 환경변수를 찾지 못했다`); + return match[1].trim().split(/\s+/).filter(Boolean).map((login) => login.toLowerCase()); +} + +test("the gate exemption is judged before any debt is counted", () => { + // 면제를 빚 계산 뒤에 두면, 면제받은 사람의 PR 때문에 조회가 실패했을 때 가드가 그를 + // 대신해 죽는다. 봇·fork·권한 판정과 같은 자리, 열린 PR 을 훑기 전에 둔다 (#1910). + const exemptAt = guard.indexOf("리뷰 게이트 면제 작성자"); + const sweepAt = guard.indexOf("if ! open_prs=$(gh api"); + assert.ok(exemptAt > 0, "면제 경로가 없다"); + assert.ok(sweepAt > exemptAt, "면제가 빚 계산 뒤에 있다"); + assert.match(guard, /리뷰 게이트 면제 작성자\(\$AUTHOR\)[^\n]*exit 0/); +}); + +test("exempt logins are matched case-insensitively", () => { + // GitHub 로그인은 대소문자를 가리지 않는다. 그대로 비교하면 웹훅이 Koongmai 를 내리는 + // 순간 면제가 조용히 풀리고, 그 사람의 PR 이 닫힌다. + assert.match(guard, /author_lc=\$\(printf '%s' "\$AUTHOR" \| tr '\[:upper:\]' '\[:lower:\]'\)/); + assert.match(guard, /exempt_lc=\$\(printf '%s' "\$REVIEW_GATE_EXEMPT_AUTHORS" \| tr '\[:upper:\]' '\[:lower:\]'\)/); +}); + +test("a change request from an exempt reviewer is not charged to the rest of the team", () => { + // 면제된 사람은 리뷰할 의무가 없다. 그가 자발적으로 낸 변경요청 뒤 침묵하면 그 침묵의 + // 대가를 나머지 팀원이 "새 PR 을 못 연다" 로 치른다. 그래도 그 PR 이 그냥 머지되지는 + // 않는다. 승인 1건은 required-approval 룰셋이 계속 요구한다. + assert.match(guard, /case " \$exempt_lc " in\n\s*\*" \$blocked_by "\*\)/); + assert.match(guard, /면제 리뷰어 @\$blocked_by/); +}); + +test("nobody is exempt from the gate while still on the automatic review roster", () => { + // 리뷰 의무가 없는 사람에게 요청만 계속 걸면, 그 요청은 아무도 응답하지 않는 알림으로 + // 쌓이고 두 워크플로가 서로 다른 팀 명단을 갖게 된다. + const exempt = spaceSeparatedEnv(guard, "REVIEW_GATE_EXEMPT_AUTHORS"); + const team = spaceSeparatedEnv(requestAll, "TEAM"); + for (const login of exempt) { + assert.ok(!team.includes(login), `${login} 이 면제이면서 자동 요청 대상이다`); + } + assert.ok(team.length > 0, "자동 요청 대상이 비었다"); +}); diff --git a/.github/scripts/review-debt-query.test.mjs b/.github/scripts/review-debt-query.test.mjs new file mode 100644 index 0000000000..9751d19ab5 --- /dev/null +++ b/.github/scripts/review-debt-query.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const guard = await readFile(new URL("../workflows/review-debt-guard.yml", import.meta.url), "utf8"); +const cutoff = "2026-09-10T00:00:00Z"; +const owners = "koongmai,1hyok"; + +function runQuery(query, data, args = []) { + return spawnSync("jq", ["-r", ...args, query], { input: JSON.stringify(data), encoding: "utf8" }); +} + +for (const endpoint of ["issues", "pulls"]) { + test(`${endpoint} comments retain author context and count only later owner responses`, () => { + const start = guard.indexOf(`repos/$REPO/${endpoint}/$pn/comments?per_page=100`); + assert.ok(start >= 0); + const raw = guard.slice(start).match(/--jq "([\s\S]*?)" \\\n/)[1]; + const query = raw.replaceAll("$pr_owners", owners).replaceAll("$blocked_at", cutoff) + .replaceAll('\\"', '"').replaceAll('\\$', '$'); + const comments = [ + { id: 1, user: { login: "KoongMai" }, created_at: "2026-09-11T00:00:00Z" }, + { id: 2, user: { login: "1hyok" }, created_at: "2026-09-12T00:00:00Z" }, + { id: 3, user: { login: "koongmai" }, created_at: cutoff }, + { id: 4, user: { login: "koong" }, created_at: "2026-09-11T00:00:00Z" }, + { id: 5, user: { login: "other" }, created_at: "2026-09-11T00:00:00Z" }, + ]; + // gh --paginate executes its query separately on each page. + const pages = [comments.slice(0, 1), comments.slice(1), []]; + const output = pages.map(page => { + const result = runQuery(query, page); + assert.equal(result.status, 0, result.stderr); + return result.stdout; + }).join(""); + assert.equal(output, "1\n2\n"); + }); +} + +const bodyQuery = guard.match(/--arg cutoff "\$blocked_at" '([\s\S]*?)' <<< "\$edits_json"/)[1]; +function edits(nodes, hasPreviousPage = false) { + return { data: { repository: { pullRequest: { userContentEdits: { nodes, pageInfo: { hasPreviousPage } } } } } }; +} +const args = ["--arg", "owners", `,${owners},`, "--arg", "cutoff", cutoff]; + +test("body edit evidence keeps editor context, cutoff, and exact owner membership", () => { + const result = runQuery(bodyQuery, edits([ + { editor: { login: "KOONGMAI" }, editedAt: "2026-09-11T00:00:00Z" }, + { editor: { login: "1hyok" }, editedAt: "2026-09-12T00:00:00Z" }, + { editor: { login: "koongmai" }, editedAt: cutoff }, + { editor: { login: "koong" }, editedAt: "2026-09-11T00:00:00Z" }, + { editor: null, editedAt: "2026-09-11T00:00:00Z" }, + ]), args); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "2\n"); + assert.equal(runQuery(bodyQuery, edits([]), args).stdout, "0\n"); +}); + +test("truncated body edit history still fails closed", () => { + const result = runQuery(bodyQuery, edits([], true), args); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /최근 50건보다 많은 본문 편집 이력/); +}); diff --git a/.github/scripts/supply-chain-policy.test.mjs b/.github/scripts/supply-chain-policy.test.mjs index eb00ab61b0..787af146b9 100644 --- a/.github/scripts/supply-chain-policy.test.mjs +++ b/.github/scripts/supply-chain-policy.test.mjs @@ -488,7 +488,7 @@ test('manual dependency baseline is hard-wired to the checked-out main SHA', asy assert.doesNotMatch(manualJob, /inputs\./); }); -test('dependency PR workflows use the same complete server-side path filter', async () => { +test('all pull request heads produce a baseline while dependency review keeps its path filter', async () => { const submission = await readFile( new URL('../workflows/dependency-submission.yml', import.meta.url), 'utf8', @@ -498,12 +498,43 @@ test('dependency PR workflows use the same complete server-side path filter', as 'utf8', ); - assert.deepEqual(eventPathFilters(submission, 'pull_request'), dependencyPathFilters); + // 자식이 부모 SHA 와 비교하려면 소스 변경만 있는 부모도 그래프를 제출해야 한다. + // 필터 없는 이벤트만 허용해 브랜치·유형·경로 필터와 수동 전용 트리거를 막는다. + assert.match(submission, /^on:\n pull_request:\n\nconcurrency:/m); + assert.doesNotMatch(submission, /^\s+(?:paths(?:-ignore)?|branches(?:-ignore)?|types):/m); + assert.doesNotMatch(submission, /^\s+if:/m); + assert.doesNotMatch(submission, /pull_request_target:|workflow_dispatch:/); assert.deepEqual(eventPathFilters(review, 'pull_request'), dependencyPathFilters); assert.doesNotMatch(submission, /Detect dependency graph input changes/); assert.doesNotMatch(review, /Detect dependency graph input changes/); }); +test('all PR dependency graph generation keeps a read-only token and the shared correlator', async () => { + const producer = await readFile( + new URL('../workflows/dependency-submission.yml', import.meta.url), + 'utf8', + ); + const trusted = await readFile( + new URL('../workflows/dependency-submission-trusted.yml', import.meta.url), + 'utf8', + ); + + assert.match(producer, /^permissions:\n contents: read$/m); + const jobs = runnerJobBlocks(producer); + assert.equal(jobs.length, 1); + assert.match(jobs[0].source, /^ permissions:\n contents: read$/m); + assert.doesNotMatch(producer, /:\s*write\b/); + assert.match(producer, /persist-credentials:\s*false/); + assert.match(producer, /dependency-graph:\s*generate-and-upload/); + assert.doesNotMatch(producer, /dependency-graph:\s*(?:generate-and-submit|download-and-submit)/); + + const correlators = (source) => [...source.matchAll( + /GITHUB_DEPENDENCY_GRAPH_JOB_CORRELATOR:\s*(\S+)/g, + )].map((match) => match[1]); + assert.deepEqual(correlators(producer), ['afternote-gradle-dependency-graph']); + assert.deepEqual(correlators(trusted), Array(2).fill(correlators(producer)[0])); +}); + test('the privileged PR graph bridge never checks out or executes pull request code', async () => { const source = await readFile( new URL('../workflows/dependency-submission-upload.yml', import.meta.url), diff --git a/.github/scripts/test-release-aab-negative-fixtures.sh b/.github/scripts/test-release-aab-negative-fixtures.sh index 8251d4f1d8..5d343155d2 100755 --- a/.github/scripts/test-release-aab-negative-fixtures.sh +++ b/.github/scripts/test-release-aab-negative-fixtures.sh @@ -68,6 +68,32 @@ prepare_verifier_fixture() { printf '%s' "${root}" } +empty_bundle_root="$(prepare_verifier_fixture empty-bundle)" +: > "${empty_bundle_root}/app/build/outputs/bundle/release/app-release.aab" +set +e +empty_bundle_output="$("${empty_bundle_root}/scripts/verify-play-release-bundle.sh" --skip-build 2>&1)" +empty_bundle_status=$? +set -e +if [[ ${empty_bundle_status} -eq 0 ]] || + ! grep -Fq 'AAB를 찾을 수 없거나 비어 있습니다:' <<< "${empty_bundle_output}"; then + printf 'Empty-AAB fixture did not fail closed:\n%s\n' "${empty_bundle_output}" >&2 + exit 1 +fi +echo "PASS: empty AAB fixture was rejected." + +missing_mapping_root="$(prepare_verifier_fixture missing-mapping)" +rm "${missing_mapping_root}/app/build/outputs/mapping/release/mapping.txt" +set +e +missing_mapping_output="$("${missing_mapping_root}/scripts/verify-play-release-bundle.sh" --skip-build 2>&1)" +missing_mapping_status=$? +set -e +if [[ ${missing_mapping_status} -eq 0 ]] || + ! grep -Fq 'R8 mapping 파일을 찾을 수 없거나 비어 있습니다:' <<< "${missing_mapping_output}"; then + printf 'Missing-mapping fixture did not fail closed:\n%s\n' "${missing_mapping_output}" >&2 + exit 1 +fi +echo "PASS: missing R8 mapping fixture was rejected." + missing_entry_root="$(prepare_verifier_fixture missing-entry)" missing_entry_bundle="${missing_entry_root}/app/build/outputs/bundle/release/app-release.aab" zip -q -d "${missing_entry_bundle}" base/resources.pb @@ -90,7 +116,8 @@ unzip -p "${positive_bundle}" base/resources.pb > "${mutation_root}/base/resourc printf 'tampered' >> "${mutation_root}/base/resources.pb" ( cd "${mutation_root}" - zip -q -u "${signature_bundle}" base/resources.pb + # Replace regardless of ZIP's two-second timestamp granularity on fast runners. + zip -q "${signature_bundle}" base/resources.pb ) set +e signature_output="$("${signature_root}/scripts/verify-play-release-bundle.sh" --skip-build 2>&1)" diff --git a/.github/scripts/validate-pr-issue-link.mjs b/.github/scripts/validate-pr-issue-link.mjs index 146c720f1a..80c3c573ba 100644 --- a/.github/scripts/validate-pr-issue-link.mjs +++ b/.github/scripts/validate-pr-issue-link.mjs @@ -83,7 +83,7 @@ function isBotAuthor(user, login) { const ISSUE_ASSIGNEE_EXEMPT_LABEL = "issue-assignee-exempt"; // 다른 담당자의 모듈이 develop 을 깨뜨렸을 때처럼 어사인 이관을 기다릴 수 없는 -// 긴급 PR 은 라벨 하나로 담당자 대조만 면제한다 — review-debt-exempt 와 같은 규약. +// 긴급 PR 은 라벨 하나로 담당자 대조만 면제한다. // Issue 연결 요건 자체는 면제하지 않는다. export function hasIssueAssigneeExemptLabel(pullRequest) { return (pullRequest?.labels ?? []) diff --git a/.github/scripts/validate-pr-module-owner.mjs b/.github/scripts/validate-pr-module-owner.mjs new file mode 100644 index 0000000000..5107f3e2dd --- /dev/null +++ b/.github/scripts/validate-pr-module-owner.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node + +// PR 작성자가 담당하지 않는 모듈의 «프로덕션 파일» 을 바꿨는지 검사한다 (#1889). +// +// «남의 담당 모듈은 내 PR 에 프로덕션 코드 0줄» 은 0830 에 확정된 규칙인데, 지금까지 지도 +// (`reconcile-issue-metadata.mjs` 의 `ASSIGNEE_BY_MODULE`) 는 이슈 어사인에만 쓰였고 PR 의 변경 +// 파일과는 대조된 적이 없다. 대표 Issue 담당자 대조(#1366)는 «이슈» 단위라, 담당 이슈로 열어 놓고 +// 남의 모듈 파일을 끼워 넣는 것은 못 잡는다 — #1354 가 그렇게 setting 파일을 실었다. +// +// 지도는 한 곳이다 — 여기에 담당 표를 다시 적지 않는다. +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { assigneeForIssue } from "./reconcile-issue-metadata.mjs"; +import { changedPathsFromGithubFiles } from "./resolve-pr-impact.mjs"; +import { extractTitleIssueNumber, hasIssueAssigneeExemptLabel } from "./validate-pr-issue-link.mjs"; + +// 경로 → 모듈 키. `feature//` 는 x, `core/` 는 core, `app/` 은 app(앱 셸·루트 네비게이션), +// 나머지 저장소 운영 경로는 platform (#1986: 앱 구조 변경이 «인프라» 로 읽히지 않게 가른다). +// 수신자 홈은 #1724 가 `feature/home/…/receiver/` 로 옮겨 경로가 곧 담당이다 — 예외 표가 없다. +const APP_PREFIX = "app/"; +const PLATFORM_PREFIXES = ["build-logic/", "konsist/", ".github/", "gradle/", "scripts/", "git-hooks/"]; +const PLATFORM_FILES = new Set([ + "build.gradle.kts", "settings.gradle.kts", "gradle.properties", "gradlew", "gradlew.bat", + ".editorconfig", "Dockerfile.screenshot", +]); +// 소유자가 없는 것 — 누구나 고친다. +const UNOWNED_PREFIXES = ["docs/"]; +const UNOWNED_FILES = new Set(["README.md", ".gitignore", ".dockerignore", ".mcp.json"]); +// 테스트 소스셋은 남의 모듈이라도 무관하다 (0830 확정). 판정은 마커 목록이 아니라 «`/src/<이름>/` 의 +// <이름> 이 test 를 품는가» 다 — `AfternoteKonsistScope.isTestSourceSet` 과 같은 기준이라 variant 접미사 +// (`screenshotTestDebug` 의 골든 PNG 등)를 자연히 흡수한다. 마커 정확 일치는 `screenshotTestDebug/` 를 +// 프로덕션으로 읽어, core:ui 변경으로 남의 모듈 골든이 갱신되는 정상 경로를 전부 위반으로 잡았다(#1890 리뷰). +const SOURCE_SET_SEGMENT = /\/src\/([^/]+)\//; +const TEST_NAME_IN_SOURCE_SET = "test"; + +export const ISSUE_ASSIGNEE_EXEMPT_LABEL = "issue-assignee-exempt"; + +function normalize(filePath) { + return String(filePath ?? "").replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\/+/, ""); +} + +/** 경로가 속한 모듈 키. 소유자 없는 경로는 null. */ +export function moduleKeyOf(filePath) { + const p = normalize(filePath); + if (!p) return null; + if (UNOWNED_FILES.has(p) || UNOWNED_PREFIXES.some((prefix) => p.startsWith(prefix))) return null; + const feature = /^feature\/([a-z]+)\//.exec(p); + if (feature) return feature[1]; + if (p.startsWith("core/")) return "core"; + if (p.startsWith(APP_PREFIX)) return "app"; + if (PLATFORM_FILES.has(p) || PLATFORM_PREFIXES.some((prefix) => p.startsWith(prefix))) return "platform"; + return null; +} + +/** 프로덕션 파일인가 — 테스트 소스셋은 제외, 그 밖의 모듈 파일(빌드 스크립트·리소스·매니페스트)은 포함. */ +export function isProductionPath(filePath) { + const sourceSet = SOURCE_SET_SEGMENT.exec(`/${normalize(filePath)}`)?.[1]; + return !(sourceSet && sourceSet.toLowerCase().includes(TEST_NAME_IN_SOURCE_SET)); +} + +function isBotAuthor(user, login) { + // validate-pr-issue-link.mjs 와 같은 경계 — 봇은 담당자가 될 수 없다. + return user?.type === "Bot" || login.endsWith("[bot]"); +} + +/** + * @returns {{ skipped?: string, violations: Array<{ path: string, module: string, owner: string }> }} + */ +export function validatePullRequestModuleOwner({ pullRequest, changedPaths, resolveOwner = assigneeForIssue }) { + const login = String(pullRequest?.user?.login ?? ""); + if (!login) throw new Error("pull_request.user.login 값이 없습니다."); + if (isBotAuthor(pullRequest.user, login)) return { skipped: "봇 작성자", violations: [] }; + if (hasIssueAssigneeExemptLabel(pullRequest)) return { skipped: `${ISSUE_ASSIGNEE_EXEMPT_LABEL} 라벨`, violations: [] }; + + // 담당은 모듈만으로 정해지지 않는다. 이관에는 소급 경계가 있어서 결정 전에 열린 이슈는 옛 + // 담당자가 계속 맡는다 (#1910). 경로만 보면 그 경계를 표현할 자리가 없으므로 대표 이슈 번호를 + // 함께 넘긴다. 이슈 담당 대조와 이 경로 대조가 같은 지도를 다르게 읽으면, 한쪽은 통과시키고 + // 다른 쪽은 막는 상태가 생긴다. + const issueNumber = extractTitleIssueNumber(pullRequest.title); + if (issueNumber === null) { + // 제목 규칙은 같은 job 의 앞 스텝(validate-pr-issue-link)이 이미 막는다. 경계를 판정할 수 + // 없는 채로 남의 모듈이라고 단정하지 않고, 그쪽 실패에 맡긴다. + return { skipped: "대표 이슈 번호 없음", violations: [] }; + } + + const violations = []; + for (const raw of changedPaths) { + const filePath = normalize(raw); + if (!isProductionPath(filePath)) continue; + const module = moduleKeyOf(filePath); + if (!module) continue; + const owner = resolveOwner(module, issueNumber); + if (!owner) continue; // 지도에 없는 모듈은 판정하지 않는다 — 지도 갱신은 reconcile 쪽 일이다. + if (owner.toLowerCase() !== login.toLowerCase()) violations.push({ path: filePath, module, owner }); + } + return { violations }; +} + +export function formatViolations(pullRequest, violations) { + const byModule = new Map(); + for (const v of violations) byModule.set(v.module, [...(byModule.get(v.module) ?? []), v]); + const lines = [...byModule.entries()].map(([module, items]) => + ` ${module}(담당 @${items[0].owner}) ${items.length}건: ${items.map((i) => i.path).join(", ")}`); + return [ + `PR #${pullRequest.number} 작성자 @${pullRequest.user.login} 가 담당하지 않는 모듈의 프로덕션 파일을 바꿨습니다.`, + ...lines, + `테스트 소스셋은 무관하고, 프로덕션 파일은 떼어 담당자에게 인계하세요. 인계를 기다릴 수 없는 긴급 수선이면 \`${ISSUE_ASSIGNEE_EXEMPT_LABEL}\` 라벨을 붙인 뒤 재검증(본문 수정 또는 push)을 트리거하세요.`, + ].join("\n"); +} + +async function main() { + const [pullRequestPath, filesPath] = process.argv.slice(2); + if (!pullRequestPath || !filesPath) throw new Error("사용법: validate-pr-module-owner.mjs "); + const event = JSON.parse(await readFile(pullRequestPath, "utf8")); + const pullRequest = event.pull_request ?? event; + if (!Number.isInteger(pullRequest?.number)) throw new Error("pull request JSON이 아닙니다."); + const changedPaths = changedPathsFromGithubFiles(JSON.parse(await readFile(filesPath, "utf8"))); + + const result = validatePullRequestModuleOwner({ pullRequest, changedPaths }); + if (result.skipped) { + console.log(`${result.skipped} — 모듈 담당 대조 건너뜀`); + return; + } + if (result.violations.length > 0) throw new Error(formatViolations(pullRequest, result.violations)); + console.log(`PR #${pullRequest.number} 변경 파일 ${changedPaths.length}개 — 모듈 담당 대조 통과`); +} + +const isDirectExecution = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isDirectExecution) { + try { + await main(); + } catch (error) { + console.error(`::error::${error.message}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/validate-pr-module-owner.test.mjs b/.github/scripts/validate-pr-module-owner.test.mjs new file mode 100644 index 0000000000..f94d122675 --- /dev/null +++ b/.github/scripts/validate-pr-module-owner.test.mjs @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { ASSIGNEE_BY_MODULE, HANDOVER_BY_MODULE } from "./reconcile-issue-metadata.mjs"; +import { + formatViolations, + isProductionPath, + moduleKeyOf, + validatePullRequestModuleOwner, +} from "./validate-pr-module-owner.mjs"; + +// 기본 이슈 번호는 모든 이관 경계 위다. 경계 아래 판정은 아래 전용 테스트가 따로 본다. +const pr = (login, labels = [], type = "User", issueNumber = 9999) => ({ + number: 1, + title: `fix(scope): 요약 (#${issueNumber})`, + user: { login, type }, + labels: labels.map((name) => ({ name })), +}); + +test("경로가 모듈 키로 갈린다 — feature·core·platform·소유자 없음", () => { + assert.equal(moduleKeyOf("feature/mindrecord/presentation/src/main/kotlin/A.kt"), "mindrecord"); + assert.equal(moduleKeyOf("feature/home/presentation/src/main/kotlin/receiver/ReceiverHomeScreen.kt"), "home"); + assert.equal(moduleKeyOf("core/ui/src/main/kotlin/Scaffold.kt"), "core"); + assert.equal(moduleKeyOf("app/src/main/AndroidManifest.xml"), "app"); + assert.equal(moduleKeyOf("build-logic/src/main/kotlin/VersionCode.kt"), "platform"); + assert.equal(moduleKeyOf(".github/workflows/lint.yml"), "platform"); + assert.equal(moduleKeyOf("settings.gradle.kts"), "platform"); + assert.equal(moduleKeyOf("docs/qa/assumptions.md"), null); + assert.equal(moduleKeyOf("README.md"), null); +}); + +test("테스트 소스셋은 프로덕션이 아니고, 모듈의 빌드 스크립트·리소스는 프로덕션이다", () => { + for (const p of [ + "feature/setting/presentation/src/test/kotlin/T.kt", + "app/src/androidTest/java/A.kt", + "core/domain/src/testFixtures/kotlin/Fake.kt", + "feature/home/presentation/src/screenshotTest/kotlin/S.kt", + // 골든은 variant 접미사 소스셋에 산다 — core:ui 변경이 남의 모듈 골든을 갱신하는 것은 정상 경로다. + "feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/S_0.png", + "feature/afternote/presentation/src/screenshotTestRelease/reference/x.png", + ]) assert.equal(isProductionPath(p), false, p); + for (const p of [ + "feature/setting/presentation/src/main/kotlin/A.kt", + "feature/setting/presentation/build.gradle.kts", + "app/src/debug/AndroidManifest.xml", + "core/ui/src/main/res/values/strings.xml", + ]) assert.equal(isProductionPath(p), true, p); +}); + +test("자기 모듈만 바꾼 PR 은 통과한다", () => { + const { violations } = validatePullRequestModuleOwner({ + pullRequest: pr("Sadturtleman"), + changedPaths: ["feature/mindrecord/presentation/src/main/kotlin/A.kt", "feature/home/presentation/src/main/kotlin/B.kt"], + }); + assert.deepEqual(violations, []); +}); + +test("남의 모듈 프로덕션 파일이 섞이면 모듈·담당자와 함께 실패한다", () => { + const { violations } = validatePullRequestModuleOwner({ + pullRequest: pr("Sadturtleman"), + changedPaths: ["feature/mindrecord/presentation/src/main/kotlin/A.kt", "core/ui/src/main/kotlin/Theme.kt", "app/src/main/res/values/themes.xml"], + }); + assert.deepEqual(violations, [ + { path: "core/ui/src/main/kotlin/Theme.kt", module: "core", owner: ASSIGNEE_BY_MODULE.core }, + { path: "app/src/main/res/values/themes.xml", module: "app", owner: ASSIGNEE_BY_MODULE.app }, + ]); + const message = formatViolations(pr("Sadturtleman"), violations); + assert.match(message, /core\(담당 @1hyok\) 1건/); + assert.match(message, /app\(담당 @1hyok\) 1건/); + assert.match(message, /issue-assignee-exempt/); +}); + +test("남의 모듈이라도 테스트 소스셋·문서는 위반이 아니다 (0830 확정)", () => { + const { violations } = validatePullRequestModuleOwner({ + pullRequest: pr("Sadturtleman"), + changedPaths: ["core/ui/src/test/kotlin/ThemeTest.kt", "app/src/androidTest/java/X.kt", "docs/qa/assumptions.md", "README.md"], + }); + assert.deepEqual(violations, []); +}); + +test("issue-assignee-exempt 라벨은 파일 담당 대조도 함께 면제한다", () => { + const result = validatePullRequestModuleOwner({ + pullRequest: pr("Sadturtleman", ["issue-assignee-exempt"]), + changedPaths: ["core/ui/src/main/kotlin/Theme.kt"], + }); + assert.equal(result.skipped, "issue-assignee-exempt 라벨"); + assert.deepEqual(result.violations, []); +}); + +test("봇 작성자는 판정하지 않는다", () => { + for (const bot of [pr("dependabot[bot]"), pr("github-actions", [], "Bot")]) { + const result = validatePullRequestModuleOwner({ pullRequest: bot, changedPaths: ["gradle/libs.versions.toml"] }); + assert.equal(result.skipped, "봇 작성자"); + } +}); + +test("담당자 비교는 대소문자를 가리지 않는다", () => { + const { violations } = validatePullRequestModuleOwner({ + pullRequest: pr("SADTURTLEMAN"), + changedPaths: ["feature/mindrecord/presentation/src/main/kotlin/A.kt"], + }); + assert.deepEqual(violations, []); +}); + +test("지도는 reconcile-issue-metadata 하나다 — 이 스크립트에 담당 표를 다시 적지 않는다", async () => { + const source = await readFile(new URL("./validate-pr-module-owner.mjs", import.meta.url), "utf8"); + assert.match(source, /import \{ assigneeForIssue \} from "\.\/reconcile-issue-metadata\.mjs"/); + for (const login of new Set(Object.values(ASSIGNEE_BY_MODULE))) { + assert.doesNotMatch(source, new RegExp(`"${login}"`), `담당자 ${login} 가 스크립트에 하드코딩됐다`); + } +}); + +test("Repository Quality 가 이 검증기를 링크 게이트 뒤에 PR 전용으로 부른다", async () => { + const workflow = await readFile(new URL("../workflows/repository-quality.yml", import.meta.url), "utf8"); + const linked = workflow.indexOf("- name: Require linked Issue"); + const owner = workflow.indexOf("- name: Require module owner"); + assert.notEqual(owner, -1, "Require module owner 스텝이 없다"); + assert.ok(owner > linked, "링크 게이트 뒤에 와야 한다 — 이슈 담당 대조가 먼저다"); + assert.match(workflow, /- name: Require module owner\n\s+if: inputs\.pull_request_number > 0/); + assert.match(workflow, /validate-pr-module-owner\.mjs "\$PULL_REQUEST_JSON" "\$FILES_JSON"/); +}); + +test("이관 경계 아래 이슈로 연 PR 은 옛 담당자 기준으로 판정한다 (#1910)", () => { + // 경로만 보는 가드가 지도값만 읽으면, 이관 전에 열린 이슈로 진행 중인 PR 이 남의 모듈을 + // 건드린 것으로 잡힌다. 이슈 담당 대조는 통과시키는데 이쪽만 막는 상태가 된다. + for (const [module, handover] of Object.entries(HANDOVER_BY_MODULE)) { + const path = `feature/${module}/presentation/src/main/kotlin/A.kt`; + assert.equal(moduleKeyOf(path), module); + + const before = validatePullRequestModuleOwner({ + pullRequest: pr(handover.before, [], "User", handover.fromIssue - 1), + changedPaths: [path], + }); + assert.deepEqual(before.violations, [], `${module} 경계 아래는 옛 담당자 몫이다`); + + const after = validatePullRequestModuleOwner({ + pullRequest: pr(handover.before, [], "User", handover.fromIssue), + changedPaths: [path], + }); + assert.deepEqual( + after.violations, + [{ path, module, owner: ASSIGNEE_BY_MODULE[module] }], + `${module} 경계 위는 새 담당자 몫이다`, + ); + } +}); + +test("대표 이슈 번호를 못 읽으면 대조를 건너뛴다 — 제목 게이트가 앞에서 막는다", () => { + const result = validatePullRequestModuleOwner({ + pullRequest: { + number: 1, + title: "fix(core): 제목이 대표 이슈로 끝나지 않는다", + user: { login: "Sadturtleman", type: "User" }, + labels: [], + }, + changedPaths: ["core/ui/src/main/kotlin/Theme.kt"], + }); + assert.equal(result.skipped, "대표 이슈 번호 없음"); + assert.deepEqual(result.violations, []); +}); diff --git a/.github/scripts/validate-test-only-production-declarations.mjs b/.github/scripts/validate-test-only-production-declarations.mjs new file mode 100644 index 0000000000..dd1f317245 --- /dev/null +++ b/.github/scripts/validate-test-only-production-declarations.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node + +// PR 이 src/main 에 새로 넣은 Kotlin 함수가 main 어디서도 참조되지 않으면 실패시킨다 (#1895). +// +// 0904 PR #1583 에 ViewModel 멤버 `onReceiverRegisterStart()` 가 main 호출자 없이 테스트만 참조하는 +// 채로 올라갔다. `ProductionVisibilityKonsistTest`(#1678) 는 최상위 선언만 보므로 멤버 함수는 어떤 +// 게이트에도 걸리지 않았다. 이 스크립트는 PR files API 의 `patch` 에서 «추가된» 선언만 뽑아 +// 체크아웃된 PR 트리에서 이름 참조를 센다 — 기존 코드의 미참조 멤버는 대상이 아니라 baseline 이 없다. +// +// 판정은 이름 기반이라 거짓 음성 쪽으로 기운다(같은 이름이 main 어디든 있으면 통과). 참조는 있지만 +// 죽은 코드(항상 첫 줄에서 return · 소비자가 항상 null 을 넘기는 파라미터)는 못 본다 — 리뷰 몫이다. + +import { spawnSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { flattenPaginatedFiles } from "./classify-documentation-changes.mjs"; + +export const EXEMPT_LABEL = "test-only-production-exempt"; + +const MAIN_KOTLIN_RE = /(^|\/)src\/main\/.*\.kt$/; +// testing 모듈의 main 은 테스트 픽스처다. konsist·build-logic 은 앱 코드가 아니다. +const SKIP_PATH_RE = /\/testing\/|^konsist\/|^build-logic\//; +const FUN_DECL_RE = + /^\+\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:(?:public|internal|protected)\s+)?(?:(?:suspend|inline|infix|tailrec|open|actual|expect)\s+)*fun\s+(?:<[^>]+>\s+)?(?:[\w.<>?*, ]+\.)?(\w+)\s*\(/; +// private 은 파일 안, override 는 인터페이스, operator 는 연산자 문법, abstract 는 구현체를 거쳐 불린다. +const SKIP_DECL_RE = /^\+[^\n]*\b(?:private|override|operator|abstract)\b[^\n]*\bfun\b/; +// Dagger 가 호출자를 생성하므로 소스 참조는 원리적으로 0건이다. 모듈을 interface 로 쓰면 abstract +// 키워드가 없어 위 SKIP_DECL_RE 에 걸리지 않는다 (#1906). +const DI_BINDING_RE = /^@(?:Binds|Provides)\b/; +const ANNOTATION_ONLY_RE = /^@\w+(?:\([^)]*\))?$/; + +export function addedFunctionNames(patch) { + const names = []; + // 애노테이션은 선언보다 앞줄에 온다. 그 줄들을 지나는 동안 «지금 DI 바인딩을 읽는 중» 을 들고 간다. + let diBinding = false; + for (const line of String(patch ?? "").split("\n")) { + if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@")) { + diBinding = false; + continue; + } + if (line.startsWith("-")) continue; + const body = line.slice(1).trim(); + if (body === "") continue; + if (DI_BINDING_RE.test(body)) { + diBinding = true; + // 애노테이션과 선언이 한 줄이면 아래 선언 처리로 내려가 그 자리에서 상태를 닫는다. + if (!/\bfun\b/.test(body)) continue; + } + // 다른 애노테이션(@Singleton·@Suppress…)은 사이에 끼어도 바인딩 판정을 끊지 않는다. + if (ANNOTATION_ONLY_RE.test(body)) continue; + const match = FUN_DECL_RE.exec(line); + if (match) { + const sameLineBinding = /@(?:Binds|Provides)\b/.test(line.slice(0, line.indexOf("fun"))); + if (line.startsWith("+") && !SKIP_DECL_RE.test(line) && !diBinding && !sameLineBinding) { + names.push(match[1]); + } + } + diBinding = false; + } + return names; +} + +function isCandidateFile(file, index) { + if (file === null || typeof file !== "object" || typeof file.filename !== "string") { + throw new Error(`pull request file at index ${index} has no valid filename`); + } + if (!MAIN_KOTLIN_RE.test(file.filename) || SKIP_PATH_RE.test(file.filename)) return false; + if (file.status === "removed") return false; + // 순수 rename 은 patch 가 없고 새 선언도 없다. + if (file.status === "renamed" && Number(file.changes ?? 0) === 0) return false; + return true; +} + +export function candidateDeclarations(files) { + const declarations = []; + files.forEach((file, index) => { + if (!isCandidateFile(file, index)) return; + if (typeof file.patch !== "string") { + // fail-closed — patch 없이 «새 선언 없음» 으로 통과시키면 큰 파일이 게이트를 우회한다. + throw new Error(`${file.filename}: pull request files API 응답에 patch 가 없어 새 선언을 판정할 수 없습니다`); + } + for (const name of addedFunctionNames(file.patch)) { + declarations.push({ filename: file.filename, name }); + } + }); + return declarations; +} + +const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +// git grep 은 POSIX ERE 다 — \b·\s 는 GNU 확장이라 플랫폼에 따라 글자로 읽힌다. +export function referencePattern(name) { + const escaped = escapeRegExp(name); + // 인자가 후행 람다뿐인 호출은 `이름 {` 이라 `(` 만 세면 0건이 된다 — Compose 소비가 전부 이 꼴이다 (#1906). + return `(^|[^[:alnum:]_])${escaped}[[:space:]]*[({]|::${escaped}([^[:alnum:]_]|$)`; +} + +function gitGrepCount(root, revision, pattern, pathspecs) { + const args = ["-C", root, "grep", "-c", "-E", pattern]; + if (revision) args.push(revision); + args.push("--", ...pathspecs); + const result = spawnSync("git", args, { encoding: "utf8" }); + if (result.status !== 0 && result.status !== 1) { + throw new Error(`git grep failed (${result.status}): ${result.stderr.trim()}`); + } + return result.stdout + .split("\n") + .filter((line) => line.includes(":")) + .reduce((total, line) => total + Number(line.slice(line.lastIndexOf(":") + 1)), 0); +} + +async function ownFileText(root, revision, filename) { + if (revision) { + const result = spawnSync("git", ["-C", root, "show", `${revision}:${filename}`], { encoding: "utf8" }); + return result.status === 0 ? result.stdout : ""; + } + try { + return await readFile(resolve(root, filename), "utf8"); + } catch { + return ""; + } +} + +async function ownFileReferences(root, revision, filename, name) { + const escaped = escapeRegExp(name); + const use = new RegExp(`(^|[^\\w])${escaped}\\s*[({]|::${escaped}\\b`); + const declaration = new RegExp(`\\bfun\\s+(?:<[^>]+>\\s+)?(?:[\\w.<>?*, ]+\\.)?${escaped}\\s*\\(`); + return (await ownFileText(root, revision, filename)) + .split("\n") + .filter((line) => use.test(line) && !declaration.test(line)).length; +} + +export async function findViolations(files, { root, revision = "" } = {}) { + const violations = []; + for (const { filename, name } of candidateDeclarations(files)) { + const pattern = referencePattern(name); + const mainReferences = + gitGrepCount(root, revision, pattern, ["*/src/main/*", `:!${filename}`]) + + (await ownFileReferences(root, revision, filename, name)); + if (mainReferences > 0) continue; + const testReferences = gitGrepCount(root, revision, pattern, ["*/src/test/*", "*/src/androidTest/*"]); + violations.push({ filename, name, kind: testReferences > 0 ? "test-only" : "unreferenced" }); + } + return violations; +} + +const KIND_LABEL = { "test-only": "테스트만 부른다", unreferenced: "아무도 안 부른다" }; + +export function describe(violation) { + return `${violation.filename}::${violation.name} — main 참조 0, ${KIND_LABEL[violation.kind]}`; +} + +export function hasExemptLabel(pullRequest) { + return (pullRequest?.labels ?? []).some((label) => (typeof label === "string" ? label : label?.name) === EXEMPT_LABEL); +} + +// 로컬 점검용 — `--local [head]` 는 API 없이 git diff 로 같은 files 배열을 만든다. +export function filesFromGitDiff(root, base, head = "HEAD") { + const result = spawnSync( + "git", + ["-C", root, "diff", `${base}...${head}`, "--diff-filter=AMR", "--", "*/src/main/*.kt"], + { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, + ); + if (result.status !== 0) throw new Error(`git diff failed: ${result.stderr.trim()}`); + const files = []; + let current = null; + for (const line of result.stdout.split("\n")) { + const header = /^diff --git a\/(.+?) b\/(.+)$/.exec(line); + if (header) { + current = { filename: header[2], status: "modified", changes: 1, patch: "" }; + files.push(current); + continue; + } + if (current && (line.startsWith("+") || line.startsWith("-") || line.startsWith("@@") || line.startsWith(" "))) { + current.patch += `${line}\n`; + } + } + return files; +} + +async function main(argv) { + // --local 은 개발자가 저장소 루트에서 부른다. Actions 러너에서 돌리는 테스트에도 GITHUB_WORKSPACE 가 + // 실제 저장소로 잡혀 있으므로, 그 값은 PR 모드에서만 믿는다 (0904 CI 실측 — fixture 대신 레포를 읽었다). + const root = argv[0] === "--local" ? process.cwd() : process.env.GITHUB_WORKSPACE || process.cwd(); + let files; + let pullRequest = { labels: [] }; + let revision = ""; + if (argv[0] === "--local") { + const [, base, head = "HEAD"] = argv; + if (!base) throw new Error("--local [head]"); + revision = head; + files = filesFromGitDiff(root, base, head); + } else { + const [pullRequestPath, filesPath] = argv; + if (!pullRequestPath || !filesPath) { + throw new Error("usage: validate-test-only-production-declarations.mjs | --local [head]"); + } + pullRequest = JSON.parse(await readFile(pullRequestPath, "utf8")); + files = flattenPaginatedFiles(JSON.parse(await readFile(filesPath, "utf8"))); + } + + const violations = await findViolations(files, { root, revision }); + if (violations.length === 0) { + console.log("새로 넣은 프로덕션 함수는 전부 main 에서 참조된다."); + return 0; + } + const exempt = hasExemptLabel(pullRequest); + for (const violation of violations) { + console.log(`::${exempt ? "warning" : "error"} file=${violation.filename}::${describe(violation)}`); + } + console.log( + `${violations.length}건 — 프로덕션 호출자가 없는 함수는 지금 필요 없는 코드다. 함수와 그 테스트를 빼거나 main 호출자를 붙여라.` + + (exempt + ? ` (${EXEMPT_LABEL} 라벨로 면제 — PR 본문에 소비처를 적어 두었는지 확인)` + : ` 후속 PR 이 곧 소비하는 공개 계약이면 PR 에 \`${EXEMPT_LABEL}\` 라벨을 붙이고 본문에 소비처를 적는다.`), + ); + return exempt ? 0 : 1; +} + +const isDirectExecution = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectExecution) { + main(process.argv.slice(2)) + .then((code) => { + process.exitCode = code; + }) + .catch((error) => { + console.error(`::error::${error.message}`); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/validate-test-only-production-declarations.test.mjs b/.github/scripts/validate-test-only-production-declarations.test.mjs new file mode 100644 index 0000000000..7805d00102 --- /dev/null +++ b/.github/scripts/validate-test-only-production-declarations.test.mjs @@ -0,0 +1,204 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + EXEMPT_LABEL, + addedFunctionNames, + candidateDeclarations, + filesFromGitDiff, + findViolations, + hasExemptLabel, + referencePattern, +} from "./validate-test-only-production-declarations.mjs"; + +const scriptPath = fileURLToPath(new URL("./validate-test-only-production-declarations.mjs", import.meta.url)); +const gitEnvironment = { + ...process.env, + GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t", +}; + +function git(repo, ...args) { + const result = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", env: gitEnvironment }); + assert.equal(result.status, 0, result.stderr); + return result.stdout; +} + +function write(repo, relative, text) { + mkdirSync(join(repo, relative, ".."), { recursive: true }); + writeFileSync(join(repo, relative), text); +} + +// develop 에 Base.kt, feat 브랜치에 새 함수 6종 — 테스트만/아무도/main 호출/private/override/파일 안 호출. +function fixture() { + const repo = mkdtempSync(join(tmpdir(), "test-only-production-")); + git(repo, "init", "-q", "-b", "develop"); + write(repo, "app/src/main/kotlin/a/Base.kt", "package a\n\nclass Base {\n fun stay() = 1\n}\n"); + git(repo, "add", "."); + git(repo, "commit", "-q", "-m", "base"); + git(repo, "checkout", "-q", "-b", "feat/x"); + write(repo, "app/src/main/kotlin/a/Vm.kt", `package a + +class Vm : Parent() { + fun onlyTestCalls() = 1 + fun nobodyCalls() = 2 + fun usedInMain() = 3 + private fun hidden() = 4 + override fun onCleared() = Unit + fun helperUsedHere() = 5 + fun caller() = helperUsedHere() +} +`); + write(repo, "app/src/main/kotlin/a/Route.kt", "package a\n\nprivate fun route(vm: Vm) { vm.usedInMain(); vm.caller() }\n"); + write(repo, "app/src/test/kotlin/a/VmTest.kt", "package a\n\nclass VmTest { fun t() { Vm().onlyTestCalls() } }\n"); + git(repo, "add", "."); + git(repo, "commit", "-q", "-m", "feat"); + return repo; +} + +const VM_PATCH = `@@ -0,0 +1,10 @@ ++package a ++ ++class Vm : Parent() { ++ fun onlyTestCalls() = 1 ++ fun nobodyCalls() = 2 ++ fun usedInMain() = 3 ++ private fun hidden() = 4 ++ override fun onCleared() = Unit ++ fun helperUsedHere() = 5 ++ fun caller() = helperUsedHere() ++}`; + +test("추가된 줄의 fun 선언만 모으고 private·override·operator·abstract 는 뺀다", () => { + assert.deepEqual(addedFunctionNames(VM_PATCH), ["onlyTestCalls", "nobodyCalls", "usedInMain", "helperUsedHere", "caller"]); + assert.deepEqual(addedFunctionNames("@@ -1 +1 @@\n-fun old()\n+ internal suspend fun List.pick(i: Int)"), ["pick"]); + assert.deepEqual(addedFunctionNames("+ @Composable\n+ fun Screen(state: S) {\n+ operator fun plus(o: A) = o\n+ abstract fun load()"), ["Screen"]); + assert.deepEqual(addedFunctionNames("+++ b/x.kt\n fun context() = 1\n-fun removed() = 2"), []); +}); + +test("Hilt @Binds·@Provides 선언은 후보에서 뺀다 — 호출자는 Dagger 가 생성한다 (#1906)", () => { + const patch = [ + "@@ -1 +1 @@", + "+@Module", + "+@InstallIn(SingletonComponent::class)", + "+interface AuthorRepositoryModule {", + "+ @Suppress(\"unused\")", + "+ @Binds", + "+ @Singleton", + "+ fun bindAudioUploadRepository(impl: AudioUploadRepositoryImpl): AudioUploadRepository", + "+", + "+ @Provides fun provideClock(): Clock = Clock.systemUTC()", + "+", + "+ fun notABinding(): Int = 1", + "+}", + ].join("\n"); + + assert.deepEqual(addedFunctionNames(patch), ["notABinding"]); +}); + +test("후행 람다 호출도 참조로 센다 — Compose 소비가 전부 이 꼴이다 (#1906)", async () => { + const repo = fixture(); + write(repo, "app/src/main/kotlin/a/Theme.kt", "package a\n\nfun ThemeWrapper(content: () -> Unit) { content() }\n"); + write(repo, "app/src/main/kotlin/a/Host.kt", "package a\n\nprivate fun host() { ThemeWrapper { } }\n"); + git(repo, "add", "."); + git(repo, "commit", "-q", "-m", "trailing lambda"); + + const patch = "@@ -0,0 +1 @@\n+fun ThemeWrapper(content: () -> Unit) { content() }"; + const violations = await findViolations( + [{ filename: "app/src/main/kotlin/a/Theme.kt", status: "added", patch }], + { root: repo }, + ); + + assert.deepEqual(violations, []); + assert.match(referencePattern("ThemeWrapper"), /\[\(\{\]/); +}); + +test("src/main Kotlin 만 보고, testing 모듈·konsist·삭제·순수 rename 은 건너뛴다", () => { + const files = [ + { filename: "app/src/main/kotlin/a/Vm.kt", status: "added", patch: VM_PATCH }, + { filename: "app/src/test/kotlin/a/VmTest.kt", status: "added", patch: "+fun onlyInTest() = 1" }, + { filename: "core/domain/testing/src/main/kotlin/Fake.kt", status: "added", patch: "+fun fake() = 1" }, + { filename: "konsist/src/main/kotlin/K.kt", status: "added", patch: "+fun k() = 1" }, + { filename: "app/src/main/kotlin/a/Gone.kt", status: "removed", patch: "-fun gone() = 1" }, + { filename: "app/src/main/kotlin/a/Moved.kt", status: "renamed", changes: 0 }, + { filename: "app/src/main/res/values/strings.xml", status: "modified", patch: "+fun x(" }, + ]; + assert.deepEqual( + candidateDeclarations(files).map((d) => d.name), + ["onlyTestCalls", "nobodyCalls", "usedInMain", "helperUsedHere", "caller"], + ); +}); + +test("main Kotlin 파일에 patch 가 없으면 통과시키지 않고 실패한다", () => { + assert.throws( + () => candidateDeclarations([{ filename: "app/src/main/kotlin/a/Big.kt", status: "modified", changes: 3 }]), + /Big\.kt: .*patch 가 없어/, + ); +}); + +test("참조 패턴은 POSIX ERE 다 — \\b·\\s 를 쓰지 않는다", () => { + const pattern = referencePattern("toggle$x"); + assert.doesNotMatch(pattern, /\\[bs]/); + assert.match(pattern, /\[\[:space:\]\]/); + assert.match(pattern, /::toggle\\\$x/); +}); + +test("테스트만 부르는 함수와 아무도 안 부르는 함수만 위반이다", async () => { + const repo = fixture(); + const files = [{ filename: "app/src/main/kotlin/a/Vm.kt", status: "added", patch: VM_PATCH }]; + + const violations = await findViolations(files, { root: repo }); + + assert.deepEqual(violations, [ + { filename: "app/src/main/kotlin/a/Vm.kt", name: "onlyTestCalls", kind: "test-only" }, + { filename: "app/src/main/kotlin/a/Vm.kt", name: "nobodyCalls", kind: "unreferenced" }, + ]); +}); + +test("main 호출자를 붙이면 위반이 사라진다", async () => { + const repo = fixture(); + write(repo, "app/src/main/kotlin/a/Route.kt", + "package a\n\nprivate fun route(vm: Vm) { vm.usedInMain(); vm.caller(); vm.onlyTestCalls(); vm.nobodyCalls() }\n"); + git(repo, "commit", "-q", "-am", "wire"); + + assert.deepEqual(await findViolations([{ filename: "app/src/main/kotlin/a/Vm.kt", status: "added", patch: VM_PATCH }], { root: repo }), []); +}); + +test("--local 은 git diff 로 같은 files 배열을 만들고 리비전에 대고 검사한다", async () => { + const repo = fixture(); + const files = filesFromGitDiff(repo, "develop", "HEAD"); + + assert.deepEqual(files.map((f) => f.filename), ["app/src/main/kotlin/a/Route.kt", "app/src/main/kotlin/a/Vm.kt"]); + // Actions 러너처럼 GITHUB_WORKSPACE 가 다른 저장소를 가리켜도 --local 은 cwd 를 본다. + const result = spawnSync("node", [scriptPath, "--local", "develop", "HEAD"], { + cwd: repo, encoding: "utf8", env: { ...process.env, GITHUB_WORKSPACE: "/nonexistent-workspace" }, + }); + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stdout, /::error file=app\/src\/main\/kotlin\/a\/Vm\.kt::.*onlyTestCalls — main 참조 0, 테스트만 부른다/); + assert.match(result.stdout, /nobodyCalls — main 참조 0, 아무도 안 부른다/); + assert.doesNotMatch(result.stdout, /usedInMain|hidden|onCleared|helperUsedHere|caller|route/); +}); + +test("PR 모드는 files API 페이지를 읽고, 면제 라벨이 있으면 경고로 낮춰 통과시킨다", async () => { + const repo = fixture(); + const filesPath = join(repo, "files.json"); + writeFileSync(filesPath, JSON.stringify([[{ filename: "app/src/main/kotlin/a/Vm.kt", status: "added", patch: VM_PATCH }]])); + const prPath = join(repo, "pr.json"); + + writeFileSync(prPath, JSON.stringify({ labels: [] })); + const blocked = spawnSync("node", [scriptPath, prPath, filesPath], { cwd: repo, encoding: "utf8", env: { ...process.env, GITHUB_WORKSPACE: repo } }); + assert.equal(blocked.status, 1, blocked.stdout + blocked.stderr); + assert.match(blocked.stdout, /::error file=/); + assert.match(blocked.stdout, new RegExp(EXEMPT_LABEL)); + + writeFileSync(prPath, JSON.stringify({ labels: [{ name: EXEMPT_LABEL }] })); + const exempted = spawnSync("node", [scriptPath, prPath, filesPath], { cwd: repo, encoding: "utf8", env: { ...process.env, GITHUB_WORKSPACE: repo } }); + assert.equal(exempted.status, 0, exempted.stdout + exempted.stderr); + assert.match(exempted.stdout, /::warning file=/); + assert.equal(hasExemptLabel({ labels: [{ name: EXEMPT_LABEL }] }), true); + assert.equal(hasExemptLabel({ labels: ["issue-assignee-exempt"] }), false); +}); diff --git a/.github/scripts/verify-android-manifest.mjs b/.github/scripts/verify-android-manifest.mjs index 0e238694fb..0e4de352c6 100644 --- a/.github/scripts/verify-android-manifest.mjs +++ b/.github/scripts/verify-android-manifest.mjs @@ -11,6 +11,7 @@ export const ALLOWED_PERMISSIONS = new Set([ 'android.permission.INTERNET', 'android.permission.POST_NOTIFICATIONS', 'android.permission.RECEIVE_BOOT_COMPLETED', + 'android.permission.RECORD_AUDIO', 'android.permission.USE_BIOMETRIC', 'android.permission.USE_FINGERPRINT', 'android.permission.WAKE_LOCK', @@ -25,6 +26,12 @@ export const ALLOWED_UNPROTECTED_EXPORTED_COMPONENTS = new Set([ 'com.kakao.sdk.auth.AuthCodeHandlerActivity', ]); +// enableEdgeToEdge() 를 쓰는 액티비티는 키보드가 떠도 윈도우 프레임이 줄지 않는다. 그 대신 IME +// inset 이 와야 하는데, adjustResize 선언이 없으면 그마저 오지 않아 Compose 의 imePadding() 이 +// 전부 0 을 더한다 — 코드는 살아 있는데 하단 CTA 가 키보드에 덮인다 (#1849). 눈으로도 테스트로도 +// 안 잡히는 회귀라 매니페스트 정책으로 못 박는다. +export const IME_RESIZE_REQUIRED_ACTIVITIES = new Set(['com.afternote.afternote_fe.MainActivity']); + function attributes(source) { return Object.fromEntries( [...source.matchAll(/android:([A-Za-z][A-Za-z0-9]*)\s*=\s*"([^"]*)"/g)].map((match) => [ @@ -39,9 +46,11 @@ export function inspectManifest( { allowedPermissions = ALLOWED_PERMISSIONS, allowedUnprotectedExportedComponents = ALLOWED_UNPROTECTED_EXPORTED_COMPONENTS, + imeResizeRequiredActivities = IME_RESIZE_REQUIRED_ACTIVITIES, } = {}, ) { const violations = []; + const declaredActivities = new Set(); const application = /]*)>/s.exec(source); if (!application) { violations.push('application declaration is missing'); @@ -67,6 +76,15 @@ export function inspectManifest( for (const match of source.matchAll(/<(activity|activity-alias|service|receiver|provider)\b([^>]*)\/?\s*>/gs)) { const [, kind, rawAttributes] = match; const component = attributes(rawAttributes); + if (kind === 'activity' && component.name) { + declaredActivities.add(component.name); + const softInputModes = (component.windowSoftInputMode ?? '').split('|'); + if (imeResizeRequiredActivities.has(component.name) && !softInputModes.includes('adjustResize')) { + violations.push( + `activity must declare android:windowSoftInputMode="adjustResize": ${component.name}`, + ); + } + } if (component.exported !== 'true') { continue; } @@ -80,6 +98,12 @@ export function inspectManifest( } } + for (const activity of imeResizeRequiredActivities) { + if (!declaredActivities.has(activity)) { + violations.push(`activity requiring adjustResize is missing: ${activity}`); + } + } + return violations; } diff --git a/.github/scripts/verify-android-manifest.test.mjs b/.github/scripts/verify-android-manifest.test.mjs index b9f9c462f2..cecbb70889 100644 --- a/.github/scripts/verify-android-manifest.test.mjs +++ b/.github/scripts/verify-android-manifest.test.mjs @@ -3,7 +3,11 @@ import test from 'node:test'; import { inspectManifest } from './verify-android-manifest.mjs'; -const manifest = ({ permissions = '', components = '', cleartext = 'false' } = {}) => ` +const MAIN_ACTIVITY = + ''; + +const manifest = ({ permissions = '', components = MAIN_ACTIVITY, cleartext = 'false' } = {}) => ` ${permissions} @@ -18,7 +22,7 @@ test('current allowed permissions and protected exported components pass', () => `, components: ` - + ${MAIN_ACTIVITY} `, @@ -46,6 +50,7 @@ test('cleartext traffic must stay explicitly disabled', () => { ]); assert.deepEqual(inspectManifest(''), [ 'application must explicitly set android:usesCleartextTraffic="false"', + 'activity requiring adjustResize is missing: com.afternote.afternote_fe.MainActivity', ]); }); @@ -53,6 +58,7 @@ test('unprotected exports and every exported provider fail closed', () => { const violations = inspectManifest( manifest({ components: ` + ${MAIN_ACTIVITY} `, @@ -64,3 +70,28 @@ test('unprotected exports and every exported provider fail closed', () => { 'exported provider is forbidden: example.OpenProvider', ]); }); + +test('edge-to-edge 액티비티에서 adjustResize 가 빠지면 실패한다', () => { + const violations = inspectManifest( + manifest({ + components: + '', + }), + ); + + assert.deepEqual(violations, [ + 'activity must declare android:windowSoftInputMode="adjustResize": com.afternote.afternote_fe.MainActivity', + ]); +}); + +test('adjustResize 는 다른 플래그와 함께 선언해도 통과한다', () => { + const violations = inspectManifest( + manifest({ + components: + '', + }), + ); + + assert.deepEqual(violations, []); +}); diff --git a/.github/scripts/verify-kdoc-links.mjs b/.github/scripts/verify-kdoc-links.mjs new file mode 100644 index 0000000000..37187f2ce4 --- /dev/null +++ b/.github/scripts/verify-kdoc-links.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +// KDoc 의 `[com.afternote…]` FQN 링크가 실제 선언을 가리키는지 센다 (#1889). +// +// 어느 게이트도 안 보던 자리다 — 패키지 이동(#1092 평탄화, #1724 수신자 홈 이동)마다 끊긴 링크가 +// 수십 건씩 쌓였고 0903 에 손으로 30건을 찾아 고쳤다. 링크는 IDE 에서만 경고라 CI 가 세지 않으면 +// 다음 이동에서 그대로 되풀이된다. +// +// 해석 순서 — ① 패키지 링크(`[com.afternote.feature.afternote.domain]`) ② 가장 긴 패키지 접두를 +// 뗀 나머지를 [타입[.중첩][.멤버]] 사슬로 ③ 그것도 아니면 최상위 callable(Composable 함수 등). +// 0903 스캔의 오탐 두 종을 미리 막는다 — 확장 함수 수신자(`fun Foo?.bar`)를 심볼로 오인하지 않게 +// 마지막 이름만 취하고, 패키지 링크는 접두 탐색 «전에» 패키지 집합에서 본다. +// +// 사용: node verify-kdoc-links.mjs [] [--json] +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); +// 빌드 산출물과 점 디렉터리(중첩 워크트리 .claude/.codex 포함)는 걷지 않는다. +const SKIP_DIRS = new Set(["build", "node_modules"]); + +function* kotlinFiles(dir) { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue; + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) yield* kotlinFiles(full); + else if (entry.endsWith(".kt")) yield full; + } +} + +const MODIFIERS = "(?:(?:public|internal|private|protected|open|abstract|sealed|data|enum|annotation|value|inline|inner|companion|expect|actual|const|lateinit|override|external|tailrec|operator|infix|suspend)\\s+)*"; +// 타입 선언 — 중첩 포함(들여쓰기 무관). typealias 도 타입이다. +const TYPE_DECL = new RegExp(`^\\s*${MODIFIERS}(?:class|interface|object|typealias)\\s+\`?(\\w+)\`?`, "gm"); +// 최상위·멤버 fun/val/var — 확장 수신자(Foo?.)와 제네릭()을 걷고 마지막 이름만 취한다(오탐 ①). +const CALLABLE_DECL = new RegExp(`^\\s*${MODIFIERS}(?:fun|val|var)\\s+(?:<[^>]*>\\s*)?(?:[\\w.<>?,\\s*]+?\\.)?\`?(\\w+)\`?\\s*[(:=<{]`, "gm"); +const PACKAGE = /^package\s+([\w.]+)/m; +const KDOC = /\/\*\*([\s\S]*?)\*\//g; +const LINK = /\[(com\.afternote\.[\w.]+)\]/g; + +export function scanKdocLinks(root = DEFAULT_ROOT) { +const files = [...kotlinFiles(root)]; +const pkgOf = new Map(); // file -> package +const textOf = new Map(); +const typesIn = new Map(); // package -> Set +const callablesIn = new Map(); // package -> Set (최상위+멤버 뭉뚱그림 — 멤버는 아래 타입 파일 텍스트로 재확인) +const typeFiles = new Map(); // `${pkg}.${type}` -> [file] +for (const f of files) { + const text = readFileSync(f, "utf8"); + const pkg = PACKAGE.exec(text)?.[1]; + if (!pkg) continue; + textOf.set(f, text); pkgOf.set(f, pkg); + const ts = typesIn.get(pkg) ?? new Set(); const cs = callablesIn.get(pkg) ?? new Set(); + for (const m of text.matchAll(TYPE_DECL)) { ts.add(m[1]); const k = `${pkg}.${m[1]}`; typeFiles.set(k, [...(typeFiles.get(k) ?? []), f]); } + for (const m of text.matchAll(CALLABLE_DECL)) cs.add(m[1]); + typesIn.set(pkg, ts); callablesIn.set(pkg, cs); +} +const packages = new Set(pkgOf.values()); +const packagePrefixes = new Set(); +for (const p of packages) { const parts = p.split("."); for (let i = 1; i <= parts.length; i++) packagePrefixes.add(parts.slice(0, i).join(".")); } + +// 링크 해석 — ② 패키지 링크 먼저. 그다음 가장 긴 패키지 접두를 떼고 남은 사슬을 +// [타입…][.멤버] 또는 [최상위 callable] 로 푼다. +function resolve_(fqn) { + if (packagePrefixes.has(fqn)) return { ok: true, kind: "package" }; + const parts = fqn.split("."); + for (let i = parts.length - 1; i >= 1; i--) { + const pkg = parts.slice(0, i).join("."); + if (!packages.has(pkg)) continue; + const chain = parts.slice(i); + const head = chain[0]; + if (typesIn.get(pkg)?.has(head)) { + if (chain.length === 1) return { ok: true, kind: "type" }; + // 멤버·중첩: 그 타입을 선언한 파일 텍스트에서 이름을 느슨히 확인 + const rest = chain.slice(1); + const filesOfType = typeFiles.get(`${pkg}.${head}`) ?? []; + const seen = filesOfType.some((f) => rest.every((name) => new RegExp(`\\b${name}\\b`).test(textOf.get(f)))); + return seen ? { ok: true, kind: "member" } : { ok: false, reason: `타입 ${pkg}.${head} 에 ${rest.join(".")} 없음` }; + } + if (chain.length === 1 && callablesIn.get(pkg)?.has(head)) return { ok: true, kind: "callable" }; + return { ok: false, reason: `패키지 ${pkg} 에 ${head} 선언 없음` }; + } + return { ok: false, reason: "일치하는 패키지 접두 없음" }; +} + +// 같은 단순 이름의 현재 선언 위치(이동 후보) +function candidates(fqn) { + const parts = fqn.split("."); + let head = parts.findLast((p) => /^[A-Z]/.test(p)) ?? parts.at(-1); + const out = []; + for (const [pkg, ts] of typesIn) if (ts.has(head)) out.push(`${pkg}.${head}`); + if (out.length === 0) for (const [pkg, cs] of callablesIn) if (cs.has(parts.at(-1))) out.push(`${pkg}.${parts.at(-1)}`); + return out; +} + +const broken = []; +let total = 0; +for (const [f, text] of textOf) { + const seenInFile = new Set(); + for (const kd of text.matchAll(KDOC)) for (const l of kd[1].matchAll(LINK)) { + const fqn = l[1]; if (seenInFile.has(fqn)) continue; seenInFile.add(fqn); total++; + const r = resolve_(fqn); + if (!r.ok) broken.push({ file: relative(root, f).replaceAll("\\", "/"), link: fqn, reason: r.reason, candidates: candidates(fqn) }); + } +} +return { total, broken: broken.sort((a, b) => a.file.localeCompare(b.file) || a.link.localeCompare(b.link)) }; +} + +function main() { + const args = process.argv.slice(2); + const asJson = args.includes("--json"); + const root = args.find((a) => !a.startsWith("--")) ?? DEFAULT_ROOT; + const { total, broken } = scanKdocLinks(root); + if (asJson) { console.log(JSON.stringify({ total, broken }, null, 2)); } + else { + console.log(`KDoc FQN 링크 ${total}개 중 끊김 ${broken.length}개`); + for (const b of broken) { + const cand = b.candidates.length ? ` → 후보: ${b.candidates.slice(0, 2).join(" | ")}${b.candidates.length > 2 ? ` (+${b.candidates.length - 2})` : ""}` : " → 선언 없음(링크를 걷고 코드 스팬으로)"; + console.log(` ${b.file}\n [${b.link}]\n ${b.reason}${cand}`); + } + if (broken.length) console.error(`::error::KDoc FQN 링크 ${broken.length}개가 실제 선언을 가리키지 않습니다. 이동이면 새 위치로, 소멸이면 링크를 걷으세요 (#1889).`); + } + process.exitCode = broken.length ? 1 : 0; +} + +const isDirectExecution = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isDirectExecution) main(); diff --git a/.github/scripts/verify-kdoc-links.test.mjs b/.github/scripts/verify-kdoc-links.test.mjs new file mode 100644 index 0000000000..2c6fe4a1d7 --- /dev/null +++ b/.github/scripts/verify-kdoc-links.test.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { scanKdocLinks } from "./verify-kdoc-links.mjs"; + +async function fixture(files) { + const root = await mkdtemp(join(tmpdir(), "kdoc-links-")); + for (const [rel, body] of Object.entries(files)) { + await mkdir(join(root, rel, ".."), { recursive: true }); + await writeFile(join(root, rel), body); + } + return root; +} + +const A = `package com.afternote.feature.x.presentation.detail + +/** 상세 상태. */ +sealed interface DetailUiState { + data class Success(val isDeleting: Boolean) : DetailUiState + data object Error : DetailUiState +} + +class DetailViewModel { + fun onDeleteResultConsumed() = Unit +} + +@Composable +fun AccountDetailScreen() = Unit + +fun List?.orEmptyList(): List = this ?: emptyList() +`; + +test("타입·중첩·멤버·최상위 함수·패키지 링크는 전부 해석된다", async () => { + const root = await fixture({ + "feature/x/presentation/src/main/kotlin/A.kt": A, + "feature/x/presentation/src/main/kotlin/B.kt": `package com.afternote.feature.x.presentation.other + +/** + * [com.afternote.feature.x.presentation.detail.DetailUiState] · + * [com.afternote.feature.x.presentation.detail.DetailUiState.Success.isDeleting] · + * [com.afternote.feature.x.presentation.detail.DetailViewModel.onDeleteResultConsumed] · + * [com.afternote.feature.x.presentation.detail.AccountDetailScreen] · + * [com.afternote.feature.x.presentation.detail.orEmptyList] · + * [com.afternote.feature.x.presentation.detail] · [com.afternote.feature.x] + */ +class B +`, + }); + try { + const { total, broken } = scanKdocLinks(root); + assert.equal(total, 7); + assert.deepEqual(broken, []); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("옛 패키지를 가리키는 링크는 끊김으로 잡히고 현재 위치가 후보로 붙는다", async () => { + const root = await fixture({ + "feature/x/presentation/src/main/kotlin/A.kt": A, + "feature/x/presentation/src/main/kotlin/C.kt": `package com.afternote.feature.x.presentation + +/** [com.afternote.feature.x.presentation.author.detail.DetailViewModel] 과 같다. */ +class C +`, + }); + try { + const { broken } = scanKdocLinks(root); + assert.equal(broken.length, 1); + assert.equal(broken[0].link, "com.afternote.feature.x.presentation.author.detail.DetailViewModel"); + assert.deepEqual(broken[0].candidates, ["com.afternote.feature.x.presentation.detail.DetailViewModel"]); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("사라진 선언은 후보 없이, 없는 멤버는 타입 위치를 후보로 달고 끊김이다", async () => { + const root = await fixture({ + "feature/x/presentation/src/main/kotlin/A.kt": A, + "feature/x/presentation/src/main/kotlin/D.kt": `package com.afternote.feature.x.presentation.detail + +/** [com.afternote.feature.x.presentation.detail.Vanished] · [com.afternote.feature.x.presentation.detail.DetailViewModel.nope] */ +class D +`, + }); + try { + const { broken } = scanKdocLinks(root); + // 타입은 있는데 멤버가 없으면 «어디를 보고 고칠지» 를 알려 주는 게 맞다 — 후보는 타입 위치 하나. + const byName = Object.fromEntries(broken.map((b) => [b.link.split(".").at(-1), b.candidates])); + assert.deepEqual(byName, { + Vanished: [], + nope: ["com.afternote.feature.x.presentation.detail.DetailViewModel"], + }); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("빌드 산출물과 점 디렉터리(중첩 워크트리)는 걷지 않는다", async () => { + const root = await fixture({ + "feature/x/presentation/src/main/kotlin/A.kt": A, + ".claude/worktrees/other/feature/x/src/main/kotlin/Z.kt": `package com.afternote.z +/** [com.afternote.nowhere.Gone] */ +class Z +`, + "feature/x/presentation/build/generated/Gen.kt": `package com.afternote.gen +/** [com.afternote.nowhere.Gone] */ +class Gen +`, + }); + try { + assert.deepEqual(scanKdocLinks(root).broken, []); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("Repository Quality 가 저장소 전체에 스캐너를 돌린다", async () => { + const workflow = await readFile(new URL("../workflows/repository-quality.yml", import.meta.url), "utf8"); + assert.match(workflow, /- name: Verify KDoc FQN links\n\s+run: node \.github\/scripts\/verify-kdoc-links\.mjs$/m); +}); diff --git a/.github/scripts/verify-play-release-bundle.test.mjs b/.github/scripts/verify-play-release-bundle.test.mjs new file mode 100644 index 0000000000..516a6599a3 --- /dev/null +++ b/.github/scripts/verify-play-release-bundle.test.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const verifier = await readFile(new URL("../../scripts/verify-play-release-bundle.sh", import.meta.url), "utf8"); +const signaturePolicy = await readFile(new URL("../../scripts/jarsigner-verification-policy.sh", import.meta.url), "utf8"); +const versionPolicy = await readFile(new URL("../../build-logic/src/main/kotlin/VersionCode.kt", import.meta.url), "utf8"); +const pinnedSha = "a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29"; + +// ZIP와 실제 검증 스크립트를 실행한다. Gradle/JDK/다운로드만 fake로 대체해 Play 계정 없이 +// manifest 응답·주입값·고정 digest·임시 파일 정리를 함께 검증한다. +async function runVerifier({ expected = "202", actual = "202", jarSha = pinnedSha, + javaStatus = 0, providedJar = true, skipBuild = true, source = verifier, + policy = versionPolicy, curlStatus = 0 } = {}) { + const root = await mkdtemp(join(tmpdir(), "afternote-play-bundle-test-")); + try { + const bundle = join(root, "app/build/outputs/bundle/release/app-release.aab"); + for (const directory of ["scripts", "bin", "tmp", "build-logic/src/main/kotlin", + "app/build/outputs/bundle/release", "app/build/outputs/mapping/release", "entries/base/manifest", "entries/base/dex"]) { + await mkdir(join(root, directory), { recursive: true }); + } + await writeFile(join(root, "scripts/verify-play-release-bundle.sh"), source); + await writeFile(join(root, "scripts/jarsigner-verification-policy.sh"), signaturePolicy); + await writeFile(join(root, "build-logic/src/main/kotlin/VersionCode.kt"), policy); + await writeFile(join(root, "app/build/outputs/mapping/release/mapping.txt"), "mapping"); + for (const entry of ["BundleConfig.pb", "base/manifest/AndroidManifest.xml", "base/resources.pb", "base/dex/classes.dex"]) { + await writeFile(join(root, "entries", entry), "fixture"); + } + const zip = spawnSync("zip", ["-qr", bundle, "."], { cwd: join(root, "entries"), encoding: "utf8" }); + assert.equal(zip.status, 0, zip.stderr); + await writeFile(join(root, "bundletool.jar"), "fake pinned jar"); + const programs = { + jarsigner: 'echo "jar verified."', + keytool: 'echo "SHA256: AA:BB"', + shasum: 'printf "%s %s\\n" "$FAKE_JAR_SHA" "$3"', + java: ` +printf '%s\\n' "$*" >> "$FAKE_ROOT/java.calls" +test "$1" = -jar && test "$3" = dump && test "$4" = manifest || exit 91 +test "$5" = "--bundle=$FAKE_ROOT/app/build/outputs/bundle/release/app-release.aab" || exit 92 +test "$6" = --module=base && test "$7" = --xpath=/manifest/@android:versionCode || exit 93 +printf '%s\\n' "$FAKE_MANIFEST_VERSION_CODE" +exit "$FAKE_JAVA_STATUS"`, + curl: ` +printf '%s\\n' "$*" >> "$FAKE_ROOT/curl.calls" +if [ "$FAKE_CURL_STATUS" -ne 0 ]; then exit "$FAKE_CURL_STATUS"; fi +while [ "$#" -gt 0 ]; do + if [ "$1" = --output ]; then shift; printf 'fake pinned jar' > "$1"; fi + shift +done`, + }; + for (const [name, body] of Object.entries(programs)) { + await writeFile(join(root, "bin", name), `#!/usr/bin/env bash\nset -euo pipefail\n${body}\n`, { mode: 0o755 }); + } + await writeFile(join(root, "gradlew"), '#!/usr/bin/env bash\nprintf "%s\\n" "$*" > "$FAKE_ROOT/gradle.calls"\n', { mode: 0o755 }); + const env = { ...process.env, PATH: `${join(root, "bin")}:${process.env.PATH}`, + TMPDIR: join(root, "tmp"), FAKE_ROOT: root, FAKE_JAR_SHA: jarSha, + FAKE_MANIFEST_VERSION_CODE: actual, FAKE_JAVA_STATUS: String(javaStatus), FAKE_CURL_STATUS: String(curlStatus) }; + delete env.AFTERNOTE_VERSION_CODE; + delete env.BUNDLETOOL_JAR; + if (expected !== null) env.AFTERNOTE_VERSION_CODE = expected; + if (providedJar) env.BUNDLETOOL_JAR = join(root, "bundletool.jar"); + const result = spawnSync("bash", [join(root, "scripts/verify-play-release-bundle.sh"), ...(skipBuild ? ["--skip-build"] : [])], + { env, encoding: "utf8", timeout: 10_000 }); + assert.equal(result.error, undefined, result.error?.message); + const readOptional = (name) => readFile(join(root, name), "utf8").catch(() => ""); + const [javaCalls, curlCalls, gradleCalls] = await Promise.all([readOptional("java.calls"), readOptional("curl.calls"), readOptional("gradle.calls")]); + return { ...result, javaCalls, curlCalls, gradleCalls, temporaryFiles: await readdir(join(root, "tmp")) }; + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test("manifest versionCode 가 주입값과 같아야 로컬과 skip-build 검증을 통과한다", async () => { + for (const skipBuild of [false, true]) { + const result = await runVerifier({ skipBuild }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /versionCode: 202/); + assert.match(result.javaCalls, /dump manifest/); + assert.equal(result.curlCalls, "", "이미 고정 digest의 jar가 있으면 다운로드하지 않는다"); + assert.equal(Boolean(result.gradleCalls), !skipBuild); + } +}); + +// Play 대역 하한(#2062)이 들어간 뒤 resolver 가 내는 첫 값이다. 검증기는 1 이상 2,100,000,000 +// 이하만 보고 대역은 모르므로, 열 자리 값도 그대로 통과해야 한다. +test("Play 대역 versionCode 도 기대값 그대로 통과한다", async () => { + const result = await runVerifier({ expected: "1000000101", actual: "1000000101" }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /versionCode: 1000000101/); +}); + +test("manifest versionCode 누락·비정수·불일치와 bundletool 실패는 거부한다", async () => { + for (const options of [{ actual: "1" }, { actual: "" }, { actual: "abc" }, { actual: "202\n202" }, { javaStatus: 1 }]) { + const result = await runVerifier(options); + assert.notEqual(result.status, 0, JSON.stringify(options)); + assert.match(result.stderr, /versionCode|bundletool/, JSON.stringify(options)); + } +}); + +test("versionCode 미설정은 Gradle 기본값을 읽고 명시된 잘못된 값은 거부한다", async () => { + const changedDefault = versionPolicy.replace("DEFAULT_AFTERNOTE_VERSION_CODE = 1", "DEFAULT_AFTERNOTE_VERSION_CODE = 7"); + const result = await runVerifier({ expected: null, actual: "7", policy: changedDefault }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /versionCode: 7/); + for (const expected of ["", "0", "01", "not-a-code", "2100000001", "99999999999999999999999"]) { + const invalid = await runVerifier({ expected, actual: expected }); + assert.notEqual(invalid.status, 0, expected); + assert.match(invalid.stderr, /AFTERNOTE_VERSION_CODE/); + } + const trimmed = await runVerifier({ expected: " 202 ", actual: "202" }); + assert.equal(trimmed.status, 0, trimmed.stderr); +}); + +test("Gradle 기본 상수의 private 여부는 값 추출에 영향을 주지 않는다", async () => { + for (const visibility of ["", "private "]) { + const result = await runVerifier({ expected: null, actual: "1234", + policy: `${visibility}const val DEFAULT_AFTERNOTE_VERSION_CODE = 1_234\n` }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /versionCode: 1234/); + } +}); + +test("Gradle 기본값 누락·중복·잘못된 값은 manifest 조회 전에 거부한다", async () => { + const declaration = "private const val DEFAULT_AFTERNOTE_VERSION_CODE = 7\n"; + for (const policy of ["", declaration.repeat(2), declaration.replace("= 7", "= 0"), + declaration.replace("= 7", "= 2100000001"), declaration.replace("= 7", "= otherValue")]) { + const result = await runVerifier({ expected: null, actual: "7", policy }); + assert.notEqual(result.status, 0, policy); + assert.match(result.stderr, /Gradle 기본값/); + assert.equal(result.javaCalls, "", policy); + } +}); + +test("bundletool SHA256 불일치는 Java 실행 전에 거부한다", async () => { + const result = await runVerifier({ jarSha: "0".repeat(64) }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /bundletool SHA-256/); + assert.equal(result.javaCalls, ""); +}); + +test("로컬 bundletool 다운로드는 고정 버전을 쓰고 성공·실패 모두 임시 파일을 지운다", async () => { + for (const options of [{}, { actual: "1" }, { curlStatus: 22 }, { jarSha: "0".repeat(64) }]) { + const result = await runVerifier({ providedJar: false, ...options }); + assert.equal(result.status === 0, Object.keys(options).length === 0, result.stderr); + assert.match(result.curlCalls, /https:\/\/github.com\/google\/bundletool\/releases\/download\/1\.18\.3\/bundletool-all-1\.18\.3\.jar/); + assert.deepEqual(result.temporaryFiles, []); + } +}); diff --git a/.github/scripts/version-code-bands.mjs b/.github/scripts/version-code-bands.mjs new file mode 100644 index 0000000000..23c7667e4d --- /dev/null +++ b/.github/scripts/version-code-bands.mjs @@ -0,0 +1,23 @@ +// 두 배포 채널이 쓰는 versionCode 대역과 공통 파싱 규약을 한곳에 둔다. 정책 본문은 +// docs/play-release.md 의 "versionCode 정책" 절에 있다. +// +// release-distribution.yml(Firebase, main push 자동)과 release-play-internal.yml(Play 내부 +// 테스트 트랙, 수동 실행)은 별개 워크플로라 서로의 run 카운터를 모른다. 같은 식을 각자 쓰면 +// 같은 값이 양쪽에서 나오므로, 겹치지 않는 대역으로 갈라 둔다. 상수를 양쪽 resolver 에 +// 복사하면 한쪽만 바뀌었을 때 대역이 조용히 겹치니 이 파일 하나가 정본이다. + +export const ATTEMPT_STRIDE = 100n; +export const FIREBASE_BAND_MIN = 101n; +export const FIREBASE_BAND_MAX = 999_999_999n; +export const PLAY_BAND_FLOOR = 1_000_000_000n; + +export function parseInteger(name, rawValue, { minimum }) { + if (typeof rawValue !== "string" || !/^(0|[1-9][0-9]*)$/.test(rawValue)) { + throw new Error(`${name} must be a base-10 integer.`); + } + const value = BigInt(rawValue); + if (value < minimum) { + throw new Error(`${name} must be at least ${minimum}.`); + } + return value; +} diff --git a/.github/workflows/android-managed-device.yml b/.github/workflows/android-managed-device.yml index fae86f3a82..7646189a31 100644 --- a/.github/workflows/android-managed-device.yml +++ b/.github/workflows/android-managed-device.yml @@ -71,7 +71,7 @@ jobs: run: test "$(git rev-parse HEAD)" = "$GITHUB_SHA" - name: Set up JDK - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: "21" @@ -549,7 +549,7 @@ jobs: - name: Set up JDK if: steps.target.outputs.run_lane == 'true' - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: "21" @@ -612,8 +612,14 @@ jobs: deadline=$(( $(date +%s) + GRADLE_TIMEOUT_MINUTES * 60 )) run_gradle() { # $1: selector 목록(빈 문자열 = 필터 없음), $2: 로그 라벨 - local selectors="$1" label="$2" remaining rc - local test_arguments=() + local selectors="$1" label="$2" remaining rc=0 + local test_arguments=() jvm_arguments=() + # #1824 merge-group API34: cold APK packaging exhausted the 2Gi heap + # in ApkFlinger. The 15Gi runner still had 7Gi available, so reserve + # 4Gi for this lane only and preserve the existing UTF-8 JVM flag. + if [[ "$DEVICE_TASK" == "pixel2Api34DebugAndroidTest" ]]; then + jvm_arguments+=("-Dorg.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8") + fi if [[ -n "$selectors" ]]; then test_arguments+=("-Pandroid.testInstrumentationRunnerArguments.class=$selectors") fi @@ -625,14 +631,13 @@ jobs: echo "Managed-device Gradle budget exhausted before: $label" >> "$gradle_log" return 124 fi - set +e + # errexit 를 함수 안에서 켜고 끄지 않는다. 여기서 set -e 를 되살리면 호출부의 set +e 가 + # 무너져 Gradle 이 실패하는 순간 스텝이 exit_code 를 쓰기 전에 빠진다(#2157). timeout --signal=TERM --kill-after=30s "${remaining}s" \ - ./gradlew "$DEVICE_TASK" "${test_arguments[@]}" \ + ./gradlew "$DEVICE_TASK" "${test_arguments[@]}" "${jvm_arguments[@]}" \ -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect \ --console=plain \ - --stacktrace >> "$gradle_log" 2>&1 - rc=$? - set -e + --stacktrace >> "$gradle_log" 2>&1 || rc=$? return "$rc" } @@ -652,20 +657,16 @@ jobs: } if [[ -z "$TEST_SELECTORS_JSON" || "$TEST_SELECTORS_JSON" == "[]" ]]; then - set +e - run_gradle "" "" - status=$? - set -e + status=0 + run_gradle "" "" || status=$? else declared=() while IFS= read -r entry; do declared+=("$entry") done < <(jq -r '.[]' <<< "$TEST_SELECTORS_JSON") joined="$(jq -r 'join(",")' <<< "$TEST_SELECTORS_JSON")" - set +e - run_gradle "$joined" "selected invocation: 전체 목록 ${#declared[@]}개" - status=$? - set -e + status=0 + run_gradle "$joined" "selected invocation: 전체 목록 ${#declared[@]}개" || status=$? # 누락 판정은 1차 런의 live 결과에 대해 한 번만 한다 — 재실행이 시작되면 # live 는 마지막 invocation 결과로 바뀌기 때문이다. @@ -682,11 +683,13 @@ jobs: retried=0 for selector in ${missing[@]+"${missing[@]}"}; do preserve_results "invocation-$retried" + # 재실행이 테스트 태스크 전에 실패하면 live 가 이전 XML 을 그대로 들고 있어 합본에서 + # 두 번 집계된다. 보존한 뒤 비워 live 가 이번 invocation 결과만 갖게 한다. + rm -rf "$results_root" retried=$(( retried + 1 )) - set +e - run_gradle "$selector" "selected retry $retried/${#missing[@]}: $selector (목록 실행에서 누락 — #1378)" - status=$? - set -e + status=0 + run_gradle "$selector" "selected retry $retried/${#missing[@]}: $selector (목록 실행에서 누락 — #1378)" \ + || status=$? if (( status != 0 )); then break fi diff --git a/.github/workflows/api-contract-smoke.yml b/.github/workflows/api-contract-smoke.yml index 87db60a2f0..e72e94d48f 100644 --- a/.github/workflows/api-contract-smoke.yml +++ b/.github/workflows/api-contract-smoke.yml @@ -53,7 +53,7 @@ jobs: persist-credentials: false - name: Set up JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: '21' diff --git a/.github/workflows/base-delete-recovery.yml b/.github/workflows/base-delete-recovery.yml new file mode 100644 index 0000000000..c504380321 --- /dev/null +++ b/.github/workflows/base-delete-recovery.yml @@ -0,0 +1,56 @@ +# base 브랜치 삭제로 자동 폐쇄된 PR 되살리기 (#2076) +# +# 브랜치를 지우면 그 브랜치를 base 로 하던 열린 PR 이 GitHub 정책에 따라 즉시 닫힌다. 닫는 +# 코멘트도 리뷰도 없이 base_ref_deleted 다음 closed 두 이벤트만 남아, 승인까지 받은 PR 이 +# 흔적 없이 사라진다(2026-09-11 #1626). 삭제 자체는 막지 않고 닫힘만 되돌린다. +# +# delete 이벤트는 default branch 의 workflow 정의만 실행한다. 판정과 문구는 +# recover-base-deleted-pull-requests.mjs 에 있고 여기서는 부르기만 한다. +# +# PR 코멘트는 issues/{number}/comments 로 나가지만 대상이 PR 이라 issues: write 로는 403 이다 +# (#2068). reopen 과 경위 코멘트에는 pull-requests: write 가 필요하고, 되살릴 수 없을 때 여는 +# 추적 이슈에는 issues: write 가 필요하다. +name: base-delete-recovery + +on: + delete: + +permissions: {} + +jobs: + recover: + # 같은 브랜치의 삭제·재삭제가 겹쳐도 줄을 세운다. 취소하면 되살리다 만 PR 이 남는다. + concurrency: + group: base-delete-recovery-${{ github.repository_id }}-${{ github.event.ref }} + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Clone recovery policy from the default branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + # 이 job 은 정책 스크립트와 그 테스트만 쓴다. 테스트가 워크플로 정의와 이슈 양식도 + # 읽으므로 그 디렉터리까지 함께 받는다. 목록이 빠지면 테스트 스텝이 파일을 못 찾는다. + sparse-checkout: | + .github/scripts + .github/workflows + .github/ISSUE_TEMPLATE + + - name: Run script tests + run: node --test .github/scripts/recover-base-deleted-pull-requests.test.mjs + + - name: 자동 폐쇄된 PR 되살리기 + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + DELETED_REF: ${{ github.event.ref }} + DELETED_REF_TYPE: ${{ github.event.ref_type }} + DELETED_BY: ${{ github.actor }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: node .github/scripts/recover-base-deleted-pull-requests.mjs diff --git a/.github/workflows/baseline-profile.yml b/.github/workflows/baseline-profile.yml index 27f5718d3b..4135680397 100644 --- a/.github/workflows/baseline-profile.yml +++ b/.github/workflows/baseline-profile.yml @@ -41,7 +41,7 @@ jobs: persist-credentials: false - name: Set up JDK - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: '21' diff --git a/.github/workflows/build-cache-warm.yml b/.github/workflows/build-cache-warm.yml index 49339250d3..cc8655fa60 100644 --- a/.github/workflows/build-cache-warm.yml +++ b/.github/workflows/build-cache-warm.yml @@ -54,7 +54,7 @@ jobs: persist-credentials: false - name: Set up JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '21' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2d3a651aaf..f527e90441 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -136,14 +136,14 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: languages: actions build-mode: none - name: Analyze and upload CodeQL results id: analyze - uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: category: /language:actions @@ -173,14 +173,14 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: languages: javascript-typescript build-mode: none - name: Analyze and upload CodeQL results id: analyze - uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: category: /language:javascript-typescript @@ -212,7 +212,7 @@ jobs: persist-credentials: false - name: Set up JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: '21' @@ -230,7 +230,7 @@ jobs: uses: ./.github/actions/setup-ci-config - name: Initialize CodeQL - uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: languages: java-kotlin build-mode: manual @@ -256,7 +256,7 @@ jobs: - name: Analyze and upload CodeQL results id: analyze - uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: category: /language:java-kotlin diff --git a/.github/workflows/conflict-label.yml b/.github/workflows/conflict-label.yml index 51a47f8a8c..5b0cb8cea3 100644 --- a/.github/workflows/conflict-label.yml +++ b/.github/workflows/conflict-label.yml @@ -11,6 +11,10 @@ name: PR Label Reconcile # 변경요청 뒤 작성자가 아무 조치도 하지 않은 PR 에도 라벨을 붙인다 (#1552). `review-debt-guard` # 는 리뷰어의 빚만 잡고 그 상태를 «공은 작성자에게» 로 흘려보내므로, 그쪽 축은 어디서도 신호가 # 나지 않았다. 붙이기와 떼기를 한 번의 차이 계산으로 처리해 스테일 라벨이 남지 않게 한다. +# +# 열린 PR 끼리의 «형제 충돌» 도 같은 리컨사일러가 잡는다 (#1750). `conflict` 라벨은 base 와의 충돌만 +# 보므로, 두 PR 이 같은 파일을 서로 다르게 고친 사실은 한쪽이 머지된 뒤에야 드러났다. merge-tree 에 +# 전체 히스토리가 필요해 별도 job 으로 두고 checkout 을 fetch-depth 0 으로 받는다. on: push: branches: [develop] @@ -77,3 +81,33 @@ jobs: node .github/scripts/label-issues-with-open-prs.mjs || failures=1 node .github/scripts/label-awaiting-author-prs.mjs || failures=1 exit "$failures" + + sibling-conflicts: + if: github.event_name != 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch + name: Label sibling pull request conflicts + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + issues: write + pull-requests: write + steps: + # merge-tree 는 두 head 의 공통 조상까지 필요하다. 얕은 clone 이면 merge-base 가 없어 모든 쌍이 + # «비교 실패» 로 남는다. 전체 히스토리를 받으면 저장소 안 브랜치의 head 도 함께 온다. + - name: Clone repo with full history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + persist-credentials: false + + - name: Run script tests + run: node --test .github/scripts/label-sibling-conflicts.test.mjs + + - name: Label sibling pull request conflicts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + DRY_RUN: ${{ inputs.dry_run == true && 'true' || 'false' }} + run: node .github/scripts/label-sibling-conflicts.mjs diff --git a/.github/workflows/dependabot-pr-gate.yml b/.github/workflows/dependabot-pr-gate.yml new file mode 100644 index 0000000000..cd5e362f72 --- /dev/null +++ b/.github/workflows/dependabot-pr-gate.yml @@ -0,0 +1,41 @@ +# dependabot PR 이 열릴 때 대표 이슈·제목·CI Test Plan 을 채운다 (#2112) +# +# 봇은 이슈를 만들지 못하고 본문에 CI Test Plan 도 없어 Repository Quality 와 기기 잡이 +# 구조적으로 실패한다(#1397·#1699·#2106). 사람이 손으로 하던 보정을 여기서 한다. +# +# pull_request_target 인 이유: dependabot 이 연 pull_request 이벤트의 토큰은 읽기 전용이라 +# 이슈 생성·PR 편집을 못 한다. default branch 의 워크플로 정의만 실행되고, PR 코드는 +# 체크아웃하지도 실행하지도 않는다. default branch 의 정책 스크립트와 GitHub API 응답만 쓴다. +name: dependabot-pr-gate + +on: + pull_request_target: + types: [opened, reopened, synchronize] + +permissions: {} + +jobs: + fill-gate: + if: github.event.pull_request.user.login == 'dependabot[bot]' + concurrency: + group: dependabot-pr-gate-${{ github.repository_id }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Clone gate policy from the default branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + sparse-checkout: .github/scripts + + - name: 대표 이슈·제목·CI Test Plan 을 채운다 + env: + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: node .github/scripts/dependabot-pr-gate.mjs diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml index f623be4997..93c8a01dca 100644 --- a/.github/workflows/dependency-audit.yml +++ b/.github/workflows/dependency-audit.yml @@ -35,7 +35,7 @@ jobs: persist-credentials: false - name: Set up JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '21' diff --git a/.github/workflows/dependency-submission-trusted.yml b/.github/workflows/dependency-submission-trusted.yml index 85c1a87fc2..3b21836ebb 100644 --- a/.github/workflows/dependency-submission-trusted.yml +++ b/.github/workflows/dependency-submission-trusted.yml @@ -33,7 +33,7 @@ jobs: persist-credentials: false - name: Set up JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: '21' @@ -80,7 +80,7 @@ jobs: echo "Submitting refs/heads/main at \`$snapshot_sha\`." >> "$GITHUB_STEP_SUMMARY" - name: Set up JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: '21' diff --git a/.github/workflows/dependency-submission-upload.yml b/.github/workflows/dependency-submission-upload.yml index f13a1ea113..35258f36a3 100644 --- a/.github/workflows/dependency-submission-upload.yml +++ b/.github/workflows/dependency-submission-upload.yml @@ -2,8 +2,8 @@ name: Submit PR Dependency Graph # workflow_run 은 default branch 의 검토된 workflow 정의로 실행된다. untrusted PR 코드는 # checkout·실행하지 않고 Gradle action 이 만든 dependency graph artifact 만 제출한다. -# upstream 은 dependency paths 가 바뀐 PR 에서만 실행되므로 artifact 없는 source PR 은 -# 이 후속 workflow 자체를 만들지 않는다. +# upstream 은 소스 변경만 있는 PR 을 포함해 모든 PR HEAD 의 graph artifact 를 만든다. +# 성공한 producer 의 artifact 만 제출해 스택 부모의 비교 기준선도 같은 경계로 보관한다 (#2185). on: workflow_run: workflows: [Generate PR Dependency Graph] diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml index 1ded2c52ea..2a73d6828f 100644 --- a/.github/workflows/dependency-submission.yml +++ b/.github/workflows/dependency-submission.yml @@ -1,24 +1,10 @@ name: Generate PR Dependency Graph -# PR 에서 의존성 입력이 바뀔 때만 write token 없는 snapshot artifact 를 만든다. +# 모든 PR HEAD 에 write token 없는 snapshot artifact 를 만든다. +# 소스 변경만 있는 PR 도 스택 부모가 될 수 있으므로 자식과 비교할 기준선이 필요하다 (#2185). # default branch 의 Submit PR Dependency Graph workflow_run 이 검토된 코드로 artifact 를 제출한다. -# 이전 workflow 이름과 분리해 이미 대기 중인 source-only run 이 새 uploader 를 깨우지 않게 한다. on: pull_request: - paths: - - '**/*.gradle' - - '**/*.gradle.kts' - - '**/*.lockfile' - - '**/build-logic/**' - - '**/buildSrc/**' - - '**/gradle.properties' - - '**/gradlew' - - '**/gradlew.bat' - - 'gradle/**' - - '.github/workflows/dependency-review.yml' - - '.github/workflows/dependency-submission.yml' - - '.github/workflows/dependency-submission-trusted.yml' - - '.github/workflows/dependency-submission-upload.yml' concurrency: group: generate-pr-dependency-graph-${{ github.event.pull_request.number }} @@ -46,7 +32,7 @@ jobs: persist-credentials: false - name: Set up JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: '21' diff --git a/.github/workflows/firebase-wif-canary.yml b/.github/workflows/firebase-wif-canary.yml index 95ed57992a..61238d6ab2 100644 --- a/.github/workflows/firebase-wif-canary.yml +++ b/.github/workflows/firebase-wif-canary.yml @@ -85,7 +85,7 @@ jobs: cat "$RELEASE_NOTES_FILE" >> "$GITHUB_STEP_SUMMARY" - name: Set up JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: '21' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 93a53fbd7c..4383e66ae4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -54,7 +54,7 @@ jobs: persist-credentials: false - name: Set up JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '21' @@ -102,7 +102,7 @@ jobs: persist-credentials: false - name: Set up JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '21' diff --git a/.github/workflows/merge-order-guard.yml b/.github/workflows/merge-order-guard.yml index 6066b822cd..9848abfd1e 100644 --- a/.github/workflows/merge-order-guard.yml +++ b/.github/workflows/merge-order-guard.yml @@ -3,14 +3,22 @@ # GitHub 는 이슈 Relationships(blocked_by)를 표시만 하고 머지 버튼을 잠그지 않는다. # 이 워크플로는 PR 의 closing 이슈에 아직 열린 blocked_by 가 있으면 check 를 # 실패시킨다. branch protection 에서 required check 로 지정하면 머지 버튼이 -# 실제로 잠긴다. base 가 develop/main 이 아닌 스택 PR 은 경고로만 남긴다. +# 실제로 잠긴다. 부모 브랜치 대상 스택 PR에도 같은 실패 판정을 적용한다. # # 네이티브 스택의 닫힌 미병합 멤버는 merge queue synthetic SHA에서 live로 다시 검사한다. # 중간 멤버가 닫힌 직후의 조치 안내는 stack-integrity-notify가 상위 PR에 남긴다. +# +# 판정은 항상 default branch 의 정책으로 한다 (#1977). pull_request 로 돌리면 PR 사본의 +# 이 YAML 이 실행돼 낡은 브랜치는 옛 정책으로 판정하고, PR 이 가드를 고쳐 스스로 통과할 수 +# 있다. 종전에는 그 구멍을 inline 종료 뒤 최신 정책을 dispatch 해 check-run 으로 재게시하는 +# 두 번째 판정으로 메웠는데, GITHUB_TOKEN 이 만든 check-run 은 같은 SHA 의 임의 check +# suite 에 얹혀 무관한 워크플로 이름 아래 «guard» 가 두 줄로 떴다. pull_request_target 은 +# base 의 YAML 과 스크립트로 돌면서 check 는 PR HEAD 에 붙으므로 첫 판정이 곧 최종 판정이다. +# PR 코드는 체크아웃하지 않고 권한은 read 뿐이다(stack-integrity-notify 와 같은 경계). name: merge-order-guard on: - pull_request: + pull_request_target: types: [opened, reopened, synchronize, edited] # merge queue 는 큐 ref 에서 guard context 를 다시 요구한다. 큐 ref # (gh-readonly-queue//pr--) 에서 PR 번호를 파싱해 같은 검사를 돌린다. @@ -18,7 +26,11 @@ on: types: [checks_requested] issues: types: [closed, reopened] - # GITHUB_TOKEN 이 만든 baseline/stack merge commit 에도 최신 SHA 기준 guard 를 붙인다. + # GITHUB_TOKEN 이 만든 baseline/stack merge commit 은 pull_request_target 도 내지 않으므로, + # 그 SHA 에는 dispatch 결과를 check-run 으로 붙인다. 선행 이슈가 닫힌 뒤의 PR HEAD 는 이미 + # pull_request_target run 이 있으니 refresh 가 그 run 을 재실행하고, run 이 없을 때만 여기로 + # 내려온다 (#1983). PR HEAD 에 check-run 을 새로 만들면 임의 suite 에 얹혀 무관한 워크플로 + # 이름 아래 «guard» 가 또 뜬다. workflow_dispatch: inputs: pull_request_number: @@ -26,22 +38,42 @@ on: required: true type: number -permissions: - contents: read - issues: read - pull-requests: read +# pull_request_target 은 base 권한으로 돌므로 워크플로 기본 권한은 비우고 job 마다 필요한 +# 읽기·쓰기만 준다(create-ci-config 정책 테스트의 narrow bridge 불변식). +permissions: {} jobs: guard: if: >- - github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || + github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' || github.event_name == 'merge_group' + permissions: + contents: read + issues: read + pull-requests: read runs-on: ubuntu-latest timeout-minutes: 5 + outputs: + target_sha: ${{ steps.target.outputs.sha }} steps: + - name: Capture dispatch target HEAD + if: github.event_name == 'workflow_dispatch' + id: target + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pull_request_number }} + run: | + set -euo pipefail + [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] + gh pr view "$PR_NUMBER" --json state,headRefOid > "$RUNNER_TEMP/guard-target.json" + jq -e '.state == "OPEN"' "$RUNNER_TEMP/guard-target.json" > /dev/null + printf 'sha=%s\n' "$(jq -r '.headRefOid' "$RUNNER_TEMP/guard-target.json")" >> "$GITHUB_OUTPUT" + # 게이트 스크립트는 **default branch 사본**으로 돌린다. PR 사본을 체크아웃하면 PR 이 - # 자기를 검사하는 가드를 고쳐 통과시킬 수 있다. 필요한 건 .github/scripts 뿐이라 - # sparse-checkout 으로 좁힌다. + # 자기를 검사하는 가드를 고쳐 통과시킬 수 있다. pull_request_target 의 기본 ref 도 + # base 지만 dispatch·merge_group 경로와 같은 사본을 명시한다. 필요한 건 .github/scripts + # 뿐이라 sparse-checkout 으로 좁힌다. - name: Clone gate scripts from the default branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -107,10 +139,8 @@ jobs: BASE_REF=$(jq -r '.baseRefName' <<< "$pr_json") fi - # 스택 PR 안내 — base 가 부모 브랜치면 애초에 develop 로 머지되지 않고, - # 부모가 머지되면 GitHub 가 base 를 develop 로 이관하며 이 가드가 다시 - # 돌아 blocked_by 를 검사한다. 실패로 올리면 스택 PR 이 수명 내내 X 로 - # 남아 목록에서 진짜 CI 실패와 구분되지 않으므로 경고로만 남긴다. + # base는 스택 등록 안내에만 사용한다. 열린 이슈 선행은 base와 무관하게 + # 실패하며, 선행 이슈가 닫히면 최신 정책을 dispatch해 결과를 갱신한다. stacked=0 case "$BASE_REF" in develop|main) ;; @@ -215,15 +245,8 @@ jobs: open_blockers=$(gh api "repos/$GITHUB_REPOSITORY/issues/$n/dependencies/blocked_by" \ --jq '.[] | select(.state == "open") | .number') for b in $open_blockers; do - if [ "$stacked" -eq 1 ]; then - # 스택 PR 은 develop 로 직접 머지되지 않는다. base 가 develop 로 - # 이관되는 순간 이 가드가 다시 돌아 error 로 막으므로, 그전까지는 - # 경고로만 남겨 진짜 CI 실패와 구분되게 한다. - echo "::warning::#$n 이 아직 열린 #$b 에 blocked — base 를 develop 로 이관하기 전에 선행 PR 부터 머지할 것" - else - echo "::error::#$n 이 아직 열린 #$b 에 blocked — 머지 순서 위반. 선행 PR 부터 머지할 것" - fail=1 - fi + echo "::error::#$n 이 아직 열린 #$b 에 blocked — 머지 순서 위반. 선행 이슈를 먼저 완료할 것 (base: $BASE_REF)" + fail=1 done done 3<<< "$refs" @@ -245,10 +268,46 @@ jobs: node .github/scripts/ensure-api-quota.mjs classify env: GH_TOKEN: ${{ github.token }} + publish-dispatch-result: + if: >- + always() && github.event_name == 'workflow_dispatch' && + github.ref_name == github.event.repository.default_branch && needs.guard.outputs.target_sha != '' + needs: guard + permissions: + checks: write + pull-requests: read + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Publish trusted guard verdict on the captured PR HEAD + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pull_request_number }} + TARGET_SHA: ${{ needs.guard.outputs.target_sha }} + GUARD_RESULT: ${{ needs.guard.result }} + run: | + set -euo pipefail + current=$(gh pr view "$PR_NUMBER" --json state,headRefOid) + if [ "$(jq -r '.state' <<< "$current")" != "OPEN" ] || + [ "$(jq -r '.headRefOid' <<< "$current")" != "$TARGET_SHA" ]; then + echo "::notice::PR closed or HEAD changed; do not publish a stale verdict" + exit 0 + fi + conclusion=failure + [ "$GUARD_RESULT" != "success" ] || conclusion=success + details_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + jq -n --arg sha "$TARGET_SHA" --arg conclusion "$conclusion" --arg url "$details_url" \ + '{name:"guard", head_sha:$sha, status:"completed", conclusion:$conclusion, + details_url:$url, output:{title:"merge-order-guard", summary:"Latest default-branch policy. See the linked run for blocker details."}}' \ + > "$RUNNER_TEMP/guard-check.json" + gh api "repos/$GITHUB_REPOSITORY/check-runs" --method POST \ + --input "$RUNNER_TEMP/guard-check.json" > /dev/null + refresh: if: github.event_name == 'issues' permissions: - actions: write # gh run rerun — 필요한 이 잡에만 job-level 로 한정 + actions: write # 기존 pull_request_target run 재실행, 없으면 workflow_dispatch issues: read pull-requests: read runs-on: ubuntu-latest @@ -261,6 +320,7 @@ jobs: # gh run list/rerun 의 저장소 컨텍스트가 이 env 로만 잡힌다. GH_REPO: ${{ github.repository }} ISSUE: ${{ github.event.issue.number }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail @@ -278,7 +338,7 @@ jobs: # 닫힐 때 같은 parser로 열린 PR을 다시 찾아야, 그 PR의 stale red check가 갱신된다. prs_file="$RUNNER_TEMP/merge-order-open-prs.json" gh api graphql --paginate --slurp \ - -f query='query($owner:String!,$repo:String!,$endCursor:String){repository(owner:$owner,name:$repo){pullRequests(states:OPEN,first:100,after:$endCursor){nodes{headRefName title body closingIssuesReferences(first:20){nodes{number repository{nameWithOwner}}}} pageInfo{hasNextPage endCursor}}}}' \ + -f query='query($owner:String!,$repo:String!,$endCursor:String){repository(owner:$owner,name:$repo){pullRequests(states:OPEN,first:100,after:$endCursor){nodes{number headRefOid title body closingIssuesReferences(first:20){nodes{number repository{nameWithOwner}}}} pageInfo{hasNextPage endCursor}}}}' \ -f owner="$owner" -f repo="$repo" > "$prs_file" heads=$( BLOCKED="$blocked" PRS_FILE="$prs_file" node <<'NODE' @@ -307,7 +367,7 @@ jobs: } if ([...issues].some((number) => blocked.has(number))) { - heads.add(pr.headRefName); + heads.add(pr.number); } } @@ -320,11 +380,30 @@ jobs: exit 0 fi - for h in $heads; do - run_id=$(gh run list --workflow merge-order-guard.yml --branch "$h" \ - --limit 1 --json databaseId --jq '.[0].databaseId // empty') - if [ -n "$run_id" ]; then - # 진행 중 런 재실행 거절 등은 치명 아님 — 경고로만 남긴다 - gh run rerun "$run_id" || echo "::warning::run $run_id 재실행 실패 — 진행 중이거나 권한 문제" + # PR HEAD 에는 pull_request_target 으로 돈 guard run 이 이미 있다. 그 run 은 base 의 + # YAML 과 default branch 의 스크립트로 돌았고, 재실행도 스크립트를 default branch 에서 + # 다시 받으므로 같은 suite 안의 guard 가 제자리에서 갱신된다. dispatch 결과를 check-run + # 으로 새로 붙이면 임의 suite 에 얹혀 무관한 워크플로 이름 아래 guard 가 또 뜬다 (#1983). + # run 이 없거나(토큰 커밋) 재실행이 거절되면(진행 중·보존 기간 초과) dispatch 로 내려간다. + # HEAD SHA 는 위 GraphQL 결과에서 읽는다 — PR 마다 REST 를 또 부르면 한도를 태운다 (#1465). + dispatch_failed=0 + for pr in $heads; do + sha=$(jq -r --argjson n "$pr" \ + '[.[].data.repository.pullRequests.nodes[] | select(.number == $n) | .headRefOid][0] // empty' \ + "$prs_file") + run_id="" + if [ -n "$sha" ]; then + run_id=$(gh run list --workflow merge-order-guard.yml --event pull_request_target \ + --commit "$sha" --status completed --limit 1 --json databaseId \ + --jq '.[0].databaseId // empty' || true) + fi + if [ -n "$run_id" ] && gh run rerun "$run_id"; then + echo "PR #$pr: pull_request_target run $run_id 재실행 — 같은 suite 안에서 guard 갱신" + continue + fi + if ! gh workflow run merge-order-guard.yml --ref "$DEFAULT_BRANCH" -f pull_request_number="$pr"; then + echo "::error::PR #$pr 최신 가드 dispatch 실패" + dispatch_failed=1 fi done + exit "$dispatch_failed" diff --git a/.github/workflows/merge-queue-dequeue.yml b/.github/workflows/merge-queue-dequeue.yml new file mode 100644 index 0000000000..1ecb7f86bb --- /dev/null +++ b/.github/workflows/merge-queue-dequeue.yml @@ -0,0 +1,47 @@ +# merge queue 방출 자동 처리 (#1892) +# +# pull_request_target 은 default branch 의 workflow 정의만 실행한다. PR 코드·artifact·cache 를 +# 체크아웃하거나 실행하지 않고, GitHub API 응답과 default branch 의 정책 스크립트만 쓴다 +# (stack-integrity-notify.yml 과 같은 경계). +# +# 판정은 handle-merge-queue-dequeue.mjs 가 한다 — 이 PR 의 큐 브랜치 merge_group run 에 실패 job 이 +# 있으면 링크를 코멘트로 남기고, 없으면(조용한 방출) 같은 head 당 한 번만 재투입하고, +# MANUAL·ALREADY_MERGED·MERGE 는 아무것도 하지 않는다. +name: merge-queue-dequeue + +on: + pull_request_target: + types: [dequeued] + +permissions: {} + +jobs: + handle: + # 같은 PR 의 방출 이벤트가 겹쳐도 줄을 세운다 — 취소하면 마커 코멘트 없이 재투입만 남을 수 있다. + concurrency: + group: merge-queue-dequeue-${{ github.repository_id }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + contents: write + issues: write + pull-requests: write + steps: + - name: Clone dequeue policy from the default branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Run script tests + run: node --test .github/scripts/handle-merge-queue-dequeue.test.mjs + + - name: 실패 job 안내 또는 한 번 재투입 + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + DEQUEUE_REASON: ${{ github.event.reason }} + run: node .github/scripts/handle-merge-queue-dequeue.mjs diff --git a/.github/workflows/mock-cleanup-check.yml b/.github/workflows/mock-cleanup-check.yml index 0f5f94e916..b8c404391e 100644 --- a/.github/workflows/mock-cleanup-check.yml +++ b/.github/workflows/mock-cleanup-check.yml @@ -1,12 +1,29 @@ name: Mock Cleanup Check +# main 에 들어가기 **전에** 막는다. +# +# #684 는 이 워크플로를 push(main) 으로 옮겼고, 그때 근거는 「이 레포의 main 은 PR 이 아니라 +# 직접 push 로 갱신되어 왔다」였다 — 당시엔 사실이었다. 그 전제가 낡았다. 최근 main 갱신은 +# 전부 PR 머지다 (#1778 · #1558 · #1273 · #1025 · #916). push 로만 두면 2026-08-23 처럼 +# 이미 main 이 그 커밋을 가진 뒤에 red 를 내는 사후 경보가 된다 (#1877). +# +# push(main) 은 남긴다 — 룰셋을 우회한 직접 push 가 들어오면 그때도 잡아야 한다. +# +# base 를 develop 으로 넓히는 것은 여전히 오답이다. develop 에는 진행 중 마커가 상주할 수 +# 있고, 그러면 전 PR 이 상시 red 가 된다 (#684). on: + pull_request: + branches: [main] push: branches: [main] permissions: contents: read +concurrency: + group: mock-cleanup-check-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: mock-cleanup: name: Check MOCK_CLEANUP TODOs @@ -20,12 +37,13 @@ jobs: - name: Check for MOCK_CLEANUP markers run: | + # --exclude-dir 은 경로가 아니라 디렉터리 «이름» glob 을 받는다 (#684). MATCHES=$(grep -rn "MOCK_CLEANUP" --include="*.kt" \ --exclude-dir="debug" \ --exclude-dir="test" \ --exclude-dir="androidTest" || true) if [ -n "$MATCHES" ]; then - echo "::warning::⚠️ MOCK_CLEANUP 마커가 발견되었습니다. 목업 코드가 프로덕션에 남아있을 수 있습니다:" + echo "::error::MOCK_CLEANUP 마커가 프로덕션 소스에 남아 있습니다. main 에 들어가기 전에 걷어내세요:" echo "$MATCHES" exit 1 fi diff --git a/.github/workflows/pr-assign-author.yml b/.github/workflows/pr-assign-author.yml new file mode 100644 index 0000000000..9e8f5c72e5 --- /dev/null +++ b/.github/workflows/pr-assign-author.yml @@ -0,0 +1,53 @@ +# PR 작성자 자동 어사인 +# +# 어사인이 비어 있는 PR 은 보드·필터에서 담당자가 사라진다. 이슈는 +# issue-metadata-guard 가 어사인을 강제하는데 PR 은 손에 맡겨져 있었다. +# 어사인이 비어 있는 PR 에만 작성자를 건다. 사람이 담당자를 바꾼 것(작성자를 떼고 +# 다른 사람을 건 것)은 그대로 둔다 — 강제하는 것은 «비어 있지 않음» 이지 «작성자» 가 +# 아니다. 전부 떼어 비게 되면 다시 작성자를 건다. +# review-request-all 과 같은 조건(봇·외부 기여자 제외)으로 돈다. +name: pr-assign-author + +on: + pull_request: + types: [opened, reopened, ready_for_review, unassigned] + +permissions: + pull-requests: write + +jobs: + assign: + if: github.event.pull_request.state == 'open' + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - name: 작성자를 어사인 + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + AUTHOR: ${{ github.event.pull_request.user.login }} + ASSOCIATION: ${{ github.event.pull_request.author_association }} + run: | + set -euo pipefail + + case "$AUTHOR" in + *'[bot]'|dependabot|github-actions) echo "봇 PR — 건너뜀"; exit 0 ;; + esac + + # 외부 기여자 PR 은 토큰이 read-only 라 어사인할 수 없다. + case "$ASSOCIATION" in + OWNER|MEMBER|COLLABORATOR) ;; + *) echo "외부 기여자($ASSOCIATION) — 건너뜀"; exit 0 ;; + esac + + current=$(gh api "repos/$REPO/pulls/$PR_NUMBER" \ + --jq '[.assignees[].login] | join(" ")') + if [ -n "$current" ]; then + echo "이미 어사인됨: $current" + exit 0 + fi + + gh api -X POST "repos/$REPO/issues/$PR_NUMBER/assignees" \ + -f "assignees[]=$AUTHOR" >/dev/null + echo "어사인: $AUTHOR" diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index fcd2fe9854..e33f356dc2 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -22,6 +22,12 @@ on: types: [checks_requested] # GITHUB_TOKEN 으로 PR 브랜치에 baseline/stack merge commit 을 올리면 pull_request # 이벤트가 다시 발생하지 않는다. 권한 있는 bridge 가 같은 SHA 에 필수 검증을 붙일 때 쓴다. + # + # type: number 는 dispatch 입력의 «검증» 만 한다 — 런타임 값은 여전히 문자열이다. + # 그 문자열을 아래 reusable workflow 의 number 입력에 그대로 꽂으면 워크플로가 + # 시작조차 못 하고 «evaluate reusable workflow inputs ... Unexpected value '1840'» + # 로 죽는다. job 이 0개라 로그도 check-run annotation 도 남지 않아 gh 로는 사유가 + # 보이지 않는다. 그래서 각 with: 는 fromJSON 으로 수를 되살려 넘긴다 (#1850). workflow_dispatch: inputs: pull_request_number: @@ -44,7 +50,7 @@ jobs: name: Repository Quality uses: ./.github/workflows/repository-quality.yml with: - pull_request_number: ${{ inputs.pull_request_number || github.event.pull_request.number || 0 }} + pull_request_number: ${{ fromJSON(inputs.pull_request_number || '0') || github.event.pull_request.number || 0 }} static-analysis: name: Static Analysis @@ -52,7 +58,7 @@ jobs: if: ${{ !cancelled() }} uses: ./.github/workflows/lint.yml with: - pull_request_number: ${{ inputs.pull_request_number || github.event.pull_request.number || 0 }} + pull_request_number: ${{ fromJSON(inputs.pull_request_number || '0') || github.event.pull_request.number || 0 }} run_ktlint: ${{ needs.repository-quality.result != 'success' || needs.repository-quality.outputs.ktlint_required != 'false' }} ktlint_tasks: ${{ needs.repository-quality.result == 'success' && needs.repository-quality.outputs.ktlint_tasks || 'ktlintCheck :build-logic:ktlintCheck' }} run_android_lint: ${{ needs.repository-quality.result != 'success' || needs.repository-quality.outputs.android_lint_required != 'false' }} @@ -65,7 +71,7 @@ jobs: if: ${{ !cancelled() }} uses: ./.github/workflows/unit-test.yml with: - pull_request_number: ${{ inputs.pull_request_number || github.event.pull_request.number || 0 }} + pull_request_number: ${{ fromJSON(inputs.pull_request_number || '0') || github.event.pull_request.number || 0 }} run_validation: ${{ needs.repository-quality.result != 'success' || needs.repository-quality.outputs.unit_test_required != 'false' }} run_node_tests: ${{ needs.repository-quality.result != 'success' || needs.repository-quality.outputs.run_node_tests != 'false' }} gradle_tasks: ${{ needs.repository-quality.result == 'success' && needs.repository-quality.outputs.unit_test_tasks || ':koverXmlReportCi :koverHtmlReportCi :konsist:test :build-logic:test :app:compileDebugAndroidTestKotlin' }} @@ -77,7 +83,7 @@ jobs: if: ${{ !cancelled() }} uses: ./.github/workflows/screenshot.yml with: - pull_request_number: ${{ inputs.pull_request_number || github.event.pull_request.number || 0 }} + pull_request_number: ${{ fromJSON(inputs.pull_request_number || '0') || github.event.pull_request.number || 0 }} run_validation: ${{ needs.repository-quality.result != 'success' || needs.repository-quality.outputs.screenshot_required != 'false' }} gradle_tasks: ${{ needs.repository-quality.result == 'success' && needs.repository-quality.outputs.screenshot_tasks || ':core:ui:validateScreenshotTest :feature:home:presentation:validateScreenshotTest :feature:receiver:presentation:validateScreenshotTest :feature:onboarding:presentation:validateScreenshotTest :feature:afternote:presentation:validateScreenshotTest :feature:mindrecord:presentation:validateScreenshotTest :feature:setting:presentation:validateScreenshotTest :feature:timeletter:presentation:validateScreenshotTest' }} screenshot_modules: ${{ needs.repository-quality.result == 'success' && needs.repository-quality.outputs.screenshot_modules || ':core:ui :feature:home:presentation :feature:receiver:presentation :feature:onboarding:presentation :feature:afternote:presentation :feature:mindrecord:presentation :feature:setting:presentation :feature:timeletter:presentation' }} diff --git a/.github/workflows/qa-distribution-decision.yml b/.github/workflows/qa-distribution-decision.yml new file mode 100644 index 0000000000..abf95fc5e6 --- /dev/null +++ b/.github/workflows/qa-distribution-decision.yml @@ -0,0 +1,56 @@ +name: QA Distribution Decision + +on: + push: + branches: [develop] + schedule: + - cron: '19 22 * * *' + workflow_run: + workflows: [Release Distribution (Firebase App Distribution)] + types: [completed] + branches: [main] + workflow_dispatch: + +permissions: + contents: read + actions: read + pull-requests: read + +jobs: + evaluate: + name: Evaluate QA deployment timing + if: >- + (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/develop') && + (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') + # 건너뛴 배포 실패 이벤트가 진행 중인 판정을 취소하지 않게 job에 한정한다. + concurrency: + group: ${{ github.run_attempt == 1 && 'qa-distribution-decision-current' || format('qa-distribution-decision-rerun-{0}', github.run_id) }} + cancel-in-progress: true + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Clone trusted develop revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event_name == 'push' && github.sha || 'develop' }} + fetch-depth: 0 + persist-credentials: false + + - name: Pin evaluated develop SHA + run: echo "QA_DISTRIBUTION_HEAD_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + + - name: Evaluate without posting comments + env: + GH_TOKEN: ${{ github.token }} + QA_DISTRIBUTION_OUTPUT_DIR: ${{ runner.temp }}/qa-distribution-decision + run: node .github/scripts/qa-distribution-decision.mjs + + - name: Upload decision and evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: qa-distribution-decision + path: ${{ runner.temp }}/qa-distribution-decision + if-no-files-found: error + # 표준 보관 기간 + retention-days: 7 diff --git a/.github/workflows/release-aab-preflight.yml b/.github/workflows/release-aab-preflight.yml index 0875c25c45..cbaf5ff396 100644 --- a/.github/workflows/release-aab-preflight.yml +++ b/.github/workflows/release-aab-preflight.yml @@ -37,7 +37,7 @@ jobs: persist-credentials: false - name: Set up JDK - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: "21" @@ -75,8 +75,8 @@ jobs: mkdir -p "$baseline_dir" previous_run_id="$( - gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs?status=success&per_page=100" \ - --jq ".workflow_runs[] | select(.name == \"Release AAB Preflight\" and .id != ${CURRENT_RUN_ID}) | .id" \ + gh api "repos/$GITHUB_REPOSITORY/actions/workflows/release-aab-preflight.yml/runs?status=success&per_page=1" \ + --jq ".workflow_runs[] | select(.id != ${CURRENT_RUN_ID}) | .id" \ | sed -n '1p' )" if [ -z "$previous_run_id" ]; then diff --git a/.github/workflows/release-distribution.yml b/.github/workflows/release-distribution.yml index b2eea35abe..e051378929 100644 --- a/.github/workflows/release-distribution.yml +++ b/.github/workflows/release-distribution.yml @@ -69,7 +69,7 @@ jobs: cat "$RELEASE_NOTES_FILE" >> "$GITHUB_STEP_SUMMARY" - name: Set up JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '21' @@ -98,6 +98,14 @@ jobs: release-key-password: ${{ secrets.RELEASE_KEY_PASSWORD }} compose-local-properties: 'true' + # versionCode 는 이 채널 대역(101 ~ 999,999,999) 안에서 run_number 로 정해진다. 자격이 + # 필요 없는 순수 계산이라 release keystore·WIF 가 붙기 전인 여기에 둔다. Play 대역 + # (1,000,000,000 이상)과 겹치지 않아 Crashlytics 가 두 채널 배포본을 한 버전으로 묶지 + # 않는다 (#1865, docs/play-release.md 의 versionCode 정책). + - name: Resolve the Firebase-band versionCode + id: version_code + run: node .github/scripts/resolve-firebase-version-code.mjs + # 빌드와 업로드를 갈라 놓은 이유는 그 사이에 attest 할 «파일 하나» 를 확정하기 위해서다. # digest 는 signing 이 끝난 바로 그 APK 에서 뜨고, 이후 단계는 전부 이 값을 기준으로 판정한다. # 이 step 에는 Google 자격이 없다. WIF 전환(#850) 이후로는 파이프라인 어디에도 장기 키가 @@ -106,6 +114,7 @@ jobs: - name: Build signed release APK id: release_apk env: + AFTERNOTE_VERSION_CODE: ${{ steps.version_code.outputs.version_code }} GOOGLE_WEB_CLIENT_ID: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} run: | diff --git a/.github/workflows/release-play-internal.yml b/.github/workflows/release-play-internal.yml index bac25275d0..b54248a1b1 100644 --- a/.github/workflows/release-play-internal.yml +++ b/.github/workflows/release-play-internal.yml @@ -71,8 +71,20 @@ jobs: exit 1 fi + # 값이 있다고 맞는 값은 아니다. 오타나 다른 앱 패키지가 들어가면 남의 앱에 edit 를 열고 + # 40분 빌드를 태운 뒤 업로드에서 죽는다. 빌드 전에 applicationId 와 대조해 즉시 끊는다. + application_id="$(sed -n 's/.*applicationId = "\([^"]*\)".*/\1/p' app/build.gradle.kts | head -n 1)" + if [ -z "$application_id" ]; then + echo "::error::app/build.gradle.kts 에서 applicationId 를 읽지 못했다 — 대조 없이 진행하지 않는다." + exit 1 + fi + if [ "$PLAY_PACKAGE_NAME" != "$application_id" ]; then + echo "::error::PLAY_PACKAGE_NAME($PLAY_PACKAGE_NAME) 이 applicationId($application_id) 와 다르다." + exit 1 + fi + - name: Set up JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '21' @@ -166,6 +178,7 @@ jobs: - name: Build the signed release AAB id: release_aab env: + AFTERNOTE_STORE_DISTRIBUTED_BUILD: "true" AFTERNOTE_VERSION_CODE: ${{ steps.version_code.outputs.version_code }} GOOGLE_WEB_CLIENT_ID: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} @@ -176,9 +189,11 @@ jobs: echo "path=$bundle_path" >> "$GITHUB_OUTPUT" node .github/scripts/verify-release-attestation.mjs digest "$bundle_path" "$GITHUB_OUTPUT" - # 서명·필수 bundle 항목·mapping 존재를 확인한다. 잘못 서명된 AAB 가 Play 까지 가서 - # 거절당하는 대신 여기서 멈춘다. + # 서명·필수 항목·mapping·주입된 versionCode 를 attest 전에 확인한다. + # 검증기가 preflight 와 같은 버전·SHA256 의 bundletool 로 manifest 를 읽는다. - name: Verify the AAB signature and contents + env: + AFTERNOTE_VERSION_CODE: ${{ steps.version_code.outputs.version_code }} run: bash scripts/verify-play-release-bundle.sh --skip-build - name: Attest the exact signed AAB diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index 69d44d63b7..2c173a018d 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -208,6 +208,15 @@ jobs: GH_TOKEN: ${{ github.token }} run: node .github/scripts/validate-pr-issue-link.mjs "${{ steps.changed-files.outputs.pull_request_json }}" + # 이슈 담당 대조(위)는 «어느 이슈로 열었나» 를, 이 스텝은 «어느 모듈 파일을 바꿨나» 를 본다. + # 둘 다 같은 지도(ASSIGNEE_BY_MODULE)를 쓰고 같은 라벨(issue-assignee-exempt)로 면제된다 (#1889). + - name: Require module owner + if: inputs.pull_request_number > 0 + env: + PULL_REQUEST_JSON: ${{ steps.changed-files.outputs.pull_request_json }} + FILES_JSON: ${{ steps.classify-documentation-changes.outputs.files_json }} + run: node .github/scripts/validate-pr-module-owner.mjs "$PULL_REQUEST_JSON" "$FILES_JSON" + - name: Validate CI Test Plan if: inputs.pull_request_number > 0 run: >- @@ -216,6 +225,17 @@ jobs: "$GITHUB_WORKSPACE" "${{ steps.classify-documentation-changes.outputs.files_json }}" + # PR 이 src/main 에 새로 넣은 함수가 main 어디서도 참조되지 않으면 — 테스트만 부르거나 아무도 + # 안 부르면 — 실패시킨다 (#1895). ProductionVisibilityKonsistTest 는 최상위 선언만 보므로 + # 멤버 함수가 샜다(#1583 의 onReceiverRegisterStart). 면제는 PR 라벨 + # test-only-production-exempt — issue-assignee-exempt 와 같은 자리다. + - name: Reject test-only production declarations + if: inputs.pull_request_number > 0 + run: >- + node .github/scripts/validate-test-only-production-declarations.mjs + "${{ steps.changed-files.outputs.pull_request_json }}" + "${{ steps.classify-documentation-changes.outputs.files_json }}" + - name: Install actionlint env: ACTIONLINT_VERSION: 1.7.12 @@ -248,6 +268,11 @@ jobs: scripts/repository-quality.sh fi + # KDoc 의 [FQN] 링크가 실제 선언을 가리키는지 — 패키지 이동마다 끊긴 링크가 쌓이던 자리 (#1889). + # 저장소 전체를 보는 값싼 검사라 PR·push 가리지 않는다. + - name: Verify KDoc FQN links + run: node .github/scripts/verify-kdoc-links.mjs + - name: Verify failing fixtures if: inputs.pull_request_number == 0 || steps.classify-documentation-changes.outputs.repository_quality_fixtures == 'true' run: scripts/test-repository-quality.sh diff --git a/.github/workflows/review-debt-guard.yml b/.github/workflows/review-debt-guard.yml index bb782bd128..f64c37a3c8 100644 --- a/.github/workflows/review-debt-guard.yml +++ b/.github/workflows/review-debt-guard.yml @@ -7,11 +7,15 @@ # 그 PR 을 닫는다. 브랜치는 남으므로 리뷰를 끝낸 뒤 되살릴 수 있다. # # 리뷰어 빚의 기준은 «나에게 걸린 리뷰 요청» 이 아니라 «아무도 응답하지 않은 열린 -# PR» 이다(#1148). 다만 한번 변경요청을 남긴 뒤에는 작성자가 그 리뷰어에게 명시적으로 +# PR» 이다(#1148). 다만 한번 변경요청을 남긴 뒤에는 담당자가 그 리뷰어에게 명시적으로 # 재요청해야만 공이 돌아온다. 수정 커밋만으로 리뷰 의무를 만들지는 않는다(#1136). -# 작성자 빚은 같은 작성자의 다른 열린 PR 이 최신 변경요청 뒤 실질 커밋·응답·작성자 +# 작성자 빚은 같은 사람이 담당하는 다른 열린 PR 이 최신 변경요청 뒤 실질 커밋·응답· # 본문 편집 없이 남은 상태다. 라벨 갱신 시점에 기대지 않고 새 PR 을 열 때 현재 상태를 # 다시 계산한다(#1679, #1700). +# +# PR 의 담당자는 «올린 사람» 이 아니라 «어사인된 사람» 이다(#1974 결정, #1980). 변경요청에 +# 응답하는 것도, «자기 PR» 을 리뷰어 빚에서 빼는 것도 어사인 기준이고, 어사인이 비어 있는 +# 동안만 작성자가 담당자다. 새 PR 을 여는 사람(AUTHOR)이 게이트를 받는 것은 그대로다. name: review-debt-guard on: @@ -62,7 +66,9 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} AUTHOR: ${{ github.event.pull_request.user.login }} HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + # PR 제한·awaiting-author 라벨의 공통 면제 목록 (#1910, #1934). + # 라벨러도 이 선언을 읽는다. 인용·주석 없는 한 줄에서 공백으로 구분한다. + REVIEW_GATE_EXEMPT_AUTHORS: koongmai run: | # 조회 실패를 "빚 없음" 으로 오인해 통과시키지 않는다. 다만 판정 근거를 # 얻지 못한 상태로 남의 PR 을 닫지도 않는다 — 아래에서 명시적으로 나눈다. @@ -98,9 +104,18 @@ jobs: *) echo "::error::권한 값을 알 수 없다($perm) — 재실행할 것"; exit 1 ;; esac - # 긴급 우회 라벨. - case ",$LABELS," in - *,review-debt-exempt,*) echo "review-debt-exempt 라벨 — 건너뜀"; exit 0 ;; + # 이 가드 자체를 면제받은 작성자 (#1910). 팀이 koongmai 에게는 PR 을 여는 시점의 + # 제약을 두지 않고 머지 시점 제약만 남기기로 했다. 리뷰 지적을 반영했는지와 무관하게 + # 새 PR 을 열 수 있고, 남의 PR 을 리뷰할 의무도 없어 review-request-all 의 TEAM 에서도 + # 빠져 있다. 머지 게이트는 그대로다. develop 은 여전히 승인 1건과 필수 체크를 요구하고, + # 그 요건은 이 가드가 아니라 required-approval·required-checks 룰셋이 건다. + # + # 면제는 "빚 계산" 앞에 둔다. 뒤에 두면 면제받은 사람의 PR 때문에 조회가 실패했을 때 + # 가드가 그를 대신해 죽는다. GitHub 로그인은 대소문자를 가리지 않으므로 낮춰 비교한다. + author_lc=$(printf '%s' "$AUTHOR" | tr '[:upper:]' '[:lower:]') + exempt_lc=$(printf '%s' "$REVIEW_GATE_EXEMPT_AUTHORS" | tr '[:upper:]' '[:lower:]') + case " $exempt_lc " in + *" $author_lc "*) echo "리뷰 게이트 면제 작성자($AUTHOR) — 건너뜀"; exit 0 ;; esac # 빚의 기준은 «나에게 요청이 걸렸는가» 가 아니라 «아무도 응하지 않은 PR 이 @@ -108,11 +123,16 @@ jobs: # 누구에게 걸렸든 팀에서 한 사람이 보면 그 PR 의 리뷰는 끝난 것이다. # 그래서 요청 목록이 아니라 열린 PR 전체를 훑는다. # - # 자기 PR 은 자신이 리뷰할 수 없으므로 뺀다. draft 는 리뷰를 받을 상태가 - # 아니므로 뺀다(review-request-all 도 draft 에는 요청을 걸지 않는다). + # 자기 PR 은 뺀다 — «자기» 는 담당자(어사인, 비어 있으면 작성자) 기준이다. 남이 + # 올렸어도 내게 어사인된 PR 은 내 것이고, 내가 올렸어도 남에게 어사인된 PR 은 남의 + # 것이라 내가 리뷰할 수 있다. draft 는 리뷰를 받을 상태가 아니므로 뺀다 + # (review-request-all 도 draft 에는 요청을 걸지 않는다). 담당자 목록은 소문자 + # 쉼표 구분으로 실어 아래 반영·응답 집계의 신원으로 쓴다. if ! open_prs=$(gh api "repos/$REPO/pulls?state=open&per_page=100" --paginate \ - --jq ".[] | select(.draft == false) | select(.user.login != \"$AUTHOR\") - | \"\(.number)\t\(.created_at[0:10])\t\(.user.login)\t\([.requested_reviewers[].login | ascii_downcase] | join(\",\"))\t\(.title)\""); then + --jq ".[] | select(.draft == false) + | ((if (.assignees | length) > 0 then [.assignees[].login] else [.user.login] end) | map(ascii_downcase)) as \$owners + | select((\$owners | index(\"$author_lc\")) == null) + | \"\(.number)\t\(.created_at[0:10])\t\(\$owners | join(\",\"))\t\([.requested_reviewers[].login | ascii_downcase] | join(\",\"))\t\(.title)\""); then echo "::error::열린 PR 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다. 재실행할 것" exit 1 fi @@ -156,7 +176,7 @@ jobs: # (parents 2개 이상)을 뺀 실질 커밋으로만 센다. debt_file=$(mktemp) reviews_file=$(mktemp) - while IFS=$'\t' read -r pn created pr_author requested_reviewers title; do + while IFS=$'\t' read -r pn created pr_owners requested_reviewers title; do [ -n "$pn" ] || continue if ! gh api "repos/$REPO/pulls/$pn/reviews" --paginate \ @@ -195,6 +215,18 @@ jobs: fi IFS=$'\t' read -r blocked_by blocked_at <<< "$blocked" + # 면제 리뷰어가 낸 변경요청은 팀의 빚으로 돌아오지 않는다 (#1910). 면제된 사람은 + # 리뷰할 의무가 없으므로, 그가 자발적으로 낸 변경요청 뒤 침묵하면 그 침묵의 대가를 + # 나머지 팀원이 "새 PR 을 못 연다" 로 치르게 된다. 여기서 세지 않아도 그 PR 이 + # 그냥 머지되지는 않는다. 승인 1건이 머지 게이트에 그대로 남아 있다. + # jq 의 최신 판정은 이미 소문자로 내려온다. + case " $exempt_lc " in + *" $blocked_by "*) + echo "#$pn — 면제 리뷰어 @$blocked_by 의 변경요청, 팀 빚 아님" + continue + ;; + esac + # 반영을 비병합 커밋으로만 세면 병합으로 반영한 PR 을 놓친다. base 를 # 끌어와 충돌을 풀며 지적을 반영하면 그 뒤 커밋이 전부 parents 2개라 # «미반영» 이 된다 — #1316 은 리뷰가 지목한 파일이 실제로 바뀌고 작성자 @@ -219,21 +251,26 @@ jobs: fi # 계정이 연결되지 않은 커밋은 .author 가 비어 login 으로 가릴 수 없다. 같은 PR 에서 - # 작성자 것으로 확인된 커밋의 이메일을 모아 폴백 신원으로 쓴다. - author_emails=$(awk -F'\t' -v author="$pr_author" ' - BEGIN { target = tolower(author) } - tolower($2) == target && $3 != "" { print tolower($3) }' "$commits_file" | sort -u) + # 담당자 것으로 확인된 커밋의 이메일을 모아 폴백 신원으로 쓴다. 담당자는 여럿일 수 + # 있으므로 집합으로 들고 다닌다. + author_emails=$(awk -F'\t' -v owners="$pr_owners" ' + BEGIN { + n = split(owners, list, ",") + for (i = 1; i <= n; i++) if (list[i] != "") target[list[i]] = 1 + } + (tolower($2) in target) && $3 != "" { print tolower($3) }' "$commits_file" | sort -u) - author_commits=$(awk -F'\t' -v author="$pr_author" -v cutoff="$blocked_at" -v emails="$author_emails" ' + author_commits=$(awk -F'\t' -v owners="$pr_owners" -v cutoff="$blocked_at" -v emails="$author_emails" ' BEGIN { - target = tolower(author) + n = split(owners, list, ",") + for (i = 1; i <= n; i++) if (list[i] != "") target[list[i]] = 1 count = split(emails, list, "\n") for (i = 1; i <= count; i++) if (list[i] != "") own[list[i]] = 1 } $4 > cutoff { login = tolower($2) email = tolower($3) - if (login != "") { if (login == target) print $1 } + if (login != "") { if (login in target) print $1 } else if (email != "" && (email in own)) print $1 }' "$commits_file") @@ -258,8 +295,9 @@ jobs: fixed=$((fixed + 1)) done <<< "$author_commits" + # 응답 주체도 담당자 집합이다. 쉼표로 감싼 목록에 쉼표로 감싼 로그인이 들어 있는지 본다. if ! said=$(gh api "repos/$REPO/issues/$pn/comments?per_page=100" --paginate \ - --jq ".[] | select(.user.login == \"$pr_author\") + --jq ".[] | select((.user.login | ascii_downcase) as \$login | \",$pr_owners,\" | contains(\",\" + \$login + \",\")) | select(.created_at > \"$blocked_at\") | .id" \ | wc -l | tr -d ' '); then echo "::error::PR #$pn 작성자 일반 응답 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" @@ -267,7 +305,7 @@ jobs: fi if ! answered=$(gh api "repos/$REPO/pulls/$pn/comments?per_page=100" --paginate \ - --jq ".[] | select(.user.login == \"$pr_author\") + --jq ".[] | select((.user.login | ascii_downcase) as \$login | \",$pr_owners,\" | contains(\",\" + \$login + \",\")) | select(.created_at > \"$blocked_at\") | .id" \ | wc -l | tr -d ' '); then echo "::error::PR #$pn 작성자 리뷰 응답 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" @@ -276,7 +314,7 @@ jobs: # PR 본문은 커밋 없이도 CI 계약·설명을 고치는 변경 표면이다(#771). REST 의 # updated_at 은 댓글·라벨까지 섞이고 GraphQL lastEditedAt 은 마지막 편집자만 - # 남기므로, 영속 편집 이력에서 «최신 변경요청 뒤 작성자가 직접 고친 본문»만 센다. + # 남기므로, 영속 편집 이력에서 «최신 변경요청 뒤 담당자가 직접 고친 본문»만 센다. # 최근 50건보다 많은 편집 이력이 있으면 필요한 구간이 잘렸을 수 있으므로 # 근거 없이 작성자·리뷰어 어느 쪽으로도 공을 넘기지 않고 가드를 실패시킨다. # shellcheck disable=SC2016 # GraphQL 변수는 gh가 -F 값으로 바인딩한다. @@ -289,7 +327,7 @@ jobs: exit 1 fi if ! body_edits=$(jq -er \ - --arg author "$pr_author" \ + --arg owners ",$pr_owners," \ --arg cutoff "$blocked_at" ' .data.repository.pullRequest.userContentEdits as $edits | if ($edits.pageInfo.hasPreviousPage != false) then @@ -297,7 +335,7 @@ jobs: else [ $edits.nodes[] - | select(((.editor.login // "") | ascii_downcase) == ($author | ascii_downcase)) + | select(((.editor.login // "") | ascii_downcase) as $login | $owners | contains("," + $login + ",")) | select(.editedAt > $cutoff) ] | length end @@ -312,7 +350,7 @@ jobs: echo "#$pn — @$blocked_by 재리뷰 요청 뒤 변경요청 반영(${fixed}커밋 · 응답 ${responses}건 · 본문 편집 ${body_edits}건), 아직 미응답" printf '%s\t%s\t%s\n' "$pn" "$created" "$title" >> "$debt_file" else - echo "#$pn — 변경요청 미반영, 공은 작성자에게" + echo "#$pn — 변경요청 미반영, 공은 담당자에게" fi done <<< "$open_prs" @@ -355,10 +393,11 @@ jobs: done <<< "$author_debt" echo fi - echo "이 저장소는 팀 리뷰 응답 또는 자기 PR 의 변경요청 대응을 기다리는 PR 이 1건이라도 남아 있으면 새 PR 을 열 수 없습니다 (#1136, #1679)." + echo "이 저장소는 팀 리뷰 응답 또는 자기가 담당하는 PR 의 변경요청 대응을 기다리는 PR 이 1건이라도 남아 있으면 새 PR 을 열 수 없습니다 (#1136, #1679)." echo - echo "각 PR 에 필요한 리뷰는 **한 건**입니다 — 누구든 먼저 판정을 내면 그 PR 은 모두의 목록에서 빠집니다. 승인 뒤 변경을 다시 볼 의무는 없고, **변경요청도 작성자가 같은 리뷰어에게 재요청한 뒤 실질 커밋·응답·본문 편집을 남긴 경우에만** 다시 리뷰어의 몫이 됩니다." - echo "자기 PR 이 **최신 변경요청 뒤 실질 커밋·응답·작성자 본문 편집 없이** 남아 있으면 작성자의 몫입니다. 조치를 남기면 다음 검사에서 작성자 빚이 해소됩니다." + echo "PR 의 담당자는 올린 사람이 아니라 **어사인된 사람**입니다(어사인이 비어 있으면 작성자, #1974)." + echo "각 PR 에 필요한 리뷰는 **한 건**입니다 — 누구든 먼저 판정을 내면 그 PR 은 모두의 목록에서 빠집니다. 승인 뒤 변경을 다시 볼 의무는 없고, **변경요청도 담당자가 같은 리뷰어에게 재요청한 뒤 실질 커밋·응답·본문 편집을 남긴 경우에만** 다시 리뷰어의 몫이 됩니다." + echo "담당하는 PR 이 **최신 변경요청 뒤 실질 커밋·응답·본문 편집 없이** 남아 있으면 담당자의 몫입니다. 조치를 남기면 다음 검사에서 작성자 빚이 해소됩니다." echo echo "### 되살리는 법" echo @@ -367,8 +406,6 @@ jobs: echo '```bash' echo "gh pr reopen $PR_NUMBER" echo '```' - echo - echo "리뷰보다 이 PR 이 급하면 \`review-debt-exempt\` 라벨을 붙이고 다시 열면 통과합니다." } > /tmp/debt-comment.md echo "리뷰·작성자 미응답 ${count}건 — PR #$PR_NUMBER 를 닫는다" diff --git a/.github/workflows/review-request-all.yml b/.github/workflows/review-request-all.yml index d2c393851d..cbcba2ebe2 100644 --- a/.github/workflows/review-request-all.yml +++ b/.github/workflows/review-request-all.yml @@ -1,8 +1,10 @@ # 리뷰어 자동 요청 (#1136) # -# 리뷰 요청을 손으로 걸다 보면 빠뜨린다. PR 이 열리면 작성자를 뺀 팀 전원에게 +# 리뷰 요청을 손으로 걸다 보면 빠뜨린다. PR 이 열리면 담당자를 뺀 팀 전원에게 # 자동으로 요청한다. GitHub 은 자기 PR 의 작성자를 리뷰어로 받지 않으므로 -# 목록에 전원을 적어도 작성자는 자동으로 빠진다. +# 목록에 전원을 적어도 작성자는 자동으로 빠진다. 담당자(어사인)는 GitHub 이 막지 +# 않으므로 여기서 직접 뺀다 — PR 의 공은 올린 사람이 아니라 어사인된 사람에게 +# 있다(#1974 결정, #1980). 어사인이 비어 있는 동안만 작성자가 담당자다. # # 이 워크플로는 review-debt-guard 와 한 세트다. 요청이 자동으로 걸려야 # "리뷰를 안 하면 새 PR 을 못 연다" 가 성립한다 — 요청을 손으로 걸던 때는 @@ -40,14 +42,20 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: 작성자를 뺀 전원에게 리뷰 요청 + - name: 담당자를 뺀 전원에게 리뷰 요청 env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} AUTHOR: ${{ github.event.pull_request.user.login }} ASSOCIATION: ${{ github.event.pull_request.author_association }} - TEAM: 1hyok koongmai Sadturtleman + # 이벤트 시점의 어사인. pr-assign-author 가 같은 이벤트에서 빈 어사인에 작성자를 + # 거는 중일 수 있으므로, 비어 있으면 작성자로 폴백한다. + ASSIGNEES: ${{ join(github.event.pull_request.assignees.*.login, ' ') }} + # 리뷰 의무가 있는 팀원만 적는다. koongmai 는 남의 PR 을 리뷰할 의무가 없어 + # 빠져 있다 (#1910). 자기 PR 에는 나머지 둘에게 그대로 요청이 걸리고, + # 스스로 리뷰하는 것도 막히지 않는다. 빠진 것은 자동 요청뿐이다. + TEAM: 1hyok Sadturtleman run: | set -euo pipefail @@ -66,26 +74,35 @@ jobs: current=$(gh api "repos/$REPO/pulls/$PR_NUMBER" \ --jq '[.requested_reviewers[].login] | join(" ")') + # 담당자는 리뷰어가 아니다. 어사인이 있으면 어사인, 없으면 작성자. 로그인은 + # 대소문자를 가리지 않으므로 낮춰 비교한다. + owners_lc=$(printf '%s' "${ASSIGNEES:-$AUTHOR}" | tr '[:upper:]' '[:lower:]') + current_lc=$(printf '%s' "$current" | tr '[:upper:]' '[:lower:]') + targets="" for u in $TEAM; do - [ "$u" != "$AUTHOR" ] || continue - case " $current " in - *" $u "*) continue ;; + u_lc=$(printf '%s' "$u" | tr '[:upper:]' '[:lower:]') + case " $owners_lc " in + *" $u_lc "*) continue ;; + esac + case " $current_lc " in + *" $u_lc "*) continue ;; esac targets="${targets:+$targets,}$u" done if [ -z "$targets" ]; then - echo "추가할 리뷰어 없음 (이미 요청됨 또는 작성자뿐)" + echo "추가할 리뷰어 없음 (이미 요청됨 또는 담당자뿐)" exit 0 fi echo "리뷰 요청: $targets" gh pr edit "$PR_NUMBER" --repo "$REPO" --add-reviewer "$targets" - # 변경요청 뒤 반영 커밋이 올라오거나 작성자가 PR 본문을 고치면 그 리뷰어에게 + # 변경요청 뒤 반영 커밋이 올라오거나 담당자가 PR 본문을 고치면 그 리뷰어에게 # 다시 요청을 건다(#1450, #1700). `edited` 전체를 받되 제목 수정·관리자 대리 수정은 - # 작성자 조치가 아니므로 body 변경과 sender=author 를 함께 요구한다. + # 담당자 조치가 아니므로 body 변경과 sender=담당자 를 함께 요구한다. 담당자는 + # 어사인이고, 어사인이 비어 있으면 작성자다(#1974). # # 판정 기준은 review-debt-guard 와 같아야 한다 — 여기서 거는 요청이 곧 그쪽의 # 빚 성립 조건이므로, 한쪽만 «PR 전체의 최신 판정» 을 보면 자동 재요청이 빚으로 @@ -99,7 +116,13 @@ jobs: ( github.event.action == 'edited' && github.event.changes.body != null && - github.event.sender.login == github.event.pull_request.user.login + ( + contains(github.event.pull_request.assignees.*.login, github.event.sender.login) || + ( + github.event.pull_request.assignees[0] == null && + github.event.sender.login == github.event.pull_request.user.login + ) + ) ) ) runs-on: ubuntu-latest @@ -111,6 +134,7 @@ jobs: REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} AUTHOR: ${{ github.event.pull_request.user.login }} + ASSIGNEES: ${{ join(github.event.pull_request.assignees.*.login, ' ') }} HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} ACTION: ${{ github.event.action }} EVENT_AT: ${{ github.event.pull_request.updated_at }} @@ -162,20 +186,21 @@ jobs: IFS=$'\t' read -r blocked blocked_at <<< "$blocked" # 자기 자신은 리뷰어가 될 수 없다(422). 작성자가 자기 PR 에 변경요청을 낼 - # 수는 없지만, 소유권이 옮겨진 PR 에서 그렇게 보일 수 있다. - if [ "$blocked" = "$AUTHOR" ]; then - echo "변경요청을 낸 사람이 작성자 본인 — 건너뜀" - exit 0 - fi + # 수는 없지만, 소유권이 옮겨진 PR 에서 그렇게 보일 수 있다. 담당자(어사인)가 + # 낸 변경요청도 자기 PR 에 대한 것이라 되살리지 않는다. + owners_lc=$(printf '%s' "${ASSIGNEES:-$AUTHOR}" | tr '[:upper:]' '[:lower:]') + blocked_lc=$(printf '%s' "$blocked" | tr '[:upper:]' '[:lower:]') + case " $owners_lc " in + *" $blocked_lc "*) echo "변경요청을 낸 사람이 담당자 본인(@$blocked) — 건너뜀"; exit 0 ;; + esac # 이미 걸려 있으면 다시 걸지 않는다. synchronize 는 force-push 마다 온다. current=$(gh api "repos/$REPO/pulls/$PR_NUMBER" \ --jq '[.requested_reviewers[].login] | join(" ")') - blocked_lc=$(printf '%s' "$blocked" | tr '[:upper:]' '[:lower:]') current_lc=$(printf '%s' "$current" | tr '[:upper:]' '[:lower:]') case " $current_lc " in *" $blocked_lc "*) echo "@$blocked 요청이 이미 걸려 있음 — 건너뜀"; exit 0 ;; esac - echo "변경요청($blocked_at) 뒤 작성자 조치($ACTION) — @$blocked 에게 재리뷰 요청" + echo "변경요청($blocked_at) 뒤 담당자 조치($ACTION) — @$blocked 에게 재리뷰 요청" gh pr edit "$PR_NUMBER" --repo "$REPO" --add-reviewer "$blocked" diff --git a/.github/workflows/screenshot-baseline-generate.yml b/.github/workflows/screenshot-baseline-generate.yml index aad1e4d254..36cfd5f7cd 100644 --- a/.github/workflows/screenshot-baseline-generate.yml +++ b/.github/workflows/screenshot-baseline-generate.yml @@ -55,10 +55,10 @@ jobs: fi - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build CI screenshot image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: Dockerfile.screenshot diff --git a/.github/workflows/screenshot.yml b/.github/workflows/screenshot.yml index 03a8781fee..5465785242 100644 --- a/.github/workflows/screenshot.yml +++ b/.github/workflows/screenshot.yml @@ -53,10 +53,10 @@ jobs: # 동일 이미지에서 baseline 생성·검증을 수행해 anti-aliasing/font/scale 등 host 환경 차이 root fix. # Dockerfile 변경 없으면 cache 재사용 — 분당 단위 추가 비용. - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build screenshot image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: Dockerfile.screenshot diff --git a/.github/workflows/stack-integrity-notify.yml b/.github/workflows/stack-integrity-notify.yml index e2158b88ac..b6c3a6e9e9 100644 --- a/.github/workflows/stack-integrity-notify.yml +++ b/.github/workflows/stack-integrity-notify.yml @@ -21,7 +21,10 @@ jobs: permissions: contents: read issues: write - pull-requests: read + # 이 job 이 코멘트를 남기는 대상은 언제나 PR 이다. GitHub 은 issues/{n}/comments 의 + # 필요 스코프를 대상 타입으로 가르기 때문에 PR 에 쓰려면 pull-requests 쓰기가 있어야 한다. + # read 로 두면 목록 GET 은 통과하고 첫 POST 에서 403 이 난다(#2068). + pull-requests: write steps: - name: Clone stack policy from the default branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index decec9d655..cb1079109e 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -114,9 +114,13 @@ jobs: if: inputs.run_node_tests run: node --test .github/scripts/*.test.mjs + - name: Run jarsigner policy fixtures + if: inputs.run_node_tests + run: bash scripts/test-jarsigner-verification-policy.sh + - name: Set up JDK 21 if: inputs.gradle_tasks != '' - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '21' diff --git a/.gitignore b/.gitignore index 9a4dba8947..2fbcfd7448 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,11 @@ gha-creds-*.json **/.DS_Store AGENTS.md +# 코딩 에이전트 설정 — 사람마다 도구와 구성이 다르므로 저장소가 공유하지 않는다 (README 참고) .codex/ .cursor/ .artifacts/ +.mcp.json + +# Windows PowerShell cache +Microsoft/Windows/PowerShell/ModuleAnalysisCache diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 83957bc689..0000000000 --- a/.mcp.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "mcpServers": { - "mobile": { - "type": "stdio", - "command": "npx", - "args": ["-y", "claude-in-mobile@3.8.1"] - } - } -} diff --git a/README.md b/README.md index e32a86ff06..b7d2e69a98 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,9 @@ debug 빌드는 빈 값으로도 통과하지만(로컬 개발 편의) 다음이 ## 공유 debug keystore -debug 빌드는 기본적으로 머신마다 다른 `~/.android/debug.keystore` 로 서명되어, 카카오 로그인 키 해시를 팀원 머신별로 콘솔에 등록해야 한다. 팀 공유 debug keystore 를 배치하면 전 머신이 동일 키 해시로 서명되어 콘솔 등록이 keystore 1개로 끝난다. (미배치 시에도 빌드는 정상 — 기본 debug keystore 폴백 — 대신 본인 머신 키 해시를 직접 등록해야 카카오 로그인이 동작한다.) +debug 빌드는 기본적으로 머신마다 다른 `~/.android/debug.keystore` 로 서명되어, 카카오 로그인 키 해시를 팀원 머신별로 콘솔에 등록해야 한다. 팀 공유 debug keystore 를 배치하면 전 머신이 동일 키 해시로 서명되어 콘솔 등록이 keystore 1개로 끝난다. + +`DEBUG_*` 네 값이 모두 없으면 기본 debug keystore로 빌드는 계속되지만, `preDebugBuild`에 연결된 `checkDebugSigningForKakao`가 매번 경고한다(configuration cache 재사용 시에도 출력). 콘솔에 등록되지 않은 키로 서명하면 카카오톡이 설치되어 있어도 앱 로그인에 실패하고 웹(CustomTab) 로그인으로 폴백할 수 있다. **웹 로그인 성공만으로 카카오톡 앱-투-앱 경로 QA를 완료하지 않는다.** 공유 키를 설정했더라도 콘솔 등록 상태는 별도로 확인해야 한다. 1. **keystore 수령·배치** — 위 1Password 공유 링크의 `debug_store_file_b64` 값을 복사한 뒤 홈 디렉토리에 복원한다. (공유 링크에는 파일 첨부가 실리지 않아 keystore 를 base64 텍스트로 전달한다.) @@ -109,12 +111,26 @@ debug 빌드는 기본적으로 머신마다 다른 `~/.android/debug.keystore` 3. **적용 확인** — `./gradlew :app:signingReport` 출력의 `Variant: debug` 에서 `Store:` 가 공유 keystore 경로를 가리키는지 확인 -공유 keystore 의 카카오 키 해시 추출 명령 (Kakao Developers → 앱 → 플랫폼 → Android → 키 해시 등록·재확인용): +현재 debug 빌드가 사용하는 keystore의 카카오 키 해시를 출력한다. 공유 키를 설정했다면 공유 인증서, 미설정이면 AGP 기본 인증서를 읽으며, 새 머신의 기본 keystore는 `validateSigningDebug`가 먼저 생성한다. 비밀번호나 개인키는 출력하지 않는다. ```bash -keytool -exportcert -alias afternote-debug-shared -keystore ~/afternote-debug-shared.jks | openssl sha1 -binary | openssl base64 +./gradlew :app:printDebugKakaoKeyHash ``` +`Debug Kakao key hash:` 뒤의 값을 Kakao Developers → 앱 → 플랫폼 → Android → 키 해시에 등록·재확인한다. 값은 서명 인증서의 SHA-1을 Base64로 인코딩한 것으로, `signingReport`의 콜론으로 구분된 SHA-1 문자열과 형식이 다르다([카카오 공식 안내](https://developers.kakao.com/docs/latest/ko/android/getting-started#key-hash)). 등록 후에는 카카오톡이 설치된 기기에서 앱 전환 후 로그인 완료까지 다시 확인한다. + +## 코딩 에이전트 도구 (선택) + +코딩 에이전트 설정은 저장소가 공유하지 않는다 — 사람마다 쓰는 도구와 구성이 다르고, 저장소 파일이 각자 머신에서 실행되는 것을 정하면 리뷰가 그것까지 책임져야 한다. `.claude/`·`CLAUDE.md`·`AGENTS.md`·`.codex/`·`.mcp.json` 은 모두 `.gitignore` 대상이며 각자 자기 환경에 등록한다. + +에뮬레이터·실기기를 에이전트로 제어하려면 아래 MCP 서버를 한 번 등록한다. (`-s user` 는 이 머신의 모든 프로젝트에 적용한다.) + +```bash +claude mcp add mobile -s user -- npx -y claude-in-mobile@3.8.1 +``` + +등록 확인은 `claude mcp list` 로 한다. 기기 제어를 쓰지 않으면 등록하지 않아도 된다. + --- # 💻 코딩 및 패키지 컨벤션 @@ -152,6 +168,7 @@ keytool -exportcert -alias afternote-debug-shared -keystore ~/afternote-debug-sh - Issue는 [현재 Issue form](.github/ISSUE_TEMPLATE/issue.yml)을 사용하고, 같은 작업의 기존 Issue가 있으면 새로 만들지 않고 재사용한다. - PR은 [현재 PR 템플릿](.github/PULL_REQUEST_TEMPLATE.md)을 그대로 채운다. 본문에 같은 저장소의 실제 Issue를 `Refs #N`으로 연결해야 Repository Quality 검사를 통과한다. +- `src/main`에 새로 넣은 Kotlin 함수는 main 어딘가에서 참조되어야 한다. 테스트만 부르거나 아무도 부르지 않으면 Repository Quality가 실패한다. 후속 PR이 곧 소비하는 공개 계약이면 PR 라벨 `test-only-production-exempt`로 경고로 낮추고 본문에 소비처를 적는다. - 여러 PR이 같은 Issue를 공유할 수 있다. 그 Issue의 작업을 최종 완료하는 PR에서만 `Closes #N`·`Fixes #N`·`Resolves #N`을 사용한다. - `CI Test Plan`에는 Android 계측 테스트를 `none`·`selected`·`full`로 선언하고 선택 이유를 변경 경계 기준으로 남긴다. - 필수 검사는 머지 순서 가드, Ktlint, Android Lint, Unit Test, Screenshot, Repository Quality, CodeQL(Java/Kotlin·Actions)다. JavaScript/TypeScript CodeQL도 저장소 자동화 스크립트를 분석한다. 이름이나 구성이 바뀌면 README 목록보다 활성 ruleset과 [PR 검증 진입점](.github/workflows/pr-validation.yml)을 우선한다. @@ -168,10 +185,11 @@ Kover는 임의의 절대 커버리지 목표를 강제하지 않는다. 정확 ## 코드 리뷰 -- Draft가 아닌 내부 팀원 PR이 열리거나 리뷰 가능 상태가 되면 [자동 요청 workflow](.github/workflows/review-request-all.yml)가 작성자를 제외한 팀원에게 리뷰를 요청한다. 요청 인원 수와 필수 승인 수는 같은 뜻이 아니다. +- PR의 담당자는 올린 사람이 아니라 **어사인된 사람**이다. 어사인이 비어 있으면 [자동 어사인 workflow](.github/workflows/pr-assign-author.yml)가 작성자를 걸고, 사람이 담당자를 바꾼 것은 되돌리지 않는다. 변경요청 대응·리뷰 요청 제외·아래 적체 가드의 «자기 PR» 판정은 모두 이 담당자 기준이다. +- Draft가 아닌 내부 팀원 PR이 열리거나 리뷰 가능 상태가 되면 [자동 요청 workflow](.github/workflows/review-request-all.yml)가 담당자를 제외한 리뷰 담당 팀원(`TEAM`)에게 리뷰를 요청한다. 요청 인원 수와 필수 승인 수는 같은 뜻이 아니다. - 현재 `develop`·`main` 머지에는 **승인 1건**이 필요하다. `main`은 모든 리뷰 스레드 해결도 필요하다. - 리뷰 결과는 GitHub의 `APPROVED`·`CHANGES_REQUESTED`·일반 코멘트로 표현한다. 별도 RCA prefix나 12시간 SLA는 두지 않는다. -- [리뷰 적체 가드](.github/workflows/review-debt-guard.yml)는 응답을 기다리는 다른 PR이 남았거나 자기 PR의 최신 변경요청 뒤 아무 조치도 하지 않은 팀원의 새 PR을 닫을 수 있다. 각 PR은 팀원 한 명이 먼저 유효한 판정을 내리면 최초 미응답 목록에서 빠지고, 작성자가 실질 커밋이나 응답을 남기면 작성자 대기 목록에서 빠진다. -- `awaiting-author` 라벨은 변경요청 뒤 작성자 무조치 상태를 보여 주는 표식이다. 새 PR 가드는 라벨 갱신 시점에 의존하지 않고 같은 판정을 현재 열린 PR에 다시 적용한다. +- [리뷰 적체 가드](.github/workflows/review-debt-guard.yml)는 응답을 기다리는 다른 PR이 남았거나 자기가 담당하는 PR의 최신 변경요청 뒤 아무 조치도 하지 않은 팀원의 새 PR을 닫을 수 있다. 아래 면제 목록에 있는 작성자는 대상이 아니다. 각 PR은 팀원 한 명이 먼저 유효한 판정을 내리면 최초 미응답 목록에서 빠지고, 담당자가 실질 커밋이나 응답을 남기면 작성자 대기 목록에서 빠진다. +- `awaiting-author` 라벨은 리뷰 게이트 적용 대상 중 변경요청 뒤 담당자 무조치 상태인 PR을 보여 준다. 면제 작성자에게는 붙이지 않으며, 이미 붙은 라벨도 리컨사일러가 제거한다. 새 PR 가드는 라벨 갱신 시점에 의존하지 않고 같은 판정을 현재 열린 PR에 다시 적용한다. - 쓰기 권한이 있는 리뷰어별 최신 `APPROVED`·`CHANGES_REQUESTED` 가운데 PR 전체에서 가장 늦은 판정을 최종 판정으로 사용한다. 가장 늦은 판정이 승인이면 더 오래된 변경 요청은 자동 해제되고, 승인 뒤에 새 변경 요청이 오면 다시 차단된다. -- 긴급 PR은 근거가 있을 때만 `review-debt-exempt` 라벨로 적체 가드를 우회한다. +- 적체 가드에는 라벨 우회가 없다. 가드와 `awaiting-author` 라벨이 함께 쓰는 면제 목록은 [review-debt-guard.yml](.github/workflows/review-debt-guard.yml)의 `REVIEW_GATE_EXEMPT_AUTHORS`이며, 지금은 `koongmai`가 거기에 있다. 면제받은 사람은 리뷰 지적 반영 여부와 무관하게 새 PR을 열 수 있고 남의 PR을 리뷰할 의무가 없다. 그래서 자동 요청 대상에서도 빠지고, 그가 낸 변경요청은 다른 팀원의 빚으로 세지 않는다. 머지 게이트는 면제와 무관하다. `develop` 승인 1건과 필수 체크는 그대로 요구된다. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ee3ba17eb5..38b91e10e1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,6 +21,10 @@ if (localPropertiesFile.exists()) { // BuildConfig 가 같은 값을 쓴다 — 주입 지점이 둘이어도 키는 여기서 한 번만 읽는다. val kakaoKey = socialLoginKey("KAKAO_NATIVE_APP_KEY") +// Play 배포 워크플로가 명시적으로 활성화한다. versionCode만으로 배포 채널을 추측하지 않는다. +val afternoteVersionCode = resolveAfternoteVersionCode(System.getenv(AFTERNOTE_VERSION_CODE_ENV)) +val storeDistributedBuild = providers.environmentVariable("AFTERNOTE_STORE_DISTRIBUTED_BUILD").orNull == "true" + android { namespace = "com.afternote.afternote_fe" @@ -30,7 +34,7 @@ android { defaultConfig { applicationId = "com.afternote.afternote_fe" - versionCode = resolveAfternoteVersionCode(System.getenv(AFTERNOTE_VERSION_CODE_ENV)) + versionCode = afternoteVersionCode versionName = "1.0" testInstrumentationRunner = "com.afternote.afternote_fe.test.AfternoteTestRunner" @@ -38,6 +42,7 @@ android { manifestPlaceholders["KAKAO_NATIVE_APP_KEY"] = kakaoKey buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"$kakaoKey\"") + buildConfigField("boolean", "STORE_DISTRIBUTED_BUILD", storeDistributedBuild.toString()) } testOptions { @@ -179,6 +184,12 @@ android { ) } } + registerDebugSigningDiagnostics( + usingDefaultKeystore = provided.isEmpty(), + storeFile = storeFile, + storePassword = storePassword, + keyAlias = keyAlias, + ) } } @@ -222,8 +233,6 @@ dependencies { // App Startup — 기동 초기화는 app 매니페스트에 등록한 Initializer 로 실행한다. implementation(libs.androidx.startup.runtime) - // DailyNotificationInitializer 가 WorkManagerInitializer 를 선행 의존으로 지정한다. - implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.profileinstaller) // 카카오 OAuth redirect Activity(`com.kakao.sdk.auth.AuthCodeHandlerActivity`)를 @@ -260,21 +269,24 @@ dependencies { implementation(projects.feature.onboarding.presentation) implementation(projects.feature.setting.presentation) - // Feature — domain (AppNavigationActions 가 에디터 종류를 AfternoteType 으로 받는다) - implementation(projects.feature.afternote.domain) - // Feature — data (Hilt @Module / 바인딩이 루트 그래프에 포함되도록 app이 classpath에 둔다) implementation(projects.feature.afternote.data) implementation(projects.feature.receiver.data) implementation(projects.feature.mindrecord.data) implementation(projects.feature.timeletter.data) implementation(projects.feature.onboarding.data) + implementation(projects.feature.setting.data) testImplementation(libs.coroutines.test) testImplementation(testFixtures(projects.core.domain)) + // 데일리 알림 예약 관찰(#2146)이 실제로 넣고 지운 WorkManager 예약을 읽는다. app main 은 WorkManager 를 + // 직접 부르지 않는다. 예약 API 는 core:common 에 있고, WorkManagerInitializer 는 그 의존을 타고 + // InitializationProvider 에 병합된다. + testImplementation(libs.androidx.work.testing) // Nav2 백스택 회귀 기준 (#1601) — 에뮬레이터 없이 NavHost 를 실제 컴포지션으로 띄워 - // 탭 상태 복원·인증 스택 경계·flow-scoped ViewModel 수명을 잰다. 대상(AppState· + // 탭 상태 복원·인증 스택 경계·predictive back 진행·취소·완료를 잰다. flow-scoped ViewModel + // 수명은 Nav3 이관(#1698)이 core:ui 의 FeatureNavDisplayTest 로 옮겼다. 대상(AppState· // AppNavigationActions)이 app 모듈에만 있어 피처 모듈 Robolectric 설정을 재사용할 수 없다. testImplementation(libs.robolectric) testImplementation(libs.androidx.compose.ui.test.junit4) @@ -293,14 +305,20 @@ dependencies { androidTestImplementation(libs.coroutines.test) androidTestImplementation(projects.core.data) androidTestImplementation(testFixtures(projects.core.domain)) + // 실제 화면에서 잰 48dp 터치 계약 판정을 피처 테스트와 같은 스캐너로 공유한다 (#2035). + androidTestImplementation(testFixtures(projects.core.ui)) androidTestImplementation(projects.feature.afternote.domain) androidTestImplementation(testFixtures(projects.feature.afternote.domain)) androidTestImplementation(projects.feature.mindrecord.domain) androidTestImplementation(testFixtures(projects.feature.mindrecord.domain)) androidTestImplementation(projects.feature.receiver.domain) androidTestImplementation(testFixtures(projects.feature.receiver.domain)) + androidTestImplementation(projects.feature.setting.data) + androidTestImplementation(projects.feature.setting.domain) + androidTestImplementation(testFixtures(projects.feature.setting.domain)) androidTestImplementation(projects.feature.timeletter.domain) androidTestImplementation(testFixtures(projects.feature.timeletter.domain)) + androidTestImplementation(testFixtures(projects.feature.timeletter.data)) kspAndroidTest(libs.hilt.compiler) debugImplementation(libs.androidx.compose.ui.test.manifest) androidTestUtil(libs.androidx.test.orchestrator) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 53a2ad290c..48a2a5ecba 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -60,8 +60,10 @@ # 한다(값 파싱은 enum 상수의 이름 문자열 비교). R8 은 클래스·상수 이름만 바꾸고 그 문자열은 # 안 바꾸므로, 규칙이 없으면 release 빌드가 NavHost 조립에서 IllegalArgumentException 으로 # 죽는다 — 로그인 여부와 무관하게 모든 기동이 실패한다 (#1753). -# 지금 nav 인자로 쓰이는 enum 은 AfternoteType 하나지만 대상을 하나만 적으면 다음에 enum 인자를 -# 추가하는 사람이 같은 크래시를 다시 만든다. 예외 타입 규칙과 같은 이유·같은 모양으로 앱 전체에 건다. +# #1753 때 걸린 enum 은 AfternoteType 이었는데, 그 route 는 Nav3 이관(#1698)으로 NavKey 가 되어 지금 +# Nav2 route 인자에는 enum 이 없다(Nav3 NavKeySerializer 는 런타임 클래스 이름으로 쓰고 읽어 이 경로를 +# 타지 않는다). 그래도 대상을 하나만 적으면 다음에 enum 인자를 추가하는 사람이 같은 크래시를 다시 +# 만든다. 예외 타입 규칙과 같은 이유·같은 모양으로 앱 전체에 건다. # 이름만 필요하므로 멤버 난독화와 미사용 클래스 제거는 그대로 둔다. -keepnames class com.afternote.** extends java.lang.Enum -keepclassmembernames class com.afternote.** extends java.lang.Enum { diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt index d5106e75b3..3d20af2511 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt @@ -20,6 +20,7 @@ import androidx.paging.PagingState import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.ListItem @@ -65,7 +66,7 @@ class AfternoteAuthorExtendedAndroidTest { listFlows[null] = Pager(PagingConfig(pageSize = 20)) { pagingSource }.flow listFlows[AfternoteType.SOCIAL_NETWORK] = flowOf(PagingData.empty()) - val viewModel = AfternoteHomeViewModel(repository) + val viewModel = AfternoteHomeViewModel(repository, FakeErrorReporter()) val detailRoutes = mutableListOf() val addRoutes = mutableListOf() diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt new file mode 100644 index 0000000000..20b113c583 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt @@ -0,0 +1,190 @@ +package com.afternote.afternote_fe + +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.afternote_fe.test.FakeErrorReporter +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.author.ListItem +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import com.afternote.feature.afternote.presentation.home.AfternoteHomeEntry +import com.afternote.feature.afternote.presentation.home.AfternoteHomeViewModel +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import com.afternote.core.ui.R as CoreUiR +import com.afternote.feature.afternote.presentation.R as AfternoteR + +/** + * 새로고침 실패의 표시 갈림 세 가지를 실기에서 잠근다 (#1790 · 구현은 #705). + * + * | 상태 | 그려야 하는 것 | + * |---|---| + * | 실패 + 목록 0건 | 전면 오류 본문 | + * | 실패 아님 | 둘 다 아님 | + * | 실패 + 목록 있음 | 새로고침 실패 배너 (목록은 그대로) | + * + * **왜 계측인가.** 이 갈림은 `AfternoteHomeScreen` 본문의 `when` 이 `LazyPagingItems.loadState` 와 + * `itemCount` 를 함께 보고 정한다. JVM(Robolectric)에서 화면을 띄우면 `collectAsLazyPagingItems` 의 + * 첫 로드 상태가 `Loading` 인 채로 남아 단언 시점까지 이펙트가 진행되지 않는다 — 클래스가 누적된 + * 모듈 전량 실행에서 재현되고 `waitUntil` 로도 풀리지 않는다(#1443 이 실측, PR #1608 에서 두 번 무너짐). + * 실기에서는 같은 수집이 정상으로 돌아 세 갈림이 결정적으로 그려진다. + * + * 판정 함수(`shouldShowRefreshErrorBanner`)의 공개 범위는 넓히지 않는다(#1678). 화면을 그대로 띄워 + * **그려진 결과**로 확인하므로 프로덕션 seam 도 새로 만들지 않는다 — 이 파일은 #1804(MVI Screen/Content + * 2단 전환)이 다시 쓸 화면 파일을 건드리지 않는다. + * + * 상태 전이는 페이징 소스가 만든다. 실패/성공은 [failing] 이 정하고, 「당겨 새로고침」은 + * 현재 세대의 소스를 무효화해 만든다 — `LazyPagingItems.refresh()` 가 내부에서 하는 것과 같은 일이라 + * 제스처 임계값에 결과가 좌우되지 않는다. + */ +@RunWith(AndroidJUnit4::class) +class AfternoteHomeRefreshBannerAndroidTest { + @get:Rule(order = 0) + val composeRule = createComposeRule() + + @get:Rule(order = 1) + val failureArtifactRule = + FailureArtifactRule { + composeRule.onRoot().captureToImage().asAndroidBitmap() + } + + private val failing = AtomicBoolean(true) + private val currentSource = AtomicReference?>(null) + + @Test + fun homeRefreshFailure_showsBannerOverKeptList_andFullErrorOnlyWhenListIsEmpty() { + val repository = + FakeAfternoteRepository.strict().apply { + onGetPagedAfternotes = { + Pager(PagingConfig(pageSize = 20)) { + ScriptedListPagingSource(failing, authorListItems()) + .also(currentSource::set) + }.flow + } + } + val viewModel = AfternoteHomeViewModel(repository, FakeErrorReporter()) + + composeRule.setContent { + AfternoteTheme { + AfternoteHomeEntry( + navigateToDetail = {}, + navigateToAdd = {}, + onSettingClick = {}, + viewModel = viewModel, + ) + } + } + + // 실패 + 목록 0건 → 전면 오류만. + awaitText(fullErrorCopy) + composeRule.onNodeWithText(fullErrorCopy).assertIsDisplayed() + composeRule.onNodeWithText(bannerCopy).assertDoesNotExist() + + // 실패 아님 → 둘 다 아님. 목록이 실제로 그려졌음을 함께 단언해 로딩 화면의 false green 을 막는다. + failing.set(false) + composeRule.onNodeWithText(retryCopy).performClick() + awaitText(FIRST_ITEM_NAME) + composeRule.onNodeWithText(FIRST_ITEM_NAME).assertIsDisplayed() + composeRule.onNodeWithText(bannerCopy).assertDoesNotExist() + composeRule.onNodeWithText(fullErrorCopy).assertDoesNotExist() + + // 실패 + 목록 있음 → 배너. 전면 오류가 목록을 밀어내지 않는다. + failing.set(true) + refreshList() + awaitText(bannerCopy) + composeRule.onNodeWithText(bannerCopy).assertIsDisplayed() + composeRule.onNodeWithText(fullErrorCopy).assertDoesNotExist() + composeRule.onNodeWithText(FIRST_ITEM_NAME).assertIsDisplayed() + + // 배너의 «다시 시도» 가 같은 목록을 복구하고 배너를 걷는다. + failing.set(false) + composeRule.onNodeWithText(bannerRetryCopy).performClick() + awaitTextGone(bannerCopy) + composeRule.onNodeWithText(FIRST_ITEM_NAME).assertIsDisplayed() + composeRule.onNodeWithText(fullErrorCopy).assertDoesNotExist() + } + + /** 「당겨 새로고침」과 같은 경로 — 현재 세대의 소스를 무효화해 refresh 로드를 다시 태운다. */ + private fun refreshList() { + val source = requireNotNull(currentSource.get()) { "페이징 소스가 아직 만들어지지 않았다" } + source.invalidate() + } + + private fun awaitText(text: String) { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun awaitTextGone(text: String) { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isEmpty() + } + } + + /** 문구는 리소스가 정본이다 — 문구가 바뀌어도 단언이 따라간다 (#567). */ + private fun copy(resId: Int): String = + InstrumentationRegistry + .getInstrumentation() + .targetContext + .getString(resId) + + private val fullErrorCopy: String get() = copy(AfternoteR.string.afternote_home_load_error) + private val bannerCopy: String get() = copy(CoreUiR.string.core_ui_list_refresh_error) + private val bannerRetryCopy: String get() = copy(CoreUiR.string.core_ui_list_refresh_retry) + private val retryCopy: String get() = copy(AfternoteR.string.afternote_home_retry) + + private companion object { + const val TIMEOUT_MILLIS = 10_000L + + /** 목록이 살아 있는지 보는 기준 항목. 첫 카드라 스크롤 없이 그려진다. */ + const val FIRST_ITEM_NAME = "Instagram" + } +} + +/** 실패/성공을 [failing] 으로 갈아끼우는 목록 소스. 세대마다 새 인스턴스가 필요해 팩토리가 매번 만든다. */ +private class ScriptedListPagingSource( + private val failing: AtomicBoolean, + private val items: List, +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult = + if (failing.get()) { + LoadResult.Error(IllegalStateException("offline")) + } else { + LoadResult.Page(data = items, prevKey = null, nextKey = null) + } + + override fun getRefreshKey(state: PagingState): Int? = null +} + +private fun authorListItems(): List = + listOf( + ListItem( + id = 201L, + serviceName = "Instagram", + date = "2026.09.03", + type = AfternoteType.SOCIAL_NETWORK, + ), + ListItem( + id = 202L, + serviceName = "Google Drive", + date = "2026.09.03", + type = AfternoteType.GALLERY_AND_FILES, + ), + ) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt index 93a05984c3..5865b76162 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt @@ -6,7 +6,6 @@ import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.captureToImage @@ -15,6 +14,7 @@ import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick @@ -23,16 +23,15 @@ import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextReplacement import androidx.compose.ui.test.waitUntilAtLeastOneExists import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navigation import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.closeSoftKeyboard import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.navigation.AppState import com.afternote.afternote_fe.navigation.rememberAfternoteAppState import com.afternote.afternote_fe.navigation.rememberHomeTabActions -import com.afternote.afternote_fe.navigation.rememberReceiverNavActions import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.afternote_fe.test.emptyWeeklyReport @@ -48,40 +47,32 @@ import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel +import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute import com.afternote.feature.home.presentation.HomeTabActions -import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords +import com.afternote.feature.home.presentation.receiver.ReceiverHomeEvent import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository -import com.afternote.feature.mindrecord.domain.testing.FakeMindRecordReceiverRepository import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepository import com.afternote.feature.mindrecord.presentation.model.MindRecordCategory import com.afternote.feature.receiver.domain.error.ReceiverFailure import com.afternote.feature.receiver.domain.error.ReceiverRejectionReason -import com.afternote.feature.receiver.domain.model.AfterNoteListItem -import com.afternote.feature.receiver.domain.model.AfterNotesListResult import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail import com.afternote.feature.receiver.domain.model.ReceivedPlaylistDetail import com.afternote.feature.receiver.domain.model.ReceivedPlaylistSong import com.afternote.feature.receiver.domain.model.ReceiverEmailAuthResult -import com.afternote.feature.receiver.domain.model.SenderMessageInfo import com.afternote.feature.receiver.domain.testing.FakeIdentityVerificationRepository import com.afternote.feature.receiver.domain.testing.FakeReceiverAuthRepository import com.afternote.feature.receiver.domain.testing.FakeReceiverDeliveryDocumentUploadRepository import com.afternote.feature.receiver.domain.testing.FakeReceiverRepository +import com.afternote.feature.receiver.domain.usecase.SubmitDeliveryVerificationUseCase import com.afternote.feature.receiver.presentation.deliveryverification.DocumentSlot import com.afternote.feature.receiver.presentation.deliveryverification.DocumentUploadScreen import com.afternote.feature.receiver.presentation.deliveryverification.DocumentUploadViewModel import com.afternote.feature.receiver.presentation.deliveryverification.IdentityVerificationEmailScreen import com.afternote.feature.receiver.presentation.deliveryverification.IdentityVerificationViewModel -import com.afternote.feature.receiver.presentation.home.ReceiverHomeActions -import com.afternote.feature.receiver.presentation.home.ReceiverHomeEvent -import com.afternote.feature.receiver.presentation.home.ReceiverHomeScreen -import com.afternote.feature.receiver.presentation.home.ReceiverHomeViewModel -import com.afternote.feature.receiver.presentation.home.model.ReceiverHomeUiState import com.afternote.feature.receiver.presentation.navigation.ReceiverNavActions import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute -import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetterList import com.afternote.feature.timeletter.domain.testing.FakeReceiverTimeLetterRepository import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -102,14 +93,6 @@ import com.afternote.feature.home.presentation.R as HomeR import com.afternote.feature.onboarding.presentation.R as OnboardingR import com.afternote.feature.receiver.presentation.R as ReceiverR -/** 이 테스트의 관심 밖인 외부 라우팅을 채우는 no-op 묶음. */ -private val noopActions = - ReceiverHomeActions( - onNavigateToMindRecord = {}, - onNavigateToTimeLetter = {}, - onNavigateToAfternote = {}, - ) - @HiltAndroidTest @RunWith(AndroidJUnit4::class) @OptIn(ExperimentalTestApi::class) @@ -238,6 +221,43 @@ class AppAndReceiverCompletionAndroidTest { .onNodeWithText(context.getString(ReceiverR.string.receiver_records_box_empty)) .assertIsDisplayed() } + + /** + * 발신자 카드 탭 → 발신자 상세 (#2168). + * + * 상세 ViewModel 은 로컬 Navigation 3 entry 안에서 실제 Hilt 경로로 만들어진다. entry 의 + * SavedStateHandle 에는 NavKey 필드가 실리지 않으므로 `toRoute` 로 `senderId` 를 읽던 VM 은 + * 여기서 `MissingFieldException` 으로 죽었다. VM 을 손으로 만드는 유닛 테스트는 이 경로를 타지 않는다. + */ + @Test + fun welcomeCheckRecords_registeredSenderCard_opensSenderDetail() { + val senderName = "테스트 발신자" + composeRule + .onNodeWithText(context.getString(OnboardingR.string.onboarding_welcome_check_records)) + .performClick() + composeRule + .onNodeWithContentDescription(context.getString(CoreUiR.string.core_ui_fab_content_description_add)) + .performClick() + composeRule.onNode(hasSetTextAction()).performTextInput(senderName) + // API 30에서 IME가 등록 버튼을 가린다. 입력을 끝낸 뒤 표시된 버튼을 누른다. + closeSoftKeyboard() + composeRule + .onNode( + hasText(context.getString(ReceiverR.string.receiver_sender_registration_submit)) and hasClickAction(), + ).assertIsDisplayed() + .performClick() + + val senderCard = hasText(senderName) and hasClickAction() and !hasSetTextAction() + composeRule.waitUntilAtLeastOneExists(senderCard, timeoutMillis = 5_000) + composeRule.onNode(senderCard).assertIsDisplayed().performClick() + + composeRule + .onNodeWithText(context.getString(ReceiverR.string.receiver_sender_detail_status_unavailable)) + .assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(ReceiverR.string.receiver_sender_detail_request_verification)) + .assertIsDisplayed() + } } @RunWith(AndroidJUnit4::class) @@ -253,144 +273,6 @@ class ReceiverRuntimeCompletionAndroidTest { private val context get() = ApplicationProvider.getApplicationContext() - @Test - fun receiverHome_allFailureThenRetryPartialSuccess_keepsAvailableSectionsAndReportsBothStages() { - // 완료 시점을 테스트가 쥐어야 세 조회의 경합 순서를 만들 수 있다. - val afterNoteHomeResults = ArrayDeque>>() - val senderMessageHomeResults = ArrayDeque>>() - val repository = - FakeReceiverRepository.strict().apply { - onGetReceivedAfterNotes = { afterNoteHomeResults.removeFirst().await() } - onLoadSenderMessage = { senderMessageHomeResults.removeFirst().await() } - } - val mindRecordHomeResults = ArrayDeque>>() - val mindRecordRepository = - FakeMindRecordReceiverRepository(onGetAll = { mindRecordHomeResults.removeFirst().await() }) - val timeLetterHomeResults = ArrayDeque>>() - val timeLetterRepository = - FakeReceiverTimeLetterRepository.strict().apply { - onGetReceivedTimeLetters = { - timeLetterHomeResults.removeFirst().await().getOrThrow() - } - } - - fun homeCallCounts(): List = - listOf( - repository.getReceivedAfterNotesCalls, - mindRecordRepository.getAllCalls, - timeLetterRepository.getReceivedTimeLettersCalls, - repository.loadSenderMessageCalls, - ) - - val allFailureAttempt = - enqueueHomeAttempt( - afterNoteHomeResults, - mindRecordHomeResults, - timeLetterHomeResults, - senderMessageHomeResults, - ) - val partialAttempt = - enqueueHomeAttempt( - afterNoteHomeResults, - mindRecordHomeResults, - timeLetterHomeResults, - senderMessageHomeResults, - ) - val reporter = FakeErrorReporter() - val viewModel = - ReceiverHomeViewModel( - receiverRepository = repository, - mindRecordReceiverRepository = mindRecordRepository, - receiverTimeLetterRepository = timeLetterRepository, - errorReporter = reporter, - ) - - composeRule.setContent { - val uiState = viewModel.uiState.collectAsStateWithLifecycle().value - AfternoteTheme { - ReceiverHomeScreen( - uiState = uiState, - onEvent = viewModel::onEvent, - actions = noopActions, - ) - } - } - - composeRule.waitUntil(timeoutMillis = 5_000) { homeCallCounts().all { it == 1 } } - composeRule.runOnIdle { - assertSame(ReceiverHomeUiState.Loading, viewModel.uiState.value) - } - - val offline = IllegalStateException("offline") - allFailureAttempt.complete( - afterNotes = Result.failure(offline), - mindRecords = Result.failure(offline), - timeLetters = Result.failure(offline), - senderMessage = Result.failure(offline), - ) - composeRule - .onNodeWithText(context.getString(ReceiverR.string.receiver_home_error_message)) - .assertIsDisplayed() - composeRule - .onNodeWithText(context.getString(ReceiverR.string.receiver_home_retry)) - .performClick() - - composeRule.waitUntil(timeoutMillis = 5_000) { homeCallCounts().all { it == 2 } } - composeRule.runOnIdle { - assertSame(ReceiverHomeUiState.Loading, viewModel.uiState.value) - } - - partialAttempt.complete( - afterNotes = - Result.success( - AfterNotesListResult( - items = - listOf( - AfterNoteListItem(1L, "Google Drive", AfternoteType.GALLERY_AND_FILES, null), - AfterNoteListItem(2L, "추억 노트", AfternoteType.MEMORIAL, null), - ), - totalCount = 2, - ), - ), - mindRecords = Result.failure(IllegalStateException("mind records unavailable")), - timeLetters = Result.success(ReceivedTimeLetterList(timeLetters = emptyList(), totalCount = 8)), - senderMessage = - Result.success( - SenderMessageInfo( - senderName = "이발신", - message = "언제나 응원할게", - createdAt = "2026.08.22", - ), - ), - ) - - composeRule - .onNodeWithText(context.getString(ReceiverR.string.receiver_home_sender_record_title, "이발신")) - .assertIsDisplayed() - composeRule.onNodeWithText("언제나 응원할게").assertIsDisplayed() - composeRule - .onAllNodes( - hasText(context.getString(ReceiverR.string.receiver_home_section_count_unavailable)), - ).apply { - assertCountEquals(2) - this[0].performScrollTo().assertIsDisplayed() - } - composeRule - .onNodeWithText("8개 라이프 이벤트 레터가 있습니다.") - .performScrollTo() - .assertIsDisplayed() - composeRule - .onNodeWithText("2개의 애프터노트가 있습니다.") - .performScrollTo() - .assertIsDisplayed() - - assertEquals(2, reporter.failures.size) - assertEquals("receiver_home_load", reporter.failures[0].second["receiver_stage"]) - assertEquals("receiver_home_partial_load", reporter.failures[1].second["receiver_stage"]) - assertEquals("mind_records", reporter.failures[1].second["receiver_failed_sources"]) - assertEquals(listOf(2, 2, 2, 2), homeCallCounts()) - } - @Test fun emailCodeExpired_resendAndNewCode_verifyExactlyOnce() { val verifyEmailResults = ArrayDeque>() @@ -489,7 +371,7 @@ class ReceiverRuntimeCompletionAndroidTest { val viewModel = DocumentUploadViewModel( uploadRepository, - authRepository, + SubmitDeliveryVerificationUseCase(authRepository), FakeErrorReporter(), ) composeRule.setContent { AfternoteTheme {} } @@ -546,7 +428,7 @@ class ReceiverRuntimeCompletionAndroidTest { val viewModel = DocumentUploadViewModel( uploadRepository, - FakeReceiverAuthRepository.strict(), + SubmitDeliveryVerificationUseCase(FakeReceiverAuthRepository.strict()), FakeErrorReporter(), ) composeRule.setContent { @@ -604,60 +486,9 @@ class ReceiverRuntimeCompletionAndroidTest { assertEquals(3, uploadRepository.uploadCalls.size) } - @Test - fun receiverVerificationActions_removeConsumedStepsAndCompletionReturnsToRecords() { - var actions: ReceiverNavActions? = null - composeRule.setContent { - val appState = rememberAfternoteAppState() - val receiverActions = rememberReceiverNavActions(appState) - SideEffect { actions = receiverActions } - AfternoteTheme { - NavHost( - navController = appState.navController, - startDestination = ReceiverRoute.ReceivedRecordsRoute, - ) { - composable { Text("records") } - composable { Text("sender detail") } - navigation( - startDestination = ReceiverRoute.IdentityVerificationIntroRoute, - ) { - composable { Text("identity intro") } - composable { Text("identity email") } - composable { Text("master key") } - composable { Text("documents") } - composable { Text("complete") } - } - } - } - } - composeRule.onNodeWithText("records").assertIsDisplayed() - - val receiverActions = checkNotNull(actions) - composeRule.runOnIdle { receiverActions.navigateToSenderDetail("sender-7") } - composeRule.onNodeWithText("sender detail").assertIsDisplayed() - composeRule.runOnIdle { receiverActions.navigateToDeliveryVerificationFlow("sender-7") } - composeRule.onNodeWithText("identity intro").assertIsDisplayed() - composeRule.runOnIdle { receiverActions.navigateToIdentityVerificationEmail() } - composeRule.onNodeWithText("identity email").assertIsDisplayed() - composeRule.runOnIdle { receiverActions.proceedToMasterKey() } - composeRule.onNodeWithText("master key").assertIsDisplayed() - composeRule.runOnIdle { receiverActions.popBack() } - composeRule.onNodeWithText("sender detail").assertIsDisplayed() - composeRule.onNodeWithText("identity email").assertDoesNotExist() - - composeRule.runOnIdle { receiverActions.navigateToDeliveryVerificationFlow("sender-7") } - composeRule.runOnIdle { receiverActions.navigateToIdentityVerificationEmail() } - composeRule.runOnIdle { receiverActions.proceedToMasterKey() } - composeRule.runOnIdle { receiverActions.proceedToDocumentUpload() } - composeRule.onNodeWithText("documents").assertIsDisplayed() - composeRule.runOnIdle { receiverActions.proceedToDeliveryVerificationComplete() } - composeRule.onNodeWithText("complete").assertIsDisplayed() - composeRule.runOnIdle { receiverActions.popToReceivedRecords() } - - composeRule.onNodeWithText("records").assertIsDisplayed() - composeRule.onNodeWithText("complete").assertDoesNotExist() - composeRule.onNodeWithText("sender detail").assertDoesNotExist() - } + // 열람 신청 단계 소거·완료 복귀는 Navigation 3 이관(#1698) 뒤 수신자 로컬 스택 안으로 + // 들어갔다. 회귀 기준은 기기 없이 도는 JVM 테스트로 옮겼다 — + // DeliveryVerificationFlowLocalNavActionsTest · ReceiverLocalNavActionsTest. @Test fun homeActions_routeImplementedEntryPointsToExactDestinations() { @@ -731,7 +562,7 @@ class ReceiverRuntimeCompletionAndroidTest { ) val viewModel = ReceivedAfternoteDetailViewModel( - savedStateHandle = SavedStateHandle(mapOf("afternoteId" to 202L)), + route = ReceivedAfternoteRoute.DetailRoute(afternoteId = 202L), receiverRepository = repository, errorReporter = FakeErrorReporter(), ) @@ -781,7 +612,7 @@ class ReceiverRuntimeCompletionAndroidTest { ) val viewModel = ReceivedAfternoteDetailViewModel( - savedStateHandle = SavedStateHandle(mapOf("afternoteId" to 303L)), + route = ReceivedAfternoteRoute.DetailRoute(afternoteId = 303L), receiverRepository = repository, errorReporter = FakeErrorReporter(), ) @@ -812,46 +643,6 @@ class ReceiverRuntimeCompletionAndroidTest { } } -private data class PendingHomeAttempt( - val afterNotes: CompletableDeferred>, - val mindRecords: CompletableDeferred>, - val timeLetters: CompletableDeferred>, - val senderMessage: CompletableDeferred>, -) { - fun complete( - afterNotes: Result, - mindRecords: Result, - timeLetters: Result, - senderMessage: Result, - ) { - this.afterNotes.complete(afterNotes) - this.mindRecords.complete(mindRecords) - this.timeLetters.complete(timeLetters) - this.senderMessage.complete(senderMessage) - } -} - -/** 홈 한 번의 로드가 물리는 세 리포지토리 대기열에 결과 게이트를 한 벌씩 건다. */ -private fun enqueueHomeAttempt( - afterNoteHomeResults: ArrayDeque>>, - mindRecordHomeResults: ArrayDeque>>, - timeLetterHomeResults: ArrayDeque>>, - senderMessageHomeResults: ArrayDeque>>, -): PendingHomeAttempt { - val attempt = - PendingHomeAttempt( - afterNotes = CompletableDeferred(), - mindRecords = CompletableDeferred(), - timeLetters = CompletableDeferred(), - senderMessage = CompletableDeferred(), - ) - afterNoteHomeResults.addLast(attempt.afterNotes) - mindRecordHomeResults.addLast(attempt.mindRecords) - timeLetterHomeResults.addLast(attempt.timeLetters) - senderMessageHomeResults.addLast(attempt.senderMessage) - return attempt -} - /** * ServerRejection 이 나르는 원인 예외 자리. 프로덕션에서는 `ApiException` 이 들어오지만, 도메인 계약이 * 요구하는 것은 `Throwable` 뿐이라 이 테스트들은 core:network 를 끌어오지 않는다. diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt index fc33518a3b..367a3bf8df 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt @@ -1,5 +1,6 @@ package com.afternote.afternote_fe +import android.view.WindowInsets import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed @@ -156,6 +157,56 @@ class AppOnboardingCanaryTest { assertEquals(0, fakeAuth.attemptedEmailLogins.size) } + @Test + fun findIdAction_keepsDesignGapAboveNavigationBarAndKeyboard() { + composeRule + .onNodeWithText(context.getString(OnboardingR.string.onboarding_welcome_start)) + .performClick() + composeRule + .onNodeWithText(context.getString(OnboardingR.string.onboarding_login_find_account)) + .performClick() + + assertFindIdActionBottomGap() + composeRule + .onNodeWithText(context.getString(OnboardingR.string.onboarding_find_account_email_placeholder)) + .performClick() + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.activity.window.decorView.rootWindowInsets + .isVisible(WindowInsets.Type.ime()) + } + composeRule.waitForIdle() + assertFindIdActionBottomGap() + + composeRule.runOnIdle { + composeRule.activity.window.insetsController + ?.hide(WindowInsets.Type.ime()) + } + composeRule.waitUntil(timeoutMillis = 5_000) { + !composeRule.activity.window.decorView.rootWindowInsets + .isVisible(WindowInsets.Type.ime()) + } + composeRule.waitForIdle() + assertFindIdActionBottomGap() + } + + private fun assertFindIdActionBottomGap() { + val action = composeRule.onNodeWithText(context.getString(OnboardingR.string.onboarding_find_account_next)) + action.assertIsDisplayed() + // IME visibility는 애니메이션 시작에 바뀐다. 최종 배치까지 기다린 뒤 간격을 확인한다. + var gap = Float.NaN + composeRule.waitUntil(timeoutMillis = 5_000) { + val buttonBottom = action.fetchSemanticsNode().boundsInWindow.bottom + gap = + composeRule.runOnIdle { + val decor = composeRule.activity.window.decorView + val bottomInset = decor.rootWindowInsets.getInsets(WindowInsets.Type.navigationBars() or WindowInsets.Type.ime()).bottom + (decor.height - bottomInset - buttonBottom) / context.resources.displayMetrics.density + } + kotlin.math.abs(gap - 49f) <= 1f + } + assertEquals("CTA gap above system UI", 49f, gap, 1f) + } + private fun openLoginAndEnterCredentials() { composeRule .onNodeWithText(context.getString(OnboardingR.string.onboarding_welcome_start)) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/DailyQuestionResumeFailureAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/DailyQuestionResumeFailureAndroidTest.kt index c39be79572..461b4eb92c 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/DailyQuestionResumeFailureAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/DailyQuestionResumeFailureAndroidTest.kt @@ -24,8 +24,8 @@ import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionReposito import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestionWriteScreen +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel -import com.afternote.feature.mindrecord.presentation.viewmodel.MindRecordDraftLoader import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -66,7 +66,7 @@ class DailyQuestionResumeFailureAndroidTest { savedStateHandle = SavedStateHandle(emptyMap()), repository = repository, photoUploadRepository = FakePhotoUploadRepository.strict(), - draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository), // #964 텔레메트리가 필수 인자로 들어왔다. 이 테스트는 기록 내용을 보지 않으므로 // 받아만 두는 fake 를 넘긴다. errorReporter = FakeErrorReporter(), @@ -74,7 +74,12 @@ class DailyQuestionResumeFailureAndroidTest { composeRule.setContent { AfternoteTheme { - DailyQuestionWriteScreen(viewModel = viewModel, onSubmitSuccess = {}) + DailyQuestionWriteScreen( + viewModel = viewModel, + onSubmitSuccess = {}, + onBackClick = {}, + onDraftListClick = {}, + ) } } @@ -120,7 +125,7 @@ class DailyQuestionResumeFailureAndroidTest { savedStateHandle = SavedStateHandle(emptyMap()), repository = repository, photoUploadRepository = FakePhotoUploadRepository.strict(), - draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository), // #964 텔레메트리가 필수 인자로 들어왔다. 이 테스트는 기록 내용을 보지 않으므로 // 받아만 두는 fake 를 넘긴다. errorReporter = FakeErrorReporter(), @@ -128,7 +133,12 @@ class DailyQuestionResumeFailureAndroidTest { composeRule.setContent { AfternoteTheme { - DailyQuestionWriteScreen(viewModel = viewModel, onSubmitSuccess = {}) + DailyQuestionWriteScreen( + viewModel = viewModel, + onSubmitSuccess = {}, + onBackClick = {}, + onDraftListClick = {}, + ) } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/DeliveryConditionTouchTargetAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/DeliveryConditionTouchTargetAndroidTest.kt new file mode 100644 index 0000000000..616aa4d1ff --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/DeliveryConditionTouchTargetAndroidTest.kt @@ -0,0 +1,703 @@ +package com.afternote.afternote_fe + +import android.content.ComponentCallbacks +import android.content.Context +import android.content.res.Configuration +import android.hardware.display.DisplayManager +import android.os.SystemClock +import android.provider.Settings +import android.util.DisplayMetrics +import android.util.Log +import android.view.Display +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.click +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.waitUntilAtLeastOneExists +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.auth.AuthRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.model.Session +import com.afternote.core.model.delivery.ReceiverDeliveryConditions +import com.afternote.core.model.user.ReceiverDetail +import com.afternote.core.ui.testing.EnabledClickTarget +import com.afternote.core.ui.testing.MinimumTouchTargetSize +import com.afternote.core.ui.testing.scanEnabledClickTargets +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExternalResource +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.junit.runners.model.Statement +import java.io.FileInputStream +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.math.abs +import com.afternote.core.ui.R as CoreUiR +import com.afternote.feature.setting.presentation.R as SettingR + +/** + * 실제 설정 화면의 마지막 인사말 편집 행이 48dp 터치 계약을 지키는지 잰다 (#2035). + * + * 화면을 따로 세우지 않는다. 실제 [MainActivity] 가 실제 앱을 띄우고, 계측이 이미 주입해 둔 + * fake 로 홈에서 설정, 사후 전달 조건까지 공개 UI 로만 걸어 들어간다. 판정 기준은 + * `scanEnabledClickTargets()` 가 읽는 `touchBoundsInRoot` 다. Foundation 이 hit-test 에 실제로 + * 적용한 최소 영역 확장과 ancestor clipping 이 둘 다 반영된 값이라, 보이는 행 높이가 아니라 + * 손가락이 닿는 영역을 잰다. + * + * 뷰포트는 기기 기본값에 맡기지 않는다. [ViewportOverrideRule] 이 Activity 기동 전에 + * `wm size`, `wm density`, `font_scale` 을 덮어 360x800dp 기본 글자와 320x568dp 두 배 글자를 + * 고정하고, 테스트가 끝나면 원래 override 를 그대로 되돌린다. + */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalTestApi::class) +class DeliveryConditionTouchTargetAndroidTest { + @Inject + lateinit var authRepository: AuthRepository + + @Inject + lateinit var userRepository: UserRepository + + private val fakeUser get() = userRepository as FakeUserRepository + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val viewportRule = ViewportOverrideRule() + + @get:Rule(order = 2) + val signedInRule = SignedInBeforeLaunchRule(hiltRule) { prepareSignedInSession() } + + @get:Rule(order = 3) + val composeRule = createAndroidComposeRule() + + @get:Rule(order = 4) + val failureArtifactRule = + FailureArtifactRule { + composeRule.onRoot().captureToImage().asAndroidBitmap() + } + + private val context get() = ApplicationProvider.getApplicationContext() + + @Test + @ViewportOverride(widthDp = 360, heightDp = 800, fontScale = 1.0f) + fun regularViewport_lastGreetingRowKeepsMinimumTouchAreaAndAcceptsTouchOutsideVisualRow() { + openDeliveryCondition() + assertRenderedViewport() + + composeRule.onNodeWithText(lastGreetingLabel).assertIsDisplayed() + val target = lastGreetingTarget() + logMeasurement("regular-viewport", target) + assertMinimumTouchArea(target) + assertTouchBoundsInsideViewport(target) + + tapOutsideVisualRow("regular-viewport", target) + assertRecipientEditOpenedForSelectedReceiver() + } + + @Test + @ViewportOverride(widthDp = 320, heightDp = 568, fontScale = 2.0f) + fun smallViewportWithDoubleFontScale_scrolledLastGreetingRowKeepsMinimumTouchAreaInsideViewport() { + openDeliveryCondition() + assertRenderedViewport() + + val beforeScrollOffset = verticalScrollOffset() + val beforeScroll = lastGreetingTarget() + logMeasurement("small-viewport-before-scroll", beforeScroll, "scrollOffset=$beforeScrollOffset") + + // 스크롤 전에는 행이 뷰포트 아래에 있어 ancestor clip 이 hit 영역을 통째로 잘라낸다. + // 잘린 rect 는 좌표가 0,0 으로 접히므로 top 값을 스크롤 뒤와 비교할 수 없다. 대신 + // 잘려서 누를 곳이 없다는 상태 자체를 단언한다. + assertTrue( + "작은 화면 두 배 글자에서 마지막 인사말 행이 스크롤 전부터 보입니다. ${describe(beforeScroll)}", + beforeScroll.isClippedAway(), + ) + composeRule.onNodeWithText(lastGreetingLabel).assertIsNotDisplayed() + + // 마지막 안내 문구까지 내려 행을 뷰포트 안쪽에 둔다. 행 자체로 스크롤하면 행 아래 끝이 + // 뷰포트 하단에 딱 붙어, 아래로 확장된 터치 영역이 스크롤 컨테이너 clip 에 잘린다. + deliveryScrollContainer() + .performScrollToNode(hasText(context.getString(SettingR.string.setting_after_delivery_notice_safety))) + + val afterScrollOffset = verticalScrollOffset() + val afterScroll = lastGreetingTarget() + logMeasurement("small-viewport-after-scroll", afterScroll, "scrollOffset=$afterScrollOffset") + + assertTrue( + "작은 화면 두 배 글자에서 스크롤이 일어나지 않았습니다. " + + "scrollOffset $beforeScrollOffset -> $afterScrollOffset", + afterScrollOffset > beforeScrollOffset, + ) + composeRule.onNodeWithText(lastGreetingLabel).assertIsDisplayed() + assertMinimumTouchArea(afterScroll) + assertTouchBoundsInsideViewport(afterScroll) + + tapOutsideVisualRow("small-viewport-after-scroll", afterScroll) + assertRecipientEditOpenedForSelectedReceiver() + } + + /** + * Activity 기동 전에 실행된다. 로그인 UI 를 거치지 않고 실제 [AuthRepository] 에 fake 세션을 + * 저장해 실제 앱이 홈부터 시작하게 한다. + */ + private fun prepareSignedInSession() { + val session = Session.DefaultSession(FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN) + runBlocking { + authRepository.saveSession(session.accessToken, session.refreshToken) + } + // 전달 조건 조회는 이 시나리오의 관심사가 아니다. 빈 목록 성공으로 닫아 화면 상단에 + // 오류 문구가 끼어들지 않게 한다. + fakeUser.onGetReceiverDeliveryConditions = { receiverId -> + ReceiverDeliveryConditions(receiverId = receiverId, conditions = emptyList()) + } + // 수신자별로 다른 이름을 돌려준다. 편집 화면에 뜬 이름만으로 어느 수신자가 실려 갔는지 + // 갈리므로, 내부 라우트를 들여다보지 않고 콜백이 실제로 탄 경로를 판정할 수 있다. + fakeUser.onGetReceiverDetail = { receiverId -> receiverDetailFor(receiverId) } + } + + private fun receiverDetailFor(receiverId: Long): ReceiverDetail = + ReceiverDetail( + receiverId = receiverId, + name = if (receiverId == SELECTED_RECEIVER_ID) SELECTED_RECEIVER_NAME else OTHER_RECEIVER_NAME, + relation = "가족", + phone = "01012345678", + email = "kim@afternote.local", + dailyQuestionCount = 0, + timeLetterCount = 0, + afterNoteCount = 0, + message = null, + ) + + /** 실제 앱 UI 만으로 홈에서 사후 전달 조건 화면까지 들어간다. */ + private fun openDeliveryCondition() { + val settingLabel = context.getString(CoreUiR.string.core_ui_home_top_bar_setting) + composeRule.waitUntilAtLeastOneExists(hasContentDescription(settingLabel), TIMEOUT_MILLIS) + composeRule.onNodeWithContentDescription(settingLabel).performClick() + + composeRule.waitUntilAtLeastOneExists( + hasText(context.getString(SettingR.string.setting_account_profile_edit)), + TIMEOUT_MILLIS, + ) + val deliveryLabel = context.getString(SettingR.string.setting_recipient_after_delivery) + composeRule.onNodeWithText(deliveryLabel).performScrollTo().performClick() + + composeRule.waitUntilAtLeastOneExists(hasText(SELECTED_RECEIVER_NAME), TIMEOUT_MILLIS) + // 계측 fake 의 수신자는 SELECTED_RECEIVER_ID 하나뿐이다. 체크박스가 하나라는 것을 먼저 + // 못박아 두면, 아래에서 고른 첫 번째가 곧 그 수신자라는 전제가 드러난다. + composeRule + .onAllNodes(checkboxMatcher) + .assertCountEquals(1) + .onFirst() + .performClick() + composeRule + .onNodeWithText(context.getString(CoreUiR.string.core_ui_receiver_select_confirm)) + .performClick() + + composeRule.waitUntilAtLeastOneExists(hasText(lastGreetingLabel), TIMEOUT_MILLIS) + // 전환 애니메이션이 끝난 배치에서 재도록 한 번 더 idle 을 기다린다. + composeRule.waitForIdle() + } + + private val lastGreetingLabel: String + get() = context.getString(SettingR.string.setting_last_greeting_edit_section_title) + + private fun lastGreetingTarget(): EnabledClickTarget { + val targets = composeRule.scanEnabledClickTargets() + return targets.singleOrNull { it.name.contains(lastGreetingLabel) } + ?: throw AssertionError( + "마지막 인사말 편집 행을 하나로 특정하지 못했습니다. " + + "수집된 클릭 타깃: ${targets.joinToString { it.name.ifBlank { "(이름 없음)" } }}", + ) + } + + /** 실제 렌더 시점의 density, fontScale, 표시 크기가 이 테스트가 요구한 뷰포트인지 본다. */ + private fun assertRenderedViewport() { + val spec = viewportRule.appliedSpec() + val metrics = realDisplayMetrics() + assertEquals("표시 가로(px)", spec.widthDp * VIEWPORT_PX_PER_DP, metrics.widthPixels) + assertEquals("표시 세로(px)", spec.heightDp * VIEWPORT_PX_PER_DP, metrics.heightPixels) + assertEquals("표시 densityDpi", VIEWPORT_DENSITY_DPI, metrics.densityDpi) + + val root = composeRule.onRoot().fetchSemanticsNode() + val density = root.layoutInfo.density + assertEquals("컴포지션 density", VIEWPORT_PX_PER_DP.toFloat(), density.density, DENSITY_TOLERANCE) + assertEquals("컴포지션 fontScale", spec.fontScale, density.fontScale, DENSITY_TOLERANCE) + + val rootWidthDp = with(density) { root.size.width.toDp() } + val rootHeightDp = with(density) { root.size.height.toDp() } + logLine( + "viewport spec=${spec.widthDp}x${spec.heightDp}dp fontScale=${spec.fontScale} " + + "display=${metrics.widthPixels}x${metrics.heightPixels}px@${metrics.densityDpi}dpi " + + "root=${rootWidthDp.value}x${rootHeightDp.value}dp", + ) + assertEquals("컴포즈 루트 가로(dp)", spec.widthDp.toFloat(), rootWidthDp.value, DP_TOLERANCE) + assertTrue( + "컴포즈 루트 세로(${rootHeightDp.value}dp)가 뷰포트(${spec.heightDp}dp)를 넘습니다", + rootHeightDp.value <= spec.heightDp + DP_TOLERANCE, + ) + } + + private fun assertMinimumTouchArea(target: EnabledClickTarget) { + assertTrue( + "마지막 인사말 편집 행의 실제 터치 영역이 $MinimumTouchTargetSize 미만입니다. ${describe(target)}", + !target.isSmallerThan(MinimumTouchTargetSize), + ) + } + + /** ancestor clipping 이 반영된 터치 영역이 화면 밖으로 새지 않는지 본다. */ + private fun assertTouchBoundsInsideViewport(target: EnabledClickTarget) { + val root = composeRule.onRoot().fetchSemanticsNode() + val density = root.layoutInfo.density + val rootWidth = with(density) { root.size.width.toDp() } + val rootHeight = with(density) { root.size.height.toDp() } + val bounds = target.touchBounds + assertTrue( + "터치 영역이 뷰포트(${rootWidth.value}x${rootHeight.value}dp)를 벗어납니다. ${describe(target)}", + bounds.left >= (-DP_TOLERANCE).dp && + bounds.top >= (-DP_TOLERANCE).dp && + bounds.right <= rootWidth + DP_TOLERANCE.dp && + bounds.bottom <= rootHeight + DP_TOLERANCE.dp, + ) + } + + /** + * 보이는 행 밖, 확장된 터치 영역 안의 좌표를 실제 터치로 누른다. + * + * 시맨틱 `performClick()` 은 노드의 액션을 직접 호출해 hit-test 를 건너뛴다. 여기서 재려는 + * 것이 바로 그 hit-test 라서, 루트 좌표로 MotionEvent 를 넣어 Foundation 이 실제로 이 행을 + * 집는지 본다. + */ + private fun tapOutsideVisualRow( + label: String, + target: EnabledClickTarget, + ) { + val gapAbove = target.layoutBounds.top - target.touchBounds.top + val gapBelow = target.touchBounds.bottom - target.layoutBounds.bottom + val tapY = + when { + gapAbove >= MIN_EXTERNAL_GAP -> { + target.touchBounds.top + gapAbove / 2f + } + + gapBelow >= MIN_EXTERNAL_GAP -> { + target.layoutBounds.bottom + gapBelow / 2f + } + + else -> { + throw AssertionError( + "보이는 행 밖이면서 터치 영역 안인 좌표가 없습니다. ${describe(target)}", + ) + } + } + val tapX = target.touchBounds.left + (target.touchBounds.right - target.touchBounds.left) / 2f + + assertTrue( + "탭 좌표 $tapY 가 보이는 행 안입니다. ${describe(target)}", + tapY < target.layoutBounds.top || tapY > target.layoutBounds.bottom, + ) + assertTrue( + "탭 좌표 $tapY 가 터치 영역 밖입니다. ${describe(target)}", + tapY > target.touchBounds.top && tapY < target.touchBounds.bottom, + ) + + val root = composeRule.onRoot().fetchSemanticsNode() + val density = root.layoutInfo.density + // touchBoundsInRoot 는 루트 기준 px 다. performTouchInput 의 좌표계는 대상 노드의 + // 좌상단 기준이라, 루트 노드를 잡고 그 원점을 빼 두 좌표계를 맞춘다. + val rootOrigin = root.boundsInRoot.topLeft + val tapInRoot = with(density) { Offset(tapX.toPx(), tapY.toPx()) } + val tapInNode = tapInRoot - rootOrigin + logLine( + "$label tap=(${tapX.value}dp, ${tapY.value}dp) rootPx=(${tapInRoot.x}, ${tapInRoot.y}) " + + "rootOriginPx=(${rootOrigin.x}, ${rootOrigin.y})", + ) + + composeRule.onRoot().performTouchInput { click(tapInNode) } + } + + /** 터치가 실제 마지막 인사말 콜백을 태웠는지, 열린 화면과 그 화면이 실은 수신자로 본다. */ + private fun assertRecipientEditOpenedForSelectedReceiver() { + composeRule.waitUntilAtLeastOneExists(hasText(RECIPIENT_EDIT_TITLE), TIMEOUT_MILLIS) + // 편집 화면 본문은 LazyColumn 이라 화면 밖 항목은 아직 합성되지 않는다. + composeRule + .onAllNodes(hasScrollAction()) + .onFirst() + .performScrollToNode(hasText(SELECTED_RECEIVER_NAME)) + composeRule.onNodeWithText(SELECTED_RECEIVER_NAME).assertIsDisplayed() + } + + /** 마지막 인사말 행을 담은 스크롤 컨테이너. 전 화면이 아직 트리에 남아 있어도 헷갈리지 않는다. */ + private fun deliveryScrollContainer(): SemanticsNodeInteraction = + composeRule.onNode(hasScrollAction() and hasAnyDescendant(hasText(lastGreetingLabel))) + + private fun verticalScrollOffset(): Float { + val range = + deliveryScrollContainer() + .fetchSemanticsNode() + .config + .getOrNull(SemanticsProperties.VerticalScrollAxisRange) + ?: throw AssertionError("사후 전달 조건 화면에서 세로 스크롤 상태를 읽지 못했습니다") + return range.value() + } + + private fun logMeasurement( + label: String, + target: EnabledClickTarget, + extra: String = "", + ) { + logLine("$label ${describe(target)}${if (extra.isBlank()) "" else " $extra"}") + } + + private fun describe(target: EnabledClickTarget): String = + "name=\"${target.name}\" " + + "layout=${target.layoutWidth.value}x${target.layoutHeight.value}dp" + + "@(${target.layoutBounds.left.value}, ${target.layoutBounds.top.value}) " + + "touch=${target.width.value}x${target.height.value}dp" + + "@(${target.touchBounds.left.value}, ${target.touchBounds.top.value})" + + private fun logLine(message: String) { + Log.i(TAG, message) + println("$TAG $message") + } + + private companion object { + const val TAG = "DeliveryConditionTouch" + const val TIMEOUT_MILLIS = 10_000L + const val FAKE_ACCESS_TOKEN = "touch-target-access" + const val FAKE_REFRESH_TOKEN = "touch-target-refresh" + const val SELECTED_RECEIVER_ID = 7L + const val SELECTED_RECEIVER_NAME = "김수신" + const val OTHER_RECEIVER_NAME = "다른수신자" + const val RECIPIENT_EDIT_TITLE = "수신자 수정" + const val DENSITY_TOLERANCE = 0.01f + const val DP_TOLERANCE = 1f + val MIN_EXTERNAL_GAP = 2.dp + val checkboxMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox) + } +} + +/** [ViewportOverrideRule] 이 Activity 기동 전에 고정할 화면 크기와 글자 배율. */ +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +annotation class ViewportOverride( + val widthDp: Int, + val heightDp: Int, + val fontScale: Float, +) + +/** + * Hilt 주입과 기동 전 준비를 Activity 실행보다 먼저 끝낸다. + * + * `createAndroidComposeRule` 보다 바깥 order 로 걸어야 의미가 있다. 안쪽에 두면 Activity 가 이미 + * 뜬 뒤라 진입 화면이 온보딩으로 정해진다. + */ +class SignedInBeforeLaunchRule( + private val hiltRule: HiltAndroidRule, + private val prepare: () -> Unit, +) : ExternalResource() { + override fun before() { + hiltRule.inject() + prepare() + } +} + +/** + * [ViewportOverride] 가 붙은 테스트에만 화면 크기, 밀도, 글자 배율을 덮어씌운다. + * + * 기기 기본값에 맡기면 같은 테스트가 기기마다 다른 폭에서 재므로 48dp 판정이 흔들린다. + * `wm size` 와 `wm density` 는 px 와 dpi 를 받으므로, dp 는 [VIEWPORT_PX_PER_DP] 로 환산한다. + * 기기를 만지기 전에 직전 상태를 먼저 읽어 두고, 적용이 도중에 실패해도 그 상태로 되돌린다. + * 복원이 확인되지 않으면 테스트를 실패시킨다. 뒤이어 도는 CI 테스트가 바뀐 뷰포트에서 조용히 + * 돌면, 그쪽 실패의 원인이 여기라는 것을 아무도 모른다. + */ +class ViewportOverrideRule : TestRule { + private var currentSpec: ViewportOverride? = null + + fun appliedSpec(): ViewportOverride = requireNotNull(currentSpec) { "@ViewportOverride 가 붙지 않은 테스트입니다" } + + override fun apply( + base: Statement, + description: Description, + ): Statement { + val spec = description.getAnnotation(ViewportOverride::class.java) ?: return base + return object : Statement() { + override fun evaluate() { + // 기기를 만지기 전에 읽는다. 적용이 중간에 깨져도 되돌릴 기준이 남는다. + val original = capture() + var failure: Throwable? = null + try { + currentSpec = spec + applyViewport(spec) + base.evaluate() + } catch (throwable: Throwable) { + failure = throwable + } finally { + currentSpec = null + } + + val restoreFailure = runCatching { restore(original) }.exceptionOrNull() + // 본 실패가 있으면 그것을 던지고 복원 실패는 suppressed 로 붙인다. 복원만 실패했으면 + // 그 자체로 테스트를 떨어뜨린다. 복원 실패를 통과로 삼키지 않는다. + failure?.let { primary -> + restoreFailure?.let(primary::addSuppressed) + throw primary + } + restoreFailure?.let { throw it } + } + } + } + + /** 기기를 건드리기 전 상태. override 문자열과 그때 실제로 적용돼 있던 값을 함께 남긴다. */ + private fun capture(): ViewportState { + val metrics = realDisplayMetrics() + return ViewportState( + sizeOverride = overrideValue(shell("wm size"), "Override size:"), + densityOverride = overrideValue(shell("wm density"), "Override density:"), + fontScaleSetting = + shell("settings get system font_scale") + .trim() + .takeUnless { it.isBlank() || it == "null" }, + widthPx = metrics.widthPixels, + heightPx = metrics.heightPixels, + densityDpi = metrics.densityDpi, + fontScale = systemFontScale(), + ) + } + + private fun applyViewport(spec: ViewportOverride) { + val widthPx = spec.widthDp * VIEWPORT_PX_PER_DP + val heightPx = spec.heightDp * VIEWPORT_PX_PER_DP + shell("wm size ${widthPx}x$heightPx") + shell("wm density $VIEWPORT_DENSITY_DPI") + shell("settings put system font_scale ${spec.fontScale}") + awaitViewport(widthPx, heightPx, spec.fontScale) + } + + /** + * 직전 상태로 되돌린다. override 가 없었으면 `reset`, `font_scale` 설정이 없었으면 `delete` 다. + * + * 확인은 명령의 성공이 아니라 실제 적용값으로 한다. 안드로이드는 `font_scale` 설정이 없으면 + * 1.0 으로 정규화하므로, 설정이 없었다는 사실이 아니라 그때 실제로 쓰이던 배율이 돌아왔는지를 + * 본다. + */ + private fun restore(original: ViewportState) { + val failures = mutableListOf() + val restoreFont = { + shell( + original.fontScaleSetting + ?.let { "settings put system font_scale $it" } + ?: "settings delete system font_scale", + ) + } + val restoreDensity = { shell(original.densityOverride?.let { "wm density $it" } ?: "wm density reset") } + val restoreSize = { shell(original.sizeOverride?.let { "wm size $it" } ?: "wm size reset") } + // 하나가 실패해도 나머지 복원을 건너뛰지 않는다. + listOf(restoreFont, restoreDensity, restoreSize).forEach { command -> + runCatching { command() }.onFailure { failures += it } + } + + val restored = + awaitCondition { + val metrics = realDisplayMetrics() + metrics.widthPixels == original.widthPx && + metrics.heightPixels == original.heightPx && + metrics.densityDpi == original.densityDpi && + abs(systemFontScale() - original.fontScale) < FONT_SCALE_TOLERANCE + } + if (!restored) { + val metrics = realDisplayMetrics() + failures += + AssertionError( + "뷰포트 복원이 확인되지 않았습니다. " + + "기대=${original.widthPx}x${original.heightPx}px@${original.densityDpi}dpi " + + "fontScale=${original.fontScale}, " + + "현재=${metrics.widthPixels}x${metrics.heightPixels}px@${metrics.densityDpi}dpi " + + "fontScale=${systemFontScale()}", + ) + } + + val primary = failures.firstOrNull() ?: return + failures.drop(1).forEach(primary::addSuppressed) + throw primary + } + + private fun awaitViewport( + widthPx: Int, + heightPx: Int, + fontScale: Float, + ) { + val applied = + awaitCondition { + val metrics = realDisplayMetrics() + metrics.widthPixels == widthPx && + metrics.heightPixels == heightPx && + metrics.densityDpi == VIEWPORT_DENSITY_DPI && + abs(systemFontScale() - fontScale) < FONT_SCALE_TOLERANCE + } + // 앱 프로세스 설정까지 따라오면 기다린다. 다만 여기서 못 따라와도 실패로 보지 않는다. + // Activity 는 기동 시점의 시스템 설정을 다시 받으므로, 실제 렌더 값 판정은 화면이 뜬 뒤 + // `assertRenderedViewport()` 가 한다. + if (applied && + !awaitCondition(SOFT_TIMEOUT_MILLIS) { abs(applicationFontScale() - fontScale) < FONT_SCALE_TOLERANCE } + ) { + Log.w(TAG, "앱 프로세스 fontScale 이 ${applicationFontScale()} 로 남아 있습니다") + } + if (applied) return + + val metrics = realDisplayMetrics() + throw AssertionError( + "요청한 뷰포트가 반영되지 않았습니다. " + + "요청=${widthPx}x${heightPx}px@${VIEWPORT_DENSITY_DPI}dpi fontScale=$fontScale, " + + "현재=${metrics.widthPixels}x${metrics.heightPixels}px@${metrics.densityDpi}dpi " + + "systemFontScale=${systemFontScale()} appFontScale=${applicationFontScale()}", + ) + } + + /** + * 설정 변경 콜백을 기다리며 조건을 다시 본다. + * + * 콜백이 오지 않는 경우에도 [POLL_SLICE_MILLIS] 마다 깨어나 다시 확인하므로, 콜백은 대기를 + * 짧게 끊어 주는 가속 신호일 뿐 판정 근거가 아니다. + */ + private fun awaitCondition( + timeoutMillis: Long = CONFIGURATION_TIMEOUT_MILLIS, + matches: () -> Boolean, + ): Boolean { + if (matches()) return true + val context = ApplicationProvider.getApplicationContext() + val signals = LinkedBlockingQueue() + val callbacks = + object : ComponentCallbacks { + override fun onConfigurationChanged(newConfig: Configuration) { + signals.offer(Unit) + } + + override fun onLowMemory() = Unit + } + context.registerComponentCallbacks(callbacks) + try { + val deadline = SystemClock.uptimeMillis() + timeoutMillis + while (!matches()) { + val remaining = deadline - SystemClock.uptimeMillis() + if (remaining <= 0L) return false + signals.poll(minOf(remaining, POLL_SLICE_MILLIS), TimeUnit.MILLISECONDS) + } + return true + } finally { + context.unregisterComponentCallbacks(callbacks) + } + } + + private fun systemFontScale(): Float { + val resolver = ApplicationProvider.getApplicationContext().contentResolver + return Settings.System.getFloat(resolver, Settings.System.FONT_SCALE, 1f) + } + + private fun applicationFontScale(): Float = + ApplicationProvider + .getApplicationContext() + .resources.configuration.fontScale + + private fun overrideValue( + output: String, + prefix: String, + ): String? = + output + .lineSequence() + .map(String::trim) + .firstOrNull { it.startsWith(prefix) } + ?.removePrefix(prefix) + ?.trim() + + private fun shell(command: String): String { + val descriptor = + InstrumentationRegistry + .getInstrumentation() + .uiAutomation + .executeShellCommand(command) + return descriptor.use { + FileInputStream(it.fileDescriptor).use { input -> input.readBytes().toString(Charsets.UTF_8) } + } + } + + /** 기기를 만지기 전 뷰포트. 복원 확인은 override 문자열이 아니라 여기 적힌 적용값으로 한다. */ + private class ViewportState( + val sizeOverride: String?, + val densityOverride: String?, + val fontScaleSetting: String?, + val widthPx: Int, + val heightPx: Int, + val densityDpi: Int, + val fontScale: Float, + ) + + private companion object { + const val TAG = "ViewportOverrideRule" + const val CONFIGURATION_TIMEOUT_MILLIS = 20_000L + + /** 실패로 보지 않는 보조 대기. 앱 프로세스 설정 반영과 복원 확인에 쓴다. */ + const val SOFT_TIMEOUT_MILLIS = 5_000L + const val POLL_SLICE_MILLIS = 100L + const val FONT_SCALE_TOLERANCE = 0.01f + } +} + +/** + * dp 를 px 로 바꾸는 배수. `wm density` 가 받는 dpi 는 이 값에 [DisplayMetrics.DENSITY_DEFAULT] 를 + * 곱한 [VIEWPORT_DENSITY_DPI] 다. + */ +private const val VIEWPORT_PX_PER_DP = 2 + +private const val VIEWPORT_DENSITY_DPI = DisplayMetrics.DENSITY_DEFAULT * VIEWPORT_PX_PER_DP + +/** 시스템 데코를 포함한 실제 표시 크기. `wm size` 가 정한 값과 같은 좌표계다. */ +private fun realDisplayMetrics(): DisplayMetrics { + val context = ApplicationProvider.getApplicationContext() + val display = + (context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager) + .getDisplay(Display.DEFAULT_DISPLAY) + val metrics = DisplayMetrics() + @Suppress("DEPRECATION") + display.getRealMetrics(metrics) + return metrics +} + +/** ancestor clip 이 hit 영역을 통째로 잘라, 어디를 눌러도 이 타깃에 닿지 않는 상태. */ +private fun EnabledClickTarget.isClippedAway(): Boolean = width <= 0.dp || height <= 0.dp diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt index a46129d5aa..85880a8a48 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt @@ -161,11 +161,14 @@ class HomeWeeklyCountAndroidTest { dailyQuestionAmount: Int = 0, diaryAmount: Int = 0, weeklyFailure: Throwable? = null, - ): HomeTabViewModel = - HomeTabViewModel( + ): HomeTabViewModel { + // 같은 페이크가 두 좁은 계약을 다 구현한다 — UserRepository 가 둘을 상속한다 (#1742). + val userRepository = appTestUserRepository() + return HomeTabViewModel( getHomeSummary = GetHomeSummaryUseCase( - userRepository = appTestUserRepository(), + myProfileRepository = userRepository, + userReceiverRepository = userRepository, dailyQuestionRepository = FakeDailyQuestionRepository(), getWeeklyRecordCount = GetWeeklyRecordCountUseCase( @@ -175,6 +178,7 @@ class HomeWeeklyCountAndroidTest { userProfileCacheRepository = FakeUserProfileCacheRepository(), errorReporter = SilentErrorReporter, ) + } private companion object { const val TIMEOUT = 5_000L @@ -231,6 +235,8 @@ private class RecordingHomeTabActions( override fun onMemoriesSectionClick() = Unit + override fun onMemoriesRecordDetailClick(recordId: Long) = Unit + override fun onSettingClick() = Unit override fun onRetryLoad() = Unit diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/NotificationColdStartAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/NotificationColdStartAndroidTest.kt index 7ca07e555b..648401a442 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/NotificationColdStartAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/NotificationColdStartAndroidTest.kt @@ -8,6 +8,7 @@ import androidx.test.platform.app.InstrumentationRegistry import com.afternote.afternote_fe.notification.NotificationEntryRequest import com.afternote.afternote_fe.notification.NotificationEntrySource import com.afternote.afternote_fe.test.backgroundActivityStartOptions +import com.afternote.core.common.notification.NotificationDestination import com.afternote.core.common.notification.NotificationPendingIntentFactory import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -50,6 +51,8 @@ class NotificationColdStartAndroidTest { val context = ApplicationProvider.getApplicationContext() val source = NotificationEntrySource.FCM val occurrenceId = "cold-start-1" + // 폴백(HOME)이 아닌 목적지를 실어 cold start 경로에서 extra 가 살아남는지 본다. + val destination = NotificationDestination.TIME_LETTER activityMonitor = instrumentation.addMonitor(MainActivity::class.java.name, null, false) val pendingIntent = @@ -57,6 +60,7 @@ class NotificationColdStartAndroidTest { context = context, source = source.contractValue, occurrenceId = occurrenceId, + destination = destination, ) assertNotNull(pendingIntent) @@ -78,7 +82,11 @@ class NotificationColdStartAndroidTest { } assertEquals( - NotificationEntryRequest(source = source, occurrenceId = occurrenceId), + NotificationEntryRequest( + source = source, + occurrenceId = occurrenceId, + destination = destination, + ), pendingEntry, ) } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/NotificationNavigationAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/NotificationNavigationAndroidTest.kt index 16db85ca1d..b24735a475 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/NotificationNavigationAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/NotificationNavigationAndroidTest.kt @@ -21,6 +21,7 @@ import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry import androidx.test.runner.lifecycle.Stage import com.afternote.afternote_fe.notification.NotificationEntrySource import com.afternote.afternote_fe.test.backgroundActivityStartOptions +import com.afternote.core.common.notification.NotificationDestination import com.afternote.core.common.notification.NotificationPendingIntentFactory import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.domain.testing.FakeAuthRepository @@ -108,9 +109,9 @@ class NotificationNavigationAndroidTest { composeRule .onNodeWithText(context.getString(OnboardingR.string.onboarding_welcome_start)) .assertIsDisplayed() - deliverWarmNotification(NotificationEntrySource.FCM, "logged-out-1") + deliverWarmNotification(NotificationEntrySource.FCM, "logged-out-1", NotificationDestination.HOME) - awaitNotificationIntent(NotificationEntrySource.FCM, "logged-out-1") + awaitNotificationIntent(NotificationEntrySource.FCM, "logged-out-1", NotificationDestination.HOME) assertSame(activityUnderTest, resumedMainActivity()) composeRule .onNodeWithText(context.getString(OnboardingR.string.onboarding_welcome_start)) @@ -125,17 +126,18 @@ class NotificationNavigationAndroidTest { } @Test - fun warmNotificationWhileLoggedIn_preservesExistingNavHost() { + fun warmNotificationWhileLoggedIn_isReceivedWithoutRecreatingActivity() { fakeAuth.loggedIn = true val greeting = context.getString(HomeR.string.home_tab_greeting, "테스트 사용자") composeRule.waitUntilAtLeastOneExists(hasText(greeting), timeoutMillis = 10_000) openTimeLetterTab() - deliverWarmNotification(NotificationEntrySource.FCM, "logged-in-1") + deliverWarmNotification(NotificationEntrySource.FCM, "logged-in-1", NotificationDestination.HOME) - awaitNotificationIntent(NotificationEntrySource.FCM, "logged-in-1") + awaitNotificationIntent(NotificationEntrySource.FCM, "logged-in-1", NotificationDestination.HOME) assertSame(activityUnderTest, resumedMainActivity()) - composeRule.onNode(selectedBottomBarMatcher(CoreUiR.string.core_ui_nav_item_timeletter)).assertIsSelected() + // 목적지 이동 결선은 #1795 가 Nav3 루트에 붙인다 — 지금은 보던 탭에 머문다. + awaitSelectedBottomBarTab(CoreUiR.string.core_ui_nav_item_timeletter) } @Test @@ -144,25 +146,41 @@ class NotificationNavigationAndroidTest { val greeting = context.getString(HomeR.string.home_tab_greeting, "테스트 사용자") composeRule.waitUntilAtLeastOneExists(hasText(greeting), timeoutMillis = 10_000) - openTimeLetterTab() composeRule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) - deliverWarmNotification(NotificationEntrySource.DAILY, "background-1") + deliverWarmNotification( + NotificationEntrySource.DAILY, + "background-1", + NotificationDestination.AFTERNOTE, + ) composeRule.waitUntil(timeoutMillis = 10_000) { activityUnderTest.lifecycle.currentState == Lifecycle.State.RESUMED } - awaitNotificationIntent(NotificationEntrySource.DAILY, "background-1") + awaitNotificationIntent( + NotificationEntrySource.DAILY, + "background-1", + NotificationDestination.AFTERNOTE, + ) assertSame(activityUnderTest, resumedMainActivity()) - composeRule.onNode(selectedBottomBarMatcher(CoreUiR.string.core_ui_nav_item_timeletter)).assertIsSelected() + // 목적지 이동 결선은 #1795 가 붙인다 — 지금은 보던 홈 탭에 머문다. + awaitSelectedBottomBarTab(CoreUiR.string.core_ui_nav_item_home) } @Test fun sameRequestCode_differentSourceAndOccurrenceKeepLatestExtras() { - deliverWarmNotification(NotificationEntrySource.FCM, "shared-token") - awaitNotificationIntent(NotificationEntrySource.FCM, "shared-token") + deliverWarmNotification(NotificationEntrySource.FCM, "shared-token", NotificationDestination.HOME) + awaitNotificationIntent(NotificationEntrySource.FCM, "shared-token", NotificationDestination.HOME) - deliverWarmNotification(NotificationEntrySource.DAILY, "shared-token") - awaitNotificationIntent(NotificationEntrySource.DAILY, "shared-token") + deliverWarmNotification( + NotificationEntrySource.DAILY, + "shared-token", + NotificationDestination.TIME_LETTER, + ) + awaitNotificationIntent( + NotificationEntrySource.DAILY, + "shared-token", + NotificationDestination.TIME_LETTER, + ) } private fun openTimeLetterTab() { @@ -176,12 +194,14 @@ class NotificationNavigationAndroidTest { private fun deliverWarmNotification( source: NotificationEntrySource, occurrenceId: String, + destination: NotificationDestination, ) { val pendingIntent = NotificationPendingIntentFactory.create( context = context, source = source.contractValue, occurrenceId = occurrenceId, + destination = destination, ) assertNotNull(pendingIntent) @@ -191,6 +211,7 @@ class NotificationNavigationAndroidTest { private fun awaitNotificationIntent( source: NotificationEntrySource, occurrenceId: String, + destination: NotificationDestination, ) { composeRule.waitUntil(timeoutMillis = 10_000) { activityUnderTest.intent.getStringExtra( @@ -198,10 +219,22 @@ class NotificationNavigationAndroidTest { ) == source.contractValue && activityUnderTest.intent.getStringExtra( NotificationPendingIntentFactory.EXTRA_NOTIFICATION_OCCURRENCE_TOKEN, - ) == occurrenceId + ) == occurrenceId && + activityUnderTest.intent.getStringExtra( + NotificationPendingIntentFactory.EXTRA_NOTIFICATION_DESTINATION, + ) == destination.contractValue } } + /** 목적지 이동은 Intent 도착 뒤 한 프레임 더 걸린다 — 선택 탭이 바뀔 때까지 기다린다. */ + private fun awaitSelectedBottomBarTab(labelResource: Int) { + composeRule.waitUntilAtLeastOneExists( + selectedBottomBarMatcher(labelResource), + timeoutMillis = 10_000, + ) + composeRule.onNode(selectedBottomBarMatcher(labelResource)).assertIsSelected() + } + private fun resumedMainActivity(): MainActivity { var resumedActivity: MainActivity? = null InstrumentationRegistry.getInstrumentation().runOnMainSync { diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ProcessingMethodListAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ProcessingMethodListAndroidTest.kt index 365d97274a..261b916851 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ProcessingMethodListAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ProcessingMethodListAndroidTest.kt @@ -22,7 +22,7 @@ import org.junit.runner.RunWith /** * 처리 방법 입력 칸 여닫힘 실기 검증 (이슈 #777). * - * [com.afternote.feature.afternote.presentation.author.editor.processing.ProcessingMethodListState] + * [com.afternote.feature.afternote.presentation.editor.processing.ProcessingMethodListState] * 단위 테스트는 «닫는 경로가 있다» 까지만 말한다. 그 경로에 AddItemTextField 의 신호가 실제로 * 닿는지는 상호작용을 거쳐야 드러나므로 여기서 본다 — 종전에는 그 신호가 * `ProcessingMethodSection.onTextFieldVisibilityChanged = {}` 로 나가 사라졌고, 항목만 추가되고 diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt index 932ac6dcce..8b52e0098f 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt @@ -27,6 +27,7 @@ import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel +import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute import com.afternote.feature.receiver.domain.model.ReceivedAccountCredentials import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail import com.afternote.feature.receiver.domain.model.ReceivedExportBundle @@ -178,7 +179,7 @@ class ReceiverAdvancedAndroidTest { detailResults.addLast(Result.success(receivedSocialDetail())) val viewModel = ReceivedAfternoteDetailViewModel( - savedStateHandle = SavedStateHandle(mapOf("afternoteId" to 91L)), + route = ReceivedAfternoteRoute.DetailRoute(afternoteId = 91L), receiverRepository = repository, errorReporter = FakeErrorReporter(), ) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt index 478c23ba25..07e2cd1e76 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt @@ -13,12 +13,12 @@ import androidx.compose.ui.test.hasProgressBarRangeInfo import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo -import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.ComposeNavigator import androidx.navigation.testing.TestNavHostController import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -31,8 +31,6 @@ import com.afternote.core.domain.testing.FakeUserRepository import com.afternote.core.model.user.Receiver import com.afternote.core.ui.Route import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.CompletableDeferred @@ -53,11 +51,11 @@ import com.afternote.feature.afternote.presentation.R as AfternoteR * 에뮬레이터 없이 도는 `ReceiverSelectScreenTest` 가 이미 고정한다. 여기서 덮는 것은 그 단위 * 테스트가 닿지 못하는 구간 — **실제 NavHost 를 지나 소비 route 로 돌아오는 값**이다. * - * 경로는 세 모듈에 걸쳐 있다. + * 경로는 두 모듈에 걸쳐 있다. * 1. `core:ui` 공용 화면이 선택된 수신자 id 목록을 완료 콜백으로 내보내고, - * 2. app 모듈의 `popBackWithSelectedReceivers(List)` 가 그 목록을 - * **직전 back stack entry**(에디터)의 `SavedStateHandle` 에 - * `SELECTED_RECEIVER_IDS_KEY` (`LongArray`)로 쓰고 pop 하며, + * 2. 에디터 흐름의 `popBackWithSelectedReceivers(List)` 가 그 목록을 + * 흐름이 공유하는 에디터 ViewModel 의 `SavedStateHandle` 에 + * `SELECTED_RECEIVER_IDS_KEY` (`LongArray`)로 남기고 흐름 스택을 pop 하며, * 3. `feature:afternote` 에디터가 복귀 시 그 id 목록을 이름·관계로 해석해 폼에 넣는다. * * 어느 한 마디만 어긋나도 «다른 수신자가 지정되는» 회귀가 되는데, 각 모듈의 단위 테스트는 @@ -67,17 +65,21 @@ import com.afternote.feature.afternote.presentation.R as AfternoteR * 되며 공용 컴포넌트 소비를 그만두고, 타임레터·마음의 기록은 아직 각 기능 전용 구현이다). * 소비처가 늘어나면 각 모듈이 자기 route 테스트를 여기 옆에 더한다. * - * **배리어 규약.** `navController` 의 destination 은 `popBackStack()` 순간 뒤집히지만 화면 조립은 - * 그 뒤에 따라온다. 그래서 라우트 대기만으로는 "선택 화면이 사라졌다" 를 보장하지 못한다. + * **배리어 규약.** 선택 화면은 에디터 흐름의 로컬 스택에서 pop 되고 루트 destination 은 [Route.Afternote] 로 + * 고정이라, 라우트로는 "선택 화면이 사라졌다" 를 알 수 없고 pop 뒤 화면 조립도 한 박자 늦게 따라온다. * 화면 전환 판정은 항상 [waitForEditorAddButtons] 로 한다 — "추가" 설명이 붙은 버튼은 에디터에만 - * 있고 선택 화면엔 없다. 부정 단언("들어오지 않았다") 앞에는 추가로 [awaitReceiverLoad] 로 - * 저장소를 한 바퀴 돌린다. 선택 결과 반영도 같은 저장소 홉(`resolveSelectedReceiver`)을 지나므로, + * 있고 선택 화면엔 없다. 부정 단언("들어오지 않았다") 앞에는 추가로 [StagedReceiverSource.gateNextLoad] 로 + * 다음 조회를 붙잡았다가 [releaseGatedLoad] 로 풀어 저장소를 한 바퀴 돌린다. 선택 결과 반영도 같은 저장소 홉(`resolveSelectedReceiver`)을 지나므로, * 그 홉에 IO 나 지연이 생겨도 단언 뒤로 착지할 수 없다. * * **진입 경로 주의.** [Route.Afternote] 의 시작 화면은 지문 로그인이고, 계측에 주입되는 * `FakeUserProfileCacheRepository` 가 패스키 미등록(false)을 내야 홈으로 자동 통과한다. - * 그 fake 기본값이 바뀌면 세 테스트가 모두 홈 대기에서 멈추므로, 실패 메시지에 현재 destination 을 - * 실어 원인이 드러나게 했다([waitForRoute]). + * 그 fake 기본값이 바뀌면 세 테스트가 모두 홈 대기에서 멈추므로, 실패 메시지에 현재 "추가" 노드 + * 개수를 실어 어느 화면에 멈췄는지 드러나게 했다([waitForAddButtons]). + * + * **화면 판정이 라우트가 아닌 이유.** Navigation 3 이관(#1698) 뒤 애프터노트 화면들은 피처 로컬 + * 스택에 있고 루트 destination 은 [Route.Afternote] 하나로 고정된다. 그래서 이 테스트의 화면 + * 판정은 전부 UI 앵커다 — 원래 배리어 규약이 권하던 방식과 같다. */ @HiltAndroidTest @RunWith(AndroidJUnit4::class) @@ -124,13 +126,14 @@ class ReceiverSelectionResultAndroidTest { } /** - * 완료가 돌려주는 것은 **고른 수신자 id 목록**이다. + * 완료가 돌려주는 것은 **그 시점에 체크된 수신자 id 전체**다 (#1426). * - * 목록 순서(index)나 표시된 수신자 목록 전체가 아니라 고른 id 목록이 건너가는지를, - * 고르지 않은 수신자가 폼에 들어오지 않는 것으로 판정한다. + * 화면은 폼에 이미 담겨 있던 수신자를 체크된 채로 열고(완료는 처음부터 활성), 완료는 체크된 id + * 목록을 폼 수신자 전체로 되돌린다. 폼에 있던 사람은 남고, 고른 사람은 더해지고, 고르지 않은 + * 사람은 따라 들어오지 않는 것으로 판정한다. */ @Test - fun afternoteEditorNavHost_receiverSelectConfirmAddsOnlyTheChosenReceiverToEditorForm() { + fun afternoteEditorNavHost_receiverSelectOpensWithFormReceiverCheckedAndConfirmAddsOnlyTheChosen() { receiverSource.receivers = listOf(KIM) openNewSocialEditor() waitForEditorReceiver(KIM.name) @@ -139,11 +142,12 @@ class ReceiverSelectionResultAndroidTest { receiverSource.receivers = listOf(KIM, PARK, LEE) openReceiverSelect() composeRule.onNodeWithText(copy(CoreUiR.string.core_ui_receiver_select_title)).assertIsDisplayed() - composeRule.onNodeWithText(confirmText).assertIsNotEnabled() + waitForSelectRow(PARK.name) + // 폼에 있던 김혜성이 체크된 채로 열리므로 완료는 고르기 전부터 활성이다. + composeRule.onNodeWithText(confirmText).assertIsEnabled() composeRule.onNodeWithText(PARK.name).performClick() composeRule.onNodeWithText(confirmText).assertIsEnabled().performClick() - waitForRoute("에디터") // 선택 화면에도 세 사람이 모두 떠 있으므로, 에디터가 조립된 뒤에야 이름으로 판정할 수 있다. waitForEditorAddButtons() waitForEditorReceiver(PARK.name) @@ -152,6 +156,31 @@ class ReceiverSelectionResultAndroidTest { composeRule.onNodeWithText(LEE.name).assertDoesNotExist() } + /** + * 화면에서 체크를 푼 수신자는 폼에서도 빠진다 — 완료는 «추가» 가 아니라 «교체» 다 (#1426). + * + * 체크가 0명이면 완료가 잠기므로, 이 화면으로 폼을 전부 비우는 경로는 없다는 것도 함께 고정한다. + * 교체는 폼 상태 한 번의 쓰기라, 새로 고른 사람이 에디터에 뜬 시점엔 푼 사람도 이미 빠져 있다. + */ + @Test + fun afternoteEditorNavHost_receiverSelectUncheckingFormReceiverRemovesItFromEditorOnConfirm() { + receiverSource.receivers = listOf(KIM) + openNewSocialEditor() + waitForEditorReceiver(KIM.name) + + receiverSource.receivers = listOf(KIM, PARK) + openReceiverSelect() + waitForSelectRow(PARK.name) + composeRule.onNodeWithText(KIM.name).performClick() + composeRule.onNodeWithText(confirmText).assertIsNotEnabled() + composeRule.onNodeWithText(PARK.name).performClick() + composeRule.onNodeWithText(confirmText).assertIsEnabled().performClick() + + waitForEditorAddButtons() + waitForEditorReceiver(PARK.name) + composeRule.onNodeWithText(KIM.name).assertDoesNotExist() + } + /** 취소(뒤로가기)는 화면 안에서 고른 값을 버리고 소비 화면의 기존 지정을 그대로 둔다. */ @Test fun afternoteEditorNavHost_receiverSelectBackKeepsEditorReceiversUnchanged() { @@ -170,7 +199,6 @@ class ReceiverSelectionResultAndroidTest { .onNodeWithContentDescription(copy(CoreUiR.string.core_ui_content_description_back)) .performClick() - waitForRoute("에디터") waitForEditorAddButtons() releaseGatedLoad(gate) @@ -210,7 +238,6 @@ class ReceiverSelectionResultAndroidTest { composeRule .onNodeWithContentDescription(copy(CoreUiR.string.core_ui_content_description_back)) .performClick() - waitForRoute("에디터") waitForEditorAddButtons() receiverSource.failing = true @@ -222,7 +249,7 @@ class ReceiverSelectionResultAndroidTest { receiverSource.failing = false receiverSource.receivers = listOf(KIM, PARK) - composeRule.onNodeWithText(copy(AfternoteR.string.afternote_select_receiver_retry)).performClick() + composeRule.onNodeWithText(copy(CoreUiR.string.core_ui_receiver_list_retry)).performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { composeRule.onAllNodesWithText(KIM.name).fetchSemanticsNodes().isNotEmpty() @@ -239,16 +266,25 @@ class ReceiverSelectionResultAndroidTest { get() = copy(AfternoteR.string.afternote_select_receiver_empty) private val loadFailedText: String - get() = copy(AfternoteR.string.afternote_select_receiver_load_failed) + get() = copy(CoreUiR.string.core_ui_receiver_list_load_failed) + /** + * 애프터노트 홈의 연필 FAB 으로 계정 카테고리 에디터를 연다. + * + * 이관(#1698) 전에는 루트 `NavController` 로 `EditorFlowRoute` 에 곧장 뛰어들었지만, 그 + * 라우트는 이제 애프터노트 로컬 스택 안에 있어 루트에서 보이지 않는다. 대신 실제 진입점을 + * 지난다 — 홈의 FAB 은 선택된 카테고리가 없을 때 계정(`SOCIAL_NETWORK`)으로 열고, 이 테스트는 + * 카테고리를 고르지 않으므로 그 기본값이 곧 목적지다. + * + * 홈에서 "추가" 설명을 가진 노드는 이 FAB 하나뿐이고 에디터에는 [EDITOR_ADD_BUTTON_COUNT] 개라, + * 개수만으로 두 화면을 구분할 수 있다. + */ private fun openNewSocialEditor() { - waitForRoute("애프터노트 홈(지문 로그인 자동 통과)") - composeRule.runOnIdle { - navController.navigate( - AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.SOCIAL_NETWORK), - ) - } - waitForRoute("에디터") + waitForAddButtons(HOME_FAB_COUNT, "애프터노트 홈(지문 로그인 자동 통과)") + composeRule + .onAllNodesWithContentDescription(addDescription) + .onFirst() + .performClick() waitForEditorAddButtons() } @@ -257,7 +293,7 @@ class ReceiverSelectionResultAndroidTest { * * 계정 카테고리 폼에는 같은 "추가" 설명을 가진 버튼이 수신자 지정·처리 방법 두 곳에 있고, * 시안 순서상 수신자 지정이 앞이라 첫 번째가 대상이다(개수 단언으로 그 전제를 고정한다). - * 순서가 바뀌면 라우트 대기가 현재 destination 을 실은 메시지로 실패한다. + * 순서가 바뀌면 선택 화면 제목을 기다리다 시간 초과로 실패한다. */ private fun openReceiverSelect() { waitForEditorAddButtons() @@ -265,19 +301,47 @@ class ReceiverSelectionResultAndroidTest { assertCountEquals(EDITOR_ADD_BUTTON_COUNT) get(0).performScrollTo().performClick() } - waitForRoute("수신자 선택 화면") + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule + .onAllNodesWithText(copy(CoreUiR.string.core_ui_receiver_select_title)) + .fetchSemanticsNodes() + .isNotEmpty() + } } /** * 에디터가 조립됐음을 확정한다. "추가" 설명이 붙은 버튼은 에디터에만 있고 선택 화면엔 없어, * 수신자 이름과 달리 두 화면을 구분하는 앵커가 된다. */ - private fun waitForEditorAddButtons() { - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - composeRule - .onAllNodesWithContentDescription(addDescription) - .fetchSemanticsNodes() - .size == EDITOR_ADD_BUTTON_COUNT + private fun waitForEditorAddButtons() = waitForAddButtons(EDITOR_ADD_BUTTON_COUNT, "에디터") + + /** + * "추가" 설명을 가진 노드가 [count] 개가 될 때까지 기다린다. + * + * 실패 시 현재 개수를 남긴다 — 「10초 뒤 조건 미충족」만으론 어느 화면에 멈췄는지 안 드러난다. + * 이관 뒤 루트 destination 은 그래프 host 하나로 고정이라 더는 단서가 되지 못한다. + */ + private fun waitForAddButtons( + count: Int, + description: String, + ) { + try { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule + .onAllNodesWithContentDescription(addDescription) + .fetchSemanticsNodes() + .size == count + } + } catch (e: ComposeTimeoutException) { + val actual = + composeRule + .onAllNodesWithContentDescription(addDescription) + .fetchSemanticsNodes() + .size + throw AssertionError( + "$description 으로 이동하지 못했습니다. \"추가\" 노드 기대=$count 실제=$actual", + e, + ) } } @@ -304,17 +368,10 @@ class ReceiverSelectionResultAndroidTest { } } - /** 실패 시 현재 destination 을 함께 남긴다 — 「10초 뒤 조건 미충족」만으론 원인이 안 드러난다. */ - private inline fun waitForRoute(description: String) { - try { - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - navController.currentDestination?.hasRoute() == true - } - } catch (e: ComposeTimeoutException) { - throw AssertionError( - "$description 으로 이동하지 못했습니다. 현재 destination=${navController.currentDestination?.route}", - e, - ) + /** 선택 화면의 목록 응답이 도착해 행이 그려진 것을 확정한다 — 그 뒤에야 체크·완료 상태를 단언할 수 있다. */ + private fun waitForSelectRow(name: String) { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodesWithText(name).fetchSemanticsNodes().isNotEmpty() } } @@ -381,8 +438,11 @@ class ReceiverSelectionResultAndroidTest { /** 계정 카테고리 에디터의 "추가" 버튼 — 수신자 지정 + 처리 방법 리스트. */ const val EDITOR_ADD_BUTTON_COUNT = 2 - val KIM = Receiver(receiverId = 7L, name = "김수신", relation = "가족", authCode = "fake-auth-7") - val PARK = Receiver(receiverId = 11L, name = "박친구", relation = "친구", authCode = "fake-auth-11") - val LEE = Receiver(receiverId = 23L, name = "이지인", relation = "지인", authCode = "fake-auth-23") + /** 애프터노트 홈의 "추가" 노드 — 연필 FAB 하나뿐이다. */ + const val HOME_FAB_COUNT = 1 + + val KIM = Receiver(receiverId = 7L, name = "김수신", relation = "가족") + val PARK = Receiver(receiverId = 11L, name = "박친구", relation = "친구") + val LEE = Receiver(receiverId = 23L, name = "이지인", relation = "지인") } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSessionIsolationAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSessionIsolationAndroidTest.kt index a61caa5db8..c5e6f36a26 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSessionIsolationAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSessionIsolationAndroidTest.kt @@ -8,39 +8,23 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule -import com.afternote.afternote_fe.test.FakeErrorReporter -import com.afternote.core.data.repoimpl.UserRepositoryImpl -import com.afternote.core.domain.testing.FakeAuthRepository -import com.afternote.core.network.dto.DeletePushTokenRequestDto -import com.afternote.core.network.dto.PushTokenDto -import com.afternote.core.network.dto.ReceiverDetailDto -import com.afternote.core.network.dto.ReceiverListDto -import com.afternote.core.network.dto.RegisterPushTokenRequestDto -import com.afternote.core.network.dto.SocialAccountLinkRequestDto -import com.afternote.core.network.dto.UserConnectedAccountDto -import com.afternote.core.network.dto.UserCreateReceiverDto -import com.afternote.core.network.dto.UserCreateReceiverRequestDto -import com.afternote.core.network.dto.UserDto -import com.afternote.core.network.dto.UserPatchReceiverDto -import com.afternote.core.network.dto.UserPatchReceiverRequestDto -import com.afternote.core.network.dto.UserPushSettingDto -import com.afternote.core.network.dto.UserUpdateProfileRequestDto -import com.afternote.core.network.dto.UserUpdatePushSettingRequestDto -import com.afternote.core.network.dto.UserUpdateReceiverMessageRequestDto -import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionDto -import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionUpdateRequestDto -import com.afternote.core.network.model.ApiException -import com.afternote.core.network.model.BaseResponse -import com.afternote.core.network.service.UserApiService +import com.afternote.afternote_fe.test.appTestUserRepository +import com.afternote.core.model.user.Receiver import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.timeletter.presentation.screen.sender.RecipientListScreen import com.afternote.feature.timeletter.presentation.viewmodel.RecipientListViewModel -import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.util.ArrayDeque +/** + * 수신인 목록 화면은 저장소가 내는 목록을 그대로 따른다 — 세션이 바뀌어 목록이 비면 이전 계정의 + * 식별자가 화면에 남지 않고 화면도 살아 있어야 한다. + * + * «로그아웃·새 세션의 401 을 예외로 흘리지 않고 빈 목록으로 낮춘다» 는 판정은 저장소 구현의 계약이라 + * `:core:data` 단위 테스트(`UserRepositoryImplTest` 의 receiverListFlow 계열)가 고정한다. 여기서는 + * 그 결과를 fake 로 흉내 내 화면 쪽 경계만 본다. + */ @RunWith(AndroidJUnit4::class) class ReceiverSessionIsolationAndroidTest { @get:Rule(order = 0) @@ -53,32 +37,8 @@ class ReceiverSessionIsolationAndroidTest { } @Test - fun recipientList_newSession401_keepsScreenAliveWithoutPreviousReceiverIdentity() { - val authRepository = - FakeAuthRepository.strict(loggedIn = true).apply { - onIsLoggedIn = { loggedInState } - } - val userApi = SessionReceiverUserApi() - userApi.enqueueReceivers( - Result.success( - BaseResponse( - status = 200, - code = 200, - data = listOf(receiverDto(PREVIOUS_ACCOUNT_RECEIVER)), - ), - ), - ) - userApi.enqueueReceivers( - Result.failure( - ApiException( - status = 401, - code = 401, - serverMessage = "인증되지 않은 요청입니다.", - fallbackMessage = "인증되지 않은 요청입니다.", - ), - ), - ) - val repository = UserRepositoryImpl(userApi, authRepository, FakeErrorReporter()) + fun recipientList_receiverListEmptiesOnSessionChange_keepsScreenAliveWithoutPreviousReceiverIdentity() { + val repository = appTestUserRepository(receivers = listOf(previousAccountReceiver())) val viewModel = RecipientListViewModel(repository) composeRule.setContent { @@ -92,112 +52,23 @@ class ReceiverSessionIsolationAndroidTest { } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - userApi.receiverCalls == 1 && viewModel.recipients.value.any { it.name == PREVIOUS_ACCOUNT_RECEIVER } + viewModel.recipients.value.any { it.name == PREVIOUS_ACCOUNT_RECEIVER } } composeRule.onNodeWithText(PREVIOUS_ACCOUNT_RECEIVER).assertIsDisplayed() - composeRule.runOnIdle { authRepository.loggedIn = false } + composeRule.runOnIdle { repository.receiverState.value = emptyList() } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.recipients.value.isEmpty() } - assertEquals(1, userApi.receiverCalls) - composeRule.onNodeWithText(PREVIOUS_ACCOUNT_RECEIVER).assertDoesNotExist() - - composeRule.runOnIdle { authRepository.loggedIn = true } - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - userApi.receiverCalls == 2 - } - // 401을 예외로 방출했다면 이 시점 전에 테스트 프로세스가 죽는다. 실제 화면이 남고 이전 계정의 - // 식별자가 다시 나타나지 않는지를 함께 확인해 «크래시 방지 + 세션 격리» 경계를 고정한다. composeRule.onNodeWithText("수신인 목록").assertIsDisplayed() composeRule.onNodeWithText(PREVIOUS_ACCOUNT_RECEIVER).assertDoesNotExist() - assertEquals(2, userApi.receiverCalls) } private companion object { const val PREVIOUS_ACCOUNT_RECEIVER = "이전 계정 수신인" const val TIMEOUT_MILLIS = 5_000L - } -} - -private fun receiverDto(name: String) = - ReceiverListDto( - receiverId = 91L, - name = name, - relation = "가족", - authCode = "previous-account-auth", - ) -private class SessionReceiverUserApi : UserApiService { - private val receiverResults = ArrayDeque>>>() - - @Volatile - var receiverCalls: Int = 0 - private set - - fun enqueueReceivers(result: Result>>) { - synchronized(this) { receiverResults.addLast(result) } - } - - override suspend fun registerPushToken(request: RegisterPushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") - - override suspend fun deletePushToken(request: DeletePushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") - - override suspend fun getReceivers(): BaseResponse> { - val result = - synchronized(this) { - receiverCalls += 1 - receiverResults.pollFirst() ?: error("getReceivers result was not prepared") - } - return result.getOrThrow() + fun previousAccountReceiver() = Receiver(91L, PREVIOUS_ACCOUNT_RECEIVER, "가족") } - - override suspend fun createReceiver(request: UserCreateReceiverRequestDto): BaseResponse = - receiverSessionUnexpected("createReceiver") - - override suspend fun getReceiverDetail(receiverId: Long): BaseResponse = - receiverSessionUnexpected("getReceiverDetail") - - override suspend fun updateReceiver( - receiverId: Long, - request: UserPatchReceiverRequestDto, - ): BaseResponse = receiverSessionUnexpected("updateReceiver") - - override suspend fun updateReceiverMessage( - receiverId: Long, - request: UserUpdateReceiverMessageRequestDto, - ): BaseResponse = receiverSessionUnexpected("updateReceiverMessage") - - override suspend fun getMyProfile(): BaseResponse = receiverSessionUnexpected("getMyProfile") - - override suspend fun updateMyProfile(request: UserUpdateProfileRequestDto): BaseResponse = - receiverSessionUnexpected("updateMyProfile") - - override suspend fun deleteAccount(): BaseResponse = receiverSessionUnexpected("deleteAccount") - - override suspend fun getMyPushSettings(): BaseResponse = receiverSessionUnexpected("getMyPushSettings") - - override suspend fun updateMyPushSettings(request: UserUpdatePushSettingRequestDto): BaseResponse = - receiverSessionUnexpected("updateMyPushSettings") - - override suspend fun getConnectedAccounts(): BaseResponse = receiverSessionUnexpected("getConnectedAccounts") - - override suspend fun linkConnectedAccount( - provider: String, - request: SocialAccountLinkRequestDto, - ): BaseResponse = receiverSessionUnexpected("linkConnectedAccount") - - override suspend fun unlinkConnectedAccount(provider: String): BaseResponse = - receiverSessionUnexpected("unlinkConnectedAccount") - - override suspend fun getReceiverDeliveryConditions(receiverId: Long): BaseResponse = - receiverSessionUnexpected("getReceiverDeliveryConditions") - - override suspend fun updateReceiverDeliveryConditions( - receiverId: Long, - request: ReceiverDeliveryConditionUpdateRequestDto, - ): BaseResponse = receiverSessionUnexpected("updateReceiverDeliveryConditions") } - -private fun receiverSessionUnexpected(method: String): T = error("$method must not be called by this test") diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/SettingImplementedCoverageAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/SettingImplementedCoverageAndroidTest.kt index e1234b7ca5..98eeb6aaee 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/SettingImplementedCoverageAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/SettingImplementedCoverageAndroidTest.kt @@ -12,22 +12,25 @@ import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.hasScrollAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollToNode -import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.ComposeNavigator import androidx.navigation.testing.TestNavHostController -import androidx.navigation.toRoute import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.navigation.AppNavigation import com.afternote.afternote_fe.navigation.AppState import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.testing.FakeUserRepository import com.afternote.core.ui.Route +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.setting.presentation.navigation.SettingRoute +import com.afternote.feature.setting.presentation.navigation.SettingExternalActions +import com.afternote.feature.setting.presentation.navigation.SettingNavHost import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Assert.assertEquals @@ -35,7 +38,16 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import javax.inject.Inject +/** + * 설정 로컬 스택을 실제 화면·Hilt ViewModel 로 띄워 본다 (#1695). + * + * 스택 «모양» 은 `SettingLocalNavActionsTest` 가 보므로, 여기서 재는 것은 둘이다 — 화면 클릭이 route + * 인자를 그 화면의 ViewModel 까지 정확히 나르는가(Nav3 entry 의 키는 바깥에서 읽을 수 없어, 인자를 + * 받은 ViewModel 이 저장소에 요청한 `receiverId` 로 판정한다), 그리고 host 가 시작점 인자로 첫 화면을 + * 고르는가(entry 결선과 시작점은 JVM 테스트가 못 보는 자리라 이 클래스가 유일한 자동 가드다). + */ @HiltAndroidTest @RunWith(AndroidJUnit4::class) class SettingImplementedCoverageAndroidTest { @@ -51,44 +63,119 @@ class SettingImplementedCoverageAndroidTest { composeRule.onRoot().captureToImage().asAndroidBitmap() } - private lateinit var navController: TestNavHostController + @Inject + lateinit var userRepository: UserRepository + + private val fakeUserRepository: FakeUserRepository + get() = userRepository as FakeUserRepository + + /** 로컬 스택 바닥에서 셸로 나간 횟수 — 설정 안에서 도는 동안은 0 이어야 한다. */ + private var exits = 0 @Before fun setUp() { hiltRule.inject() + } + + /** 루트가 `Route.Setting` 인자로 host 를 띄우는 자리를 그대로 옮긴다 — 시작점은 테스트마다 다르다. */ + private fun launchHost(startWithRecipientRegistration: Boolean = false) { composeRule.activityRule.scenario.onActivity { activity -> - navController = + activity.setContent { + AfternoteTheme { + SettingNavHost( + navigationCallbacks = FeatureNavigationCallbacks { exits += 1 }, + externalActions = + object : SettingExternalActions { + override fun onLogoutSuccess() = Unit + + override fun onWithdrawSuccess() = Unit + }, + startWithRecipientRegistration = startWithRecipientRegistration, + ) + } + } + } + } + + @Test + fun settingNavHost_startWithRecipientRegistrationOpensRegisterFirstAndBackExitsHost() { + launchHost(startWithRecipientRegistration = true) + + // 홈 칩 진입 — 설정 홈을 거치지 않고 등록 화면이 스택 바닥이다. + composeRule.onNodeWithText("수신자 등록").assertIsDisplayed() + composeRule.onAllNodes(hasText("프로필 수정")).assertCountEquals(0) + + composeRule.onNodeWithContentDescription("뒤로가기").performClick() + + // 바닥의 back 은 로컬 스택을 비우지 않고 셸로 나간다 — 부른 곳(홈)으로 돌아간다 (#506). + assertEquals(1, composeRule.runOnIdle { exits }) + } + + /** + * 루트의 `composable` 이 entry 의 인자를 host 시작점으로 넘기는 한 줄을 실제 루트 + * 그래프로 지난다. [launchHost] 는 host 를 직접 띄우므로 그 결선은 이 케이스만 본다 — 인자를 + * 흘리면(`startWithRecipientRegistration = false` 고정) 설정 홈이 첫 화면이 되어 여기서 빨개진다. + */ + @Test + fun appNavigation_settingRouteArgumentSelectsRegisterAsFirstScreen() { + composeRule.activityRule.scenario.onActivity { activity -> + val navController = TestNavHostController(activity).apply { navigatorProvider.addNavigator(ComposeNavigator()) } activity.setContent { AfternoteTheme { AppNavigation( - startDestination = Route.Setting, + startDestination = Route.Setting(startWithRecipientRegistration = true), appState = AppState(navController), ) } } } + + composeRule.onNodeWithText("수신자 등록").assertIsDisplayed() + composeRule.onAllNodes(hasText("프로필 수정")).assertCountEquals(0) } @Test - fun actualSettingNavHost_receiverSelectionPreservesNormalBackAndExactDeliveryReceiverId() { - waitForRoute() + fun settingNavHost_passwordChangeEntryAndBackKeepSettingsHost() { + launchHost() waitForSettingHomeContent() + + composeRule.onNodeWithText("비밀번호 변경").performScrollTo().performClick() + composeRule.onNodeWithText("현재 비밀번호").assertIsDisplayed() + composeRule.onNodeWithText("새 비밀번호").assertIsDisplayed() + composeRule.onNodeWithContentDescription("뒤로가기").performClick() + + waitForSettingHomeContent() + composeRule.onNodeWithText("프로필 수정").assertIsDisplayed() + assertEquals(0, composeRule.runOnIdle { exits }) + } + + @Test + fun settingNavHost_receiverManageListRowNavigatesToEditWithExactReceiverId() { + launchHost() + waitForSettingHomeContent() + val detailCallsBefore = fakeUserRepository.receiverDetailCalls.size composeRule.onAllNodes(hasText("수신자 목록")).run { assertCountEquals(2) get(1).performScrollTo().performClick() } composeRule.onNodeWithText("김수신").assertIsDisplayed() - composeRule.onAllNodes(checkboxMatcher).run { - assertCountEquals(1) - get(0).performClick() - } - composeRule.onNodeWithText("수신자 선택 완료하기").performClick() + composeRule.onAllNodes(checkboxMatcher).assertCountEquals(0) - waitForRoute() + composeRule.onNodeWithText("김수신").performClick() + + assertEquals(RECEIVER_ID, waitForNewCall(fakeUserRepository.receiverDetailCalls, detailCallsBefore)) + assertEquals(0, exits) + } + + @Test + fun settingNavHost_deliveryConditionReceiverSelectionPreservesExactReceiverId() { + launchHost() waitForSettingHomeContent() + val deliveryCallsBefore = fakeUserRepository.deliveryLoadCalls.size + val detailCallsBefore = fakeUserRepository.receiverDetailCalls.size composeRule .onNodeWithText("사후 전달 조건") .performScrollTo() @@ -100,20 +187,20 @@ class SettingImplementedCoverageAndroidTest { } composeRule.onNodeWithText("수신자 선택 완료하기").performClick() - val deliveryRoute = waitForRoute() - assertEquals(RECEIVER_ID, deliveryRoute.receiverId) + assertEquals(RECEIVER_ID, waitForNewCall(fakeUserRepository.deliveryLoadCalls, deliveryCallsBefore)) composeRule .onNodeWithText("마지막 인사말 수정하기") .assertIsDisplayed() .performClick() - val editRoute = waitForRoute() - assertEquals(RECEIVER_ID, editRoute.receiverId) + assertEquals(RECEIVER_ID, waitForNewCall(fakeUserRepository.receiverDetailCalls, detailCallsBefore)) + assertEquals(0, exits) } @Test - fun actualSettingNavHost_withdrawGuideCancelThenAgreementConfirmPreservesBoundary() { - waitForRoute() + fun settingNavHost_withdrawGuideCancelThenAgreementConfirmPreservesBoundary() { + launchHost() + waitForSettingHomeContent() openWithdrawGuide() composeRule .onNode(hasScrollAction()) @@ -125,7 +212,10 @@ class SettingImplementedCoverageAndroidTest { .onNodeWithText("취소하기") .performClick() - waitForRoute() + // 취소는 설정 홈으로 한 칸 내려온다 — 로컬 스택 바닥을 넘어 셸로 나가지 않는다. + waitForSettingHomeContent() + assertEquals(0, exits) + openWithdrawGuide() composeRule .onNode(hasScrollAction()) @@ -138,21 +228,19 @@ class SettingImplementedCoverageAndroidTest { .onNodeWithText("탈퇴하기") .performClick() - waitForRoute() composeRule .onNodeWithText("안전한 탈퇴 진행을 위해 아래 문장을 입력해 주세요.") .assertIsDisplayed() + assertEquals(0, exits) } private fun openWithdrawGuide() { - waitForSettingHomeContent() composeRule .onNode(hasScrollAction()) .performScrollToNode(hasText("회원 탈퇴")) composeRule .onNodeWithText("회원 탈퇴") .performClick() - waitForRoute() } private fun waitForSettingHomeContent() { @@ -161,14 +249,13 @@ class SettingImplementedCoverageAndroidTest { } } - private inline fun waitForRoute(): T { - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - navController.currentDestination?.hasRoute() == true - } - return composeRule.runOnIdle { - navController.currentBackStackEntry?.toRoute() - ?: error("Current back stack entry is missing") - } + /** [calls] 가 [sizeBefore] 보다 길어질 때까지 기다려 새로 들어온 첫 값을 돌려준다. fake 는 프로세스 싱글턴이라 이전 테스트의 기록이 남아 있다. */ + private fun waitForNewCall( + calls: List, + sizeBefore: Int, + ): Long { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { calls.size > sizeBefore } + return calls[sizeBefore] } private companion object { diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt index 936444c658..90c7f7e36d 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.test.performTouchInput import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.afternote_fe.test.appTestUserRepository @@ -54,20 +55,19 @@ import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepository -import com.afternote.feature.mindrecord.presentation.screen.memoryspace.MemorySpaceScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestionAnswerListScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestionWriteScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryWriteScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DraftListScreen import com.afternote.feature.mindrecord.presentation.screen.sender.WeeklyReportScreen +import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionListUiState import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionListViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DraftListViewModel -import com.afternote.feature.mindrecord.presentation.viewmodel.MemorySpaceUiState -import com.afternote.feature.mindrecord.presentation.viewmodel.MemorySpaceViewModel -import com.afternote.feature.mindrecord.presentation.viewmodel.MindRecordDraftLoader import com.afternote.feature.mindrecord.presentation.viewmodel.SubmitState import com.afternote.feature.mindrecord.presentation.viewmodel.WeeklyReportUiState import com.afternote.feature.mindrecord.presentation.viewmodel.WeeklyReportViewModel @@ -80,6 +80,7 @@ import com.afternote.feature.timeletter.domain.model.TimeLetterList import com.afternote.feature.timeletter.domain.model.TimeLetterStatus import com.afternote.feature.timeletter.domain.testing.FakeFileMetadataRepository import com.afternote.feature.timeletter.domain.testing.FakeTimeLetterRepository +import com.afternote.feature.timeletter.domain.testing.FakeVoiceRecorderRepository import com.afternote.feature.timeletter.domain.usecase.CreateTimeLetterUseCase import com.afternote.feature.timeletter.domain.usecase.ResolveTimeLetterBlocksUseCase import com.afternote.feature.timeletter.presentation.screen.sender.RecipientListScreen @@ -100,6 +101,7 @@ import org.junit.Test import org.junit.runner.RunWith import java.time.LocalDate import java.time.YearMonth +import com.afternote.feature.mindrecord.presentation.R as MindRecordR /** #838/#839에서 기존 production Screen/ViewModel로 표현 가능한 미검증 완료 경계. */ @RunWith(AndroidJUnit4::class) @@ -146,7 +148,7 @@ class TimeLetterMindRecordCompletionAndroidTest { val register = composeRule.onNode(hasText("등록") and hasClickAction()) register.performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT) { - viewModel.uiState.value.error == TimeLetterWriteError.RECIPIENT_REQUIRED + viewModel.uiState.value.error == TimeLetterWriteError.RecipientRequired } composeRule.onNodeWithText("수신자를 선택해주세요.").assertIsDisplayed() @@ -157,7 +159,7 @@ class TimeLetterMindRecordCompletionAndroidTest { } register.performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT) { - viewModel.uiState.value.error == TimeLetterWriteError.SEND_DATE_REQUIRED + viewModel.uiState.value.error == TimeLetterWriteError.SendDateRequired } composeRule.onNodeWithText("발송 날짜를 선택해주세요.").assertIsDisplayed() @@ -364,11 +366,15 @@ class TimeLetterMindRecordCompletionAndroidTest { pending?.await() ?: Result.success(emptyList()) } } - var activeViewModel by mutableStateOf(DailyQuestionListViewModel(emptyRepository, MindRecordChangeTracker())) + var activeViewModel by mutableStateOf(DailyQuestionListViewModel(emptyRepository, MindRecordChangeTracker(), FakeErrorReporter())) composeRule.setContent { AfternoteTheme { - DailyQuestionAnswerListScreen(viewModel = activeViewModel, onItemClick = { _, _ -> }) + DailyQuestionAnswerListScreen( + viewModel = activeViewModel, + onItemClick = { _, _ -> }, + onEditClick = {}, + ) } } @@ -392,7 +398,7 @@ class TimeLetterMindRecordCompletionAndroidTest { FakeDailyQuestionRepository(today = completionToday()).apply { onGetList = { _, _ -> listResults.removeFirst() } } - val retryViewModel = DailyQuestionListViewModel(retryRepository, MindRecordChangeTracker()) + val retryViewModel = DailyQuestionListViewModel(retryRepository, MindRecordChangeTracker(), FakeErrorReporter()) composeRule.runOnIdle { activeViewModel = retryViewModel } composeRule.waitUntil(timeoutMillis = TIMEOUT) { retryViewModel.uiState.value is DailyQuestionListUiState.Error @@ -424,7 +430,7 @@ class TimeLetterMindRecordCompletionAndroidTest { // 목록이 그대로»(#520) 를 잡는 이 테스트가 조용히 침묵한다 (#966 리뷰). val changeTracker = MindRecordChangeTracker() val repository = FakeDailyQuestionRepository(today = completionToday(), changeTracker = changeTracker) - val listViewModel = DailyQuestionListViewModel(repository, changeTracker) + val listViewModel = DailyQuestionListViewModel(repository, changeTracker, FakeErrorReporter()) var writeViewModel by mutableStateOf(null) var submitSuccessCalls = 0 @@ -432,7 +438,11 @@ class TimeLetterMindRecordCompletionAndroidTest { val activeWriteViewModel = writeViewModel AfternoteTheme { if (activeWriteViewModel == null) { - DailyQuestionAnswerListScreen(viewModel = listViewModel, onItemClick = { _, _ -> }) + DailyQuestionAnswerListScreen( + viewModel = listViewModel, + onItemClick = { _, _ -> }, + onEditClick = {}, + ) } else { DailyQuestionWriteScreen( viewModel = activeWriteViewModel, @@ -441,6 +451,8 @@ class TimeLetterMindRecordCompletionAndroidTest { writeViewModel = null listViewModel.refreshOnReturn() }, + onBackClick = {}, + onDraftListClick = {}, ) } } @@ -459,7 +471,7 @@ class TimeLetterMindRecordCompletionAndroidTest { savedStateHandle = SavedStateHandle(emptyMap()), repository = repository, photoUploadRepository = FakePhotoUploadRepository.strict(), - draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository), errorReporter = FakeErrorReporter(), ) } @@ -515,9 +527,8 @@ class TimeLetterMindRecordCompletionAndroidTest { val draftDailyQuestionRepository = FakeDailyQuestionRepository(today = completionToday()) val draftListViewModel = DraftListViewModel( - loader = MindRecordDraftLoader(repository, draftDailyQuestionRepository), - diaryRepository = repository, - dailyQuestionRepository = draftDailyQuestionRepository, + loadDrafts = LoadMindRecordDraftsUseCase(repository, draftDailyQuestionRepository), + deleteDrafts = DeleteMindRecordDraftsUseCase(repository, draftDailyQuestionRepository), errorReporter = FakeErrorReporter(), ) var routedArguments: Pair? = null @@ -546,15 +557,19 @@ class TimeLetterMindRecordCompletionAndroidTest { photoUploadRepository = FakePhotoUploadRepository.strict(), userRepository = appTestUserRepository(), draftLoader = - MindRecordDraftLoader(repository, draftDailyQuestionRepository), + LoadMindRecordDraftsUseCase(repository, draftDailyQuestionRepository), errorReporter = FakeErrorReporter(), ) }, + onBackClick = {}, + onDailyQuestionDraftClick = {}, ) } else { DiaryWriteScreen( viewModel = activeWriteViewModel, onSubmitSuccess = { submitSuccessCalls += 1 }, + onBackClick = {}, + onDraftListClick = {}, ) } } @@ -599,65 +614,6 @@ class TimeLetterMindRecordCompletionAndroidTest { ) } - @Test - fun memorySpace_supportedSuccess_opensAndClosesDetailThenNavigatesBack() { - val memoryDate = LocalDate.now() - val memory = - Diary( - diaryId = 501L, - title = "추억이 된 하루", - content = "이 순간은 나에게 특별한 의미가 있었습니다.", - date = memoryDate.toString(), - createdAt = memoryDate.toString(), - todayMood = TodayMood.HAPPY, - imageUrl = "https://afternote.test/memory.jpg", - isDraft = false, - ) - val diaryRepository = - FakeDiaryRepository( - onGetList = { yearMonth, _ -> - val diaries = - if (yearMonth == YearMonth.from(memoryDate).toString()) listOf(memory) else emptyList() - Result.success( - DiaryList( - diaries = diaries, - monthDiaryCount = diaries.size, - weeklyDominantMood = diaries.firstOrNull()?.todayMood, - ), - ) - }, - ) - val viewModel = MemorySpaceViewModel(diaryRepository, FakeDailyQuestionRepository()) - var backCalls = 0 - - composeRule.setContent { - AfternoteTheme { - MemorySpaceScreen( - viewModel = viewModel, - onBackClick = { backCalls += 1 }, - ) - } - } - - composeRule.onNodeWithText("MEMORY SPACE").assertIsDisplayed() - composeRule.waitUntil(timeoutMillis = TIMEOUT) { - viewModel.uiState.value is MemorySpaceUiState.Success - } - composeRule.onNodeWithContentDescription("추억이 된 하루").performClick() - composeRule - .onNodeWithText("이 순간은 나에게 특별한 의미가 있었습니다.", substring = true) - .assertIsDisplayed() - // 태그는 사용자가 고른 오늘의 기분 이모지다 — 종전 더미의 `#평온` 은 출처가 없었다 (#559). - composeRule.onNodeWithText("#😊").assertIsDisplayed() - composeRule.onNodeWithContentDescription("닫기").performClick() - composeRule - .onNodeWithText("이 순간은 나에게 특별한 의미가 있었습니다.", substring = true) - .assertDoesNotExist() - - composeRule.onNodeWithText("돌아가기").performClick() - composeRule.runOnIdle { assertEquals(1, backCalls) } - } - @Test fun weeklyReport_errorRetryThenEmptyAndComplete_preservesRequestedWeekContract() { val repository = @@ -669,7 +625,13 @@ class TimeLetterMindRecordCompletionAndroidTest { profile = User("주간 사용자", "weekly@afternote.local", null, null), receivers = emptyList(), ) - val viewModel = WeeklyReportViewModel(repository, userRepository, MindRecordChangeTracker()) + val errorReporter = FakeErrorReporter() + val viewModel = + WeeklyReportViewModel( + ObserveWeeklyReportUseCase(repository, userRepository), + MindRecordChangeTracker(), + errorReporter, + ) composeRule.setContent { AfternoteTheme { @@ -680,7 +642,17 @@ class TimeLetterMindRecordCompletionAndroidTest { composeRule.waitUntil(timeoutMillis = TIMEOUT) { viewModel.uiState.value is WeeklyReportUiState.Error } - composeRule.onNodeWithText("weekly offline").assertIsDisplayed() + // 종전에는 여기서 «weekly offline» — 즉 예외 원문 — 이 화면에 뜨는 것을 단언했다. + // 서버 오류 원문은 화면에 내지 않고 계측으로만 보낸다는 규약(#1339)과 정반대라 + // 결함을 고정하고 있었다. 화면은 안내 문자열로, 원문은 계측으로 확인한다 (#1882). + val weeklyFailureCopy = + InstrumentationRegistry + .getInstrumentation() + .targetContext + .getString(MindRecordR.string.mindrecord_error_weekly_report_failed) + composeRule.onNodeWithText(weeklyFailureCopy).assertIsDisplayed() + composeRule.onNodeWithText("weekly offline").assertDoesNotExist() + assertEquals(listOf("weekly_report_load"), errorReporter.mindRecordStages) val firstMonday = LocalDate.parse(repository.requestedDates.single()) repository.results.addLast(Result.success(emptyWeeklyReport())) @@ -735,6 +707,7 @@ class TimeLetterMindRecordCompletionAndroidTest { timeLetterRepository = repository, userRepository = userRepository, fileMetadataRepository = FakeFileMetadataRepository.strict(), + voiceRecorderRepository = FakeVoiceRecorderRepository, savedStateHandle = SavedStateHandle(mapOf("timeLetterId" to null)), ) } @@ -751,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, @@ -778,7 +750,6 @@ private fun completionDetailLetter(): TimeLetter = id = 509L, title = "상세 route 편지", sendAt = "2026-10-09T09:40:00", - deliveredAt = null, status = TimeLetterStatus.SCHEDULED, blocks = listOf( diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/VoiceRecordingAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/VoiceRecordingAndroidTest.kt new file mode 100644 index 0000000000..d0108b40a1 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/VoiceRecordingAndroidTest.kt @@ -0,0 +1,149 @@ +package com.afternote.afternote_fe + +import android.Manifest +import android.content.Context +import android.net.Uri +import android.os.SystemClock +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.afternote.feature.timeletter.data.testing.createVoiceRecorderRepositoryForTesting +import com.afternote.feature.timeletter.domain.model.RecordedAudio +import com.afternote.feature.timeletter.domain.repository.VoiceRecorderRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * `VoiceRecorderRepositoryImpl` 이 실제 `MediaRecorder → filesDir → FileProvider` 경계를 + * 실행하는지 검증한다. 기존 `FakeVoiceRecorderRepository` 는 `start`/`stop` 호출을 항상 + * 실패시키도록 되어 있어, fake 로만 짠 테스트는 이 경계를 한 번도 타지 않는다 (#437 리뷰). + * 구현은 이 모듈 밖에서 internal 이라 `createVoiceRecorderRepositoryForTesting` 로 공개 + * 계약(`VoiceRecorderRepository`)만 얻는다 (#440 리뷰). + */ +@RunWith(AndroidJUnit4::class) +class VoiceRecordingAndroidTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val repository: VoiceRecorderRepository = createVoiceRecorderRepositoryForTesting(context, Dispatchers.IO) + + @Before + fun grantMicrophonePermission() { + InstrumentationRegistry + .getInstrumentation() + .uiAutomation + .grantRuntimePermission(context.packageName, Manifest.permission.RECORD_AUDIO) + } + + @Test + fun startThenStop_returnsPlayableAudioMp4File() = + runBlocking { + val audio = recordFor(RECORDING_DURATION_MILLIS) + val uri = Uri.parse(audio.uriString) + + assertEquals("audio/mp4", audio.mimeType) + assertTrue("파일명이 .m4a 로 끝나야 한다: ${audio.fileName}", audio.fileName.endsWith(".m4a")) + assertTrue( + "기록된 길이가 실제 녹음 시간에 못 미친다: ${audio.durationMillis}", + audio.durationMillis >= RECORDING_DURATION_MILLIS - DURATION_TOLERANCE_MILLIS, + ) + assertEquals("${context.packageName}.timeletter.fileprovider", uri.authority) + + val bytes = context.contentResolver.openInputStream(uri)!!.use { it.readBytes() } + assertTrue("녹음 파일이 비어 있으면 안 된다", bytes.isNotEmpty()) + + repository.deleteRecordedFile(audio.uriString) + } + + @Test + fun discard_afterStop_deletesTheUnretainedFile() = + runBlocking { + val audio = recordFor(RECORDING_DURATION_MILLIS) + val uri = Uri.parse(audio.uriString) + + repository.discard() + + assertFileGone(uri) + } + + @Test + fun retainRecordedFile_survivesDiscardUntilExplicitDelete() = + runBlocking { + val audio = recordFor(RECORDING_DURATION_MILLIS) + val uri = Uri.parse(audio.uriString) + repository.retainRecordedFile() + + // retain 이후에는 discard() 가 이 파일을 건드리지 않아야 한다. + repository.discard() + context.contentResolver.openInputStream(uri)!!.close() + + repository.deleteRecordedFile(audio.uriString) + assertFileGone(uri) + } + + @Test + fun release_deletesRetainedFilesAsynchronously() = + runBlocking { + val audio = recordFor(RECORDING_DURATION_MILLIS) + val uri = Uri.parse(audio.uriString) + repository.retainRecordedFile() + + repository.release() + + val deleted = + awaitCondition(timeoutMillis = RELEASE_TIMEOUT_MILLIS) { + runCatching { context.contentResolver.openInputStream(uri)?.close() }.isFailure + } + assertTrue("release() 이후에도 retain 된 파일이 정리되지 않았다", deleted) + } + + @Test + fun newInstance_sweepsFilesLeftByAPreviousProcess() = + runBlocking { + val audioDirectory = File(context.filesDir, "timeletter_audio").apply { mkdirs() } + val orphan = File(audioDirectory, "orphan-from-previous-process.m4a") + orphan.writeBytes(byteArrayOf(1, 2, 3)) + assertTrue(orphan.exists()) + + // 이전 프로세스가 등록/삭제 없이 죽어 filesDir 에 파일이 남은 상황을 흉내 낸다. + createVoiceRecorderRepositoryForTesting(context, Dispatchers.IO) + + val swept = awaitCondition(timeoutMillis = RELEASE_TIMEOUT_MILLIS) { !orphan.exists() } + assertTrue("새 인스턴스가 생성되면 이전 프로세스가 남긴 고아 파일을 정리해야 한다", swept) + } + + private suspend fun recordFor(durationMillis: Long): RecordedAudio { + repository.start().getOrThrow() + delay(durationMillis) + return repository.stop().getOrThrow() + } + + private fun assertFileGone(uri: Uri) { + val stillReadable = runCatching { context.contentResolver.openInputStream(uri)?.close() }.isSuccess + assertTrue("파일이 삭제됐어야 한다: $uri", !stillReadable) + } + + private fun awaitCondition( + timeoutMillis: Long, + condition: () -> Boolean, + ): Boolean { + val deadline = SystemClock.elapsedRealtime() + timeoutMillis + while (SystemClock.elapsedRealtime() < deadline) { + if (condition()) return true + Thread.sleep(POLL_INTERVAL_MILLIS) + } + return condition() + } + + private companion object { + const val RECORDING_DURATION_MILLIS = 1_200L + const val DURATION_TOLERANCE_MILLIS = 300L + const val RELEASE_TIMEOUT_MILLIS = 5_000L + const val POLL_INTERVAL_MILLIS = 50L + } +} diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/VoiceRecordingLifecycleAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/VoiceRecordingLifecycleAndroidTest.kt new file mode 100644 index 0000000000..ba86dc8745 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/VoiceRecordingLifecycleAndroidTest.kt @@ -0,0 +1,180 @@ +package com.afternote.afternote_fe + +import android.Manifest +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.afternote.afternote_fe.test.appTestUserRepository +import com.afternote.afternote_fe.test.testReceiver +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.timeletter.data.testing.createVoiceRecorderRepositoryForTesting +import com.afternote.feature.timeletter.domain.testing.FakeFileMetadataRepository +import com.afternote.feature.timeletter.domain.testing.FakeTimeLetterRepository +import com.afternote.feature.timeletter.domain.usecase.CreateTimeLetterUseCase +import com.afternote.feature.timeletter.domain.usecase.ResolveTimeLetterBlocksUseCase +import com.afternote.feature.timeletter.presentation.screen.sender.TimeLetterWriteScreen +import com.afternote.feature.timeletter.presentation.viewmodel.TimeLetterWriteViewModel +import com.afternote.feature.timeletter.presentation.viewmodel.VoiceRecordingState +import kotlinx.coroutines.Dispatchers +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * 녹음 중 화면 이탈(`ON_STOP`)이 실제 Activity 생명주기 위에서, 실제 + * `TimeLetterWriteScreen → TimeLetterWriteViewModel.discardVoiceRecording() → + * VoiceRecorderRepository.discard() → 파일 삭제` 결선을 실행하는지 검증한다. 이전 버전은 + * synthetic uiState 만 바꾸는 로컬 람다로 짜여 있어 이 결선을 한 번도 타지 않았다 (#440 리뷰). + * `VoiceRecorderRepository` 는 fake 가 아니라 `createVoiceRecorderRepositoryForTesting` 로 얻은 + * 실제 구현이라, discard 가 실제 `MediaRecorder`/`filesDir` 자원까지 정리하는지도 함께 본다. + */ +@RunWith(AndroidJUnit4::class) +class VoiceRecordingLifecycleAndroidTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + private val audioDirectory = File(context.filesDir, "timeletter_audio") + + @Before + fun grantMicrophonePermission() { + InstrumentationRegistry + .getInstrumentation() + .uiAutomation + .grantRuntimePermission(context.packageName, Manifest.permission.RECORD_AUDIO) + } + + @Test + fun appBackgrounded_whileRecording_discardsTheRecordingAndDeletesTheFile() { + val viewModel = timeLetterWriteViewModel() + renderWriteScreen(viewModel) + + composeRule.runOnIdle { + viewModel.openVoiceRecorder() + viewModel.startVoiceRecording() + } + composeRule.waitUntil(timeoutMillis = TIMEOUT) { + viewModel.uiState.value.voiceRecordingState is VoiceRecordingState.Recording + } + + composeRule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + composeRule.waitForIdle() + + composeRule.waitUntil(timeoutMillis = TIMEOUT) { + viewModel.uiState.value.voiceRecordingState == VoiceRecordingState.Idle + } + composeRule.waitUntil(timeoutMillis = TIMEOUT) { audioDirectory.listFiles().isNullOrEmpty() } + assertTrue( + "ON_STOP 이 discardVoiceRecording() -> repository.discard() 를 실제로 태워 파일을 지워야 한다", + audioDirectory.listFiles().isNullOrEmpty(), + ) + } + + @Test + fun appBackgrounded_immediatelyAfterStarting_discardsTheLateArrivingRecording() { + // start() 완료 전에 화면을 떠나는 경주 상황이다 (#440 리뷰 P1) — Starting 을 동기 + // 반영하고 세대(generation) 로 뒤늦은 성공을 걸러내는지를 최종 파일 상태로 확인한다. + val viewModel = timeLetterWriteViewModel() + renderWriteScreen(viewModel) + + composeRule.runOnIdle { + viewModel.openVoiceRecorder() + viewModel.startVoiceRecording() + } + // runOnIdle 은 액션 실행 "전" idle 만 보장한다 — 이 recomposition 이 실제로 반영돼 + // LifecycleEventEffect 의 클로저가 Starting 을 보게 된 뒤에 백그라운드로 보내야, + // ON_STOP 조건이 방금 반영된 상태가 아니라 그 이전 stale Idle 을 보고 건너뛰지 않는다. + composeRule.waitForIdle() + composeRule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + composeRule.waitForIdle() + + composeRule.waitUntil(timeoutMillis = TIMEOUT) { + viewModel.uiState.value.voiceRecordingState == VoiceRecordingState.Idle + } + composeRule.waitUntil(timeoutMillis = TIMEOUT) { audioDirectory.listFiles().isNullOrEmpty() } + assertTrue( + "Starting 중 이탈해도 뒤늦게 성공한 start() 결과가 남기는 녹음 파일이 정리돼야 한다", + audioDirectory.listFiles().isNullOrEmpty(), + ) + } + + @Test + fun appBackgrounded_afterRecordingFinished_doesNotDiscardTheRecordedAudio() { + val viewModel = timeLetterWriteViewModel() + renderWriteScreen(viewModel) + + composeRule.runOnIdle { + viewModel.openVoiceRecorder() + viewModel.startVoiceRecording() + } + composeRule.waitUntil(timeoutMillis = TIMEOUT) { + viewModel.uiState.value.voiceRecordingState is VoiceRecordingState.Recording + } + composeRule.runOnIdle { viewModel.stopVoiceRecording() } + composeRule.waitUntil(timeoutMillis = TIMEOUT) { + viewModel.uiState.value.voiceRecordingState is VoiceRecordingState.Recorded + } + + composeRule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + composeRule.waitForIdle() + + assertTrue( + "녹음이 끝난 뒤(Recorded)에는 ON_STOP 이 결과를 지우면 안 된다", + viewModel.uiState.value.voiceRecordingState is VoiceRecordingState.Recorded, + ) + assertTrue(viewModel.uiState.value.showVoiceRecorder) + + viewModel.discardVoiceRecording() + } + + private fun renderWriteScreen(viewModel: TimeLetterWriteViewModel) { + composeRule.activityRule.scenario.onActivity { activity -> + activity.setContent { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + AfternoteTheme { + TimeLetterWriteScreen( + uiState = uiState, + titleState = TextFieldState(), + onOpenVoiceRecorder = viewModel::openVoiceRecorder, + onStartVoiceRecording = viewModel::startVoiceRecording, + onStopVoiceRecording = viewModel::stopVoiceRecording, + onDiscardVoiceRecording = viewModel::discardVoiceRecording, + ) + } + } + } + composeRule.waitForIdle() + } + + private fun timeLetterWriteViewModel(): TimeLetterWriteViewModel { + val repository = FakeTimeLetterRepository.strict() + val userRepository = appTestUserRepository(receivers = listOf(testReceiver())) + val resolver = ResolveTimeLetterBlocksUseCase(FakePhotoUploadRepository.strict()) + return TimeLetterWriteViewModel( + createTimeLetterUseCase = CreateTimeLetterUseCase(repository, resolver), + resolveTimeLetterBlocksUseCase = resolver, + timeLetterRepository = repository, + userRepository = userRepository, + fileMetadataRepository = FakeFileMetadataRepository.strict(), + voiceRecorderRepository = createVoiceRecorderRepositoryForTesting(context, Dispatchers.IO), + savedStateHandle = SavedStateHandle(mapOf("timeLetterId" to null)), + ) + } + + private companion object { + const val TIMEOUT = 5_000L + } +} diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt index 23f6791e61..13ea5b7270 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt @@ -11,6 +11,8 @@ import com.afternote.core.model.user.UserPushSetting import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository fun afternoteEditorSavedStateHandle( initialType: AfternoteType, @@ -41,28 +43,51 @@ fun appTestAuthRepository(loggedIn: Boolean = false): FakeAuthRepository = /** * 기존 app androidTest 공용 User fake 의 기본 허용 범위를 정본 fixture 위에 보존한다. - * 수신자 상세·수정, 계정 연결, 전달조건은 전용 시나리오가 `onX` 로 명시해야 열린다. + * 수신자 상세·수정, 전달조건은 전용 시나리오가 `onX` 로 명시해야 열린다. + * + * 계정·알림은 좁은 계약으로 내려가(#1429) [appTestSettingAccountRepository]· + * [appTestSettingNotificationRepository] 가 같은 허용 범위를 이어받는다. */ fun appTestUserRepository( profile: User = DEFAULT_TEST_USER, receivers: List = listOf(testReceiver()), - pushSetting: UserPushSetting = DEFAULT_TEST_PUSH_SETTING, ): FakeUserRepository = FakeUserRepository.strict().apply { this.profile = profile receiverState.value = receivers.toList() - this.pushSetting = pushSetting - connectedAccounts = defaultConnectedAccounts(profile.email) onReceiverListFlow = null onGetReceivers = null onCreateReceiver = null onGetMyProfile = null onUpdateMyProfile = null + } + +/** + * 계정 fake — 탈퇴와 연결 계정 조회만 열어 둔다 (#1429). + * + * 연결·해제는 전용 시나리오가 `onX` 로 명시해야 열린다. 합본 fake 시절과 같은 경계다. + * + * `@JvmOverloads` 는 Hilt 대체 모듈이 Java 이기 때문이다 — Java 는 Kotlin 기본값을 못 본다. + * 그 모듈이 Java 인 이유는 `TestSettingUserRepositoryModule` 에 있다. + */ +@JvmOverloads +fun appTestSettingAccountRepository(profile: User = DEFAULT_TEST_USER): FakeSettingAccountRepository = + FakeSettingAccountRepository.strict().apply { + connectedAccounts = defaultConnectedAccounts(profile.email) onDeleteAccount = null + onGetConnectedAccounts = null + } + +/** 알림 fake — 푸시 토글과 마케팅 동의 네 멤버를 모두 메모리 동작으로 연다 (#1429). */ +@JvmOverloads +fun appTestSettingNotificationRepository(pushSetting: UserPushSetting = DEFAULT_TEST_PUSH_SETTING): FakeSettingNotificationRepository = + FakeSettingNotificationRepository.strict().apply { + this.pushSetting = pushSetting onGetMyPushSettings = null onUpdateMyPushSettings = null - onGetConnectedAccounts = { defaultConnectedAccounts(this.profile.email) } + onGetMyMarketingConsents = null + onUpdateMyMarketingConsents = null } class FakeErrorReporter : ErrorReporter { @@ -83,7 +108,7 @@ class FakeErrorReporter : ErrorReporter { fun testReceiver( id: Long = 7L, name: String = "김수신", -): Receiver = Receiver(receiverId = id, name = name, relation = "가족", authCode = "fake-auth-$id") +): Receiver = Receiver(receiverId = id, name = name, relation = "가족") private val DEFAULT_TEST_USER = User("테스트 사용자", "test@afternote.local", null, null) private val DEFAULT_TEST_PUSH_SETTING = UserPushSetting(true, true, true) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt index 80ad179e49..eb541c8055 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt @@ -5,7 +5,9 @@ import com.afternote.afternote_fe.notification.di.NotificationPermissionStoreMod import com.afternote.afternote_fe.reporting.ErrorReportingModule import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.data.di.CoreUserRepositoryModule +import com.afternote.core.domain.repository.MyProfileRepository import com.afternote.core.domain.repository.UserProfileCacheRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.domain.repository.UserRepository import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.domain.testing.FakeUserProfileCacheRepository @@ -23,8 +25,10 @@ import com.afternote.feature.timeletter.data.repositoryImpl.FileMetadataReposito import com.afternote.feature.timeletter.domain.repository.FileMetadataRepository import com.afternote.feature.timeletter.domain.repository.ReceiverTimeLetterRepository import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository +import com.afternote.feature.timeletter.domain.repository.VoiceRecorderRepository import com.afternote.feature.timeletter.domain.testing.FakeReceiverTimeLetterRepository import com.afternote.feature.timeletter.domain.testing.FakeTimeLetterRepository +import com.afternote.feature.timeletter.domain.testing.FakeVoiceRecorderRepository import dagger.Module import dagger.Provides import dagger.hilt.components.SingletonComponent @@ -46,6 +50,16 @@ object TestCoreUserRepositoryModule { @Singleton fun provideUserRepository(): UserRepository = appTestUserRepository() + // 책임별 좁은 계약 2종 (#1282). 이 모듈이 `CoreUserRepositoryModule` 을 통째로 replaces 하므로 + // 거기 있던 두 바인딩도 같이 사라진다 — 좁은 계약을 주입받는 화면(#1743 의 애프터노트 상세·에디터)이 + // 계측 그래프에서 MissingBinding 으로 깨지지 않게 여기서 다시 잇는다. + // 프로덕션과 같은 모양으로 싱글턴 `UserRepository` 를 경유해, 계측이 세운 fake 한 인스턴스를 공유한다. + @Provides + fun provideUserReceiverRepository(userRepository: UserRepository): UserReceiverRepository = userRepository + + @Provides + fun provideMyProfileRepository(userRepository: UserRepository): MyProfileRepository = userRepository + @Provides @Singleton fun provideUserProfileCacheRepository(): UserProfileCacheRepository = FakeUserProfileCacheRepository() @@ -154,6 +168,21 @@ object TestTimeLetterModule { @Provides @Singleton fun provideFileMetadataRepository(impl: FileMetadataRepositoryImpl): FileMetadataRepository = impl + + /** + * 녹음도 fake 로 격리한다. + * + * 실제 구현은 `MediaRecorder` 로 마이크를 잡는다 — 앱 전체를 띄우는 계측에서 실제 녹음 장치에 + * 붙이면 에뮬레이터 환경에 따라 흔들리고, 이 저장소를 명시적으로 다루는 테스트는 + * [VoiceRecordingAndroidTest]·[VoiceRecordingLifecycleAndroidTest] 처럼 + * `createVoiceRecorderRepositoryForTesting` 로 실제 구현을 직접 얻어 검증한다. + * + * `start`/`stop` 은 일부러 [FakeVoiceRecorderRepository] 의 기본대로 호출 시 터진다 — 관심 밖 + * 화면이 실수로 녹음을 시작하면 조용히 넘어가지 않고 바로 드러나야 한다. + */ + @Provides + @Singleton + fun provideVoiceRecorderRepository(): VoiceRecorderRepository = FakeVoiceRecorderRepository } @Module diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestSettingUserRepositoryModule.java b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestSettingUserRepositoryModule.java new file mode 100644 index 0000000000..2a335c1120 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestSettingUserRepositoryModule.java @@ -0,0 +1,36 @@ +package com.afternote.afternote_fe.test; + +import com.afternote.feature.setting.data.di.SettingUserRepositoryModule; +import com.afternote.feature.setting.domain.SettingAccountRepository; +import com.afternote.feature.setting.domain.SettingNotificationRepository; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; +import dagger.hilt.components.SingletonComponent; +import dagger.hilt.testing.TestInstallIn; + +/** + * 설정 계정·알림 저장소를 계측 테스트의 fake 바인딩으로 교체한다. + * + *

Kotlin internal 클래스는 JVM에서 public이므로 Java 테스트에서 참조할 수 있다. + * 프로덕션 Hilt 모듈의 Kotlin 공개 범위를 유지하기 위해 이 대체 모듈만 Java로 작성한다. + */ +@Module +@TestInstallIn(components = SingletonComponent.class, replaces = SettingUserRepositoryModule.class) +public final class TestSettingUserRepositoryModule { + private TestSettingUserRepositoryModule() {} + + @Provides + @Singleton + static SettingAccountRepository provideSettingAccountRepository() { + return TestFakesKt.appTestSettingAccountRepository(); + } + + @Provides + @Singleton + static SettingNotificationRepository provideSettingNotificationRepository() { + return TestFakesKt.appTestSettingNotificationRepository(); + } +} diff --git a/app/src/debug/java/com/afternote/afternote_fe/debug/DebugSettingsActivity.kt b/app/src/debug/java/com/afternote/afternote_fe/debug/DebugSettingsActivity.kt index e897bf0b21..7863368128 100644 --- a/app/src/debug/java/com/afternote/afternote_fe/debug/DebugSettingsActivity.kt +++ b/app/src/debug/java/com/afternote/afternote_fe/debug/DebugSettingsActivity.kt @@ -4,7 +4,6 @@ import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -19,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.afternote.afternote_fe.EXTRA_DEBUG_START_TIMELETTER import com.afternote.afternote_fe.MainActivity +import com.afternote.afternote_fe.enableLightEdgeToEdge import dagger.hilt.android.AndroidEntryPoint /** @@ -30,7 +30,7 @@ import dagger.hilt.android.AndroidEntryPoint class DebugSettingsActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() + enableLightEdgeToEdge() setContent { Surface( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ad92bb3297..c0acfe832c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -57,9 +57,6 @@ android:authorities="${applicationId}.androidx-startup" android:exported="false" tools:node="merge"> - @@ -77,6 +74,12 @@ 현재 화면은 폰 세로 레이아웃만 지원한다. Android 16 대화면에서는 시스템이 이 제한을 무시하므로 대화면 대응을 대신하는 선언은 아니다 (#1144). --> + diff --git a/app/src/main/generated/baselineProfiles/baseline-prof.txt b/app/src/main/generated/baselineProfiles/baseline-prof.txt index 7456c933b7..db8624332c 100644 --- a/app/src/main/generated/baselineProfiles/baseline-prof.txt +++ b/app/src/main/generated/baselineProfiles/baseline-prof.txt @@ -16481,12 +16481,6 @@ Lcom/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel HSPLcom/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel_HiltModules_BindsModule_Binds_LazyMapKey;->()V Lcom/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel_HiltModules_KeyModule_Provide_LazyMapKey; HSPLcom/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel_HiltModules_KeyModule_Provide_LazyMapKey;->()V -Lcom/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel_HiltModules$KeyModule; -HSPLcom/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel_HiltModules$KeyModule;->provide()Z -Lcom/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel_HiltModules_BindsModule_Binds_LazyMapKey; -HSPLcom/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel_HiltModules_BindsModule_Binds_LazyMapKey;->()V -Lcom/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel_HiltModules_KeyModule_Provide_LazyMapKey; -HSPLcom/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel_HiltModules_KeyModule_Provide_LazyMapKey;->()V Lcom/afternote/feature/setting/presentation/viewmodel/PassKeyViewModel_HiltModules$KeyModule; HSPLcom/afternote/feature/setting/presentation/viewmodel/PassKeyViewModel_HiltModules$KeyModule;->provide()Z Lcom/afternote/feature/setting/presentation/viewmodel/PassKeyViewModel_HiltModules_BindsModule_Binds_LazyMapKey; diff --git a/app/src/main/generated/baselineProfiles/startup-prof.txt b/app/src/main/generated/baselineProfiles/startup-prof.txt index 7456c933b7..db8624332c 100644 --- a/app/src/main/generated/baselineProfiles/startup-prof.txt +++ b/app/src/main/generated/baselineProfiles/startup-prof.txt @@ -16481,12 +16481,6 @@ Lcom/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel HSPLcom/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel_HiltModules_BindsModule_Binds_LazyMapKey;->()V Lcom/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel_HiltModules_KeyModule_Provide_LazyMapKey; HSPLcom/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel_HiltModules_KeyModule_Provide_LazyMapKey;->()V -Lcom/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel_HiltModules$KeyModule; -HSPLcom/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel_HiltModules$KeyModule;->provide()Z -Lcom/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel_HiltModules_BindsModule_Binds_LazyMapKey; -HSPLcom/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel_HiltModules_BindsModule_Binds_LazyMapKey;->()V -Lcom/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel_HiltModules_KeyModule_Provide_LazyMapKey; -HSPLcom/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel_HiltModules_KeyModule_Provide_LazyMapKey;->()V Lcom/afternote/feature/setting/presentation/viewmodel/PassKeyViewModel_HiltModules$KeyModule; HSPLcom/afternote/feature/setting/presentation/viewmodel/PassKeyViewModel_HiltModules$KeyModule;->provide()Z Lcom/afternote/feature/setting/presentation/viewmodel/PassKeyViewModel_HiltModules_BindsModule_Binds_LazyMapKey; diff --git a/app/src/main/java/com/afternote/afternote_fe/GlobalApplication.kt b/app/src/main/java/com/afternote/afternote_fe/GlobalApplication.kt index 17f771eb77..b4dd6a8c0e 100644 --- a/app/src/main/java/com/afternote/afternote_fe/GlobalApplication.kt +++ b/app/src/main/java/com/afternote/afternote_fe/GlobalApplication.kt @@ -6,6 +6,8 @@ import coil3.PlatformContext import coil3.SingletonImageLoader import com.afternote.afternote_fe.messaging.FcmNotificationChannel import com.afternote.afternote_fe.messaging.PushTargetSynchronizer +import com.afternote.afternote_fe.notification.DailyNotificationScheduleSynchronizer +import com.afternote.afternote_fe.update.ForceUpdateGate import com.afternote.core.network.di.CoilImageLoaderEntryPoint import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp @@ -27,6 +29,22 @@ class GlobalApplication : @Inject lateinit var pushTargetSynchronizer: PushTargetSynchronizer + /** + * 강제 업데이트 관문 (#1539). + * + * [pushTargetSynchronizer] 와 같은 이유로 여기 있다 — 아래 [startForceUpdateCheck] 주석 참고. + */ + @Inject + lateinit var forceUpdateGate: ForceUpdateGate + + /** + * 데일리 알림 예약을 로그인 상태에 맞추는 관찰의 주체 (#2146). + * + * [pushTargetSynchronizer] 와 같은 이유로 여기 있다. 세션 판정이 Hilt 그래프에 있어 Initializer 에서는 못 본다. + */ + @Inject + lateinit var dailyNotificationScheduleSynchronizer: DailyNotificationScheduleSynchronizer + /** Hilt 가 만든 [ImageLoader] 를 Coil 앱 전역 싱글톤으로 등록 — 모든 AsyncImage 가 명시적 imageLoader 없이 이걸 사용. */ override fun newImageLoader(context: PlatformContext): ImageLoader = EntryPointAccessors @@ -37,6 +55,8 @@ class GlobalApplication : super.onCreate() FcmNotificationChannel.create(this) startPushTargetSync() + startDailyNotificationSync() + startForceUpdateCheck() // 그 밖의 기동 초기화는 startup 패키지의 Initializer 에 둔다. } @@ -57,4 +77,33 @@ class GlobalApplication : pushTargetSynchronizer.observeLogin() } } + + /** + * 데일리 알림 예약을 로그인 상태에 맞춘다 (#2146). + * + * 자리가 여기인 이유는 [startPushTargetSync] 와 같다. 관찰은 앱 프로세스 수명 동안 유지돼야 해서 + * 이 스코프도 취소하지 않는다. + */ + private fun startDailyNotificationSync() { + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + dailyNotificationScheduleSynchronizer.observeLogin() + } + } + + /** + * 서버에 «이 버전을 아직 써도 되는가» 를 프로세스 기동마다 한 번 물어본다 (#1539). + * + * `androidx.startup` Initializer 가 아닌 이유는 [startPushTargetSync] 와 같다 — + * `InitializationProvider` 는 `Application.onCreate()` 이전에 도는 ContentProvider 라 + * 그 시점의 Hilt 접근이 계기 테스트를 전멸시킨다. + * + * 화면 진입을 기다리게 하지 않는다. 결과가 오면 [ForceUpdateGate.prompt] 가 바뀌고 + * [MainActivity] 가 그 위에 팝업을 얹을 뿐이라, 응답이 늦거나 영영 오지 않아도 + * 사용자는 평소대로 앱을 쓴다. + */ + private fun startForceUpdateCheck() { + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + forceUpdateGate.refresh() + } + } } diff --git a/app/src/main/java/com/afternote/afternote_fe/LightEdgeToEdge.kt b/app/src/main/java/com/afternote/afternote_fe/LightEdgeToEdge.kt new file mode 100644 index 0000000000..ca6d4034c4 --- /dev/null +++ b/app/src/main/java/com/afternote/afternote_fe/LightEdgeToEdge.kt @@ -0,0 +1,25 @@ +package com.afternote.afternote_fe + +import android.graphics.Color +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge + +/** + * 시스템바 아이콘까지 라이트로 고정한 edge-to-edge (#1719). + * + * 무인자 [enableEdgeToEdge] 는 `SystemBarStyle.auto` 라 `uiMode == NIGHT_YES` 면 + * `isAppearanceLightStatusBars` 를 `false` 로 잡아 **흰 아이콘**을 고른다. 그런데 + * `AfternoteTheme` 이 라이트로 잠긴 뒤로 Compose 는 다크 기기에서도 `gray1`(#FAFAFA) 을 + * 상태바 밑까지 그린다. 둘을 그대로 두면 밝은 배경 위에 흰 시계·배터리·제스처 바가 얹혀 + * 모든 화면에서 보이지 않는다. + * + * 그래서 시스템바 스타일도 함께 라이트로 못박는다. **`AfternoteTheme` 의 `isDarkTheme` + * 기본값을 시스템 추종으로 되돌릴 때 이 함수도 같이 되돌린다** — 두 자리는 한 쌍이다. + */ +fun ComponentActivity.enableLightEdgeToEdge() { + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT), + ) +} diff --git a/app/src/main/java/com/afternote/afternote_fe/MainActivity.kt b/app/src/main/java/com/afternote/afternote_fe/MainActivity.kt index 1a41c2a638..030627f27e 100644 --- a/app/src/main/java/com/afternote/afternote_fe/MainActivity.kt +++ b/app/src/main/java/com/afternote/afternote_fe/MainActivity.kt @@ -3,17 +3,21 @@ package com.afternote.afternote_fe import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.runtime.getValue +import androidx.core.net.toUri import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.afternote_fe.navigation.AppNavigation import com.afternote.afternote_fe.notification.NotificationIntentContract +import com.afternote.afternote_fe.update.ForceUpdateGate +import com.afternote.afternote_fe.update.ForceUpdatePopup +import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.ui.Route import com.afternote.core.ui.theme.AfternoteTheme import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject const val EXTRA_DEBUG_START_TIMELETTER = "debug_start_timeletter" @@ -21,10 +25,20 @@ const val EXTRA_DEBUG_START_TIMELETTER = "debug_start_timeletter" class MainActivity : FragmentActivity() { private val viewModel: MainViewModel by viewModels() + /** + * 강제 업데이트 판정 (#1539). 관문은 앱 프로세스 수명 단위라 ViewModel 이 아니라 싱글톤이고, + * 화면은 그 값을 읽기만 한다 — 이 Activity 가 부르는 조회는 없다. + */ + @Inject + lateinit var forceUpdateGate: ForceUpdateGate + + @Inject + lateinit var errorReporter: ErrorReporter + override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) - enableEdgeToEdge() + enableLightEdgeToEdge() enqueueNotificationIntent(intent) @@ -48,10 +62,28 @@ class MainActivity : FragmentActivity() { startRoute?.let { route -> AppNavigation(startDestination = route) } + val forceUpdatePrompt by forceUpdateGate.prompt.collectAsStateWithLifecycle() + forceUpdatePrompt?.let { prompt -> + ForceUpdatePopup(onConfirm = { openStore(prompt.storeUrl) }) + } } } } + /** + * 스토어를 연다. 열지 못해도 앱을 죽이지 않는다 — 관문은 이미 떠 있고, 사용자는 다시 누를 수 있다. + * + * 여는 데 실패했다는 사실 자체는 남긴다. 여기까지 왔다는 건 서버가 준 주소가 Play Store + * 형식은 맞았다는 뜻이라, 실패하면 기기에 그 주소를 열 수 있는 앱이 없다는 신호다. + */ + private fun openStore(storeUrl: String) { + runCatching { + startActivity(Intent(Intent.ACTION_VIEW, storeUrl.toUri())) + }.onFailure { error -> + errorReporter.recordFailure(error, mapOf("stage" to "force_update_open_store")) + } + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) diff --git a/app/src/main/java/com/afternote/afternote_fe/messaging/AfternoteFirebaseMessagingService.kt b/app/src/main/java/com/afternote/afternote_fe/messaging/AfternoteFirebaseMessagingService.kt index c4bc384aec..56671eec8c 100644 --- a/app/src/main/java/com/afternote/afternote_fe/messaging/AfternoteFirebaseMessagingService.kt +++ b/app/src/main/java/com/afternote/afternote_fe/messaging/AfternoteFirebaseMessagingService.kt @@ -8,6 +8,7 @@ import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.afternote.afternote_fe.R +import com.afternote.core.common.notification.NotificationDestination import com.afternote.core.common.notification.NotificationPendingIntentFactory import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage @@ -94,12 +95,16 @@ class AfternoteFirebaseMessagingService : FirebaseMessagingService() { setContentText(body) setStyle(NotificationCompat.BigTextStyle().bigText(body)) } - // BE 목적지 계약 전에는 FCM data를 화면 이동 payload로 연결하지 않는다. + // 목적지 data 키 계약(Afternote/Afternote-BE#261)이 서기 전까지 FCM 은 홈을 + // 명시한다. 키를 추측해 두면 BE 가 다른 이름을 고르는 순간 조용히 죽는 분기가 + // 남는다 — 계약이 확정되면 여기서 data 를 읽어 NotificationDestination 으로 + // 옮긴다. NotificationPendingIntentFactory .create( context = this@AfternoteFirebaseMessagingService, source = NOTIFICATION_SOURCE, occurrenceId = occurrenceId, + destination = NotificationDestination.HOME, )?.let(::setContentIntent) }.build() @@ -115,12 +120,12 @@ class AfternoteFirebaseMessagingService : FirebaseMessagingService() { } } -internal data class FcmNotificationContent( +private data class FcmNotificationContent( val title: String, val body: String?, ) -internal object FcmNotificationIdentity { +private object FcmNotificationIdentity { fun occurrenceId(messageId: String?): String = messageId ?.takeIf(String::isNotBlank) @@ -130,7 +135,7 @@ internal object FcmNotificationIdentity { fun notificationTag(occurrenceId: String): String = "fcm:$occurrenceId" } -internal object FcmNotificationContentResolver { +private object FcmNotificationContentResolver { fun resolve( notificationTitle: String?, notificationBody: String?, diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt index 1b515a0623..26b3296d43 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt @@ -2,6 +2,7 @@ package com.afternote.afternote_fe.navigation import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars @@ -23,21 +24,23 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.toRoute import com.afternote.afternote_fe.notification.NotificationPermissionEffect import com.afternote.core.ui.Route import com.afternote.core.ui.bottombar.BottomBar -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.feature.afternote.presentation.navigation.afternoteNavGraph -import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute -import com.afternote.feature.afternote.presentation.receiver.navigation.receivedAfternoteNavGraph +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.core.ui.navigation.NavDestinationBackground +import com.afternote.core.ui.navigation.PredictiveBackPopEnter +import com.afternote.core.ui.navigation.PredictiveBackPopExit +import com.afternote.feature.afternote.presentation.navigation.AfternoteNavHost +import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteNavHost import com.afternote.feature.home.presentation.HomeTabScreen import com.afternote.feature.home.presentation.HomeTabViewModel +import com.afternote.feature.home.presentation.receiver.ReceiverHomeEntry import com.afternote.feature.mindrecord.presentation.navigation.mindRecordNavGraph -import com.afternote.feature.onboarding.presentation.navigation.onboardingNavGraph -import com.afternote.feature.receiver.presentation.home.ReceiverHomeEntry -import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute -import com.afternote.feature.receiver.presentation.navigation.receiverNavGraph -import com.afternote.feature.setting.presentation.navigation.settingNavGraph +import com.afternote.feature.onboarding.presentation.navigation.OnboardingNavHost +import com.afternote.feature.receiver.presentation.navigation.ReceiverNavHost +import com.afternote.feature.setting.presentation.navigation.SettingNavHost import com.afternote.feature.timeletter.presentation.navigation.timeLetterNavGraph import kotlinx.coroutines.launch @@ -49,31 +52,41 @@ fun AppNavigation( ) { val navEntry by appState.navController.currentBackStackEntryAsState() val currentDestination = navEntry?.destination - val showBottomBar = appState.shouldShowBottomBar(currentDestination) + + // 로컬 Nav3 스택의 깊이는 Nav2 destination 에 안 보인다 — 애프터노트 host 가 올려 주는 신호를 + // 바텀바 판정에 합성한다. 피처를 떠나면 host 가 true 로 되돌려 다른 탭 판정을 오염시키지 않는다. + var isAfternoteStackAtRoot by remember { mutableStateOf(true) } + + val showBottomBar = appState.shouldShowBottomBar(currentDestination, isAfternoteStackAtRoot) val currentTab = appState.getCurrentNavTab(currentDestination) - val onboardingNavActions = rememberOnboardingNavActions(appState.navController) val mindRecordNavActions = rememberMindRecordNavActions(appState.navController) - val settingNavActions = rememberSettingNavActions(appState) val timeLetterNavActions = rememberTimeLetterNavActions(appState.navController) val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - val afternoteNavActions = - rememberAfternoteNavActions(appState) { message -> + val onboardingExternalActions = rememberOnboardingExternalActions(appState) + val settingExternalActions = rememberSettingExternalActions(appState) + val afternoteExternalActions = + rememberAfternoteExternalActions(appState) { message -> scope.launch { snackbarHostState.showSnackbar(message) } } - val receivedAfternoteNavActions = rememberReceivedAfternoteNavActions(appState) - val receiverNavActions = rememberReceiverNavActions(appState) val receiverHomeActions = rememberReceiverHomeActions(appState) + // 로컬 스택 바닥에서의 back 은 루트 백스택 pop 으로 돌려준다. 루트가 NavDisplay 로 바뀌어도 + // 계약은 그대로고 이 구현만 갈린다 (#1702). + // 바텀바가 없는 그래프는 깊이를 셸에 올릴 일이 없다. + val rootNavigationCallbacks = rememberRootNavigationCallbacks(appState, onAtRootChanged = {}) + val afternoteNavigationCallbacks = + rememberRootNavigationCallbacks(appState) { isAtRoot -> isAfternoteStackAtRoot = isAtRoot } + // 13+ 는 런타임 권한이 없으면 알림이 한 건도 게시되지 않는다 (#1454). NotificationPermissionEffect(snackbarHostState = snackbarHostState) Scaffold( modifier = modifier, - containerColor = AfternoteDesign.colors.gray1, + containerColor = NavDestinationBackground, snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, contentWindowInsets = WindowInsets.systemBars.only( @@ -89,16 +102,48 @@ fun AppNavigation( }, ) { innerPadding -> NavHost( - modifier = Modifier.padding(innerPadding), + // 루트가 확보한 시스템 바·하단 탭 여백을 하위 inset 계산에도 전달한다. + modifier = Modifier.padding(innerPadding).consumeWindowInsets(innerPadding), navController = appState.navController, startDestination = startDestination, + // pop 은 기본값이 fade 라 predictive back 진행 중 두 화면이 같은 크기로 겹쳐 보였다 (#1869). + // 값은 Nav3 로컬 스택이 이미 쓰는 것과 같은 것을 core/ui 에서 받아 두 엔진을 맞춘다. + popEnterTransition = { PredictiveBackPopEnter }, + popExitTransition = { PredictiveBackPopExit }, ) { - onboardingNavGraph( - graphScopedParentEntry = { - appState.navController.getBackStackEntry() - }, - actions = onboardingNavActions, - ) + // ── Navigation 3 로컬 스택을 가진 그래프 (#1698) — 루트엔 host destination 하나씩만 둔다. + composable { + OnboardingNavHost( + boundary = rootNavigationCallbacks, + externalActions = onboardingExternalActions, + ) + } + composable { + AfternoteNavHost( + navigationCallbacks = afternoteNavigationCallbacks, + externalActions = afternoteExternalActions, + ) + } + // 수신 애프터노트 화면은 애프터노트 피처가 갖는다 (#1461). Route.Afternote 그래프는 + // 발신자용 지문 관문을 시작점으로 삼으므로 그 안에 중첩하지 않고 루트에 직접 등록한다. + composable { + ReceivedAfternoteNavHost(navigationCallbacks = rootNavigationCallbacks) + } + composable { + ReceiverNavHost( + homeContent = { ReceiverHomeEntry(actions = receiverHomeActions) }, + navigationCallbacks = rootNavigationCallbacks, + ) + } + composable { entry -> + SettingNavHost( + navigationCallbacks = rootNavigationCallbacks, + externalActions = settingExternalActions, + startWithRecipientRegistration = entry.toRoute().startWithRecipientRegistration, + ) + } + + // ── 아직 Navigation 2 인 그래프 — #1696 · #1697 이 각각 이관한다. composable { val viewModel: HomeTabViewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -125,38 +170,31 @@ fun AppNavigation( actions = homeTabActions, ) } - settingNavGraph( - graphScopedParentEntry = { - appState.navController.getBackStackEntry() - }, - actions = settingNavActions, - ) mindRecordNavGraph(actions = mindRecordNavActions) timeLetterNavGraph( navController = appState.navController, actions = timeLetterNavActions, ) - afternoteNavGraph( - graphScopedParentEntry = { - appState.navController.getBackStackEntry() - }, - editorFlowParentEntry = { - appState.navController.getBackStackEntry() - }, - actions = afternoteNavActions, - ) - // 수신 애프터노트 화면은 애프터노트 피처가 갖는다 (#1461). Route.Afternote 그래프는 - // 발신자용 지문 관문을 시작점으로 삼으므로 그 안에 중첩하지 않고 루트에 직접 등록한다. - receivedAfternoteNavGraph(actions = receivedAfternoteNavActions) - receiverNavGraph( - homeContent = { ReceiverHomeEntry(actions = receiverHomeActions) }, - actions = receiverNavActions, - // 열람 신청 nested graph 의 parent route. 이 route 의 backStackEntry 가 자체 ViewModelStore 를 - // 보유 → 자식 5 화면이 그 안의 DeliveryVerificationFlowViewModel 을 공유 (flow-scoped VM). - deliveryFlowParentEntry = { - appState.navController.getBackStackEntry() - }, - ) } } } + +/** + * 로컬 스택 바닥의 back 을 루트 백스택 pop 으로 돌려주는 경계. + * + * @param onAtRootChanged 바텀바 판정에 깊이를 합성해야 하는 그래프만 넘긴다. + */ +@Composable +private fun rememberRootNavigationCallbacks( + appState: AppState, + onAtRootChanged: (Boolean) -> Unit, +): FeatureNavigationCallbacks = + remember(appState, onAtRootChanged) { + object : FeatureNavigationCallbacks { + override fun exit() { + appState.navController.popBackStack() + } + + override fun onAtRootChanged(isAtRoot: Boolean) = onAtRootChanged(isAtRoot) + } + } diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt index c32d52f21b..dafdebc3ae 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt @@ -7,93 +7,18 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.navigation.NavController import com.afternote.core.ui.Route import com.afternote.core.ui.bottombar.BottomNavTab -import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.afternote.presentation.navigation.AfternoteNavActions +import com.afternote.feature.afternote.presentation.navigation.AfternoteExternalActions import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute -import com.afternote.feature.afternote.presentation.navigation.model.SELECTED_RECEIVER_ID_KEY -import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteNavActions -import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute import com.afternote.feature.home.presentation.HomeTabActions +import com.afternote.feature.home.presentation.receiver.ReceiverHomeActions import com.afternote.feature.mindrecord.presentation.navigation.MindRecordNavActions import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute -import com.afternote.feature.onboarding.presentation.navigation.OnboardingNavActions -import com.afternote.feature.onboarding.presentation.navigation.OnboardingRoute -import com.afternote.feature.receiver.presentation.home.ReceiverHomeActions -import com.afternote.feature.receiver.presentation.navigation.ReceiverNavActions +import com.afternote.feature.onboarding.presentation.navigation.OnboardingExternalActions import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute -import com.afternote.feature.setting.presentation.navigation.SettingNavActions -import com.afternote.feature.setting.presentation.navigation.SettingRoute +import com.afternote.feature.setting.presentation.navigation.SettingExternalActions import com.afternote.feature.timeletter.presentation.navigation.TimeLetterNavActions import com.afternote.feature.timeletter.presentation.navigation.TimeLetterRoute -@Composable -fun rememberOnboardingNavActions(navController: NavController): OnboardingNavActions = - remember(navController) { - object : OnboardingNavActions { - override fun replaceOnboardingWithHome() { - navController.navigate(Route.Home) { - // 온보딩 흐름 전체를 stack 에서 비우고 Home 진입 — 뒤로가기로 온보딩으로 못 돌아가게(앱 종료). - popUpTo(0) { inclusive = true } - } - } - - override fun replaceLoginWithWelcome() { - navController.navigate(OnboardingRoute.WelcomeRoute) { - // 소셜 신규 가입자 — Login(과 그 아래 Welcome)을 비우고 새 Welcome 진입. 뒤로가기로 Login 에 못 돌아가게. - popUpTo { inclusive = true } - launchSingleTop = true - } - } - - override fun navigateToSignUp() { - navController.navigate(OnboardingRoute.SignUpRoute) - } - - override fun navigateToLogin() { - navController.navigate(OnboardingRoute.LoginRoute) - } - - override fun navigateToReceivedRecords() { - navController.navigate(Route.Receiver) - } - - override fun replaceLoginWithSignUp() { - navController.navigate(OnboardingRoute.SignUpRoute) { - // Login 화면을 SignUp 으로 교체 — 뒤로가기 시 Login 으로 돌아가지 않고 그 이전(Welcome) 으로. - popUpTo { inclusive = true } - } - } - - override fun popBack() { - navController.popBackStack() - } - - override fun navigateToFindId() { - navController.navigate(OnboardingRoute.FindIdRoute) - } - - override fun proceedToSignUpResidentNumber() { - navController.navigate(OnboardingRoute.SignUpResidentNumberRoute) - } - - override fun proceedToSignUpPassword() { - navController.navigate(OnboardingRoute.SignUpPasswordRoute) - } - - override fun proceedToTerms() { - navController.navigate(OnboardingRoute.TermsRoute) - } - - override fun proceedToProfile() { - navController.navigate(OnboardingRoute.ProfileRoute) - } - - override fun navigateToTermsDetail() { - navController.navigate(OnboardingRoute.TermsDetailRoute) - } - } - } - @Composable fun rememberMindRecordNavActions(navController: NavController): MindRecordNavActions = remember(navController) { @@ -168,7 +93,7 @@ fun rememberTimeLetterNavActions(navController: NavController): TimeLetterNavAct remember(navController) { object : TimeLetterNavActions { override fun onSettingClick() { - navController.navigate(Route.Setting) + navController.navigate(Route.Setting()) } override fun onNavigateToWrite() { @@ -217,14 +142,16 @@ fun rememberTimeLetterNavActions(navController: NavController): TimeLetterNavAct } } +/** + * 설정 로컬 스택이 셸에 남긴 이동 (#1695). + * + * 그래프 안의 push/pop 은 `SettingNavHost` 가 로컬 백스택으로 직접 처리하므로, 여기엔 인증 상태가 + * 바뀐 뒤 루트 스택을 비우는 둘만 남는다. + */ @Composable -fun rememberSettingNavActions(appState: AppState): SettingNavActions = +fun rememberSettingExternalActions(appState: AppState): SettingExternalActions = remember(appState) { - object : SettingNavActions { - override fun onSettingBack() { - appState.navController.popBackStack() - } - + object : SettingExternalActions { override fun onLogoutSuccess() { appState.navController.navigate(Route.Onboarding) { // 로그아웃 — 인증 이후 모든 stack 비우고 Onboarding 진입. 뒤로가기로 로그인 상태 화면에 못 돌아가게. @@ -232,130 +159,12 @@ fun rememberSettingNavActions(appState: AppState): SettingNavActions = } } - override fun onNavigateToWithdrawGuide() { - appState.navController.navigate(SettingRoute.WithdrawGuideRoute) - } - - override fun onNavigateToWithdrawConfirm() { - appState.navController.navigate(SettingRoute.WithdrawConfirmRoute) - } - - override fun onWithdrawGuideBack() { - appState.navController.popBackStack() - } - - override fun onWithdrawConfirmBack() { - appState.navController.popBackStack() - } - override fun onWithdrawSuccess() { appState.navController.navigate(Route.Onboarding) { // 탈퇴 — 계정 사라진 상태라 stack 전체 비우고 Onboarding 진입. 뒤로가기로 인증된 화면에 못 돌아가게. popUpTo(0) { inclusive = true } } } - - override fun onNavigateToProfileEdit() { - appState.navController.navigate(SettingRoute.ProfileEditRoute) - } - - override fun onProfileEditBack() { - appState.navController.popBackStack() - } - - override fun onNavigateToLinkedAccount() { - appState.navController.navigate(SettingRoute.LinkedAccountRoute) - } - - override fun onLinkedAccountBack() { - appState.navController.popBackStack() - } - - override fun onNavigateToNotification() { - appState.navController.navigate(SettingRoute.NotificationRoute) - } - - override fun onNotificationBack() { - appState.navController.popBackStack() - } - - override fun onNavigateToRecipientList() { - appState.navController.navigate(SettingRoute.RecipientListRoute()) - } - - override fun onNavigateToRecipientListForDeliveryConditions() { - appState.navController.navigate( - SettingRoute.RecipientListRoute(selectForDeliveryConditions = true), - ) - } - - override fun onRecipientListBack() { - appState.navController.popBackStack() - } - - override fun onNavigateToRecipientRegister() { - appState.navController.navigate(SettingRoute.RecipientRegisterRoute) - } - - override fun onRecipientRegisterBack() { - appState.navController.popBackStack() - } - - override fun onNavigateToRecipientEdit(receiverId: Long) { - appState.navController.navigate(SettingRoute.RecipientEditRoute(receiverId)) - } - - override fun onRecipientEditBack() { - appState.navController.popBackStack() - } - - override fun onNavigateToAfterDelivery(receiverId: Long) { - appState.navController.navigate(SettingRoute.AfterDeliveryRoute(receiverId)) - } - - override fun onAfterDeliveryBack() { - appState.navController.popBackStack() - } - - override fun onNavigateToPasskey() { - appState.navController.navigate(SettingRoute.PasskeyRoute) - } - - override fun onPasskeyBack() { - appState.navController.popBackStack() - } - - override fun onNavigateToPasskeyMaking() { - appState.navController.navigate(SettingRoute.PasskeyMakingRoute) - } - - override fun onPasskeyMakingBack() { - appState.navController.popBackStack() - } - - override fun onNavigateToPasskeyPassword() { - appState.navController.navigate(SettingRoute.PasskeyPasswordRoute) - } - - override fun onPasskeyPasswordBack() { - appState.navController.popBackStack() - } - - override fun onNavigateToAppLock() { - appState.navController.navigate(SettingRoute.AppLockSetupRoute) - } - - override fun onAppLockBack() { - appState.navController.popBackStack() - } - - override fun onNavigateToNotice() { - appState.navController.navigate(SettingRoute.NoticeRoute) - } - - override fun onNoticeBack() { - appState.navController.popBackStack() - } } } @@ -373,7 +182,8 @@ fun rememberHomeTabActions( // 나오고 사용자는 다시 등록 화면을 찾아야 한다. 상태를 해소하는 화면으로 보낸다. // // 목적지가 등록인지 목록인지는 기획 확정 전이다 (#506) — docs/qa/assumptions.md 참고. - appState.navController.navigate(SettingRoute.RecipientRegisterRoute) + // 등록 화면은 설정 로컬 스택 안에 있어 host 에 시작점으로 알린다 (#1695). + appState.navController.navigate(Route.Setting(startWithRecipientRegistration = true)) } override fun onAnswerClick() { @@ -406,213 +216,79 @@ fun rememberHomeTabActions( appState.navController.navigate(Route.MemorySpace) } - override fun onSettingClick() { - appState.navController.navigate(Route.Setting) - } - - override fun onRetryLoad() { - onRetryLoadState() - } - } - } -} - -/** - * Afternote 서브그래프에 넘길 루트 레벨 네비게이션 [AfternoteNavActions] 구현체. - */ -@Composable -fun rememberAfternoteNavActions( - appState: AppState, - onFingerprintAuthError: (String) -> Unit, -): AfternoteNavActions { - val onFingerprintErrorState by rememberUpdatedState(onFingerprintAuthError) - return remember(appState) { - object : AfternoteNavActions { - override fun navigateToBottomTab(tab: BottomNavTab) { - appState.navigateToBottomBarRoute(tab.route) - } - - override fun popBack() { - appState.navController.popBackStack() - } - - override fun navigateToAfternoteDetail(itemId: Long) { - appState.navController.navigate(AfternoteRoute.DetailRoute(itemId = itemId)) - } - - override fun navigateToNewEditor(initialType: AfternoteType) { - appState.navController.navigate(AfternoteRoute.EditorFlowRoute(initialType = initialType)) - } - - override fun navigateToEditorForEdit( - itemId: Long, - initialType: AfternoteType, - ) { + override fun onMemoriesRecordDetailClick(recordId: Long) { + // 카드가 싣는 것은 가장 최근 **데일리질문 답변** 한 건이라 `isDiary = false` 다. + // 카드가 나중에 일기까지 포함하게 되면 종류를 함께 넘겨야 한다 (#793). appState.navController.navigate( - AfternoteRoute.EditorFlowRoute( - itemId = itemId, - initialType = initialType, - ), + MindRecordRoute.RecordDetailRoute(recordId = recordId, isDiary = false), ) } - override fun navigateToMemorialPlaylist() { - appState.navController.navigate(AfternoteRoute.MemorialPlaylistRoute) - } - - override fun navigateToSelectReceiver() { - appState.navController.navigate(AfternoteRoute.SelectReceiverRoute) - } - - override fun popBackWithSelectedReceiver(receiverId: Long) { - // 선택 화면이 현재 destination 이므로 previousBackStackEntry 가 에디터다. - // 에디터는 복귀 시 SELECTED_RECEIVER_ID_KEY 를 읽고 지운다 (AfternoteNavGraphEditor). - appState.navController.previousBackStackEntry - ?.savedStateHandle - ?.set(SELECTED_RECEIVER_ID_KEY, receiverId) - appState.navController.popBackStack() - } - - override fun navigateToAddSong() { - appState.navController.navigate(AfternoteRoute.AddSongRoute) - } - - override fun replaceFingerprintLoginWithAfternoteHome() { - appState.navController.navigate(AfternoteRoute.AfternoteHomeRoute) { - // 지문 로그인 화면 pop — 인증 성공 후 뒤로가기로 다시 입력 요구하는 화면에 못 돌아가게. - popUpTo { inclusive = true } - launchSingleTop = true - } - } - - override fun onFingerprintAuthFailed(message: String) { - onFingerprintErrorState(message) - } - - override fun popToAfternoteHome() { - appState.navController.navigate(AfternoteRoute.AfternoteHomeRoute) { - // 저장 성공 — Home 위의 화면들(에디터·미디어 선택 등) 만 pop, Home 자체는 유지(inclusive=false). - // launchSingleTop 으로 새 Home 인스턴스 생성 대신 기존 Home 위로 복귀. - popUpTo { inclusive = false } - launchSingleTop = true - } + override fun onSettingClick() { + appState.navController.navigate(Route.Setting()) } - override fun navigateToSetting() { - appState.navController.navigate(Route.Setting) + override fun onRetryLoad() { + onRetryLoadState() } } } } /** - * 수신자 서브그래프에 넘길 그래프 내부 [ReceiverNavActions] 구현체. + * 온보딩 로컬 스택이 셸에 남긴 이동 (#1698). * - * 본인 확인 캐시 분기(Intro→MasterKey)는 nested 그래프 진입 후 `DeliveryVerificationFlowViewModel` 에서 - * 자동 처리되므로(#220) 본 actions 는 순수 네비게이션만 수행한다. masterKey 같은 Repository 사이드이펙트는 - * 각 화면 ViewModel 에서 처리. + * 그래프 안의 push/pop 은 `OnboardingNavHost` 가 로컬 백스택으로 직접 처리하므로, 여기엔 다른 + * 소관 그래프로 넘어가는 둘만 남는다. */ @Composable -fun rememberReceivedAfternoteNavActions(appState: AppState): ReceivedAfternoteNavActions = +fun rememberOnboardingExternalActions(appState: AppState): OnboardingExternalActions = remember(appState) { - object : ReceivedAfternoteNavActions { - override fun popBack() { - appState.navController.popBackStack() - } - - // 수신 상세의 "애프터노트 확인하기" 진입점(#777). 사용자는 목록에서 상세로 들어와 있는 것이 - // 보통이므로 그냥 navigate 하면 [목록 → 상세 → 목록] 이 쌓여 뒤로가기가 방금 나온 상세로 - // 되돌아간다. popUpTo 로 기존 목록까지 걷어내고, 목록이 백스택에 없는 진입(딥링크 등)에서는 - // popUpTo 가 무시되고 push 만 일어나 양쪽 모두 맞는다. - override fun navigateToList() { - appState.navController.navigate(ReceivedAfternoteRoute.ListRoute) { - popUpTo(ReceivedAfternoteRoute.ListRoute) { inclusive = false } - launchSingleTop = true + object : OnboardingExternalActions { + override fun replaceOnboardingWithHome() { + appState.navController.navigate(Route.Home) { + // 온보딩 흐름 전체를 stack 에서 비우고 Home 진입 — 뒤로가기로 온보딩으로 못 돌아가게(앱 종료). + popUpTo(0) { inclusive = true } } } - override fun navigateToDetail(afternoteId: Long) { - appState.navController.navigate( - ReceivedAfternoteRoute.DetailRoute(afternoteId = afternoteId), - ) - } - - override fun navigateToMemorialPlaylist(afternoteId: Long) { - appState.navController.navigate( - ReceivedAfternoteRoute.MemorialPlaylistRoute(afternoteId = afternoteId), - ) + override fun navigateToReceivedRecords() { + appState.navController.navigate(Route.Receiver) } } } +/** + * 애프터노트 로컬 스택이 셸에 남긴 이동·신호 (#1698). + * + * 지문 인증 실패 문구는 화면이 아니라 앱 셸의 snackbar 가 띄우므로 콜백으로 올라온다. + */ @Composable -fun rememberReceiverNavActions(appState: AppState): ReceiverNavActions = - remember(appState) { - object : ReceiverNavActions { - override fun popBack() { - appState.navController.popBackStack() - } - - override fun navigateToSenderRegistration() { - appState.navController.navigate(ReceiverRoute.SenderRegistrationRoute) - } - - override fun navigateToSenderDetail(senderId: String) { - appState.navController.navigate( - ReceiverRoute.SenderDetailRoute(senderId = senderId), - ) - } - - override fun navigateToDeliveryVerificationFlow(senderId: String) { - // nested 열람 신청 흐름 그래프 진입. 본인 확인 캐시 분기는 IntroRoute 의 LaunchedEffect 가 처리. - appState.navController.navigate( - ReceiverRoute.DeliveryVerificationFlowRoute(senderId = senderId), - ) - } - - override fun navigateToIdentityVerificationEmail() { - appState.navController.navigate(ReceiverRoute.IdentityVerificationEmailRoute) - } - - override fun proceedToMasterKey() { - // 두 진입 경로 (Intro 의 캐시 hit jump / Email 인증 성공) 공통 — Intro 까지 pop 해서 - // 뒤로가기로 본인 확인 화면들에 돌아오지 않게. - appState.navController.navigate(ReceiverRoute.MasterKeyRoute) { - popUpTo { inclusive = true } - } - } - - override fun proceedToDocumentUpload() { - appState.navController.navigate(ReceiverRoute.DocumentUploadRoute) { - // 마스터 키 검증 성공 직후 — MasterKey 화면 pop. 뒤로가기로 이미 검증된 마스터 키 재입력 화면에 못 돌아가게. - popUpTo { inclusive = true } - } - } - - override fun proceedToDeliveryVerificationComplete() { - appState.navController.navigate(ReceiverRoute.DeliveryVerificationCompleteRoute) { - // 서류 제출 성공 직후 — DocumentUpload pop. 뒤로가기로 제출 끝난 업로드 화면에 못 돌아가게. - popUpTo { inclusive = true } - } +fun rememberAfternoteExternalActions( + appState: AppState, + onFingerprintAuthError: (String) -> Unit, +): AfternoteExternalActions { + val onFingerprintErrorState by rememberUpdatedState(onFingerprintAuthError) + return remember(appState) { + object : AfternoteExternalActions { + override fun navigateToBottomTab(tab: BottomNavTab) { + appState.navigateToBottomBarRoute(tab.route) } - override fun popToReceivedRecords() { - // 받은 기록함을 남기고 신청 흐름 화면들(완료/서류/마스터 키)을 모두 pop. - appState.navController.navigate(ReceiverRoute.ReceivedRecordsRoute) { - popUpTo { inclusive = false } - launchSingleTop = true - } + override fun navigateToSetting() { + appState.navController.navigate(Route.Setting()) } - override fun navigateToReceiverHome() { - appState.navController.navigate(ReceiverRoute.HomeRoute) + override fun onFingerprintAuthFailed(message: String) { + onFingerprintErrorState(message) } } } +} /** - * 수신자 홈에서 발생하는 다른 top-level Route(설정/마음의 기록/타임레터)와 - * 수신자 그래프 내부(애프터노트 목록) 이동을 묶은 [ReceiverHomeActions]. + * 수신자 홈에서 다른 top-level Route(마음의 기록/타임레터/수신 애프터노트)로 빠지는 + * 이동을 묶은 [ReceiverHomeActions]. * * 마음의 기록은 발신자/수신자 화면이 분리돼 수신자 진입은 [Route.ReceiverMindRecord] 로 라우팅한다. * TimeLetter 는 현재 작성자용 화면만 있어 수신자 진입 시 동일 화면이 노출된다 — 분기 후속 작업. @@ -623,8 +299,6 @@ fun rememberReceiverHomeActions(appState: AppState): ReceiverHomeActions = ReceiverHomeActions( onNavigateToMindRecord = { appState.navController.navigate(Route.ReceiverMindRecord) }, onNavigateToTimeLetter = { appState.navController.navigate(Route.TimeLetter) }, - onNavigateToAfternote = { - appState.navController.navigate(ReceivedAfternoteRoute.ListRoute) - }, + onNavigateToAfternote = { appState.navController.navigate(Route.ReceivedAfternote) }, ) } diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppState.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppState.kt index d90b35a136..f7e32ee208 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppState.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppState.kt @@ -10,7 +10,6 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.afternote.core.ui.Route import com.afternote.core.ui.bottombar.BottomNavTab -import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import com.afternote.feature.timeletter.presentation.navigation.TimeLetterRoute // 컴포즈 엔진은 커스텀 클래스에 대해 변경 여부를 확신할 수 없어 리컴포지션 스킵 불가 @@ -20,9 +19,21 @@ import com.afternote.feature.timeletter.presentation.navigation.TimeLetterRoute class AppState( val navController: NavHostController, ) { - fun shouldShowBottomBar(currentDestination: NavDestination?): Boolean = - bottomBarRoutes.any { route -> - currentDestination?.hasRoute(route) == true + /** + * @param isAfternoteStackAtRoot 애프터노트 로컬 Nav3 스택이 바닥(지문 관문 또는 홈)인지. + * 그 그래프는 이제 Nav2 destination 이 [Route.Afternote] 하나뿐이라 destination 만으로는 + * 상세·에디터가 쌓였는지 알 수 없다 — 깊이를 아는 host 가 올려 준다 (#1698). + */ + fun shouldShowBottomBar( + currentDestination: NavDestination?, + isAfternoteStackAtRoot: Boolean, + ): Boolean = + if (currentDestination?.hasRoute(Route.Afternote::class) == true) { + isAfternoteStackAtRoot + } else { + bottomBarRoutes.any { route -> + currentDestination?.hasRoute(route) == true + } } fun getCurrentNavTab(currentDestination: NavDestination?): BottomNavTab = @@ -40,16 +51,14 @@ class AppState( } ?: BottomNavTab.HOME // 바텀바를 보여줄 화면 목록 - // 여기에 없는 화면(상세, 에디터 등)에서는 바텀바가 숨겨짐 + // 여기에 없는 화면(상세, 에디터 등)에서는 바텀바가 숨겨짐. + // 애프터노트는 로컬 스택 깊이로 판정하므로 이 목록이 아니라 shouldShowBottomBar 가 직접 다룬다. private val bottomBarRoutes = setOf( Route.Home::class, Route.MindRecord::class, Route.TimeLetter::class, TimeLetterRoute.TimeLetterHomeRoute::class, - Route.Afternote::class, - AfternoteRoute.AfternoteHomeRoute::class, - AfternoteRoute.FingerprintLoginRoute::class, ) fun navigateToBottomBarRoute(route: Route) { @@ -62,8 +71,8 @@ class AppState( // inclusive = false — Home을 백스택에 유지한다 (기본값이나 의도를 명시) } launchSingleTop = true - // 애프터노트 서브그래프의 start는 인증 화면이다. restoreState = true이면 - // 저장된 홈 등으로 복원되어 인증을 건너뛴다. 진입점은 피처 NavGraph가 단일로 결정한다. + // 애프터노트 로컬 스택은 인증 화면에서 시작한다. restoreState = true이면 + // 인증 뒤 저장된 로컬 스택(홈 등)이 복원되어 인증을 건너뛴다. 진입점은 피처 NavHost가 단일로 결정한다. restoreState = route != Route.Afternote } } diff --git a/app/src/main/java/com/afternote/afternote_fe/notification/DailyNotificationScheduleSynchronizer.kt b/app/src/main/java/com/afternote/afternote_fe/notification/DailyNotificationScheduleSynchronizer.kt new file mode 100644 index 0000000000..d1b3703c90 --- /dev/null +++ b/app/src/main/java/com/afternote/afternote_fe/notification/DailyNotificationScheduleSynchronizer.kt @@ -0,0 +1,74 @@ +package com.afternote.afternote_fe.notification + +import android.content.Context +import com.afternote.core.common.notification.NotificationScheduler +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.repository.auth.AuthRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.distinctUntilChanged +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 데일리 알림(매일 09:00 로컬 알림) 예약을 로그인 상태에 맞춘다 (#2146). + * + * 알림 문구가 계정의 기록을 재촉하는 말이라, 기록할 계정이 없는 기기에는 예약을 두지 않는다. + * 로그인이 확정되면 예약하고 로그아웃되면 지운다. 로그아웃, 탈퇴, 세션 강제 만료는 모두 토큰 + * 저장소를 비워 [AuthRepository.isLoggedIn] 을 false 로 바꾸므로 이 관찰 하나가 세 경로를 다 받는다. + * 로그아웃 상태로 앱을 켜면 첫 방출이 false 라, 이전 빌드가 세션 없이 넣어 둔 예약도 여기서 지워진다. + * + * 예약은 `ExistingPeriodicWorkPolicy.KEEP` 이다. 로그인한 채 앱을 켤 때마다 다시 불러도 다음 발화 + * 시각은 밀리지 않는다. + * + * ### 왜 App Startup Initializer 가 아닌가 + * + * 전에는 `DailyNotificationInitializer` 가 세션을 보지 않고 예약했다. 세션은 Hilt 로 만든 저장소에 + * 있고 Initializer 는 Hilt 에 닿으면 안 되므로(`StartupInitializerHiltKonsistTest`) 판정 자리를 + * `GlobalApplication.onCreate` 로 옮겼다. + * + * 옛 Initializer 는 Application.onCreate 에서 부르면 WorkManager 가 먼저 준비된다는 보장이 없다며 + * `WorkManagerInitializer` 를 선행 의존으로 걸었다. 그 보장은 이 자리에서도 선다. work-runtime 라이브러리 + * 매니페스트가 `WorkManagerInitializer` 를 앱의 `InitializationProvider` 에 병합해 두고, ContentProvider 는 + * Application.onCreate 보다 먼저 초기화된다. + */ +@Singleton +class DailyNotificationScheduleSynchronizer + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val authRepository: AuthRepository, + private val errorReporter: ErrorReporter, + ) { + /** + * 로그인 상태를 계속 지켜본다. 앱 프로세스가 사는 동안 돈다. + * + * 방출 하나를 처리하다 난 예외는 기록만 하고 관찰은 이어 간다. 여기서 `collect` 가 끝나면 + * 그 프로세스에서는 이후 로그아웃이 예약을 지우지 못한다. + */ + suspend fun observeLogin() { + authRepository.isLoggedIn + .distinctUntilChanged() + .collect { isLoggedIn -> + runCatchingCancellable { + if (isLoggedIn) { + NotificationScheduler.scheduleDailyNotification( + context, + hour = DAILY_HOUR, + minute = DAILY_MINUTE, + ) + } else { + NotificationScheduler.cancelDailyNotification(context) + } + }.onFailure { error -> + errorReporter.recordFailure(error, mapOf("stage" to STAGE_SYNC)) + } + } + } + } + +// TODO: 설정(DataStore 등)에서 시·분 읽기. 알림 시각은 PM 알림 문서가 정한다(0920 회의 기준 미정). +private const val DAILY_HOUR = 9 +private const val DAILY_MINUTE = 0 + +private const val STAGE_SYNC = "daily_notification_sync" diff --git a/app/src/main/java/com/afternote/afternote_fe/notification/NotificationIntentContract.kt b/app/src/main/java/com/afternote/afternote_fe/notification/NotificationIntentContract.kt index d6fcf99cd2..90bd81c398 100644 --- a/app/src/main/java/com/afternote/afternote_fe/notification/NotificationIntentContract.kt +++ b/app/src/main/java/com/afternote/afternote_fe/notification/NotificationIntentContract.kt @@ -1,6 +1,7 @@ package com.afternote.afternote_fe.notification import android.content.Intent +import com.afternote.core.common.notification.NotificationDestination import com.afternote.core.common.notification.NotificationPendingIntentFactory /** 앱이 직접 생성하는 알림 진입 출처. BE FCM data key와는 별개의 내부 계약이다. */ @@ -21,12 +22,14 @@ internal enum class NotificationEntrySource( /** * 알림 탭이 앱의 cold/warm 진입 경계까지 도착했다는 내부 이벤트. * - * 실제 화면 목적지는 #1111의 BE payload 계약이 확정된 뒤 별도 adapter에서 추가한다. 이 단계에서는 - * 출처와 occurrence만 보존해 Activity 수명주기와 PendingIntent identity를 목적지 계약과 분리한다. + * [destination]은 화면 이동의 목적지이지 이벤트의 식별자가 아니다. 그래서 [identityKey]에는 + * 들어가지 않는다 — 같은 발생을 다시 게시하며 목적지만 바뀌어도 「이미 소비한 알림」이라는 + * 판정은 유지돼야 한다. */ internal data class NotificationEntryRequest( val source: NotificationEntrySource, val occurrenceId: String, + val destination: NotificationDestination, ) { /** 서로 다른 source가 같은 occurrence 문자열을 사용해도 소비 상태가 충돌하지 않는 내부 키. */ val identityKey: String = @@ -50,12 +53,23 @@ internal object NotificationIntentContract { runCatching { intent.getStringExtra(EXTRA_NOTIFICATION_OCCURRENCE_TOKEN) }.getOrNull(), + rawDestination = + runCatching { + intent.getStringExtra(EXTRA_NOTIFICATION_DESTINATION) + }.getOrNull(), ) - internal fun resolve( + /** + * source·occurrence는 **거부** 조건이고 목적지는 **폴백** 조건이다. 앞의 둘이 깨지면 이 + * Intent가 우리가 만든 알림인지조차 확신할 수 없어 이벤트로 승격하지 않는다. 반면 목적지는 + * 이미 확인된 알림의 부가 정보라, 계약 밖 값이 와도 탭 자체는 살리고 [FALLBACK_DESTINATION] + * 으로 떨어뜨린다 — 알림을 눌렀는데 아무 일도 안 일어나는 편이 더 나쁘다. + */ + private fun resolve( isNotificationEntry: Boolean, rawSource: String?, occurrenceId: String?, + rawDestination: String?, ): NotificationEntryRequest? { if (!isNotificationEntry) return null @@ -69,13 +83,21 @@ internal object NotificationIntentContract { return NotificationEntryRequest( source = source, occurrenceId = id, + destination = + NotificationDestination.fromContractValue(rawDestination) + ?: FALLBACK_DESTINATION, ) } + /** 목적지 키가 없거나 계약 밖 값일 때 여는 화면. */ + private val FALLBACK_DESTINATION = NotificationDestination.HOME + private const val EXTRA_NOTIFICATION_ENTRY = NotificationPendingIntentFactory.EXTRA_NOTIFICATION_ENTRY private const val EXTRA_NOTIFICATION_SOURCE = NotificationPendingIntentFactory.EXTRA_NOTIFICATION_SOURCE private const val EXTRA_NOTIFICATION_OCCURRENCE_TOKEN = NotificationPendingIntentFactory.EXTRA_NOTIFICATION_OCCURRENCE_TOKEN + private const val EXTRA_NOTIFICATION_DESTINATION = + NotificationPendingIntentFactory.EXTRA_NOTIFICATION_DESTINATION } diff --git a/app/src/main/java/com/afternote/afternote_fe/notification/NotificationPermissionContract.kt b/app/src/main/java/com/afternote/afternote_fe/notification/NotificationPermissionContract.kt new file mode 100644 index 0000000000..70df65501e --- /dev/null +++ b/app/src/main/java/com/afternote/afternote_fe/notification/NotificationPermissionContract.kt @@ -0,0 +1,27 @@ +package com.afternote.afternote_fe.notification + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.core.ui.mvi.UiState + +/** 알림 권한 Effect가 현재 기기에서 요청해야 하는지 소비하는 상태. */ +internal data class NotificationPermissionUiState( + val shouldRequest: Boolean = false, +) : UiState + +/** Effect의 lifecycle과 권한 확인 결과를 ViewModel에 전달한다. */ +internal sealed interface NotificationPermissionIntent : MviIntent { + data object ObservationStarted : NotificationPermissionIntent + + data object ObservationStopped : NotificationPermissionIntent + + /** 허용·거부 결과를 받았거나 시스템에서 이미 허용된 것을 확인했다. */ + data object RecordRequest : NotificationPermissionIntent +} + +/** 로그인·기기 저장소 관찰 결과. 요청 대상 판정은 기존 두 흐름을 함께 읽는다. */ +internal sealed interface NotificationPermissionReducerEvent : ReducerEvent { + data class EligibilityChanged( + val shouldRequest: Boolean, + ) : NotificationPermissionReducerEvent +} diff --git a/app/src/main/java/com/afternote/afternote_fe/notification/NotificationPermissionEffect.kt b/app/src/main/java/com/afternote/afternote_fe/notification/NotificationPermissionEffect.kt index 39a7462a24..475b71100e 100644 --- a/app/src/main/java/com/afternote/afternote_fe/notification/NotificationPermissionEffect.kt +++ b/app/src/main/java/com/afternote/afternote_fe/notification/NotificationPermissionEffect.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.afternote_fe.R import kotlinx.coroutines.launch @@ -32,7 +33,7 @@ import kotlinx.coroutines.launch * 같은 목적지를 갖고 있어, 새 안내 화면을 만드는 대신 그 경로로 잇는다. */ @Composable -fun NotificationPermissionEffect( +internal fun NotificationPermissionEffect( snackbarHostState: SnackbarHostState, viewModel: NotificationPermissionViewModel = hiltViewModel(), ) { @@ -40,14 +41,21 @@ fun NotificationPermissionEffect( val context = LocalContext.current val scope = rememberCoroutineScope() - val shouldRequest by viewModel.shouldRequest.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val deniedMessage = stringResource(R.string.notification_permission_denied_message) val deniedActionLabel = stringResource(R.string.notification_permission_denied_action) + LifecycleStartEffect(viewModel) { + viewModel.onIntent(NotificationPermissionIntent.ObservationStarted) + onStopOrDispose { + viewModel.onIntent(NotificationPermissionIntent.ObservationStopped) + } + } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> // 허용이든 거부든 물었다는 사실은 남긴다 — 매 실행마다 다시 묻지 않기 위해서다. - viewModel.markRequested() + viewModel.onIntent(NotificationPermissionIntent.RecordRequest) if (isGranted) return@rememberLauncherForActivityResult scope.launch { @@ -66,8 +74,8 @@ fun NotificationPermissionEffect( } } - LaunchedEffect(shouldRequest) { - if (!shouldRequest) return@LaunchedEffect + LaunchedEffect(uiState.shouldRequest) { + if (!uiState.shouldRequest) return@LaunchedEffect val alreadyGranted = ContextCompat.checkSelfPermission( @@ -76,7 +84,7 @@ fun NotificationPermissionEffect( ) == PackageManager.PERMISSION_GRANTED if (alreadyGranted) { // 사용자가 시스템 설정에서 먼저 켠 경우. 물을 이유가 없으므로 기록만 남기고 끝낸다. - viewModel.markRequested() + viewModel.onIntent(NotificationPermissionIntent.RecordRequest) return@LaunchedEffect } diff --git a/app/src/main/java/com/afternote/afternote_fe/notification/NotificationPermissionViewModel.kt b/app/src/main/java/com/afternote/afternote_fe/notification/NotificationPermissionViewModel.kt index e7f6953568..d3e4b7a170 100644 --- a/app/src/main/java/com/afternote/afternote_fe/notification/NotificationPermissionViewModel.kt +++ b/app/src/main/java/com/afternote/afternote_fe/notification/NotificationPermissionViewModel.kt @@ -1,15 +1,14 @@ package com.afternote.afternote_fe.notification -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.auth.AuthRepository +import com.afternote.core.ui.mvi.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -22,32 +21,71 @@ import javax.inject.Inject * 기존 «기기 알림 설정» 행이다(새 UX 를 만들지 않는다). */ @HiltViewModel -class NotificationPermissionViewModel +internal class NotificationPermissionViewModel @Inject constructor( authRepository: AuthRepository, private val store: NotificationPermissionRequestStore, - ) : ViewModel() { - val shouldRequest: StateFlow = + ) : MviViewModel( + NotificationPermissionUiState(), + ) { + private val requestEligibility = combine( authRepository.isLoggedIn, store.hasRequested, ) { isLoggedIn, hasRequested -> isLoggedIn && !hasRequested }.distinctUntilChanged() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = false, - ) + + private var observationJob: Job? = null + private var stopJob: Job? = null + + override fun onIntent(intent: NotificationPermissionIntent) { + when (intent) { + NotificationPermissionIntent.ObservationStarted -> startObservation() + NotificationPermissionIntent.ObservationStopped -> stopObservation() + NotificationPermissionIntent.RecordRequest -> markRequested() + } + } + + override fun reduce( + state: NotificationPermissionUiState, + event: NotificationPermissionReducerEvent, + ): NotificationPermissionUiState = + when (event) { + is NotificationPermissionReducerEvent.EligibilityChanged -> state.copy(shouldRequest = event.shouldRequest) + } + + private fun startObservation() { + stopJob?.cancel() + if (observationJob?.isActive == true) return + observationJob = + viewModelScope.launch { + requestEligibility.collect { shouldRequest -> + dispatch(NotificationPermissionReducerEvent.EligibilityChanged(shouldRequest)) + } + } + } + + private fun stopObservation() { + if (stopJob?.isActive == true) return + stopJob = + viewModelScope.launch { + // 기존 WhileSubscribed(5_000)처럼 잠깐의 화면 중단에는 구독을 유지한다. + delay(OBSERVATION_STOP_TIMEOUT_MILLIS) + observationJob?.cancel() + } + } /** * 다이얼로그를 띄운 사실을 기록한다. 기록 실패(디스크 오류)는 삼킨다 — 다음 실행에서 한 번 더 * 묻게 될 뿐이고, 시스템이 두 번 거부한 뒤에는 다이얼로그 자체를 띄우지 않는다. */ - fun markRequested() { + private fun markRequested() { viewModelScope.launch { runCatchingCancellable { store.markRequested() } } } } + +private const val OBSERVATION_STOP_TIMEOUT_MILLIS = 5_000L diff --git a/app/src/main/java/com/afternote/afternote_fe/startup/DailyNotificationInitializer.kt b/app/src/main/java/com/afternote/afternote_fe/startup/DailyNotificationInitializer.kt deleted file mode 100644 index fe1400f0d4..0000000000 --- a/app/src/main/java/com/afternote/afternote_fe/startup/DailyNotificationInitializer.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.afternote.afternote_fe.startup - -import android.content.Context -import androidx.startup.Initializer -import androidx.work.WorkManagerInitializer -import com.afternote.core.common.notification.NotificationScheduler - -/** - * 앱 기동 시 일일 알림 WorkManager 예약. - * - * [WorkManagerInitializer]를 의존성에 넣어 [androidx.work.WorkManager]가 먼저 준비된 뒤 스케줄링하도록 한다. - * `Application.onCreate()`에서 [NotificationScheduler]를 직접 불러서는 이 선후 보장을 얻지 못한다. - */ -class DailyNotificationInitializer : Initializer { - override fun create(context: Context) { - // TODO: 설정(DataStore 등)에서 시·분 읽기. - NotificationScheduler.scheduleDailyNotification( - context.applicationContext, - hour = 9, - minute = 0, - ) - } - - override fun dependencies(): List>> = listOf(WorkManagerInitializer::class.java) -} diff --git a/app/src/main/java/com/afternote/afternote_fe/update/ForceUpdateGate.kt b/app/src/main/java/com/afternote/afternote_fe/update/ForceUpdateGate.kt new file mode 100644 index 0000000000..14b76884af --- /dev/null +++ b/app/src/main/java/com/afternote/afternote_fe/update/ForceUpdateGate.kt @@ -0,0 +1,136 @@ +package com.afternote.afternote_fe.update + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.repository.appversion.AppVersionRepository +import com.afternote.core.model.appversion.AppVersionCheck +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** 관문이 사용자에게 보여 줄 것 — 보낼 스토어 주소 하나다. */ +data class ForceUpdatePrompt( + val storeUrl: String, +) + +/** + * 서버가 «이 버전은 더 못 쓴다» 고 말할 때 사용자를 스토어로 보내는 관문 (#1539). + * + * `GET /app/version` 은 #423 이 추가한 뒤로 소비처가 0건이었다. 서버는 판정을 들고 있는데 + * 앱이 물어보지 않아, 구버전 사용자가 계속 쓰다 다른 곳에서 깨졌다. + * + * ### fail-open — 못 물어봤으면 통과시킨다 + * + * 조회 실패·타임아웃·파싱 실패는 전부 «업데이트 불필요» 와 같게 다룬다. 서버가 죽었다는 + * 이유로 앱 전체가 잠기면 강제 업데이트가 막으려던 것보다 큰 피해가 난다. + * BE `@Operation` 설명은 반대로 «API 호출 실패 시에도 스플래시 진입을 막는다» 고 적고 있지만, + * 이 저장소는 #1539 완료 조건대로 fail-open 을 택했다. + * + * 지연도 같은 원칙으로 닫혀 있다 — 이 관문은 시작을 **지연시키지 않는다**. 앱은 평소대로 + * 뜨고, 판정이 돌아왔을 때만 그 위에 팝업이 얹힌다. 그래서 응답이 영영 오지 않아도 + * 사용자는 아무것도 잃지 않는다. + * + * ### 보낼 곳이 없으면 걸지 않는다 + * + * `updateRequired=true` 라도 **실제로 그 설치본을 갱신할 수 있는 경로가 있을 때만** 막는다. + * 두 조건을 각각 확인한다. + * + * 1. 서버가 준 `storeUrl` 이 실제 Play Store 주소인가 ([playStoreTargetOrNull]). + * dev 서버는 지금 `APP_ANDROID_STORE_URL` 미설정으로 플레이스홀더를 들고 있다. + * 거기로 보내면 사용자는 갈 곳 없이 팝업만 마주한다. + * 2. 이 빌드가 스토어로 갱신될 수 있는 산출물인가 ([InstalledBuild.storeDistributed]). + * Play 빌드 단계에서 명시한 배포 설정을 사용한다. 로컬·Firebase 빌드는 versionCode를 + * 올리더라도 이 설정이 없으면 관문을 걸지 않는다. + * + * 두 조건이 다 갖춰지는 시점은 Play 내부 테스트 트랙 배포(#852)가 서고 서버가 실 스토어 URL 을 + * 들고 나서다. 그때 이 관문은 **코드 변경 없이 스스로 켜진다**. + * + * ### 계측에서는 돌지 않는다 + * + * 유일한 기동 지점이 `GlobalApplication.onCreate` 인데, androidTest 는 그 클래스를 + * `HiltTestApplication` 으로 대체하므로 [refresh] 가 호출되지 않는다 — + * `PushTargetSynchronizer`(#1493) 와 같은 구조다. 그래도 필요하면 + * [AppVersionRepository] 가 도메인 인터페이스라 페이크로 대체할 수 있다. + */ +@Singleton +class ForceUpdateGate + @Inject + constructor( + private val appVersionRepository: AppVersionRepository, + private val installedBuild: InstalledBuild, + private val errorReporter: ErrorReporter, + ) { + private val promptState = MutableStateFlow(null) + + /** null 이면 막을 이유가 없다 — 초기값이자 fail-open 의 결과값이다. */ + val prompt: StateFlow = promptState.asStateFlow() + + /** + * 서버 판정을 한 번 받아 온다. 프로세스 기동마다 한 번 부른다. + * + * 실패해도 [prompt] 를 건드리지 않는다 — 직전 판정이 있었다면 그대로 두고, + * 없었다면 null(통과) 로 남는다. + */ + suspend fun refresh() { + val check = + appVersionRepository + .checkAndroidVersion(installedBuild.versionCode) + .onFailure { error -> + errorReporter.recordFailure(error, mapOf(KEY_STAGE to STAGE_CHECK)) + }.getOrNull() ?: return + + promptState.value = decide(check) + } + + private fun decide(check: AppVersionCheck): ForceUpdatePrompt? { + if (!check.updateRequired) return null + + val storeUrl = playStoreTargetOrNull(check.storeUrl) + if (storeUrl == null) { + // 서버가 막으라고 했는데 보낼 곳을 주지 않았다. 서버 설정 결함이므로 텔레메트리로 남기고 통과시킨다. + errorReporter.recordFailure( + UnroutableStoreUrlException(), + mapOf(KEY_STAGE to STAGE_STORE_URL), + ) + return null + } + + // 스토어가 이 설치본을 갱신할 수 없다면 막아 봐야 빠져나갈 길이 없다. + if (!installedBuild.storeDistributed) return null + + return ForceUpdatePrompt(storeUrl = storeUrl) + } + + private companion object { + const val KEY_STAGE = "stage" + const val STAGE_CHECK = "force_update_check" + const val STAGE_STORE_URL = "force_update_store_url" + } + } + +/** + * 서버가 `updateRequired=true` 와 함께 준 주소가 Play Store 로 가지 않을 때 남기는 실패 타입. + * + * [ErrorReporter] 가 문구를 지우므로 콘솔에서 이 갈래를 가르는 것은 `error_type` 과 + * `stage=force_update_store_url` 이다. 그래서 이 타입의 **이름 자체가 관측 계약**이고, + * 타입을 내보낼 이유는 그것뿐이다 — 프로덕션 사용처가 이 파일뿐이라 `private` 로 닫고 + * 테스트는 `error_type` 문자열로 계약을 고정한다 (#1678 가드). + */ +private class UnroutableStoreUrlException : RuntimeException("force update store url is not a Play Store target") + +/** + * Play Store 앱 상세로 실제로 가는 주소면 그 값을, 아니면 null 을 돌려준다. + * + * 화이트리스트로 판정하는 이유 — 「http 로 시작하니 열리겠지」 식 판정은 dev 시드의 + * `http://your-playstore-domain` 같은 플레이스홀더를 그대로 통과시킨다. 관문이 사용자를 + * 가두는 대가로 얻는 게 «갈 수 있는 곳» 이므로, 갈 수 있다고 확신할 때만 막는다. + */ +internal fun playStoreTargetOrNull(storeUrl: String?): String? { + val url = storeUrl?.trim().orEmpty() + if (!url.startsWith(MARKET_DETAILS_PREFIX) && !url.startsWith(PLAY_DETAILS_PREFIX)) return null + return url +} + +private const val MARKET_DETAILS_PREFIX = "market://details?id=" +private const val PLAY_DETAILS_PREFIX = "https://play.google.com/store/apps/details?" diff --git a/app/src/main/java/com/afternote/afternote_fe/update/ForceUpdatePopup.kt b/app/src/main/java/com/afternote/afternote_fe/update/ForceUpdatePopup.kt new file mode 100644 index 0000000000..6644fdd375 --- /dev/null +++ b/app/src/main/java/com/afternote/afternote_fe/update/ForceUpdatePopup.kt @@ -0,0 +1,28 @@ +package com.afternote.afternote_fe.update + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.afternote.afternote_fe.R +import com.afternote.core.ui.popup.Popup +import com.afternote.core.ui.popup.PopupType + +/** + * 서버가 더 이상 허용하지 않는 버전임을 알리고 스토어로 보내는 팝업 (#1539). + * + * 닫는 길을 주지 않는다 — 뒤로가기·바깥 탭이 부르는 `onDismiss` 를 비워 두면 팝업이 사라지지 + * 않는다. 취소를 허용하면 관문이 관문이 아니게 된다. 사라지는 유일한 조건은 서버가 다시 + * 「업데이트 불필요」를 말하는 것뿐이고, 그건 앱을 다시 켤 때 확인한다. + * + * 시안이 없다(Figma 정본 페이지 전수 조회 0건). 새 화면을 그리는 대신 앱 공용 팝업 + * ([PopupType.Default]) 을 쓴다 — 디자이너 확정이 붙으면 그 자리에서 교체한다. + */ +@Composable +fun ForceUpdatePopup(onConfirm: () -> Unit) { + Popup( + type = PopupType.Default, + message = stringResource(R.string.force_update_message), + onConfirm = onConfirm, + onDismiss = {}, + confirmText = stringResource(R.string.force_update_confirm), + ) +} diff --git a/app/src/main/java/com/afternote/afternote_fe/update/InstalledBuild.kt b/app/src/main/java/com/afternote/afternote_fe/update/InstalledBuild.kt new file mode 100644 index 0000000000..fe9c2872c3 --- /dev/null +++ b/app/src/main/java/com/afternote/afternote_fe/update/InstalledBuild.kt @@ -0,0 +1,18 @@ +package com.afternote.afternote_fe.update + +/** + * 지금 돌고 있는 설치본의 배포 정체성 (#1539). + * + * [ForceUpdateGate] 가 `BuildConfig` 를 직접 읽지 않고 이 값을 주입받는 이유는 단위 테스트다 — + * `BuildConfig.VERSION_CODE` 는 빌드 시각에 고정돼 관문의 분기를 테스트로 덮을 수 없다. + * + * @property versionCode 서버에 보낼 이 설치본의 versionCode. + * @property storeDistributed 이 빌드가 **스토어로 업데이트될 수 있는** 산출물인가. + * Play 빌드 단계가 `AFTERNOTE_STORE_DISTRIBUTED_BUILD=true`로 명시한 경우에만 true다. + * versionCode와 독립적이며 실제 설치 출처나 서명을 검사하는 값은 아니다. + * 로컬·Firebase 빌드는 이 설정을 주입하지 않아 관문을 걸지 않는다(`docs/play-release.md`). + */ +data class InstalledBuild( + val versionCode: Int, + val storeDistributed: Boolean, +) diff --git a/app/src/main/java/com/afternote/afternote_fe/update/di/AppUpdateModule.kt b/app/src/main/java/com/afternote/afternote_fe/update/di/AppUpdateModule.kt new file mode 100644 index 0000000000..e6de091b89 --- /dev/null +++ b/app/src/main/java/com/afternote/afternote_fe/update/di/AppUpdateModule.kt @@ -0,0 +1,22 @@ +package com.afternote.afternote_fe.update.di + +import com.afternote.afternote_fe.BuildConfig +import com.afternote.afternote_fe.update.InstalledBuild +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** `BuildConfig` 를 읽는 자리를 한 곳으로 모은다 — 관문 자체는 주입받은 값만 본다 (#1539). */ +@Module +@InstallIn(SingletonComponent::class) +object AppUpdateModule { + @Provides + @Singleton + fun provideInstalledBuild(): InstalledBuild = + InstalledBuild( + versionCode = BuildConfig.VERSION_CODE, + storeDistributed = BuildConfig.STORE_DISTRIBUTED_BUILD, + ) +} diff --git a/app/src/main/res/values-v27/themes.xml b/app/src/main/res/values-v27/themes.xml new file mode 100644 index 0000000000..c929763c79 --- /dev/null +++ b/app/src/main/res/values-v27/themes.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 79d2e4a7ba..4d991dc2ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,4 +5,6 @@ AFTERNOTE 알림이 꺼져 있어 새로운 소식을 받을 수 없어요 설정 + 새로운 버전이 나왔어요.\n업데이트 후 이용해 주세요. + 업데이트하러 가기 diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2fe44078a0..9b2b71e273 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -9,5 +9,14 @@ @color/splash_background @mipmap/ic_launcher @style/Theme.Afternotefe.Main + + + true \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 4df9255824..ed287b70d3 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,13 +1,28 @@ - + + + - \ No newline at end of file + DataStore 전량 제외 (#1876). files/datastore/.preferences_pb 에 암호화 없이 쌓인다. + 지금 있는 네 저장소는 전부 기기를 건너면 안 되는 것들이다 — Token·afternote_receiver_auth 는 + 액세스·리프레시 토큰과 수신자 마스터키를 평문으로 들고, UserProfile 은 로그인하면 서버에서 + 다시 받으며, NotificationPermission 은 「권한을 물어봤다」 는 기기 사실이라 복원되면 새 기기가 + 권한도 없이 다시 묻지도 않는 상태가 된다(#1454 의 요청 1회 보장이 무효가 된다). + 개별 파일이 아니라 디렉터리를 제외하는 이유는, 새 저장소가 늘 때 규칙을 잊어도 기본값이 + 「백업 안 함」 이어야 하기 때문이다. 백업이 필요한 저장소가 생기면 그 근거를 여기 적고 include 한다. + --> + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997b0b..41fcb42cce 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,19 +1,26 @@ - - + + + + + - + + - --> - \ No newline at end of file + diff --git a/app/src/test/java/com/afternote/afternote_fe/MainViewModelNotificationTest.kt b/app/src/test/java/com/afternote/afternote_fe/MainViewModelNotificationTest.kt index 7a9d9ca38f..c6f553db27 100644 --- a/app/src/test/java/com/afternote/afternote_fe/MainViewModelNotificationTest.kt +++ b/app/src/test/java/com/afternote/afternote_fe/MainViewModelNotificationTest.kt @@ -3,6 +3,7 @@ package com.afternote.afternote_fe import androidx.lifecycle.SavedStateHandle import com.afternote.afternote_fe.notification.NotificationEntryRequest import com.afternote.afternote_fe.notification.NotificationEntrySource +import com.afternote.core.common.notification.NotificationDestination import com.afternote.core.domain.testing.FakeAuthRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -94,9 +95,11 @@ class MainViewModelNotificationTest { private fun request( occurrenceId: String, source: NotificationEntrySource = NotificationEntrySource.FCM, + destination: NotificationDestination = NotificationDestination.HOME, ): NotificationEntryRequest = NotificationEntryRequest( source = source, occurrenceId = occurrenceId, + destination = destination, ) } diff --git a/app/src/test/java/com/afternote/afternote_fe/messaging/AfternoteFirebaseMessagingServiceTest.kt b/app/src/test/java/com/afternote/afternote_fe/messaging/AfternoteFirebaseMessagingServiceTest.kt new file mode 100644 index 0000000000..7a863ba1d9 --- /dev/null +++ b/app/src/test/java/com/afternote/afternote_fe/messaging/AfternoteFirebaseMessagingServiceTest.kt @@ -0,0 +1,147 @@ +package com.afternote.afternote_fe.messaging + +import android.Manifest +import android.app.Application +import android.app.Notification +import android.app.NotificationManager +import android.os.Bundle +import com.google.firebase.messaging.RemoteMessage +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * FCM 메시지 한 건이 실제로 어떤 알림이 되는지의 회귀 기준. + * + * 제목·본문 결정과 occurrence 식별은 `AfternoteFirebaseMessagingService.kt` 안에서만 쓰이는 + * file-local 구현이라 직접 부르지 않는다 (#1671). 대신 Firebase 가 부르는 `onMessageReceived` + * 에 진짜 [RemoteMessage] 를 넣고 **게시된 알림**을 읽는다. + * + * 그래서 «occurrence 별 tag» 같은 내부 개념도 밖에서 보이는 결과로 바꿔 단언한다 — 같은 발생을 + * 두 번 받으면 알림이 하나로 합쳐지고, 다른 발생이면 둘로 남는 것이 그 tag 가 존재하는 이유다. + * + * `onCreate()` 는 부르지 않는다. Hilt 진입점이라 주입이 먼저 도는데 이 테스트가 검증하는 경로는 + * 주입된 협력자를 쓰지 않는다. 알림 채널은 `GlobalApplication` 이 하듯 여기서 직접 만든다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class AfternoteFirebaseMessagingServiceTest { + private lateinit var service: AfternoteFirebaseMessagingService + + @Before + fun setUp() { + val application = RuntimeEnvironment.getApplication() + shadowOf(application).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + FcmNotificationChannel.create(application) + service = Robolectric.buildService(AfternoteFirebaseMessagingService::class.java).get() + } + + @Test + fun `notification payload가 data payload보다 우선한다`() { + service.onMessageReceived( + remoteMessage( + notificationTitle = "알림 제목", + notificationBody = "알림 본문", + data = mapOf("title" to "data 제목", "body" to "data 본문"), + ), + ) + + assertEquals("알림 제목", contentTitleOfSingleNotification()) + assertEquals("알림 본문", contentTextOfSingleNotification()) + } + + @Test + fun `제목이 없으면 fallback 제목으로 알림을 띄운다`() { + service.onMessageReceived(remoteMessage(data = mapOf("body" to "data 본문"))) + + assertEquals("AFTERNOTE", contentTitleOfSingleNotification()) + assertEquals("data 본문", contentTextOfSingleNotification()) + } + + @Test + fun `제목도 본문도 없는 메시지는 알림을 만들지 않는다`() { + service.onMessageReceived( + remoteMessage(notificationTitle = " ", data = mapOf("body" to "")), + ) + + assertEquals(0, postedNotifications().size) + } + + @Test + fun `같은 message id를 다시 받으면 알림이 하나로 합쳐진다`() { + service.onMessageReceived(remoteMessage(messageId = "message-1", notificationTitle = "첫 번째")) + service.onMessageReceived(remoteMessage(messageId = "message-1", notificationTitle = "다시 온 같은 발생")) + + assertEquals(1, postedNotifications().size) + assertEquals("다시 온 같은 발생", contentTitleOfSingleNotification()) + } + + @Test + fun `서로 다른 message id는 각각 남는다`() { + service.onMessageReceived(remoteMessage(messageId = "message-1", notificationTitle = "첫 번째")) + service.onMessageReceived(remoteMessage(messageId = "message-2", notificationTitle = "두 번째")) + + assertEquals(2, postedNotifications().size) + } + + @Test + fun `Java hash가 충돌하는 message id도 서로 다른 알림으로 남는다`() { + assertEquals("Aa".hashCode(), "BB".hashCode()) + + service.onMessageReceived(remoteMessage(messageId = "Aa", notificationTitle = "첫 번째")) + service.onMessageReceived(remoteMessage(messageId = "BB", notificationTitle = "두 번째")) + + assertEquals(2, postedNotifications().size) + } + + @Test + fun `message id가 없는 메시지는 발생마다 따로 남는다`() { + service.onMessageReceived(remoteMessage(messageId = null, notificationTitle = "첫 번째")) + service.onMessageReceived(remoteMessage(messageId = " ", notificationTitle = "두 번째")) + + assertEquals(2, postedNotifications().size) + } + + private fun postedNotifications(): List { + val manager = RuntimeEnvironment.getApplication().getSystemService(NotificationManager::class.java) + return shadowOf(manager).allNotifications + } + + private fun contentTitleOfSingleNotification(): String = shadowOf(singleNotification()).contentTitle.toString() + + private fun contentTextOfSingleNotification(): String = shadowOf(singleNotification()).contentText.toString() + + private fun singleNotification(): Notification { + val notifications = postedNotifications() + assertEquals("알림이 정확히 하나 게시돼야 한다", 1, notifications.size) + return notifications.single() + } + + /** + * FCM 이 전달하는 그대로의 wire payload 를 만든다. `gcm.n.*` 는 notification payload, + * 접두사 없는 키는 data payload, `google.message_id` 는 발생 식별자다. + */ + private fun remoteMessage( + notificationTitle: String? = null, + notificationBody: String? = null, + data: Map = emptyMap(), + messageId: String? = "message-1", + ): RemoteMessage = + RemoteMessage( + Bundle().apply { + messageId?.let { putString("google.message_id", it) } + if (notificationTitle != null || notificationBody != null) { + putString("gcm.n.e", "1") + notificationTitle?.let { putString("gcm.n.title", it) } + notificationBody?.let { putString("gcm.n.body", it) } + } + data.forEach { (key, value) -> putString(key, value) } + }, + ) +} diff --git a/app/src/test/java/com/afternote/afternote_fe/messaging/FcmNotificationContentResolverTest.kt b/app/src/test/java/com/afternote/afternote_fe/messaging/FcmNotificationContentResolverTest.kt deleted file mode 100644 index 0f16c9dbef..0000000000 --- a/app/src/test/java/com/afternote/afternote_fe/messaging/FcmNotificationContentResolverTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.afternote.afternote_fe.messaging - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class FcmNotificationContentResolverTest { - @Test - fun `notification payload takes priority over data payload`() { - val content = - FcmNotificationContentResolver.resolve( - notificationTitle = "알림 제목", - notificationBody = "알림 본문", - data = mapOf("title" to "data 제목", "body" to "data 본문"), - fallbackTitle = "AFTERNOTE", - ) - - assertEquals(FcmNotificationContent(title = "알림 제목", body = "알림 본문"), content) - } - - @Test - fun `data body uses fallback title when title is absent`() { - val content = - FcmNotificationContentResolver.resolve( - notificationTitle = null, - notificationBody = null, - data = mapOf("body" to "data 본문"), - fallbackTitle = "AFTERNOTE", - ) - - assertEquals(FcmNotificationContent(title = "AFTERNOTE", body = "data 본문"), content) - } - - @Test - fun `blank payload does not create a notification`() { - val content = - FcmNotificationContentResolver.resolve( - notificationTitle = " ", - notificationBody = null, - data = mapOf("body" to ""), - fallbackTitle = "AFTERNOTE", - ) - - assertNull(content) - } - - @Test - fun `message id가 있으면 occurrence token으로 보존한다`() { - assertEquals("message-1", FcmNotificationIdentity.occurrenceId("message-1")) - } - - @Test - fun `message id가 없거나 blank이면 매 발생마다 고유 token을 만든다`() { - val missing = FcmNotificationIdentity.occurrenceId(null) - val blank = FcmNotificationIdentity.occurrenceId(" ") - - assertTrue(missing.isNotBlank()) - assertTrue(blank.isNotBlank()) - assertNotEquals(missing, blank) - } - - @Test - fun `notification tag는 occurrence마다 다르고 프로세스 상태와 무관하게 결정적이다`() { - val first = FcmNotificationIdentity.notificationTag("occurrence-1") - - assertEquals(first, FcmNotificationIdentity.notificationTag("occurrence-1")) - assertNotEquals(first, FcmNotificationIdentity.notificationTag("occurrence-2")) - } - - @Test - fun `Java hash가 충돌하는 message id도 서로 다른 notification tag를 만든다`() { - assertEquals("Aa".hashCode(), "BB".hashCode()) - - val first = FcmNotificationIdentity.notificationTag("Aa") - val second = FcmNotificationIdentity.notificationTag("BB") - - assertNotEquals(first, second) - } -} diff --git a/app/src/test/java/com/afternote/afternote_fe/navigation/AuthBoundaryBackStackTest.kt b/app/src/test/java/com/afternote/afternote_fe/navigation/AuthBoundaryBackStackTest.kt index 9b2e89d3f0..bd13c87fb1 100644 --- a/app/src/test/java/com/afternote/afternote_fe/navigation/AuthBoundaryBackStackTest.kt +++ b/app/src/test/java/com/afternote/afternote_fe/navigation/AuthBoundaryBackStackTest.kt @@ -12,12 +12,18 @@ import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode /** - * 인증 전후 백스택 경계 회귀 기준 (#1601). + * 인증 전후 **루트** 백스택 경계 회귀 기준 (#1601). * - * 이 앱의 인증 게이트는 세 겹이다 — 로그인(`MainViewModel.startRoute`) · 지문 - * (`AfternoteNavGraph` 의 `startDestination`) · 수신자 본인 확인(`ReceiverNavGraph` 의 nested flow). - * 각 게이트를 통과한 뒤 **뒤로가기로 게이트에 돌아갈 수 없는지**를 `popUpTo` 조합의 결과인 - * 백스택 모양으로 못박는다. + * 이 앱의 인증 게이트는 세 겹이다 — 로그인(`MainViewModel.startRoute`) · 지문(애프터노트 로컬 + * 스택의 시작 화면) · 수신자 본인 확인(열람 신청 흐름). Navigation 3 이관(#1698) 뒤 뒤의 두 + * 게이트는 피처 로컬 스택 안에 있으므로, 그 경계는 각 피처의 `*LocalNavActions` 테스트가 본다: + * + * - 지문 관문 → `AfternoteLocalNavActionsTest` + * - 열람 신청 단계 소거 → `DeliveryVerificationFlowLocalNavActionsTest` + * - 로그인 화면 교체·소셜 신규 가입 분기 → `OnboardingLocalNavActionsTest` + * - 설정 안쪽(탈퇴 안내 → 확인)의 모양 → `SettingLocalNavActionsTest` + * + * 여기 남는 것은 **루트가 소유한 경계**뿐이다 — 온보딩↔홈 전환과 로그아웃·탈퇴. */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -38,148 +44,69 @@ class AuthBoundaryBackStackTest { private fun routes(): List = composeRule.runOnIdle { harness.navController.backStackRouteNames() } @Test - fun `로그인 성공은 온보딩 스택을 전부 비우고 홈만 남긴다`() { + fun `로그인 성공은 온보딩을 루트에서 비우고 홈만 남긴다`() { start(Route.Onboarding) - composeRule.runOnIdle { harness.onboardingActions.navigateToLogin() } - composeRule.runOnIdle { harness.onboardingActions.navigateToSignUp() } - composeRule.runOnIdle { harness.onboardingActions.proceedToSignUpResidentNumber() } + composeRule.runOnIdle { harness.onboardingExternalActions.replaceOnboardingWithHome() } - composeRule.runOnIdle { harness.onboardingActions.replaceOnboardingWithHome() } - - // popUpTo(0) { inclusive } — 온보딩 그래프 엔트리까지 사라진다. + // popUpTo(0) { inclusive } — 온보딩 host destination 까지 사라진다. assertEquals(listOf("NavHostRoot", "Home"), routes()) - // 홈에서 뒤로가기는 앱을 벗어난다(=pop 실패). 로그인 화면으로 되돌아가지 않는다. + // 홈에서 뒤로가기는 앱을 벗어난다(=pop 실패). 온보딩으로 되돌아가지 않는다. assertEquals(false, composeRule.runOnIdle { harness.navController.popBackStack() }) } @Test - fun `로그아웃은 인증 이후 스택을 전부 비우고 온보딩 시작 화면만 남긴다`() { - start(Route.Home) - - composeRule.runOnIdle { harness.homeActions.onSettingClick() } - composeRule.runOnIdle { harness.settingActions.onNavigateToWithdrawGuide() } + fun `온보딩 Welcome 의 받은 기록 확인은 수신자 흐름을 새로 쌓는다`() { + start(Route.Onboarding) - composeRule.runOnIdle { harness.settingActions.onLogoutSuccess() } + composeRule.runOnIdle { harness.onboardingExternalActions.navigateToReceivedRecords() } - assertEquals(listOf("NavHostRoot", "Onboarding", "WelcomeRoute"), routes()) - assertEquals(false, composeRule.runOnIdle { harness.navController.popBackStack() }) + assertEquals(listOf("NavHostRoot", "Onboarding", "Receiver"), routes()) } @Test - fun `탈퇴는 로그아웃과 같은 경계로 스택을 비운다`() { + fun `로그아웃은 인증 이후 스택을 전부 비우고 온보딩만 남긴다`() { start(Route.Home) composeRule.runOnIdle { harness.homeActions.onSettingClick() } - composeRule.runOnIdle { harness.settingActions.onNavigateToWithdrawGuide() } - composeRule.runOnIdle { harness.settingActions.onNavigateToWithdrawConfirm() } - composeRule.runOnIdle { harness.settingActions.onWithdrawSuccess() } + // 설정 안쪽 화면은 로컬 스택에 있어 루트는 host 한 칸만 본다. 로그아웃은 그 host 가 외부 경계로 올린다. + composeRule.runOnIdle { harness.settingExternalActions.onLogoutSuccess() } - assertEquals(listOf("NavHostRoot", "Onboarding", "WelcomeRoute"), routes()) + assertEquals(listOf("NavHostRoot", "Onboarding"), routes()) assertEquals(false, composeRule.runOnIdle { harness.navController.popBackStack() }) } @Test - fun `로그인 화면 교체 이동은 로그인을 백스택에 남기지 않는다`() { - start(Route.Onboarding) - - composeRule.runOnIdle { harness.onboardingActions.navigateToLogin() } - composeRule.runOnIdle { harness.onboardingActions.replaceLoginWithSignUp() } - - assertEquals( - listOf("NavHostRoot", "Onboarding", "WelcomeRoute", "SignUpRoute"), - routes(), - ) - // 가입 화면에서 뒤로가기는 로그인이 아니라 Welcome 으로 간다. - composeRule.runOnIdle { harness.onboardingActions.popBack() } - assertEquals("WelcomeRoute", composeRule.runOnIdle { harness.navController.currentRouteName() }) - } + fun `탈퇴는 로그아웃과 같은 경계로 스택을 비운다`() { + start(Route.Home) - @Test - fun `소셜 신규 가입 분기는 Welcome 을 새로 세우고 로그인을 남기지 않는다`() { - start(Route.Onboarding) + composeRule.runOnIdle { harness.homeActions.onSettingClick() } - composeRule.runOnIdle { harness.onboardingActions.navigateToLogin() } - composeRule.runOnIdle { harness.onboardingActions.replaceLoginWithWelcome() } + composeRule.runOnIdle { harness.settingExternalActions.onWithdrawSuccess() } - assertEquals(listOf("NavHostRoot", "Onboarding", "WelcomeRoute"), routes()) + assertEquals(listOf("NavHostRoot", "Onboarding"), routes()) assertEquals(false, composeRule.runOnIdle { harness.navController.popBackStack() }) } @Test - fun `지문 관문은 인증 성공 뒤 백스택에서 사라진다`() { + fun `이관된 그래프는 루트에 host destination 한 칸만 쌓는다`() { start(Route.Home) composeRule.runOnIdle { harness.appState.navigateToBottomBarRoute(Route.Afternote) } - assertEquals( - listOf("NavHostRoot", "Home", "Afternote", "FingerprintLoginRoute"), - routes(), - ) - - composeRule.runOnIdle { harness.afternoteActions.replaceFingerprintLoginWithAfternoteHome() } - - assertEquals( - listOf("NavHostRoot", "Home", "Afternote", "AfternoteHomeRoute"), - routes(), - ) - // 애프터노트 홈에서 뒤로가기는 지문 관문이 아니라 홈 탭으로 나간다. - composeRule.runOnIdle { harness.afternoteActions.popBack() } - assertEquals("Home", composeRule.runOnIdle { harness.navController.currentRouteName() }) - } - @Test - fun `수신자 열람 신청은 소비한 본인확인 단계를 즉시 백스택에서 지운다`() { - start(Route.Receiver) - - composeRule.runOnIdle { harness.receiverActions.navigateToSenderDetail(SENDER_ID) } - composeRule.runOnIdle { harness.receiverActions.navigateToDeliveryVerificationFlow(SENDER_ID) } - composeRule.runOnIdle { harness.receiverActions.navigateToIdentityVerificationEmail() } - - composeRule.runOnIdle { harness.receiverActions.proceedToMasterKey() } - assertEquals( - listOf( - "NavHostRoot", - "Receiver", - "ReceivedRecordsRoute", - "SenderDetailRoute", - "DeliveryVerificationFlowRoute", - "MasterKeyRoute", - ), - routes(), - ) - - composeRule.runOnIdle { harness.receiverActions.proceedToDocumentUpload() } - assertEquals( - listOf( - "NavHostRoot", - "Receiver", - "ReceivedRecordsRoute", - "SenderDetailRoute", - "DeliveryVerificationFlowRoute", - "DocumentUploadRoute", - ), - routes(), - ) - - composeRule.runOnIdle { harness.receiverActions.proceedToDeliveryVerificationComplete() } - assertEquals( - listOf( - "NavHostRoot", - "Receiver", - "ReceivedRecordsRoute", - "SenderDetailRoute", - "DeliveryVerificationFlowRoute", - "DeliveryVerificationCompleteRoute", - ), - routes(), - ) - - composeRule.runOnIdle { harness.receiverActions.popToReceivedRecords() } - assertEquals(listOf("NavHostRoot", "Receiver", "ReceivedRecordsRoute"), routes()) - } + // Nav2 시절엔 [Afternote, FingerprintLoginRoute] 두 칸이었다. 이관 뒤 그래프 안쪽 화면은 + // 로컬 스택에 있으므로 루트는 host 한 칸만 본다 — #1702 가 루트를 바꿀 때 이 모양을 받는다. + assertEquals(listOf("NavHostRoot", "Home", "Afternote"), routes()) + + // 로컬 스택 바닥에서의 back 은 boundary 로 올라와 이 한 칸을 pop 한다. + assertEquals(true, composeRule.runOnIdle { harness.navController.popBackStack() }) + assertEquals("Home", composeRule.runOnIdle { harness.navController.currentRouteName() }) - private companion object { - const val SENDER_ID = "sender-1601" + // 설정도 같다 — Nav2 시절엔 [Setting, SettingHomeRoute] 두 칸이었다 (#1695). + composeRule.runOnIdle { harness.homeActions.onSettingClick() } + assertEquals(listOf("NavHostRoot", "Home", "Setting"), routes()) + assertEquals(true, composeRule.runOnIdle { harness.navController.popBackStack() }) + assertEquals("Home", composeRule.runOnIdle { harness.navController.currentRouteName() }) } } diff --git a/app/src/test/java/com/afternote/afternote_fe/navigation/BottomTabBackStackRestorationTest.kt b/app/src/test/java/com/afternote/afternote_fe/navigation/BottomTabBackStackRestorationTest.kt index 899b7d15e3..5abf390e29 100644 --- a/app/src/test/java/com/afternote/afternote_fe/navigation/BottomTabBackStackRestorationTest.kt +++ b/app/src/test/java/com/afternote/afternote_fe/navigation/BottomTabBackStackRestorationTest.kt @@ -86,30 +86,24 @@ class BottomTabBackStackRestorationTest { } @Test - fun `애프터노트 탭만은 재진입 때 저장된 스택 대신 지문 관문으로 돌아간다`() { + fun `애프터노트 탭만은 재진입 때 저장된 화면 상태를 복원하지 않는다`() { start() composeRule.runOnIdle { harness.appState.navigateToBottomBarRoute(Route.Afternote) } - assertEquals( - "FingerprintLoginRoute", - composeRule.runOnIdle { harness.navController.currentRouteName() }, - ) - - composeRule.runOnIdle { harness.afternoteActions.replaceFingerprintLoginWithAfternoteHome() } - composeRule.runOnIdle { harness.afternoteActions.navigateToAfternoteDetail(itemId = 7L) } - assertEquals( - "DetailRoute", - composeRule.runOnIdle { harness.navController.currentRouteName() }, - ) + composeRule.onNodeWithText("Afternote#0").performClick() + composeRule.onNodeWithText("Afternote#1").assertExists() composeRule.runOnIdle { harness.appState.navigateToBottomBarRoute(Route.Home) } composeRule.runOnIdle { harness.appState.navigateToBottomBarRoute(Route.Afternote) } - // restoreState = false 라 상세·홈이 복원되지 않고 인증 관문이 다시 선다. + // restoreState = false — 저장된 화면 상태가 복원되지 않고 host 가 새로 선다. 그래서 + // 로컬 스택도 시작 화면(지문 관문)부터 다시 쌓인다. 그 시작점 자체는 + // AfternoteLocalNavActionsTest 가 본다. assertEquals( - listOf("NavHostRoot", "Home", "Afternote", "FingerprintLoginRoute"), + listOf("NavHostRoot", "Home", "Afternote"), composeRule.runOnIdle { harness.navController.backStackRouteNames() }, ) + composeRule.onNodeWithText("Afternote#0").assertExists() } @Test diff --git a/app/src/test/java/com/afternote/afternote_fe/navigation/FlowScopedViewModelLifetimeTest.kt b/app/src/test/java/com/afternote/afternote_fe/navigation/FlowScopedViewModelLifetimeTest.kt deleted file mode 100644 index 1e669e2b28..0000000000 --- a/app/src/test/java/com/afternote/afternote_fe/navigation/FlowScopedViewModelLifetimeTest.kt +++ /dev/null @@ -1,166 +0,0 @@ -package com.afternote.afternote_fe.navigation - -import android.app.Application -import androidx.compose.ui.test.junit4.v2.createComposeRule -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import androidx.navigation.NavBackStackEntry -import com.afternote.core.ui.Route -import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute -import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute -import org.junit.Assert.assertEquals -import org.junit.Assert.assertSame -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** - * flow-scoped ViewModel 수명 회귀 기준 (#1601). - * - * 에디터 3화면(`AfternoteEditorViewModel`)과 열람 신청 5화면 - * (`DeliveryVerificationFlowViewModel`)은 **중첩 그래프 엔트리의 ViewModelStore** 를 공유한다. - * 즉 «자식 화면 사이에서는 같은 인스턴스, 그래프가 백스택에서 빠질 때 정리» 가 계약이고, - * 그 계약은 프로덕션 코드가 아니라 Navigation 의 백스택 수명이 지키고 있다. Navigation 3 전환은 - * 정확히 그 기전을 바꾸므로 여기에 현행 동작을 못박는다. - * - * 실제 ViewModel 대신 정리 시점만 기록하는 [ClearRecordingViewModel] 을 같은 스토어에 올려 - * Hilt·Repository 없이 수명만 잰다. - */ -@RunWith(RobolectricTestRunner::class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(sdk = [35], application = Application::class) -class FlowScopedViewModelLifetimeTest { - @get:Rule - val composeRule = createComposeRule() - - private lateinit var harness: NavBackStackHarness - private var clearedCount = 0 - - private fun start(startDestination: Route) { - composeRule.setContent { - SkeletonAppNavigation(startDestination = startDestination) { harness = it } - } - composeRule.waitForIdle() - } - - /** [entry] 의 ViewModelStore 에 올라간(없으면 새로 만드는) 기록용 ViewModel. */ - private fun flowScopedViewModel(entry: NavBackStackEntry): ClearRecordingViewModel = - ViewModelProvider( - entry, - viewModelFactory { - initializer { ClearRecordingViewModel { clearedCount += 1 } } - }, - )[ClearRecordingViewModel::class.java] - - private inline fun flowScopedViewModel(): ClearRecordingViewModel = - composeRule.runOnIdle { - flowScopedViewModel(harness.navController.getBackStackEntry()) - } - - @Test - fun `에디터 흐름 ViewModel 은 자식 화면 사이에서 같은 인스턴스로 유지된다`() { - start(Route.Home) - openEditorFlow() - - val onEditor = flowScopedViewModel() - - composeRule.runOnIdle { harness.afternoteActions.navigateToMemorialPlaylist() } - assertSame(onEditor, flowScopedViewModel()) - - composeRule.runOnIdle { harness.afternoteActions.navigateToAddSong() } - assertSame(onEditor, flowScopedViewModel()) - - composeRule.runOnIdle { harness.afternoteActions.popBack() } - composeRule.runOnIdle { harness.afternoteActions.popBack() } - assertSame(onEditor, flowScopedViewModel()) - assertEquals(0, clearedCount) - } - - @Test - fun `에디터 흐름은 저장 후 홈 복귀에서 정리된다`() { - start(Route.Home) - openEditorFlow() - flowScopedViewModel() - - composeRule.runOnIdle { harness.afternoteActions.navigateToMemorialPlaylist() } - composeRule.runOnIdle { harness.afternoteActions.popToAfternoteHome() } - - assertEquals( - listOf("NavHostRoot", "Home", "Afternote", "AfternoteHomeRoute"), - composeRule.runOnIdle { harness.navController.backStackRouteNames() }, - ) - assertEquals(1, clearedCount) - } - - @Test - fun `그래프 밖으로 나가도 백스택에 남아 있는 동안은 에디터 흐름이 정리되지 않는다`() { - start(Route.Home) - openEditorFlow() - - val onEditor = flowScopedViewModel() - - // 설정은 에디터 흐름 밖의 top-level 라우트지만 흐름 엔트리를 pop 하지 않는다. - composeRule.runOnIdle { harness.afternoteActions.navigateToSetting() } - assertEquals(0, clearedCount) - - composeRule.runOnIdle { harness.afternoteActions.popBack() } - assertEquals("EditorRoute", composeRule.runOnIdle { harness.navController.currentRouteName() }) - assertSame(onEditor, flowScopedViewModel()) - assertEquals(0, clearedCount) - } - - @Test - fun `열람 신청 흐름 ViewModel 은 단계 이동으로 정리되지 않고 흐름 종료에서만 정리된다`() { - start(Route.Receiver) - - composeRule.runOnIdle { harness.receiverActions.navigateToSenderDetail(SENDER_ID) } - composeRule.runOnIdle { harness.receiverActions.navigateToDeliveryVerificationFlow(SENDER_ID) } - - val onIntro = flowScopedViewModel() - - composeRule.runOnIdle { harness.receiverActions.navigateToIdentityVerificationEmail() } - composeRule.runOnIdle { harness.receiverActions.proceedToMasterKey() } - composeRule.runOnIdle { harness.receiverActions.proceedToDocumentUpload() } - composeRule.runOnIdle { harness.receiverActions.proceedToDeliveryVerificationComplete() } - - // 본인확인 화면들이 백스택에서 사라지는 동안에도 흐름 엔트리는 유지된다. - assertSame(onIntro, flowScopedViewModel()) - assertEquals(0, clearedCount) - - composeRule.runOnIdle { harness.receiverActions.popToReceivedRecords() } - - assertEquals( - listOf("NavHostRoot", "Receiver", "ReceivedRecordsRoute"), - composeRule.runOnIdle { harness.navController.backStackRouteNames() }, - ) - assertEquals(1, clearedCount) - } - - /** 지문 관문을 지나 새 에디터 흐름에 진입한다. */ - private fun openEditorFlow() { - composeRule.runOnIdle { harness.appState.navigateToBottomBarRoute(Route.Afternote) } - composeRule.runOnIdle { harness.afternoteActions.replaceFingerprintLoginWithAfternoteHome() } - composeRule.runOnIdle { - harness.afternoteActions.navigateToNewEditor(AfternoteType.SOCIAL_NETWORK) - } - assertEquals("EditorRoute", composeRule.runOnIdle { harness.navController.currentRouteName() }) - } - - private class ClearRecordingViewModel( - private val onClearedRecorder: () -> Unit, - ) : ViewModel() { - override fun onCleared() { - onClearedRecorder() - } - } - - private companion object { - const val SENDER_ID = "sender-1601" - } -} diff --git a/app/src/test/java/com/afternote/afternote_fe/navigation/MemoriesReadAgainDestinationTest.kt b/app/src/test/java/com/afternote/afternote_fe/navigation/MemoriesReadAgainDestinationTest.kt new file mode 100644 index 0000000000..0fae7bd5a7 --- /dev/null +++ b/app/src/test/java/com/afternote/afternote_fe/navigation/MemoriesReadAgainDestinationTest.kt @@ -0,0 +1,88 @@ +package com.afternote.afternote_fe.navigation + +import android.app.Application +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.navigation.toRoute +import com.afternote.core.ui.Route +import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * 홈 MEMORIES 의 「그날의 기록 다시 읽기」가 **어디로 가는지**를 못박는다 (#793). + * + * 카드 쪽 회귀(`MemoriesReadAgainTest`)는 0건에서 버튼이 없다는 것과 두 콜백이 서로 다르다는 + * 것까지만 센다 — 콜백이 실제로 어떤 목적지를 여는지는 앱 모듈의 배선에 있어서 그 층에서는 + * 잡히지 않는다. 그래서 반대 방향, 즉 **유효한 기록 id 를 받았을 때 그 id 그대로 + * [MindRecordRoute.RecordDetailRoute] 에 도착하는지**를 여기서 고정한다. + * + * [MindRecordRoute.RecordDetailRoute.isDiary] 가 `false` 인 것이 계약의 절반이다. 카드가 싣는 + * 것은 가장 최근 **데일리질문 답변** 한 건이라, 이 값이 뒤집히면 상세가 일기를 찾으러 가서 + * 빈 화면이 된다. 인자를 버리는 [backStackRouteNames] 로는 이 뒤집힘이 보이지 않으므로 + * [toRoute] 로 값까지 꺼내 단언한다. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], application = Application::class) +class MemoriesReadAgainDestinationTest { + @get:Rule + val composeRule = createComposeRule() + + private lateinit var harness: NavBackStackHarness + + private fun startAtHome() { + composeRule.setContent { + SkeletonAppNavigation(startDestination = Route.Home) { harness = it } + } + composeRule.waitForIdle() + } + + private fun currentRecordDetail(): MindRecordRoute.RecordDetailRoute = + composeRule.runOnIdle { + harness.navController.currentBackStackEntry!!.toRoute() + } + + @Test + fun `다시 읽기는 받은 기록 id 를 그대로 실어 기록 상세로 간다`() { + startAtHome() + + composeRule.runOnIdle { harness.homeActions.onMemoriesRecordDetailClick(RECORD_ID) } + + assertEquals( + listOf("NavHostRoot", "Home", "RecordDetailRoute"), + composeRule.runOnIdle { harness.navController.backStackRouteNames() }, + ) + assertEquals(RECORD_ID, currentRecordDetail().recordId) + } + + @Test + fun `다시 읽기가 여는 상세는 일기가 아니라 데일리질문 답변이다`() { + startAtHome() + + composeRule.runOnIdle { harness.homeActions.onMemoriesRecordDetailClick(RECORD_ID) } + + assertEquals(false, currentRecordDetail().isDiary) + } + + @Test + fun `MEMORIES 섹션 자체는 추억 공간으로 가 상세와 목적지가 갈린다`() { + startAtHome() + + composeRule.runOnIdle { harness.homeActions.onMemoriesSectionClick() } + + assertEquals( + listOf("NavHostRoot", "Home", "MemorySpace"), + composeRule.runOnIdle { harness.navController.backStackRouteNames() }, + ) + } + + private companion object { + /** 0 이나 1 이면 기본값·인덱스와 구분되지 않아, 그대로 실렸는지 보이는 값을 쓴다. */ + const val RECORD_ID = 4_213L + } +} diff --git a/app/src/test/java/com/afternote/afternote_fe/navigation/NavBackStackHarness.kt b/app/src/test/java/com/afternote/afternote_fe/navigation/NavBackStackHarness.kt index a4e242e5f3..a844a2c3ae 100644 --- a/app/src/test/java/com/afternote/afternote_fe/navigation/NavBackStackHarness.kt +++ b/app/src/test/java/com/afternote/afternote_fe/navigation/NavBackStackHarness.kt @@ -18,17 +18,10 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navigation import com.afternote.core.ui.Route -import com.afternote.feature.afternote.presentation.navigation.AfternoteNavActions -import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute -import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute import com.afternote.feature.home.presentation.HomeTabActions import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute -import com.afternote.feature.onboarding.presentation.navigation.OnboardingNavActions -import com.afternote.feature.onboarding.presentation.navigation.OnboardingRoute -import com.afternote.feature.receiver.presentation.navigation.ReceiverNavActions -import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute -import com.afternote.feature.setting.presentation.navigation.SettingNavActions -import com.afternote.feature.setting.presentation.navigation.SettingRoute +import com.afternote.feature.onboarding.presentation.navigation.OnboardingExternalActions +import com.afternote.feature.setting.presentation.navigation.SettingExternalActions import com.afternote.feature.timeletter.presentation.navigation.TimeLetterNavActions import com.afternote.feature.timeletter.presentation.navigation.TimeLetterRoute @@ -46,11 +39,9 @@ import com.afternote.feature.timeletter.presentation.navigation.TimeLetterRoute */ internal class NavBackStackHarness( val appState: AppState, - val onboardingActions: OnboardingNavActions, - val settingActions: SettingNavActions, + val onboardingExternalActions: OnboardingExternalActions, + val settingExternalActions: SettingExternalActions, val timeLetterActions: TimeLetterNavActions, - val afternoteActions: AfternoteNavActions, - val receiverActions: ReceiverNavActions, val homeActions: HomeTabActions, /** predictive back 제스처를 프로그램으로 흘려 넣기 위한 호스트 dispatcher. */ val backDispatcher: OnBackPressedDispatcher, @@ -69,11 +60,9 @@ internal fun SkeletonAppNavigation( onReady: (NavBackStackHarness) -> Unit, ) { val appState = rememberAfternoteAppState() - val onboardingActions = rememberOnboardingNavActions(appState.navController) - val settingActions = rememberSettingNavActions(appState) + val onboardingExternalActions = rememberOnboardingExternalActions(appState) + val settingExternalActions = rememberSettingExternalActions(appState) val timeLetterActions = rememberTimeLetterNavActions(appState.navController) - val afternoteActions = rememberAfternoteNavActions(appState) { } - val receiverActions = rememberReceiverNavActions(appState) val homeActions = rememberHomeTabActions(appState, onRetryLoad = { }) val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) { @@ -84,11 +73,9 @@ internal fun SkeletonAppNavigation( onReady( NavBackStackHarness( appState = appState, - onboardingActions = onboardingActions, - settingActions = settingActions, + onboardingExternalActions = onboardingExternalActions, + settingExternalActions = settingExternalActions, timeLetterActions = timeLetterActions, - afternoteActions = afternoteActions, - receiverActions = receiverActions, homeActions = homeActions, backDispatcher = backDispatcher, ), @@ -117,35 +104,27 @@ internal fun SkeletonAppNavigation( */ @Suppress("LongMethod") internal fun NavGraphBuilder.appRouteSkeleton() { - // feature/onboarding — OnboardingNavGraph.kt - navigation(startDestination = OnboardingRoute.WelcomeRoute) { - stubScreen() - stubScreen() - stubScreen() - stubScreen() - stubScreen() - stubScreen() - stubScreen() - stubScreen() - stubScreen() - } + // ── Navigation 3 로컬 스택을 가진 그래프 (#1698) — 루트엔 host destination 하나씩이다. + // 그래프 «안쪽» 백스택 모양은 각 피처의 *LocalNavActions 테스트가 본다. 여기서 재현할 것은 + // 루트가 보는 모양뿐이다. + stubScreen() + // 애프터노트만 stateful — 탭 재진입에서 저장된 상태가 복원되지 «않는» 것이 판정 대상이다. + stubScreen(stateful = true) + stubScreen() + stubScreen() + stubScreen() // app — AppNavigation.kt 가 직접 등록하는 홈 탭 stubScreen(stateful = true) - // feature/setting — SettingNavGraph.kt. 로그아웃·탈퇴 경계가 지나는 화면만 옮긴다. - navigation(startDestination = SettingRoute.SettingHomeRoute) { - stubScreen() - stubScreen() - stubScreen() - } - // feature/mindrecord — MindRecordNavGraph.kt. 중첩 없이 루트에 직접 붙는 top-level 3종과 - // 임시저장 목록만 옮긴다(작성 화면은 어느 테스트도 지나지 않는다). + // 임시저장 목록·기록 상세만 옮긴다(작성 화면은 어느 테스트도 지나지 않는다). + // 기록 상세는 홈 MEMORIES 의 「그날의 기록 다시 읽기」가 인자까지 싣고 도착하는 목적지다 (#793). stubScreen() stubScreen() stubScreen() stubScreen() + stubScreen() // feature/timeletter — TimeLetterNavGraph.kt. 탭 저장/복원과 predictive back 이 지나는 // 세 화면만 옮긴다. @@ -154,41 +133,6 @@ internal fun NavGraphBuilder.appRouteSkeleton() { stubScreen(stateful = true) stubScreen() } - - // feature/afternote(author) — AfternoteNavGraph.kt. startDestination 이 지문 관문이다. - navigation(startDestination = AfternoteRoute.FingerprintLoginRoute) { - stubScreen() - stubScreen() - navigation(startDestination = AfternoteRoute.EditorRoute) { - stubScreen() - stubScreen() - stubScreen() - stubScreen() - } - stubScreen() - } - - // feature/afternote(receiver) — ReceivedAfternoteNavGraph.kt 는 Route.Afternote 그래프가 - // 아니라 루트에 직접 등록된다(그 그래프의 시작점이 발신자용 지문 관문이라서). 목록·상세만 옮긴다. - stubScreen() - stubScreen() - - // feature/receiver — ReceiverNavGraph.kt - navigation(startDestination = ReceiverRoute.ReceivedRecordsRoute) { - stubScreen() - stubScreen() - stubScreen() - navigation( - startDestination = ReceiverRoute.IdentityVerificationIntroRoute, - ) { - stubScreen() - stubScreen() - stubScreen() - stubScreen() - stubScreen() - } - stubScreen() - } } /** diff --git a/app/src/test/java/com/afternote/afternote_fe/navigation/RecipientChipDestinationTest.kt b/app/src/test/java/com/afternote/afternote_fe/navigation/RecipientChipDestinationTest.kt new file mode 100644 index 0000000000..86a58e4952 --- /dev/null +++ b/app/src/test/java/com/afternote/afternote_fe/navigation/RecipientChipDestinationTest.kt @@ -0,0 +1,66 @@ +package com.afternote.afternote_fe.navigation + +import android.app.Application +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.navigation.toRoute +import com.afternote.core.ui.Route +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * 홈 «수신인 지정 미완료» 칩이 **어디로 가는지**를 못박는다 (#506 · #1695). + * + * Nav2 시절엔 칩이 `SettingRoute.RecipientRegisterRoute` 를 루트에 직접 찍어 백스택 이름만으로 + * 목적지가 보였다. 설정이 로컬 스택이 된 뒤로는 루트에 [Route.Setting] 한 칸만 쌓이고 등록 화면 + * 진입은 그 인자([Route.Setting.startWithRecipientRegistration])가 나른다 — 인자를 버리는 + * [backStackRouteNames] 로는 설정 홈 진입과 구분되지 않으므로 [toRoute] 로 값까지 꺼내 단언한다. + * 그 인자로 host 가 실제 첫 화면을 고르는 몫은 `SettingImplementedCoverageAndroidTest` 가 본다. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], application = Application::class) +class RecipientChipDestinationTest { + @get:Rule + val composeRule = createComposeRule() + + private lateinit var harness: NavBackStackHarness + + private fun startAtHome() { + composeRule.setContent { + SkeletonAppNavigation(startDestination = Route.Home) { harness = it } + } + composeRule.waitForIdle() + } + + private fun currentSetting(): Route.Setting = + composeRule.runOnIdle { + harness.navController.currentBackStackEntry!!.toRoute() + } + + @Test + fun `수신인 칩은 설정 host 한 칸을 쌓고 등록 화면 시작을 인자로 싣는다`() { + startAtHome() + + composeRule.runOnIdle { harness.homeActions.onRecipientChipClick() } + + assertEquals( + listOf("NavHostRoot", "Home", "Setting"), + composeRule.runOnIdle { harness.navController.backStackRouteNames() }, + ) + assertEquals(true, currentSetting().startWithRecipientRegistration) + } + + @Test + fun `설정 메뉴 진입은 같은 host 에 설정 홈 시작으로 들어간다`() { + startAtHome() + + composeRule.runOnIdle { harness.homeActions.onSettingClick() } + + assertEquals(false, currentSetting().startWithRecipientRegistration) + } +} diff --git a/app/src/test/java/com/afternote/afternote_fe/notification/DailyNotificationScheduleSynchronizerTest.kt b/app/src/test/java/com/afternote/afternote_fe/notification/DailyNotificationScheduleSynchronizerTest.kt new file mode 100644 index 0000000000..2d507cbf14 --- /dev/null +++ b/app/src/test/java/com/afternote/afternote_fe/notification/DailyNotificationScheduleSynchronizerTest.kt @@ -0,0 +1,149 @@ +package com.afternote.afternote_fe.notification + +import android.app.Application +import android.content.Context +import androidx.work.Configuration +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import com.afternote.core.common.notification.DailyNotificationWorker +import com.afternote.core.common.notification.NotificationScheduler +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.testing.FakeAuthRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * 데일리 알림 예약이 로그인 상태를 따라가는지의 회귀 기준 (#2146). + * + * 대역을 두지 않고 실제 WorkManager 에 넣고 지운 예약을 읽는다. 보는 것은 unique work 의 상태다. + * 로그인 상태에서만 ENQUEUED 이고, 로그아웃되면 CANCELLED 가 된다. + * + * `application` 을 `Application` 으로 둔 것은 `GlobalApplication` 이 Hilt 진입점이라 주입이 먼저 + * 돌기 때문이다. 이 테스트는 관찰자를 직접 만든다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class DailyNotificationScheduleSynchronizerTest { + // PushTargetSynchronizerTest 와 같은 이유다. StandardTestDispatcher 로는 backgroundScope 의 collect 가 시작되지 않는다. + private val dispatcher = UnconfinedTestDispatcher() + + private lateinit var context: Context + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + WorkManagerTestInitHelper.initializeTestWorkManager( + context, + Configuration + .Builder() + .setExecutor(SynchronousExecutor()) + .setTaskExecutor(SynchronousExecutor()) + .build(), + ) + } + + @Test + fun `로그아웃 상태로 시작하면 데일리 알림을 예약하지 않는다`() = + runTest(dispatcher) { + backgroundScope.launch { synchronizer(FakeAuthRepository(loggedIn = false)).observeLogin() } + advanceUntilIdle() + + assertTrue(dailyWork().isEmpty()) + } + + @Test + fun `로그아웃 상태로 켜지면 세션 없이 넣어 둔 예약을 지운다`() = + runTest(dispatcher) { + // 이전 빌드의 Initializer 가 세션을 보지 않고 넣어 둔 예약. + NotificationScheduler.scheduleDailyNotification(context, hour = 9, minute = 0) + assertEquals(WorkInfo.State.ENQUEUED, dailyState()) + + backgroundScope.launch { synchronizer(FakeAuthRepository(loggedIn = false)).observeLogin() } + advanceUntilIdle() + + assertEquals(WorkInfo.State.CANCELLED, dailyState()) + } + + @Test + fun `로그인하면 예약하고 로그아웃하면 지운다`() = + runTest(dispatcher) { + val authRepository = FakeAuthRepository(loggedIn = false) + backgroundScope.launch { synchronizer(authRepository).observeLogin() } + advanceUntilIdle() + + authRepository.loggedIn = true + advanceUntilIdle() + assertEquals(WorkInfo.State.ENQUEUED, dailyState()) + + authRepository.loggedIn = false + advanceUntilIdle() + assertEquals(WorkInfo.State.CANCELLED, dailyState()) + } + + @Test + fun `로그아웃 뒤 다시 로그인하면 새로 예약한다`() = + runTest(dispatcher) { + val authRepository = FakeAuthRepository(loggedIn = true) + backgroundScope.launch { synchronizer(authRepository).observeLogin() } + advanceUntilIdle() + + authRepository.loggedIn = false + advanceUntilIdle() + authRepository.loggedIn = true + advanceUntilIdle() + + assertEquals(WorkInfo.State.ENQUEUED, dailyState()) + } + + @Test + fun `로그인한 채 켜지면 이미 있는 예약을 갈아 끼우지 않는다`() = + runTest(dispatcher) { + // 갈아 끼우면 다음 발화 시각이 새 initialDelay 로 밀린다. 알림 시각 전에 앱을 켜는 사용자는 알림을 영영 못 받는다. + NotificationScheduler.scheduleDailyNotification(context, hour = 9, minute = 0) + val existing = dailyWork().single().id + + backgroundScope.launch { synchronizer(FakeAuthRepository(loggedIn = true)).observeLogin() } + advanceUntilIdle() + + val current = dailyWork().single() + assertEquals(existing, current.id) + assertEquals(WorkInfo.State.ENQUEUED, current.state) + } + + private fun synchronizer(authRepository: FakeAuthRepository) = + DailyNotificationScheduleSynchronizer( + context = context, + authRepository = authRepository, + errorReporter = FailingErrorReporter(), + ) + + private fun dailyWork(): List = + WorkManager + .getInstance(context) + .getWorkInfosForUniqueWork(DailyNotificationWorker.UNIQUE_WORK_NAME) + .get() + + private fun dailyState(): WorkInfo.State = dailyWork().single().state + + /** 이 테스트의 경로에서는 실패가 없어야 한다. 기록되면 그 자리에서 드러낸다. */ + private class FailingErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ): Unit = throw AssertionError("예상하지 못한 실패 기록: $attributes", throwable) + } +} diff --git a/app/src/test/java/com/afternote/afternote_fe/notification/NotificationIntentContractTest.kt b/app/src/test/java/com/afternote/afternote_fe/notification/NotificationIntentContractTest.kt index 774d5d65d7..2c2aa80423 100644 --- a/app/src/test/java/com/afternote/afternote_fe/notification/NotificationIntentContractTest.kt +++ b/app/src/test/java/com/afternote/afternote_fe/notification/NotificationIntentContractTest.kt @@ -1,62 +1,158 @@ package com.afternote.afternote_fe.notification +import android.app.Application +import android.content.Intent +import com.afternote.core.common.notification.NotificationDestination +import com.afternote.core.common.notification.NotificationPendingIntentFactory import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +/** + * 알림 진입 Intent 해석 계약의 회귀 기준. + * + * 판정 자체는 `fromIntent()` 뒤의 file-local 구현이라 직접 부르지 않는다 (#1671). 대신 + * `MainActivity` 가 실제로 부르는 `fromIntent(Intent)` 에 진짜 [Intent] 를 넣는다 — 그래야 + * extra 키 이름과 타입까지 계약에 포함된다. 값을 꺼내는 쪽만 검증하면 키를 바꿔 쓰는 회귀는 + * 지나간다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) class NotificationIntentContractTest { @Test fun `marker 없는 일반 launcher 진입은 알림 이벤트가 아니다`() { - val request = - NotificationIntentContract.resolve( - isNotificationEntry = false, - rawSource = "fcm", - occurrenceId = "occurrence-1", - ) - - assertNull(request) + assertNull(NotificationIntentContract.fromIntent(Intent(Intent.ACTION_MAIN))) + assertNull(NotificationIntentContract.fromIntent(notificationIntent(isNotificationEntry = false))) } @Test fun `알 수 없거나 비어 있는 source는 거부한다`() { listOf(null, "", " ", "unknown").forEach { rawSource -> - val request = - NotificationIntentContract.resolve( - isNotificationEntry = true, - rawSource = rawSource, - occurrenceId = "occurrence-1", - ) + val request = NotificationIntentContract.fromIntent(notificationIntent(source = rawSource)) - assertNull(request) + assertNull("source '$rawSource' 를 받으면 안 된다", request) } } @Test fun `occurrence token이 없거나 비어 있으면 거부한다`() { listOf(null, "", " ").forEach { occurrenceId -> + val request = NotificationIntentContract.fromIntent(notificationIntent(occurrenceId = occurrenceId)) + + assertNull("occurrence '$occurrenceId' 를 받으면 안 된다", request) + } + } + + @Test + fun `허용한 source와 occurrence token만 내부 요청으로 보존한다`() { + NotificationEntrySource.entries.forEach { source -> + val request = + NotificationIntentContract.fromIntent( + notificationIntent( + source = source.contractValue, + occurrenceId = "occurrence-${source.contractValue}", + ), + ) + + assertEquals(source, request?.source) + assertEquals("occurrence-${source.contractValue}", request?.occurrenceId) + } + } + + @Test + fun `알림을 만든 PendingIntent 를 그대로 되읽는다`() { + // 쓰는 쪽(core:common)과 읽는 쪽(app)이 같은 키 계약을 쓰는지는 왕복으로만 확인된다. + val pendingIntent = + NotificationPendingIntentFactory.create( + context = RuntimeEnvironment.getApplication(), + source = NotificationEntrySource.DAILY.contractValue, + occurrenceId = "occurrence-1", + destination = NotificationDestination.HOME, + ) + assertNotNull("런처 Intent 를 찾지 못해 PendingIntent 가 만들어지지 않았다", pendingIntent) + + val request = NotificationIntentContract.fromIntent(shadowOf(pendingIntent!!).savedIntent) + + assertEquals(NotificationEntrySource.DAILY, request?.source) + assertEquals("occurrence-1", request?.occurrenceId) + } + + private fun notificationIntent( + isNotificationEntry: Boolean = true, + source: String? = NotificationEntrySource.FCM.contractValue, + occurrenceId: String? = "occurrence-1", + ): Intent = + Intent(Intent.ACTION_MAIN).apply { + putExtra(NotificationPendingIntentFactory.EXTRA_NOTIFICATION_ENTRY, isNotificationEntry) + source?.let { putExtra(NotificationPendingIntentFactory.EXTRA_NOTIFICATION_SOURCE, it) } + occurrenceId?.let { putExtra(NotificationPendingIntentFactory.EXTRA_NOTIFICATION_OCCURRENCE_TOKEN, it) } + } + + @Test + fun `계약에 있는 목적지는 그대로 보존한다`() { + NotificationDestination.entries.forEach { destination -> val request = - NotificationIntentContract.resolve( + resolveIntent( isNotificationEntry = true, rawSource = "daily", - occurrenceId = occurrenceId, + occurrenceId = "occurrence-1", + rawDestination = destination.contractValue, ) - assertNull(request) + assertEquals(destination, request?.destination) } } @Test - fun `허용한 source와 occurrence token만 내부 요청으로 보존한다`() { - NotificationEntrySource.entries.forEach { source -> + fun `목적지가 없거나 해석되지 않아도 알림 자체는 살리고 홈으로 폴백한다`() { + listOf(null, "", " ", "unknown", "HOME", "afternote_home").forEach { rawDestination -> val request = - NotificationIntentContract.resolve( + resolveIntent( isNotificationEntry = true, - rawSource = source.contractValue, - occurrenceId = "occurrence-${source.contractValue}", + rawSource = "fcm", + occurrenceId = "occurrence-1", + rawDestination = rawDestination, ) - assertEquals(source, request?.source) - assertEquals("occurrence-${source.contractValue}", request?.occurrenceId) + assertEquals(NotificationDestination.HOME, request?.destination) } } + + @Test + fun `목적지는 소비 identity에 들어가지 않는다`() { + val toHome = + resolveIntent( + isNotificationEntry = true, + rawSource = "fcm", + occurrenceId = "occurrence-1", + rawDestination = NotificationDestination.HOME.contractValue, + ) + val toTimeLetter = + resolveIntent( + isNotificationEntry = true, + rawSource = "fcm", + occurrenceId = "occurrence-1", + rawDestination = NotificationDestination.TIME_LETTER.contractValue, + ) + + assertEquals(toHome?.identityKey, toTimeLetter?.identityKey) + } + + private fun resolveIntent( + isNotificationEntry: Boolean, + rawSource: String?, + occurrenceId: String?, + rawDestination: String?, + ): NotificationEntryRequest? = + NotificationIntentContract.fromIntent( + notificationIntent(isNotificationEntry, rawSource, occurrenceId).apply { + rawDestination?.let { putExtra(NotificationPendingIntentFactory.EXTRA_NOTIFICATION_DESTINATION, it) } + }, + ) } diff --git a/app/src/test/java/com/afternote/afternote_fe/notification/NotificationPermissionEffectTest.kt b/app/src/test/java/com/afternote/afternote_fe/notification/NotificationPermissionEffectTest.kt new file mode 100644 index 0000000000..dbe2bae349 --- /dev/null +++ b/app/src/test/java/com/afternote/afternote_fe/notification/NotificationPermissionEffectTest.kt @@ -0,0 +1,157 @@ +package com.afternote.afternote_fe.notification + +import android.Manifest +import android.app.Application +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.core.app.ActivityOptionsCompat +import androidx.lifecycle.ViewModelStore +import com.afternote.afternote_fe.R +import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.ui.theme.AfternoteTheme +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.onStart +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** 실제 Effect를 렌더하고 시스템 권한 결과만 테스트 registry로 돌려준다. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = Application::class) +class NotificationPermissionEffectTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val viewModelStore = ViewModelStore() + private val store = RecordingPermissionStore() + private val registry = PermissionResultRegistry() + private val snackbar = SnackbarHostState() + + @After + fun tearDown() { + composeRule.runOnIdle { viewModelStore.clear() } + } + + @Test + @Config(sdk = [32]) + fun `API 32에서는 관찰과 권한 요청을 시작하지 않는다`() { + setPermissionContent() + composeRule.waitForIdle() + + assertEquals(0, store.subscriptions) + assertEquals(emptyList(), registry.permissions) + assertEquals(0, store.recordedRequests) + } + + @Test + fun `로그인 뒤 한 번 요청하고 허용 결과를 Intent로 기록한다`() { + val auth = FakeAuthRepository(loggedIn = false) + setPermissionContent(auth) + composeRule.waitForIdle() + assertEquals(emptyList(), registry.permissions) + + composeRule.runOnIdle { auth.loggedIn = true } + composeRule.waitForIdle() + assertEquals(listOf(Manifest.permission.POST_NOTIFICATIONS), registry.permissions) + + composeRule.runOnIdle { registry.respond(granted = true) } + composeRule.waitForIdle() + assertEquals(1, store.recordedRequests) + assertNull(snackbar.currentSnackbarData) + assertEquals(1, registry.permissions.size) + } + + @Test + fun `이미 허용된 권한은 다이얼로그 없이 기록한다`() { + shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + setPermissionContent() + composeRule.waitForIdle() + + assertEquals(emptyList(), registry.permissions) + assertEquals(1, store.recordedRequests) + assertNull(snackbar.currentSnackbarData) + } + + @Test + fun `거부 결과를 기록하고 안내 액션으로 앱 알림 설정을 연다`() { + setPermissionContent() + composeRule.waitForIdle() + composeRule.runOnIdle { registry.respond(granted = false) } + composeRule.waitForIdle() + + assertEquals(1, store.recordedRequests) + val action = composeRule.activity.getString(R.string.notification_permission_denied_action) + composeRule.onNodeWithText(action).performClick() + composeRule.waitForIdle() + val intent = shadowOf(composeRule.activity).nextStartedActivity + assertEquals(Settings.ACTION_APP_NOTIFICATION_SETTINGS, intent.action) + assertEquals(composeRule.activity.packageName, intent.getStringExtra(Settings.EXTRA_APP_PACKAGE)) + assertEquals(1, registry.permissions.size) + } + + private fun setPermissionContent(auth: FakeAuthRepository = FakeAuthRepository(loggedIn = true)) { + val viewModel = NotificationPermissionViewModel(auth, store) + viewModelStore.put("permission", viewModel) + val registryOwner = + object : ActivityResultRegistryOwner { + override val activityResultRegistry: ActivityResultRegistry = registry + } + composeRule.setContent { + AfternoteTheme { + CompositionLocalProvider(LocalActivityResultRegistryOwner provides registryOwner) { + SnackbarHost(snackbar) + NotificationPermissionEffect(snackbarHostState = snackbar, viewModel = viewModel) + } + } + } + } +} + +private class RecordingPermissionStore : NotificationPermissionRequestStore { + private val state = MutableStateFlow(false) + var subscriptions = 0 + var recordedRequests = 0 + override val hasRequested: Flow = state.onStart { subscriptions++ } + + override suspend fun markRequested() { + recordedRequests++ + state.value = true + } +} + +private class PermissionResultRegistry : ActivityResultRegistry() { + val permissions = mutableListOf() + private var pendingRequestCode: Int? = null + + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) { + permissions += input as String + pendingRequestCode = requestCode + } + + fun respond(granted: Boolean) { + dispatchResult(checkNotNull(pendingRequestCode), granted) + } +} diff --git a/app/src/test/java/com/afternote/afternote_fe/notification/NotificationPermissionViewModelTest.kt b/app/src/test/java/com/afternote/afternote_fe/notification/NotificationPermissionViewModelTest.kt index e0d2cea564..2f8a2a6ca5 100644 --- a/app/src/test/java/com/afternote/afternote_fe/notification/NotificationPermissionViewModelTest.kt +++ b/app/src/test/java/com/afternote/afternote_fe/notification/NotificationPermissionViewModelTest.kt @@ -1,23 +1,30 @@ package com.afternote.afternote_fe.notification import com.afternote.core.domain.testing.FakeAuthRepository -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.job import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import java.io.IOException +import kotlin.coroutines.cancellation.CancellationException @OptIn(ExperimentalCoroutinesApi::class) class NotificationPermissionViewModelTest { @@ -38,10 +45,10 @@ class NotificationPermissionViewModelTest { runTest(dispatcher) { val viewModel = viewModel(loggedIn = false) - backgroundScope.subscribe(viewModel) + viewModel.onIntent(NotificationPermissionIntent.ObservationStarted) advanceUntilIdle() - assertFalse(viewModel.shouldRequest.value) + assertFalse(viewModel.uiState.value.shouldRequest) } @Test @@ -49,10 +56,10 @@ class NotificationPermissionViewModelTest { runTest(dispatcher) { val viewModel = viewModel(loggedIn = true) - backgroundScope.subscribe(viewModel) + viewModel.onIntent(NotificationPermissionIntent.ObservationStarted) advanceUntilIdle() - assertTrue(viewModel.shouldRequest.value) + assertTrue(viewModel.uiState.value.shouldRequest) } @Test @@ -60,10 +67,10 @@ class NotificationPermissionViewModelTest { runTest(dispatcher) { val viewModel = viewModel(loggedIn = true, hasRequested = true) - backgroundScope.subscribe(viewModel) + viewModel.onIntent(NotificationPermissionIntent.ObservationStarted) advanceUntilIdle() - assertFalse(viewModel.shouldRequest.value) + assertFalse(viewModel.uiState.value.shouldRequest) } @Test @@ -72,15 +79,15 @@ class NotificationPermissionViewModelTest { val store = FakeNotificationPermissionRequestStore(hasRequested = false) val viewModel = viewModel(loggedIn = true, store = store) - backgroundScope.subscribe(viewModel) + viewModel.onIntent(NotificationPermissionIntent.ObservationStarted) advanceUntilIdle() - assertTrue(viewModel.shouldRequest.value) + assertTrue(viewModel.uiState.value.shouldRequest) - viewModel.markRequested() + viewModel.onIntent(NotificationPermissionIntent.RecordRequest) advanceUntilIdle() assertEquals(1, store.markRequestedCalls) - assertFalse(viewModel.shouldRequest.value) + assertFalse(viewModel.uiState.value.shouldRequest) } @Test @@ -93,9 +100,9 @@ class NotificationPermissionViewModelTest { store = FakeNotificationPermissionRequestStore(hasRequested = false), ) - backgroundScope.subscribe(viewModel) + viewModel.onIntent(NotificationPermissionIntent.ObservationStarted) advanceUntilIdle() - viewModel.markRequested() + viewModel.onIntent(NotificationPermissionIntent.RecordRequest) advanceUntilIdle() authRepository.loggedIn = false @@ -103,12 +110,163 @@ class NotificationPermissionViewModelTest { authRepository.loggedIn = true advanceUntilIdle() - assertFalse(viewModel.shouldRequest.value) + assertFalse(viewModel.uiState.value.shouldRequest) } - /** `shouldRequest` 는 `WhileSubscribed` 라 구독자가 없으면 upstream 이 돌지 않는다. */ - private fun CoroutineScope.subscribe(viewModel: NotificationPermissionViewModel) { - launch { viewModel.shouldRequest.collect { } } + @Test + fun `관찰 시작 전에는 로그인과 저장소를 구독하지 않는다`() = + runTest(dispatcher) { + val login = TrackingFlow(true) + val requested = TrackingFlow(false) + val viewModel = trackedViewModel(login, requested) + + runCurrent() + + assertEquals(0, login.starts) + assertEquals(0, requested.starts) + assertFalse(viewModel.uiState.value.shouldRequest) + } + + @Test + fun `화면 중단 뒤 5초 유예가 끝나야 두 흐름을 해제한다`() = + runTest(dispatcher) { + val login = TrackingFlow(true) + val requested = TrackingFlow(false) + val viewModel = trackedViewModel(login, requested) + viewModel.onIntent(NotificationPermissionIntent.ObservationStarted) + runCurrent() + + viewModel.onIntent(NotificationPermissionIntent.ObservationStopped) + runCurrent() + advanceTimeBy(4_999) + runCurrent() + assertEquals(1, login.active) + assertEquals(1, requested.active) + + advanceTimeBy(1) + runCurrent() + assertEquals(0, login.active) + assertEquals(0, requested.active) + } + + @Test + fun `중단 중 바뀐 값을 복귀 재구독에서 읽는다`() = + runTest(dispatcher) { + val login = TrackingFlow(false) + val requested = TrackingFlow(false) + val viewModel = trackedViewModel(login, requested) + viewModel.onIntent(NotificationPermissionIntent.ObservationStarted) + runCurrent() + assertFalse(viewModel.uiState.value.shouldRequest) + + viewModel.onIntent(NotificationPermissionIntent.ObservationStopped) + advanceUntilIdle() + login.state.value = true + runCurrent() + assertFalse(viewModel.uiState.value.shouldRequest) + + viewModel.onIntent(NotificationPermissionIntent.ObservationStarted) + runCurrent() + assertTrue(viewModel.uiState.value.shouldRequest) + assertEquals(2, login.starts) + assertEquals(2, requested.starts) + } + + @Test + fun `5초 안에 복귀하면 예약한 해제를 취소하고 중복 구독하지 않는다`() = + runTest(dispatcher) { + val login = TrackingFlow(true) + val requested = TrackingFlow(false) + val viewModel = trackedViewModel(login, requested) + viewModel.onIntent(NotificationPermissionIntent.ObservationStarted) + runCurrent() + viewModel.onIntent(NotificationPermissionIntent.ObservationStopped) + advanceTimeBy(4_000) + viewModel.onIntent(NotificationPermissionIntent.ObservationStarted) + viewModel.onIntent(NotificationPermissionIntent.ObservationStarted) + advanceTimeBy(2_000) + runCurrent() + + requested.state.value = true + runCurrent() + assertFalse(viewModel.uiState.value.shouldRequest) + assertEquals(1, login.starts) + assertEquals(1, requested.starts) + assertEquals(1, login.active) + assertEquals(1, requested.active) + } + + @Test + fun `요청 기록 저장이 실패하면 기록된 것으로 위장하지 않는다`() = + runTest(dispatcher) { + val store = FakeNotificationPermissionRequestStore(hasRequested = false, failWrite = true) + val viewModel = viewModel(loggedIn = true, store = store) + viewModel.onIntent(NotificationPermissionIntent.ObservationStarted) + advanceUntilIdle() + + viewModel.onIntent(NotificationPermissionIntent.RecordRequest) + advanceUntilIdle() + + assertEquals(1, store.markRequestedCalls) + assertTrue(viewModel.uiState.value.shouldRequest) + } + + @Test + fun `요청 기록의 취소는 정상 완료로 삼키지 않는다`() = + runTest(dispatcher) { + val cancellation = CancellationException("screen cancelled") + var completionCause: Throwable? = null + val store = + object : NotificationPermissionRequestStore { + override val hasRequested: Flow = MutableStateFlow(false) + + override suspend fun markRequested() { + currentCoroutineContext().job.invokeOnCompletion { completionCause = it } + throw cancellation + } + } + val viewModel = viewModel(loggedIn = true, store = store) + viewModel.onIntent(NotificationPermissionIntent.ObservationStarted) + runCurrent() + + viewModel.onIntent(NotificationPermissionIntent.RecordRequest) + runCurrent() + + assertSame(cancellation, completionCause) + assertTrue(viewModel.uiState.value.shouldRequest) + } + + private fun trackedViewModel( + login: TrackingFlow, + requested: TrackingFlow, + ) = NotificationPermissionViewModel( + authRepository = FakeAuthRepository(onIsLoggedIn = { login.flow }), + store = + object : NotificationPermissionRequestStore { + override val hasRequested: Flow = requested.flow + + override suspend fun markRequested() { + requested.state.value = true + } + }, + ) + + private class TrackingFlow( + initialValue: Boolean, + ) { + val state = MutableStateFlow(initialValue) + var starts = 0 + var active = 0 + val flow: Flow = + flow { + starts++ + active++ + try { + emitAll(state) + } finally { + active-- + } + } } private fun viewModel( @@ -122,6 +280,7 @@ class NotificationPermissionViewModelTest { private class FakeNotificationPermissionRequestStore( hasRequested: Boolean, + private val failWrite: Boolean = false, ) : NotificationPermissionRequestStore { private val state = MutableStateFlow(hasRequested) @@ -132,6 +291,7 @@ class NotificationPermissionViewModelTest { override suspend fun markRequested() { markRequestedCalls++ + if (failWrite) throw IOException("write failed") state.value = true } } diff --git a/app/src/test/java/com/afternote/afternote_fe/update/ForceUpdateGateTest.kt b/app/src/test/java/com/afternote/afternote_fe/update/ForceUpdateGateTest.kt new file mode 100644 index 0000000000..bb2be103d1 --- /dev/null +++ b/app/src/test/java/com/afternote/afternote_fe/update/ForceUpdateGateTest.kt @@ -0,0 +1,206 @@ +package com.afternote.afternote_fe.update + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.repository.appversion.AppVersionRepository +import com.afternote.core.model.appversion.AppVersionCheck +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * 강제 업데이트 관문의 판정 계약 회귀 가드 (#1539). + * + * 이 관문이 잘못 켜지면 **모든 사용자가 앱에 들어오지 못한다**. 그래서 "막는다" 보다 + * "막지 않는다" 쪽 갈래를 더 촘촘히 고정한다 — 조회 실패·보낼 곳 없음·갱신 불가 빌드. + */ +class ForceUpdateGateTest { + @Test + fun `조회에 실패하면 관문을 걸지 않고 실패만 남긴다`() = + runTest { + val errorReporter = RecordingErrorReporter() + val gate = + gate( + repository = FailingAppVersionRepository(IOException("서버 무응답")), + errorReporter = errorReporter, + ) + + gate.refresh() + + assertNull(gate.prompt.value) + val reported = errorReporter.attributes.single() + assertEquals("force_update_check", reported["stage"]) + assertEquals(IOException::class.java.name, reported["error_type"]) + } + + @Test + fun `업데이트가 필요 없으면 관문을 걸지 않는다`() = + runTest { + val errorReporter = RecordingErrorReporter() + val gate = + gate( + repository = check(updateRequired = false, storeUrl = null), + errorReporter = errorReporter, + ) + + gate.refresh() + + assertNull(gate.prompt.value) + assertTrue(errorReporter.attributes.isEmpty()) + } + + @Test + fun `스토어 배포 빌드에 실 스토어 주소가 오면 관문을 건다`() = + runTest { + val gate = + gate( + repository = check(updateRequired = true, storeUrl = PLAY_URL), + installedBuild = storeBuild, + ) + + gate.refresh() + + assertEquals(ForceUpdatePrompt(PLAY_URL), gate.prompt.value) + } + + @Test + fun `market 스킴도 실 스토어 주소로 본다`() = + runTest { + val marketUrl = "market://details?id=com.afternote.afternote_fe" + val gate = + gate( + repository = check(updateRequired = true, storeUrl = marketUrl), + installedBuild = storeBuild, + ) + + gate.refresh() + + assertEquals(ForceUpdatePrompt(marketUrl), gate.prompt.value) + } + + @Test + fun `보낼 스토어 주소가 없으면 관문을 걸지 않고 실패를 남긴다`() = + runTest { + val errorReporter = RecordingErrorReporter() + val gate = + gate( + repository = check(updateRequired = true, storeUrl = null), + installedBuild = storeBuild, + errorReporter = errorReporter, + ) + + gate.refresh() + + assertNull(gate.prompt.value) + val reported = errorReporter.attributes.single() + assertEquals("force_update_store_url", reported["stage"]) + // 타입을 이름으로 부르지 않는다 — 그 타입은 이 파일 밖으로 나갈 이유가 없다(#1678). + // 콘솔에서 이 갈래를 가르는 것이 곧 이 문자열이라, 관측되는 값 그대로 고정한다. + assertEquals( + "com.afternote.afternote_fe.update.UnroutableStoreUrlException", + reported["error_type"], + ) + } + + @Test + fun `플레이스홀더 주소는 스토어로 보지 않는다`() = + runTest { + val errorReporter = RecordingErrorReporter() + val gate = + gate( + // dev 서버가 APP_ANDROID_STORE_URL 미설정으로 실제 내려주는 값 (2026-08-30 실측). + repository = check(updateRequired = true, storeUrl = "http://your-playstore-domain"), + installedBuild = storeBuild, + errorReporter = errorReporter, + ) + + gate.refresh() + + assertNull(gate.prompt.value) + assertEquals("force_update_store_url", errorReporter.attributes.single()["stage"]) + } + + @Test + fun `스토어가 갱신할 수 없는 빌드면 관문을 걸지 않는다`() = + runTest { + val errorReporter = RecordingErrorReporter() + val gate = + gate( + repository = check(updateRequired = true, storeUrl = PLAY_URL), + installedBuild = InstalledBuild(versionCode = 1, storeDistributed = false), + errorReporter = errorReporter, + ) + + gate.refresh() + + assertNull(gate.prompt.value) + assertTrue(errorReporter.attributes.isEmpty()) + } + + @Test + fun `설치본의 versionCode 를 그대로 서버에 묻는다`() = + runTest { + val repository = RecordingAppVersionRepository(AppVersionCheck(false, 10_001, null)) + val gate = gate(repository = repository, installedBuild = InstalledBuild(10_002, true)) + + gate.refresh() + + assertEquals(listOf(10_002), repository.asked) + } + + private fun gate( + repository: AppVersionRepository, + installedBuild: InstalledBuild = InstalledBuild(versionCode = 1, storeDistributed = false), + errorReporter: ErrorReporter = RecordingErrorReporter(), + ) = ForceUpdateGate( + appVersionRepository = repository, + installedBuild = installedBuild, + errorReporter = errorReporter, + ) + + private fun check( + updateRequired: Boolean, + storeUrl: String?, + ) = RecordingAppVersionRepository( + AppVersionCheck(updateRequired = updateRequired, latestVersionCode = 10_001, storeUrl = storeUrl), + ) + + private companion object { + const val PLAY_URL = "https://play.google.com/store/apps/details?id=com.afternote.afternote_fe" + val storeBuild = InstalledBuild(versionCode = 10_000, storeDistributed = true) + } +} + +private class RecordingAppVersionRepository( + private val result: AppVersionCheck, +) : AppVersionRepository { + val asked = mutableListOf() + + override suspend fun checkAndroidVersion(versionCode: Int): Result { + asked += versionCode + return Result.success(result) + } +} + +private class FailingAppVersionRepository( + private val error: Throwable, +) : AppVersionRepository { + override suspend fun checkAndroidVersion(versionCode: Int): Result = Result.failure(error) +} + +/** + * [ErrorReporter] 는 예외 문구를 지우고 타입만 속성으로 남긴다. 그래서 단언은 `error_type` · + * `stage` 로 한다 — 던진 예외 인스턴스는 리포터를 통과하면서 사라진다. + */ +private class RecordingErrorReporter : ErrorReporter { + val attributes = mutableListOf>() + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + this.attributes += attributes + } +} diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 7eea243adf..938128259b 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -27,12 +27,12 @@ dependencies { testImplementation(libs.junit) testImplementation(gradleTestKit()) - // 루트 build.gradle.kts 의 보안 하한은 별도 빌드인 여기까지 미치지 않는다 — 같은 근거(#921·#981· + // 루트 build.gradle.kts 의 보안 하한은 별도 빌드인 여기까지 미치지 않는다 — 같은 근거(#2132·#981· // #982·#985). AGP 9.3.2 이 이 클래스패스에도 같은 취약 버전을 끌어온다(netty 는 여기 없다). constraints { listOf("bcprov-jdk18on", "bcpkix-jdk18on", "bcutil-jdk18on").forEach { artifact -> implementation("org.bouncycastle:$artifact:${libs.versions.bouncycastle.get()}") { - because("GHSA-574f-3g2m-x479 등 1.84 미만 취약 — #921") + because("GHSA-9pwp-9qqc-pr26·GHSA-qp49-qgx5-5m26 — 1.85 미만 취약 — #2132") } } implementation("org.apache.commons:commons-lang3:${libs.versions.commonsLang3.get()}") { diff --git a/build-logic/src/main/kotlin/AndroidNavigationConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidNavigationConventionPlugin.kt index 7dcd996443..7b938228a5 100644 --- a/build-logic/src/main/kotlin/AndroidNavigationConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidNavigationConventionPlugin.kt @@ -1,6 +1,13 @@ import org.gradle.api.Plugin import org.gradle.api.Project +/** + * 네비게이션을 쓰는 모듈의 공통 의존성. + * + * Navigation 2 와 Navigation 3 를 함께 얹는다 — 로컬 스택 이관(#1698)이 진행되는 동안 루트 + * `NavHost` 는 Nav2 를 계속 쓰고, 각 피처는 그 아래에서 Nav3 `NavDisplay` 로 동작한다. + * Nav2 제거는 루트를 `NavDisplay` 로 바꾸는 #1702 가 소유한다. + */ class AndroidNavigationConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { @@ -8,6 +15,10 @@ class AndroidNavigationConventionPlugin : Plugin { afterNoteDependencies { implementation("android-navigation-compose") + implementation("androidx-navigation3-runtime") + implementation("androidx-navigation3-ui") + // NavEntry 범위 ViewModel 스코프 — rememberViewModelStoreNavEntryDecorator() + implementation("androidx-lifecycle-viewmodel-navigation3") implementation("kotlinx-serialization-json") } } diff --git a/build-logic/src/main/kotlin/BuildFingerprint.kt b/build-logic/src/main/kotlin/BuildFingerprint.kt index 21197cc51b..9ac618827a 100644 --- a/build-logic/src/main/kotlin/BuildFingerprint.kt +++ b/build-logic/src/main/kotlin/BuildFingerprint.kt @@ -9,7 +9,7 @@ import javax.inject.Inject const val CI_COMMIT_SHA_ENV = "GITHUB_SHA" const val SHORT_COMMIT_SHA_LENGTH = 7 -const val UNKNOWN_COMMIT_MARKER = "unknown" +private const val UNKNOWN_COMMIT_MARKER = "unknown" /** * debug `versionName` 에 붙일 빌드 지문을 만든다 — `1.0+a3f91c2` · `1.0+a3f91c2-dirty` · `1.0+unknown`. @@ -23,7 +23,7 @@ const val UNKNOWN_COMMIT_MARKER = "unknown" * [UNKNOWN_COMMIT_MARKER] 를 남기는 것도 같은 이유다 — 접미사 없는 `1.0` 은 "sha 를 못 읽었다"와 * "이 기능이 없던 빌드"를 구분해 주지 않아, 없는 근거를 만들 여지를 남긴다. */ -fun debugVersionNameSuffix( +private fun debugVersionNameSuffix( rawCommitSha: String?, hasUncommittedChanges: Boolean, ): String { @@ -37,7 +37,7 @@ fun debugVersionNameSuffix( * 입력이 두 갈래라 정규화가 필요하다 — CI 의 `GITHUB_SHA` 도 로컬 `git rev-parse HEAD` 도 40자다. * QA 대장 파일명(`docs/qa/evidence/.json`)과 대조하려면 자릿수가 일정해야 한다. */ -fun shortCommitSha(rawCommitSha: String?): String? { +private fun shortCommitSha(rawCommitSha: String?): String? { val value = rawCommitSha?.trim()?.lowercase() ?: return null if (!value.matches(Regex("[0-9a-f]{$SHORT_COMMIT_SHA_LENGTH,40}"))) return null return value.take(SHORT_COMMIT_SHA_LENGTH) diff --git a/build-logic/src/main/kotlin/DebugSigningDiagnostics.kt b/build-logic/src/main/kotlin/DebugSigningDiagnostics.kt new file mode 100644 index 0000000000..6a7d6fda0f --- /dev/null +++ b/build-logic/src/main/kotlin/DebugSigningDiagnostics.kt @@ -0,0 +1,64 @@ +import org.gradle.api.GradleException +import org.gradle.api.Project +import java.io.File +import java.security.KeyStore +import java.security.MessageDigest +import java.util.Base64 + +/** + * debug 서명 폴백을 매 빌드에 알리고, 실제 서명 인증서의 카카오 키 해시를 제공한다. + * + * configuration cache 재사용 시에도 경고가 남도록 태스크 액션에서 출력한다. + * 액션에는 AGP DSL이나 Project 대신 확정된 서명 값만 캡처한다. + */ +public fun Project.registerDebugSigningDiagnostics( + usingDefaultKeystore: Boolean, + storeFile: File?, + storePassword: String?, + keyAlias: String?, +) { + val warning = + tasks.register("checkDebugSigningForKakao") { + group = "verification" + description = "기본 debug 서명으로 카카오톡 로그인 QA가 제한될 수 있음을 알린다." + doLast { + if (usingDefaultKeystore) { + logger.warn( + """ + |DEBUG_* 서명 설정이 없어 AGP 기본 debug keystore로 폴백합니다. + |이 키 해시가 카카오 콘솔에 등록되지 않았다면 카카오톡 앱 로그인에 실패하고 웹 로그인으로 폴백할 수 있습니다. + |웹 로그인 성공만으로 카카오톡 앱-투-앱 로그인 QA를 완료하지 마세요. + |README '공유 debug keystore'를 따라 설정하거나, ./gradlew :app:printDebugKakaoKeyHash 출력값을 콘솔에 등록하세요. + """.trimMargin(), + ) + } + } + } + tasks.matching { it.name == "preDebugBuild" }.configureEach { dependsOn(warning) } + + tasks.register("printDebugKakaoKeyHash") { + group = "help" + description = "실제 debug 서명 인증서의 카카오 키 해시(Base64 SHA-1)를 출력한다." + // 새 머신에서는 AGP가 기본 debug keystore를 먼저 생성하도록 한다. + dependsOn("validateSigningDebug") + doLast { + logger.lifecycle("Debug Kakao key hash: ${debugKakaoKeyHash(storeFile, storePassword, keyAlias)}") + } + } +} + +private fun debugKakaoKeyHash( + storeFile: File?, + storePassword: String?, + keyAlias: String?, +): String { + if (storeFile?.isFile != true || storePassword == null || keyAlias.isNullOrBlank()) { + throw GradleException("debug 서명 설정과 keystore 파일을 확인하세요. README '공유 debug keystore' 참고.") + } + // 파일 형식을 탐지해 AGP 기본 JKS와 팀 공유 PKCS12 keystore를 모두 지원한다. + val keyStore = KeyStore.getInstance(storeFile, storePassword.toCharArray()) + val certificate = + keyStore.getCertificate(keyAlias) + ?: throw GradleException("debug 서명 alias의 인증서를 찾을 수 없습니다. DEBUG_KEY_ALIAS를 확인하세요.") + return Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-1").digest(certificate.encoded)) +} diff --git a/build-logic/src/main/kotlin/JvmLintConventionPlugin.kt b/build-logic/src/main/kotlin/JvmLintConventionPlugin.kt index 7c722e45a0..9c663d0859 100644 --- a/build-logic/src/main/kotlin/JvmLintConventionPlugin.kt +++ b/build-logic/src/main/kotlin/JvmLintConventionPlugin.kt @@ -5,9 +5,8 @@ import org.gradle.api.Project * 컨벤션 밖에서 java-library 를 직접 쓰는 JVM 모듈에 ktlint 만 붙인다. * * [JvmLibraryConventionPlugin] 은 Java/Kotlin 타깃까지 통일하므로, 타깃을 자기 사정으로 고정해야 - * 하는 모듈(현재 `:konsist`·`:feature:setting:domain` 이 Java 11)은 그것을 탈 수 없다. 그렇다고 - * ktlint 설정을 모듈마다 복붙하면 카탈로그 버전·리포터 설정이 갈라진다 — `afternote.android.lint` - * 의 JVM 짝이다. + * 하는 모듈(현재 `:konsist` 가 Java 11)은 그것을 탈 수 없다. 그렇다고 ktlint 설정을 모듈마다 + * 복붙하면 카탈로그 버전·리포터 설정이 갈라진다 — `afternote.android.lint` 의 JVM 짝이다. */ class JvmLintConventionPlugin : Plugin { override fun apply(target: Project) { diff --git a/build-logic/src/main/kotlin/VersionCode.kt b/build-logic/src/main/kotlin/VersionCode.kt index cc6a9babab..c1cc19189b 100644 --- a/build-logic/src/main/kotlin/VersionCode.kt +++ b/build-logic/src/main/kotlin/VersionCode.kt @@ -1,8 +1,8 @@ import org.gradle.api.GradleException const val AFTERNOTE_VERSION_CODE_ENV = "AFTERNOTE_VERSION_CODE" -const val DEFAULT_AFTERNOTE_VERSION_CODE = 1 -const val MAX_PLAY_VERSION_CODE = 2_100_000_000 +private const val DEFAULT_AFTERNOTE_VERSION_CODE = 1 +private const val MAX_PLAY_VERSION_CODE = 2_100_000_000 /** * Keeps ordinary local/Firebase builds on the existing versionCode while allowing an approved Play @@ -18,11 +18,11 @@ fun resolveAfternoteVersionCode(rawValue: String?): Int { ) } - val parsed = value.toLongOrNull() + val parsed = value.toIntOrNull() if (parsed == null || parsed > MAX_PLAY_VERSION_CODE) { throw GradleException( "$AFTERNOTE_VERSION_CODE_ENV 는 Google Play 최대값 $MAX_PLAY_VERSION_CODE 이하여야 합니다.", ) } - return parsed.toInt() + return parsed } diff --git a/build-logic/src/test/kotlin/BuildFingerprintTest.kt b/build-logic/src/test/kotlin/BuildFingerprintTest.kt index 366fb7f308..482e5d037c 100644 --- a/build-logic/src/test/kotlin/BuildFingerprintTest.kt +++ b/build-logic/src/test/kotlin/BuildFingerprintTest.kt @@ -1,43 +1,151 @@ +import org.gradle.testkit.runner.GradleRunner import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +/** + * debug `versionName` 접미사의 빌드 계약 회귀 테스트 (Gradle TestKit). + * + * 지문을 만드는 두 함수는 `BuildFingerprint.kt` 안에서만 쓰이는 file-local 구현이라 테스트가 + * 직접 부르지 않는다 (#1671). 대신 빌드 스크립트가 실제로 부르는 `resolveDebugVersionNameSuffix()` + * 를 스텁 프로젝트에서 돌려 **빌드에 최종 적용되는 문자열**을 고정한다 — 검증 대상이 계산식이 + * 아니라 «QA 가 `dumpsys` 로 읽게 될 값» 이라는 점도 이 방향이 맞다(#1135). + * + * 환경변수를 매번 명시적으로 넘긴다. CI 러너에는 `GITHUB_SHA` 가 이미 있어서, git 경로를 검증하는 + * 테스트가 조용히 CI 경로로 새어 들어가면 로컬에서만 초록인 테스트가 된다. + */ class BuildFingerprintTest { - private val fullSha = "a3f91c2b8d4e5f60718293a4b5c6d7e8f9012345" + @get:Rule + val projectDir = TemporaryFolder() - @Test - fun `커밋을 읽었고 워킹트리가 깨끗하면 short sha 만 붙인다`() { - assertEquals("+a3f91c2", debugVersionNameSuffix(fullSha, hasUncommittedChanges = false)) + @Before + fun setUp() { + writeStubProject() } @Test - fun `커밋 안 된 변경이 있으면 dirty 를 함께 붙인다`() { - assertEquals("+a3f91c2-dirty", debugVersionNameSuffix(fullSha, hasUncommittedChanges = true)) + fun `CI 는 GITHUB_SHA 를 다듬어 앞 7자리만 붙인다`() { + assertEquals("+a3f91c2", suffixWithCommitSha(" A3F91C2B8D4E5F60718293A4B5C6D7E8F9012345 ")) } + /** + * 형식에 안 맞는 `GITHUB_SHA` 는 커밋으로 받지 않는다. + * + * **세 입력이 각각 다른 회귀를 잡는다.** 케이스당 Gradle 실행 1회가 들어 한 테스트에 묶었다. + * + * | 입력 | 무너지면 나오는 값 | 무엇이 깨진 것인가 | + * |---|---|---| + * | `not-a-sha` | — | hex 런이 없어 어떤 완화에도 안 걸린다. 기본 경로 | + * | `a3f91c2-dirty` | `+a3f91c2` | `matches`(완전일치)를 `containsMatchIn`/`find` 로 바꾼 것. **거짓 clean 지문** | + * | `a3f91c`(6자) | `+a3f91c` | `{7,40}` 하한이 무너진 것. 자릿수가 흔들린다 | + * + * 가운데가 KDoc 이 말하는 QA 증거 사고 그 자체다 — dirty 빌드가 clean 지문을 달고 나간다. + */ @Test - fun `커밋을 특정할 수 없으면 접미사를 생략하지 않고 unknown 을 남긴다`() { - assertEquals("+$UNKNOWN_COMMIT_MARKER", debugVersionNameSuffix(null, hasUncommittedChanges = false)) - // 커밋을 모르면 무엇 대비 dirty 인지도 말할 수 없다 — dirty 를 붙이지 않는다. - assertEquals("+$UNKNOWN_COMMIT_MARKER", debugVersionNameSuffix(null, hasUncommittedChanges = true)) + fun `16진수가 아닌 GITHUB_SHA 는 커밋으로 받지 않고 unknown 을 남긴다`() { + // 접미사를 생략하면 «sha 를 못 읽었다» 와 «이 기능이 없던 빌드» 를 구분할 수 없다. + assertEquals("+unknown", suffixWithCommitSha("not-a-sha")) + + // 부분 일치로 완화하면 여기서 «+a3f91c2» 라는 거짓 clean 지문이 나온다. + assertEquals("+unknown", suffixWithCommitSha("a3f91c2-dirty")) + + // 하한이 무너지면 여기서 «+a3f91c» 로 자릿수가 흔들린다. + assertEquals("+unknown", suffixWithCommitSha("a3f91c")) } @Test - fun `sha 는 앞 7자리로 정규화한다`() { - assertEquals("a3f91c2", shortCommitSha(fullSha)) - assertEquals("a3f91c2", shortCommitSha("a3f91c2")) - assertEquals("a3f91c2", shortCommitSha(" A3F91C2B8D \n")) + fun `커밋을 특정할 수 없는 빌드에도 접미사를 남긴다`() { + // git 저장소가 아니라 rev-parse 가 실패한다. 커밋을 모르면 dirty 도 말할 수 없어 붙이지 않는다. + assertEquals("+unknown", suffixFromGitWorkingTree()) } @Test - fun `16진수가 아니거나 7자리에 못 미치는 값은 커밋으로 받지 않는다`() { - listOf("", " ", "a3f91c", "z3f91c2", "a3f91c2-dirty", "HEAD", "$fullSha" + "0").forEach { invalid -> - assertNull("커밋으로 받으면 안 된다: '$invalid'", shortCommitSha(invalid)) - } + fun `워킹트리가 깨끗하면 short sha 만 붙이고 변경이 있으면 dirty 를 함께 붙인다`() { + val shortSha = initGitRepositoryWithSingleCommit() + + assertEquals("+$shortSha", suffixFromGitWorkingTree()) + + projectDir.newFile("uncommitted.txt").writeText("edited\n") + + // untracked 도 dirty 로 센다 — 거짓 clean 은 검증하지 않은 코드에 QA 증거를 붙이게 한다. + assertEquals("+$shortSha-dirty", suffixFromGitWorkingTree()) } - @Test - fun `정규화에 실패한 값은 커밋 없이 빌드한 것과 같게 다룬다`() { - assertEquals("+$UNKNOWN_COMMIT_MARKER", debugVersionNameSuffix("not-a-sha", hasUncommittedChanges = false)) + private fun suffixWithCommitSha(ciCommitSha: String): String = resolvedSuffix(System.getenv() + mapOf(CI_COMMIT_SHA to ciCommitSha)) + + private fun suffixFromGitWorkingTree(): String = resolvedSuffix(System.getenv() - CI_COMMIT_SHA) + + private fun resolvedSuffix(environment: Map): String { + val output = + GradleRunner + .create() + .withProjectDir(projectDir.root) + .withEnvironment(environment) + .withArguments("showBuildFingerprint") + .build() + .output + + return SUFFIX_MARKER.find(output)?.groupValues?.get(1) + ?: error("빌드 출력에서 접미사를 찾지 못했다:\n$output") + } + + /** 스텁 프로젝트를 git 저장소로 만들고 커밋한 뒤 기대 short sha 를 돌려준다. */ + private fun initGitRepositoryWithSingleCommit(): String { + // Gradle 이 빌드마다 만드는 산출물까지 dirty 로 세면 clean 경로를 검증할 수 없다. + projectDir.newFile(".gitignore").writeText(".gradle/\nbuild/\n") + git("init") + git("add", "--all") + git("-c", "user.email=build-logic@example.com", "-c", "user.name=build-logic", "commit", "--message", "stub") + return git("rev-parse", "HEAD").take(7) + } + + private fun git(vararg arguments: String): String { + val process = + ProcessBuilder(listOf("git", *arguments)) + .directory(projectDir.root) + .redirectErrorStream(true) + .start() + val output = + process.inputStream + .bufferedReader() + .readText() + .trim() + val exitCode = process.waitFor() + check(exitCode == 0) { "git ${arguments.joinToString(" ")} 실패($exitCode):\n$output" } + return output + } + + private fun writeStubProject() { + val classpathLiteral = + guardClasspath() + .split(File.pathSeparator) + .filter { it.isNotBlank() } + .joinToString(", ") { "\"${it.replace("\\", "/")}\"" } + projectDir.newFile("settings.gradle.kts").writeText("rootProject.name = \"fingerprint-stub\"\n") + projectDir.newFile("build.gradle.kts").writeText( + """ + buildscript { + dependencies { + classpath(files($classpathLiteral)) + } + } + + println("suffix=[" + resolveDebugVersionNameSuffix() + "]") + + tasks.register("showBuildFingerprint") + """.trimIndent() + "\n", + ) + } + + private fun guardClasspath(): String = + System.getProperty("guardClasspath") + ?: error("guardClasspath 시스템 프로퍼티 누락 — build-logic/build.gradle.kts 의 Test 태스크 설정 참고") + + private companion object { + const val CI_COMMIT_SHA = "GITHUB_SHA" + val SUFFIX_MARKER = Regex("suffix=\\[(.*)]") } } diff --git a/build-logic/src/test/kotlin/DebugSigningDiagnosticsTest.kt b/build-logic/src/test/kotlin/DebugSigningDiagnosticsTest.kt new file mode 100644 index 0000000000..6c521533ba --- /dev/null +++ b/build-logic/src/test/kotlin/DebugSigningDiagnosticsTest.kt @@ -0,0 +1,178 @@ +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.Base64 + +class DebugSigningDiagnosticsTest { + @get:Rule + val projectDir = TemporaryFolder() + + @Test + fun `기본 서명 경고는 configuration cache 재사용 빌드에도 출력된다`() { + writeStubProject(usingDefaultKeystore = true) + + val first = runner("assembleDebug").build() + val second = runner("assembleDebug").build() + + assertTrue(first.output.contains("Configuration cache entry stored")) + assertTrue(second.output.contains("Reusing configuration cache")) + listOf(first, second).forEach { result -> + assertEquals(TaskOutcome.SUCCESS, result.task(":checkDebugSigningForKakao")?.outcome) + assertTrue(result.output.contains("AGP 기본 debug keystore로 폴백합니다")) + assertTrue(result.output.contains(":app:printDebugKakaoKeyHash")) + assertFalse(result.output.contains("test-store-password")) + } + } + + @Test + fun `공유 서명은 캐시 재사용 빌드에서도 폴백 경고를 내지 않는다`() { + writeStubProject(usingDefaultKeystore = false) + + runner("assembleDebug").build() + val second = runner("assembleDebug").build() + + assertTrue(second.output.contains("Reusing configuration cache")) + assertEquals(TaskOutcome.SUCCESS, second.task(":checkDebugSigningForKakao")?.outcome) + assertFalse(second.output.contains("AGP 기본 debug keystore로 폴백합니다")) + } + + @Test + fun `release와 help 경로에서는 debug 경고를 실행하지 않는다`() { + writeStubProject(usingDefaultKeystore = true) + + listOf("assembleRelease", "help").forEach { task -> + val result = runner(task).build() + assertNull(result.task(":checkDebugSigningForKakao")) + assertFalse(result.output.contains("AGP 기본 debug keystore로 폴백합니다")) + } + } + + @Test + fun `JKS debug 인증서 해시는 keytool 지문과 일치하며 매번 출력된다`() { + assertKeyHash("JKS", usingDefaultKeystore = true) + } + + @Test + fun `PKCS12 공유 인증서 해시는 설정된 alias를 사용한다`() { + assertKeyHash("PKCS12", usingDefaultKeystore = false) + } + + @Test + fun `존재하지 않는 alias는 조용히 다른 인증서로 대체하지 않는다`() { + generateKeyStore("PKCS12") + writeStubProject(usingDefaultKeystore = false, alias = "missing-alias") + + val result = runner("printDebugKakaoKeyHash").buildAndFail() + + assertTrue(result.output.contains("DEBUG_KEY_ALIAS를 확인하세요")) + assertFalse(result.output.contains("test-store-password")) + assertFalse(result.output.contains("Debug Kakao key hash:")) + } + + private fun assertKeyHash( + storeType: String, + usingDefaultKeystore: Boolean, + ) { + generateKeyStore(storeType) + // 다른 인증서가 먼저 있어도 지정한 alias의 해시를 읽어야 한다. + generateKeyStore(storeType, alias = "selected-debug-key") + writeStubProject(usingDefaultKeystore, alias = "selected-debug-key") + val listing = keytool("-list", "-v", "-alias", "selected-debug-key") + val fingerprint = Regex("SHA1: ([0-9A-F:]+)").find(listing)!!.groupValues[1] + val expected = Base64.getEncoder().encodeToString(fingerprint.split(":").map { it.toInt(16).toByte() }.toByteArray()) + + val first = runner("printDebugKakaoKeyHash").build() + val second = runner("printDebugKakaoKeyHash").build() + + assertTrue(second.output.contains("Reusing configuration cache")) + listOf(first, second).forEach { result -> + assertTrue(result.output.contains("Debug Kakao key hash: $expected")) + assertEquals(TaskOutcome.SUCCESS, result.task(":printDebugKakaoKeyHash")?.outcome) + assertTrue( + result.tasks.indexOf(result.task(":validateSigningDebug")) < result.tasks.indexOf(result.task(":printDebugKakaoKeyHash")), + ) + assertFalse(result.output.contains("test-store-password")) + assertFalse(result.output.contains("PRIVATE KEY")) + } + } + + private fun generateKeyStore( + storeType: String, + alias: String = "other-key", + ) { + keytool( + "-genkeypair", + "-alias", + alias, + "-keyalg", + "RSA", + "-keysize", + "2048", + "-validity", + "1", + "-dname", + "CN=Afternote Test", + "-storetype", + storeType, + "-keypass", + "test-store-password", + ) + } + + private fun keytool(vararg arguments: String): String { + val command = + listOf( + File(System.getProperty("java.home"), "bin/keytool").absolutePath, + "-J-Duser.language=en", + "-keystore", + File(projectDir.root, "debug.keystore").absolutePath, + "-storepass", + "test-store-password", + ) + arguments + val process = ProcessBuilder(command).redirectErrorStream(true).start() + val output = process.inputStream.bufferedReader().use { it.readText() } + assertEquals(output, 0, process.waitFor()) + return output + } + + private fun writeStubProject( + usingDefaultKeystore: Boolean, + alias: String = "other-key", + ) { + val classpath = + System + .getProperty("guardClasspath") + .split(File.pathSeparator) + .filter { it.isNotBlank() } + .joinToString(", ") { "\"${it.replace("\\", "/")}\"" } + projectDir.newFile("settings.gradle.kts").writeText("rootProject.name = \"debug-signing-stub\"\n") + projectDir.newFile("build.gradle.kts").writeText( + """ + buildscript { dependencies { classpath(files($classpath)) } } + registerDebugSigningDiagnostics( + usingDefaultKeystore = $usingDefaultKeystore, + storeFile = file("debug.keystore"), + storePassword = "test-store-password", + keyAlias = "$alias", + ) + tasks.register("preDebugBuild") + tasks.register("assembleDebug") { dependsOn("preDebugBuild") } + tasks.register("assembleRelease") + tasks.register("validateSigningDebug") + """.trimIndent() + "\n", + ) + } + + private fun runner(vararg arguments: String): GradleRunner = + GradleRunner + .create() + .withProjectDir(projectDir.root) + .withArguments(*arguments, "--configuration-cache", "--configuration-cache-problems=fail", "--console=plain") +} diff --git a/build-logic/src/test/kotlin/VersionCodeTest.kt b/build-logic/src/test/kotlin/VersionCodeTest.kt index 6d74b55ac0..8a9341fc07 100644 --- a/build-logic/src/test/kotlin/VersionCodeTest.kt +++ b/build-logic/src/test/kotlin/VersionCodeTest.kt @@ -3,16 +3,23 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Test +/** + * `versionCode` 주입 계약의 회귀 기준. + * + * 기대값을 프로덕션 상수로 쓰지 않고 리터럴로 적는다 (#1671). 상수끼리 비교하면 값이 바뀌어도 + * 테스트는 계속 통과해 «Play 상한이 2_100_000_000 이다» 라는 계약을 아무도 지키지 않는다. + * 여기 적힌 숫자가 바뀌어야 할 때는 계약이 바뀐 때뿐이다. + */ class VersionCodeTest { @Test fun `override가 없으면 기존 versionCode를 유지한다`() { - assertEquals(DEFAULT_AFTERNOTE_VERSION_CODE, resolveAfternoteVersionCode(null)) + assertEquals(1, resolveAfternoteVersionCode(null)) } @Test fun `유효한 Play versionCode를 주입한다`() { assertEquals(101, resolveAfternoteVersionCode("101")) - assertEquals(MAX_PLAY_VERSION_CODE, resolveAfternoteVersionCode("$MAX_PLAY_VERSION_CODE")) + assertEquals(2_100_000_000, resolveAfternoteVersionCode("2100000000")) } @Test @@ -26,8 +33,10 @@ class VersionCodeTest { @Test fun `Google Play 최대 versionCode를 넘으면 실패한다`() { - assertThrows(GradleException::class.java) { - resolveAfternoteVersionCode("2100000001") + listOf("2100000001", "2147483648", "9223372036854775808").forEach { invalid -> + assertThrows(GradleException::class.java) { + resolveAfternoteVersionCode(invalid) + } } } } diff --git a/build.gradle.kts b/build.gradle.kts index 912ff56c66..42d07c9c9e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,13 +1,13 @@ // AGP 9.3.2·Firebase App Distribution 5.3.0 이 buildscript classpath 로 끌어오는 전이 의존성 중 -// 보안 권고 영향권인 것들의 하한(#921·#975~#985). 루트 classpath 는 plugins 블록 처리 시점에 +// 보안 권고 영향권인 것들의 하한(#975~#985·#2132). 루트 classpath 는 plugins 블록 처리 시점에 // 리졸브가 끝나 아래 본문 훅으로는 늦고, buildscript 블록에서는 버전 카탈로그 accessor 를 쓸 수 없다 // — libs.versions.toml 의 같은 이름 버전과 값을 맞춰 유지할 것. buildscript { dependencies { constraints { listOf("bcprov-jdk18on", "bcpkix-jdk18on", "bcutil-jdk18on").forEach { artifact -> - add("classpath", "org.bouncycastle:$artifact:1.84") { - because("GHSA-574f-3g2m-x479 등 1.84 미만 취약 — #921") + add("classpath", "org.bouncycastle:$artifact:1.85") { + because("GHSA-9pwp-9qqc-pr26·GHSA-qp49-qgx5-5m26 — 1.85 미만 취약 — #2132") } } add("classpath", "org.apache.commons:commons-lang3:3.18.0") { @@ -53,7 +53,7 @@ plugins { alias(libs.plugins.firebase.crashlytics) apply false } -// 빌드·테스트 클래스패스의 보안 하한(#921·#975~#985·#1058·#1072·#1262). 상류가 취약 버전을 물고 있고 상류 +// 빌드·테스트 클래스패스의 보안 하한(#975~#985·#1058·#1072·#1262·#2132). 상류가 취약 버전을 물고 있고 상류 // 최신판도 아직 패치 버전 미만이라 constraint 로 올린다 — Robolectric 4.15.1(bcprov 1.80)·AGP // 9.3.2(bcprov 1.79·commons-lang3 3.16.0·jose4j 0.9.5·jdom2 2.0.6)·Firebase App Distribution 5.3.0 // 과 AGP UTP 설정(netty — unified-test-platform-core 가 4.1.93, -host-emulator-control 이 4.1.110) @@ -75,7 +75,7 @@ val securityFloors = SecurityFloor( module = "org.bouncycastle:$artifact", version = libs.versions.bouncycastle.get(), - because = "GHSA-574f-3g2m-x479 등 1.84 미만 취약 — #921", + because = "GHSA-9pwp-9qqc-pr26·GHSA-qp49-qgx5-5m26 — 1.85 미만 취약 — #2132", ) } + listOf( diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 8e22dc4207..a227880794 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -12,6 +12,10 @@ android { dependencies { implementation(libs.androidx.work.runtime.ktx) + // BiometricCryptoGate 가 BiometricPrompt.CryptoObject 를 공개 시그니처로 돌려주므로 + // 소비 모듈의 컴파일 클래스패스에도 올라가야 한다 — implementation 이면 반환 타입을 못 본다. + api(libs.androidx.biometric) + // launchMemorialVideo 의 URL 검증이 android.net.Uri 파싱에 기대므로 JVM 테스트에 Android // 구현이 필요하다. 매니페스트는 읽지 않으므로 isIncludeAndroidResources 는 켜지 않는다. testImplementation(libs.robolectric) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricCryptoGate.kt b/core/common/src/main/kotlin/com/afternote/core/common/biometric/BiometricCryptoGate.kt similarity index 95% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricCryptoGate.kt rename to core/common/src/main/kotlin/com/afternote/core/common/biometric/BiometricCryptoGate.kt index b9e1164fff..9688a5db95 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricCryptoGate.kt +++ b/core/common/src/main/kotlin/com/afternote/core/common/biometric/BiometricCryptoGate.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.shared.fingerprint +package com.afternote.core.common.biometric import android.os.Build import android.security.keystore.KeyGenParameterSpec @@ -11,6 +11,7 @@ import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey +private const val LOG_TAG = "BiometricCryptoGate" private const val KEY_ALIAS = "afternote_biometric_gate" private const val ANDROID_KEYSTORE = "AndroidKeyStore" @@ -34,7 +35,7 @@ private val CRYPTO_CHALLENGE = "afternote-biometric-gate".toByteArray() * minSdk 는 26 이고 기기 잠금(PIN·패턴)만 등록한 사용자를 막지 않는 것이 현 정책이므로, * API 26~29 에서는 허용자 조합을 유지한 채 `CryptoObject` 없이 인증한다. */ -internal val isBiometricCryptoSupported: Boolean +val isBiometricCryptoSupported: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R /** @@ -44,7 +45,7 @@ internal val isBiometricCryptoSupported: Boolean * 이 관문은 열람 게이트일 뿐 보관 중인 암호문이 없어, 키를 쓸 수 없는 기기에서까지 로그인을 * 막는 것은 얻는 것보다 잃는 것이 크다. */ -internal fun createBiometricCryptoObject(): BiometricPrompt.CryptoObject? = +fun createBiometricCryptoObject(): BiometricPrompt.CryptoObject? = runCatching { BiometricPrompt.CryptoObject(initEncryptCipher()) } .onFailure { Log.w(LOG_TAG, "CryptoObject 준비 실패 — crypto 없이 진행한다", it) } .getOrNull() @@ -69,7 +70,7 @@ internal fun createBiometricCryptoObject(): BiometricPrompt.CryptoObject? = * * 그래서 [runCatching] 으로 전부 받는다 — 좁은 catch 로 바꾸면 그 예외가 그대로 앱을 타고 올라간다. */ -internal fun confirmWithCryptoOperation(cipher: Cipher?): Result { +fun confirmWithCryptoOperation(cipher: Cipher?): Result { if (cipher == null) return Result.success(Unit) return runCatching { cipher.doFinal(CRYPTO_CHALLENGE) }.map { } } diff --git a/core/common/src/main/kotlin/com/afternote/core/common/notification/DailyNotificationWorker.kt b/core/common/src/main/kotlin/com/afternote/core/common/notification/DailyNotificationWorker.kt index ccd5674d0a..00eb76e80f 100644 --- a/core/common/src/main/kotlin/com/afternote/core/common/notification/DailyNotificationWorker.kt +++ b/core/common/src/main/kotlin/com/afternote/core/common/notification/DailyNotificationWorker.kt @@ -31,6 +31,7 @@ class DailyNotificationWorker( context = applicationContext, source = NOTIFICATION_SOURCE, occurrenceId = UUID.randomUUID().toString(), + destination = NOTIFICATION_DESTINATION, ) val notification = @@ -70,5 +71,12 @@ class DailyNotificationWorker( private const val CHANNEL_ID = "DAILY_CHANNEL_ID" private const val NOTIFICATION_ID = 1001 private const val NOTIFICATION_SOURCE = "daily" + + /** + * 일일 알림 문구(`core_common_notification_daily_text` — 「오늘 하루 누구한테 가장 + * 고마웠나요?」)는 데일리 질문 답변을 재촉하는 말이다. 그 화면을 품은 탭이 마음의 기록이라 + * 탭 자체를 목적지로 둔다. 개별 질문 화면은 인자가 필요해 BE·FE 양쪽 계약이 더 서야 한다. + */ + private val NOTIFICATION_DESTINATION = NotificationDestination.MIND_RECORD } } diff --git a/core/common/src/main/kotlin/com/afternote/core/common/notification/NotificationDestination.kt b/core/common/src/main/kotlin/com/afternote/core/common/notification/NotificationDestination.kt new file mode 100644 index 0000000000..c8a7c71600 --- /dev/null +++ b/core/common/src/main/kotlin/com/afternote/core/common/notification/NotificationDestination.kt @@ -0,0 +1,36 @@ +package com.afternote.core.common.notification + +/** + * 알림 탭이 향할 앱 최상위 화면 (#1111). + * + * 알림 Intent extra 로 프로세스 경계를 넘나드는 값이라 [contractValue] 는 리팩터링에도 흔들리지 + * 않는 고정 문자열이다. enum 이름을 바꿔도 이 값은 유지해야 한다 — 이미 시스템 트레이에 떠 있는 + * 알림의 [android.app.PendingIntent] 는 옛 값을 담은 채 살아 있다. + * + * 목적지 집합을 **최상위 화면으로 제한**한다. 알림은 앱 밖에서 오는 입력이라 어떤 화면이든 열 수 + * 있게 두면 인자·권한 관문을 우회하는 통로가 된다. 실제 `Route` 매핑은 app 모듈이 갖는다 — + * `core:common` 은 화면 그래프를 알지 않는다(그래서 이 모듈의 알림 코드가 런처 Intent 로 우회한다). + */ +enum class NotificationDestination( + val contractValue: String, +) { + HOME("home"), + MIND_RECORD("mind_record"), + TIME_LETTER("time_letter"), + AFTERNOTE("afternote"), + ; + + companion object { + private val valuesByContractValue = entries.associateBy(NotificationDestination::contractValue) + + /** + * 계약 밖 값·공백·null 은 모두 `null` 이다. 폴백 화면을 무엇으로 둘지는 해석하는 쪽이 + * 정한다 — 이 함수는 「계약에 있는 값인가」만 판정한다. + */ + fun fromContractValue(value: String?): NotificationDestination? = + value + ?.trim() + ?.takeIf(String::isNotEmpty) + ?.let(valuesByContractValue::get) + } +} diff --git a/core/common/src/main/kotlin/com/afternote/core/common/notification/NotificationPendingIntentFactory.kt b/core/common/src/main/kotlin/com/afternote/core/common/notification/NotificationPendingIntentFactory.kt index faccb9bada..c41b97f70b 100644 --- a/core/common/src/main/kotlin/com/afternote/core/common/notification/NotificationPendingIntentFactory.kt +++ b/core/common/src/main/kotlin/com/afternote/core/common/notification/NotificationPendingIntentFactory.kt @@ -10,7 +10,11 @@ import android.content.Intent * [PendingIntent]의 동일성 비교에는 extras가 포함되지 않는다. 따라서 내부의 고정 request code를 * 재사용하더라도 서로 다른 알림 발생이 alias되지 않도록 action에 [source]와 * [occurrenceId]을 모두 포함한다. 대상 Activity 이름은 알지 않고 패키지의 런처 Intent를 - * 사용하므로 이 모듈은 app/feature Route에 의존하지 않는다. + * 사용하므로 이 모듈은 app/feature Route에 의존하지 않는다. [destination]도 같은 이유로 + * `Route`가 아니라 [NotificationDestination] 계약값으로 실린다 — 화면으로의 번역은 app 몫이다. + * + * [destination]은 action에 넣지 않는다. 목적지는 발생(occurrence)의 **속성**이지 식별자가 + * 아니므로, 같은 발생을 다시 게시하면 `FLAG_UPDATE_CURRENT`가 extras를 갱신하는 것이 옳다. * * 런처 Activity의 manifest `launchMode`는 그대로 두고 `NEW_TASK | CLEAR_TOP | SINGLE_TOP`을 * 이 알림 Intent에만 적용한다. 따라서 cold start와 기존 top Activity의 `onNewIntent` 진입을 @@ -19,6 +23,8 @@ import android.content.Intent * * @param source 알림을 만든 내부 출처 * @param occurrenceId 같은 출처 안에서 알림 발생을 구분하는 식별자 + * @param destination 탭했을 때 열 최상위 화면. 생산자가 반드시 정한다 — 기본값을 두면 목적지 + * 배선을 빠뜨린 알림이 조용히 홈으로만 떨어진다. */ object NotificationPendingIntentFactory { const val EXTRA_NOTIFICATION_ENTRY = "com.afternote.notification.ENTRY" @@ -30,11 +36,13 @@ object NotificationPendingIntentFactory { * 알림을 탭했을 때 진입 정보를 못 읽는다. 상수 이름은 그 키를 그대로 가리키게 둔다. */ const val EXTRA_NOTIFICATION_OCCURRENCE_TOKEN = "com.afternote.notification.OCCURRENCE_TOKEN" + const val EXTRA_NOTIFICATION_DESTINATION = "com.afternote.notification.DESTINATION" fun create( context: Context, source: String, occurrenceId: String, + destination: NotificationDestination, ): PendingIntent? { val action = notificationAction(source = source, occurrenceId = occurrenceId) val launchIntent = @@ -44,6 +52,7 @@ object NotificationPendingIntentFactory { putExtra(EXTRA_NOTIFICATION_ENTRY, true) putExtra(EXTRA_NOTIFICATION_SOURCE, source) putExtra(EXTRA_NOTIFICATION_OCCURRENCE_TOKEN, occurrenceId) + putExtra(EXTRA_NOTIFICATION_DESTINATION, destination.contractValue) this.action = action addFlags( Intent.FLAG_ACTIVITY_NEW_TASK or @@ -61,7 +70,7 @@ object NotificationPendingIntentFactory { ) } - internal fun notificationAction( + private fun notificationAction( source: String, occurrenceId: String, ): String { diff --git a/core/common/src/main/kotlin/com/afternote/core/common/notification/NotificationScheduler.kt b/core/common/src/main/kotlin/com/afternote/core/common/notification/NotificationScheduler.kt index a123ab51cf..488af0b1aa 100644 --- a/core/common/src/main/kotlin/com/afternote/core/common/notification/NotificationScheduler.kt +++ b/core/common/src/main/kotlin/com/afternote/core/common/notification/NotificationScheduler.kt @@ -55,4 +55,9 @@ object NotificationScheduler { dailyWorkRequest, ) } + + /** [scheduleDailyNotification] 이 넣은 예약을 지운다. 예약이 없으면 아무 일도 하지 않는다. */ + fun cancelDailyNotification(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(DailyNotificationWorker.UNIQUE_WORK_NAME) + } } diff --git a/core/common/src/test/java/com/afternote/core/common/notification/NotificationDestinationTest.kt b/core/common/src/test/java/com/afternote/core/common/notification/NotificationDestinationTest.kt new file mode 100644 index 0000000000..8fbd7f98f8 --- /dev/null +++ b/core/common/src/test/java/com/afternote/core/common/notification/NotificationDestinationTest.kt @@ -0,0 +1,40 @@ +package com.afternote.core.common.notification + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NotificationDestinationTest { + @Test + fun `계약값으로 목적지를 되찾는다`() { + NotificationDestination.entries.forEach { destination -> + assertEquals( + destination, + NotificationDestination.fromContractValue(destination.contractValue), + ) + } + } + + @Test + fun `계약 밖 값과 빈 값은 목적지가 아니다`() { + listOf(null, "", " ", "HOME", "unknown", "home2").forEach { raw -> + assertNull(NotificationDestination.fromContractValue(raw)) + } + } + + @Test + fun `앞뒤 공백은 다듬어 해석한다`() { + assertEquals( + NotificationDestination.HOME, + NotificationDestination.fromContractValue(" home\n"), + ) + } + + @Test + fun `계약값은 서로 다르고 비어 있지 않다`() { + val contractValues = NotificationDestination.entries.map(NotificationDestination::contractValue) + + assertEquals(contractValues.size, contractValues.toSet().size) + assertEquals(emptyList(), contractValues.filter(String::isBlank)) + } +} diff --git a/core/common/src/test/java/com/afternote/core/common/notification/NotificationPendingIntentFactoryTest.kt b/core/common/src/test/java/com/afternote/core/common/notification/NotificationPendingIntentFactoryTest.kt index 52e6ae308f..feeb695f47 100644 --- a/core/common/src/test/java/com/afternote/core/common/notification/NotificationPendingIntentFactoryTest.kt +++ b/core/common/src/test/java/com/afternote/core/common/notification/NotificationPendingIntentFactoryTest.kt @@ -1,52 +1,124 @@ package com.afternote.core.common.notification +import android.app.PendingIntent +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +/** + * 알림 content [PendingIntent] 의 발생 구분 계약 회귀 가드. + * + * [PendingIntent] 의 동일성은 `Intent.filterEquals`(action·data·component…)로 판정하고 **extras 는 + * 보지 않는다**. 그래서 서로 다른 알림 발생이 같은 request code 를 재사용하면서도 alias 되지 + * 않으려면 action 이 갈려야 한다. 검증은 action 문자열 조립기가 아니라 팩토리가 실제로 내놓는 + * [PendingIntent] 로 한다 — 조립 규칙은 파일 내부 구현이고, 계약은 «다른 발생이면 다른 + * PendingIntent» 다 (#1672). + */ +@RunWith(RobolectricTestRunner::class) class NotificationPendingIntentFactoryTest { + private val context: Context get() = RuntimeEnvironment.getApplication() + + @Before + fun registerLauncherActivity() { + // 라이브러리 모듈 매니페스트에는 런처가 없다 — 팩토리가 쓰는 launcher Intent 를 심어 둔다. + val launcher = ComponentName(context.packageName, "com.afternote.core.common.TestLauncherActivity") + val shadowPackageManager = shadowOf(context.packageManager) + shadowPackageManager.addActivityIfNotPresent(launcher) + shadowPackageManager.addIntentFilterForActivity( + launcher, + IntentFilter(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }, + ) + } + @Test - fun `같은 source와 occurrence token은 같은 action을 만든다`() { - val first = NotificationPendingIntentFactory.notificationAction("fcm", "message-1") - val second = NotificationPendingIntentFactory.notificationAction("fcm", "message-1") + fun `같은 source와 occurrence token은 같은 PendingIntent 로 수렴한다`() { + val first = create("fcm", "message-1") + val second = create("fcm", "message-1") assertEquals(first, second) } @Test - fun `source 또는 occurrence token이 다르면 action이 다르다`() { - val baseline = NotificationPendingIntentFactory.notificationAction("fcm", "message-1") + fun `source 또는 occurrence token이 다르면 다른 PendingIntent 다`() { + val baseline = create("fcm", "message-1") - assertNotEquals(baseline, NotificationPendingIntentFactory.notificationAction("daily", "message-1")) - assertNotEquals(baseline, NotificationPendingIntentFactory.notificationAction("fcm", "message-2")) + assertNotEquals(baseline, create("daily", "message-1")) + assertNotEquals(baseline, create("fcm", "message-2")) } @Test - fun `Java hash가 충돌하는 occurrence token도 다른 action을 만든다`() { + fun `Java hash가 충돌하는 occurrence token도 alias 되지 않는다`() { assertEquals("Aa".hashCode(), "BB".hashCode()) - val first = NotificationPendingIntentFactory.notificationAction("fcm", "Aa") - val second = NotificationPendingIntentFactory.notificationAction("fcm", "BB") - - assertNotEquals(first, second) + assertNotEquals(create("fcm", "Aa"), create("fcm", "BB")) } @Test - fun `source와 token의 경계가 달라지면 이어 붙인 문자가 같아도 action이 다르다`() { - val first = NotificationPendingIntentFactory.notificationAction("a", "bc") - val second = NotificationPendingIntentFactory.notificationAction("ab", "c") - - assertNotEquals(first, second) + fun `source와 token의 경계가 달라지면 이어 붙인 문자가 같아도 alias 되지 않는다`() { + assertNotEquals(create("a", "bc"), create("ab", "c")) } @Test fun `빈 identity 구성요소는 거부한다`() { assertThrows(IllegalArgumentException::class.java) { - NotificationPendingIntentFactory.notificationAction(" ", "token") + NotificationPendingIntentFactory.create( + context, + source = " ", + occurrenceId = "token", + destination = NotificationDestination.HOME, + ) } assertThrows(IllegalArgumentException::class.java) { - NotificationPendingIntentFactory.notificationAction("fcm", "") + NotificationPendingIntentFactory.create( + context, + source = "fcm", + occurrenceId = "", + destination = NotificationDestination.HOME, + ) } } + + @Test + fun `진입 정보는 launcher Intent extras 로 실려 나간다`() { + val intent = shadowOf(create("fcm", "message-1")).savedIntent + + assertTrue(intent.getBooleanExtra(NotificationPendingIntentFactory.EXTRA_NOTIFICATION_ENTRY, false)) + assertEquals("fcm", intent.getStringExtra(NotificationPendingIntentFactory.EXTRA_NOTIFICATION_SOURCE)) + assertEquals( + "message-1", + intent.getStringExtra(NotificationPendingIntentFactory.EXTRA_NOTIFICATION_OCCURRENCE_TOKEN), + ) + assertEquals( + NotificationDestination.HOME.contractValue, + intent.getStringExtra(NotificationPendingIntentFactory.EXTRA_NOTIFICATION_DESTINATION), + ) + assertEquals( + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP, + intent.flags, + ) + } + + private fun create( + source: String, + occurrenceId: String, + ): PendingIntent = + requireNotNull( + NotificationPendingIntentFactory.create( + context, + source = source, + occurrenceId = occurrenceId, + destination = NotificationDestination.HOME, + ), + ) { "런처 Intent 가 없어 PendingIntent 를 만들지 못했습니다" } } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricCryptoGateTest.kt b/core/common/src/test/kotlin/com/afternote/core/common/biometric/BiometricCryptoGateTest.kt similarity index 96% rename from feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricCryptoGateTest.kt rename to core/common/src/test/kotlin/com/afternote/core/common/biometric/BiometricCryptoGateTest.kt index 92c57ed973..f229a600f2 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricCryptoGateTest.kt +++ b/core/common/src/test/kotlin/com/afternote/core/common/biometric/BiometricCryptoGateTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.shared.fingerprint +package com.afternote.core.common.biometric import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/core/data/src/main/java/com/afternote/core/data/di/CoreAppVersionRepositoryModule.kt b/core/data/src/main/java/com/afternote/core/data/di/CoreAppVersionRepositoryModule.kt new file mode 100644 index 0000000000..bdf3553943 --- /dev/null +++ b/core/data/src/main/java/com/afternote/core/data/di/CoreAppVersionRepositoryModule.kt @@ -0,0 +1,17 @@ +package com.afternote.core.data.di + +import com.afternote.core.data.repoimpl.appversion.AppVersionRepositoryImpl +import com.afternote.core.domain.repository.appversion.AppVersionRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class CoreAppVersionRepositoryModule { + @Binds + @Singleton + abstract fun bindAppVersionRepository(impl: AppVersionRepositoryImpl): AppVersionRepository +} diff --git a/core/data/src/main/java/com/afternote/core/data/di/CoreUserRepositoryModule.kt b/core/data/src/main/java/com/afternote/core/data/di/CoreUserRepositoryModule.kt index 0709991fc5..3c3ebc982d 100644 --- a/core/data/src/main/java/com/afternote/core/data/di/CoreUserRepositoryModule.kt +++ b/core/data/src/main/java/com/afternote/core/data/di/CoreUserRepositoryModule.kt @@ -1,6 +1,8 @@ package com.afternote.core.data.di +import com.afternote.core.data.repoimpl.MyProfileRepositoryImpl import com.afternote.core.data.repoimpl.UserProfileCacheRepositoryImpl +import com.afternote.core.data.repoimpl.UserReceiverRepositoryImpl import com.afternote.core.data.repoimpl.UserRepositoryImpl import com.afternote.core.data.repoimpl.auth.AuthRepositoryImpl import com.afternote.core.domain.repository.MyProfileRepository @@ -36,14 +38,14 @@ abstract class CoreUserRepositoryModule { @Singleton internal abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository - // 책임별 좁은 계약 2종 (#1282). `UserRepositoryImpl` 을 다시 요청하면 바인딩마다 별도 - // 인스턴스가 생기므로(클래스가 아니라 위 @Binds 가 @Singleton), 이미 싱글턴인 - // [bindUserRepository] 바인딩을 경유해 전부 같은 인스턴스로 위임한다. + // 책임별 좁은 계약 2종 (#1282). 상태를 가진 `UserReceiverRepositoryImpl` 은 클래스에 @Singleton 이 있어 + // 합본 `UserRepositoryImpl` 이 생성자로 받는 인스턴스와 여기서 바인딩되는 인스턴스가 같다 — 좁은 계약으로 + // 만든 수신자가 합본 구독자의 목록도 갱신한다. `MyProfileRepositoryImpl` 은 상태가 없어 unscoped 다. @Binds - internal abstract fun bindUserReceiverRepository(impl: UserRepository): UserReceiverRepository + internal abstract fun bindUserReceiverRepository(impl: UserReceiverRepositoryImpl): UserReceiverRepository @Binds - internal abstract fun bindMyProfileRepository(impl: UserRepository): MyProfileRepository + internal abstract fun bindMyProfileRepository(impl: MyProfileRepositoryImpl): MyProfileRepository @Binds @Singleton diff --git a/core/data/src/main/java/com/afternote/core/data/mapper/delivery/DeliveryConditionMapper.kt b/core/data/src/main/java/com/afternote/core/data/mapper/delivery/DeliveryConditionMapper.kt index 3f72b4bc6e..39b981fc75 100644 --- a/core/data/src/main/java/com/afternote/core/data/mapper/delivery/DeliveryConditionMapper.kt +++ b/core/data/src/main/java/com/afternote/core/data/mapper/delivery/DeliveryConditionMapper.kt @@ -18,7 +18,7 @@ import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionDto // Enum Mapper (DTO → Domain) // ======================================== -fun DeliveryContentTypeDto.toDomain(): DeliveryContentType = +private fun DeliveryContentTypeDto.toDomain(): DeliveryContentType = when (this) { DeliveryContentTypeDto.TIME_LETTER -> DeliveryContentType.TIME_LETTER DeliveryContentTypeDto.AFTERNOTE -> DeliveryContentType.AFTERNOTE @@ -27,20 +27,20 @@ fun DeliveryContentTypeDto.toDomain(): DeliveryContentType = DeliveryContentTypeDto.DEEP_THOUGHT -> DeliveryContentType.DEEP_THOUGHT } -fun DeliveryConditionTypeDto.toDomain(): DeliveryConditionType = +private fun DeliveryConditionTypeDto.toDomain(): DeliveryConditionType = when (this) { DeliveryConditionTypeDto.INACTIVITY -> DeliveryConditionType.INACTIVITY DeliveryConditionTypeDto.RECEIVER_REQUEST -> DeliveryConditionType.RECEIVER_REQUEST } -fun InactivityPeriodDto.toDomain(): InactivityPeriod = +private fun InactivityPeriodDto.toDomain(): InactivityPeriod = when (this) { InactivityPeriodDto.THREE_MONTHS -> InactivityPeriod.THREE_MONTHS InactivityPeriodDto.SIX_MONTHS -> InactivityPeriod.SIX_MONTHS InactivityPeriodDto.ONE_YEAR -> InactivityPeriod.ONE_YEAR } -fun ConditionStateDto.toDomain(): ConditionState = +private fun ConditionStateDto.toDomain(): ConditionState = when (this) { ConditionStateDto.ACTIVE -> ConditionState.ACTIVE ConditionStateDto.PENDING_CONFIRMATION -> ConditionState.PENDING_CONFIRMATION @@ -52,7 +52,7 @@ fun ConditionStateDto.toDomain(): ConditionState = // Enum Mapper (Domain → DTO, 요청용) // ======================================== -fun DeliveryContentType.toDto(): DeliveryContentTypeDto = +private fun DeliveryContentType.toDto(): DeliveryContentTypeDto = when (this) { DeliveryContentType.TIME_LETTER -> DeliveryContentTypeDto.TIME_LETTER DeliveryContentType.AFTERNOTE -> DeliveryContentTypeDto.AFTERNOTE @@ -61,13 +61,13 @@ fun DeliveryContentType.toDto(): DeliveryContentTypeDto = DeliveryContentType.DEEP_THOUGHT -> DeliveryContentTypeDto.DEEP_THOUGHT } -fun DeliveryConditionType.toDto(): DeliveryConditionTypeDto = +private fun DeliveryConditionType.toDto(): DeliveryConditionTypeDto = when (this) { DeliveryConditionType.INACTIVITY -> DeliveryConditionTypeDto.INACTIVITY DeliveryConditionType.RECEIVER_REQUEST -> DeliveryConditionTypeDto.RECEIVER_REQUEST } -fun InactivityPeriod.toDto(): InactivityPeriodDto = +private fun InactivityPeriod.toDto(): InactivityPeriodDto = when (this) { InactivityPeriod.THREE_MONTHS -> InactivityPeriodDto.THREE_MONTHS InactivityPeriod.SIX_MONTHS -> InactivityPeriodDto.SIX_MONTHS @@ -78,7 +78,7 @@ fun InactivityPeriod.toDto(): InactivityPeriodDto = // Response Mapper (DTO → Domain) // ======================================== -fun DeliveryConditionItemDto.toDomain(): DeliveryConditionItem = +private fun DeliveryConditionItemDto.toDomain(): DeliveryConditionItem = DeliveryConditionItem( contentType = contentType.toDomain(), conditionType = conditionType.toDomain(), diff --git a/core/data/src/main/java/com/afternote/core/data/mapper/user/UserMapper.kt b/core/data/src/main/java/com/afternote/core/data/mapper/user/UserMapper.kt index 2cdacecdf8..184735ac69 100644 --- a/core/data/src/main/java/com/afternote/core/data/mapper/user/UserMapper.kt +++ b/core/data/src/main/java/com/afternote/core/data/mapper/user/UserMapper.kt @@ -4,15 +4,11 @@ import com.afternote.core.model.user.Receiver import com.afternote.core.model.user.ReceiverCreated import com.afternote.core.model.user.ReceiverDetail import com.afternote.core.model.user.User -import com.afternote.core.model.user.UserConnectedAccount -import com.afternote.core.model.user.UserPushSetting import com.afternote.core.network.dto.ReceiverDetailDto import com.afternote.core.network.dto.ReceiverListDto -import com.afternote.core.network.dto.UserConnectedAccountDto import com.afternote.core.network.dto.UserCreateReceiverDto import com.afternote.core.network.dto.UserDto import com.afternote.core.network.dto.UserPatchReceiverDto -import com.afternote.core.network.dto.UserPushSettingDto // ======================================== // Enum Mapper @@ -30,26 +26,29 @@ fun UserDto.toDomain(): User = profileImageUrl = profileImageUrl, ) -fun ReceiverListDto.toDomain(): Receiver = +/** 목록 응답의 `relation` 은 DB 가 null 을 허용해 비어 올 수 있다 — 빈 문자열로 채워 [Receiver] 의 문자열 계약을 유지한다 (#2105). */ +internal fun ReceiverListDto.toDomain(): Receiver = Receiver( receiverId = receiverId, name = name, - relation = relation, - authCode = authCode, + relation = relation.orEmpty(), ) +/** + * 상세 응답의 nullable `relation` 을 빈 문자열로 옮긴다 (#2155). + * 수정 화면은 빈 관계를 선택 안 함으로 그린다. + */ fun ReceiverDetailDto.toDomain(): ReceiverDetail = ReceiverDetail( receiverId = receiverId, name = name, - relation = relation, + relation = relation.orEmpty(), phone = phone, email = email, dailyQuestionCount = dailyQuestionCount, timeLetterCount = timeLetterCount, afterNoteCount = afterNoteCount, message = message, - authCode = authCode, ) fun UserCreateReceiverDto.toDomain(): ReceiverCreated = @@ -63,26 +62,4 @@ fun UserPatchReceiverDto.toDomain(): Receiver = receiverId = receiverId, name = name, relation = relation, - authCode = "", - ) - -fun UserPushSettingDto.toDomain(): UserPushSetting = - UserPushSetting( - timeLetter = timeLetter, - mindRecord = mindRecord, - afterNote = afterNote, - ) - -fun UserConnectedAccountDto.toDomain(): UserConnectedAccount = - UserConnectedAccount( - local = local, - google = google, - naver = naver, - kakao = kakao, - apple = apple, - localEmail = localEmail, - googleEmail = googleEmail, - naverEmail = naverEmail, - kakaoEmail = kakaoEmail, - appleEmail = appleEmail, ) diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/MyProfileRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/MyProfileRepositoryImpl.kt new file mode 100644 index 0000000000..429a30a643 --- /dev/null +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/MyProfileRepositoryImpl.kt @@ -0,0 +1,43 @@ +package com.afternote.core.data.repoimpl + +import com.afternote.core.data.mapper.user.toDomain +import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.model.user.User +import com.afternote.core.network.dto.UserUpdateProfileRequestDto +import com.afternote.core.network.model.requireData +import com.afternote.core.network.service.UserApiService +import javax.inject.Inject + +/** + * 서버 정본 프로필 조회·수정 구현 (#1282). + * + * 상태를 갖지 않고 [UserApiService] 위임과 매퍼가 전부다 — 세션·캐시는 수신자 계약 전용이다. + * 로컬 캐시(사용자 이름·패스키 등록 여부)는 별개 책임이라 `UserProfileRepositoryImpl` 이 담당한다. + * 상태가 없으므로 스코프를 두지 않는다 — 인스턴스가 몇 개든 관찰되는 동작이 같다. + */ +internal class MyProfileRepositoryImpl + @Inject + constructor( + private val userApiService: UserApiService, + ) : MyProfileRepository { + override suspend fun getMyProfile(): User = + userApiService + .getMyProfile() + .requireData() + .toDomain() + + override suspend fun updateMyProfile( + name: String?, + phone: String?, + profileImageUrl: String?, + ): User = + userApiService + .updateMyProfile( + UserUpdateProfileRequestDto( + name = name, + phone = phone, + profileImageUrl = profileImageUrl, + ), + ).requireData() + .toDomain() + } diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/PhotoUploadRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/PhotoUploadRepositoryImpl.kt index 68e28a56bf..55222618f6 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/PhotoUploadRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/PhotoUploadRepositoryImpl.kt @@ -48,19 +48,17 @@ private val MIME_TO_EXTENSION = "image/jfif" to "jpeg", "video/quicktime" to "mov", "audio/mpeg" to "mp3", + "audio/mp4" to "m4a", "audio/x-m4a" to "m4a", ) private fun resolveExtension(mime: String?): String { if (mime == null) return DEFAULT_EXTENSION + MIME_TO_EXTENSION[mime] + ?.takeIf { it in ALLOWED_EXTENSIONS } + ?.let { return it } val fromMime = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) - val resolved = - if (fromMime != null && fromMime in ALLOWED_EXTENSIONS) { - fromMime - } else { - MIME_TO_EXTENSION[mime] ?: fromMime - } - return resolved?.takeIf { it in ALLOWED_EXTENSIONS } ?: DEFAULT_EXTENSION + return fromMime?.takeIf { it in ALLOWED_EXTENSIONS } ?: DEFAULT_EXTENSION } internal class PhotoUploadRepositoryImpl @@ -132,5 +130,5 @@ internal class PhotoUploadRepositoryImpl } finally { tempFile.delete() } - } + }.mapUploadFailure() } diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/UploadFailureTranslation.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/UploadFailureTranslation.kt new file mode 100644 index 0000000000..98c42c509b --- /dev/null +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/UploadFailureTranslation.kt @@ -0,0 +1,38 @@ +package com.afternote.core.data.repoimpl + +import com.afternote.core.domain.error.FileUploadFailure +import com.afternote.core.network.model.ApiException + +private const val CODE_FILE_SIZE_EXCEEDED = 1803 + +/** + * 파일 업로드 API 실패를 도메인 예외로 옮긴다 — presentation 이 `core:network` 를 모른 채 타입만으로 + * 분기하게 하는 것이 목적이다(#1868). + * + * 가르는 신호는 서버 봉투의 `code` 뿐이고 `message` 는 옮기지 않는다(BE#92). 표시 문구는 각 화면이 + * 자기 리소스로 갖는다. **사유를 확인하지 못한 실패는 치환하지 않고 원본 그대로 흘려보낸다** — + * 전송 실패나 S3 PUT 실패는 재시도가 실제로 통하는 실패라 기존 안내 경로에 그대로 남아야 한다. + * + * 취소는 다시 보지 않는다 — 호출부가 전부 `runCatchingCancellable`(#661)이라 + * `CancellationException` 이 [Result] 로 도달하지 않는다. + * + * 사진·영상 두 구현이 같은 실패를 같은 규칙으로 옮기므로 어느 한쪽 impl 안에 두지 않는다. + */ +internal fun Result.mapUploadFailure(): Result = + when (val exception = exceptionOrNull()) { + is ApiException -> { + when (exception.code) { + CODE_FILE_SIZE_EXCEEDED -> { + Result.failure(FileUploadFailure.FileSizeExceeded(exception)) + } + + else -> { + this + } + } + } + + else -> { + this + } + } diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt new file mode 100644 index 0000000000..944efe020c --- /dev/null +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt @@ -0,0 +1,285 @@ +package com.afternote.core.data.repoimpl + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.data.mapper.delivery.toRequestDto +import com.afternote.core.data.mapper.user.toDomain +import com.afternote.core.datastore.TokenDataSource +import com.afternote.core.domain.error.ReceiverRequestRejectedException +import com.afternote.core.domain.model.ReceiverListState +import com.afternote.core.domain.repository.UserReceiverRepository +import com.afternote.core.model.delivery.DeliveryConditionItem +import com.afternote.core.model.delivery.ReceiverDeliveryConditions +import com.afternote.core.model.user.Receiver +import com.afternote.core.model.user.ReceiverCreated +import com.afternote.core.model.user.ReceiverDetail +import com.afternote.core.network.dto.UserCreateReceiverRequestDto +import com.afternote.core.network.dto.UserPatchReceiverRequestDto +import com.afternote.core.network.dto.UserUpdateReceiverMessageRequestDto +import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionUpdateRequestDto +import com.afternote.core.network.model.ApiException +import com.afternote.core.network.model.requireData +import com.afternote.core.network.model.requireStatus +import com.afternote.core.network.service.UserApiService +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton +import com.afternote.core.data.mapper.delivery.toDomain as toDeliveryConditionsDomain + +/** + * 사용자 계정에 등록된 수신자 계약 구현 (#1282). + * + * 이 구현만 수신자 갱신 revision 과 «로그인 구간 + collector» 에 귀속된 목록 캐시를 소유한다 — + * 프로필·계정·푸시 설정과 상태를 공유하지 않는다. + * + * `@Singleton` 인 이유 — 위 revision 과 캐시는 합본 [UserRepositoryImpl] 을 거치는 경로와 [UserReceiverRepository] + * 를 직접 주입받는 경로가 **같은 인스턴스** 로 봐야 한다(좁은 계약으로 만든 수신자가 합본 구독자의 목록도 + * 갱신한다). 스코프를 인터페이스 바인딩이 아니라 상태를 실제로 가진 이 클래스에 두면 어느 경로로 요청해도 + * 하나로 수렴한다. + */ +@Singleton +internal class UserReceiverRepositoryImpl + @Inject + constructor( + private val userApiService: UserApiService, + // 세션 경계의 정본. 캐시 수명을 이 식별자에 건다(아래 receiverListFlow 주석) (#2135). + private val tokenDataSource: TokenDataSource, + private val errorReporter: ErrorReporter, + ) : UserReceiverRepository { + private val receiverRefreshRevision = MutableStateFlow(0L) + + // 조회 실패를 예외로 흘리면 구독 중인 화면이 미처리 예외로 죽는다. 일반적인 일시 실패는 같은 + // 로그인 구간에서 이 collector가 마지막으로 성공한 목록을 실은 Failure 로 낸다. 캐시를 flow 안에 두는 이유는 + // 저장소 인스턴스보다 수명이 짧은 «로그인 구간 + collector»에 귀속해 계정 사이에 섞이지 않게 하기 위함이다. + // + // 그 로그인 구간을 [TokenDataSource.sessionId] 로 가른다. 로그인 여부 Boolean 으로 가르면 + // 로그아웃과 새 로그인이 한 구간에 겹칠 때 수집자에게 true → true 로 뭉개져 이 flow 가 재시작되지 + // 않고, 캐시가 계정 경계를 넘는다 (#2135). 세션 식별자는 로그인마다 새 값이라 중간 로그아웃 방출이 + // 생략돼도 값이 달라지고, 같은 세션의 토큰 회전에는 값이 그대로여서 캐시가 유지된다. + // + // 이전 세션 추적은 수집자마다 새로 만든다. 이 저장소는 @Singleton 이라 필드에 두면 한 화면의 세션 + // 전환 판정을 다른 화면이 먹는다. + // + // 목록 전용 [receiverListFlow] 도 이 Flow 에서 파생한다 (#2045). 세션 분기와 늦은 결과 폐기를 한 곳에만 + // 둬야 두 공개 Flow 의 격리 규칙이 갈라지지 않는다. + @OptIn(ExperimentalCoroutinesApi::class) + override val receiverListStateFlow: Flow = + flow { + var previousSessionId: String? = null + emitAll( + tokenDataSource.sessionId + .distinctUntilChanged() + .flatMapLatest { sessionId -> + val previous = previousSessionId + previousSessionId = sessionId + when { + sessionId == null -> flowOf(ReceiverListState.SignedOut) + + // 첫 구독과 로그아웃 뒤 로그인. 이 수집자가 들고 있는 값이 이전 계정 목록이 + // 아니므로 곧바로 조회한다. 여기서까지 빈 목록을 먼저 내면 첫 구독을 + // Flow.first() 로 받는 호출처가 요청 전에 끊긴다. + previous == null -> receiverListForSession(sessionId) + + // 실제 세션 교체. 하류 stateIn 의 initialValue 는 내부 flow 가 교체돼도 + // 되돌아가지 않으므로, 새 세션 조회가 끝날 때까지 이전 계정 목록이 수집자의 + // 현재 값으로 남는다. 계약이 "새 세션에는 빈 목록" 이라 조회를 기다리지 않고 + // 먼저 비운다. 저장소에서 합쳐져 사라진 중간 로그아웃 방출을 되살리는 셈이다. + else -> receiverListForSession(sessionId).onStart { emit(ReceiverListState.SignedOut) } + } + }, + ) + } + + // 기존 목록 전용 구독자의 방출 순서를 그대로 지킨다. 로딩은 방출을 만들지 않고, 실패는 이 구독이 같은 + // 세션에서 마지막으로 성공한 목록(없거나 401 로 버렸으면 빈 목록)으로 낮춘다. + override val receiverListFlow: Flow> = + receiverListStateFlow.mapNotNull { state -> + when (state) { + is ReceiverListState.Loading -> null + is ReceiverListState.Success -> state.receivers + is ReceiverListState.Failure -> state.previousReceivers.orEmpty() + ReceiverListState.SignedOut -> emptyList() + } + } + + override fun refreshReceiverList() { + receiverRefreshRevision.update { it + 1 } + } + + private fun receiverListForSession(sessionId: String): Flow = + flow { + var lastKnownReceivers: List? = null + receiverRefreshRevision.collect { + // 로딩에는 이 구독이 이 세션에서 이미 낸 성공 목록만 싣는다. 수집자가 이미 받은 값 밖의 것이 + // 새지 않으므로 아래의 세션 재확인은 여기에 걸지 않는다. + emit(ReceiverListState.Loading(lastKnownReceivers, sessionId)) + val outcome = + runCatchingCancellable { getReceivers() } + .onFailure { + // 이 flow 가 하는 일이 «예외를 삼켜 화면을 살리는 것» 이라, 삼킨 뒤의 + // 기록이 이 실패 경로의 유일한 신호다. logcat 은 실기에서 회수되지 않으므로 + // 크래시 리포팅 창구로 남긴다. 취소 제외·문구 redaction 은 리포터 정책이 담당한다. + errorReporter.recordFailure( + throwable = it, + attributes = mapOf(KEY_STAGE to STAGE_RECEIVER_LIST), + ) + } + + // 응답이 돌아온 지금 저장된 세션이 이 구독의 세션과 다르면 결과는 이전 계정 것이다. + // 세션 전환이 저장소에 커밋되고도 상위 sessionId 관측이 새 식별자를 아직 전달하지 못한 + // 구간에는 이 flow 가 취소되지 않은 채 살아 있고, 그때 끝난 성공도 실패 폴백도 이전 계정 + // 목록을 새 계정으로 흘려보냈다 (#2135). 재조회에서 세션이 다르면 이 결과를 반영하지 + // 않는다. 이 비교와 방출이 원자적인 것은 아니며, 이미 내보낸 값을 되돌리지는 못한다. + if (tokenDataSource.sessionId.first() != sessionId) return@collect + + val state = + outcome.fold( + onSuccess = { receivers -> + lastKnownReceivers = receivers + ReceiverListState.Success(receivers) + }, + onFailure = { failure -> + val discardPrevious = failure is ApiException && failure.status == UNAUTHORIZED_STATUS + if (discardPrevious) lastKnownReceivers = null + ReceiverListState.Failure(lastKnownReceivers, discardPrevious) + }, + ) + emit(state) + } + } + + /** + * 비로그인 상태에서는 서버를 호출하지 않고 빈 목록을 돌려준다. 따라서 호출처는 빈 목록만으로 + * «수신인 없음» 과 «로그인 안 됨» 을 구분할 수 없다 — 구분이 필요하면 + * `AuthRepository.isLoggedIn` 을 함께 봐야 한다. + */ + override suspend fun getReceivers(): List { + if (tokenDataSource.sessionId.first() == null) return emptyList() + + return userApiService + .getReceivers() + .requireData() + .map { it.toDomain() } + } + + override suspend fun createReceiver( + name: String, + relation: String, + phone: String?, + email: String, + message: String?, + ): ReceiverCreated { + val result = + mapReceiverRequestFailure { + userApiService + .createReceiver( + UserCreateReceiverRequestDto( + name = name, + relation = relation, + phone = phone, + email = email, + message = message, + ), + ).requireData() + .toDomain() + } + receiverRefreshRevision.update { it + 1 } + return result + } + + override suspend fun getReceiverDetail(receiverId: Long): ReceiverDetail = + userApiService + .getReceiverDetail(receiverId) + .requireData() + .toDomain() + + /** + * 등록과 마찬가지로 성공한 뒤에만 목록 revision 을 올린다 (#2127). 이름·관계가 목록에 그대로 + * 실리므로, 올리지 않으면 수정하고 목록으로 돌아온 구독자가 옛 값을 계속 보여준다. + */ + override suspend fun updateReceiver( + receiverId: Long, + name: String, + phone: String, + relation: String, + email: String, + ): Receiver { + val updated = + mapReceiverRequestFailure { + userApiService + .updateReceiver( + receiverId = receiverId, + request = + UserPatchReceiverRequestDto( + name = name, + phone = phone, + relation = relation, + email = email, + ), + ).requireData() + .toDomain() + } + receiverRefreshRevision.update { it + 1 } + return updated + } + + override suspend fun updateReceiverMessage( + receiverId: Long, + message: String, + ) { + mapReceiverRequestFailure { + userApiService + .updateReceiverMessage( + receiverId = receiverId, + request = UserUpdateReceiverMessageRequestDto(message = message), + ).requireStatus() + } + } + + override suspend fun getReceiverDeliveryConditions(receiverId: Long): ReceiverDeliveryConditions = + userApiService + .getReceiverDeliveryConditions(receiverId) + .requireData() + .toDeliveryConditionsDomain() + + override suspend fun updateReceiverDeliveryConditions( + receiverId: Long, + conditions: List, + ): ReceiverDeliveryConditions = + userApiService + .updateReceiverDeliveryConditions( + receiverId = receiverId, + request = ReceiverDeliveryConditionUpdateRequestDto(conditions.map { it.toRequestDto() }), + ).requireData() + .toDeliveryConditionsDomain() + + private companion object { + const val UNAUTHORIZED_STATUS = 401 + } + } + +private suspend inline fun mapReceiverRequestFailure(request: suspend () -> T): T = + try { + request() + } catch (error: ApiException) { + val serverMessage = error.serverMessage + if (error.status in setOf(400, 409) && !serverMessage.isNullOrBlank()) { + throw ReceiverRequestRejectedException(error) + } + throw error + } + +private const val KEY_STAGE = "stage" +private const val STAGE_RECEIVER_LIST = "receiver_list" diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt index b00d04bab4..fdadb8e643 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt @@ -1,301 +1,25 @@ package com.afternote.core.data.repoimpl -import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.core.data.mapper.delivery.toRequestDto -import com.afternote.core.data.mapper.user.toDomain -import com.afternote.core.domain.error.ReceiverRequestRejectedException +import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.domain.repository.UserRepository -import com.afternote.core.domain.repository.auth.AuthRepository -import com.afternote.core.model.delivery.DeliveryConditionItem -import com.afternote.core.model.delivery.ReceiverDeliveryConditions -import com.afternote.core.model.user.Receiver -import com.afternote.core.model.user.ReceiverCreated -import com.afternote.core.model.user.ReceiverDetail -import com.afternote.core.model.user.User -import com.afternote.core.model.user.UserConnectedAccount -import com.afternote.core.model.user.UserPushSetting -import com.afternote.core.network.dto.SocialAccountLinkRequestDto -import com.afternote.core.network.dto.UserCreateReceiverRequestDto -import com.afternote.core.network.dto.UserPatchReceiverRequestDto -import com.afternote.core.network.dto.UserUpdateProfileRequestDto -import com.afternote.core.network.dto.UserUpdatePushSettingRequestDto -import com.afternote.core.network.dto.UserUpdateReceiverMessageRequestDto -import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionUpdateRequestDto -import com.afternote.core.network.model.ApiException -import com.afternote.core.network.model.requireData -import com.afternote.core.network.model.requireStatus -import com.afternote.core.network.service.UserApiService -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.update import javax.inject.Inject -import com.afternote.core.data.mapper.delivery.toDomain as toDeliveryConditionsDomain -// `app` 의 androidTest(SettingCompletionAndroidTest)가 Hilt 를 우회해 이 구현을 직접 조립하므로 -// 형제 구현체들과 달리 `internal` 로 닫지 못한다. 닫으려면 그 테스트의 페이크 기반 개조가 선행돼야 한다 (#930). -class UserRepositoryImpl +/** + * [UserRepository] 전환기 구현 — 좁은 계약 2종에 위임만 한다 (#1282). + * + * 수신자는 [UserReceiverRepository], 서버 정본 프로필은 [MyProfileRepository] 의 구현이 소유하고 + * 여기서는 생성자로 주입받은 그 바인딩에 위임한다. 수신자 구현은 `@Singleton` 이라 이 합본을 거치든 + * 좁은 계약을 직접 주입받든 같은 인스턴스를 본다 — 좁은 계약으로 만든 수신자가 합본 구독자의 목록도 + * 갱신한다. + * + * 계정·푸시 8멤버는 `feature:setting:data` 로 내려갔다 (#1429). + */ +internal class UserRepositoryImpl @Inject constructor( - private val userApiService: UserApiService, - private val authRepository: AuthRepository, - private val errorReporter: ErrorReporter, - ) : UserRepository { - private val receiverRefreshRevision = MutableStateFlow(0L) - - // 조회 실패를 예외로 흘리면 구독 중인 화면이 미처리 예외로 죽는다. 일반적인 일시 실패는 같은 - // 로그인 구간에서 이 collector가 마지막으로 성공한 목록으로 낮춘다. 캐시를 flow 안에 두는 이유는 - // 저장소 인스턴스보다 수명이 짧은 «로그인 구간 + collector»에 귀속해 계정 사이에 섞이지 않게 하기 위함이다. - @OptIn(ExperimentalCoroutinesApi::class) - override val receiverListFlow: Flow> = - authRepository.isLoggedIn - .distinctUntilChanged() - .flatMapLatest { loggedIn -> - if (!loggedIn) { - flowOf(emptyList()) - } else { - receiverListForAuthenticatedSession() - } - } - - private fun receiverListForAuthenticatedSession(): Flow> = - flow { - var lastKnownReceivers = emptyList() - receiverRefreshRevision.collect { - val receivers = - runCatchingCancellable { getReceivers() } - .onFailure { - // 이 flow 가 하는 일이 «예외를 삼켜 화면을 살리는 것» 이라, 삼킨 뒤의 - // 기록이 이 실패 경로의 유일한 신호다. logcat 은 실기에서 회수되지 않으므로 - // 크래시 리포팅 창구로 남긴다. 취소 제외·문구 redaction 은 리포터 정책이 담당한다. - errorReporter.recordFailure( - throwable = it, - attributes = mapOf(KEY_STAGE to STAGE_RECEIVER_LIST), - ) - }.fold( - onSuccess = { it }, - onFailure = { failure -> - if (failure is ApiException && failure.status == UNAUTHORIZED_STATUS) { - emptyList() - } else { - lastKnownReceivers - } - }, - ) - lastKnownReceivers = receivers - emit(receivers) - } - } - - /** - * 비로그인 상태에서는 서버를 호출하지 않고 빈 목록을 돌려준다. 따라서 호출처는 빈 목록만으로 - * «수신인 없음» 과 «로그인 안 됨» 을 구분할 수 없다 — 구분이 필요하면 [AuthRepository.isLoggedIn] 을 함께 봐야 한다. - */ - override suspend fun getReceivers(): List { - if (!authRepository.isLoggedIn.first()) return emptyList() - - return userApiService - .getReceivers() - .requireData() - .map { it.toDomain() } - } - - override suspend fun createReceiver( - name: String, - relation: String, - phone: String?, - email: String?, - message: String?, - ): ReceiverCreated { - val result = - mapReceiverRequestFailure { - userApiService - .createReceiver( - UserCreateReceiverRequestDto( - name = name, - relation = relation, - phone = phone, - email = email, - message = message, - ), - ).requireData() - .toDomain() - } - receiverRefreshRevision.update { it + 1 } - return result - } - - override suspend fun getReceiverDetail(receiverId: Long): ReceiverDetail = - userApiService - .getReceiverDetail(receiverId) - .requireData() - .toDomain() - - override suspend fun updateReceiver( - receiverId: Long, - name: String, - phone: String, - relation: String, - email: String, - ): Receiver = - mapReceiverRequestFailure { - userApiService - .updateReceiver( - receiverId = receiverId, - request = - UserPatchReceiverRequestDto( - name = name, - phone = phone, - relation = relation, - email = email, - ), - ).requireData() - .toDomain() - } - - override suspend fun updateReceiverMessage( - receiverId: Long, - message: String, - ) { - mapReceiverRequestFailure { - userApiService - .updateReceiverMessage( - receiverId = receiverId, - request = UserUpdateReceiverMessageRequestDto(message = message), - ).requireStatus() - } - } - - override suspend fun getMyProfile(): User = - userApiService - .getMyProfile() - .requireData() - .toDomain() - - override suspend fun updateMyProfile( - name: String?, - phone: String?, - profileImageUrl: String?, - ): User = - userApiService - .updateMyProfile( - UserUpdateProfileRequestDto( - name = name, - phone = phone, - profileImageUrl = profileImageUrl, - ), - ).requireData() - .toDomain() - - /** - * 탈퇴 성공 후 로컬 세션도 정리한다 (#586) — 서버가 계정을 지워도 토큰이 남으면 재시작 시 - * 죽은 토큰으로 홈이 뜨고 인증 요청이 연달아 401 로 실패한다(2026-07-28 에뮬 실측). - * - * 정리를 서버 호출 **뒤**에 두는 건 `AuthRepositoryImpl.logout()` 과 같은 이유 — DELETE 요청도 - * `AuthInterceptor` 를 지나므로 그 시점엔 토큰이 살아 있어야 한다. - * - * `clearSession()` 의 실패는 삼킨다. 서버 계정은 이미 지워졌으므로 여기서 예외를 올리면 - * 화면이 "탈퇴 실패" 로 표시돼 사용자가 재시도하고, 그 재시도는 없는 계정에 대해 실패한다. - * 대신 [ErrorReporter] 에 비식별 단계만 붙여 남긴다 — 조용히 넘기면 로컬 토큰이 남은 채로 - * 이 버그가 재발해도 탐지되지 않는다. 예외 원문 제거는 리포터 공통 정책이 담당한다. - */ - override suspend fun deleteAccount() { - userApiService - .deleteAccount() - .requireStatus() - authRepository.clearSession().onFailure { - errorReporter.recordFailure( - throwable = it, - attributes = mapOf(KEY_ACCOUNT_STAGE to ACCOUNT_STAGE_DELETE_SESSION_CLEANUP), - ) - } - } - - override suspend fun getMyPushSettings(): UserPushSetting = - userApiService - .getMyPushSettings() - .requireData() - .toDomain() - - override suspend fun updateMyPushSettings( - timeLetter: Boolean?, - mindRecord: Boolean?, - afterNote: Boolean?, - ): UserPushSetting = - userApiService - .updateMyPushSettings( - UserUpdatePushSettingRequestDto( - timeLetter = timeLetter, - mindRecord = mindRecord, - afterNote = afterNote, - ), - ).requireData() - .toDomain() - - override suspend fun getConnectedAccounts(): UserConnectedAccount = - userApiService - .getConnectedAccounts() - .requireData() - .toDomain() - - override suspend fun linkConnectedAccount( - provider: String, - accessToken: String, - ): UserConnectedAccount = - userApiService - .linkConnectedAccount( - provider = provider, - request = SocialAccountLinkRequestDto(accessToken = accessToken), - ).requireData() - .toDomain() - - override suspend fun unlinkConnectedAccount(provider: String): UserConnectedAccount = - userApiService - .unlinkConnectedAccount(provider) - .requireData() - .toDomain() - - override suspend fun getReceiverDeliveryConditions(receiverId: Long): ReceiverDeliveryConditions = - userApiService - .getReceiverDeliveryConditions(receiverId) - .requireData() - .toDeliveryConditionsDomain() - - override suspend fun updateReceiverDeliveryConditions( - receiverId: Long, - conditions: List, - ): ReceiverDeliveryConditions = - userApiService - .updateReceiverDeliveryConditions( - receiverId = receiverId, - request = ReceiverDeliveryConditionUpdateRequestDto(conditions.map { it.toRequestDto() }), - ).requireData() - .toDeliveryConditionsDomain() - - private companion object { - const val UNAUTHORIZED_STATUS = 401 - } - } - -private suspend inline fun mapReceiverRequestFailure(request: suspend () -> T): T = - try { - request() - } catch (error: ApiException) { - val serverMessage = error.serverMessage - if (error.status in setOf(400, 409) && !serverMessage.isNullOrBlank()) { - throw ReceiverRequestRejectedException(serverMessage, error) - } - throw error - } - -private const val KEY_ACCOUNT_STAGE = "account_stage" -private const val ACCOUNT_STAGE_DELETE_SESSION_CLEANUP = "delete_session_cleanup" -private const val KEY_STAGE = "stage" -private const val STAGE_RECEIVER_LIST = "receiver_list" + receiverRepository: UserReceiverRepository, + myProfileRepository: MyProfileRepository, + ) : UserRepository, + UserReceiverRepository by receiverRepository, + MyProfileRepository by myProfileRepository diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/VideoUploadRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/VideoUploadRepositoryImpl.kt index d02b7bbf34..1a3c5f724c 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/VideoUploadRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/VideoUploadRepositoryImpl.kt @@ -90,7 +90,7 @@ internal class VideoUploadRepositoryImpl } finally { tempFile.delete() } - } + }.mapUploadFailure() private fun videoExtensionFromUri(uri: Uri): String { val mime = context.contentResolver.getType(uri) ?: return DEFAULT_VIDEO_EXTENSION diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountFailureTranslation.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountFailureTranslation.kt new file mode 100644 index 0000000000..102275d549 --- /dev/null +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountFailureTranslation.kt @@ -0,0 +1,53 @@ +package com.afternote.core.data.repoimpl.account + +import com.afternote.core.domain.error.CoreAuthFailure +import com.afternote.core.network.model.ApiException +import java.io.IOException + +private const val CODE_INVALID_VERIFICATION = 1207 +private const val CODE_EMAIL_ALREADY_REGISTERED = 1200 +private const val CODE_NEW_PASSWORD_UNCHANGED = 1206 +private const val CODE_SOCIAL_LOGIN_USER = 1702 + +/** + * 계정 API 실패를 도메인 예외로 옮긴다 — presentation 이 `core:network` 를 모른 채 타입만으로 + * 분기하게 하는 것이 목적이다(#646). + * + * 가르는 신호는 서버 봉투의 `code` 뿐이고 `message` 는 옮기지 않는다(BE#92). 표시 문구는 각 화면이 + * 자기 리소스로 갖는다. 취소는 다시 보지 않는다 — 호출부가 전부 `runCatchingCancellable`(#661) + * 이라 `CancellationException` 이 [Result] 로 도달하지 않는다. + */ +internal fun Result.mapAccountFailure(): Result = + when (val exception = exceptionOrNull()) { + is ApiException -> { + when (exception.code) { + CODE_INVALID_VERIFICATION -> { + Result.failure(CoreAuthFailure.EmailVerification(exception)) + } + + CODE_EMAIL_ALREADY_REGISTERED -> { + Result.failure(CoreAuthFailure.EmailAlreadyRegistered(exception)) + } + + CODE_NEW_PASSWORD_UNCHANGED -> { + Result.failure(CoreAuthFailure.PasswordUnchanged(exception)) + } + + CODE_SOCIAL_LOGIN_USER -> { + Result.failure(CoreAuthFailure.SocialSignUpAccount(exception)) + } + + else -> { + this + } + } + } + + is IOException -> { + Result.failure(CoreAuthFailure.NetworkUnavailable(exception)) + } + + else -> { + this + } + } diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt index 443155600a..7108fce92e 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt @@ -2,21 +2,19 @@ package com.afternote.core.data.repoimpl.account import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.data.mapper.auth.AuthMapper -import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.repository.account.AccountRepository import com.afternote.core.model.AccountRegistration import com.afternote.core.model.FoundAccount import com.afternote.core.network.dto.EmailFindRequestDto import com.afternote.core.network.dto.FindSendCodeRequestDto import com.afternote.core.network.dto.PasswordChangeRequestDto +import com.afternote.core.network.dto.PasswordFindRequestDto import com.afternote.core.network.dto.SendEmailCodeRequestDto import com.afternote.core.network.dto.SignUpRequestDto import com.afternote.core.network.dto.VerifyEmailRequestDto -import com.afternote.core.network.model.ApiException import com.afternote.core.network.model.requireData import com.afternote.core.network.model.requireStatus import com.afternote.core.network.service.AccountApiService -import java.io.IOException import javax.inject.Inject internal class AccountRepositoryImpl @@ -67,6 +65,24 @@ internal class AccountRepositoryImpl AuthMapper.toFoundAccount(response.requireData()) }.mapAccountFailure() + override suspend fun resetPassword( + email: String, + certificateCode: String, + newPassword: String, + confirmPassword: String, + ): Result = + runCatchingCancellable { + accountApiService + .findPassword( + PasswordFindRequestDto( + email = email, + certificateCode = certificateCode, + newPassword = newPassword, + confirmPassword = confirmPassword, + ), + ).requireStatus() + }.mapAccountFailure() + override suspend fun signUp( email: String, password: String, @@ -98,43 +114,5 @@ internal class AccountRepositoryImpl newPassword, ), ).requireStatus() - } - } - -private const val CODE_INVALID_VERIFICATION = 1207 -private const val CODE_EMAIL_ALREADY_REGISTERED = 1200 - -/** - * 계정 API 실패를 도메인 예외로 옮긴다 — presentation 이 `core:network` 를 모른 채 타입만으로 - * 분기하게 하는 것이 목적이다(#646). - * - * 가르는 신호는 서버 봉투의 `code` 뿐이고 `message` 는 옮기지 않는다(BE#92). 표시 문구는 각 화면이 - * 자기 리소스로 갖는다. 취소는 다시 보지 않는다 — 호출부가 전부 `runCatchingCancellable`(#661) - * 이라 `CancellationException` 이 [Result] 로 도달하지 않는다. - */ -private fun Result.mapAccountFailure(): Result = - when (val exception = exceptionOrNull()) { - is ApiException -> { - when (exception.code) { - CODE_INVALID_VERIFICATION -> { - Result.failure(CoreAuthFailure.EmailVerification(exception)) - } - - CODE_EMAIL_ALREADY_REGISTERED -> { - Result.failure(CoreAuthFailure.EmailAlreadyRegistered(exception)) - } - - else -> { - this - } - } - } - - is IOException -> { - Result.failure(CoreAuthFailure.NetworkUnavailable(exception)) - } - - else -> { - this - } + }.mapAccountFailure() } diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/appversion/AppVersionRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/appversion/AppVersionRepositoryImpl.kt new file mode 100644 index 0000000000..08e9f297cd --- /dev/null +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/appversion/AppVersionRepositoryImpl.kt @@ -0,0 +1,35 @@ +package com.afternote.core.data.repoimpl.appversion + +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.repository.appversion.AppVersionRepository +import com.afternote.core.model.appversion.AppVersionCheck +import com.afternote.core.network.dto.AppPlatformDto +import com.afternote.core.network.model.requireData +import com.afternote.core.network.service.AppVersionApiService +import javax.inject.Inject + +/** + * `GET /app/version` 의 유일한 소비 지점 (#1539). + * + * 플랫폼은 여기서 고정한다 — 서버 enum 은 확장 가능하지만 이 앱이 물어볼 수 있는 값은 + * ANDROID 하나뿐이고, BE 는 그 밖의 값에 `UNSUPPORTED_APP_PLATFORM` 을 낸다. + */ +internal class AppVersionRepositoryImpl + @Inject + constructor( + private val appVersionApiService: AppVersionApiService, + ) : AppVersionRepository { + override suspend fun checkAndroidVersion(versionCode: Int): Result = + runCatchingCancellable { + appVersionApiService + .checkVersion(platform = AppPlatformDto.ANDROID, versionCode = versionCode) + .requireData() + .let { dto -> + AppVersionCheck( + updateRequired = dto.updateRequired, + latestVersionCode = dto.latestVersionCode, + storeUrl = dto.storeUrl, + ) + } + } + } diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt index a2bdb28e68..dbdf296cf9 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt @@ -5,7 +5,6 @@ import com.afternote.core.data.mapper.auth.AuthMapper import com.afternote.core.datastore.LocalStoreRegistry import com.afternote.core.datastore.StoreScope import com.afternote.core.datastore.TokenDataSource -import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.push.DevicePushTargetProvider import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.domain.repository.push.PushTargetRepository @@ -15,13 +14,11 @@ import com.afternote.core.network.dto.LoginRequestDto import com.afternote.core.network.dto.LogoutRequestDto import com.afternote.core.network.dto.ReissueRequestDto import com.afternote.core.network.dto.SocialLoginRequestDto -import com.afternote.core.network.model.ApiException import com.afternote.core.network.model.requireData import com.afternote.core.network.service.AuthApiService import com.afternote.core.network.service.TokenApiService import com.afternote.core.network.token.AccessTokenExpiryTracker import kotlinx.coroutines.flow.Flow -import java.io.IOException import javax.inject.Inject internal class AuthRepositoryImpl @@ -176,46 +173,3 @@ internal class AuthRepositoryImpl expiresInSeconds?.let(expiryTracker::record) ?: expiryTracker.clear() } } - -// [ApiException.code] 는 HTTP 상태가 아니라 응답 본문의 비즈니스 코드다 — 대역 판정(4xx 통과)으로 -// 바꾸지 말 것. 5xx 에 붙는 코드도 있어 대역으로는 사용자 거절과 장애가 갈리지 않는다. -private const val CODE_USER_NOT_FOUND = 1201 -private const val CODE_PASSWORD_MISMATCH = 1202 -private const val CODE_SOCIAL_LOGIN_FAILED = 1208 -private const val CODE_UNSUPPORTED_SOCIAL_LOGIN = 1209 - -/** - * 로그인 실패를 도메인 예외로 옮긴다 — 가르는 신호는 서버 봉투의 `code` 뿐이고 `message` 는 - * 옮기지 않는다(BE#92 — 사용자 노출용이라는 규정이 없어 계약이 아니다). 사유가 확인된 실패만 - * 치환하고 나머지는 그대로 두어, 소비처가 일반 문구로 내려앉는다(5xx 본문 실측 #511). - * - * 서버 응답 실패인 [ApiException]과 전송 실패인 [IOException]은 서로 다른 타입 계열이다. 취소는 - * 다시 보지 않는다 — 호출부가 전부 [runCatchingCancellable]이라 `CancellationException`이 [Result]에 - * 담긴 채로 도달하지 않는다. - */ -private fun Result.mapLoginFailure(): Result = - when (val exception = exceptionOrNull()) { - is ApiException -> { - when (exception.code) { - CODE_USER_NOT_FOUND, CODE_PASSWORD_MISMATCH -> { - Result.failure(CoreAuthFailure.InvalidLoginCredentials(exception)) - } - - CODE_SOCIAL_LOGIN_FAILED, CODE_UNSUPPORTED_SOCIAL_LOGIN -> { - Result.failure(CoreAuthFailure.SocialLoginRejected(exception)) - } - - else -> { - this - } - } - } - - is IOException -> { - Result.failure(CoreAuthFailure.NetworkUnavailable(exception)) - } - - else -> { - this - } - } diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/LoginFailureTranslation.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/LoginFailureTranslation.kt new file mode 100644 index 0000000000..5c82dbda1b --- /dev/null +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/LoginFailureTranslation.kt @@ -0,0 +1,57 @@ +package com.afternote.core.data.repoimpl.auth + +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.error.CoreAuthFailure +import com.afternote.core.network.model.ApiException +import java.io.IOException + +// [ApiException.code] 는 HTTP 상태가 아니라 응답 본문의 비즈니스 코드다 — 대역 판정(4xx 통과)으로 +// 바꾸지 말 것. 5xx 에 붙는 코드도 있어 대역으로는 사용자 거절과 장애가 갈리지 않는다. +private const val CODE_USER_NOT_FOUND = 1201 +private const val CODE_PASSWORD_MISMATCH = 1202 +private const val CODE_SOCIAL_LOGIN_FAILED = 1208 +private const val CODE_UNSUPPORTED_SOCIAL_LOGIN = 1209 +private const val CODE_SOCIAL_SIGNUP_ACCOUNT = 1702 + +/** + * 로그인 실패를 도메인 예외로 옮긴다 — 가르는 신호는 서버 봉투의 `code` 뿐이고 `message` 는 + * 옮기지 않는다(BE#92 — 사용자 노출용이라는 규정이 없어 계약이 아니다). 사유가 확인된 실패만 + * 치환하고 나머지는 그대로 두어, 소비처가 일반 문구로 내려앉는다(5xx 본문 실측 #511). + * + * 서버 응답 실패인 [ApiException]과 전송 실패인 [IOException]은 서로 다른 타입 계열이다. 취소는 + * 다시 보지 않는다 — 호출부가 전부 [runCatchingCancellable]이라 `CancellationException`이 [Result]에 + * 담긴 채로 도달하지 않는다. + */ +internal fun Result.mapLoginFailure(): Result = + when (val exception = exceptionOrNull()) { + is ApiException -> { + when (exception.code) { + CODE_USER_NOT_FOUND, CODE_PASSWORD_MISMATCH -> { + Result.failure(CoreAuthFailure.InvalidLoginCredentials(exception)) + } + + CODE_SOCIAL_LOGIN_FAILED, CODE_UNSUPPORTED_SOCIAL_LOGIN -> { + Result.failure(CoreAuthFailure.SocialLoginRejected(exception)) + } + + // 소셜로 가입해 로컬 비밀번호가 없는 계정에 이메일 로그인을 시도한 것(BE + // `AuthService.login` 이 `password == null` 을 이 코드로 거절한다). 자격 거절과 가르는 + // 이유는 안내가 갈리기 때문이다 — 입력을 고쳐서 될 일이 아니라 로그인 수단이 틀렸다. + CODE_SOCIAL_SIGNUP_ACCOUNT -> { + Result.failure(CoreAuthFailure.SocialSignUpAccount(exception)) + } + + else -> { + this + } + } + } + + is IOException -> { + Result.failure(CoreAuthFailure.NetworkUnavailable(exception)) + } + + else -> { + this + } + } diff --git a/core/data/src/test/java/com/afternote/core/data/mapper/delivery/DeliveryConditionMapperTest.kt b/core/data/src/test/java/com/afternote/core/data/mapper/delivery/DeliveryConditionMapperTest.kt index a95a3d7a76..0fadfe35f4 100644 --- a/core/data/src/test/java/com/afternote/core/data/mapper/delivery/DeliveryConditionMapperTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/mapper/delivery/DeliveryConditionMapperTest.kt @@ -10,25 +10,30 @@ import com.afternote.core.network.dto.delivery.DeliveryConditionItemDto import com.afternote.core.network.dto.delivery.DeliveryConditionTypeDto import com.afternote.core.network.dto.delivery.DeliveryContentTypeDto import com.afternote.core.network.dto.delivery.InactivityPeriodDto +import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionDto import org.junit.Assert.assertEquals import org.junit.Test /** - * [DeliveryConditionMapper] enum·응답 매핑 회귀 가드 (이슈 #427). + * `DeliveryConditionMapper` enum·응답 매핑 회귀 가드 (이슈 #427). * * 서버 enum 추가/누락 시 `when` 분기 누락이 도메인에 조용히 반영되는 사고를 막는다. - * 검증 방식은 [com.afternote.core.data.mapper.user.UserMapperTest] 와 동일 — DTO/Domain 의 - * 동일 이름 상수를 `valueOf` 로 독립 조회해 매퍼 결과와 대조(자기검증 아님). + * 검증 방식은 DTO/Domain 의 동일 이름 상수를 `valueOf` 로 독립 조회해 매퍼 결과와 대조하는 것이다(자기검증 아님). + * + * 파일 내부 helper 는 `private` 이라 매퍼의 **공개 진입점 두 개**로만 검증한다 (#1672) — + * 응답은 [ReceiverDeliveryConditionDto.toDomain], 요청은 [DeliveryConditionItem.toRequestDto]. + * enum 한 짝을 진입점에 태워 넣고 반대편에서 읽어 내는 방식이라 분기 누락은 그대로 드러난다. */ class DeliveryConditionMapperTest { @Test fun `DeliveryContentType 매핑 - 양방향 전체 케이스 + round-trip + 개수 동기화`() { DeliveryContentTypeDto.entries.forEach { dto -> - assertEquals(DeliveryContentType.valueOf(dto.name), dto.toDomain()) + assertEquals(DeliveryContentType.valueOf(dto.name), mapResponse(contentType = dto).contentType) } DeliveryContentType.entries.forEach { domain -> - assertEquals(DeliveryContentTypeDto.valueOf(domain.name), domain.toDto()) - assertEquals(domain, domain.toDto().toDomain()) + val requested = mapRequest(contentType = domain).contentType + assertEquals(DeliveryContentTypeDto.valueOf(domain.name), requested) + assertEquals(domain, mapResponse(contentType = requested).contentType) } assertEquals(DeliveryContentTypeDto.entries.size, DeliveryContentType.entries.size) } @@ -36,11 +41,12 @@ class DeliveryConditionMapperTest { @Test fun `DeliveryConditionType 매핑 - 양방향 전체 케이스 + round-trip + 개수 동기화`() { DeliveryConditionTypeDto.entries.forEach { dto -> - assertEquals(DeliveryConditionType.valueOf(dto.name), dto.toDomain()) + assertEquals(DeliveryConditionType.valueOf(dto.name), mapResponse(conditionType = dto).conditionType) } DeliveryConditionType.entries.forEach { domain -> - assertEquals(DeliveryConditionTypeDto.valueOf(domain.name), domain.toDto()) - assertEquals(domain, domain.toDto().toDomain()) + val requested = mapRequest(conditionType = domain).conditionType + assertEquals(DeliveryConditionTypeDto.valueOf(domain.name), requested) + assertEquals(domain, mapResponse(conditionType = requested).conditionType) } assertEquals(DeliveryConditionTypeDto.entries.size, DeliveryConditionType.entries.size) } @@ -48,65 +54,109 @@ class DeliveryConditionMapperTest { @Test fun `InactivityPeriod 매핑 - 양방향 전체 케이스 + round-trip + 개수 동기화`() { InactivityPeriodDto.entries.forEach { dto -> - assertEquals(InactivityPeriod.valueOf(dto.name), dto.toDomain()) + assertEquals(InactivityPeriod.valueOf(dto.name), mapResponse(inactivityPeriod = dto).inactivityPeriod) } InactivityPeriod.entries.forEach { domain -> - assertEquals(InactivityPeriodDto.valueOf(domain.name), domain.toDto()) - assertEquals(domain, domain.toDto().toDomain()) + val requested = mapRequest(inactivityPeriod = domain).inactivityPeriod + assertEquals(InactivityPeriodDto.valueOf(domain.name), requested) + assertEquals(domain, mapResponse(inactivityPeriod = requested).inactivityPeriod) } assertEquals(InactivityPeriodDto.entries.size, InactivityPeriod.entries.size) } @Test fun `ConditionState 매핑 - 응답 전용 전체 케이스 + 개수 동기화`() { - // ConditionState 는 서버 판정 값이라 응답(toDomain)만 존재 — 요청 방향(toDto) 없음. + // ConditionState 는 서버 판정 값이라 응답 방향만 존재 — 요청 DTO 에는 필드 자체가 없다. ConditionStateDto.entries.forEach { dto -> - assertEquals(ConditionState.valueOf(dto.name), dto.toDomain()) + assertEquals(ConditionState.valueOf(dto.name), mapResponse(state = dto).state) } assertEquals(ConditionStateDto.entries.size, ConditionState.entries.size) } @Test - fun `DeliveryConditionItemDto toDomain - 필드 보존 + INACTIVITY 기간 매핑`() { - val dto = - DeliveryConditionItemDto( - contentType = DeliveryContentTypeDto.AFTERNOTE, - conditionType = DeliveryConditionTypeDto.INACTIVITY, - inactivityPeriod = InactivityPeriodDto.ONE_YEAR, - state = ConditionStateDto.PENDING_CONFIRMATION, - fulfilled = false, - gracePeriodStartedAt = "2026-07-08T00:00:00Z", - fulfilledAt = null, + fun `응답 매핑 - receiverId 와 항목 필드를 보존한다`() { + val response = + ReceiverDeliveryConditionDto( + receiverId = 42L, + conditions = + listOf( + itemDto( + contentType = DeliveryContentTypeDto.AFTERNOTE, + conditionType = DeliveryConditionTypeDto.INACTIVITY, + inactivityPeriod = InactivityPeriodDto.ONE_YEAR, + state = ConditionStateDto.PENDING_CONFIRMATION, + ), + ), ) - val domain = dto.toDomain() + val domain = response.toDomain() - assertEquals(DeliveryContentType.AFTERNOTE, domain.contentType) - assertEquals(DeliveryConditionType.INACTIVITY, domain.conditionType) - assertEquals(InactivityPeriod.ONE_YEAR, domain.inactivityPeriod) - assertEquals(ConditionState.PENDING_CONFIRMATION, domain.state) - assertEquals(false, domain.fulfilled) - assertEquals("2026-07-08T00:00:00Z", domain.gracePeriodStartedAt) - assertEquals(null, domain.fulfilledAt) + assertEquals(42L, domain.receiverId) + val item = domain.conditions.single() + assertEquals(DeliveryContentType.AFTERNOTE, item.contentType) + assertEquals(DeliveryConditionType.INACTIVITY, item.conditionType) + assertEquals(InactivityPeriod.ONE_YEAR, item.inactivityPeriod) + assertEquals(ConditionState.PENDING_CONFIRMATION, item.state) + assertEquals(false, item.fulfilled) + assertEquals("2026-07-08T00:00:00Z", item.gracePeriodStartedAt) + assertEquals(null, item.fulfilledAt) } @Test - fun `DeliveryConditionItem toRequestDto - RECEIVER_REQUEST 는 기간 null 유지`() { - val item = - DeliveryConditionItem( + fun `요청 매핑 - RECEIVER_REQUEST 는 기간 null 유지`() { + val request = + mapRequest( contentType = DeliveryContentType.DIARY, conditionType = DeliveryConditionType.RECEIVER_REQUEST, inactivityPeriod = null, - state = ConditionState.ACTIVE, - fulfilled = false, - gracePeriodStartedAt = null, - fulfilledAt = null, ) - val req = item.toRequestDto() - - assertEquals(DeliveryContentTypeDto.DIARY, req.contentType) - assertEquals(DeliveryConditionTypeDto.RECEIVER_REQUEST, req.conditionType) - assertEquals(null, req.inactivityPeriod) + assertEquals(DeliveryContentTypeDto.DIARY, request.contentType) + assertEquals(DeliveryConditionTypeDto.RECEIVER_REQUEST, request.conditionType) + assertEquals(null, request.inactivityPeriod) } + + private fun itemDto( + contentType: DeliveryContentTypeDto = DeliveryContentTypeDto.AFTERNOTE, + conditionType: DeliveryConditionTypeDto = DeliveryConditionTypeDto.INACTIVITY, + inactivityPeriod: InactivityPeriodDto? = InactivityPeriodDto.THREE_MONTHS, + state: ConditionStateDto = ConditionStateDto.ACTIVE, + ) = DeliveryConditionItemDto( + contentType = contentType, + conditionType = conditionType, + inactivityPeriod = inactivityPeriod, + state = state, + fulfilled = false, + gracePeriodStartedAt = "2026-07-08T00:00:00Z", + fulfilledAt = null, + ) + + /** 응답 진입점([ReceiverDeliveryConditionDto.toDomain])으로 항목 하나를 태워 도메인으로 받는다. */ + private fun mapResponse( + contentType: DeliveryContentTypeDto = DeliveryContentTypeDto.AFTERNOTE, + conditionType: DeliveryConditionTypeDto = DeliveryConditionTypeDto.INACTIVITY, + inactivityPeriod: InactivityPeriodDto? = InactivityPeriodDto.THREE_MONTHS, + state: ConditionStateDto = ConditionStateDto.ACTIVE, + ): DeliveryConditionItem = + ReceiverDeliveryConditionDto( + receiverId = 1L, + conditions = listOf(itemDto(contentType, conditionType, inactivityPeriod, state)), + ).toDomain() + .conditions + .single() + + /** 요청 진입점([DeliveryConditionItem.toRequestDto])으로 도메인 항목 하나를 DTO 로 받는다. */ + private fun mapRequest( + contentType: DeliveryContentType = DeliveryContentType.AFTERNOTE, + conditionType: DeliveryConditionType = DeliveryConditionType.INACTIVITY, + inactivityPeriod: InactivityPeriod? = InactivityPeriod.THREE_MONTHS, + ) = DeliveryConditionItem( + contentType = contentType, + conditionType = conditionType, + inactivityPeriod = inactivityPeriod, + state = ConditionState.ACTIVE, + fulfilled = false, + gracePeriodStartedAt = null, + fulfilledAt = null, + ).toRequestDto() } diff --git a/core/data/src/test/java/com/afternote/core/data/mapper/user/ReceiverDetailMapperTest.kt b/core/data/src/test/java/com/afternote/core/data/mapper/user/ReceiverDetailMapperTest.kt new file mode 100644 index 0000000000..6b4f73ff80 --- /dev/null +++ b/core/data/src/test/java/com/afternote/core/data/mapper/user/ReceiverDetailMapperTest.kt @@ -0,0 +1,65 @@ +package com.afternote.core.data.mapper.user + +import com.afternote.core.model.user.ReceiverDetail +import com.afternote.core.network.di.NetworkModule +import com.afternote.core.network.dto.ReceiverDetailDto +import com.afternote.core.network.model.BaseResponse +import com.afternote.core.network.model.requireData +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 수신자 상세 응답 → [ReceiverDetail] 매핑 회귀 가드 (#2155). + * + * `UserReceiverRepositoryImpl.getReceiverDetail` 이 하는 일(`requireData().toDomain()`)을 dev 서버 상세 응답 + * 모양의 본문에 그대로 태운다. 수정 화면은 빈 관계를 선택 안 함으로 그리므로 null 관계는 빈 문자열로 옮긴다. + */ +class ReceiverDetailMapperTest { + private val json = NetworkModule.provideJson() + + @Test + fun `상세 응답 - 저장된 이름·관계·연락처·마지막 인사말이 그대로 옮겨진다`() { + val payload = + """{"status":200,"code":200,"message":"성공","data":{"receiverId":14,"name":"QA수신자","relation":"DAUGHTER","phone":"010-1234-5678","email":"receiver@afternote.local","dailyQuestionCount":0,"timeLetterCount":0,"afterNoteCount":0,"message":"마지막 인사말"}}""" + + val receiver = json.decodeFromString>(payload).requireData().toDomain() + + assertEquals( + ReceiverDetail( + receiverId = 14L, + name = "QA수신자", + relation = "DAUGHTER", + phone = "010-1234-5678", + email = "receiver@afternote.local", + dailyQuestionCount = 0, + timeLetterCount = 0, + afterNoteCount = 0, + message = "마지막 인사말", + ), + receiver, + ) + } + + @Test + fun `상세 응답 - relation 이 null 이면 빈 관계로 옮겨진다`() { + val payload = + """{"status":200,"code":200,"message":"성공","data":{"receiverId":21,"name":"Admin","relation":null,"phone":null,"email":"admin-receiver@afternote.local","dailyQuestionCount":0,"timeLetterCount":0,"afterNoteCount":0,"message":null}}""" + + val receiver = json.decodeFromString>(payload).requireData().toDomain() + + assertEquals( + ReceiverDetail( + receiverId = 21L, + name = "Admin", + relation = "", + phone = null, + email = "admin-receiver@afternote.local", + dailyQuestionCount = 0, + timeLetterCount = 0, + afterNoteCount = 0, + message = null, + ), + receiver, + ) + } +} diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverListStateFlowTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverListStateFlowTest.kt new file mode 100644 index 0000000000..4e4bb7e4cd --- /dev/null +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverListStateFlowTest.kt @@ -0,0 +1,477 @@ +package com.afternote.core.data.repoimpl + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.model.ReceiverListState +import com.afternote.core.domain.repository.UserReceiverRepository +import com.afternote.core.model.user.Receiver +import com.afternote.core.network.dto.DeletePushTokenRequestDto +import com.afternote.core.network.dto.PushTokenDto +import com.afternote.core.network.dto.ReceiverDetailDto +import com.afternote.core.network.dto.ReceiverListDto +import com.afternote.core.network.dto.RegisterPushTokenRequestDto +import com.afternote.core.network.dto.SocialAccountLinkRequestDto +import com.afternote.core.network.dto.UserConnectedAccountDto +import com.afternote.core.network.dto.UserCreateReceiverDto +import com.afternote.core.network.dto.UserCreateReceiverRequestDto +import com.afternote.core.network.dto.UserDto +import com.afternote.core.network.dto.UserMarketingConsentDto +import com.afternote.core.network.dto.UserPatchReceiverDto +import com.afternote.core.network.dto.UserPatchReceiverRequestDto +import com.afternote.core.network.dto.UserPushSettingDto +import com.afternote.core.network.dto.UserUpdateMarketingConsentRequestDto +import com.afternote.core.network.dto.UserUpdateProfileRequestDto +import com.afternote.core.network.dto.UserUpdatePushSettingRequestDto +import com.afternote.core.network.dto.UserUpdateReceiverMessageRequestDto +import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionDto +import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionUpdateRequestDto +import com.afternote.core.network.model.ApiException +import com.afternote.core.network.model.BaseResponse +import com.afternote.core.network.service.UserApiService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.net.UnknownHostException +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger + +/** + * 수신자 목록 조회 결과 계약 (#2045). + * + * [UserReceiverRepository.receiverListStateFlow] 가 로딩·성공·실패·세션 종료를 가르는지, 그리고 같은 원천에서 + * 파생한 [UserReceiverRepository.receiverListFlow] 가 #2135 의 방출 순서를 그대로 지키는지 공개 계약으로만 본다. + * 세션 경계는 실제 파일 DataStore 위의 [TestTokenSessionStore] 로 세운다. 기존 목록 전용 테스트 + * (`UserRepositoryImplTest`·[ReceiverSessionBoundaryRaceTest]·[ReceiverUpdateRefreshTest])는 수정 없이 통과해야 한다. + */ +class ReceiverListStateFlowTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val sessionStore by lazy { TestTokenSessionStore(temporaryFolder.root) } + private val fetches = ScriptedFetches() + private val reporter = RecordingErrorReporter() + + @Before + fun openSession() = runBlocking { sessionStore.login(accessToken = "access-a", refreshToken = "refresh-a") } + + @After + fun closeSessionStore() = sessionStore.close() + + @Test + fun `receiverListStateFlow - 첫 조회는 로딩 뒤 결과를 내고 성공한 0건은 빈 Success 다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + try { + assertEquals(loading(null), states.next()) + fetches.awaitStarted() + fetches.respond() + assertEquals(ReceiverListState.Success(emptyList()), states.next()) + } finally { + states.stop() + } + } + + @Test + fun `receiverListStateFlow - 첫 실패는 빈 성공과 구별되고 같은 구독이 refreshReceiverList 로 다시 조회한다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + try { + assertEquals(loading(null), states.next()) + fetches.awaitStarted() + fetches.fail(UnknownHostException("오프라인")) + assertEquals(ReceiverListState.Failure(null, discardPrevious = false), states.next()) + + repository.refreshReceiverList() + + assertEquals(loading(null), states.next()) + fetches.awaitStarted() + fetches.respond(RECEIVER_A) + assertEquals(ReceiverListState.Success(listOf(receiver(RECEIVER_A))), states.next()) + assertEquals(2, fetches.startedCount) + assertEquals(1, reporter.failures.size) + } finally { + states.stop() + } + } + + @Test + fun `receiverListStateFlow - 갱신 로딩과 갱신 실패는 마지막 성공 목록을 싣는다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + try { + states.succeedFirst(RECEIVER_A) + + repository.refreshReceiverList() + + val previous = listOf(receiver(RECEIVER_A)) + assertEquals(loading(previous), states.next()) + fetches.awaitStarted() + fetches.fail(ApiException(status = 500, code = 500, serverMessage = null, fallbackMessage = "서버 오류")) + assertEquals(ReceiverListState.Failure(previous, discardPrevious = false), states.next()) + } finally { + states.stop() + } + } + + @Test + fun `receiverListStateFlow - 401 은 마지막 성공 목록을 버리고 다음 실패에도 되살리지 않는다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + try { + states.succeedFirst(RECEIVER_A) + + repository.refreshReceiverList() + assertEquals(loading(listOf(receiver(RECEIVER_A))), states.next()) + fetches.awaitStarted() + fetches.fail(ApiException(status = 401, code = 401, serverMessage = null, fallbackMessage = "인증 만료")) + assertEquals(ReceiverListState.Failure(null, discardPrevious = true), states.next()) + + repository.refreshReceiverList() + assertEquals(loading(null), states.next()) + fetches.awaitStarted() + fetches.fail(UnknownHostException("오프라인")) + assertEquals(ReceiverListState.Failure(null, discardPrevious = false), states.next()) + } finally { + states.stop() + } + } + + /** + * 앱에서 401 은 `TokenAuthenticator` 가 재발급 거절을 확정하고 세션을 비운 뒤에야 조회 예외로 올라온다 + * (`TokenReissuer` 가 락 안에서 정리, #1126). 그래서 이 경로에서는 Failure(null) 가 나오지 않고, 소비자는 + * SignedOut 으로 이전 계정 행을 비운다. + */ + @Test + fun `receiverListStateFlow - 세션을 비운 뒤 올라온 401 은 Failure 없이 SignedOut 만 낸다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + try { + states.succeedFirst(RECEIVER_A) + sessionStore.holdObservation() + + repository.refreshReceiverList() + assertEquals(loading(listOf(receiver(RECEIVER_A))), states.next()) + fetches.awaitStarted() + sessionStore.clearSession() + fetches.fail(ApiException(status = 401, code = 401, serverMessage = null, fallbackMessage = "인증 만료")) + assertNull("세션을 비운 뒤의 401 이 Failure 로 나왔다", states.nextOrNull()) + + sessionStore.releaseObservation() + assertEquals(ReceiverListState.SignedOut, states.next()) + assertNull("SignedOut 뒤에 상태가 더 나왔다", states.nextOrNull()) + } finally { + states.stop() + } + } + + @Test + fun `receiverListStateFlow - 로그인 세션이 없으면 서버를 부르지 않고 SignedOut 을 낸다`() = + runBlocking { + sessionStore.clearSession() + val repository = repository() + + assertEquals(ReceiverListState.SignedOut, repository.receiverListStateFlow.first()) + assertEquals(0, fetches.startedCount) + } + + /** + * 수집자가 중간 로그아웃 방출을 못 본 채 새 세션만 받는 구간 (#2135). 상태 Flow 는 새 세션의 로딩보다 + * 먼저 SignedOut 을 내야 한다. Loading(null) 부터 내면 소비자는 이전 계정 행을 계속 들고 있어도 된다고 읽는다. + */ + @Test + fun `receiverListStateFlow - 로그아웃 방출 없이 세션이 바뀌어도 새 세션 로딩 전에 SignedOut 을 먼저 낸다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + try { + states.succeedFirst(RECEIVER_A) + + sessionStore.holdObservation() + sessionStore.clearSession() + sessionStore.login(accessToken = "access-b", refreshToken = "refresh-b") + sessionStore.releaseObservation() + + assertEquals(ReceiverListState.SignedOut, states.next()) + assertEquals(loading(null), states.next()) + fetches.awaitStarted() + fetches.respond(RECEIVER_B) + assertEquals(ReceiverListState.Success(listOf(receiver(RECEIVER_B))), states.next()) + } finally { + states.stop() + } + } + + /** + * 세션 전환이 저장소에는 커밋됐지만 상위 관측이 아직 새 식별자를 전달하지 못한 구간에 끝난 조회는 + * 목록 Flow 와 마찬가지로 상태 Flow 에도 결과를 내지 않는다 (#2135 의 늦은 결과 가드). + */ + @Test + fun `receiverListStateFlow - 이전 세션의 늦은 조회 결과는 성공도 실패도 내지 않는다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + try { + states.succeedFirst(RECEIVER_A) + val previousSessionLoading = loading(listOf(receiver(RECEIVER_A))) + sessionStore.holdObservation() + + repository.refreshReceiverList() + assertEquals(previousSessionLoading, states.next()) + fetches.awaitStarted() + sessionStore.clearSession() + sessionStore.login(accessToken = "access-b", refreshToken = "refresh-b") + fetches.fail(UnknownHostException("이전 세션 조회 실패")) + assertNull("이전 세션 조회의 실패가 상태로 나왔다", states.nextOrNull()) + + repository.refreshReceiverList() + assertEquals(previousSessionLoading, states.next()) + fetches.awaitStarted() + fetches.respond(RECEIVER_A) + assertNull("이전 세션 조회의 늦은 성공이 상태로 나왔다", states.nextOrNull()) + + sessionStore.releaseObservation() + assertEquals(ReceiverListState.SignedOut, states.next()) + assertEquals(loading(null), states.next()) + fetches.awaitStarted() + fetches.respond(RECEIVER_B) + assertEquals(ReceiverListState.Success(listOf(receiver(RECEIVER_B))), states.next()) + } finally { + states.stop() + } + } + + @Test + fun `receiverListStateFlow - 조회 중 구독을 취소하면 Failure 도 실패 리포트도 남기지 않는다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + assertEquals(loading(null), states.next()) + fetches.awaitStarted() + states.stop() + + assertNull(states.nextOrNull()) + assertEquals(emptyList(), reporter.failures.toList()) + } + + /** + * 목록 전용 소비자(타임레터 수신인 목록 등)의 방출 순서 보존 조건. 상태 Flow 가 재조회마다 Loading 을 더 내도 + * 목록 Flow 에는 새 방출이 생기지 않아야 한다. 생기면 기존 소비자가 조회 중에 빈 목록이나 같은 목록을 한 번 더 받는다. + */ + @Test + fun `receiverListFlow - 상태 Flow 에서 파생돼도 재조회 로딩은 방출을 만들지 않는다`() = + runBlocking { + val repository = repository() + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { repository.receiverListFlow.collect { emissions.send(it) } } + + try { + fetches.awaitStarted() + assertNull("조회 결과 전에 목록이 나왔다", emissions.tryReceive().getOrNull()) + fetches.respond(RECEIVER_A) + assertEquals(listOf(receiver(RECEIVER_A)), withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }) + + repository.refreshReceiverList() + fetches.awaitStarted() + assertNull("재조회 로딩이 목록 방출을 만들었다", withTimeoutOrNull(QUIET_WAIT_MILLIS) { emissions.receive() }) + + fetches.fail(UnknownHostException("오프라인")) + assertEquals(listOf(receiver(RECEIVER_A)), withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }) + } finally { + collector.cancelAndJoin() + } + } + + /** 두 공개 Flow 가 같은 revision 을 본다. 재시도가 목록 전용 구독자도 다시 조회하게 하는 것은 의도된 공유다. */ + @Test + fun `refreshReceiverList - 목록 전용 구독자도 함께 다시 조회한다`() = + runBlocking { + val repository = repository() + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { repository.receiverListFlow.collect { emissions.send(it) } } + + try { + fetches.awaitStarted() + fetches.respond(RECEIVER_A) + assertEquals(listOf(receiver(RECEIVER_A)), withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }) + + repository.refreshReceiverList() + + fetches.awaitStarted() + fetches.respond(RECEIVER_B) + assertEquals(listOf(receiver(RECEIVER_B)), withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }) + } finally { + collector.cancelAndJoin() + } + } + + private fun repository(): UserReceiverRepositoryImpl = + UserReceiverRepositoryImpl( + userApiService = ScriptedUserApiService(fetches), + tokenDataSource = sessionStore.tokenDataSource, + errorReporter = reporter, + ) + + private suspend fun loading(previous: List?): ReceiverListState.Loading = + ReceiverListState.Loading(previous, requireNotNull(sessionStore.tokenDataSource.sessionId.first())) + + private fun CoroutineScope.collectStates(repository: UserReceiverRepository): StateRecorder { + val channel = Channel(capacity = Channel.UNLIMITED) + val job = launch(start = CoroutineStart.UNDISPATCHED) { repository.receiverListStateFlow.collect { channel.send(it) } } + return StateRecorder(channel, job) + } + + private inner class StateRecorder( + private val channel: Channel, + private val job: Job, + ) { + suspend fun next(): ReceiverListState = withTimeout(TEST_TIMEOUT_MILLIS) { channel.receive() } + + suspend fun nextOrNull(): ReceiverListState? = withTimeoutOrNull(QUIET_WAIT_MILLIS) { channel.receive() } + + /** 첫 로딩과 첫 조회 성공까지 지나 보낸다. */ + suspend fun succeedFirst(name: String) { + assertEquals(loading(null), next()) + fetches.awaitStarted() + fetches.respond(name) + assertEquals(ReceiverListState.Success(listOf(receiver(name))), next()) + } + + suspend fun stop() = job.cancelAndJoin() + } + + /** `getReceivers` 를 테스트가 한 건씩 여는 조회. 시작 시점과 응답 시점을 따로 쥐어야 로딩 구간을 단언할 수 있다. */ + private class ScriptedFetches { + private val started = Channel(capacity = Channel.UNLIMITED) + private val responses = Channel>>(capacity = Channel.UNLIMITED) + private val startedCounter = AtomicInteger() + + val startedCount: Int get() = startedCounter.get() + + suspend fun fetch(): BaseResponse> { + startedCounter.incrementAndGet() + started.send(Unit) + val names = responses.receive().getOrThrow() + return BaseResponse( + status = 200, + code = 200, + data = names.map { ReceiverListDto(receiverId = receiverIdOf(it), name = it, relation = RELATION) }, + ) + } + + suspend fun awaitStarted() { + withTimeout(TEST_TIMEOUT_MILLIS) { started.receive() } + } + + suspend fun respond(vararg names: String) = responses.send(Result.success(names.toList())) + + suspend fun fail(error: Throwable) = responses.send(Result.failure(error)) + } + + private class RecordingErrorReporter : ErrorReporter { + val failures = CopyOnWriteArrayList() + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + failures += throwable + } + } + + private class ScriptedUserApiService( + private val fetches: ScriptedFetches, + ) : UserApiService { + override suspend fun getReceivers(): BaseResponse> = fetches.fetch() + + override suspend fun createReceiver(request: UserCreateReceiverRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getReceiverDetail(receiverId: Long): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiver( + receiverId: Long, + request: UserPatchReceiverRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiverMessage( + receiverId: Long, + request: UserUpdateReceiverMessageRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getMyProfile(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyProfile(request: UserUpdateProfileRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun deleteAccount(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun registerPushToken(request: RegisterPushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun deletePushToken(request: DeletePushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getMyPushSettings(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyPushSettings(request: UserUpdatePushSettingRequestDto): BaseResponse = + TODO("이 테스트 미사용") + + override suspend fun getMyMarketingConsents(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyMarketingConsents( + request: UserUpdateMarketingConsentRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getConnectedAccounts(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun linkConnectedAccount( + provider: String, + request: SocialAccountLinkRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun unlinkConnectedAccount(provider: String): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getReceiverDeliveryConditions(receiverId: Long): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiverDeliveryConditions( + receiverId: Long, + request: ReceiverDeliveryConditionUpdateRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + } + + private companion object { + const val RECEIVER_A = "계정 A 수신인" + const val RECEIVER_B = "계정 B 수신인" + const val RELATION = "친구" + const val TEST_TIMEOUT_MILLIS = 5_000L + const val QUIET_WAIT_MILLIS = 500L + + fun receiverIdOf(name: String): Long = if (name == RECEIVER_A) 1L else 2L + + fun receiver(name: String) = Receiver(receiverId = receiverIdOf(name), name = name, relation = RELATION) + } +} diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverRequestFailureTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverRequestFailureTest.kt index ae59f89b5f..7f3fddcd6f 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverRequestFailureTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverRequestFailureTest.kt @@ -2,7 +2,6 @@ package com.afternote.core.data.repoimpl import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.error.ReceiverRequestRejectedException -import com.afternote.core.domain.testing.FakeAuthRepository import com.afternote.core.network.dto.DeletePushTokenRequestDto import com.afternote.core.network.dto.PushTokenDto import com.afternote.core.network.dto.ReceiverDetailDto @@ -13,9 +12,11 @@ import com.afternote.core.network.dto.UserConnectedAccountDto import com.afternote.core.network.dto.UserCreateReceiverDto import com.afternote.core.network.dto.UserCreateReceiverRequestDto import com.afternote.core.network.dto.UserDto +import com.afternote.core.network.dto.UserMarketingConsentDto import com.afternote.core.network.dto.UserPatchReceiverDto import com.afternote.core.network.dto.UserPatchReceiverRequestDto import com.afternote.core.network.dto.UserPushSettingDto +import com.afternote.core.network.dto.UserUpdateMarketingConsentRequestDto import com.afternote.core.network.dto.UserUpdateProfileRequestDto import com.afternote.core.network.dto.UserUpdatePushSettingRequestDto import com.afternote.core.network.dto.UserUpdateReceiverMessageRequestDto @@ -25,34 +26,46 @@ import com.afternote.core.network.model.ApiException import com.afternote.core.network.model.BaseResponse import com.afternote.core.network.service.UserApiService import kotlinx.coroutines.runBlocking +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertSame import org.junit.Assert.assertTrue +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder /** - * `UserRepositoryImpl` 안의 `mapReceiverRequestFailure` 는 `private` 이라 이 파일에서 직접 호출할 수 - * 없다 — 그 helper 를 공유하는 공개 계약([UserRepositoryImpl.createReceiver])을 통해 같은 회귀를 고정한다. + * `UserReceiverRepositoryImpl` 옆의 `mapReceiverRequestFailure` 는 `private` 이라 이 파일에서 직접 호출할 수 + * 없다 — 그 helper 를 공유하는 공개 계약([UserReceiverRepositoryImpl.createReceiver])을 통해 같은 회귀를 고정한다. */ class ReceiverRequestFailureTest { - private fun repositoryThrowingOnCreate(apiError: ApiException) = - UserRepositoryImpl( + @get:Rule + val temporaryFolder = TemporaryFolder() + + // 등록 실패 매핑은 세션 상태와 무관하다. 저장소는 조립에 필요한 만큼만 세우고 비워 둔다. + private val sessionStore by lazy { TestTokenSessionStore(temporaryFolder.root) } + + @After + fun closeSessionStore() = sessionStore.close() + + private fun repositoryThrowingOnCreate(apiError: Throwable) = + UserReceiverRepositoryImpl( userApiService = CreateReceiverThrowingApiService(onCreateReceiver = { throw apiError }), - authRepository = FakeAuthRepository(loggedIn = false), + tokenDataSource = sessionStore.tokenDataSource, errorReporter = NoOpErrorReporter, ) - private suspend fun createReceiver(repository: UserRepositoryImpl) = + private suspend fun createReceiver(repository: UserReceiverRepositoryImpl) = repository.createReceiver( name = "친구", relation = "친구", phone = null, - email = null, + email = "friend@example.com", message = null, ) @Test - fun `400 서버 메시지는 사용자 노출 도메인 오류로 바뀐다`() = + fun `400 입력 거절은 고정 진단 문구와 원본 cause를 가진다`() = runBlocking { val apiError = ApiException( @@ -67,12 +80,13 @@ class ReceiverRequestFailureTest { assertTrue(result is ReceiverRequestRejectedException) val domainError = result as ReceiverRequestRejectedException - assertEquals("수신자 이메일은 필수입니다.", domainError.userMessage) + // 서버 원문과 관계없이 도메인 message는 같은 문구이며 원인은 그대로 보존한다. + assertEquals("receiver request rejected", domainError.message) assertSame(apiError, domainError.cause) } @Test - fun `409 서버 메시지는 사용자 노출 도메인 오류로 바뀐다`() = + fun `409 입력 거절도 같은 진단 문구와 원본 cause를 가진다`() = runBlocking { val apiError = ApiException( @@ -86,6 +100,34 @@ class ReceiverRequestFailureTest { val result = runCatching { createReceiver(repository) }.exceptionOrNull() assertTrue(result is ReceiverRequestRejectedException) + assertEquals("receiver request rejected", result?.message) + assertSame(apiError, result?.cause) + } + + @Test + fun `거절 사유 없는 400과 409는 원본 오류로 유지한다`() = + runBlocking { + for (status in listOf(400, 409)) { + for (serverMessage in listOf(null, "", " \t")) { + val apiError = ApiException(status, status, serverMessage, "fallback") + val repository = repositoryThrowingOnCreate(apiError) + + val result = runCatching { createReceiver(repository) }.exceptionOrNull() + + assertSame(apiError, result) + } + } + } + + @Test + fun `요청 취소는 도메인 실패로 변환하지 않는다`() = + runBlocking { + val cancellation = kotlinx.coroutines.CancellationException("cancelled") + val repository = repositoryThrowingOnCreate(cancellation) + + val result = runCatching { createReceiver(repository) }.exceptionOrNull() + + assertSame(cancellation, result) } @Test @@ -165,6 +207,11 @@ private class CreateReceiverThrowingApiService( override suspend fun updateMyPushSettings(request: UserUpdatePushSettingRequestDto): BaseResponse = TODO("이 테스트 미사용") + override suspend fun getMyMarketingConsents(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyMarketingConsents(request: UserUpdateMarketingConsentRequestDto): BaseResponse = + TODO("이 테스트 미사용") + override suspend fun getConnectedAccounts(): BaseResponse = TODO("이 테스트 미사용") override suspend fun linkConnectedAccount( diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverSessionBoundaryRaceTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverSessionBoundaryRaceTest.kt new file mode 100644 index 0000000000..5b5f46b51a --- /dev/null +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverSessionBoundaryRaceTest.kt @@ -0,0 +1,654 @@ +package com.afternote.core.data.repoimpl + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.data.repoimpl.auth.AuthRepositoryImpl +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope +import com.afternote.core.domain.push.DevicePushTargetProvider +import com.afternote.core.domain.repository.auth.AuthRepository +import com.afternote.core.domain.repository.push.PushTargetRepository +import com.afternote.core.model.user.Receiver +import com.afternote.core.network.dto.DeletePushTokenRequestDto +import com.afternote.core.network.dto.LoginDto +import com.afternote.core.network.dto.LoginRequestDto +import com.afternote.core.network.dto.LogoutRequestDto +import com.afternote.core.network.dto.PushTokenDto +import com.afternote.core.network.dto.ReceiverDetailDto +import com.afternote.core.network.dto.ReceiverListDto +import com.afternote.core.network.dto.RegisterPushTokenRequestDto +import com.afternote.core.network.dto.ReissueDto +import com.afternote.core.network.dto.ReissueRequestDto +import com.afternote.core.network.dto.SocialAccountLinkRequestDto +import com.afternote.core.network.dto.SocialLoginRequestDto +import com.afternote.core.network.dto.UserConnectedAccountDto +import com.afternote.core.network.dto.UserCreateReceiverDto +import com.afternote.core.network.dto.UserCreateReceiverRequestDto +import com.afternote.core.network.dto.UserDto +import com.afternote.core.network.dto.UserMarketingConsentDto +import com.afternote.core.network.dto.UserPatchReceiverDto +import com.afternote.core.network.dto.UserPatchReceiverRequestDto +import com.afternote.core.network.dto.UserPushSettingDto +import com.afternote.core.network.dto.UserUpdateMarketingConsentRequestDto +import com.afternote.core.network.dto.UserUpdateProfileRequestDto +import com.afternote.core.network.dto.UserUpdatePushSettingRequestDto +import com.afternote.core.network.dto.UserUpdateReceiverMessageRequestDto +import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionDto +import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionUpdateRequestDto +import com.afternote.core.network.model.BaseResponse +import com.afternote.core.network.service.AuthApiService +import com.afternote.core.network.service.TokenApiService +import com.afternote.core.network.service.UserApiService +import com.afternote.core.network.token.AccessTokenExpiryTracker +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.net.UnknownHostException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger + +/** + * 수신자 목록 캐시의 세션 경계 계약 (#2135, #2045 재현 조사에서 분리). + * + * 기존 세션 경계 테스트(`UserRepositoryImplTest`)도 실제 토큰 저장소를 쓰지만, 여기서는 세션을 여닫는 + * 쪽도 프로덕션 조립 그대로다. [AuthRepositoryImpl] 실물 + [UserReceiverRepositoryImpl] 실물 + + * [TestTokenSessionStore] 의 실제 파일 Preferences DataStore. 공개 계약([AuthRepository.saveSession] · + * [AuthRepository.logout] · [AuthRepository.rotateToken])만 호출하고 비공개 상태는 건드리지 않는다. + * + * 수집자가 세션 전환 구간을 제때 못 돌리는 상황은 두 가지로 모델링한다. [blockCollectorThread] 는 수집자 + * 디스패처를 잠그고, [TestTokenSessionStore.holdObservation] 은 관측 스트림만 멈춘다. 로그인 여부 Boolean 으로 캐시 수명을 정하면 이 구간의 로그아웃이 + * `true → true` 로 뭉개져 이전 계정의 목록이 새 계정에 남았다. + */ +class ReceiverSessionBoundaryRaceTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val sessionStore by lazy { TestTokenSessionStore(temporaryFolder.root) } + + // 데몬 스레드. 빗장이 끝내 안 풀리는 경우에도 이 스레드가 Gradle 워커를 붙잡고 있지 않게 한다. + private val collectorExecutor = + Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "receiver-collector").apply { isDaemon = true } } + private val collectorDispatcher = collectorExecutor.asCoroutineDispatcher() + private val collectorGate = CountDownLatch(1) + + @After + fun tearDown() { + // 단언이 빗장 구간 안에서 깨졌을 때를 위한 안전망. 잠긴 채로 두면 수집자 취소가 매달린다. + collectorGate.countDown() + collectorDispatcher.close() + sessionStore.close() + } + + @Test + fun `로그아웃과 새 계정 로그인이 한 구간에 겹쳐도 이전 계정 목록은 새 계정에 남지 않는다`() = + runBlocking { + var requestCount = 0 + val userApiService = + StubUserApiService( + onGetReceivers = { + requestCount += 1 + if (requestCount == 1) { + receiverListResponse(ACCOUNT_A_RECEIVER) + } else { + throw UnknownHostException("계정 B 조회 실패") + } + }, + ) + val authRepository = authRepository() + val repository = UserReceiverRepositoryImpl(userApiService, sessionStore.tokenDataSource, NoopErrorReporter) + + authRepository.saveSession(accessToken = "access-account-a", refreshToken = "refresh-account-a").getOrThrow() + + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = launch(collectorDispatcher) { repository.receiverListFlow.collect { emissions.send(it) } } + + try { + assertEquals(listOf(ACCOUNT_A_RECEIVER), emissions.receiveNames()) + + blockCollectorThread { + authRepository.logout().getOrThrow() + authRepository.saveSession(accessToken = "access-account-b", refreshToken = "refresh-account-b").getOrThrow() + } + + // 세션 경계가 전달됐다면 여기서 재시작 방출(빈 목록)이 온다. 늦게 오면 아래에서 받는다. + val afterBoundary = withTimeoutOrNull(BOUNDARY_WAIT_MILLIS) { emissions.receive() } + + createReceiver(repository, "계정 B 수신자") + val afterNewAccountFailure = withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() } + + // 재시작 방출이 늦게 와도 아래 단언은 그것을 받아 통과한다. 타이밍으로 red 가 되지 않는다. + assertEquals( + "새 계정 세션의 조회 실패가 이전 계정 목록으로 낮아졌다 " + + "(세션 경계 재시작 방출=${afterBoundary?.map { it.name }}, getReceivers 호출=$requestCount)", + emptyList(), + afterNewAccountFailure.map { it.name }, + ) + } finally { + collector.cancelCollector() + } + } + + /** + * 세션 식별자를 토큰 값에서 끌어오면(해시 등) 이 경우에 이전 세션과 값이 같아져 캐시가 그대로 살아난다. + * 로그아웃이 저장소를 통째로 비우므로 "직전 값 + 1" 같은 증가 카운터도 같은 함정에 빠진다. + */ + @Test + fun `로그아웃 뒤 같은 토큰 값으로 다시 로그인해도 이전 계정 목록은 남지 않는다`() = + runBlocking { + var requestCount = 0 + val userApiService = + StubUserApiService( + onGetReceivers = { + requestCount += 1 + if (requestCount == 1) { + receiverListResponse(ACCOUNT_A_RECEIVER) + } else { + throw UnknownHostException("재로그인 세션 조회 실패") + } + }, + ) + val authRepository = authRepository() + val repository = UserReceiverRepositoryImpl(userApiService, sessionStore.tokenDataSource, NoopErrorReporter) + + authRepository.saveSession(accessToken = SAME_ACCESS_TOKEN, refreshToken = SAME_REFRESH_TOKEN).getOrThrow() + + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = launch(collectorDispatcher) { repository.receiverListFlow.collect { emissions.send(it) } } + + try { + assertEquals(listOf(ACCOUNT_A_RECEIVER), emissions.receiveNames()) + + blockCollectorThread { + authRepository.logout().getOrThrow() + authRepository.saveSession(accessToken = SAME_ACCESS_TOKEN, refreshToken = SAME_REFRESH_TOKEN).getOrThrow() + } + + withTimeoutOrNull(BOUNDARY_WAIT_MILLIS) { emissions.receive() } + createReceiver(repository, "재로그인 세션 수신자") + + assertEquals( + "같은 토큰 값으로 다시 로그인한 세션이 이전 세션으로 취급됐다 (getReceivers 호출=$requestCount)", + emptyList(), + withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.map { it.name }, + ) + } finally { + collector.cancelCollector() + } + } + + /** 토큰 회전은 계정이 바뀌는 사건이 아니다. 여기서 캐시를 버리면 화면이 보고 있던 목록이 빈다. */ + @Test + fun `같은 세션의 토큰 회전 뒤에도 마지막으로 성공한 목록을 유지한다`() = + runBlocking { + var requestCount = 0 + val userApiService = + StubUserApiService( + onGetReceivers = { + requestCount += 1 + if (requestCount == 1) { + receiverListResponse(ACCOUNT_A_RECEIVER) + } else { + throw UnknownHostException("회전 뒤 갱신 조회 실패") + } + }, + ) + val authRepository = authRepository(onReissue = { ReissueDto(accessToken = "회전된 액세스", refreshToken = "회전된 리프레시") }) + val repository = UserReceiverRepositoryImpl(userApiService, sessionStore.tokenDataSource, NoopErrorReporter) + + authRepository.saveSession(accessToken = "access-account-a", refreshToken = "refresh-account-a").getOrThrow() + + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { repository.receiverListFlow.collect { emissions.send(it) } } + + try { + assertEquals(listOf(ACCOUNT_A_RECEIVER), emissions.receiveNames()) + + authRepository.rotateToken().getOrThrow() + createReceiver(repository, "회전 뒤 등록한 수신자") + + assertEquals( + "토큰 회전이 세션 교체로 취급돼 캐시가 버려졌다 (getReceivers 호출=$requestCount)", + listOf(ACCOUNT_A_RECEIVER), + withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.map { it.name }, + ) + assertEquals(2, requestCount) + } finally { + collector.cancelCollector() + } + } + + /** 세션 식별자 키가 생기기 전에 저장된 설치본이다. 로그인이 풀려서도, 회전이 세션 교체로 보여서도 안 된다. */ + @Test + fun `세션 식별자 없이 저장된 기존 토큰도 로그인 상태이고 회전 뒤 목록을 유지한다`() = + runBlocking { + var requestCount = 0 + val userApiService = + StubUserApiService( + onGetReceivers = { + requestCount += 1 + if (requestCount == 1) { + receiverListResponse(LEGACY_RECEIVER) + } else { + throw UnknownHostException("회전 뒤 갱신 조회 실패") + } + }, + ) + val authRepository = authRepository(onReissue = { ReissueDto(accessToken = "회전된 액세스", refreshToken = "회전된 리프레시") }) + val repository = UserReceiverRepositoryImpl(userApiService, sessionStore.tokenDataSource, NoopErrorReporter) + + sessionStore.writeLegacyTokens(accessToken = "기존 설치본 액세스", refreshToken = "기존 설치본 리프레시") + + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { repository.receiverListFlow.collect { emissions.send(it) } } + + try { + assertEquals( + "세션 식별자 없이 저장된 토큰이 로그아웃으로 읽혔다 (getReceivers 호출=$requestCount)", + listOf(LEGACY_RECEIVER), + emissions.receiveNames(), + ) + + authRepository.rotateToken().getOrThrow() + createReceiver(repository, "회전 뒤 등록한 수신자") + + assertEquals( + listOf(LEGACY_RECEIVER), + withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.map { it.name }, + ) + } finally { + collector.cancelCollector() + } + } + + /** 세션이 바뀌는 순간 매달려 있던 이전 세션의 조회는 취소돼야 하고, 늦게 끝나도 새 세션에 방출되면 안 된다. */ + @Test + fun `이전 세션의 늦은 조회 결과는 새 세션 목록으로 새지 않는다`() = + runBlocking { + var requestCount = 0 + val previousSessionFetchStarted = CompletableDeferred() + val previousSessionFetchCancelled = CompletableDeferred() + val previousSessionResponse = CompletableDeferred>>() + val userApiService = + StubUserApiService( + onGetReceivers = { + requestCount += 1 + if (requestCount == 1) { + previousSessionFetchStarted.complete(Unit) + try { + previousSessionResponse.await() + } catch (cancellation: CancellationException) { + previousSessionFetchCancelled.complete(Unit) + throw cancellation + } + } else { + receiverListResponse(ACCOUNT_B_RECEIVER) + } + }, + ) + val authRepository = authRepository() + val repository = UserReceiverRepositoryImpl(userApiService, sessionStore.tokenDataSource, NoopErrorReporter) + + authRepository.saveSession(accessToken = "access-account-a", refreshToken = "refresh-account-a").getOrThrow() + + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { repository.receiverListFlow.collect { emissions.send(it) } } + + try { + withTimeout(TEST_TIMEOUT_MILLIS) { previousSessionFetchStarted.await() } + + authRepository.logout().getOrThrow() + authRepository.saveSession(accessToken = "access-account-b", refreshToken = "refresh-account-b").getOrThrow() + + withTimeout(TEST_TIMEOUT_MILLIS) { previousSessionFetchCancelled.await() } + previousSessionResponse.complete(receiverListResponse(ACCOUNT_A_RECEIVER)) + + assertEquals(listOf(ACCOUNT_B_RECEIVER), emissions.receiveFirstNonEmptyNames()) + assertNull( + "이전 세션의 늦은 응답이 새 세션 목록으로 방출됐다", + withTimeoutOrNull(BOUNDARY_WAIT_MILLIS) { emissions.receive() }, + ) + } finally { + collector.cancelCollector() + } + } + + /** + * 수집자가 이전 계정 목록을 들고 있는 동안 세션이 바뀌면, 새 세션 조회가 끝나기 전에 목록이 먼저 + * 비어야 한다. 하류 `stateIn(WhileSubscribed(5000), emptyList())` 의 initialValue 는 내부 flow 가 + * 교체돼도 되돌아가지 않으므로(ReceiverListViewModel · RecipientListViewModel 둘 다 이 조립이다), + * 여기서 안 비우면 새 계정 화면에 이전 계정 수신인이 조회가 끝날 때까지 떠 있다. + * + * 이 테스트는 새 세션 조회를 응답 전 상태로 붙잡아 둔 채 단언한다. 조회 실패를 기다려서 비는 것과 + * 전환 즉시 비는 것을 가르기 위해서다. + */ + @Test + fun `세션이 바뀌면 새 세션 조회가 끝나기 전에 이전 계정 목록을 먼저 비운다`() = + runBlocking { + val fetches = ScriptedReceiverFetches() + val authRepository = authRepository() + val repository = + UserReceiverRepositoryImpl( + StubUserApiService(onGetReceivers = { fetches.fetch() }), + sessionStore.tokenDataSource, + NoopErrorReporter, + ) + + authRepository.saveSession(accessToken = "access-account-a", refreshToken = "refresh-account-a").getOrThrow() + + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = launch(collectorDispatcher) { repository.receiverListFlow.collect { emissions.send(it) } } + + try { + fetches.awaitFetchStarted() + fetches.respondWith(ACCOUNT_A_RECEIVER) + assertEquals(listOf(ACCOUNT_A_RECEIVER), emissions.receiveNames()) + + // 수집자가 중간 로그아웃을 못 보는 구간. 재개되면 세션 식별자가 A 에서 B 로 바뀐 것만 보인다. + sessionStore.holdObservation() + authRepository.logout().getOrThrow() + authRepository.saveSession(accessToken = "access-account-b", refreshToken = "refresh-account-b").getOrThrow() + sessionStore.releaseObservation() + + fetches.awaitFetchStarted() + assertEquals( + "새 세션 조회가 매달린 동안 이전 계정 목록이 이 수집자의 현재 값으로 남았다", + emptyList(), + emissions.receiveNames(), + ) + assertEquals( + "이 단언은 새 세션 조회가 아직 응답하기 전의 방출을 봐야 한다", + 1, + fetches.answeredCount, + ) + + fetches.respondWith(ACCOUNT_B_RECEIVER) + assertEquals(listOf(ACCOUNT_B_RECEIVER), emissions.receiveNames()) + } finally { + collector.cancelCollector() + } + } + + /** + * 세션 전환이 저장소에는 이미 커밋됐지만 상위 [com.afternote.core.datastore.TokenDataSource.sessionId] + * 관측이 아직 새 식별자를 전달하지 못한 구간이 있다. 그 구간에서는 이전 세션의 내부 flow 가 아직 + * 취소되지 않았으므로, 그때 끝난 조회의 성공도 실패 폴백도 이전 계정 목록을 새 계정으로 흘려보낸다. + * + * 이 테스트가 증명하는 창은 취소 이전이 아니라 **관측 이전** 이다. 취소를 먼저 기다리는 테스트는 이미 + * 취소된 경로만 보므로 이 구멍을 못 본다. 한 시나리오에서 실패 폴백(401 아님)과 성공 응답을 차례로 + * 태워 둘 다 고정한다. 이미 방출된 값을 되돌린다는 뜻은 아니다. 전환 뒤에 새로 방출되지 않는다는 뜻이다. + */ + @Test + fun `상위 관측이 새 세션을 받기 전에 끝난 이전 세션 조회는 목록에도 캐시에도 반영되지 않는다`() = + runBlocking { + val fetches = ScriptedReceiverFetches() + val authRepository = authRepository() + val repository = + UserReceiverRepositoryImpl( + StubUserApiService(onGetReceivers = { fetches.fetch() }), + sessionStore.tokenDataSource, + NoopErrorReporter, + ) + + authRepository.saveSession(accessToken = "access-account-a", refreshToken = "refresh-account-a").getOrThrow() + + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = launch(collectorDispatcher) { repository.receiverListFlow.collect { emissions.send(it) } } + + try { + fetches.awaitFetchStarted() + fetches.respondWith(ACCOUNT_A_RECEIVER) + assertEquals(listOf(ACCOUNT_A_RECEIVER), emissions.receiveNames()) + + // 여기서부터 상위 관측만 멈춘다. 쓰기는 그대로 커밋되고 즉시 재조회는 새 값을 본다. + sessionStore.holdObservation() + + createReceiver(repository, "전환 직전 등록") + fetches.awaitFetchStarted() + authRepository.logout().getOrThrow() + authRepository.saveSession(accessToken = "access-account-b", refreshToken = "refresh-account-b").getOrThrow() + + fetches.failWith(UnknownHostException("이전 세션 조회 실패")) + assertNull( + "이전 세션 조회의 실패 폴백이 새 세션에서 이전 계정 목록으로 방출됐다", + withTimeoutOrNull(BOUNDARY_WAIT_MILLIS) { emissions.receive() }, + ) + + createReceiver(repository, "전환 뒤 등록") + fetches.awaitFetchStarted() + fetches.respondWith(ACCOUNT_A_RECEIVER) + assertNull( + "이전 세션 조회의 늦은 성공 응답이 새 세션 목록으로 방출됐다", + withTimeoutOrNull(BOUNDARY_WAIT_MILLIS) { emissions.receive() }, + ) + + // 관측이 풀리면 비로소 새 세션의 목록이 선다. 그 전까지 받은 값에 이전 계정은 없어야 한다. + sessionStore.releaseObservation() + fetches.awaitFetchStarted() + fetches.respondWith(ACCOUNT_B_RECEIVER) + assertEquals(listOf(ACCOUNT_B_RECEIVER), emissions.receiveFirstNonEmptyNames()) + } finally { + collector.cancelCollector() + } + } + + /** + * 수집자 스레드를 잠근 채 [duringBlackout] 을 실행한다. 그 구간의 세션 변화는 수집자에게 뭉개져 전달된다. + * 빗장은 어떤 경로로 빠져나가든 반드시 풀어야 한다. 안 풀면 뒤따르는 수집자 취소가 그 스레드를 영영 기다린다. + */ + private suspend fun blockCollectorThread(duringBlackout: suspend () -> Unit) { + collectorExecutor.execute { collectorGate.await() } + try { + duringBlackout() + } finally { + collectorGate.countDown() + } + } + + private suspend fun Job.cancelCollector() { + collectorGate.countDown() + cancelAndJoin() + } + + private suspend fun Channel>.receiveNames(): List = + withTimeout(TEST_TIMEOUT_MILLIS) { receive() }.map { it.name } + + private suspend fun Channel>.receiveFirstNonEmptyNames(): List { + while (true) { + val names = receiveNames() + if (names.isNotEmpty()) return names + } + } + + private suspend fun createReceiver( + repository: UserReceiverRepositoryImpl, + name: String, + ) = repository.createReceiver( + name = name, + relation = "친구", + phone = null, + email = "receiver@example.com", + message = null, + ) + + private fun authRepository(onReissue: suspend () -> ReissueDto = { error("reissue 는 이 테스트에서 호출되면 안 됨") }): AuthRepository = + AuthRepositoryImpl( + tokenDataSource = sessionStore.tokenDataSource, + authApiService = StubAuthApiService, + tokenApiService = StubTokenApiService(onReissue), + expiryTracker = AccessTokenExpiryTracker(), + // 실제 레지스트리와 같은 정리 방식이다. SESSION 스코프 저장소의 키를 비운다. + localStoreRegistry = + object : LocalStoreRegistry { + override fun store( + name: String, + scope: StoreScope, + ) = error("store 는 이 테스트에서 호출되면 안 됨") + + override suspend fun clearScope(scope: StoreScope) { + if (scope == StoreScope.SESSION) sessionStore.clearSession() + } + }, + pushTargetRepository = + object : PushTargetRepository { + override suspend fun register(targetId: String): Result = error("register 는 이 테스트에서 호출되면 안 됨") + + override suspend fun unregister(targetId: String): Result = Result.success(Unit) + }, + devicePushTargetProvider = + object : DevicePushTargetProvider { + override suspend fun currentTargetId(): String? = error("currentTargetId 는 이 테스트에서 호출되면 안 됨") + + override suspend fun existingTargetId(): String? = null + }, + ) + + /** + * `getReceivers` 를 테스트가 한 건씩 열어 주는 조회로 바꾼다. 조회가 시작된 사실과 응답 시점을 테스트가 + * 따로 쥐고 있어야 "새 세션 조회가 아직 안 끝난 상태" 를 단언할 수 있다. + */ + private class ScriptedReceiverFetches { + private val started = Channel(capacity = Channel.UNLIMITED) + private val responses = Channel>>>(capacity = Channel.UNLIMITED) + private val answered = AtomicInteger() + + /** 응답까지 끝난 조회 수. 조회 코루틴이 다른 스레드라 원자 카운터로 읽는다. */ + val answeredCount: Int get() = answered.get() + + suspend fun fetch(): BaseResponse> { + started.send(Unit) + val response = responses.receive() + answered.incrementAndGet() + return response.getOrThrow() + } + + suspend fun awaitFetchStarted() { + withTimeout(TEST_TIMEOUT_MILLIS) { started.receive() } + } + + suspend fun respondWith(receiverName: String) = responses.send(Result.success(receiverListResponse(receiverName))) + + suspend fun failWith(error: Throwable) = responses.send(Result.failure(error)) + } + + private object NoopErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit + } + + private object StubAuthApiService : AuthApiService { + override suspend fun login(body: LoginRequestDto): BaseResponse = error("login 은 이 테스트에서 호출되면 안 됨") + + override suspend fun socialLogin(body: SocialLoginRequestDto): BaseResponse = + error("socialLogin 은 이 테스트에서 호출되면 안 됨") + + override suspend fun logout(body: LogoutRequestDto): BaseResponse = BaseResponse(status = 200, code = 200) + } + + private class StubTokenApiService( + private val onReissue: suspend () -> ReissueDto, + ) : TokenApiService { + override suspend fun reissue(body: ReissueRequestDto): BaseResponse = + BaseResponse(status = 200, code = 200, data = onReissue()) + } + + private class StubUserApiService( + private val onGetReceivers: suspend () -> BaseResponse>, + ) : UserApiService { + override suspend fun getReceivers(): BaseResponse> = onGetReceivers() + + override suspend fun createReceiver(request: UserCreateReceiverRequestDto): BaseResponse = + BaseResponse(status = 200, code = 200, data = UserCreateReceiverDto(receiverId = 2L, authCode = "AUTH-2")) + + override suspend fun deleteAccount(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun registerPushToken(request: RegisterPushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun deletePushToken(request: DeletePushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getReceiverDetail(receiverId: Long): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiver( + receiverId: Long, + request: UserPatchReceiverRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiverMessage( + receiverId: Long, + request: UserUpdateReceiverMessageRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getMyProfile(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyProfile(request: UserUpdateProfileRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getMyPushSettings(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyPushSettings(request: UserUpdatePushSettingRequestDto): BaseResponse = + TODO("이 테스트 미사용") + + override suspend fun getMyMarketingConsents(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyMarketingConsents( + request: UserUpdateMarketingConsentRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getConnectedAccounts(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun linkConnectedAccount( + provider: String, + request: SocialAccountLinkRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun unlinkConnectedAccount(provider: String): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getReceiverDeliveryConditions(receiverId: Long): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiverDeliveryConditions( + receiverId: Long, + request: ReceiverDeliveryConditionUpdateRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + } + + private companion object { + const val ACCOUNT_A_RECEIVER = "계정 A 수신인" + const val ACCOUNT_B_RECEIVER = "계정 B 수신인" + const val LEGACY_RECEIVER = "기존 설치본 수신인" + const val SAME_ACCESS_TOKEN = "같은 값의 액세스 토큰" + const val SAME_REFRESH_TOKEN = "같은 값의 리프레시 토큰" + const val TEST_TIMEOUT_MILLIS = 5_000L + const val BOUNDARY_WAIT_MILLIS = 1_000L + + fun receiverListResponse(name: String) = + BaseResponse( + status = 200, + code = 200, + data = + listOf( + ReceiverListDto( + receiverId = 1L, + name = name, + relation = "친구", + ), + ), + ) + } +} diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverUpdateRefreshTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverUpdateRefreshTest.kt new file mode 100644 index 0000000000..f5f97a197a --- /dev/null +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverUpdateRefreshTest.kt @@ -0,0 +1,282 @@ +package com.afternote.core.data.repoimpl + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.repository.UserReceiverRepository +import com.afternote.core.model.user.Receiver +import com.afternote.core.network.dto.DeletePushTokenRequestDto +import com.afternote.core.network.dto.PushTokenDto +import com.afternote.core.network.dto.ReceiverDetailDto +import com.afternote.core.network.dto.ReceiverListDto +import com.afternote.core.network.dto.RegisterPushTokenRequestDto +import com.afternote.core.network.dto.SocialAccountLinkRequestDto +import com.afternote.core.network.dto.UserConnectedAccountDto +import com.afternote.core.network.dto.UserCreateReceiverDto +import com.afternote.core.network.dto.UserCreateReceiverRequestDto +import com.afternote.core.network.dto.UserDto +import com.afternote.core.network.dto.UserMarketingConsentDto +import com.afternote.core.network.dto.UserPatchReceiverDto +import com.afternote.core.network.dto.UserPatchReceiverRequestDto +import com.afternote.core.network.dto.UserPushSettingDto +import com.afternote.core.network.dto.UserUpdateMarketingConsentRequestDto +import com.afternote.core.network.dto.UserUpdateProfileRequestDto +import com.afternote.core.network.dto.UserUpdatePushSettingRequestDto +import com.afternote.core.network.dto.UserUpdateReceiverMessageRequestDto +import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionDto +import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionUpdateRequestDto +import com.afternote.core.network.model.ApiException +import com.afternote.core.network.model.BaseResponse +import com.afternote.core.network.service.UserApiService +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * 수신자 수정 성공 뒤 구독 중인 목록 갱신 회귀 (#2127). + * + * `createReceiver` 와 달리 `updateReceiver` 는 목록 revision 을 올리지 않았다. 설정 › 수신인 수정에서 + * 저장하고 목록으로 돌아와도 `WhileSubscribed(5_000)` 로 살아 있는 **같은 구독** 이 옛 이름·관계를 + * 그대로 보여 준다. 구독이 끊겼다 붙는 느린 복귀에서만 우연히 최신 목록이 보였다. + * + * 그래서 여기서는 구독을 한 번도 끊지 않는다. 화면·ViewModel 을 세우지 않고 저장소 공개 계약 + * ([UserReceiverRepository.receiverListFlow] · [UserReceiverRepository.updateReceiver])만으로 고정한다. + * + * 400·409 를 도메인 거절로 바꾸는 매핑 자체는 같은 helper 를 공유하는 [ReceiverRequestFailureTest] 가 + * 지킨다. 이 파일이 보는 실패는 실패·취소에는 목록 재조회를 걸지 않는다 쪽이다. + */ +class ReceiverUpdateRefreshTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val sessionStore by lazy { TestTokenSessionStore(temporaryFolder.root) } + + @Before + fun openSession() = runBlocking { sessionStore.login() } + + @After + fun closeSessionStore() = sessionStore.close() + + @Test + fun `updateReceiver - 성공하면 재구독 없이 같은 구독자가 수정된 이름과 관계를 받는다`() = + runBlocking { + val apiService = MutableReceiverApiService(name = "원래 이름", relation = "친구") + val repository = receiverRepository(apiService) + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + repository.receiverListFlow.collect { emissions.send(it) } + } + + try { + val before = withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.single() + assertEquals(listOf("원래 이름", "친구"), listOf(before.name, before.relation)) + + val updated = requestUpdate(repository, name = "바뀐 이름", relation = "가족") + + val after = withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.single() + assertEquals(listOf("바뀐 이름", "가족"), listOf(after.name, after.relation)) + assertEquals(2, apiService.getReceiversCallCount) + // 갱신을 얹어도 수정 호출 자체의 반환값은 그대로다. + assertEquals(Receiver(RECEIVER_ID, "바뀐 이름", "가족"), updated) + } finally { + collector.cancelAndJoin() + } + } + + @Test + fun `updateReceiver - 서버가 실패하면 원본 오류를 올리고 목록을 다시 조회하지 않는다`() { + val apiError = + ApiException( + status = 500, + code = 500, + serverMessage = null, + fallbackMessage = "일시적인 서버 오류", + ) + + assertSame(apiError, failedUpdateError(apiError)) + } + + @Test + fun `updateReceiver - 취소된 수정도 목록을 다시 조회하지 않는다`() { + val cancellation = CancellationException("cancelled") + + assertSame(cancellation, failedUpdateError(cancellation)) + } + + /** + * 실패한 PATCH 하나를 구독 중인 목록에 물려 보고, 올라온 오류를 돌려준다. 오류를 그대로 올리는지는 + * 호출부가 보고, 여기서는 재조회가 걸리지 않았는지 를 확인한다. + */ + private fun failedUpdateError(failure: Throwable): Throwable? = + runBlocking { + val apiService = MutableReceiverApiService(name = "원래 이름", relation = "친구", failUpdateWith = failure) + val repository = receiverRepository(apiService) + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + repository.receiverListFlow.collect { emissions.send(it) } + } + + try { + assertEquals("원래 이름", withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.single().name) + + val raised = runCatching { requestUpdate(repository, name = "바뀐 이름", relation = "가족") }.exceptionOrNull() + + assertEquals(1, apiService.updateReceiverCallCount) + drainPendingRefresh() + assertEquals(1, apiService.getReceiversCallCount) + assertNull(emissions.tryReceive().getOrNull()) + raised + } finally { + collector.cancelAndJoin() + } + } + + private fun receiverRepository(apiService: UserApiService) = + UserReceiverRepositoryImpl( + userApiService = apiService, + tokenDataSource = sessionStore.tokenDataSource, + errorReporter = SilentErrorReporter, + ) + + private suspend fun requestUpdate( + repository: UserReceiverRepository, + name: String, + relation: String, + ): Receiver = + repository.updateReceiver( + receiverId = RECEIVER_ID, + name = name, + phone = "01012345678", + relation = relation, + email = "receiver@example.com", + ) + + /** + * revision 을 올려도 목록 조회는 그 자리에서 일어나지 않는다. 구독자 코루틴이 깨어나야 한다. 잘못 + * 얹은 갱신이 아직 안 돌았을 뿐 인 채로 초록이 되지 않도록 runBlocking 이벤트 루프를 몇 바퀴 돌린다. + */ + private suspend fun drainPendingRefresh() = repeat(DISPATCH_ROUNDS) { yield() } +} + +private const val RECEIVER_ID = 7L +private const val TEST_TIMEOUT_MILLIS = 2_000L +private const val DISPATCH_ROUNDS = 5 + +private object SilentErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit +} + +/** PATCH 를 실제 서버처럼 다음 조회에 반영 한다. 목록이 낡았는지는 이 반영을 봐야 드러난다. */ +private class MutableReceiverApiService( + name: String, + relation: String, + private val failUpdateWith: Throwable? = null, +) : UserApiService { + var getReceiversCallCount = 0 + private set + + var updateReceiverCallCount = 0 + private set + + private var serverName = name + private var serverRelation = relation + + override suspend fun getReceivers(): BaseResponse> { + getReceiversCallCount += 1 + return BaseResponse( + status = 200, + code = 200, + data = + listOf( + ReceiverListDto( + receiverId = RECEIVER_ID, + name = serverName, + relation = serverRelation, + ), + ), + ) + } + + override suspend fun updateReceiver( + receiverId: Long, + request: UserPatchReceiverRequestDto, + ): BaseResponse { + updateReceiverCallCount += 1 + failUpdateWith?.let { throw it } + serverName = request.name + serverRelation = request.relation + return BaseResponse( + status = 200, + code = 200, + data = + UserPatchReceiverDto( + receiverId = receiverId, + name = request.name, + phone = request.phone, + relation = request.relation, + email = request.email, + ), + ) + } + + override suspend fun createReceiver(request: UserCreateReceiverRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getReceiverDetail(receiverId: Long): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiverMessage( + receiverId: Long, + request: UserUpdateReceiverMessageRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getMyProfile(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyProfile(request: UserUpdateProfileRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun deleteAccount(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun registerPushToken(request: RegisterPushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun deletePushToken(request: DeletePushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getMyPushSettings(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyPushSettings(request: UserUpdatePushSettingRequestDto): BaseResponse = + TODO("이 테스트 미사용") + + override suspend fun getMyMarketingConsents(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyMarketingConsents(request: UserUpdateMarketingConsentRequestDto): BaseResponse = + TODO("이 테스트 미사용") + + override suspend fun getConnectedAccounts(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun linkConnectedAccount( + provider: String, + request: SocialAccountLinkRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun unlinkConnectedAccount(provider: String): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getReceiverDeliveryConditions(receiverId: Long): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiverDeliveryConditions( + receiverId: Long, + request: ReceiverDeliveryConditionUpdateRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") +} diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/TestTokenSessionStore.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/TestTokenSessionStore.kt new file mode 100644 index 0000000000..05298caf93 --- /dev/null +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/TestTokenSessionStore.kt @@ -0,0 +1,126 @@ +package com.afternote.core.data.repoimpl + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.afternote.core.datastore.TokenDataSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import java.io.File + +/** + * 실제 파일 Preferences DataStore + [TokenDataSource] 실물로 세션 경계를 세우는 테스트 하네스 (#2135). + * + * fake 의 `MutableStateFlow` 를 토글하는 방식으로는 로그아웃 방출이 수집자에게 뭉개지는 구간이 재현되지 + * 않는다. 이 결함이 정확히 그 구멍으로 살아남았다. 저장소 생성 경로는 `LocalStoreRegistryImpl.createStore` + * 와 같고, [clearSession] 은 로그아웃·탈퇴·401 이 쓰는 `LocalStoreRegistry.clearScope(StoreScope.SESSION)` + * 과 같이 키를 통째로 비운다. + */ +internal class TestTokenSessionStore( + directory: File, +) { + private val storeScope = CoroutineScope(SupervisorJob()) + + private val storedPreferences: DataStore = + PreferenceDataStoreFactory.create(scope = storeScope) { File(directory, STORE_FILE_NAME) } + + private val observationHeld = MutableStateFlow(false) + + private val store: DataStore = ObservationGatedDataStore(storedPreferences, observationHeld) + + val tokenDataSource = TokenDataSource(store) + + /** + * 이 시점에 이미 열려 있는 관측 스트림에 한해 새 저장값의 전달을 멈춘다 (#2135). + * + * 쓰기는 그대로 커밋되고, 멈춘 동안 새로 여는 읽기(`sessionId.first()` 같은 즉시 재조회)는 현재 저장값을 + * 본다. 실제 앱에서 수집자의 디스패처가 바빠 세션 전환 통지를 아직 처리하지 못한 구간이 이 모양이다. + * 멈춘 구간에 쌓인 중간 상태는 [releaseObservation] 때 마지막 값 하나로 합쳐져 전달된다. + */ + fun holdObservation() { + observationHeld.value = true + } + + fun releaseObservation() { + observationHeld.value = false + } + + /** 로그인. 프로덕션에서 `AuthRepository.saveSession` 이 도달하는 쓰기와 같다. */ + suspend fun login( + accessToken: String = DEFAULT_ACCESS_TOKEN, + refreshToken: String = DEFAULT_REFRESH_TOKEN, + ) = tokenDataSource.saveTokens(accessToken = accessToken, refreshToken = refreshToken) + + /** 같은 세션의 토큰 회전. `AuthRepository.rotateToken` 이 도달하는 쓰기와 같다. */ + suspend fun rotateTokens( + accessToken: String, + refreshToken: String, + ) = tokenDataSource.updateTokens(accessToken = accessToken, refreshToken = refreshToken) + + suspend fun clearSession() { + store.edit { it.clear() } + } + + /** + * 세션 식별자 키가 생기기 전 버전이 저장해 둔 토큰. 키 이름을 여기에 다시 적는 것이 이 helper 의 일이다. + * 기존 설치본의 디스크 내용을 흉내 내는 것이라 [TokenDataSource] 의 키 상수를 빌려 쓰면 "기존 저장분을 + * 계속 읽는가" 를 못 본다. + */ + suspend fun writeLegacyTokens( + accessToken: String = DEFAULT_ACCESS_TOKEN, + refreshToken: String = DEFAULT_REFRESH_TOKEN, + ) { + store.edit { prefs -> + prefs[stringPreferencesKey("access_token")] = accessToken + prefs[stringPreferencesKey("refresh_token")] = refreshToken + } + } + + fun close() = storeScope.cancel() + + private companion object { + const val STORE_FILE_NAME = "Token.preferences_pb" + const val DEFAULT_ACCESS_TOKEN = "access" + const val DEFAULT_REFRESH_TOKEN = "refresh" + } +} + +/** + * 실제 [DataStore] 를 감싸, 쓰기는 이미 커밋됐지만 이어지던 관측 스트림에는 아직 전달되지 않은 구간을 + * 결정적으로 만드는 테스트 장치 (#2135). + * + * 붙잡는 대상은 [held] 가 켜진 시점에 이미 열려 있던 수집뿐이다. 그 뒤에 새로 여는 읽기는 통과해 현재 + * 저장값을 본다. 실제 DataStore 도 커밋된 쓰기를 새 읽기에는 곧바로 주고, 이어지던 수집자에게는 그 + * 코루틴이 재개될 때 전달한다. + * + * 붙잡는 동안에는 위임 저장소의 수집 자체를 끊고, 풀릴 때 현재 저장값부터 다시 수집한다. 그래서 붙잡힌 + * 구간의 중간 상태(로그아웃)는 수집자에게 도달할 수 없고, 풀린 뒤 첫 값은 반드시 그때의 저장값이다. + * 수집자가 중간 상태를 못 보고 다음 세션만 보는 구간이 이 결함의 전제이므로 타이밍 운에 맡기지 않는다. + */ +private class ObservationGatedDataStore( + private val delegate: DataStore, + private val held: StateFlow, +) : DataStore { + @OptIn(ExperimentalCoroutinesApi::class) + override val data: Flow = + flow { + if (held.value) { + emitAll(delegate.data) + } else { + emitAll(held.flatMapLatest { holding -> if (holding) emptyFlow() else delegate.data }) + } + } + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences = delegate.updateData(transform) +} diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/UploadFailureTranslationTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/UploadFailureTranslationTest.kt new file mode 100644 index 0000000000..2ee1da15ac --- /dev/null +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/UploadFailureTranslationTest.kt @@ -0,0 +1,80 @@ +package com.afternote.core.data.repoimpl + +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.error.FileUploadFailure +import com.afternote.core.network.model.ApiException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.coroutines.cancellation.CancellationException + +/** + * 파일 업로드 실패 번역 계약 가드 (#1868). + * + * 계약 — 서버가 파일 크기 초과(code 1803)로 거절하면 [FileUploadFailure.FileSizeExceeded] 로 + * 번역해 소비처가 타입으로 분기할 수 있게 하고, 사유를 확인하지 못한 실패는 원본 그대로 흘려보내 + * 기존 재시도 안내 경로에 남긴다. 사진·영상 두 구현이 같은 함수를 체인하므로 여기서 한 번 고정한다. + */ +class UploadFailureTranslationTest { + @Test + fun `파일 크기 초과(1803)는 FileSizeExceeded 로 번역`() { + val cause = apiException(code = 1803, serverMessage = "파일 크기는 10MB 이하여야 합니다.") + + val result = Result.failure(cause).mapUploadFailure() + + val error = result.exceptionOrNull() + assertTrue(error is FileUploadFailure.FileSizeExceeded) + assertSame("서버 코드값은 cause 인 ApiException 이 갖는다", cause, error?.cause) + } + + @Test + fun `그 외 code 실패는 원본 ApiException 유지`() { + val cause = apiException(code = 1400, serverMessage = "요청 값이 올바르지 않습니다.") + + val result = Result.failure(cause).mapUploadFailure() + + val error = result.exceptionOrNull() + assertTrue(error is ApiException) + assertEquals(1400, (error as ApiException).code) + } + + @Test + fun `ApiException 이 아닌 실패는 원본 유지 (S3 PUT 실패 등 재시도가 통하는 경로)`() { + val cause = IllegalStateException("S3 upload failed: 503 Service Unavailable") + + val result = Result.failure(cause).mapUploadFailure() + + assertSame(cause, result.exceptionOrNull()) + } + + @Test + fun `취소는 Result 로 소비하지 않고 다시 던짐 (코루틴 취소 보존)`() { + val thrown = + try { + runCatchingCancellable { throw CancellationException("업로드 취소") }.mapUploadFailure() + null + } catch (expected: CancellationException) { + expected + } + + assertTrue(thrown is CancellationException) + } + + @Test + fun `성공은 그대로 통과`() { + val result = Result.success("uploaded").mapUploadFailure() + + assertEquals("uploaded", result.getOrNull()) + } + + private fun apiException( + code: Int, + serverMessage: String, + ) = ApiException( + status = 400, + code = code, + serverMessage = serverMessage, + fallbackMessage = "요청에 실패했습니다.", + ) +} diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt index 2d657f0892..aa6f503ab3 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt @@ -1,8 +1,12 @@ package com.afternote.core.data.repoimpl import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.datastore.TokenDataSource +import com.afternote.core.domain.repository.UserReceiverRepository +import com.afternote.core.domain.repository.UserRepository import com.afternote.core.model.user.Receiver +import com.afternote.core.model.user.ReceiverCreated +import com.afternote.core.network.di.NetworkModule import com.afternote.core.network.dto.DeletePushTokenRequestDto import com.afternote.core.network.dto.PushTokenDto import com.afternote.core.network.dto.ReceiverDetailDto @@ -13,9 +17,11 @@ import com.afternote.core.network.dto.UserConnectedAccountDto import com.afternote.core.network.dto.UserCreateReceiverDto import com.afternote.core.network.dto.UserCreateReceiverRequestDto import com.afternote.core.network.dto.UserDto +import com.afternote.core.network.dto.UserMarketingConsentDto import com.afternote.core.network.dto.UserPatchReceiverDto import com.afternote.core.network.dto.UserPatchReceiverRequestDto import com.afternote.core.network.dto.UserPushSettingDto +import com.afternote.core.network.dto.UserUpdateMarketingConsentRequestDto import com.afternote.core.network.dto.UserUpdateProfileRequestDto import com.afternote.core.network.dto.UserUpdatePushSettingRequestDto import com.afternote.core.network.dto.UserUpdateReceiverMessageRequestDto @@ -24,106 +30,58 @@ import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionUpdateRe import com.afternote.core.network.model.ApiException import com.afternote.core.network.model.BaseResponse import com.afternote.core.network.service.UserApiService +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import java.net.UnknownHostException class UserRepositoryImplTest { - private val calls = mutableListOf() + @get:Rule + val temporaryFolder = TemporaryFolder() + private val errorReporter = RecordingErrorReporter() + /** + * 수신자 목록의 세션 경계는 fake 의 Boolean 이 아니라 실제 토큰 저장소가 정한다 (#2135). + * 각 테스트는 로그인된 세션에서 시작하고, 로그아웃이 필요한 테스트만 [TestTokenSessionStore.clearSession] 을 부른다. + */ + private val sessionStore by lazy { TestTokenSessionStore(temporaryFolder.root) } + + @Before + fun openSession() = runBlocking { sessionStore.login() } + + @After + fun closeSessionStore() = sessionStore.close() + private fun repository( - deleteAccountResponse: BaseResponse = success(), - clearSessionResult: Result = Result.success(Unit), onGetReceivers: suspend () -> BaseResponse> = { TODO("이 테스트 미사용") }, onCreateReceiver: suspend (UserCreateReceiverRequestDto) -> BaseResponse = { TODO("이 테스트 미사용") }, - authRepository: FakeAuthRepository = receiverAuthRepository(loggedIn = true), - ) = UserRepositoryImpl( + ) = repositoryOf( userApiService = FakeUserApiService( - onDeleteAccount = { - calls += "deleteAccount" - deleteAccountResponse - }, onGetReceivers = onGetReceivers, onCreateReceiver = onCreateReceiver, ), - authRepository = - authRepository.apply { - onClearSession = { - calls += "clearSession" - clearSessionResult - } - }, + tokenDataSource = sessionStore.tokenDataSource, errorReporter = errorReporter, ) - @Test - fun `deleteAccount - 탈퇴 성공 시 로컬 세션을 정리한다`() { - val repository = repository() - - runBlocking { repository.deleteAccount() } - - assertEquals(listOf("deleteAccount", "clearSession"), calls) - assertEquals(0, errorReporter.writtenFailures.size) - } - - @Test - fun `deleteAccount - 서버 탈퇴 실패면 세션을 유지한다`() { - val repository = repository(deleteAccountResponse = BaseResponse(status = 500, code = 500)) - - assertThrows(ApiException::class.java) { - runBlocking { repository.deleteAccount() } - } - - assertEquals(listOf("deleteAccount"), calls) - assertEquals(0, errorReporter.writtenFailures.size) - } - - /** - * 서버 계정은 이미 지워진 뒤라 정리 실패를 예외로 올리면 화면이 "탈퇴 실패" 로 표시되고, - * 사용자의 재시도는 없는 계정에 대해 다시 실패한다. 삼키는 것이 계약이다. - */ - @Test - fun `deleteAccount - 세션 정리가 실패해도 탈퇴는 성공으로 끝난다`() { - val failure = IllegalStateException("datastore 쓰기 실패") - val repository = repository(clearSessionResult = Result.failure(failure)) - - runBlocking { repository.deleteAccount() } - - assertEquals(listOf("deleteAccount", "clearSession"), calls) - val (reported, attributes) = errorReporter.writtenFailures.single() - assertEquals(IllegalStateException::class.java.name, reported.message) - assertEquals( - mapOf( - "account_stage" to "delete_session_cleanup", - "error_type" to IllegalStateException::class.java.name, - ), - attributes, - ) - } - - /** 정리가 DELETE 앞에 오면 요청이 토큰 없이 나가므로, 순서 자체가 계약이다. */ - @Test - fun `deleteAccount - 세션 정리는 서버 호출 뒤에 온다`() { - val repository = repository() - - runBlocking { repository.deleteAccount() } - - assertEquals(0, calls.indexOf("deleteAccount")) - assertEquals(1, calls.indexOf("clearSession")) - } - @Test fun `getReceivers - 다음 호출은 서버의 최신 계정 목록을 다시 조회한다`() { var requestCount = 0 @@ -162,6 +120,33 @@ class UserRepositoryImplTest { assertEquals(2, requestCount) } + /** + * 실서버 본문을 프로덕션 Json 으로 디코드해 그대로 태운다 (#2105). 항목 하나가 깨지면 목록 전체가 + * 조용히 빈 목록으로 떨어지므로, 건수와 «리포터에 남은 실패 0건» 을 같이 본다. + */ + @Test + fun `receiverListFlow - 실서버 목록 본문을 항목 소실 없이 낸다`() { + val payload = + """{"status":200,"code":200,"message":"성공","data":[{"receiverId":14,"name":"QA수신자","relation":"DAUGHTER"},{"receiverId":21,"name":"Admin","relation":null}]}""" + val repository = + repository( + onGetReceivers = { + NetworkModule.provideJson().decodeFromString>>(payload) + }, + ) + + val emitted = runBlocking { repository.receiverListFlow.first() } + + assertEquals( + listOf( + Receiver(receiverId = 14L, name = "QA수신자", relation = "DAUGHTER"), + Receiver(receiverId = 21L, name = "Admin", relation = ""), + ), + emitted, + ) + assertEquals(0, errorReporter.writtenFailures.size) + } + @Test fun `receiverListFlow - 조회가 실패해도 예외로 새지 않고 빈 목록을 낸다`() { val repository = repository(onGetReceivers = { throw UnknownHostException("Unable to resolve host") }) @@ -214,10 +199,9 @@ class UserRepositoryImplTest { @Test fun `receiverListFlow - 로그아웃 중에는 서버를 호출하지 않는다`() { var requestCount = 0 - val authRepository = receiverAuthRepository(loggedIn = false) + runBlocking { sessionStore.clearSession() } val repository = repository( - authRepository = authRepository, onGetReceivers = { requestCount += 1 dataResponse(listOf(receiverDto("호출되면 안 됨"))) @@ -236,10 +220,8 @@ class UserRepositoryImplTest { fun `receiverListFlow - 로그아웃 뒤 새 세션의 첫 실패에는 이전 계정 목록을 내지 않는다`() = runBlocking { var requestCount = 0 - val authRepository = receiverAuthRepository(loggedIn = true) val repository = repository( - authRepository = authRepository, onGetReceivers = { requestCount += 1 if (requestCount == 1) { @@ -261,14 +243,14 @@ class UserRepositoryImplTest { withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.map { it.name }, ) - authRepository.loggedIn = false + sessionStore.clearSession() assertEquals( emptyList(), withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }, ) assertEquals(1, requestCount) - authRepository.loggedIn = true + sessionStore.login(accessToken = "새 세션 액세스", refreshToken = "새 세션 리프레시") assertEquals( emptyList(), withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }, @@ -322,7 +304,7 @@ class UserRepositoryImplTest { name = "새 수신자", relation = "친구", phone = null, - email = null, + email = "receiver@example.com", message = null, ) assertEquals("조회 2", withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.single().name) @@ -332,6 +314,46 @@ class UserRepositoryImplTest { } } + /** + * 수신자 구현이 [UserRepositoryImpl] 밖으로 나갔어도 좁은 계약과 합본 계약은 **같은 인스턴스**를 + * 봐야 한다 (#1282). 여기서 갈리면 `UserReceiverRepository` 로 등록한 수신자가 + * `UserRepository` 구독자의 목록을 갱신하지 못하고 화면이 방금 만든 수신인을 놓친다. + */ + @Test + fun `좁은 계약과 합본 계약은 같은 수신자 갱신 상태를 본다`() = + runBlocking { + var requestCount = 0 + val repository = + repository( + onGetReceivers = { + requestCount += 1 + dataResponse(listOf(receiverDto("조회 $requestCount"))) + }, + onCreateReceiver = { dataResponse(UserCreateReceiverDto(receiverId = 2L, authCode = "AUTH-2")) }, + ) + val narrowContract: UserReceiverRepository = repository + val mergedContract: UserRepository = repository + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + mergedContract.receiverListFlow.collect { emissions.send(it) } + } + + try { + assertEquals("조회 1", withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.single().name) + narrowContract.createReceiver( + name = "새 수신자", + relation = "친구", + phone = null, + email = "receiver@example.com", + message = null, + ) + assertEquals("조회 2", withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.single().name) + } finally { + collector.cancelAndJoin() + } + } + /** * 크래시를 막자고 «보고 있던 목록» 을 지우면 안 된다. 첫 조회 실패는 아직 아무것도 못 본 상태라 * 빈 목록이 맞지만(위 테스트들), 두 번째부터의 실패는 화면에 떠 있던 수신인을 0명으로 바꾼다. @@ -364,7 +386,7 @@ class UserRepositoryImplTest { name = "새 수신자", relation = "친구", phone = null, - email = null, + email = "receiver@example.com", message = null, ) val fallback = withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() } @@ -410,7 +432,7 @@ class UserRepositoryImplTest { name = "새 수신자", relation = "친구", phone = null, - email = null, + email = "receiver@example.com", message = null, ) val afterUnauthorized = withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() } @@ -423,11 +445,173 @@ class UserRepositoryImplTest { } } - private fun receiverAuthRepository(loggedIn: Boolean): FakeAuthRepository = - FakeAuthRepository.strict(loggedIn = loggedIn).apply { - onIsLoggedIn = { loggedInState } + /** + * #709 회귀 — POST 가 성공한 순간 등록은 확정된다. 뒤따르는 목록 갱신 조회가 아직 매달려 있어도 + * 호출자는 [ReceiverCreated] 를 받아야 하고, 그 조회가 끝내 실패해도 POST 는 한 번뿐이어야 한다. + * + * 옛 구현은 등록 직후의 조회를 `createReceiver` 안에서 직접 기다렸다가 그 실패를 등록 실패로 올렸다. + * 화면은 오류 문구도 없이 사용자를 같은 등록 버튼으로 돌려보냈고, 서버에 중복 방지가 없으면 + * 재시도가 같은 수신자를 한 번 더 만들었다. + */ + @Test + fun `createReceiver - 목록 갱신 조회가 매달려 있어도 등록은 그 자리에서 끝난다`() = + runBlocking { + var getCount = 0 + var postCount = 0 + val refreshStarted = Channel(capacity = Channel.UNLIMITED) + val refreshGate = CompletableDeferred() + val repository = + repository( + onGetReceivers = { + getCount += 1 + if (getCount == 1) { + dataResponse(listOf(receiverDto("계정 A"))) + } else { + refreshStarted.send(Unit) + refreshGate.await() + throw UnknownHostException("등록 직후 갱신 조회 실패") + } + }, + onCreateReceiver = { + postCount += 1 + dataResponse(UserCreateReceiverDto(receiverId = 2L, authCode = "AUTH-2")) + }, + ) + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + repository.receiverListFlow.collect { emissions.send(it) } + } + + try { + assertEquals( + listOf("계정 A"), + withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.map { it.name }, + ) + + val registration = + async(start = CoroutineStart.UNDISPATCHED) { + repository.createReceiver( + name = "새 수신자", + relation = "친구", + phone = null, + email = "receiver@example.com", + message = null, + ) + } + withTimeout(TEST_TIMEOUT_MILLIS) { refreshStarted.receive() } + + assertTrue("등록은 매달린 갱신 조회를 기다리지 않는다", registration.isCompleted) + assertEquals(ReceiverCreated(receiverId = 2L, authCode = "AUTH-2"), registration.await()) + assertEquals(1, postCount) + assertNull("갱신 조회가 끝나기 전에는 새 목록도 나오지 않는다", emissions.tryReceive().getOrNull()) + + refreshGate.complete(Unit) + assertEquals( + listOf("계정 A"), + withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.map { it.name }, + ) + assertEquals(1, postCount) + assertEquals(2, getCount) + } finally { + refreshGate.complete(Unit) + collector.cancelAndJoin() + } + } + + /** + * #709 회귀 — 실패한 갱신 조회는 등록과 무관하게 혼자 복구된다. 목록이 다시 떠도 등록 POST 가 + * 다시 나가면 안 된다(같은 수신자가 두 번 생긴다). + */ + @Test + fun `createReceiver - 갱신 조회가 실패한 뒤 목록이 복구돼도 POST 를 다시 보내지 않는다`() = + runBlocking { + var getCount = 0 + var postCount = 0 + val repository = + repository( + onGetReceivers = { + getCount += 1 + when (getCount) { + 1 -> dataResponse(listOf(receiverDto("계정 A"))) + 2 -> throw UnknownHostException("등록 직후 갱신 조회 실패") + else -> dataResponse(listOf(receiverDto("복구된 목록"))) + } + }, + onCreateReceiver = { + postCount += 1 + dataResponse(UserCreateReceiverDto(receiverId = 2L, authCode = "AUTH-2")) + }, + ) + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + repository.receiverListFlow.collect { emissions.send(it) } + } + + try { + withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() } + + val created = + repository.createReceiver( + name = "새 수신자", + relation = "친구", + phone = null, + email = "receiver@example.com", + message = null, + ) + assertEquals(ReceiverCreated(receiverId = 2L, authCode = "AUTH-2"), created) + assertEquals( + listOf("계정 A"), + withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.map { it.name }, + ) + + val recovered = withTimeout(TEST_TIMEOUT_MILLIS) { repository.receiverListFlow.first() } + + assertEquals("복구된 목록", recovered.single().name) + assertEquals(1, postCount) + assertEquals(3, getCount) + } finally { + collector.cancelAndJoin() + } } + /** + * #709 회귀 — 목록을 아무도 구독하지 않는 상태에서도 등록은 조회 결과에 매이지 않는다. + * 옛 구현은 구독자와 무관하게 등록 직후 조회를 직접 불러, 그 실패가 그대로 등록 실패가 됐다. + */ + @Test + fun `createReceiver - 목록 구독자가 없으면 조회 실패와 무관하게 등록 결과를 돌려준다`() { + var getCount = 0 + var postCount = 0 + val repository = + repository( + onGetReceivers = { + getCount += 1 + throw UnknownHostException("등록 직후 갱신 조회 실패") + }, + onCreateReceiver = { + postCount += 1 + dataResponse(UserCreateReceiverDto(receiverId = 2L, authCode = "AUTH-2")) + }, + ) + + val created = + runBlocking { + repository.createReceiver( + name = "새 수신자", + relation = "친구", + phone = null, + email = "receiver@example.com", + message = null, + ) + } + + assertEquals(ReceiverCreated(receiverId = 2L, authCode = "AUTH-2"), created) + assertEquals(1, postCount) + assertEquals(0, getCount) + } + private companion object { const val TEST_TIMEOUT_MILLIS = 2_000L } @@ -444,8 +628,6 @@ private class RecordingErrorReporter : ErrorReporter { } } -private fun success() = BaseResponse(status = 200, code = 200) - private fun dataResponse(data: T) = BaseResponse(status = 200, code = 200, data = data) private fun receiverDto(name: String) = @@ -453,15 +635,13 @@ private fun receiverDto(name: String) = receiverId = 1L, name = name, relation = "친구", - authCode = "AUTH-1", ) private class FakeUserApiService( - private val onDeleteAccount: suspend () -> BaseResponse, private val onGetReceivers: suspend () -> BaseResponse>, private val onCreateReceiver: suspend (UserCreateReceiverRequestDto) -> BaseResponse, ) : UserApiService { - override suspend fun deleteAccount(): BaseResponse = onDeleteAccount() + override suspend fun deleteAccount(): BaseResponse = TODO("이 테스트 미사용") override suspend fun getReceivers(): BaseResponse> = onGetReceivers() @@ -493,6 +673,11 @@ private class FakeUserApiService( override suspend fun updateMyPushSettings(request: UserUpdatePushSettingRequestDto): BaseResponse = TODO("이 테스트 미사용") + override suspend fun getMyMarketingConsents(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyMarketingConsents(request: UserUpdateMarketingConsentRequestDto): BaseResponse = + TODO("이 테스트 미사용") + override suspend fun getConnectedAccounts(): BaseResponse = TODO("이 테스트 미사용") override suspend fun linkConnectedAccount( @@ -509,3 +694,14 @@ private class FakeUserApiService( request: ReceiverDeliveryConditionUpdateRequestDto, ): BaseResponse = TODO("이 테스트 미사용") } + +/** 프로덕션 조립과 같은 모양 — 위임 대상은 Hilt 가 주입하므로 여기서는 테스트가 대신 만들어 넘긴다. */ +private fun repositoryOf( + userApiService: UserApiService, + tokenDataSource: TokenDataSource, + errorReporter: ErrorReporter, +): UserRepositoryImpl = + UserRepositoryImpl( + receiverRepository = UserReceiverRepositoryImpl(userApiService, tokenDataSource, errorReporter), + myProfileRepository = MyProfileRepositoryImpl(userApiService), + ) diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImplTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImplTest.kt index 9bf3db20de..bbeff858bd 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImplTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImplTest.kt @@ -20,11 +20,19 @@ import org.junit.Assert.assertTrue import org.junit.Test /** - * [AccountRepositoryImpl.verifyEmail] 의 예외 번역 계약 회귀 가드 (#472). + * [AccountRepositoryImpl] 의 예외 번역 계약 회귀 가드 (#472 · #457). * * 계약 — 서버가 인증번호 무효(code 1207)로 거절하면 [CoreAuthFailure.EmailVerification] 으로 * 번역해 Presentation 이 타입으로 분기(인라인 표시)할 수 있게 하고, 그 외 실패 * (네트워크·서버 오류 등)는 원본 예외를 유지해 기존 스낵바 경로로 흐르게 한다. + * + * 설정의 비밀번호 변경(#564)도 같은 규칙을 따른다 — 이 메서드만 번역이 빠져 있어 1206·1702 가 + * 원본 `ApiException` 으로 새고 있었다. 화면이 타입으로만 분기하므로 번역이 끊기면 조용히 폴백 + * 문구가 나간다. + * + * 비밀번호 찾기(#457)가 더한 두 코드도 같은 규칙을 따른다 — 소셜 가입 계정(1702)은 화면이 + * 차단 팝업으로, 새 비밀번호가 기존과 같음(1206)은 스낵바 전용 문구로 가른다. 둘 다 타입으로만 + * 갈리므로 여기서 번역이 끊기면 화면이 조용히 폴백 문구를 낸다. */ class AccountRepositoryImplTest { private fun repository(accountApiService: AccountApiService) = AccountRepositoryImpl(accountApiService) @@ -77,6 +85,197 @@ class AccountRepositoryImplTest { assertTrue(result.isSuccess) } + + @Test + fun `resetPassword - 소셜 가입 계정(1702)은 SocialSignUpAccount 로 번역`() { + val repository = + repository( + FakeAccountApiService( + onFindPassword = { + throw ApiException( + status = 400, + code = 1702, + serverMessage = "소셜 로그인으로 가입한 계정입니다. 소셜 로그인을 이용해주세요.", + fallbackMessage = "소셜 로그인으로 가입한 계정입니다.", + ) + }, + ), + ) + + val result = runBlocking { repository.resetPassword(EMAIL, CODE, NEW_PASSWORD, NEW_PASSWORD) } + + assertTrue(result.exceptionOrNull() is CoreAuthFailure.SocialSignUpAccount) + } + + @Test + fun `resetPassword - 기존과 같은 비밀번호(1206)는 PasswordUnchanged 로 번역`() { + val repository = + repository( + FakeAccountApiService( + onFindPassword = { + throw ApiException( + status = 400, + code = 1206, + serverMessage = "새 비밀번호와 같습니다.", + fallbackMessage = "새 비밀번호와 같습니다.", + ) + }, + ), + ) + + val result = runBlocking { repository.resetPassword(EMAIL, CODE, NEW_PASSWORD, NEW_PASSWORD) } + + assertTrue(result.exceptionOrNull() is CoreAuthFailure.PasswordUnchanged) + } + + @Test + fun `resetPassword - 인증번호 무효(1207)는 EmailVerification 으로 번역`() { + val repository = + repository( + FakeAccountApiService( + onFindPassword = { + throw ApiException( + status = 400, + code = 1207, + serverMessage = "인증번호가 유효하지 않습니다.", + fallbackMessage = "인증번호가 유효하지 않습니다.", + ) + }, + ), + ) + + val result = runBlocking { repository.resetPassword(EMAIL, CODE, NEW_PASSWORD, NEW_PASSWORD) } + + assertTrue(result.exceptionOrNull() is CoreAuthFailure.EmailVerification) + } + + @Test + fun `resetPassword - 그 외 code 실패는 원본 ApiException 유지 (스낵바 경로)`() { + val repository = + repository( + FakeAccountApiService( + onFindPassword = { + throw ApiException(status = 400, code = 1219, serverMessage = null, fallbackMessage = "가입되지 않은 이메일입니다.") + }, + ), + ) + + val result = runBlocking { repository.resetPassword(EMAIL, CODE, NEW_PASSWORD, NEW_PASSWORD) } + + val error = result.exceptionOrNull() + assertTrue(error is ApiException) + assertEquals(1219, (error as ApiException).code) + } + + @Test + fun `resetPassword - 네 필드를 서버 요청 그대로 싣는다`() { + var sent: PasswordFindRequestDto? = null + val repository = + repository( + FakeAccountApiService( + onFindPassword = { body -> + sent = body + success(Unit) + }, + ), + ) + + val result = runBlocking { repository.resetPassword(EMAIL, CODE, NEW_PASSWORD, "Confirm1!") } + + assertTrue(result.isSuccess) + assertEquals(PasswordFindRequestDto(EMAIL, CODE, NEW_PASSWORD, "Confirm1!"), sent) + } + + @Test + fun `passwordChange - 기존과 같은 비밀번호(1206)는 PasswordUnchanged 로 번역`() { + val repository = + repository( + FakeAccountApiService( + onPasswordChange = { + throw ApiException( + status = 400, + code = 1206, + serverMessage = "새 비밀번호와 같습니다.", + fallbackMessage = "새 비밀번호와 같습니다.", + ) + }, + ), + ) + + val result = runBlocking { repository.passwordChange(CURRENT_PASSWORD, NEW_PASSWORD) } + + assertTrue(result.exceptionOrNull() is CoreAuthFailure.PasswordUnchanged) + } + + @Test + fun `passwordChange - 소셜 가입 계정(1702)은 SocialSignUpAccount 로 번역`() { + val repository = + repository( + FakeAccountApiService( + onPasswordChange = { + throw ApiException( + status = 400, + code = 1702, + serverMessage = "소셜 로그인으로 가입한 계정입니다. 소셜 로그인을 이용해주세요.", + fallbackMessage = "소셜 로그인으로 가입한 계정입니다.", + ) + }, + ), + ) + + val result = runBlocking { repository.passwordChange(CURRENT_PASSWORD, NEW_PASSWORD) } + + assertTrue(result.exceptionOrNull() is CoreAuthFailure.SocialSignUpAccount) + } + + @Test + fun `passwordChange - 그 외 code 실패는 원본 ApiException 유지 (폴백 문구 경로)`() { + val repository = + repository( + FakeAccountApiService( + onPasswordChange = { + throw ApiException( + status = 401, + code = 1202, + serverMessage = null, + fallbackMessage = "아이디 또는 비밀번호가 일치하지 않습니다.", + ) + }, + ), + ) + + val result = runBlocking { repository.passwordChange(CURRENT_PASSWORD, NEW_PASSWORD) } + + val error = result.exceptionOrNull() + assertTrue(error is ApiException) + assertEquals(1202, (error as ApiException).code) + } + + @Test + fun `passwordChange - 두 필드를 서버 요청 그대로 싣는다`() { + var sent: PasswordChangeRequestDto? = null + val repository = + repository( + FakeAccountApiService( + onPasswordChange = { body -> + sent = body + success(Unit) + }, + ), + ) + + val result = runBlocking { repository.passwordChange(CURRENT_PASSWORD, NEW_PASSWORD) } + + assertTrue(result.isSuccess) + assertEquals(PasswordChangeRequestDto(CURRENT_PASSWORD, NEW_PASSWORD), sent) + } + + private companion object { + const val EMAIL = "user@example.com" + const val CURRENT_PASSWORD = "OldPass1!" + const val CODE = "123456" + const val NEW_PASSWORD = "NewPass1!" + } } private fun success(data: T) = BaseResponse(status = 200, code = 200, message = "성공", data = data) @@ -89,6 +288,12 @@ private class FakeAccountApiService( private val onVerifyEmail: () -> BaseResponse = { error("verifyEmail 은 이 시나리오에서 호출되면 안 됨") }, + private val onFindPassword: (PasswordFindRequestDto) -> BaseResponse = { + error("findPassword 는 이 시나리오에서 호출되면 안 됨") + }, + private val onPasswordChange: (PasswordChangeRequestDto) -> BaseResponse = { + error("passwordChange 는 이 시나리오에서 호출되면 안 됨") + }, ) : AccountApiService { override suspend fun sendEmailCode(body: SendEmailCodeRequestDto): BaseResponse = error("sendEmailCode 는 이 시나리오에서 호출되면 안 됨") @@ -97,11 +302,11 @@ private class FakeAccountApiService( override suspend fun findEmail(body: EmailFindRequestDto): BaseResponse = error("findEmail 은 이 시나리오에서 호출되면 안 됨") - override suspend fun findPassword(body: PasswordFindRequestDto): BaseResponse = error("findPassword 는 이 시나리오에서 호출되면 안 됨") + override suspend fun findPassword(body: PasswordFindRequestDto): BaseResponse = onFindPassword(body) override suspend fun verifyEmail(body: VerifyEmailRequestDto): BaseResponse = onVerifyEmail() override suspend fun signUp(body: SignUpRequestDto): BaseResponse = error("signUp 은 이 시나리오에서 호출되면 안 됨") - override suspend fun passwordChange(body: PasswordChangeRequestDto): BaseResponse = error("passwordChange 는 이 시나리오에서 호출되면 안 됨") + override suspend fun passwordChange(body: PasswordChangeRequestDto): BaseResponse = onPasswordChange(body) } diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/appversion/AppVersionRepositoryImplTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/appversion/AppVersionRepositoryImplTest.kt new file mode 100644 index 0000000000..0145b76bfb --- /dev/null +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/appversion/AppVersionRepositoryImplTest.kt @@ -0,0 +1,98 @@ +package com.afternote.core.data.repoimpl.appversion + +import com.afternote.core.network.dto.AppPlatformDto +import com.afternote.core.network.dto.AppVersionDto +import com.afternote.core.network.model.ApiException +import com.afternote.core.network.model.BaseResponse +import com.afternote.core.network.service.AppVersionApiService +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * 강제 업데이트 조회의 경계 계약 회귀 가드 (#1539). + * + * 관문이 fail-open 이려면 이 경계가 **예외를 밖으로 내보내지 않아야** 한다. 전송 실패든 + * 봉투 실패든 전부 `Result.failure` 로 접어 호출자가 «못 물어봤다» 하나로 다룰 수 있게 한다. + */ +class AppVersionRepositoryImplTest { + @Test + fun `서버 응답을 도메인 모델로 옮긴다`() { + val service = + FakeAppVersionApiService { _, _ -> + envelope(AppVersionDto(updateRequired = true, latestVersionCode = 10_001, storeUrl = STORE_URL)) + } + + val result = runBlocking { AppVersionRepositoryImpl(service).checkAndroidVersion(10_000) } + + val check = result.getOrThrow() + assertTrue(check.updateRequired) + assertEquals(10_001, check.latestVersionCode) + assertEquals(STORE_URL, check.storeUrl) + } + + @Test + fun `플랫폼은 ANDROID 로 고정하고 versionCode 는 그대로 보낸다`() { + var seen: Pair? = null + val service = + FakeAppVersionApiService { platform, versionCode -> + seen = platform to versionCode + envelope(AppVersionDto(updateRequired = false, latestVersionCode = 10_001, storeUrl = null)) + } + + runBlocking { AppVersionRepositoryImpl(service).checkAndroidVersion(10_000) } + + assertEquals(AppPlatformDto.ANDROID to 10_000, seen) + } + + @Test + fun `업데이트가 필요 없으면 스토어 주소는 null 이다`() { + val service = + FakeAppVersionApiService { _, _ -> + envelope(AppVersionDto(updateRequired = false, latestVersionCode = 10_001, storeUrl = null)) + } + + val result = runBlocking { AppVersionRepositoryImpl(service).checkAndroidVersion(10_001) } + + assertNull(result.getOrThrow().storeUrl) + } + + @Test + fun `전송 실패는 예외를 던지지 않고 실패 Result 가 된다`() { + val service = FakeAppVersionApiService { _, _ -> throw IOException("서버 무응답") } + + val result = runBlocking { AppVersionRepositoryImpl(service).checkAndroidVersion(10_000) } + + assertTrue(result.exceptionOrNull() is IOException) + } + + @Test + fun `봉투가 실패를 말하면 실패 Result 가 된다`() { + val service = + FakeAppVersionApiService { _, _ -> + BaseResponse(status = 500, code = 5000, message = "앱 버전 릴리스가 등록되지 않았습니다.", data = null) + } + + val result = runBlocking { AppVersionRepositoryImpl(service).checkAndroidVersion(10_000) } + + assertTrue(result.exceptionOrNull() is ApiException) + } + + private fun envelope(dto: AppVersionDto) = BaseResponse(status = 200, code = 200, message = "성공", data = dto) + + private companion object { + const val STORE_URL = "https://play.google.com/store/apps/details?id=com.afternote.afternote_fe" + } +} + +private class FakeAppVersionApiService( + private val onCheck: (AppPlatformDto, Int) -> BaseResponse, +) : AppVersionApiService { + override suspend fun checkVersion( + platform: AppPlatformDto, + versionCode: Int, + ): BaseResponse = onCheck(platform, versionCode) +} diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt index ed5c92d285..13b0360aed 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt @@ -35,8 +35,9 @@ import kotlin.coroutines.cancellation.CancellationException * [AuthRepositoryImpl] 의 선제 reissue deadline 관리 계약 회귀 가드 (#408, PR #411 리뷰 반영). * 로그인 경로의 실패 매핑 계약(#628)도 함께 가드한다 — 전송 계층 IO 실패 → * [CoreAuthFailure.NetworkUnavailable], 자격 거절(1201·1202) → [CoreAuthFailure.InvalidLoginCredentials], - * 소셜 거절(1208·1209) → [CoreAuthFailure.SocialLoginRejected], 그 밖의 서버 실패는 치환하지 않아 - * 소비처에서 일반 문구로 내려앉는다. 서버 `message` 는 판정에 쓰지 않는다(BE#92). + * 소셜 거절(1208·1209) → [CoreAuthFailure.SocialLoginRejected], 소셜 가입 계정(1702) → + * [CoreAuthFailure.SocialSignUpAccount], 그 밖의 서버 실패는 치환하지 않아 소비처에서 일반 문구로 + * 내려앉는다. 서버 `message` 는 판정에 쓰지 않는다(BE#92). * * 계약 — 발급(로그인) 응답의 `expiresIn` 은 기록하고, 생략(null)이면 이전 토큰 기준 stale * deadline 이 새 세션에 적용되지 않게 비우며(`TokenReissuer` 회전 경로와 같은 규칙), @@ -228,6 +229,29 @@ class AuthRepositoryImplTest { assertTrue(exception?.cause is ApiException) } + @Test + fun `defaultLogin - 소셜 가입 계정(1702)은 SocialSignUpAccount 로 치환 (자격 거절과 가름)`() { + val repository = + repository( + FakeAuthApiService( + onLogin = { + throw ApiException( + status = 400, + code = 1702, + serverMessage = "소셜 로그인으로 가입한 계정입니다. 소셜 로그인을 이용해주세요.", + fallbackMessage = "소셜 로그인으로 가입한 계정입니다. 소셜 로그인을 이용해주세요.", + ) + }, + ), + ) + + val result = runBlocking { repository.defaultLogin("social@example.com", "pw") } + + val exception = result.exceptionOrNull() + assertTrue(exception is CoreAuthFailure.SocialSignUpAccount) + assertTrue(exception?.cause is ApiException) + } + @Test fun `defaultLogin - allowlist 밖 코드는 치환하지 않음 (5xx 내부 문구가 표시 경로로 못 감)`() { val internalMessage = "ERROR: duplicate key value violates unique constraint" diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistryImpl.kt b/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistryImpl.kt index 9a73ff296e..9f4613b575 100644 --- a/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistryImpl.kt +++ b/core/datastore/src/main/java/com/afternote/core/datastore/LocalStoreRegistryImpl.kt @@ -126,7 +126,7 @@ internal class LocalStoreRegistryImpl( } /** 아직 디스크에 안 간 매니페스트 기록을 기다린다 — [clearScope] 선행 단계이자 재기동 테스트의 flush 지점. */ - internal suspend fun awaitPendingRegistrations() { + private suspend fun awaitPendingRegistrations() { synchronized(lock) { pendingManifestJobs.toList() }.joinAll() } @@ -196,7 +196,7 @@ internal class LocalStoreRegistryImpl( private const val TAG = "LocalStoreRegistry" /** 레지스트리 자신의 (name → scope) 영속 기록 파일 — [store] 의 name 으로 쓸 수 없다. */ - internal const val MANIFEST_NAME = "local_store_registry" + private const val MANIFEST_NAME = "local_store_registry" private val SESSION_NAMES = stringSetPreferencesKey("session_store_names") private val DEVICE_NAMES = stringSetPreferencesKey("device_store_names") diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt b/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt index 916be8cc02..2193103bea 100644 --- a/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt +++ b/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt @@ -11,12 +11,16 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import java.io.IOException +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton /** * DataStore 기반 액세스·리프레시 토큰 로컬 저장. * 읽기 스트림은 [IOException] 시 [emptyPreferences]로 복구합니다. + * + * 토큰과 함께 [sessionId] 를 보관한다. 로그인 여부(Boolean)만으로는 "로그아웃 뒤 다른 계정 로그인" 을 + * 관측자가 구분할 수 없기 때문이다 (#2135). */ @Singleton class TokenDataSource @@ -27,6 +31,9 @@ class TokenDataSource private object Keys { val ACCESS_TOKEN = stringPreferencesKey("access_token") val REFRESH_TOKEN = stringPreferencesKey("refresh_token") + + // 키 이름은 저장 스키마 계약이다. 바꾸면 기존 세션이 식별자 없는 세션으로 떨어진다. + val SESSION_ID = stringPreferencesKey("session_id") } private val preferencesFlow: Flow = @@ -39,11 +46,30 @@ class TokenDataSource } } - val isLoggedIn: Flow = + /** + * 지금 저장된 로그인 세션의 식별자. 세션이 없으면 `null` 이다. + * + * 세션마다 **값이 다른 것** 이 이 흐름의 계약이다. 로그인 여부 Boolean 은 로그아웃과 새 로그인이 + * 한 구간에 겹치면 관측자에게 `true → true` 로 뭉개져, 세션이 바뀐 줄 모르는 구독자가 이전 계정의 + * 상태를 이어 쓴다 (#2135). 식별자는 [saveTokens] 가 토큰과 **같은 `edit` 블록** 에서 새로 발급하므로 + * (원자적 기록) 토큰과 식별자가 어긋난 중간 상태가 관측되지 않고, 새 로그인이 이전 세션과 같은 토큰 + * 값을 받아도 값이 같아지지 않는다. + * + * 식별자 키가 없던 시절에 저장된 세션은 [LEGACY_SESSION_ID] 로 읽는다. 로그인 상태는 그대로 유지되고, + * 다음 로그인부터 발급된 식별자를 쓴다. + */ + val sessionId: Flow = preferencesFlow.map { prefs -> - prefs[Keys.ACCESS_TOKEN] != null + if (prefs[Keys.ACCESS_TOKEN] == null) { + null + } else { + prefs[Keys.SESSION_ID] ?: LEGACY_SESSION_ID + } } + val isLoggedIn: Flow = sessionId.map { it != null } + + /** 로그인으로 세션을 새로 연다. 세션 식별자를 토큰과 같은 쓰기로 함께 발급한다. */ suspend fun saveTokens( accessToken: String, refreshToken: String, @@ -51,15 +77,30 @@ class TokenDataSource dataStore.edit { prefs -> prefs[Keys.ACCESS_TOKEN] = accessToken prefs[Keys.REFRESH_TOKEN] = refreshToken + prefs[Keys.SESSION_ID] = UUID.randomUUID().toString() } } + /** + * 같은 세션 안에서 토큰만 회전한다 ([saveTokens] 와 달리 [sessionId] 를 새로 발급하지 않는다). + * 회전은 계정이 바뀌는 사건이 아니므로, 세션에 귀속된 캐시를 버리게 만들면 안 된다. + */ suspend fun updateTokens( accessToken: String, refreshToken: String, - ) = saveTokens(accessToken, refreshToken) + ) { + dataStore.edit { prefs -> + prefs[Keys.ACCESS_TOKEN] = accessToken + prefs[Keys.REFRESH_TOKEN] = refreshToken + } + } suspend fun getAccessToken(): String? = preferencesFlow.first()[Keys.ACCESS_TOKEN] suspend fun getRefreshToken(): String? = preferencesFlow.first()[Keys.REFRESH_TOKEN] + + private companion object { + /** 세션 식별자 키가 생기기 전에 저장된 세션의 식별자. 그 세션이 끝날 때까지 이 값 하나로 고정된다. */ + const val LEGACY_SESSION_ID = "legacy-session" + } } diff --git a/core/datastore/src/test/java/com/afternote/core/datastore/LocalStoreRegistryImplTest.kt b/core/datastore/src/test/java/com/afternote/core/datastore/LocalStoreRegistryImplTest.kt index 5d6553a9ee..b1bcf3c80b 100644 --- a/core/datastore/src/test/java/com/afternote/core/datastore/LocalStoreRegistryImplTest.kt +++ b/core/datastore/src/test/java/com/afternote/core/datastore/LocalStoreRegistryImplTest.kt @@ -25,6 +25,10 @@ import java.io.File * 잔존이 재현된다. 매니페스트 영속이 그 구멍을 막는 것을 레지스트리 재생성(= 프로세스 재시작 * 시뮬레이션)으로 검증한다. 실물 파일 DataStore 를 쓰며, 프로세스 종료는 registryScope 취소로 * 흉내 낸다 (DataStore 의 "파일당 활성 인스턴스 1개" 등록이 scope 종료로 풀린다). + * + * 매니페스트 기록은 비동기라 종료 전에 디스크까지 내려보내야 한다. 그 flush 는 공개 operation + * [LocalStoreRegistry.clearScope] 로 한다 — clearScope 는 계약상 «대기 중인 등록을 먼저 반영한 + * 뒤» 지우므로, 아직 값을 쓰기 전에 한 번 부르면 등록만 확정되고 지울 것은 없다 (#1672). */ class LocalStoreRegistryImplTest { @get:Rule @@ -84,9 +88,11 @@ class LocalStoreRegistryImplTest { try { val registry = newRegistry(scope) + // 레지스트리 자신의 매니페스트 파일 이름. 값은 디스크 계약이라 리터럴로 고정한다 — + // 상수를 공유하려고 프로덕션 visibility 를 넓히지 않는다 (#1672). val thrown = runCatching { - registry.store(LocalStoreRegistryImpl.MANIFEST_NAME, StoreScope.DEVICE) + registry.store("local_store_registry", StoreScope.DEVICE) }.exceptionOrNull() assertTrue(thrown is IllegalArgumentException) @@ -120,9 +126,13 @@ class LocalStoreRegistryImplTest { val scope1 = newScope() val registry1 = newRegistry(scope1) runBlocking { + registry1.store("session_store", StoreScope.SESSION) + registry1.store("device_store", StoreScope.DEVICE) + // 아직 값이 없을 때 부르는 clearScope — 등록만 디스크로 확정시키는 flush 지점이다. + registry1.clearScope(StoreScope.SESSION) + registry1.store("session_store", StoreScope.SESSION).edit { it[key] = "잔존 후보" } registry1.store("device_store", StoreScope.DEVICE).edit { it[key] = "기기 값" } - registry1.awaitPendingRegistrations() } shutdown(scope1) @@ -148,8 +158,10 @@ class LocalStoreRegistryImplTest { val scope1 = newScope() val registry1 = newRegistry(scope1) runBlocking { + registry1.store("session_store", StoreScope.SESSION) + registry1.clearScope(StoreScope.SESSION) + registry1.store("session_store", StoreScope.SESSION).edit { it[key] = "값" } - registry1.awaitPendingRegistrations() } shutdown(scope1) diff --git a/core/datastore/src/test/java/com/afternote/core/datastore/TokenDataSourceTest.kt b/core/datastore/src/test/java/com/afternote/core/datastore/TokenDataSourceTest.kt new file mode 100644 index 0000000000..60b28806ee --- /dev/null +++ b/core/datastore/src/test/java/com/afternote/core/datastore/TokenDataSourceTest.kt @@ -0,0 +1,128 @@ +package com.afternote.core.datastore + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * [TokenDataSource.sessionId] 의 세션 경계 계약 (#2135). + * + * 로그인 여부 Boolean 만으로는 "로그아웃 뒤 다른 계정 로그인" 이 관측자에게 `true → true` 로 뭉개진다. + * 세션에 귀속된 캐시(현재 소비처는 `UserReceiverRepositoryImpl` 의 수신자 목록)는 이 식별자로 수명을 + * 가르므로, 여기서 고정하는 것은 세 가지다. 로그인마다 값이 달라질 것, 같은 세션의 토큰 회전에는 값이 + * 유지될 것, 식별자 키가 없던 시절의 저장분도 로그인 상태로 읽힐 것. + * + * 저장소는 실물 파일 DataStore 를 프로덕션과 같은 경로([LocalStoreRegistry])로 얻고, 로그아웃도 + * 프로덕션과 같은 [LocalStoreRegistry.clearScope] 로 한다. + */ +class TokenDataSourceTest { + @get:Rule + val tmp = TemporaryFolder() + + private val registryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val registry: LocalStoreRegistry = + LocalStoreRegistryImpl( + produceFile = { name -> File(tmp.root, "$name.preferences_pb") }, + registryScope = registryScope, + ) + + private val store = registry.store(name = "Token", scope = StoreScope.SESSION) + + private val tokenDataSource = TokenDataSource(store) + + @After + fun tearDown() = registryScope.cancel() + + @Test + fun `로그인은 세션 식별자를 함께 발급한다`() = + runBlocking { + tokenDataSource.saveTokens(accessToken = "access", refreshToken = "refresh") + + assertTrue(tokenDataSource.isLoggedIn.first()) + assertNotNullSessionId(tokenDataSource.sessionId.first()) + } + + /** + * 같은 사용자가 같은 토큰 값을 다시 받아도 다른 세션이다. 식별자를 토큰에서 끌어오거나 저장소에 + * 남은 값을 증가시키는 방식은 로그아웃이 저장소를 비우는 순간 같은 값으로 되돌아온다. + */ + @Test + fun `로그아웃 뒤 같은 토큰 값으로 다시 로그인해도 세션 식별자는 달라진다`() = + runBlocking { + tokenDataSource.saveTokens(accessToken = "access", refreshToken = "refresh") + val first = tokenDataSource.sessionId.first() + + registry.clearScope(StoreScope.SESSION) + tokenDataSource.saveTokens(accessToken = "access", refreshToken = "refresh") + val second = tokenDataSource.sessionId.first() + + assertNotNullSessionId(first) + assertNotNullSessionId(second) + assertNotEquals(first, second) + } + + @Test + fun `토큰 회전은 같은 세션 식별자를 유지한다`() = + runBlocking { + tokenDataSource.saveTokens(accessToken = "access", refreshToken = "refresh") + val beforeRotation = tokenDataSource.sessionId.first() + + tokenDataSource.updateTokens(accessToken = "회전된 액세스", refreshToken = "회전된 리프레시") + + assertEquals(beforeRotation, tokenDataSource.sessionId.first()) + assertEquals("회전된 액세스", tokenDataSource.getAccessToken()) + assertEquals("회전된 리프레시", tokenDataSource.getRefreshToken()) + } + + @Test + fun `세션 정리 뒤에는 세션 식별자가 없다`() = + runBlocking { + tokenDataSource.saveTokens(accessToken = "access", refreshToken = "refresh") + + registry.clearScope(StoreScope.SESSION) + + assertNull(tokenDataSource.sessionId.first()) + assertFalse(tokenDataSource.isLoggedIn.first()) + } + + /** + * 식별자 키가 생기기 전 버전이 저장해 둔 토큰이다. 업데이트만으로 로그인이 풀리면 안 되고, 그 세션이 + * 끝날 때까지 식별자가 흔들려도 안 된다(흔들리면 세션에 귀속된 캐시가 이유 없이 버려진다). + */ + @Test + fun `세션 식별자 없이 저장된 기존 토큰도 로그인 상태이고 식별자가 고정된다`() = + runBlocking { + store.edit { prefs -> + prefs[stringPreferencesKey("access_token")] = "기존 설치본 액세스" + prefs[stringPreferencesKey("refresh_token")] = "기존 설치본 리프레시" + } + + val legacySessionId = tokenDataSource.sessionId.first() + assertTrue(tokenDataSource.isLoggedIn.first()) + assertNotNullSessionId(legacySessionId) + + tokenDataSource.updateTokens(accessToken = "회전된 액세스", refreshToken = "회전된 리프레시") + + assertEquals(legacySessionId, tokenDataSource.sessionId.first()) + } + + private fun assertNotNullSessionId(sessionId: String?) { + assertTrue("로그인된 세션에는 식별자가 있어야 한다 (실제=$sessionId)", !sessionId.isNullOrBlank()) + } +} diff --git a/core/domain/src/main/java/com/afternote/core/domain/error/CoreAuthFailure.kt b/core/domain/src/main/java/com/afternote/core/domain/error/CoreAuthFailure.kt index 6cce539bd1..bb3fe9f34f 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/error/CoreAuthFailure.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/error/CoreAuthFailure.kt @@ -44,6 +44,31 @@ sealed class CoreAuthFailure( cause: Throwable, ) : CoreAuthFailure("email verification code invalid", cause) + /** + * 그 이메일이 소셜 로그인으로 가입한 계정이라는 사실(서버 code 1702). + * + * BE `AuthService` 는 `password == null` 인 사용자를 **세 경로**에서 이 코드로 거절한다 — + * 계정 복구(`findActiveLocalUserForRecovery`) · 이메일 로그인(`login`) · 비밀번호 변경 + * (`passwordChange`). 복구는 인증번호 발송(`auth/find/send/code`) 단계에서 이미 걸리므로, + * 비밀번호 찾기 화면은 코드 입력 전에 차단 안내를 낼 수 있다. 온보딩 전용 사유가 아니다. + * + * [SocialLoginRejected] 와 다르다 — 그쪽은 소셜 로그인 **시도**가 거절된 것이고, 이쪽은 로컬 + * 비밀번호가 없는 계정에 비밀번호를 요구한 것이라 안내가 "소셜로 로그인하라" 로 갈린다. + */ + class SocialSignUpAccount( + cause: Throwable, + ) : CoreAuthFailure("account signed up via social provider", cause) + + /** + * 새 비밀번호가 지금 쓰는 비밀번호와 같다는 사실(서버 code 1206). + * + * 서버만 판정할 수 있다 — 클라는 기존 비밀번호를 모른다(비밀번호 찾기는 현재 비밀번호를 + * 입력받지 않는다). 폴백 문구로 뭉개면 "왜 실패했는지" 를 사용자가 알 수 없어 따로 둔다. + */ + class PasswordUnchanged( + cause: Throwable, + ) : CoreAuthFailure("new password equals current password", cause) + /** * 서버 응답 없이 전송 계층에서 끝난 실패(DNS 해석 불가·타임아웃·연결 거부 등)의 도메인 표현. * diff --git a/core/domain/src/main/java/com/afternote/core/domain/error/FileUploadFailure.kt b/core/domain/src/main/java/com/afternote/core/domain/error/FileUploadFailure.kt new file mode 100644 index 0000000000..0310b17ad1 --- /dev/null +++ b/core/domain/src/main/java/com/afternote/core/domain/error/FileUploadFailure.kt @@ -0,0 +1,31 @@ +package com.afternote.core.domain.error + +/** + * 파일 업로드(presigned URL 발급 → S3 PUT) 실패 중 **사유가 확인된 것**의 공통 루트. + * + * 사진·영상 두 업로드가 같은 `POST /files/presigned-url` 로 크기를 실어 보내고 같은 서버 코드를 + * 맞으므로 매체별로 가르지 않는다 — 실패가 걸리는 대상이 사진이나 영상이 아니라 파일이다. + * + * data 계층이 `ApiException` 을 이 계열로 번역하고, presentation 은 이 루트로 좁힌 뒤 `when` 으로 + * 가른다 — 하위 타입이 늘면 컴파일러가 소비처를 잡아준다(`sealed`). 사유를 확인하지 못한 실패는 + * 번역하지 않고 원본 그대로 흘려보내므로, 소비처의 폴백 분기는 계속 필요하다. + * + * 표시 문구는 호출처 리소스가 갖는다(BE#92 — 서버 `message` 는 사용자 노출용이라는 규정이 없다). + * 서버 코드값은 cause 인 `ApiException` 이 갖는다. `message` 는 그 둘 어느 쪽도 아닌 **리포팅 + * 콘솔용 정적 진단 문구**다 — [CoreAuthFailure] 가 세운 규약을 그대로 따른다. + */ +sealed class FileUploadFailure( + message: String, + cause: Throwable, +) : Exception(message, cause) { + /** + * 업로드하려는 파일이 서버가 허용하는 크기를 넘었다는 사실(서버 code 1803 · FILE_SIZE_EXCEEDED). + * + * 재시도로 풀리지 않는 유일한 업로드 실패라 따로 둔다 — 같은 파일을 다시 보내면 서버가 같은 + * 코드로 다시 거절한다. 두 저장소 구현 모두 원본 파일 크기를 그대로 실어 보내므로 + * (`contentLength = tempFile.length()`) 사진·영상 어느 경로에서든 온다. + */ + class FileSizeExceeded( + cause: Throwable, + ) : FileUploadFailure("upload file size exceeded", cause) +} diff --git a/core/domain/src/main/java/com/afternote/core/domain/error/PushSettingFailure.kt b/core/domain/src/main/java/com/afternote/core/domain/error/PushSettingFailure.kt new file mode 100644 index 0000000000..cedf937b68 --- /dev/null +++ b/core/domain/src/main/java/com/afternote/core/domain/error/PushSettingFailure.kt @@ -0,0 +1,14 @@ +package com.afternote.core.domain.error + +sealed class PushSettingFailure( + message: String, + cause: Throwable, +) : Exception(message, cause) { + class NetworkUnavailable( + cause: Throwable, + ) : PushSettingFailure("push setting network unavailable", cause) + + class ServerUnavailable( + cause: Throwable, + ) : PushSettingFailure("push setting server unavailable", cause) +} diff --git a/core/domain/src/main/java/com/afternote/core/domain/error/ReceiverRequestRejectedException.kt b/core/domain/src/main/java/com/afternote/core/domain/error/ReceiverRequestRejectedException.kt index 8924769955..3f028ffa3d 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/error/ReceiverRequestRejectedException.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/error/ReceiverRequestRejectedException.kt @@ -1,7 +1,17 @@ package com.afternote.core.domain.error -/** 사용자가 입력을 수정해 해결할 수 있는 수신자 등록·수정 요청 오류. */ -class ReceiverRequestRejectedException( - val userMessage: String, +/** + * 사용자가 입력을 수정해 해결할 수 있는 수신자 등록·수정 요청 오류. + * + * 수신자 관리의 입력 거절은 인증·계정 실패인 [CoreAuthFailure]와 구분한다. 현재 소비처가 + * 가르는 사유는 하나이므로 별도 sealed 계층을 추가하지 않고 이 타입으로 표현한다. + * + * 표시 문구는 소비처가 타입을 보고 로컬 리소스로 고른다(BE#92). [message]는 서버 원문이나 + * [cause]의 문자열을 복사하지 않는 고정 진단 문구다. 원본 인프라 예외는 [cause]로 보존하며, + * 서버 코드·원문을 별도 도메인 프로퍼티로 옮기지 않는다. + * + * @param cause 이 실패를 만든 인프라 예외. + */ +public class ReceiverRequestRejectedException( cause: Throwable, -) : Exception(userMessage, cause) +) : Exception("receiver request rejected", cause) diff --git a/core/domain/src/main/java/com/afternote/core/domain/model/ReceiverListState.kt b/core/domain/src/main/java/com/afternote/core/domain/model/ReceiverListState.kt new file mode 100644 index 0000000000..bc308aa505 --- /dev/null +++ b/core/domain/src/main/java/com/afternote/core/domain/model/ReceiverListState.kt @@ -0,0 +1,37 @@ +package com.afternote.core.domain.model + +import com.afternote.core.model.user.Receiver + +/** + * 수신자 목록 조회의 결과 (#2045). + * + * `UserReceiverRepository.receiverListFlow` 는 실패를 마지막 목록이나 빈 목록으로 낮추므로 조회 중, 실패, + * 실제 0건이 같은 모양으로 들어온다. 이 타입은 그 셋을 가른다. + * + * 이전 목록([Loading.previousReceivers], [Failure.previousReceivers])은 같은 로그인 세션에서 이 구독이 + * 마지막으로 성공한 목록이다. 이 구독에서 아직 성공한 조회가 없거나 401 로 버렸으면 null 이다. + */ +sealed interface ReceiverListState { + data class Loading( + val previousReceivers: List?, + /** 로그인 구간의 불투명 식별자. 구독을 중단한 화면도 다른 계정의 행을 유지하지 않는다. */ + val sessionId: String, + ) : ReceiverListState + + data class Success( + val receivers: List, + ) : ReceiverListState + + /** 원래 오류는 데이터 계층이 ErrorReporter 로 남긴다. 구독 취소로 끊긴 조회는 이 값을 내지 않는다. */ + data class Failure( + val previousReceivers: List?, + /** 인증 실패로 폐기한 목록은 화면이 가지고 있던 행으로도 복원하지 않는다. */ + val discardPrevious: Boolean, + ) : ReceiverListState + + /** + * 로그인 세션이 없다. 구독 중에 세션이 바뀌어도 새 세션 조회 전에 이 값을 먼저 받는다. 중간 로그아웃 + * 방출이 저장소에서 합쳐져 사라진 경우라서다 (#2135). 받은 쪽은 이전 목록을 더 보여 주지 않는다. + */ + data object SignedOut : ReceiverListState +} diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/MyProfileRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/MyProfileRepository.kt index 39b95b7620..e5dd7781de 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/MyProfileRepository.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/MyProfileRepository.kt @@ -12,7 +12,18 @@ interface MyProfileRepository { // 내 프로필 조회 suspend fun getMyProfile(): User - // 프로필 수정 + /** + * 프로필 부분 수정 (`PATCH users/me`). + * + * 널은 «이 필드는 건드리지 않는다» 는 뜻이다. 서버 `User.updateProfile`(BE origin/main, 2026-09-04 실측)이 + * 널 필드를 건너뛰고 기존 값을 유지하므로, 바꿀 필드만 채우고 나머지는 널로 둔다. 공용 fake + * `FakeMyProfileRepository` 도 같은 규칙으로 흉내 낸다. + * + * 같은 이유로 이 계약으로는 값을 **지울 수 없다** — [phone]·[profileImageUrl] 은 공백도 널처럼 무시되고, + * [name] 은 공백이면 서버가 400(INVALID_INPUT_VALUE) 으로 거부한다. 비우기가 필요해지면 서버 계약 변경이 먼저다. + * + * @return 수정 뒤의 서버 정본 프로필 + */ suspend fun updateMyProfile( name: String?, phone: String?, diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/UserProfileRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/UserProfileRepository.kt deleted file mode 100644 index 2f3497d412..0000000000 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/UserProfileRepository.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.afternote.core.domain.repository - -/** - * [UserProfileCacheRepository] 의 옛 이름 (#1433). - * - * 이름이 서버 프로필 계약처럼 읽혀 [MyProfileRepository] 와 구분이 서지 않았다. 개명은 끝났고 - * 이 별칭은 `feature:home` 소비자 이관까지만 남는 과도기다 — 담당 모듈이 갈려 한 PR 로 못 옮긴다. - */ -@Deprecated( - message = "실체가 로컬 캐시임을 드러내는 UserProfileCacheRepository 로 옮겨 주세요.", - replaceWith = ReplaceWith("UserProfileCacheRepository"), -) -typealias UserProfileRepository = UserProfileCacheRepository diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt index 46f4568f2a..82b2f26872 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt @@ -1,5 +1,6 @@ package com.afternote.core.domain.repository +import com.afternote.core.domain.model.ReceiverListState import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.ReceiverDeliveryConditions import com.afternote.core.model.user.Receiver @@ -20,10 +21,28 @@ interface UserReceiverRepository { * * **빈 목록은 «수신인이 없음» 을 뜻하지 않는다 — 실패도 같은 모양으로 들어온다.** 화면이 이 값만 보고 * «등록된 수신인이 없어요» 를 확정하면 오프라인에서 그 거짓을 사용자에게 보여 주게 된다. 실패와 없음을 - * 가르려면 별도 신호가 필요하고, 그 표기는 #714 범위다. + * 가르려면 [receiverListStateFlow] 를 쓴다. + * + * 로그인한 구독의 첫 방출은 조회 결과다. 조회 중에는 아무것도 내지 않으므로 `first()` 로 받는 호출처가 + * 요청 전에 끊기지 않는다. */ val receiverListFlow: Flow> + /** + * [receiverListFlow] 와 같은 조회를 로딩·성공·실패로 갈라 낸다 (#2045). 두 Flow 는 한 원천에서 나오므로 + * 세션 격리, 늦은 결과 폐기, 갱신 revision 이 같다. + * + * 구독은 [ReceiverListState.Loading] 으로 시작하고, 같은 구독이 다시 조회할 때마다 Loading 을 먼저 낸 뒤 + * 결과를 낸다. 세션이 바뀌면 새 세션의 Loading 앞에 [ReceiverListState.SignedOut] 을 낸다. + */ + val receiverListStateFlow: Flow + + /** + * 구독 중인 수신자 목록을 모두 다시 조회하게 한다 (#2045). 결과는 두 목록 Flow 로 온다. 새 구독은 이 호출 + * 없이도 조회한다. + */ + fun refreshReceiverList() + // 수신자 목록 조회 suspend fun getReceivers(): List @@ -32,7 +51,7 @@ interface UserReceiverRepository { name: String, relation: String, phone: String?, - email: String?, + email: String, message: String?, ): ReceiverCreated diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/UserRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/UserRepository.kt index a7031b7805..b1d0c0d459 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/UserRepository.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/UserRepository.kt @@ -1,47 +1,19 @@ package com.afternote.core.domain.repository -import com.afternote.core.model.user.UserConnectedAccount -import com.afternote.core.model.user.UserPushSetting - /** * 사용자 도메인 통합 계약 — 전환기 전용 (#1282). * - * 수신자·프로필 멤버는 책임별 좁은 계약 2종으로 이동했고, 이 인터페이스는 그 합집합에 아직 - * 이동하지 않은 계정·푸시 설정 멤버를 더한 것이다. 기존 소비자가 좁은 계약으로 이관되는 동안만 - * 남으며, 전 소비자 이관 후 제거한다. 새 코드는 이 인터페이스가 아니라 필요한 좁은 계약만 주입받는다. + * 프로필·수신자 멤버는 책임별 좁은 계약 2종으로 이동했고, 이 인터페이스는 그 합집합만 남는다. + * 기존 소비자가 좁은 계약으로 이관되는 동안만 남으며, 전 소비자 이관 후 제거한다. 새 코드는 이 + * 인터페이스가 아니라 필요한 좁은 계약만 주입받는다. * * - [UserReceiverRepository] — 사용자 계정에 등록된 수신자 목록·CRUD·메시지·전달조건 * - [MyProfileRepository] — 서버 정본 프로필 조회·수정 * - * 아래 계정·푸시 설정 6멤버는 **core 에 좁은 계약을 신설하지 않는다.** main 소스 소비자가 - * `feature:setting` 뿐이라 core 에 계약을 세웠다가 다시 내리면 같은 멤버를 두 번 옮기게 된다. - * `feature:setting` 으로 곧장 내리는 몫은 #1429 다. + * 계정·푸시 설정 8멤버는 소비자가 `feature:setting` 뿐이라 core 에 좁은 계약을 세우지 않고 + * `feature:setting:domain` 의 `SettingAccountRepository`·`SettingNotificationRepository` 로 + * 곧장 내렸다 (#1429). */ interface UserRepository : UserReceiverRepository, - MyProfileRepository { - // 회원 탈퇴 - suspend fun deleteAccount() - - // 푸시 알림 설정 조회 - suspend fun getMyPushSettings(): UserPushSetting - - // 푸시 알림 설정 수정 - suspend fun updateMyPushSettings( - timeLetter: Boolean?, - mindRecord: Boolean?, - afterNote: Boolean?, - ): UserPushSetting - - // 연결된 계정 조회 - suspend fun getConnectedAccounts(): UserConnectedAccount - - // 소셜 계정 연결 - suspend fun linkConnectedAccount( - provider: String, - accessToken: String, - ): UserConnectedAccount - - // 소셜 계정 연결 해제 - suspend fun unlinkConnectedAccount(provider: String): UserConnectedAccount -} + MyProfileRepository diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/account/AccountRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/account/AccountRepository.kt index 8f9ba6866e..c25d81bec1 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/account/AccountRepository.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/account/AccountRepository.kt @@ -30,6 +30,35 @@ interface AccountRepository { profileUrl: String?, ): Result + /** + * 비밀번호 찾기 — 인증번호 검증과 새 비밀번호 반영을 한 요청으로 끝낸다 + * (서버 계약명은 `auth/password/find`). + * + * [findAccount] 와 달리 인증번호를 미리 소비하지 않는다. 서버는 인증번호를 **검증하면서 + * 삭제**하므로(BE `EmailService.verifyAndDeleteCode`), 화면에서 먼저 확인해 버리면 여기서 + * 쓸 코드가 남지 않는다 — 그래서 이 흐름의 인증 화면에는 인라인 "확인" 이 없고 + * [certificateCode] 를 비밀번호 변경 화면까지 들고 와 여기서 한 번에 낸다. + * + * [confirmPassword] 는 클라가 이미 일치를 강제하지만 서버도 독립적으로 검사한다 + * (BE `AuthService.findPassword` — 불일치는 code 1218). 계약 필드라 그대로 싣는다. + */ + suspend fun resetPassword( + email: String, + certificateCode: String, + newPassword: String, + confirmPassword: String, + ): Result + + /** + * 로그인한 사용자의 비밀번호 변경(서버 계약명은 `auth/password/change`). + * + * [resetPassword] 와 달리 인증번호를 쓰지 않고 현재 비밀번호로 본인을 확인한다. 확인이 끝난 + * 계정이라 `confirmPassword` 계약 필드도 없다(BE `PasswordChangeRequest` 는 두 필드뿐이다). + * + * 사유가 확인되는 실패는 [com.afternote.core.domain.error.CoreAuthFailure] 로 번역된다 — + * 새 비밀번호가 기존과 같음(1206)은 `PasswordUnchanged`, 비밀번호가 없는 소셜 가입 계정(1702)은 + * `SocialSignUpAccount`. **현재 비밀번호 불일치(1202)는 아직 번역되지 않아** 원본 예외로 온다. + */ suspend fun passwordChange( currentPassword: String, newPassword: String, diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/appversion/AppVersionRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/appversion/AppVersionRepository.kt new file mode 100644 index 0000000000..bc8e00b2a0 --- /dev/null +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/appversion/AppVersionRepository.kt @@ -0,0 +1,17 @@ +package com.afternote.core.domain.repository.appversion + +import com.afternote.core.model.appversion.AppVersionCheck + +/** + * 이 빌드가 아직 서버가 허용하는 버전인지 묻는 계약 (#1539). + * + * 인증이 필요 없다 — 서버가 이 엔드포인트를 화이트리스트에 두고 있어(BE `WhiteListUrl`) + * 로그인 전에도 부를 수 있다. + * + * 실패는 예외로 던지지 않고 [Result] 로 돌려준다. 부르는 쪽(관문)이 «못 물어봤다» 를 + * «업데이트 불필요» 와 같게 다뤄야 하기 때문이다 — 서버가 죽어도 앱은 열려야 한다. + */ +interface AppVersionRepository { + /** 이 Android 설치본의 [versionCode] 로 서버 판정을 받아 온다. */ + suspend fun checkAndroidVersion(versionCode: Int): Result +} diff --git a/core/domain/src/test/java/com/afternote/core/domain/testing/FakeCoreRepositoriesTest.kt b/core/domain/src/test/java/com/afternote/core/domain/testing/FakeCoreRepositoriesTest.kt index 24b25191a0..e2da536f56 100644 --- a/core/domain/src/test/java/com/afternote/core/domain/testing/FakeCoreRepositoriesTest.kt +++ b/core/domain/src/test/java/com/afternote/core/domain/testing/FakeCoreRepositoriesTest.kt @@ -7,6 +7,7 @@ import com.afternote.core.model.delivery.DeliveryConditionType import com.afternote.core.model.delivery.DeliveryContentType import com.afternote.core.model.delivery.InactivityPeriod import com.afternote.core.model.user.Receiver +import com.afternote.core.model.user.User import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -102,7 +103,7 @@ class FakeCoreRepositoriesTest { @Test fun `기본 receiver detail은 합성되고 수정 뒤 재조회에도 모든 필드가 남는다`() { - val receiver = Receiver(7L, "수신자", "가족", "auth-7") + val receiver = Receiver(7L, "수신자", "가족") val initialReceivers = mutableListOf(receiver) val synthesizedRepository = FakeUserRepository(receivers = initialReceivers) initialReceivers.clear() @@ -112,7 +113,6 @@ class FakeCoreRepositoriesTest { assertEquals(receiver.receiverId, synthesized.receiverId) assertEquals(receiver.name, synthesized.name) assertEquals(receiver.relation, synthesized.relation) - assertEquals(receiver.authCode, synthesized.authCode) assertNull(synthesized.phone) assertNull(synthesized.email) assertNull(synthesized.message) @@ -130,7 +130,6 @@ class FakeCoreRepositoriesTest { assertEquals("01012345678", updated.phone) assertEquals("new@test.local", updated.email) assertEquals("남길 메시지", updated.message) - assertEquals("auth-7", updated.authCode) } @Test @@ -157,4 +156,41 @@ class FakeCoreRepositoriesTest { assertEquals(listOf(condition), reloaded.conditions) assertEquals(listOf(condition), repository.deliveryConditions.getValue(7L).conditions) } + + /** + * 합본 fake 가 책임별 fake 에 위임해도(#1282) 생성 이후에 갈아끼운 `onX` 와 상태 쓰기가 + * 그대로 먹혀야 한다 — 소비자 다수가 `strict()` 로 만든 뒤 필요한 멤버만 열어 쓴다. + */ + @Test + fun `합본 fake 의 생성 후 onX 교체와 상태 쓰기는 위임 대상에 그대로 닿는다`() { + val repository = FakeUserRepository.strict() + + repository.onGetReceivers = null + repository.onGetMyProfile = null + repository.receiverState.value = listOf(Receiver(9L, "나중 수신자", "가족")) + repository.profile = User("나중 사용자", "later@afternote.local", null, null) + + assertEquals(listOf(Receiver(9L, "나중 수신자", "가족")), runBlocking { repository.getReceivers() }) + assertEquals("나중 사용자", runBlocking { repository.getMyProfile() }.name) + assertEquals(1, repository.getReceiversCalls) + assertEquals(1, repository.getProfileCalls) + } + + /** 좁은 fake 는 합본을 거치지 않고 곧장 쓸 수 있어야 한다 (#1282). */ + @Test + fun `좁은 fake 는 자기 책임 상태만 갖고 단독으로 동작한다`() { + val receiverRepository = FakeUserReceiverRepository(receivers = listOf(Receiver(3L, "수신자", "가족"))) + val profileRepository = FakeMyProfileRepository() + + val receivers = runBlocking { receiverRepository.getReceivers() } + val updated = runBlocking { profileRepository.updateMyProfile("바뀐 이름", null, null) } + + assertEquals(listOf(Receiver(3L, "수신자", "가족")), receivers) + assertEquals(1, receiverRepository.getReceiversCalls) + assertEquals("바뀐 이름", updated.name) + assertEquals( + listOf(FakeMyProfileRepository.ProfileUpdateCall("바뀐 이름", null, null)), + profileRepository.profileUpdateCalls.toList(), + ) + } } diff --git a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeMyProfileRepository.kt b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeMyProfileRepository.kt new file mode 100644 index 0000000000..0072ce3b11 --- /dev/null +++ b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeMyProfileRepository.kt @@ -0,0 +1,66 @@ +package com.afternote.core.domain.testing + +import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.model.user.User +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger + +/** + * [MyProfileRepository] fake 정본 (#1282, #1030). + * + * 서버 정본 프로필만 메모리에 담는다. 로컬 캐시(사용자 이름·패스키 등록 여부) fake 는 + * [FakeUserProfileCacheRepository] 로 책임이 다르다. + * + * 호출 기록 타입 [ProfileUpdateCall] 은 이 fake 가 소유한다 (#1429) — 이유는 + * [FakeUserReceiverRepository] 와 같다. + */ +class FakeMyProfileRepository( + @Volatile var profile: User = DEFAULT_USER, + var onGetMyProfile: (suspend () -> User)? = null, + var onUpdateMyProfile: (suspend (String?, String?, String?) -> User)? = null, +) : MyProfileRepository { + private val getProfileCounter = AtomicInteger() + + val profileUpdateCalls = CopyOnWriteArrayList() + + val getProfileCalls: Int get() = getProfileCounter.get() + val profileCalls: Int get() = getProfileCounter.get() + + override suspend fun getMyProfile(): User { + getProfileCounter.incrementAndGet() + onGetMyProfile?.let { return it() } + return profile + } + + override suspend fun updateMyProfile( + name: String?, + phone: String?, + profileImageUrl: String?, + ): User { + profileUpdateCalls += ProfileUpdateCall(name, phone, profileImageUrl) + onUpdateMyProfile?.let { return it(name, phone, profileImageUrl) } + profile = + profile.copy( + name = name ?: profile.name, + phone = phone ?: profile.phone, + profileImageUrl = profileImageUrl ?: profile.profileImageUrl, + ) + return profile + } + + data class ProfileUpdateCall( + val name: String?, + val phone: String?, + val profileImageUrl: String?, + ) + + companion object { + internal val DEFAULT_USER = User("테스트 사용자", "test@afternote.local", null, null) + + fun strict(): FakeMyProfileRepository = + FakeMyProfileRepository( + onGetMyProfile = { unexpectedCall("MyProfileRepository.getMyProfile") }, + onUpdateMyProfile = { _, _, _ -> unexpectedCall("MyProfileRepository.updateMyProfile") }, + ) + } +} diff --git a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserProfileRepository.kt b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserProfileRepository.kt deleted file mode 100644 index a7989fc96e..0000000000 --- a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserProfileRepository.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.afternote.core.domain.testing - -/** - * [FakeUserProfileCacheRepository] 의 옛 이름 (#1433). - * - * 계약 개명에 맞춘 별칭이며 `feature:home` 테스트 이관까지만 남는다. - */ -@Deprecated( - message = "FakeUserProfileCacheRepository 로 옮겨 주세요.", - replaceWith = ReplaceWith("FakeUserProfileCacheRepository"), -) -typealias FakeUserProfileRepository = FakeUserProfileCacheRepository diff --git a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt new file mode 100644 index 0000000000..7623295d53 --- /dev/null +++ b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt @@ -0,0 +1,228 @@ +package com.afternote.core.domain.testing + +import com.afternote.core.domain.model.ReceiverListState +import com.afternote.core.domain.repository.UserReceiverRepository +import com.afternote.core.model.delivery.DeliveryConditionItem +import com.afternote.core.model.delivery.ReceiverDeliveryConditions +import com.afternote.core.model.user.Receiver +import com.afternote.core.model.user.ReceiverCreated +import com.afternote.core.model.user.ReceiverDetail +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger + +/** + * [UserReceiverRepository] fake 정본 (#1282, #1030). + * + * 수신자 목록·상세·전달조건만 메모리에 담는다 — 프로필·계정·푸시 상태는 갖지 않는다. + * 호출은 모두 기록하고, 경합 게이트나 실패 응답처럼 저장소 상태만으로 표현할 수 없는 시나리오는 `onX` 로 갈아끼운다. + * + * 호출 기록 타입([ReceiverCreateCall] 등)은 이 fake 가 소유한다 (#1429) — 전환기 합본 + * [FakeUserRepository] 가 좁은 사실을 들고 있으면 소비자가 이관된 뒤에도 합본을 계속 import 한다. + */ +class FakeUserReceiverRepository( + receivers: List = listOf(DEFAULT_RECEIVER), + receiverDetails: Map = emptyMap(), + deliveryConditions: Map = emptyMap(), + var onReceiverListFlow: (() -> Flow>)? = null, + var onGetReceivers: (suspend () -> List)? = null, + var onCreateReceiver: (suspend (String, String, String?, String, String?) -> ReceiverCreated)? = null, + var onGetReceiverDetail: (suspend (Long) -> ReceiverDetail)? = null, + var onUpdateReceiver: (suspend (Long, String, String, String, String) -> Receiver)? = null, + var onUpdateReceiverMessage: (suspend (Long, String) -> Unit)? = null, + var onGetReceiverDeliveryConditions: (suspend (Long) -> ReceiverDeliveryConditions)? = null, + var onUpdateReceiverDeliveryConditions: (suspend (Long, List) -> ReceiverDeliveryConditions)? = null, + var onReceiverListStateFlow: (() -> Flow)? = null, + var onRefreshReceiverList: (() -> Unit)? = null, +) : UserReceiverRepository { + val receiverState = MutableStateFlow(receivers.toList()) + val receiverDetails = ConcurrentHashMap(receiverDetails) + val deliveryConditions = + ConcurrentHashMap( + deliveryConditions.mapValues { (_, value) -> + value.copy(conditions = value.conditions.toList()) + }, + ) + + private val receiverListFlowCounter = AtomicInteger() + private val getReceiversCounter = AtomicInteger() + private val refreshReceiverListCounter = AtomicInteger() + + val receiverCreateCalls = CopyOnWriteArrayList() + val receiverDetailCalls = CopyOnWriteArrayList() + val receiverUpdateCalls = CopyOnWriteArrayList() + val receiverMessageCalls = CopyOnWriteArrayList() + val deliveryLoadCalls = CopyOnWriteArrayList() + val deliveryUpdateCalls = CopyOnWriteArrayList() + + val receiverListFlowCalls: Int get() = receiverListFlowCounter.get() + val getReceiversCalls: Int get() = getReceiversCounter.get() + val receiverCalls: Int get() = getReceiversCounter.get() + val refreshReceiverListCalls: Int get() = refreshReceiverListCounter.get() + + override val receiverListFlow: Flow> + get() { + receiverListFlowCounter.incrementAndGet() + return onReceiverListFlow?.invoke() ?: receiverState + } + + /** + * 기본값은 [receiverListFlow] 를 성공 결과로 감싼 것이다. 목록만 갈아끼운 기존 시나리오가 상태 소비자에도 + * 그대로 먹히고, strict 도 목록 Flow 쪽 거부를 따른다. 로딩·실패는 `onReceiverListStateFlow` 로 연다. + */ + override val receiverListStateFlow: Flow + get() = onReceiverListStateFlow?.invoke() ?: receiverListFlow.map(ReceiverListState::Success) + + override fun refreshReceiverList() { + refreshReceiverListCounter.incrementAndGet() + onRefreshReceiverList?.invoke() + } + + override suspend fun getReceivers(): List { + getReceiversCounter.incrementAndGet() + onGetReceivers?.let { return it() } + return receiverState.value + } + + override suspend fun createReceiver( + name: String, + relation: String, + phone: String?, + email: String, + message: String?, + ): ReceiverCreated { + receiverCreateCalls += ReceiverCreateCall(name, relation, phone, email, message) + onCreateReceiver?.let { return it(name, relation, phone, email, message) } + val id = (receiverState.value.maxOfOrNull(Receiver::receiverId) ?: 0L) + 1L + val authCode = "fake-auth-$id" + receiverState.value = receiverState.value + Receiver(id, name, relation) + receiverDetails[id] = + ReceiverDetail(id, name, relation, phone, email, 0, 0, 0, message) + return ReceiverCreated(id, authCode) + } + + override suspend fun getReceiverDetail(receiverId: Long): ReceiverDetail { + receiverDetailCalls += receiverId + onGetReceiverDetail?.let { return it(receiverId) } + return receiverDetails.computeIfAbsent(receiverId) { + requireNotNull(receiverState.value.firstOrNull { it.receiverId == receiverId }).toDefaultDetail() + } + } + + override suspend fun updateReceiver( + receiverId: Long, + name: String, + phone: String, + relation: String, + email: String, + ): Receiver { + receiverUpdateCalls += ReceiverUpdateCall(receiverId, name, phone, relation, email) + onUpdateReceiver?.let { return it(receiverId, name, phone, relation, email) } + val current = requireNotNull(receiverState.value.firstOrNull { it.receiverId == receiverId }) + val updated = current.copy(name = name, relation = relation) + receiverState.value = receiverState.value.map { if (it.receiverId == receiverId) updated else it } + receiverDetails.compute(receiverId) { _, detail -> + (detail ?: current.toDefaultDetail()).copy(name = name, phone = phone, relation = relation, email = email) + } + return updated + } + + override suspend fun updateReceiverMessage( + receiverId: Long, + message: String, + ) { + receiverMessageCalls += ReceiverMessageCall(receiverId, message) + onUpdateReceiverMessage?.let { + it(receiverId, message) + return + } + val currentDetail = + receiverDetails[receiverId] + ?: requireNotNull(receiverState.value.firstOrNull { it.receiverId == receiverId }).toDefaultDetail() + receiverDetails.compute(receiverId) { _, detail -> + (detail ?: currentDetail).copy(message = message) + } + } + + override suspend fun getReceiverDeliveryConditions(receiverId: Long): ReceiverDeliveryConditions { + deliveryLoadCalls += receiverId + onGetReceiverDeliveryConditions?.let { return it(receiverId) } + val stored = deliveryConditions[receiverId] ?: ReceiverDeliveryConditions(receiverId, emptyList()) + return stored.copy(conditions = stored.conditions.toList()) + } + + override suspend fun updateReceiverDeliveryConditions( + receiverId: Long, + conditions: List, + ): ReceiverDeliveryConditions { + deliveryUpdateCalls += DeliveryUpdateCall(receiverId, conditions.toList()) + onUpdateReceiverDeliveryConditions?.let { return it(receiverId, conditions) } + val stored = ReceiverDeliveryConditions(receiverId, conditions.toList()) + deliveryConditions[receiverId] = stored + return stored.copy(conditions = stored.conditions.toList()) + } + + data class ReceiverCreateCall( + val name: String, + val relation: String, + val phone: String?, + val email: String, + val message: String?, + ) + + data class ReceiverUpdateCall( + val receiverId: Long, + val name: String, + val phone: String, + val relation: String, + val email: String, + ) + + data class ReceiverMessageCall( + val receiverId: Long, + val message: String, + ) + + data class DeliveryUpdateCall( + val receiverId: Long, + val conditions: List, + ) + + companion object { + internal val DEFAULT_RECEIVER = Receiver(7L, "김수신", "가족") + + fun strict(): FakeUserReceiverRepository = + FakeUserReceiverRepository( + receivers = emptyList(), + onReceiverListFlow = { unexpectedCall("UserReceiverRepository.receiverListFlow") }, + onGetReceivers = { unexpectedCall("UserReceiverRepository.getReceivers") }, + onCreateReceiver = { _, _, _, _, _ -> unexpectedCall("UserReceiverRepository.createReceiver") }, + onGetReceiverDetail = { unexpectedCall("UserReceiverRepository.getReceiverDetail") }, + onUpdateReceiver = { _, _, _, _, _ -> unexpectedCall("UserReceiverRepository.updateReceiver") }, + onUpdateReceiverMessage = { _, _ -> unexpectedCall("UserReceiverRepository.updateReceiverMessage") }, + onGetReceiverDeliveryConditions = { + unexpectedCall("UserReceiverRepository.getReceiverDeliveryConditions") + }, + onUpdateReceiverDeliveryConditions = { _, _ -> + unexpectedCall("UserReceiverRepository.updateReceiverDeliveryConditions") + }, + onRefreshReceiverList = { unexpectedCall("UserReceiverRepository.refreshReceiverList") }, + ) + } +} + +private fun Receiver.toDefaultDetail(): ReceiverDetail = + ReceiverDetail( + receiverId = receiverId, + name = name, + relation = relation, + phone = null, + email = null, + dailyQuestionCount = 0, + timeLetterCount = 0, + afterNoteCount = 0, + message = null, + ) diff --git a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserRepository.kt b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserRepository.kt index dc67ae5c7d..791ec27b75 100644 --- a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserRepository.kt +++ b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserRepository.kt @@ -1,5 +1,7 @@ package com.afternote.core.domain.testing +import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.domain.repository.UserRepository import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.ReceiverDeliveryConditions @@ -7,292 +9,159 @@ import com.afternote.core.model.user.Receiver import com.afternote.core.model.user.ReceiverCreated import com.afternote.core.model.user.ReceiverDetail import com.afternote.core.model.user.User -import com.afternote.core.model.user.UserConnectedAccount -import com.afternote.core.model.user.UserPushSetting import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.atomic.AtomicInteger /** - * [UserRepository] fake 정본 (#1030, #1041). + * [UserRepository] fake 정본 (#1030, #1041) — 전환기 합본 (#1282). * - * 기본은 사용자·수신자·설정을 메모리에 저장한다. 호출은 모두 기록하고, 경합 게이트나 - * 실패 응답처럼 저장소 상태만으로 표현할 수 없는 시나리오는 `onX` 로 갈아끼운다. + * 수신자·프로필 상태와 호출 기록은 책임별 fake 인 [FakeUserReceiverRepository]·[FakeMyProfileRepository] + * 가 소유하고 여기서는 위임한다. 계정·푸시 8멤버는 `feature:setting:domain` 의 fake 2종으로 내려가 + * 이 합본에는 남지 않는다 (#1429). + * + * 생성자·프로퍼티·호출 기록 타입·strict 문구는 남은 범위 안에서 그대로다. 새 테스트는 이 합본이 아니라 + * 필요한 좁은 fake 만 쓰고, 기존 소비자가 이관되면 이 클래스는 사라진다. */ -class FakeUserRepository( - @Volatile var profile: User = DEFAULT_USER, - receivers: List = listOf(DEFAULT_RECEIVER), - @Volatile var pushSetting: UserPushSetting = DEFAULT_PUSH_SETTING, - @Volatile var connectedAccounts: UserConnectedAccount = defaultConnectedAccounts(profile.email), - receiverDetails: Map = emptyMap(), - deliveryConditions: Map = emptyMap(), - var onReceiverListFlow: (() -> Flow>)? = null, - var onGetReceivers: (suspend () -> List)? = null, - var onCreateReceiver: (suspend (String, String, String?, String?, String?) -> ReceiverCreated)? = null, - var onGetReceiverDetail: (suspend (Long) -> ReceiverDetail)? = null, - var onUpdateReceiver: (suspend (Long, String, String, String, String) -> Receiver)? = null, - var onUpdateReceiverMessage: (suspend (Long, String) -> Unit)? = null, - var onGetMyProfile: (suspend () -> User)? = null, - var onUpdateMyProfile: (suspend (String?, String?, String?) -> User)? = null, - var onDeleteAccount: (suspend () -> Unit)? = null, - var onGetMyPushSettings: (suspend () -> UserPushSetting)? = null, - var onUpdateMyPushSettings: (suspend (Boolean?, Boolean?, Boolean?) -> UserPushSetting)? = null, - var onGetConnectedAccounts: (suspend () -> UserConnectedAccount)? = null, - var onLinkConnectedAccount: (suspend (String, String) -> UserConnectedAccount)? = null, - var onUnlinkConnectedAccount: (suspend (String) -> UserConnectedAccount)? = null, - var onGetReceiverDeliveryConditions: (suspend (Long) -> ReceiverDeliveryConditions)? = null, - var onUpdateReceiverDeliveryConditions: (suspend (Long, List) -> ReceiverDeliveryConditions)? = null, -) : UserRepository { - val receiverState = MutableStateFlow(receivers.toList()) - val receiverDetails = ConcurrentHashMap(receiverDetails) - val deliveryConditions = - ConcurrentHashMap( - deliveryConditions.mapValues { (_, value) -> - value.copy(conditions = value.conditions.toList()) - }, - ) - - private val receiverListFlowCounter = AtomicInteger() - private val getReceiversCounter = AtomicInteger() - private val getProfileCounter = AtomicInteger() - private val deleteAccountCounter = AtomicInteger() - private val getPushSettingsCounter = AtomicInteger() - private val getConnectedAccountsCounter = AtomicInteger() - - val receiverCreateCalls = CopyOnWriteArrayList() - val receiverDetailCalls = CopyOnWriteArrayList() - val receiverUpdateCalls = CopyOnWriteArrayList() - val receiverMessageCalls = CopyOnWriteArrayList() - val profileUpdateCalls = CopyOnWriteArrayList() - val pushUpdateCalls = CopyOnWriteArrayList() - val connectedLinkCalls = CopyOnWriteArrayList() - val connectedUnlinkCalls = CopyOnWriteArrayList() - val deliveryLoadCalls = CopyOnWriteArrayList() - val deliveryUpdateCalls = CopyOnWriteArrayList() - - val receiverListFlowCalls: Int get() = receiverListFlowCounter.get() - val getReceiversCalls: Int get() = getReceiversCounter.get() - val receiverCalls: Int get() = getReceiversCounter.get() - val getProfileCalls: Int get() = getProfileCounter.get() - val profileCalls: Int get() = getProfileCounter.get() - val deleteAccountCalls: Int get() = deleteAccountCounter.get() - val getMyPushSettingsCalls: Int get() = getPushSettingsCounter.get() - val getConnectedAccountsCalls: Int get() = getConnectedAccountsCounter.get() - val pushSettingUpdates: List> - get() = pushUpdateCalls.map { Triple(it.timeLetter, it.mindRecord, it.afterNote) } +class FakeUserRepository private constructor( + private val receiverFake: FakeUserReceiverRepository, + private val myProfileFake: FakeMyProfileRepository, +) : UserRepository, + UserReceiverRepository by receiverFake, + MyProfileRepository by myProfileFake { + @Suppress("LongParameterList") + constructor( + profile: User = FakeMyProfileRepository.DEFAULT_USER, + receivers: List = listOf(FakeUserReceiverRepository.DEFAULT_RECEIVER), + receiverDetails: Map = emptyMap(), + deliveryConditions: Map = emptyMap(), + onReceiverListFlow: (() -> Flow>)? = null, + onGetReceivers: (suspend () -> List)? = null, + onCreateReceiver: (suspend (String, String, String?, String, String?) -> ReceiverCreated)? = null, + onGetReceiverDetail: (suspend (Long) -> ReceiverDetail)? = null, + onUpdateReceiver: (suspend (Long, String, String, String, String) -> Receiver)? = null, + onUpdateReceiverMessage: (suspend (Long, String) -> Unit)? = null, + onGetMyProfile: (suspend () -> User)? = null, + onUpdateMyProfile: (suspend (String?, String?, String?) -> User)? = null, + onGetReceiverDeliveryConditions: (suspend (Long) -> ReceiverDeliveryConditions)? = null, + onUpdateReceiverDeliveryConditions: ( + suspend (Long, List) -> ReceiverDeliveryConditions + )? = null, + ) : this( + receiverFake = + FakeUserReceiverRepository( + receivers = receivers, + receiverDetails = receiverDetails, + deliveryConditions = deliveryConditions, + onReceiverListFlow = onReceiverListFlow, + onGetReceivers = onGetReceivers, + onCreateReceiver = onCreateReceiver, + onGetReceiverDetail = onGetReceiverDetail, + onUpdateReceiver = onUpdateReceiver, + onUpdateReceiverMessage = onUpdateReceiverMessage, + onGetReceiverDeliveryConditions = onGetReceiverDeliveryConditions, + onUpdateReceiverDeliveryConditions = onUpdateReceiverDeliveryConditions, + ), + myProfileFake = + FakeMyProfileRepository( + profile = profile, + onGetMyProfile = onGetMyProfile, + onUpdateMyProfile = onUpdateMyProfile, + ), + ) - override val receiverListFlow: Flow> - get() { - receiverListFlowCounter.incrementAndGet() - return onReceiverListFlow?.invoke() ?: receiverState + // 수신자 fake 위임 — 기존 소비자가 쓰던 이름·타입을 그대로 유지한다. + val receiverState: MutableStateFlow> get() = receiverFake.receiverState + val receiverDetails: ConcurrentHashMap get() = receiverFake.receiverDetails + val deliveryConditions: ConcurrentHashMap get() = receiverFake.deliveryConditions + val receiverCreateCalls: CopyOnWriteArrayList + get() = receiverFake.receiverCreateCalls + val receiverDetailCalls: CopyOnWriteArrayList get() = receiverFake.receiverDetailCalls + val receiverUpdateCalls: CopyOnWriteArrayList + get() = receiverFake.receiverUpdateCalls + val receiverMessageCalls: CopyOnWriteArrayList + get() = receiverFake.receiverMessageCalls + val deliveryLoadCalls: CopyOnWriteArrayList get() = receiverFake.deliveryLoadCalls + val deliveryUpdateCalls: CopyOnWriteArrayList + get() = receiverFake.deliveryUpdateCalls + val receiverListFlowCalls: Int get() = receiverFake.receiverListFlowCalls + val getReceiversCalls: Int get() = receiverFake.getReceiversCalls + val receiverCalls: Int get() = receiverFake.receiverCalls + + var onReceiverListFlow: (() -> Flow>)? + get() = receiverFake.onReceiverListFlow + set(value) { + receiverFake.onReceiverListFlow = value } - override suspend fun getReceivers(): List { - getReceiversCounter.incrementAndGet() - onGetReceivers?.let { return it() } - return receiverState.value - } - - override suspend fun createReceiver( - name: String, - relation: String, - phone: String?, - email: String?, - message: String?, - ): ReceiverCreated { - receiverCreateCalls += ReceiverCreateCall(name, relation, phone, email, message) - onCreateReceiver?.let { return it(name, relation, phone, email, message) } - val id = (receiverState.value.maxOfOrNull(Receiver::receiverId) ?: 0L) + 1L - val authCode = "fake-auth-$id" - receiverState.value = receiverState.value + Receiver(id, name, relation, authCode) - receiverDetails[id] = - ReceiverDetail(id, name, relation, phone, email, 0, 0, 0, message, authCode) - return ReceiverCreated(id, authCode) - } - - override suspend fun getReceiverDetail(receiverId: Long): ReceiverDetail { - receiverDetailCalls += receiverId - onGetReceiverDetail?.let { return it(receiverId) } - return receiverDetails.computeIfAbsent(receiverId) { - requireNotNull(receiverState.value.firstOrNull { it.receiverId == receiverId }).toDefaultDetail() + var onGetReceivers: (suspend () -> List)? + get() = receiverFake.onGetReceivers + set(value) { + receiverFake.onGetReceivers = value } - } - override suspend fun updateReceiver( - receiverId: Long, - name: String, - phone: String, - relation: String, - email: String, - ): Receiver { - receiverUpdateCalls += ReceiverUpdateCall(receiverId, name, phone, relation, email) - onUpdateReceiver?.let { return it(receiverId, name, phone, relation, email) } - val current = requireNotNull(receiverState.value.firstOrNull { it.receiverId == receiverId }) - val updated = current.copy(name = name, relation = relation) - receiverState.value = receiverState.value.map { if (it.receiverId == receiverId) updated else it } - receiverDetails.compute(receiverId) { _, detail -> - (detail ?: current.toDefaultDetail()).copy(name = name, phone = phone, relation = relation, email = email) + var onCreateReceiver: (suspend (String, String, String?, String, String?) -> ReceiverCreated)? + get() = receiverFake.onCreateReceiver + set(value) { + receiverFake.onCreateReceiver = value } - return updated - } - override suspend fun updateReceiverMessage( - receiverId: Long, - message: String, - ) { - receiverMessageCalls += ReceiverMessageCall(receiverId, message) - onUpdateReceiverMessage?.let { - it(receiverId, message) - return + var onGetReceiverDetail: (suspend (Long) -> ReceiverDetail)? + get() = receiverFake.onGetReceiverDetail + set(value) { + receiverFake.onGetReceiverDetail = value } - val currentDetail = - receiverDetails[receiverId] - ?: requireNotNull(receiverState.value.firstOrNull { it.receiverId == receiverId }).toDefaultDetail() - receiverDetails.compute(receiverId) { _, detail -> - (detail ?: currentDetail).copy(message = message) - } - } - - override suspend fun getMyProfile(): User { - getProfileCounter.incrementAndGet() - onGetMyProfile?.let { return it() } - return profile - } - - override suspend fun updateMyProfile( - name: String?, - phone: String?, - profileImageUrl: String?, - ): User { - profileUpdateCalls += ProfileUpdateCall(name, phone, profileImageUrl) - onUpdateMyProfile?.let { return it(name, phone, profileImageUrl) } - profile = - profile.copy( - name = name ?: profile.name, - phone = phone ?: profile.phone, - profileImageUrl = profileImageUrl ?: profile.profileImageUrl, - ) - return profile - } - - override suspend fun deleteAccount() { - deleteAccountCounter.incrementAndGet() - onDeleteAccount?.invoke() - } - - override suspend fun getMyPushSettings(): UserPushSetting { - getPushSettingsCounter.incrementAndGet() - onGetMyPushSettings?.let { return it() } - return pushSetting - } - - override suspend fun updateMyPushSettings( - timeLetter: Boolean?, - mindRecord: Boolean?, - afterNote: Boolean?, - ): UserPushSetting { - pushUpdateCalls += PushUpdateCall(timeLetter, mindRecord, afterNote) - onUpdateMyPushSettings?.let { return it(timeLetter, mindRecord, afterNote) } - pushSetting = - UserPushSetting( - timeLetter = timeLetter ?: pushSetting.timeLetter, - mindRecord = mindRecord ?: pushSetting.mindRecord, - afterNote = afterNote ?: pushSetting.afterNote, - ) - return pushSetting - } - - override suspend fun getConnectedAccounts(): UserConnectedAccount { - getConnectedAccountsCounter.incrementAndGet() - onGetConnectedAccounts?.let { return it() } - return connectedAccounts - } - override suspend fun linkConnectedAccount( - provider: String, - accessToken: String, - ): UserConnectedAccount { - connectedLinkCalls += ConnectedAccountLinkCall(provider, accessToken) - onLinkConnectedAccount?.let { return it(provider, accessToken) } - connectedAccounts = connectedAccounts.withProvider(provider, connected = true) - return connectedAccounts - } - - override suspend fun unlinkConnectedAccount(provider: String): UserConnectedAccount { - connectedUnlinkCalls += provider - onUnlinkConnectedAccount?.let { return it(provider) } - connectedAccounts = connectedAccounts.withProvider(provider, connected = false) - return connectedAccounts - } - - override suspend fun getReceiverDeliveryConditions(receiverId: Long): ReceiverDeliveryConditions { - deliveryLoadCalls += receiverId - onGetReceiverDeliveryConditions?.let { return it(receiverId) } - val stored = deliveryConditions[receiverId] ?: ReceiverDeliveryConditions(receiverId, emptyList()) - return stored.copy(conditions = stored.conditions.toList()) - } - - override suspend fun updateReceiverDeliveryConditions( - receiverId: Long, - conditions: List, - ): ReceiverDeliveryConditions { - deliveryUpdateCalls += DeliveryUpdateCall(receiverId, conditions.toList()) - onUpdateReceiverDeliveryConditions?.let { return it(receiverId, conditions) } - val stored = ReceiverDeliveryConditions(receiverId, conditions.toList()) - deliveryConditions[receiverId] = stored - return stored.copy(conditions = stored.conditions.toList()) - } + var onUpdateReceiver: (suspend (Long, String, String, String, String) -> Receiver)? + get() = receiverFake.onUpdateReceiver + set(value) { + receiverFake.onUpdateReceiver = value + } - data class ProfileUpdateCall( - val name: String?, - val phone: String?, - val profileImageUrl: String?, - ) + var onUpdateReceiverMessage: (suspend (Long, String) -> Unit)? + get() = receiverFake.onUpdateReceiverMessage + set(value) { + receiverFake.onUpdateReceiverMessage = value + } - data class PushUpdateCall( - val timeLetter: Boolean?, - val mindRecord: Boolean?, - val afterNote: Boolean?, - ) + var onGetReceiverDeliveryConditions: (suspend (Long) -> ReceiverDeliveryConditions)? + get() = receiverFake.onGetReceiverDeliveryConditions + set(value) { + receiverFake.onGetReceiverDeliveryConditions = value + } - data class ReceiverCreateCall( - val name: String, - val relation: String, - val phone: String?, - val email: String?, - val message: String?, - ) + var onUpdateReceiverDeliveryConditions: (suspend (Long, List) -> ReceiverDeliveryConditions)? + get() = receiverFake.onUpdateReceiverDeliveryConditions + set(value) { + receiverFake.onUpdateReceiverDeliveryConditions = value + } - data class ReceiverUpdateCall( - val receiverId: Long, - val name: String, - val phone: String, - val relation: String, - val email: String, - ) + // 서버 정본 프로필 fake 위임. + var profile: User + get() = myProfileFake.profile + set(value) { + myProfileFake.profile = value + } - data class ReceiverMessageCall( - val receiverId: Long, - val message: String, - ) + val profileUpdateCalls: CopyOnWriteArrayList + get() = myProfileFake.profileUpdateCalls + val getProfileCalls: Int get() = myProfileFake.getProfileCalls + val profileCalls: Int get() = myProfileFake.profileCalls - data class ConnectedAccountLinkCall( - val provider: String, - val accessToken: String, - ) + var onGetMyProfile: (suspend () -> User)? + get() = myProfileFake.onGetMyProfile + set(value) { + myProfileFake.onGetMyProfile = value + } - data class DeliveryUpdateCall( - val receiverId: Long, - val conditions: List, - ) + var onUpdateMyProfile: (suspend (String?, String?, String?) -> User)? + get() = myProfileFake.onUpdateMyProfile + set(value) { + myProfileFake.onUpdateMyProfile = value + } companion object { - private val DEFAULT_USER = User("테스트 사용자", "test@afternote.local", null, null) - private val DEFAULT_RECEIVER = Receiver(7L, "김수신", "가족", "fake-auth-7") - private val DEFAULT_PUSH_SETTING = UserPushSetting(true, true, true) - fun strict(): FakeUserRepository = FakeUserRepository( receivers = emptyList(), @@ -304,44 +173,8 @@ class FakeUserRepository( onUpdateReceiverMessage = { _, _ -> unexpectedCall("UserRepository.updateReceiverMessage") }, onGetMyProfile = { unexpectedCall("UserRepository.getMyProfile") }, onUpdateMyProfile = { _, _, _ -> unexpectedCall("UserRepository.updateMyProfile") }, - onDeleteAccount = { unexpectedCall("UserRepository.deleteAccount") }, - onGetMyPushSettings = { unexpectedCall("UserRepository.getMyPushSettings") }, - onUpdateMyPushSettings = { _, _, _ -> unexpectedCall("UserRepository.updateMyPushSettings") }, - onGetConnectedAccounts = { unexpectedCall("UserRepository.getConnectedAccounts") }, - onLinkConnectedAccount = { _, _ -> unexpectedCall("UserRepository.linkConnectedAccount") }, - onUnlinkConnectedAccount = { unexpectedCall("UserRepository.unlinkConnectedAccount") }, onGetReceiverDeliveryConditions = { unexpectedCall("UserRepository.getReceiverDeliveryConditions") }, onUpdateReceiverDeliveryConditions = { _, _ -> unexpectedCall("UserRepository.updateReceiverDeliveryConditions") }, ) - - private fun defaultConnectedAccounts(email: String): UserConnectedAccount = - UserConnectedAccount(true, false, false, false, false, email, null, null, null, null) } } - -private fun UserConnectedAccount.withProvider( - provider: String, - connected: Boolean, -): UserConnectedAccount = - when (provider.lowercase()) { - "google" -> copy(google = connected, googleEmail = googleEmail.takeIf { connected }) - "naver" -> copy(naver = connected, naverEmail = naverEmail.takeIf { connected }) - "kakao" -> copy(kakao = connected, kakaoEmail = kakaoEmail.takeIf { connected }) - "apple" -> copy(apple = connected, appleEmail = appleEmail.takeIf { connected }) - "local" -> copy(local = connected, localEmail = localEmail.takeIf { connected }) - else -> error("지원하지 않는 연결 계정 provider: $provider") - } - -private fun Receiver.toDefaultDetail(): ReceiverDetail = - ReceiverDetail( - receiverId = receiverId, - name = name, - relation = relation, - phone = null, - email = null, - dailyQuestionCount = 0, - timeLetterCount = 0, - afterNoteCount = 0, - message = null, - authCode = authCode, - ) diff --git a/core/model/src/main/kotlin/com/afternote/core/model/appversion/AppVersionCheck.kt b/core/model/src/main/kotlin/com/afternote/core/model/appversion/AppVersionCheck.kt new file mode 100644 index 0000000000..d9a2b941c4 --- /dev/null +++ b/core/model/src/main/kotlin/com/afternote/core/model/appversion/AppVersionCheck.kt @@ -0,0 +1,18 @@ +package com.afternote.core.model.appversion + +/** + * 서버가 판정한 이 설치본의 강제 업데이트 필요 여부 (#1539). + * + * 서버 계약(`GET /api/v1/app/version`)을 그대로 옮긴 값이다. 판정 자체는 서버가 하고 + * (`versionCode < latestVersionCode`), 클라이언트는 그 결과를 어떻게 쓸지만 정한다. + * + * @property updateRequired 서버가 더 최신 릴리스를 알고 있어 이 빌드로는 계속 쓸 수 없다고 본 경우 true. + * @property latestVersionCode 서버가 아는 최신 릴리스의 versionCode. + * @property storeUrl 보낼 스토어 주소. 서버는 [updateRequired] 가 false 면 null 을 준다 + * (BE `AppVersionService.checkVersion` 실코드 기준). true 인데도 비어 있으면 서버는 500 을 낸다. + */ +data class AppVersionCheck( + val updateRequired: Boolean, + val latestVersionCode: Int, + val storeUrl: String?, +) diff --git a/core/model/src/main/kotlin/com/afternote/core/model/delivery/DeliveryConditionModels.kt b/core/model/src/main/kotlin/com/afternote/core/model/delivery/DeliveryConditionModels.kt index f0404c35d1..ee67751a48 100644 --- a/core/model/src/main/kotlin/com/afternote/core/model/delivery/DeliveryConditionModels.kt +++ b/core/model/src/main/kotlin/com/afternote/core/model/delivery/DeliveryConditionModels.kt @@ -4,8 +4,8 @@ package com.afternote.core.model.delivery * 전달 대상 콘텐츠 유형. 세 도메인(타임레터·애프터노트·마인드레코드)의 콘텐츠를 아우른다. * * 이 패키지(수신자별 전달조건 도메인, 이슈 #427)는 서버가 전달조건을 유저단위 → (수신자 × 콘텐츠) 단위로 - * 재설계하며 신설된 축이다. 기존 유저단위 [com.afternote.core.model.user.DeliveryConditionType] - * (NONE/INACTIVITY/SPECIFIC_DATE) 은 값·의미가 달라 재사용 불가(이슈 #428 에서 제거 예정)해 별도 패키지에 둔다. + * 재설계하며 신설된 축이다. 기존 유저단위 `DeliveryConditionType` + * (NONE/INACTIVITY/SPECIFIC_DATE) 은 값·의미가 달라 재사용 불가라 별도 패키지에 두었고, 그 구 타입은 이슈 #428 에서 제거됐다. */ enum class DeliveryContentType { TIME_LETTER, diff --git a/core/model/src/main/kotlin/com/afternote/core/model/user/UserModel.kt b/core/model/src/main/kotlin/com/afternote/core/model/user/UserModel.kt index 1cedaf3075..28f79e186a 100644 --- a/core/model/src/main/kotlin/com/afternote/core/model/user/UserModel.kt +++ b/core/model/src/main/kotlin/com/afternote/core/model/user/UserModel.kt @@ -11,7 +11,6 @@ data class Receiver( val receiverId: Long, val name: String, val relation: String, - val authCode: String, ) data class ReceiverDetail( @@ -24,7 +23,6 @@ data class ReceiverDetail( val timeLetterCount: Int, val afterNoteCount: Int, val message: String?, - val authCode: String, ) data class ReceiverCreated( @@ -38,6 +36,12 @@ data class UserPushSetting( val afterNote: Boolean, ) +data class UserMarketingConsent( + val sms: Boolean, + val email: Boolean, + val push: Boolean, +) + data class UserConnectedAccount( val local: Boolean, val google: Boolean, diff --git a/core/network/src/main/kotlin/com/afternote/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/com/afternote/core/network/di/NetworkModule.kt index f489f93942..9451e0a293 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/di/NetworkModule.kt @@ -16,6 +16,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.serialization.json.Json import okhttp3.Call +import okhttp3.Dispatcher import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -72,7 +73,7 @@ private fun OkHttpClient.Builder.withApiTimeouts(): OkHttpClient.Builder = * * BE 응답이 정상 범위로 돌아오면 이 팩토리째로 지운다. */ -internal class SlowEndpointCallFactory( +private class SlowEndpointCallFactory( private val default: OkHttpClient, ) : Call.Factory { private val slow: OkHttpClient = @@ -122,7 +123,8 @@ object NetworkModule { // 이 모듈은 오브젝트 클래스 선언해서 딱 /** * 모든 클라이언트의 공통 뿌리. 파생은 [OkHttpClient.newBuilder] 로 — 설정값만이 아니라 - * ConnectionPool · Dispatcher · 스레드풀을 실제로 공유한다. + * ConnectionPool · Dispatcher · 스레드풀을 실제로 공유한다. 예외는 재발급 클라이언트의 + * Dispatcher 하나다([provideRefreshOkHttpClient]). * 인터셉터는 여기 두지 않는다: 로깅은 각 클라이언트가 마지막에 달아야 최종 요청·응답을 관찰하는데, * base 에 두면 파생 시 맨 앞으로 밀린다. */ @@ -131,8 +133,22 @@ object NetworkModule { // 이 모듈은 오브젝트 클래스 선언해서 딱 @Named("BaseClient") fun provideBaseOkHttpClient(): OkHttpClient = OkHttpClient.Builder().withApiTimeouts().build() - // 리이슈를 할 때 일반용 OkhttpClient만 사용하면 액세스 토큰이 계속 헤더에 포함되어 401을 받는 행위가 무한 반복 - // 이를 해결하기 위해 401을 받았을 때는 토큰을 헤더에 포함하지 않고 요청을 보내는 버전 + /** + * 재발급 전용. 메인 클라이언트로 재발급하면 만료된 액세스 토큰이 계속 헤더에 붙어 401 이 무한 반복되므로, + * 토큰을 붙이는 인터셉터 없이 요청을 보낸다. + * + * 커넥션 풀은 base 와 공유하지만 Dispatcher 는 따로 둔다 (#2160). + * + * 재발급을 기다리는 쪽이 메인 클라이언트의 디스패처 스레드다. [TokenAuthenticator] 와 + * [AuthInterceptor] 는 요청을 실행하던 그 스레드에서 재발급 락을 잡고 끝날 때까지 막혀 있다. + * 재발급 호출이 같은 Dispatcher 로 들어가면, 같은 호스트의 401 이 호스트당 실행 칸(기본 5개)을 + * 다 쥔 채 재발급을 기다리고 재발급은 칸이 비기를 기다리며 대기열에 머문다. 대기열의 호출에는 + * `callTimeout` 도 걸리지 않아 스스로 풀리지 않는다. 상한을 올리는 것은 교착이 나는 동시 요청 수만 + * 뒤로 미룬다. + * + * base 의 실행기를 넘겨 칸 계산만 가르지 않는 이유: 그러면 독립성이 실행기가 무제한 캐시 풀이라는 + * 전제에 기댄다. 새 [Dispatcher] 는 그 전제 없이 끊긴다. + */ @Provides @Singleton @Named("RefreshClient") @@ -142,6 +158,7 @@ object NetworkModule { // 이 모듈은 오브젝트 클래스 선언해서 딱 ): OkHttpClient = baseClient .newBuilder() + .dispatcher(Dispatcher()) .addInterceptor(loggingInterceptor) .build() diff --git a/core/network/src/main/kotlin/com/afternote/core/network/dto/UserDto.kt b/core/network/src/main/kotlin/com/afternote/core/network/dto/UserDto.kt index f38cfafdf6..a0e774d333 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/dto/UserDto.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/dto/UserDto.kt @@ -12,7 +12,7 @@ data class UserCreateReceiverRequestDto( @SerialName("name") val name: String, @SerialName("relation") val relation: String, @SerialName("phone") val phone: String? = null, - @SerialName("email") val email: String? = null, + @SerialName("email") val email: String, @SerialName("message") val message: String? = null, ) @@ -43,6 +43,13 @@ data class UserUpdatePushSettingRequestDto( @SerialName("afterNote") val afterNote: Boolean? = null, ) +@Serializable +data class UserUpdateMarketingConsentRequestDto( + @SerialName("sms") val sms: Boolean? = null, + @SerialName("email") val email: Boolean? = null, + @SerialName("push") val push: Boolean? = null, +) + @Serializable data class SocialAccountLinkRequestDto( @SerialName("accessToken") val accessToken: String, @@ -60,26 +67,32 @@ data class UserDto( @SerialName("profileImageUrl") val profileImageUrl: String? = null, ) +/** + * `GET /users/receivers` 목록 항목 — 서버가 내려주는 세 필드뿐이다 (#2105). + * `relation` 은 DB 가 null 을 허용해 그대로 비어 온다. + */ @Serializable data class ReceiverListDto( @SerialName("receiverId") val receiverId: Long, @SerialName("name") val name: String, - @SerialName("relation") val relation: String, - @SerialName("authCode") val authCode: String, + @SerialName("relation") val relation: String? = null, ) +/** + * `GET /users/receivers/{receiverId}` 상세 — 서버 `ReceiverDetailResponse` 의 9개 필드뿐이다 (#2155). + * `authCode` 는 BE#289 이후 오지 않고, `relation` 은 DB 가 null 을 허용해 미입력이면 null 로 온다. + */ @Serializable data class ReceiverDetailDto( @SerialName("receiverId") val receiverId: Long, @SerialName("name") val name: String, - @SerialName("relation") val relation: String, + @SerialName("relation") val relation: String? = null, @SerialName("phone") val phone: String? = null, @SerialName("email") val email: String? = null, @SerialName("dailyQuestionCount") val dailyQuestionCount: Int, @SerialName("timeLetterCount") val timeLetterCount: Int, @SerialName("afterNoteCount") val afterNoteCount: Int, @SerialName("message") val message: String? = null, - @SerialName("authCode") val authCode: String, ) @Serializable @@ -104,6 +117,13 @@ data class UserPushSettingDto( @SerialName("afterNote") val afterNote: Boolean, ) +@Serializable +data class UserMarketingConsentDto( + @SerialName("sms") val sms: Boolean, + @SerialName("email") val email: Boolean, + @SerialName("push") val push: Boolean, +) + @Serializable data class UserConnectedAccountDto( @SerialName("local") val local: Boolean, diff --git a/core/network/src/main/kotlin/com/afternote/core/network/dto/delivery/DeliveryConditionDto.kt b/core/network/src/main/kotlin/com/afternote/core/network/dto/delivery/DeliveryConditionDto.kt index 7482b00f5b..6059a0a449 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/dto/delivery/DeliveryConditionDto.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/dto/delivery/DeliveryConditionDto.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable * `GET/PUT /users/me/receivers/{receiverId}/delivery-conditions` DTO (이슈 #427). * * Swagger 실측(2026-07-08, afternote.kro.kr/v3/api-docs) 스키마와 1:1. 유저단위(구) - * [com.afternote.core.network.dto.DeliveryConditionTypeDto] 와 값이 달라 별도 패키지에 둔다. + * `DeliveryConditionTypeDto` 와 값이 달라 별도 패키지에 두었다 — 그 구 타입은 이슈 #428 에서 제거됐다. */ @Serializable diff --git a/core/network/src/main/kotlin/com/afternote/core/network/interceptor/AuthInterceptor.kt b/core/network/src/main/kotlin/com/afternote/core/network/interceptor/AuthInterceptor.kt index 8d29994f08..a7ea662ed5 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/interceptor/AuthInterceptor.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/interceptor/AuthInterceptor.kt @@ -64,7 +64,7 @@ class AuthInterceptor // 만료 임박이면 401 을 기다리지 않고 먼저 갈아끼움 — 실패 시 기존 토큰 유지 val accessToken = if (expiryTracker.isExpiringSoon()) { - when (val outcome = tokenReissuer.reissue(expectedAccessToken = storedToken)) { + when (val outcome = tokenReissuer.reissueOnce(expectedAccessToken = storedToken)) { is TokenReissuer.Outcome.TokenAlreadyChanged -> outcome.accessToken is TokenReissuer.Outcome.Rotated -> outcome.accessToken is TokenReissuer.Outcome.Failure -> storedToken diff --git a/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt b/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt index a6cc161a0d..974bac1bc4 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt @@ -44,7 +44,7 @@ class TokenAuthenticator // 앞선 다른 경로/스레드가 이미 회전했으면 TokenAlreadyChanged 로 새 토큰만 받아 재시도. // 인증 거절의 세션 정리는 락 안에서 끝난다 (#1126) — 여기서 정리하면 락이 풀린 뒤 // 정리가 끝나기까지가 대기자의 중복 재발급이 빠져나가는 창이 된다. - return when (val outcome = tokenReissuer.reissue(expectedAccessToken = oldAccessToken)) { + return when (val outcome = tokenReissuer.reissueOnce(expectedAccessToken = oldAccessToken)) { is TokenReissuer.Outcome.TokenAlreadyChanged -> { originalRequest.withBearer(outcome.accessToken) } @@ -78,7 +78,7 @@ private fun ErrorReporter.recordAuthContractViolation(authStage: String) { } /** 재발급의 기술 원문을 UI 에 노출하지 않고 현재 요청만 실패시키는 예외. */ -internal class TokenReissueFailureException( +private class TokenReissueFailureException( cause: Throwable, ) : IOException(null, cause) diff --git a/core/network/src/main/kotlin/com/afternote/core/network/service/UserApiService.kt b/core/network/src/main/kotlin/com/afternote/core/network/service/UserApiService.kt index 7fa14490c5..f201b49233 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/service/UserApiService.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/service/UserApiService.kt @@ -10,9 +10,11 @@ import com.afternote.core.network.dto.UserConnectedAccountDto import com.afternote.core.network.dto.UserCreateReceiverDto import com.afternote.core.network.dto.UserCreateReceiverRequestDto import com.afternote.core.network.dto.UserDto +import com.afternote.core.network.dto.UserMarketingConsentDto import com.afternote.core.network.dto.UserPatchReceiverDto import com.afternote.core.network.dto.UserPatchReceiverRequestDto import com.afternote.core.network.dto.UserPushSettingDto +import com.afternote.core.network.dto.UserUpdateMarketingConsentRequestDto import com.afternote.core.network.dto.UserUpdateProfileRequestDto import com.afternote.core.network.dto.UserUpdatePushSettingRequestDto import com.afternote.core.network.dto.UserUpdateReceiverMessageRequestDto @@ -109,6 +111,16 @@ interface UserApiService { @Body request: UserUpdatePushSettingRequestDto, ): BaseResponse + // 마케팅 수신 동의 조회 (문자·이메일·푸시) — 서비스 알림 3종(push-settings)과 별개 + @GET("users/marketing-consents") + suspend fun getMyMarketingConsents(): BaseResponse + + // 마케팅 수신 동의 수정 + @PATCH("users/marketing-consents") + suspend fun updateMyMarketingConsents( + @Body request: UserUpdateMarketingConsentRequestDto, + ): BaseResponse + // 연결된 계정 조회 @GET("users/connected-accounts") suspend fun getConnectedAccounts(): BaseResponse diff --git a/core/network/src/main/kotlin/com/afternote/core/network/token/TokenReissuer.kt b/core/network/src/main/kotlin/com/afternote/core/network/token/TokenReissuer.kt index e8563af9ce..69315032e1 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/token/TokenReissuer.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/token/TokenReissuer.kt @@ -77,8 +77,15 @@ class TokenReissuer private var rejectedAccessToken: String? = null private var rejection: Outcome.AuthenticationRejected? = null - /** @param expectedAccessToken 호출자가 교체하려는 기존 액세스 토큰. */ - fun reissue(expectedAccessToken: String): Outcome { + /** + * 같은 [expectedAccessToken] 을 본 호출자들의 재발급을 조정한다. + * + * 회전 성공 뒤 중복 재발급을 생략하고 확정 거절 결과를 공유한다. + * 일시 실패는 공유하지 않아 다음 호출에서 다시 시도할 수 있다. + * + * @param expectedAccessToken 호출자가 교체하려는 기존 액세스 토큰. + */ + internal fun reissueOnce(expectedAccessToken: String): Outcome { synchronized(this) { // 저장 토큰 비교보다 먼저다 — 세션 정리 뒤엔 저장 토큰이 비어 있어 아래 가드를 그냥 통과한다. rejection?.let { if (expectedAccessToken == rejectedAccessToken) return it } diff --git a/core/network/src/test/kotlin/com/afternote/core/network/di/ConcurrentUnauthorizedReissueTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/di/ConcurrentUnauthorizedReissueTest.kt new file mode 100644 index 0000000000..b54bd99f68 --- /dev/null +++ b/core/network/src/test/kotlin/com/afternote/core/network/di/ConcurrentUnauthorizedReissueTest.kt @@ -0,0 +1,335 @@ +package com.afternote.core.network.di + +import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.model.TokenBundle +import com.afternote.core.network.FakeErrorReporter +import com.afternote.core.network.calladapter.ApiErrorCallAdapterFactory +import com.afternote.core.network.dto.ReissueRequestDto +import com.afternote.core.network.interceptor.AuthInterceptor +import com.afternote.core.network.interceptor.TokenAuthenticator +import com.afternote.core.network.service.TokenApiService +import com.afternote.core.network.token.AccessTokenExpiryTracker +import com.afternote.core.network.token.TokenReissuer +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.logging.HttpLoggingInterceptor +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test +import retrofit2.create +import java.io.BufferedInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * 액세스 토큰이 만료된 채 콜드 스타트하면 같은 호스트로 동시에 나간 인증 요청이 전부 401 을 받는다. + * 이때 재발급까지 끝나고 모든 요청이 새 토큰으로 성공해야 한다. + * + * 조립은 운영과 같다. [NetworkModule] 의 base·재발급·메인 클라이언트와 [ServiceModule] 의 재발급 + * Retrofit 을 그대로 쓰고, 기본 주소만 로컬 서버로 바꾼다. 요청 5건도 콜드 스타트 스레드 덤프에 + * 찍힌 경로 그대로다. 주간 리포트는 운영처럼 느린 경로용 파생 클라이언트를 탄다. + * + * 서버는 만료 토큰 요청이 [ColdStartServer.expiredBarrier] 수만큼 모일 때까지 401 을 쥐고 있는다. + * 첫 401 이 재발급을 부르기 전에 요청 전부가 디스패처 실행 칸을 차지하게 해, 운 좋게 먼저 끝난 + * 요청이 칸을 비워 주는 경합을 없애기 위해서다. + */ +class ConcurrentUnauthorizedReissueTest { + private val closeables = mutableListOf() + + @After + fun tearDown() { + closeables.asReversed().forEach { runCatching { it.close() } } + } + + @Test + fun `같은 호스트 동시 401 이 5건이면 재발급이 끝나고 모든 요청이 성공한다`() { + assertColdStartCompletes(paths = COLD_START_PATHS) + } + + @Test + fun `같은 호스트 동시 401 이 4건이면 재발급이 끝나고 모든 요청이 성공한다`() { + assertColdStartCompletes(paths = COLD_START_PATHS.take(4)) + } + + private fun assertColdStartCompletes(paths: List) { + val server = ColdStartServer(expiredBarrier = paths.size).also(closeables::add) + val wiring = ColdStartWiring(server.baseUrl).also(closeables::add) + + val finished = CountDownLatch(paths.size) + val statuses = ConcurrentLinkedQueue() + paths.forEach { request -> + wiring.callFactory.newCall(request.toOkHttp(server.baseUrl)).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + response.use { statuses += "${request.path}=${it.code}" } + finished.countDown() + } + + override fun onFailure( + call: Call, + e: IOException, + ) { + statuses += "${request.path}=${e::class.simpleName}" + finished.countDown() + } + }, + ) + } + + if (!finished.await(COMPLETION_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + val dispatcher = wiring.baseClient.dispatcher + val stalled = + "요청 ${paths.size}건 중 ${paths.size - finished.count}건만 ${COMPLETION_TIMEOUT_SECONDS}초 안에 끝남. " + + "재발급 서버 도달 ${server.reissueHits.get()}회, " + + "디스패처 실행 ${dispatcher.runningCallsCount()}건·대기 ${dispatcher.queuedCallsCount()}건, " + + "maxRequestsPerHost=${dispatcher.maxRequestsPerHost}" + // 멈춘 OkHttp 스레드는 데몬이 아니다. 상한을 올려 대기 중인 재발급을 내보내야 테스트 JVM 이 + // 스레드를 남기지 않는다. 풀리는지 여부 자체가 원인이 호스트당 상한이라는 증거다. + dispatcher.maxRequestsPerHost = UNBLOCK_MAX_REQUESTS_PER_HOST + val releasedAfterRaise = finished.await(COMPLETION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + fail( + "$stalled. 상한을 $UNBLOCK_MAX_REQUESTS_PER_HOST 로 올린 뒤 풀림=$releasedAfterRaise, " + + "재발급 서버 도달 ${server.reissueHits.get()}회, 응답=$statuses", + ) + } + + assertEquals("재발급은 한 번만 서버에 도달해야 한다", 1, server.reissueHits.get()) + assertEquals( + paths.map { "${it.path}=200" }.sorted(), + statuses.toList().sorted(), + ) + } + + /** 운영 [NetworkModule]·[ServiceModule] 조립을 그대로 따르되 기본 주소만 로컬 서버로 바꾼 묶음. */ + private class ColdStartWiring( + baseUrl: String, + ) : AutoCloseable { + private val json = NetworkModule.provideJson() + private val callAdapterFactory = ApiErrorCallAdapterFactory(json) + private val loggingInterceptor = HttpLoggingInterceptor() + private val tracker = AccessTokenExpiryTracker { 0L } + private val reporter = FakeErrorReporter() + + val baseClient: OkHttpClient = NetworkModule.provideBaseOkHttpClient() + private val refreshClient: OkHttpClient = NetworkModule.provideRefreshOkHttpClient(baseClient, loggingInterceptor) + + private val tokenApiService: TokenApiService = + ServiceModule + .provideRefreshRetrofit( + refreshClient = refreshClient, + json = json, + apiErrorCallAdapterFactory = callAdapterFactory, + ).newBuilder() + .baseUrl(baseUrl) + .build() + .create() + + /** `AuthRepositoryImpl.rotateToken` 과 같은 순서: 저장 refresh 로 재발급을 치고 새 토큰을 저장한다. */ + private val repository = + FakeAuthRepository( + loggedIn = true, + accessToken = EXPIRED_ACCESS_TOKEN, + refreshToken = STORED_REFRESH_TOKEN, + onRotateToken = { + runCatching { + val data = checkNotNull(tokenApiService.reissue(ReissueRequestDto(checkNotNull(refreshToken))).data) + accessToken = data.accessToken + refreshToken = data.refreshToken + TokenBundle(data.accessToken, data.refreshToken, data.expiresIn) + } + }, + ) + + private val reissuer = TokenReissuer({ repository }, tracker, reporter) + + private val mainClient: OkHttpClient = + NetworkModule.provideMainOkHttpClient( + baseClient = baseClient, + debugInterceptors = emptySet(), + featureInterceptors = emptySet(), + loggingInterceptor = loggingInterceptor, + authInterceptor = AuthInterceptor({ repository }, tracker, reissuer), + tokenAuthenticator = TokenAuthenticator({ repository }, reissuer, reporter), + ) + + /** 운영 Retrofit 의 호출 팩토리 — 주간 리포트만 느린 경로용 파생 클라이언트로 가른다. */ + val callFactory: Call.Factory = + NetworkModule + .provideRetrofit(mainClient, json, callAdapterFactory) + .newBuilder() + .baseUrl(baseUrl) + .build() + .callFactory() + + override fun close() { + // 재발급 클라이언트는 디스패처를 따로 쓴다(#2160) — 둘 다 닫아야 스레드가 남지 않는다. + listOf(baseClient.dispatcher, refreshClient.dispatcher).distinct().forEach { dispatcher -> + dispatcher.cancelAll() + dispatcher.executorService.shutdown() + } + baseClient.connectionPool.evictAll() + } + } + + private data class ColdStartRequest( + val method: String, + val path: String, + val jsonBody: String? = null, + ) { + fun toOkHttp(baseUrl: String): Request = + Request + .Builder() + .url(baseUrl + path) + .method(method, jsonBody?.toRequestBody(JSON_MEDIA_TYPE)) + .build() + } + + /** + * 만료 토큰에는 401, 재발급에는 새 토큰, 새 토큰에는 200 으로 답하는 최소 HTTP/1.1 서버. + * 새 테스트 의존성 없이 소켓 경계를 실제로 지나게 하려고 `java.net` 만 쓴다. + */ + private class ColdStartServer( + expiredBarrier: Int, + ) : AutoCloseable { + private val serverSocket = ServerSocket(0, BACKLOG, InetAddress.getLoopbackAddress()) + private val sockets = ConcurrentLinkedQueue() + private val expiredArrivals = CountDownLatch(expiredBarrier) + val reissueHits = AtomicInteger() + val baseUrl = "http://${serverSocket.inetAddress.hostAddress}:${serverSocket.localPort}/api/v1/" + + init { + daemon("cold-start-server-accept") { + while (!serverSocket.isClosed) { + val socket = runCatching { serverSocket.accept() }.getOrNull() ?: break + sockets += socket + daemon("cold-start-server-conn") { serve(socket) } + } + } + } + + private fun serve(socket: Socket) { + socket.use { + val input = BufferedInputStream(it.getInputStream()) + val output = it.getOutputStream() + while (true) { + val requestLine = input.readHttpLine() ?: return + if (requestLine.isEmpty()) continue + val headers = generateSequence { input.readHttpLine()?.takeIf(String::isNotEmpty) }.toList() + val contentLength = + headers + .firstOrNull { header -> header.startsWith("Content-Length:", ignoreCase = true) } + ?.substringAfter(':') + ?.trim() + ?.toInt() ?: 0 + repeat(contentLength) { input.read() } + val authorization = + headers + .firstOrNull { header -> header.startsWith("Authorization:", ignoreCase = true) } + ?.substringAfter(':') + ?.trim() + val (status, body) = respond(requestLine, authorization) + val bytes = body.toByteArray() + output.write( + ( + "HTTP/1.1 $status ${if (status == 200) "OK" else "Unauthorized"}\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: ${bytes.size}\r\n\r\n" + ).toByteArray(), + ) + output.write(bytes) + output.flush() + } + } + } + + private fun respond( + requestLine: String, + authorization: String?, + ): Pair = + when { + requestLine.startsWith("POST /api/v1/auth/reissue ") -> { + reissueHits.incrementAndGet() + 200 to REISSUE_RESPONSE + } + + authorization == "Bearer $FRESH_ACCESS_TOKEN" -> { + 200 to OK_RESPONSE + } + + else -> { + expiredArrivals.countDown() + expiredArrivals.await(BARRIER_TIMEOUT_SECONDS, TimeUnit.SECONDS) + 401 to EXPIRED_RESPONSE + } + } + + override fun close() { + serverSocket.close() + sockets.forEach { runCatching { it.close() } } + } + } + + private companion object { + const val EXPIRED_ACCESS_TOKEN = "expired-access" + const val FRESH_ACCESS_TOKEN = "fresh-access" + const val STORED_REFRESH_TOKEN = "stored-refresh" + const val COMPLETION_TIMEOUT_SECONDS = 5L + const val BARRIER_TIMEOUT_SECONDS = 5L + const val UNBLOCK_MAX_REQUESTS_PER_HOST = 64 + const val BACKLOG = 50 + + val JSON_MEDIA_TYPE = "application/json".toMediaType() + + /** 콜드 스타트 스레드 덤프(2026-09-23)에서 멈춰 있던 요청 5건. */ + val COLD_START_PATHS = + listOf( + ColdStartRequest("GET", "users/me"), + ColdStartRequest("GET", "users/receivers"), + ColdStartRequest("GET", "daily-questions/today"), + ColdStartRequest("PUT", "users/push-tokens", """{"token":"fid","platform":"ANDROID"}"""), + ColdStartRequest("GET", "mind-record"), + ) + + const val OK_RESPONSE = """{"status":200,"code":200,"message":"ok","data":null}""" + const val EXPIRED_RESPONSE = """{"status":401,"code":1106,"message":"expired"}""" + const val REISSUE_RESPONSE = + """{"status":200,"code":200,"message":"ok",""" + + """"data":{"accessToken":"$FRESH_ACCESS_TOKEN","refreshToken":"fresh-refresh","expiresIn":3600}}""" + + fun daemon( + name: String, + block: () -> Unit, + ) { + Thread(block, name).apply { isDaemon = true }.start() + } + + /** CRLF 로 끝나는 한 줄을 읽는다. 스트림이 닫혔으면 null. */ + fun InputStream.readHttpLine(): String? { + val line = ByteArrayOutputStream() + while (true) { + when (val byte = read()) { + -1 -> return if (line.size() == 0) null else line.toString(Charsets.US_ASCII.name()) + '\n'.code -> return line.toString(Charsets.US_ASCII.name()).trimEnd('\r') + else -> line.write(byte) + } + } + } + } +} diff --git a/core/network/src/test/kotlin/com/afternote/core/network/di/NetworkModuleTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/di/NetworkModuleTest.kt index 24ccc0e775..3dc09c3dac 100644 --- a/core/network/src/test/kotlin/com/afternote/core/network/di/NetworkModuleTest.kt +++ b/core/network/src/test/kotlin/com/afternote/core/network/di/NetworkModuleTest.kt @@ -3,6 +3,7 @@ package com.afternote.core.network.di import com.afternote.core.network.calladapter.ApiErrorCallAdapterFactory import okhttp3.logging.HttpLoggingInterceptor import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test @@ -23,6 +24,18 @@ class NetworkModuleTest { assertEquals(listOf(loggingInterceptor), client.interceptors) } + /** + * 재발급을 기다리는 메인 디스패처 스레드가 호스트당 실행 칸을 다 쥐면, 같은 디스패처에 들어간 재발급 + * 호출은 시작하지 못해 교착이 난다 (#2160). 풀은 계속 공유해 소켓이 두 벌 생기지 않게 한다. + */ + @Test + fun `재발급 클라이언트 - 커넥션 풀은 base 와 공유하고 디스패처는 따로 쓴다`() { + val client = NetworkModule.provideRefreshOkHttpClient(baseClient, HttpLoggingInterceptor()) + + assertSame(baseClient.connectionPool, client.connectionPool) + assertNotSame(baseClient.dispatcher, client.dispatcher) + } + @Test fun `메인과 재발급 Retrofit - 같은 API 오류 CallAdapter를 등록`() { val json = NetworkModule.provideJson() diff --git a/core/network/src/test/kotlin/com/afternote/core/network/di/SlowEndpointCallFactoryTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/di/SlowEndpointCallFactoryTest.kt index 52b085de63..a362bc9be9 100644 --- a/core/network/src/test/kotlin/com/afternote/core/network/di/SlowEndpointCallFactoryTest.kt +++ b/core/network/src/test/kotlin/com/afternote/core/network/di/SlowEndpointCallFactoryTest.kt @@ -1,5 +1,7 @@ package com.afternote.core.network.di +import com.afternote.core.network.calladapter.ApiErrorCallAdapterFactory +import okhttp3.Call import okhttp3.OkHttpClient import okhttp3.Request import org.junit.Assert.assertEquals @@ -15,6 +17,9 @@ import java.util.concurrent.TimeUnit * * 인터셉터로는 `callTimeout` 을 못 늘린다 — read 만 늘리면 30초 호출 상한에 다시 걸린다. * 그래서 파생 클라이언트로 가른다. 이 테스트는 «가르는 것» 과 «나머지는 그대로» 를 함께 고정한다. + * + * 가르는 팩토리는 [NetworkModule] 파일 안에만 사는 구현이라, 검증은 모듈이 실제로 내놓는 + * Retrofit 의 call factory 로 한다 — «앱이 쓰는 그 객체» 를 그대로 재는 경로다 (#1672). */ class SlowEndpointCallFactoryTest { private val default = @@ -24,7 +29,13 @@ class SlowEndpointCallFactoryTest { .callTimeout(30, TimeUnit.SECONDS) .build() - private val factory = SlowEndpointCallFactory(default) + private val factory: Call.Factory = + NetworkModule + .provideRetrofit( + okHttpClient = default, + json = NetworkModule.provideJson(), + apiErrorCallAdapterFactory = ApiErrorCallAdapterFactory(NetworkModule.provideJson()), + ).callFactory() @Test fun `주간 리포트는 늘어난 상한으로 나간다`() { diff --git a/core/network/src/test/kotlin/com/afternote/core/network/dto/ReceiverDetailDtoContractTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/dto/ReceiverDetailDtoContractTest.kt new file mode 100644 index 0000000000..c03ae8cd41 --- /dev/null +++ b/core/network/src/test/kotlin/com/afternote/core/network/dto/ReceiverDetailDtoContractTest.kt @@ -0,0 +1,48 @@ +package com.afternote.core.network.dto + +import com.afternote.core.network.di.NetworkModule +import com.afternote.core.network.model.BaseResponse +import com.afternote.core.network.model.requireData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * `GET /users/receivers/{receiverId}` 상세 응답 수신 계약 회귀 가드 (#2155). + * + * 본문은 2026-09-23 dev 서버가 receiverId 14·21 에 내려준 모양이다(연락처는 자리표시 값으로 바꿨다). + * BE `ReceiverDetailResponse` 의 9개 필드뿐이라 `authCode` 가 없고, `relation`·`phone` 은 미입력이면 null 로 온다. + * 어느 쪽이든 디코드에 실패하면 수신자 수정 화면이 열리지 않는다. + * Json 설정은 앱이 쓰는 [NetworkModule.provideJson] 그대로다. + */ +class ReceiverDetailDtoContractTest { + private val json = NetworkModule.provideJson() + + @Test + fun `실서버 상세 응답 - authCode 없는 9필드 본문이 디코드된다`() { + val payload = + """{"status":200,"code":200,"message":"성공","data":{"receiverId":14,"name":"QA수신자","relation":"DAUGHTER","phone":"010-1234-5678","email":"receiver@afternote.local","dailyQuestionCount":0,"timeLetterCount":0,"afterNoteCount":0,"message":"마지막 인사말"}}""" + + val receiver = json.decodeFromString>(payload).requireData() + + assertEquals(14L, receiver.receiverId) + assertEquals("QA수신자", receiver.name) + assertEquals("DAUGHTER", receiver.relation) + assertEquals("010-1234-5678", receiver.phone) + assertEquals("receiver@afternote.local", receiver.email) + assertEquals("마지막 인사말", receiver.message) + } + + @Test + fun `실서버 상세 응답 - relation 이 null 이어도 디코드된다`() { + val payload = + """{"status":200,"code":200,"message":"성공","data":{"receiverId":21,"name":"Admin","relation":null,"phone":null,"email":"admin-receiver@afternote.local","dailyQuestionCount":0,"timeLetterCount":0,"afterNoteCount":0,"message":null}}""" + + val receiver = json.decodeFromString>(payload).requireData() + + assertEquals(21L, receiver.receiverId) + assertEquals("Admin", receiver.name) + assertNull(receiver.relation) + assertNull(receiver.phone) + } +} diff --git a/core/network/src/test/kotlin/com/afternote/core/network/dto/ReceiverListDtoContractTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/dto/ReceiverListDtoContractTest.kt new file mode 100644 index 0000000000..0ecb25060f --- /dev/null +++ b/core/network/src/test/kotlin/com/afternote/core/network/dto/ReceiverListDtoContractTest.kt @@ -0,0 +1,62 @@ +package com.afternote.core.network.dto + +import com.afternote.core.network.di.NetworkModule +import com.afternote.core.network.model.BaseResponse +import com.afternote.core.network.model.requireData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * `GET /users/receivers` 목록 항목 수신 계약 회귀 가드 (#2105). + * + * 항목 하나가 디코드에 실패하면 목록 전체가 날아간다 — 서버가 실제로 내려주는 모양을 그대로 태운다. + * Json 설정은 앱이 쓰는 [NetworkModule.provideJson] 그대로다. + */ +class ReceiverListDtoContractTest { + private val json = NetworkModule.provideJson() + + @Test + fun `실서버 목록 응답 - relation 이 null 인 행이 섞여도 두 건 다 디코드된다`() { + val payload = + """{"status":200,"code":200,"message":"성공","data":[{"receiverId":14,"name":"QA수신자","relation":"DAUGHTER"},{"receiverId":21,"name":"Admin","relation":null}]}""" + + val data = json.decodeFromString>>(payload).requireData() + + assertEquals(listOf(14L, 21L), data.map { it.receiverId }) + assertEquals("DAUGHTER", data.first().relation) + assertNull(data.last().relation) + } + + @Test + fun `실서버 목록 응답 - 항목 3필드만 와도 디코드된다`() { + val payload = + """{"status":200,"code":200,"message":"성공","data":[{"receiverId":14,"name":"QA수신자","relation":"DAUGHTER"}]}""" + + val receiver = json.decodeFromString>>(payload).requireData().single() + + assertEquals(14L, receiver.receiverId) + assertEquals("QA수신자", receiver.name) + assertEquals("DAUGHTER", receiver.relation) + } + + /** 서버가 항목에 모르는 키를 더해도 목록은 그대로 와야 한다 (ignoreUnknownKeys). */ + @Test + fun `실서버 목록 응답 - 미선언 키가 섞여도 디코드된다`() { + val payload = + """{"status":200,"code":200,"message":"성공","data":[{"receiverId":14,"name":"QA수신자","relation":"DAUGHTER","authCode":"AFTER123"}]}""" + + val data = json.decodeFromString>>(payload).requireData() + + assertEquals(14L, data.single().receiverId) + } + + @Test + fun `빈 목록은 그대로 0건이다`() { + val payload = """{"status":200,"code":200,"message":"성공","data":[]}""" + + val data = json.decodeFromString>>(payload).requireData() + + assertEquals(emptyList(), data) + } +} diff --git a/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt index 3943a8bb60..ead8b38dc5 100644 --- a/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt +++ b/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt @@ -17,6 +17,7 @@ import org.junit.Assert.assertSame import org.junit.Assert.assertThrows import org.junit.Test import retrofit2.HttpException +import java.io.IOException import java.net.SocketTimeoutException import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.CountDownLatch @@ -25,6 +26,10 @@ import retrofit2.Response as RetrofitResponse /** * [TokenAuthenticator] 401 사후 대응 계약 회귀 가드 (#408 에서 코디네이터 경유로 전환). * 자체 계약 위반은 [FakeErrorReporter] 로 보고하고, [TokenReissuer] 가 이미 분류한 결과는 중복 보고하지 않는다. + * + * 재발급 실패 시 던지는 예외의 **타입 이름** 은 계약이 아니다 — OkHttp `Authenticator` 가 + * 요구하는 것은 `IOException` 이고, 화면이 보는 것은 «기술 원문 없이(message == null) 원인만 + * 매달린 채 이번 요청만 실패» 다. 그래서 구현 클래스는 파일 밖으로 열지 않고 그 계약만 단언한다 (#1672). */ class TokenAuthenticatorTest { private val tracker = AccessTokenExpiryTracker { 0L } @@ -220,7 +225,7 @@ class TokenAuthenticatorTest { ) val thrown = - assertThrows(TokenReissueFailureException::class.java) { + assertThrows(IOException::class.java) { authenticator(repository, reporter).authenticate(null, unauthorizedResponse()) } @@ -242,7 +247,7 @@ class TokenAuthenticatorTest { ) val thrown = - assertThrows(TokenReissueFailureException::class.java) { + assertThrows(IOException::class.java) { authenticator(repository, reporter).authenticate(null, unauthorizedResponse()) } diff --git a/core/network/src/test/kotlin/com/afternote/core/network/service/ApiWireContractSmokeTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/service/ApiWireContractSmokeTest.kt index 5570852c8d..262f774da1 100644 --- a/core/network/src/test/kotlin/com/afternote/core/network/service/ApiWireContractSmokeTest.kt +++ b/core/network/src/test/kotlin/com/afternote/core/network/service/ApiWireContractSmokeTest.kt @@ -128,6 +128,10 @@ class ApiWireContractSmokeTest { assertNoAuthorizationHeader("POST", "/api/v1/auth/social/login") } + /** + * 본문은 실서버 실측 형태(항목당 3필드)로 둔다 — 빈 배열만 태우면 항목 스키마가 통째로 검증 + * 대상에서 빠진다. #2105 가 그 틈으로 지나갔다(항목 디코드 실패 → 목록 전체 소실). + */ @Test fun `authenticated receiver request preserves bearer header and relative API path`() = runTest { @@ -135,13 +139,17 @@ class ApiWireContractSmokeTest { method = "GET", path = "/api/v1/users/receivers", requestHeaders = mapOf("Authorization" to "Bearer contract-token"), - responseBody = """{"status":200,"code":200,"message":"ok","data":[]}""", + responseBody = + """{"status":200,"code":200,"message":"ok","data":[{"receiverId":14,"name":"QA수신자","relation":"DAUGHTER"}]}""", ) val result = userService.getReceivers() + val receiver = result.data.orEmpty().single() assertEquals(200, result.status) - assertTrue(result.data.orEmpty().isEmpty()) + assertEquals(14L, receiver.receiverId) + assertEquals("QA수신자", receiver.name) + assertEquals("DAUGHTER", receiver.relation) assertExactlyOneRecordedRequest("GET", "/api/v1/users/receivers") } diff --git a/core/network/src/test/kotlin/com/afternote/core/network/token/ReissueFailureClassificationTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/token/ReissueFailureClassificationTest.kt index 474189facd..4b152d51ac 100644 --- a/core/network/src/test/kotlin/com/afternote/core/network/token/ReissueFailureClassificationTest.kt +++ b/core/network/src/test/kotlin/com/afternote/core/network/token/ReissueFailureClassificationTest.kt @@ -68,7 +68,7 @@ class ReissueFailureClassificationTest { }, ) return TokenReissuer({ repository }, tracker, FakeErrorReporter()) - .reissue(expectedAccessToken = "old-token") + .reissueOnce(expectedAccessToken = "old-token") } @Test diff --git a/core/network/src/test/kotlin/com/afternote/core/network/token/TokenReissuerTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/token/TokenReissuerTest.kt index 0ff74cb9e8..1b0ba0fb79 100644 --- a/core/network/src/test/kotlin/com/afternote/core/network/token/TokenReissuerTest.kt +++ b/core/network/src/test/kotlin/com/afternote/core/network/token/TokenReissuerTest.kt @@ -40,7 +40,7 @@ class TokenReissuerTest { val repository = networkFakeAuthRepository(accessToken = "refreshed-by-other-path") val coordinator = reissuer(repository) - val outcome = coordinator.reissue(expectedAccessToken = "old-token") + val outcome = coordinator.reissueOnce(expectedAccessToken = "old-token") assertEquals(0, repository.rotateCallCount) assertEquals( @@ -63,7 +63,7 @@ class TokenReissuerTest { ) val coordinator = reissuer(repository) - val outcome = coordinator.reissue(expectedAccessToken = "old-token") + val outcome = coordinator.reissueOnce(expectedAccessToken = "old-token") assertEquals(1, repository.rotateCallCount) assertEquals(TokenReissuer.Outcome.Rotated("fresh-token"), outcome) @@ -85,7 +85,7 @@ class TokenReissuerTest { ) val coordinator = reissuer(repository) - val outcome = coordinator.reissue(expectedAccessToken = "old-token") + val outcome = coordinator.reissueOnce(expectedAccessToken = "old-token") assertEquals(TokenReissuer.Outcome.Rotated("fresh-token"), outcome) // expiresIn 이 없으면 직전 deadline 을 비워 선제 갱신을 쉰다 (401 안전망에 위임) @@ -105,7 +105,7 @@ class TokenReissuerTest { onClearSession = { Result.success(Unit) }, ) - val outcome = reissuer(repository, reporter).reissue(expectedAccessToken = "old-token") + val outcome = reissuer(repository, reporter).reissueOnce(expectedAccessToken = "old-token") assertTrue(outcome is TokenReissuer.Outcome.AuthenticationRejected) assertSame(failure, (outcome as TokenReissuer.Outcome.AuthenticationRejected).exception) @@ -131,7 +131,7 @@ class TokenReissuerTest { onClearSession = { Result.success(Unit) }, ) - val outcome = reissuer(repository, reporter).reissue(expectedAccessToken = "old-token") + val outcome = reissuer(repository, reporter).reissueOnce(expectedAccessToken = "old-token") assertTrue(outcome is TokenReissuer.Outcome.AuthenticationRejected) assertSame(failure, (outcome as TokenReissuer.Outcome.AuthenticationRejected).exception) @@ -152,7 +152,7 @@ class TokenReissuerTest { ) val coordinator = reissuer(repository, reporter) - val outcome = coordinator.reissue(expectedAccessToken = "old-token") + val outcome = coordinator.reissueOnce(expectedAccessToken = "old-token") assertTrue(outcome is TokenReissuer.Outcome.TransportFailure) assertSame(failure, (outcome as TokenReissuer.Outcome.TransportFailure).exception) @@ -183,7 +183,7 @@ class TokenReissuerTest { onRotateToken = { Result.failure(failure) }, ) - val outcome = reissuer(repository, reporter).reissue(expectedAccessToken = "old-token") + val outcome = reissuer(repository, reporter).reissueOnce(expectedAccessToken = "old-token") assertTrue(outcome is TokenReissuer.Outcome.ServerFailure) assertSame(failure, (outcome as TokenReissuer.Outcome.ServerFailure).exception) @@ -205,7 +205,7 @@ class TokenReissuerTest { }, ) - val outcome = reissuer(repository, reporter).reissue(expectedAccessToken = "old-token") + val outcome = reissuer(repository, reporter).reissueOnce(expectedAccessToken = "old-token") assertTrue(outcome is TokenReissuer.Outcome.UnexpectedFailure) assertFalse(tracker.isExpiringSoon()) @@ -231,7 +231,7 @@ class TokenReissuerTest { onClearSession = { Result.success(Unit) }, ) - val outcome = reissuer(repository, reporter).reissue(expectedAccessToken = "old-token") + val outcome = reissuer(repository, reporter).reissueOnce(expectedAccessToken = "old-token") assertTrue(outcome is TokenReissuer.Outcome.AuthenticationRejected) assertSame(failure, (outcome as TokenReissuer.Outcome.AuthenticationRejected).exception) @@ -256,7 +256,7 @@ class TokenReissuerTest { onRotateToken = { Result.failure(failure) }, ) - val outcome = reissuer(repository, reporter).reissue(expectedAccessToken = "old-token") + val outcome = reissuer(repository, reporter).reissueOnce(expectedAccessToken = "old-token") assertTrue(outcome is TokenReissuer.Outcome.UnexpectedFailure) assertSame(failure, (outcome as TokenReissuer.Outcome.UnexpectedFailure).exception) @@ -276,7 +276,7 @@ class TokenReissuerTest { ) val coordinator = reissuer(repository) - val outcome = coordinator.reissue(expectedAccessToken = "old-token") + val outcome = coordinator.reissueOnce(expectedAccessToken = "old-token") assertEquals(1, repository.rotateCallCount) assertTrue(outcome is TokenReissuer.Outcome.UnexpectedFailure) @@ -297,9 +297,9 @@ class TokenReissuerTest { ) val coordinator = reissuer(repository) - val first = coordinator.reissue(expectedAccessToken = "old-token") - val second = coordinator.reissue(expectedAccessToken = "old-token") - val third = coordinator.reissue(expectedAccessToken = "old-token") + val first = coordinator.reissueOnce(expectedAccessToken = "old-token") + val second = coordinator.reissueOnce(expectedAccessToken = "old-token") + val third = coordinator.reissueOnce(expectedAccessToken = "old-token") // 재발급 HTTP 와 세션 정리는 승자 1회뿐, 나머지는 같은 분류를 그대로 받는다. assertEquals(1, repository.rotateCallCount) @@ -317,8 +317,8 @@ class TokenReissuerTest { ) val coordinator = reissuer(repository) - coordinator.reissue(expectedAccessToken = "old-token") - val outcome = coordinator.reissue(expectedAccessToken = "old-token") + coordinator.reissueOnce(expectedAccessToken = "old-token") + val outcome = coordinator.reissueOnce(expectedAccessToken = "old-token") // 재시도가 성립하는 실패다 — 캐시하면 네트워크 복구 뒤에도 영영 못 살아난다. assertEquals(2, repository.rotateCallCount) @@ -347,11 +347,11 @@ class TokenReissuerTest { ) val coordinator = reissuer(repository) - coordinator.reissue(expectedAccessToken = "old-token") + coordinator.reissueOnce(expectedAccessToken = "old-token") // 재로그인이 준 새 토큰 — 거절 캐시의 키(옛 액세스 토큰)와 어긋나므로 정상 회전으로 간다. rotateFails = false repository.accessToken = "relogin-token" - val outcome = coordinator.reissue(expectedAccessToken = "relogin-token") + val outcome = coordinator.reissueOnce(expectedAccessToken = "relogin-token") assertEquals(TokenReissuer.Outcome.Rotated("rotated-token"), outcome) assertEquals(2, repository.rotateCallCount) diff --git a/core/ui/README.md b/core/ui/README.md index 1d2bc22256..452f5e02f1 100644 --- a/core/ui/README.md +++ b/core/ui/README.md @@ -36,13 +36,12 @@ Column( - **버튼**: [`AfternoteButton`](src/main/kotlin/com/afternote/core/ui/button/AfternoteButton.kt) (`Default`·`Active`·`Plain`·`Un`·`Variant5`) / [`AfternoteActionButton`](src/main/kotlin/com/afternote/core/ui/button/AfternoteButton.kt) / [`PlusBadgeButton`](src/main/kotlin/com/afternote/core/ui/button/PlusBadgeButton.kt) - **TopBar**: [`HomeTopBar`](src/main/kotlin/com/afternote/core/ui/topbar/HomeTopBar.kt) / [`DetailTopBar`](src/main/kotlin/com/afternote/core/ui/topbar/DetailTopBar.kt) / [`TitleTopBar`](src/main/kotlin/com/afternote/core/ui/topbar/TitleTopBar.kt) - **BottomBar**: [`BottomBar`](src/main/kotlin/com/afternote/core/ui/bottombar/BottomBar.kt) -- **Popup**: [`Popup(type, ...)`](src/main/kotlin/com/afternote/core/ui/popup/Popup.kt) (`Default`·`Variant2`) / [`AfternoteErrorPopup`](src/main/kotlin/com/afternote/core/ui/popup/ErrorPopup.kt) / [`NetworkErrorPopup`](src/main/kotlin/com/afternote/core/ui/popup/ErrorPopup.kt) +- **Popup**: [`Popup(type, ...)`](src/main/kotlin/com/afternote/core/ui/popup/Popup.kt) (`Default`·`Variant2`) / [`AfternoteErrorPopup`](src/main/kotlin/com/afternote/core/ui/popup/ErrorPopup.kt) — 오류 팝업은 시안 4종 중 [`NetworkErrorPopup`](src/main/kotlin/com/afternote/core/ui/popup/ErrorPopup.kt) · [`ServerErrorPopup`](src/main/kotlin/com/afternote/core/ui/popup/ErrorPopup.kt) · [`UploadErrorPopup`](src/main/kotlin/com/afternote/core/ui/popup/ErrorPopup.kt) 3종을 세웠다(403 접근 권한 없음은 생산자가 없어 아직 없다) (#446) - **TextField**: [`CaptionLabeledTextField`](src/main/kotlin/com/afternote/core/ui/CaptionLabeledTextField.kt) / [`AfternoteTextField`](src/main/kotlin/com/afternote/core/ui/TextFieldShort.kt) - **Card / Section**: [`AfternoteOutlinedCard`](src/main/kotlin/com/afternote/core/ui/AfternoteOutlinedCard.kt) / [`AfternoteSectionHeader`](src/main/kotlin/com/afternote/core/ui/AfternoteSectionHeader.kt) - **Profile**: [`ProfileImage`](src/main/kotlin/com/afternote/core/ui/ProfileImage.kt) / [`ProfileImagePicker`](src/main/kotlin/com/afternote/core/ui/ProfileImage.kt) - **Calendar**: [`BottomSheetCalendar`](src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt) - **Checkbox / Radio**: [`AfternoteCircularCheckbox`](src/main/kotlin/com/afternote/core/ui/button/AfternoteCircularCheckbox.kt) / [`AfternoteRadioGroup`](src/main/kotlin/com/afternote/core/ui/button/AfternoteRadioGroup.kt) (`selectedValue: T?` + `onSelect(T)` 단일 선택 계약) - - 단품 [`CustomRadioButton`](src/main/kotlin/com/afternote/core/ui/button/CustomRadioButton.kt) 은 **`@Deprecated` — 신규 사용 금지.** 선택값을 소유하지 않아 단일 선택이 구조로 강제되지 않는다. `core:ui` 밖 신규 사용은 `SingleSelectionRadioKonsistTest` 가 막고, 잔여 사용처(setting #1396) 이관이 끝나면 선언을 걷는다 (#649). - **FAB**: [`AfternoteFloatingActionButton`](src/main/kotlin/com/afternote/core/ui/button/FAB/AfternoteFloatingActionButton.kt) / [`PenFloatingActionButton`](src/main/kotlin/com/afternote/core/ui/button/FAB/PenFloatingActionButton.kt) - **Icon**: [`CloseIcon`](src/main/kotlin/com/afternote/core/ui/icon/CloseIcon.kt) / [`RightArrowIcon`](src/main/kotlin/com/afternote/core/ui/icon/RightArrowIcon.kt) - **Badge**: [`RecipientDesignationBadge`](src/main/kotlin/com/afternote/core/ui/badge/RecipientDesignationBadge.kt) / [`CircularCheckboxOutlineChip`](src/main/kotlin/com/afternote/core/ui/badge/CircularCheckboxOutlineChip.kt) @@ -89,7 +88,7 @@ core/ui/ |---|---| | `Material3 Card` 단순 컨테이너 | `Column + clip/background/padding` (위 참조) | | `Color(0xFF...)` 하드코드 | `AfternoteDesign.colors.*` 토큰 | -| 라디오 인디케이터를 단품(`CustomRadioButton`)으로 조합해 선택 상태 직접 관리 | `AfternoteRadioGroup(selectedValue, onSelect)`로 단일 선택 구조화 (단품은 `@Deprecated`) | +| 라디오 인디케이터를 단품으로 조합해 선택 상태 직접 관리 | `AfternoteRadioGroup(selectedValue, onSelect)`로 단일 선택 구조화 | | ViewModel 에 `Context` 주입 (string resource 용) | UiState 에 sealed error / `@StringRes Int?` 노출 → UI 가 `stringResource` 변환 | | ViewModel 에 `Channel` / `SharedFlow` | UiState 의 nullable 필드 + `onXxxShown()` 콜백 | | Repository 우회해 ViewModel 이 `DataSource` / cache 직접 주입 | Repository 만 진입점, cache 는 Repository 내부 책임 | diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 5450f53db1..6fc785e45f 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -1,6 +1,6 @@ plugins { id("afternote.android.library.compose") - kotlin("plugin.serialization") + id("afternote.android.navigation") alias(libs.plugins.compose.screenshot) id("afternote.kover") } @@ -16,6 +16,9 @@ android { dependencies { implementation(libs.androidx.fragment.ktx) + // MviViewModel 이 androidx.lifecycle.ViewModel 을 상속해 노출하므로 feature 컴파일 + // 클래스패스에도 올라가야 한다 — implementation 이면 상속체가 supertype 을 못 본다. + api(libs.androidx.lifecycle.viewmodel.ktx) implementation(projects.core.common) implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/KoreanConsonantIndex.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/KoreanConsonantIndex.kt index eb0b8b671f..635e1be738 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/KoreanConsonantIndex.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/KoreanConsonantIndex.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -53,6 +54,8 @@ fun KoreanConsonantIndex( modifier: Modifier = Modifier, ) { var totalHeightPx by remember { mutableFloatStateOf(0f) } + // Keep ongoing gestures connected to the current list after search results change. + val currentOnConsonantSelect by rememberUpdatedState(onConsonantSelect) fun indexFromY(y: Float): Int = (y / totalHeightPx * CONSONANTS.size) @@ -66,11 +69,11 @@ fun KoreanConsonantIndex( .onSizeChanged { totalHeightPx = it.height.toFloat() } .pointerInput(Unit) { detectTapGestures { offset -> - onConsonantSelect(CONSONANTS[indexFromY(offset.y)]) + currentOnConsonantSelect(CONSONANTS[indexFromY(offset.y)]) } }.pointerInput(Unit) { detectDragGestures { change, _ -> - onConsonantSelect(CONSONANTS[indexFromY(change.position.y)]) + currentOnConsonantSelect(CONSONANTS[indexFromY(change.position.y)]) } }, verticalArrangement = Arrangement.spacedBy(6.dp), diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/ProfileImage.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/ProfileImage.kt index 0b94f82fb3..af49bfbf13 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/ProfileImage.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/ProfileImage.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp @@ -89,6 +90,9 @@ private fun ProfileImageContent( modifier = modifier, placeholder = placeholder, error = placeholder, + // 아바타는 원으로 잘라 쓴다. 기본값(Fit)이면 사용자가 고른 세로·가로 사진이 원 안에서 + // 축소돼 위아래가 비므로, 칸을 채우고 넘치는 쪽을 잘라야 자리가 그대로 유지된다. + contentScale = ContentScale.Crop, ) } else { Image( diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/Route.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/Route.kt index 7bcdc93946..761c198e54 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/Route.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/Route.kt @@ -34,7 +34,17 @@ sealed interface Route { data object Afternote : Route /** - * 수신자(추모자) 흐름의 그래프 루트. + * 수신 애프터노트(목록·상세·추억 플레이리스트) 로컬 스택의 host destination. + * + * [Afternote] 로컬 스택에 넣지 않는다 — 그쪽 시작점은 발신자용 지문 관문이고 수신자는 그 + * 관문을 지나지 않는다. Nav3 이관 전에는 화면 셋이 루트에 흩어져 있었는데, 로컬 스택을 + * 가지려면 그 스택을 담을 자리가 하나 필요해 route 를 세웠다 (#1698). + */ + @Serializable + data object ReceivedAfternote : Route + + /** + * 수신자(추모자) 흐름 로컬 스택의 host destination. * * 작성자(`Route.Afternote`)와 별개로 받은 사람이 진입하는 별도 사용자 여정이며, * 데이터 레이어도 별도 인증(`X-Auth-Code`)을 사용한다. 온보딩 Welcome 의 @@ -43,7 +53,15 @@ sealed interface Route { @Serializable data object Receiver : Route - /** 임시 로그아웃 진입용 설정 화면. 정식 설정 IA 확정 전까지 단일 화면. */ + /** + * 설정 로컬 스택의 host destination (#1695). + * + * 설정 안의 화면은 전부 이 한 칸 아래의 로컬 Nav3 스택에 있다. 홈의 «수신인 지정 미완료» 칩은 + * 설정 홈을 거치지 않고 등록 화면으로 곧장 들어오는데, 로컬 스택의 시작점은 host 가 정하므로 + * 그 진입을 [startWithRecipientRegistration] 인자로 나른다. + */ @Serializable - data object Setting : Route + data class Setting( + val startWithRecipientRegistration: Boolean = false, + ) : Route } diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/TextFieldShort.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/TextFieldShort.kt index e3e22cb307..6a414c34e8 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/TextFieldShort.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/TextFieldShort.kt @@ -210,17 +210,22 @@ fun AfternoteTextField( outputTransformation = outputTransformation, focusRequester = focusRequester, isError = isError, + // else 로 닫지 않는다 — 타입이 늘면 후행 아이콘 여부를 여기서 정하도록 컴파일 에러로 잡는다. trailingContent = when (type) { TextFieldType.Search -> { { SearchIcon() } } - else -> { + TextFieldType.Basic, + is TextFieldType.Variant7, + is TextFieldType.Variant8, + -> { null } }, suffixFollowsText = type is TextFieldType.Variant8, + // 위와 같은 이유로 else 를 두지 않는다. suffix = when (type) { is TextFieldType.Variant7 -> { @@ -231,7 +236,9 @@ fun AfternoteTextField( { Variant8Suffix(type = type, onImeAction = onImeAction) } } - else -> { + TextFieldType.Basic, + TextFieldType.Search, + -> { null } }, diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/UiText.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/UiText.kt index fa7f5ab677..441f85d95c 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/UiText.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/UiText.kt @@ -21,12 +21,6 @@ sealed interface UiText { val value: String, ) : UiText - /** 서버/예외가 메시지를 주면 그대로, 없으면 리소스 fallback. `e.message ?: getString(R.string.x)` 대응. */ - data class DynamicOrResource( - val value: String?, - @param:StringRes val fallbackResId: Int, - ) : UiText - companion object { fun resource( @StringRes resId: Int, @@ -50,8 +44,4 @@ fun UiText.asString(): String = is UiText.Dynamic -> { value } - - is UiText.DynamicOrResource -> { - value ?: stringResource(fallbackResId) - } } diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/ViewModeSwitcher.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/ViewModeSwitcher.kt index e8eef9c9bf..1a30c2a283 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/ViewModeSwitcher.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/ViewModeSwitcher.kt @@ -38,8 +38,8 @@ import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.toggleShadow_1 import com.afternote.core.ui.theme.toggleShadow_2 -internal const val VIEW_MODE_PILL_TEST_TAG = "view_mode_pill" -internal const val VIEW_MODE_INDICATOR_TEST_TAG = "view_mode_indicator" +private const val VIEW_MODE_PILL_TEST_TAG = "view_mode_pill" +private const val VIEW_MODE_INDICATOR_TEST_TAG = "view_mode_indicator" @Composable fun ViewModeSwitcher( diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/button/AfternoteButton.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/button/AfternoteButton.kt index c71570f839..70c982ddb5 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/button/AfternoteButton.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/button/AfternoteButton.kt @@ -85,11 +85,18 @@ fun AfternoteButton( AfternoteButtonType.Un -> AfternoteDesign.colors.gray2 AfternoteButtonType.Variant5 -> AfternoteDesign.colors.gray9 } + // 바로 위 containerColor 와 같은 enum 이다 — else 로 닫으면 타입이 늘 때 배경색만 컴파일 에러로 + // 잡히고 글자색은 조용히 흰색으로 굳는다. 두 분기가 같은 시점에 깨지도록 항목을 모두 적는다. val contentColor = when (type) { AfternoteButtonType.Plain -> AfternoteDesign.colors.gray9 + AfternoteButtonType.Un -> AfternoteDesign.colors.gray5 - else -> AfternoteDesign.colors.white + + AfternoteButtonType.Default, + AfternoteButtonType.Active, + AfternoteButtonType.Variant5, + -> AfternoteDesign.colors.white } CompositionLocalProvider( LocalMinimumInteractiveComponentSize provides androidx.compose.ui.unit.Dp.Unspecified, diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/button/CustomRadioButton.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/button/CustomRadioButton.kt deleted file mode 100644 index 43fbc98ee3..0000000000 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/button/CustomRadioButton.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.afternote.core.ui.button - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.minimumInteractiveComponentSize -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.theme.AfternoteDesign - -/** - * 커스텀 라디오 버튼 컴포넌트 — **신규 사용 금지, [AfternoteRadioGroup] 을 쓰세요.** - * - * 이 컴포저블은 `selected`/`onClick` 만 받는 **단품**이라 라디오의 본질인 단일 선택을 구조로 - * 강제하지 못합니다. 선택값의 소유권이 호출부에 남아, `Set` 토글 상태에 물리면 "라디오 - * 비주얼인데 복수 선택" 이라는 오용이 컴파일 타임에 걸리지 않습니다 (#648 실사고). 접근성도 - * 마찬가지로 호출부 책임이라 `Modifier.selectableGroup` 이 빠지면 스크린리더가 상호배타 - * 관계를 읽지 못합니다. - * - * 대신 선택값 하나를 소유하는 [AfternoteRadioGroup] (`selectedValue: T?` + `onSelect(T)`) - * 을 쓰면 단일 선택·그룹 semantics·48dp 상호작용 경계가 컴포넌트 안에서 닫힙니다. - * - * 남아 있는 잔여 사용처는 `SingleSelectionRadioKonsistTest` 의 허용 목록이 정본이며, - * 이관 완료 시 이 선언을 삭제합니다 (#649). - * - * 체크 표시(인디케이터)와 윤곽선 간 간격이 전체 크기의 12분의 1이 되도록 자동 계산됩니다. - * [onClick]이 있으면 바깥에 [Modifier.minimumInteractiveComponentSize]와 - * [Modifier.selectable] + [Role.RadioButton]을 두어 최소 48×48dp 터치 영역과 리플을 쓰고, - * 안쪽은 [buttonSize]만큼만 그립니다. 부모가 선택을 처리할 때는 [onClick]을 null로 두면 24dp만 차지합니다. - * - * @param selected 선택 여부 - * @param onClick 클릭 이벤트 (null이면 비인터랙티브·부모에서 처리) - * @param buttonSize 전체 버튼 크기 (기본값: 24.dp) - * @param selectedColor 선택된 색상 (기본값: AfternoteDesign.colors.gray9) - * @param unselectedColor 선택 안 된 색상 (기본값: AfternoteDesign.colors.gray4) - */ -@Deprecated( - message = - "단품 라디오는 단일 선택을 구조로 강제하지 못한다. " + - "선택값 하나를 소유하는 AfternoteRadioGroup(options, selectedValue, onSelect) 로 옮겨라 (#649).", -) -@Composable -fun CustomRadioButton( - selected: Boolean, - modifier: Modifier = Modifier, - onClick: (() -> Unit)? = null, - buttonSize: Dp = 24.dp, - selectedColor: Color = AfternoteDesign.colors.gray9, - unselectedColor: Color = AfternoteDesign.colors.gray4, -) { - val borderWidth = 1.dp - val spacing = buttonSize / 12f - val maxIndicatorSize = buttonSize - (borderWidth * 2) - (spacing * 2) - - val targetBorderColor = if (selected) selectedColor else unselectedColor - val animatedBorderColor by animateColorAsState( - targetValue = targetBorderColor, - animationSpec = tween(durationMillis = 150), - label = "CustomRadioButtonBorderColor", - ) - - val indicatorSize by animateDpAsState( - targetValue = if (selected) maxIndicatorSize else 0.dp, - animationSpec = tween(durationMillis = 150), - label = "CustomRadioButtonIndicatorSize", - ) - - val interactionSource = remember { MutableInteractionSource() } - - Box( - modifier = - modifier.then( - if (onClick != null) { - Modifier - .minimumInteractiveComponentSize() - .selectable( - selected = selected, - onClick = onClick, - role = Role.RadioButton, - interactionSource = interactionSource, - indication = ripple(), - ) - } else { - Modifier - }, - ), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = - Modifier - .size(buttonSize) - .clip(CircleShape) - .border( - width = borderWidth, - color = animatedBorderColor, - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - if (indicatorSize > 0.dp) { - Box( - modifier = - Modifier - .size(indicatorSize) - .background( - color = animatedBorderColor, - shape = CircleShape, - ), - ) - } - } - } -} diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/button/FAB/AfternoteFloatingActionButton.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/button/FAB/AfternoteFloatingActionButton.kt index 39199d72cb..686fed122c 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/button/FAB/AfternoteFloatingActionButton.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/button/FAB/AfternoteFloatingActionButton.kt @@ -24,7 +24,7 @@ import com.afternote.core.ui.theme.AfternoteDesign fun AfternoteFloatingActionButton( onClick: () -> Unit, modifier: Modifier = Modifier, - size: Dp = 56.dp, + size: Dp = AfternoteFabSize, iconSize: Dp = 24.dp, ) { FloatingActionButton( @@ -41,3 +41,22 @@ fun AfternoteFloatingActionButton( ) } } + +/** FAB 기본 지름. M3 [FloatingActionButton] 기본값과 같다. */ +val AfternoteFabSize: Dp = 56.dp + +/** M3 `Scaffold` 가 FAB 과 화면 가장자리 사이에 두는 기본 여백. */ +private val FabScreenMargin: Dp = 16.dp + +/** + * FAB 이 뜬 화면의 목록이 **마지막 항목을 가리지 않도록** 남겨야 할 하단 여백. + * + * `Scaffold` 의 `paddingValues` 는 FAB 자리를 예약하지 않는다 — FAB 은 콘텐츠 위에 뜨는 것이 + * 설계다. 그래서 목록이 스스로 이만큼을 비워야 한다. 항목이 많아 스크롤이 생기면 밀어서 볼 + * 수라도 있지만, **항목이 한둘이라 스크롤 자체가 없으면 가려진 채 고정된다** — 기록이 적은 + * 신규 사용자가 정확히 그 상태다. + * + * FAB 지름에 위아래 기본 여백(M3 `Scaffold` 가 FAB 에 주는 16dp)을 더한 값이라, 지름이 + * 바뀌면 여백도 함께 따라온다 — 두 값이 따로 놀지 않게 한 자리에서 낸다. + */ +val AfternoteFabContentBottomPadding: Dp = AfternoteFabSize + FabScreenMargin * 2 diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/button/PlusBadgeButton.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/button/PlusBadgeButton.kt index 89ca9d0d81..65c6f14b3f 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/button/PlusBadgeButton.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/button/PlusBadgeButton.kt @@ -28,6 +28,10 @@ import com.afternote.core.ui.theme.AfternoteDesign * * [onClick]이 `null`이면 상위 컨테이너가 단일 클릭 영역을 소유할 때 쓰는 장식 배지다. * 이때 [contentDescription]도 `null`로 두어 같은 액션을 중복해 읽지 않게 한다. + * + * 원의 채움은 `gray9`다. 시안 컴포넌트 `52:7886`의 fill 이 `#212121`이고 그 값이 팔레트의 + * `Gray9`와 같다. core 의 다른 어두운 원형 버튼(`PenFloatingActionButton`, `AfternoteFloatingActionButton`)도 + * 같은 토큰을 쓴다. `black`(`#000000`)은 시안보다 한 단계 진해 이 자리에 쓰지 않는다(#2063). */ @Composable fun PlusBadgeButton( @@ -48,7 +52,7 @@ fun PlusBadgeButton( modifier .size(size) .clip(CircleShape) - .background(AfternoteDesign.colors.black) + .background(AfternoteDesign.colors.gray9) .then(clickModifier) .padding(paddingValues), ) { diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt index 7e8a35a25a..ae6fd474de 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt @@ -108,8 +108,13 @@ fun BottomSheetCalendar( } } +/** + * ModalBottomSheet 래퍼 없이 달력 본체만 렌더링하는 [BottomSheetCalendar] 내부 구현. + * + * 파일 밖으로 열지 않는다 — 공개 계약은 [BottomSheetCalendar] 하나다 (#1672). + */ @Composable -fun DatePickerContent( +private fun DatePickerContent( title: String, currentYear: Int, currentMonth: Int, diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/icon/RightArrowIcon.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/icon/RightArrowIcon.kt index 95c17c5c90..c9fcc308a3 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/icon/RightArrowIcon.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/icon/RightArrowIcon.kt @@ -22,10 +22,10 @@ fun RightArrowIcon( modifier: Modifier = Modifier, tint: Color = LocalContentColor.current, ) { - Icon( - painter = painterResource(R.drawable.core_ui_right_arrow), - contentDescription = null, + ArrowIcon( + iconRes = R.drawable.core_ui_right_arrow, modifier = modifier, + contentDescription = null, tint = tint, ) } diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/loading/ListRefreshErrorBanner.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/loading/ListRefreshErrorBanner.kt new file mode 100644 index 0000000000..652126335b --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/loading/ListRefreshErrorBanner.kt @@ -0,0 +1,65 @@ +package com.afternote.core.ui.loading + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.R +import com.afternote.core.ui.theme.AfternoteDesign + +/** + * 목록을 이미 그려 둔 상태에서 새로고침이 실패했을 때 목록 위에 얹는 안내 (#705). + * + * Paging 은 refresh 가 실패해도 기존 페이지를 유지하므로, 화면이 말하지 않으면 «표시만 갱신되지 + * 않은» 무음 실패가 된다. 전면 오류(애프터노트 `ErrorListBody`)로 갈아끼우지 않는 이유는 이미 보고 있는 + * 목록이 여전히 유효해서다 — 목록은 남기고 실패와 복구 수단만 덧붙인다. + * + * 스낵바가 아니라 상주 배너인 이유: 스낵바는 스스로 사라져 놓친 사용자에게 복구 수단이 남지 않는다. + * 이 배너는 다음 새로고침이 성공할 때까지 «다시 시도» 를 화면에 붙들어 둔다. + * + * 애프터노트 홈 목록과 설정 수신자 관리 목록이 같이 쓴다. 설정이 같은 구성의 사본을 만들던 것을 + * core 로 올렸다 (#2045). + * + * 표시 방식 통일(#446) 결론이 나오면 본문 표현만 교체한다 — 호출부 배선은 유지. + * + * @param onRetry «다시 시도» 클릭. Paging 목록은 보통 `LazyPagingItems.retry` 를 전달한다. + */ +@Composable +public fun ListRefreshErrorBanner( + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(AfternoteDesign.colors.gray2) + .padding(start = 16.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.core_ui_list_refresh_error), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray7, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onRetry) { + Text( + text = stringResource(R.string.core_ui_list_refresh_retry), + style = AfternoteDesign.typography.captionLargeB, + color = AfternoteDesign.colors.gray9, + ) + } + } +} diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/mvi/MviContract.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/mvi/MviContract.kt new file mode 100644 index 0000000000..1e1e3619f5 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/mvi/MviContract.kt @@ -0,0 +1,42 @@ +package com.afternote.core.ui.mvi + +/** + * View → ViewModel. 사용자가 **하려는 것**이다 — `SubmitDescription` · `SelectFilter` · + * `ConsumeError`. + * + * 화면에서 ViewModel 로 들어가는 진입점은 [MviViewModel.onIntent] 하나다. + * Intent 하나가 [ReducerEvent] 를 0개에서 N개까지 낳는다 — 가드에서 거절한 중복 요청은 0개, + * 로드 Intent 는 `Loading` → `Loaded` 2개다. 이동만 하는 클릭은 화면 콜백으로 전달하고, + * 목적지와 백스택 조작은 피처 entry·host 가 담당한다 (#1810). + * + * ## 왜 Effect 타입이 없는가 (#1800 판정) + * + * MVI 가 요구하는 것은 「일회성 효과를 상태 전이에서 분리한다」 까지고, 그것을 `Channel` 로 + * 나르는 것은 구현 선택이다. 이 저장소의 정본 규약은 #228 — ViewModel 이벤트를 `Channel` 로 + * 쏘지 않고 [UiState] 의 nullable 신호로 흡수한다. producer(ViewModel)가 consumer(UI)보다 + * 오래 사는 순간 `Channel` 은 전달을 보장하지 못하기 때문이다(구성 변경 · 프로세스 사망 · + * 분할 화면 — [공식 가이드](https://developer.android.com/topic/architecture/ui-layer/events#handle-viewmodel-events)). + * + * 그래서 베이스는 `Effect` 타입 파라미터를 두지 않고 3타입이다. 일회성 신호는 [UiState] 안의 + * nullable 필드로 두고 소비는 `Intent.ConsumeXxx` 로 들어온다 — 소비를 화면마다 + * `onXxxConsumed()` public fun 으로 노출하면 배선을 빠뜨려도 컴파일이 통과한다. + * 소비 관용구는 [ObserveSignal] 이 정본이다. + */ +interface MviIntent + +/** + * ViewModel → View. 화면이 그리는 데 필요한 전부를 담는 단일 상태다. + * + * 일회성 신호도 nullable 필드로 여기에 함께 담는다(#228). 별도 스트림을 두지 않는다. + */ +interface UiState + +/** + * [MviViewModel.reduce] 의 입력. 상태가 **겪은 것**이다 — `Loading` · `Loaded` · + * `DraftFailed`. + * + * 화면은 이 타입을 만들지 않는다. [MviIntent] 를 받은 ViewModel 만 + * [MviViewModel.dispatch] 로 낸다. 이 분리가 없으면 비동기 중간 상태를 표현할 곳이 없어 + * 다시 `_uiState.value = ...` 로 돌아간다. + */ +interface ReducerEvent diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/mvi/MviViewModel.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/mvi/MviViewModel.kt new file mode 100644 index 0000000000..cdf2302d53 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/mvi/MviViewModel.kt @@ -0,0 +1,83 @@ +package com.afternote.core.ui.mvi + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * MVI 베이스 (#1800). 화면 ViewModel 은 이것을 상속해 진입점 하나([onIntent])와 + * 순수 전이 하나([reduce])만 갖는다. + * + * ```kotlin + * @HiltViewModel + * class FindIdViewModel @Inject constructor(...) : + * MviViewModel(FindIdUiState()) { + * + * override fun onIntent(intent: FindIdIntent) { + * when (intent) { + * is FindIdIntent.UpdateEmail -> dispatch(FindIdReducerEvent.EmailChanged(intent.value)) + * FindIdIntent.RequestCode -> requestVerificationCode() + * FindIdIntent.ConsumeError -> dispatch(FindIdReducerEvent.ErrorConsumed) + * } + * } + * + * override fun reduce(state: FindIdUiState, event: FindIdReducerEvent): FindIdUiState = + * when (event) { + * is FindIdReducerEvent.EmailChanged -> state.copy(email = event.value) + * FindIdReducerEvent.ErrorConsumed -> state.copy(errorMessage = null) + * } + * } + * ``` + * + * ## 왜 베이스가 필요한가 (develop 실측) + * + * 단일 `UiState` 노출은 이미 32개 화면에 정착했지만 그 아래가 갈려 있었다. `mutateForm` 처럼 + * 순수 리듀서에 가까운 곳과 코루틴 안에서 `_uiState.value = ...` 를 직접 쓰는 곳이 섞였고, + * 진입점을 하나로 모은 화면은 51개 중 1개뿐이었다. 상태 홀더를 베이스가 감추면 전이가 + * [reduce] 밖으로 샐 통로 자체가 없어진다 — konsist 규칙 A 가 이것을 강제한다(#1801). + * + * ## 상태를 바꾸는 길은 [dispatch] 하나다 + * + * [uiState] 의 뒷단은 `private` 이고 [currentState] 는 읽기 전용이라, 상속체가 상태를 바꿀 + * 방법은 [dispatch] → [reduce] 밖에 없다. 비동기 작업은 중간 상태를 [ReducerEvent] 로 낸다 + * (`Loading` 을 dispatch → suspend 호출 → `Loaded` 를 dispatch). + */ +abstract class MviViewModel( + initialState: S, +) : ViewModel() { + private val _uiState = MutableStateFlow(initialState) + + val uiState: StateFlow = _uiState.asStateFlow() + + /** + * 지금 상태. 비동기 작업을 시작하기 전에 입력값·가드를 읽는 용도다. + * + * 읽은 값으로 다음 상태를 계산해 다시 쓰지 않는다 — 그 계산은 [reduce] 의 몫이다. + */ + protected val currentState: S get() = _uiState.value + + /** + * 화면이 부르는 **유일한** 진입점. 소비 신호도 여기로 들어온다(`Intent.ConsumeXxx`). + * + * `when (intent)` 를 `else` 없이 전수 분기해, Intent 가 늘면 컴파일이 빠진 분기를 알린다. + */ + abstract fun onIntent(intent: I) + + /** + * 순수 전이. 같은 ([state], [event]) 에는 항상 같은 결과여야 한다. + * + * [MutableStateFlow.update] 는 경합 시 람다를 다시 부르므로, 부수효과를 여기 두면 그 + * 부수효과가 두 번 일어난다. 저장소 호출 · 로깅 · 계측은 [onIntent] 쪽에 둔다. + */ + protected abstract fun reduce( + state: S, + event: E, + ): S + + /** [event] 를 [reduce] 에 흘려 상태를 갱신한다. 상태를 바꾸는 유일한 수단이다. */ + protected fun dispatch(event: E) { + _uiState.update { reduce(it, event) } + } +} diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/mvi/ObserveSignal.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/mvi/ObserveSignal.kt new file mode 100644 index 0000000000..b433e14ff2 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/mvi/ObserveSignal.kt @@ -0,0 +1,90 @@ +package com.afternote.core.ui.mvi + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState + +/** + * [UiState] 안의 일회성 신호를 소비하는 정본 관용구 (#1800). + * + * [signal] 이 null 이 아니게 되면 [onSignal] 을 한 번 부르고, 곧바로 [consumed] Intent 를 + * 되쏘아 신호를 null 로 되돌린다. 화면마다 `LaunchedEffect` 를 다시 쓰면 규약이 또 갈린다 — + * 이 저장소의 선례 `ObserveDeleteResult` 를 일반화한 것이다. + * + * ```kotlin + * ObserveSignal( + * signal = state.errorMessage, + * consumed = LoginIntent.ConsumeError, + * onIntent = onIntent, + * ) { message -> showSnackbar(message) } + * ``` + * + * ## 왜 소비가 Intent 인가 + * + * 소비를 `onErrorConsumed()` 같은 public fun 으로 따로 노출하면 진입점이 화면 수만큼 늘고, + * 배선을 빠뜨려도 컴파일이 통과해 신호가 남은 채 다음 실패를 덮는다. [MviViewModel.onIntent] + * 하나로 모으면 소비도 같은 문을 지난다. + * + * ## 같은 신호가 연속 두 번 와도 두 번 소비된다 + * + * 소비가 [signal] 을 null 로 되돌리므로 `A → null → A` 로 키가 두 번 바뀐다. 값이 같아도 + * [LaunchedEffect] 가 다시 시작하는 이유다 — reset 없이 같은 값을 다시 쓰면 두 번째는 + * 조용히 묻힌다. + * + * ## [onSignal] 안에서 suspend 를 직접 기다리지 않는다 + * + * 소비 직후의 상태 변화가 이 [LaunchedEffect] 를 재시작시키며 이전 코루틴을 취소한다. + * 스낵바처럼 시간이 걸리는 표출은 `rememberCoroutineScope()` 에 launch 해 effect 수명과 + * 분리한다(`rememberDeleteFailedHandler` 전례). + */ +@Composable +fun ObserveSignal( + signal: T?, + consumed: I, + onIntent: (I) -> Unit, + onSignal: (T) -> Unit, +) { + // 키는 signal 하나뿐이라, 콜백만 바뀐 재구성에서는 effect 가 다시 시작하지 않는다. + // 그대로 캡처하면 옛 람다가 남으므로 최신 값을 읽어 쓴다. + val currentOnIntent by rememberUpdatedState(onIntent) + val currentOnSignal by rememberUpdatedState(onSignal) + + LaunchedEffect(signal) { + if (signal == null) return@LaunchedEffect + currentOnSignal(signal) + currentOnIntent(consumed) + } +} + +/** + * `Boolean` 플래그로 든 일회성 신호를 소비한다 (#1802 파일럿에서 확정). + * + * [ObserveSignal] 과 같은 계약이고, 신호가 `nullable 값` 이 아니라 「올라갔다/내려갔다」 로만 + * 표현되는 경우다 — `isLoggedIn` · `shouldNavigateToXxx` 처럼 실어 나를 값이 없는 신호가 + * 이 저장소에 이미 여러 개 있다. + * + * ```kotlin + * ObserveFlag( + * raised = state.isLoggedIn, + * consumed = LoginIntent.ConsumeLoggedIn, + * onIntent = onIntent, + * onRaised = onLoginSuccess, + * ) + * ``` + */ +@Composable +fun ObserveFlag( + raised: Boolean, + consumed: I, + onIntent: (I) -> Unit, + onRaised: () -> Unit, +) { + // Unit? 로 접어 ObserveSignal 하나로 모은다 — 두 벌로 두면 소비 규약이 갈린다. + ObserveSignal( + signal = Unit.takeIf { raised }, + consumed = consumed, + onIntent = onIntent, + onSignal = { onRaised() }, + ) +} diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/FeatureNavDisplay.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/FeatureNavDisplay.kt new file mode 100644 index 0000000000..3d9bf8fe67 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/FeatureNavDisplay.kt @@ -0,0 +1,92 @@ +package com.afternote.core.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay + +/** + * 피처 하나가 소유하는 로컬 Navigation 3 스택의 표준 표시부. + * + * 이 저장소의 로컬 스택은 전부 이 함수를 거친다. 데코레이터 목록과 바닥 back 처리를 한 곳에 + * 모아 두면, 아래 두 함정을 피처마다 다시 밟지 않는다. entry 바탕을 칠하는 것(#2145)도 여기서 + * 한 번에 받는다([rememberStandardNavEntryDecorators]). + * + * 1. `NavDisplay.onBack` 은 `() -> Unit` 이다. 문서·블로그에 도는 `{ count -> … }` (Int 인자) + * 형태는 이 버전에서 컴파일되지 않는다 (#959 실측 함정 1). + * 2. `entryDecorators` 를 넘기면 **기본 목록을 통째로 대체한다**. 기본값은 + * [rememberSaveableStateHolderNavEntryDecorator] 하나뿐이라, entry 범위 ViewModel 을 쓰려고 + * [rememberViewModelStoreNavEntryDecorator] 만 넣으면 `rememberSaveable` 상태 보존이 조용히 + * 깨진다 (#959 실측 함정 2). 둘 다 넣는다. + * + * 바닥에서 스택을 비우지 않는 것은 Nav3 가 빈 백스택을 그릴 수 없기 때문이다. 다만 **그 자리를 + * 지키는 것이 아래 `onBack` 의 `else` 갈래는 아니다.** + * + * `NavDisplay` 는 `isBackEnabled = scene.previousEntries.isNotEmpty()` 로 back 핸들러를 켜는데 + * (`NavDisplay.kt:558`), `SinglePaneScene.previousEntries` 는 `entries.dropLast(1)` 이라 + * (`SinglePaneScene.kt:65`) **스택 크기가 1 이면 핸들러 자체가 꺼진다.** 그래서 제스처·시스템 back 은 + * 이 표시부를 그냥 지나쳐 상위(부모 `NavHost`·액티비티)로 흘러간다. + * + * `else -> boundary.exit()` 에 실제로 도달하는 것은 **화면 안 back 버튼**(`popOrExit`)뿐이다. + * `FeatureNavDisplayTest` 의 「back 은 스택만 줄이고 바닥에서는 이 표시부를 지나쳐 위로 흐른다」가 + * 이 갈림을 잠근다. + * + * #1996: 온보딩의 named argument 호환을 위해 `boundary` 이름을 소비처 전환까지 유지한다. + */ +@Composable +public fun FeatureNavDisplay( + backStack: NavBackStack, + boundary: FeatureNavigationCallbacks, + modifier: Modifier = Modifier, + entryProvider: (NavKey) -> NavEntry, +) { + val currentNavigationCallbacks by rememberUpdatedState(boundary) + val isAtRoot = backStack.size <= 1 + + LaunchedEffect(isAtRoot) { currentNavigationCallbacks.onAtRootChanged(isAtRoot) } + // 탭 이탈로 host 가 컴포지션에서 빠질 때 깊이 신호를 되돌린다. 안 되돌리면 다른 탭의 + // 바텀바 판정이 이 피처의 마지막 깊이에 오염된다. + DisposableEffect(Unit) { + onDispose { currentNavigationCallbacks.onAtRootChanged(true) } + } + + NavDisplay( + backStack = backStack, + modifier = modifier, + onBack = { + if (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) + } else { + currentNavigationCallbacks.exit() + } + }, + entryDecorators = rememberStandardNavEntryDecorators(), + entryProvider = entryProvider, + ) +} + +/** + * 로컬 스택의 표준 entry decorator 목록. + * + * [FeatureNavDisplay] 를 쓸 수 없어 `NavDisplay` 를 직접 부르는 경우(다른 scene 전략이 필요한 + * 경우 등)에도 같은 목록을 쓰도록 공개해 둔다. + * + * 세 번째 [NavDestinationSurfaceDecorator] 는 entry 마다 불투명한 바탕을 깐다. 빠지면 predictive back + * 진행 중 줄어든 앞 화면 사이로 뒤 화면 글자가 비친다(#2145). 색은 루트 셸과 같은 [NavDestinationBackground] 를 쓴다. + */ +@Composable +public fun rememberStandardNavEntryDecorators(): List> = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + NavDestinationSurfaceDecorator, + ) diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/FeatureNavigationCallbacks.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/FeatureNavigationCallbacks.kt new file mode 100644 index 0000000000..89710cafcf --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/FeatureNavigationCallbacks.kt @@ -0,0 +1,31 @@ +package com.afternote.core.ui.navigation + +import androidx.compose.runtime.Stable + +/** + * 피처의 로컬 Navigation 3 스택이 부모 앱 셸에 요청·알림을 전달하는 콜백. + * + * 로컬 스택은 제 화면 사이의 push/pop 을 스스로 처리하고, 스스로 답할 수 없는 두 가지만 셸에 + * 돌려준다 — 스택 바닥에서의 back([exit])과 바텀바 표시 판정에 필요한 깊이([onAtRootChanged]). + * 그 밖의 이동(다른 소관 그래프로 가기)은 피처마다 달라서 각 host 가 제 콜백으로 받는다. + * + * 루트가 Nav2 인 동안 [exit] 는 루트 `NavController` 의 pop 이고, 루트가 `NavDisplay` 로 바뀌면 + * 루트 백스택의 pop 이 된다 — 구현만 갈리고 계약은 그대로다 (#1702 가 소비한다). + */ +@Stable +public interface FeatureNavigationCallbacks { + /** 로컬 스택 바닥에서 back 이 눌렸다. 셸이 이 피처를 백스택에서 내린다. */ + public fun exit() + + /** + * 현재 화면이 로컬 백스택의 시작 화면인지 여부가 변경됐다. + * + * 셸의 바텀바 판정은 Nav2 destination 만 보므로 로컬 스택 깊이를 모른다. 깊이를 아는 쪽이 + * 셸에 올려 판정에 합성한다. 피처를 떠날 때는 `true` 로 복원해 다른 탭 판정을 오염시키지 + * 않는다 — [FeatureNavDisplay] 가 대신 해 준다. + */ + public fun onAtRootChanged(isAtRoot: Boolean) {} +} + +/** 바텀바가 없어 셸로 돌아갈 길만 필요한 그래프용 [FeatureNavigationCallbacks]. */ +public fun FeatureNavigationCallbacks(onExit: () -> Unit): FeatureNavigationCallbacks = FeatureStackBoundary(onExit) diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/FeatureStackBoundary.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/FeatureStackBoundary.kt new file mode 100644 index 0000000000..28933cc551 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/FeatureStackBoundary.kt @@ -0,0 +1,15 @@ +package com.afternote.core.ui.navigation + +/** + * [FeatureNavigationCallbacks]의 이전 이름. + * + * 온보딩 담당자가 소비처를 전환할 때까지 소스 호환을 유지한다 (#1996). + * 기존 이름의 소스 사용처가 모두 사라진 뒤 이 별칭과 아래 팩토리를 제거한다. + */ +public typealias FeatureStackBoundary = FeatureNavigationCallbacks + +/** 기존 팩토리 호출의 소스 호환을 유지한다. 새 코드는 [FeatureNavigationCallbacks]를 사용한다. */ +public fun FeatureStackBoundary(onExit: () -> Unit): FeatureStackBoundary = + object : FeatureStackBoundary { + override fun exit() = onExit() + } diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/NavBackStackOps.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/NavBackStackOps.kt new file mode 100644 index 0000000000..c317bdf219 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/NavBackStackOps.kt @@ -0,0 +1,62 @@ +package com.afternote.core.ui.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey + +/** + * 한 칸 내린다 — **바닥이면 스택을 비우지 않고** [navigationCallbacks] 에 넘긴다. + * + * Nav3 는 빈 백스택을 그릴 수 없고, 바닥에서의 back 은 이 피처를 떠난다는 뜻이라 셸이 판단할 + * 몫이다. 이 파일의 나머지 확장도 같은 자리다 — Nav2 가 `popUpTo` 옵션으로 적던 «결과 상태» 를 + * 로컬 스택에선 직접 만들어야 해서, 그 모양들을 한 곳에 모았다 (#1698). + */ +public fun NavBackStack.popOrExit(navigationCallbacks: FeatureNavigationCallbacks) { + if (size > 1) { + removeAt(lastIndex) + } else { + navigationCallbacks.exit() + } +} + +/** + * 스택을 [key] 하나로 수렴시킨다 — Nav2 의 `popUpTo(inclusive = true)` + `navigate` 자리. + * + * 인증·검증처럼 «되돌아가면 안 되는» 단계를 지났을 때 쓴다. + */ +public fun NavBackStack.replaceAllWith(key: NavKey) { + clear() + add(key) +} + +/** + * [key] 를 남기고 그 위를 모두 걷어낸다 — Nav2 의 `popUpTo(inclusive = false)` + `launchSingleTop` 자리. + * + * 스택에 [key] 가 없으면(외부에서 곧장 들어온 진입 등) **[replaceAllWith] 로 그 키 하나만 남긴다.** + * + * **이건 Nav2 와 결과가 다르다** — Nav2 는 `popUpTo` 대상이 없으면 그것을 무시하고 위에 push 만 해서 + * 기존 스택이 남는다. + * + * | | 결과 스택 | + * |---|---| + * | Nav2 `popUpTo(없는 키) + navigate` | `[… 기존 …, key]` | + * | 이 구현 | `[key]` | + * + * 「목록이 없는 진입에서도 목록 하나만 남긴다」를 의도한 것이다 — 알림·딥링크로 상세에 곧장 들어온 + * 뒤 목록으로 올라가면 그 목록이 바닥이어야 back 이 흐름을 빠져나간다. 다만 **Nav2 동등성이 아니라 + * 의도적인 이탈**이므로, 이 프리미티브를 새로 쓸 때는 그 차이를 전제로 골라야 한다. + */ +public fun NavBackStack.popUpTo(key: NavKey) { + val index = indexOf(key) + if (index < 0) { + replaceAllWith(key) + return + } + while (lastIndex > index) { + removeAt(lastIndex) + } +} + +/** 같은 화면을 연달아 쌓지 않는 push — Nav2 의 `launchSingleTop` 자리. */ +public fun NavBackStack.pushSingleTop(key: NavKey) { + if (lastOrNull() != key) add(key) +} diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/NavDestinationSurface.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/NavDestinationSurface.kt new file mode 100644 index 0000000000..f466ecd455 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/NavDestinationSurface.kt @@ -0,0 +1,46 @@ +package com.afternote.core.ui.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.NavKey +import com.afternote.core.ui.theme.AfternoteDesign + +/** + * 네비게이션 목적지 하나를 불투명하게 칠하는 바탕 (#2145). + * + * predictive back 진행 중 앞 화면은 [PredictiveBackPopExit] 로 줄어들어 뒤 화면 위에 뜬다(#1869). + * 그런데 화면 대부분이 `Scaffold(containerColor = Color.Transparent)` 로 그려 루트 셸의 바탕을 비쳐 + * 보이게 한다. 멈춰 있을 때는 그 아래가 루트 바탕 하나라 티가 안 나지만, 전환 중에는 그 아래에 뒤 + * 화면이 있어서 **축소된 앞 화면 사이로 뒤 화면 글자가 그대로 비친다.** + * + * 화면마다 `containerColor` 를 고치지 않고 목적지 자리에서 한 번 칠한다. 색은 루트 셸과 같은 + * [NavDestinationBackground] 라 멈춰 있을 때의 화면은 달라지지 않는다. + * + * [rememberStandardNavEntryDecorators] 가 Nav3 로컬 스택의 모든 entry 에 씌운다. + */ +@Composable +private fun NavDestinationSurface(content: @Composable () -> Unit) { + Box(modifier = Modifier.fillMaxSize().background(NavDestinationBackground)) { + content() + } +} + +/** + * 루트 셸의 바탕과 [NavDestinationSurface] 가 함께 쓰는 색. + * + * 둘이 갈리면 투명하게 그리는 화면의 멈춘 모습이 바뀌므로 한 곳에서 받는다. + */ +public val NavDestinationBackground: Color + @Composable + @ReadOnlyComposable + get() = AfternoteDesign.colors.gray1 + +/** 로컬 스택의 모든 entry 를 [NavDestinationSurface] 로 감싼다. 상태가 없어 하나를 나눠 쓴다. */ +internal val NavDestinationSurfaceDecorator: NavEntryDecorator = + NavEntryDecorator(decorate = { entry -> NavDestinationSurface { entry.Content() } }) diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/PredictiveBackTransitions.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/PredictiveBackTransitions.kt new file mode 100644 index 0000000000..1adf08d5d4 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/PredictiveBackTransitions.kt @@ -0,0 +1,44 @@ +package com.afternote.core.ui.navigation + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleOut + +/** + * predictive back 진행 중 두 화면을 그리는 전환 한 쌍 (#1869). + * + * 이 저장소는 네비게이션 엔진을 둘 쓴다 — 루트는 Navigation 2 `NavHost`, 각 피처의 로컬 스택은 + * Navigation 3 `NavDisplay` 다(#1698). **두 엔진의 기본값이 서로 다르다.** + * + * - Nav3 `NavDisplay` 는 `predictivePopTransitionSpec` 을 안 넘기면 + * `defaultPredictivePopTransitionSpec()` 을 쓴다 — 아래 두 값과 같은 모양이다 + * (navigation3-ui 1.1.6 `NavDisplay.android.kt:50`). 그래서 로컬 스택은 이미 앞 화면이 줄어든다. + * - Nav2 `NavHost` 의 `popEnterTransition`·`popExitTransition` 기본값은 `enterTransition`· + * `exitTransition` 을 그대로 받아 `fadeIn`/`fadeOut(tween(700))` 이다 + * (navigation-compose 2.9.8 `NavHost.kt:132`). 앞 화면이 줄지 않고 제자리에서 흐려지기만 해, + * 진행 중에 두 화면이 같은 크기로 겹쳐 보인다 — #1869 가 실기기에서 잡은 증상이다. + * + * 그래서 **새 값을 정하지 않고 Nav3 기본값을 그대로 복제해** 두 엔진의 눈에 보이는 동작을 맞춘다. + * 루트가 `NavDisplay` 로 바뀌면(#1702) 이 두 상수를 그대로 + * `predictivePopTransitionSpec = { ContentTransform(PredictiveBackPopEnter, PredictiveBackPopExit) }` + * 에 넘겨, 이관 전후로 화면이 달라지지 않게 한다. + * + * ### 모서리 라운딩은 여기서 못 준다 + * `ExitTransition` 은 fade·scale·slide·shrink 만 표현한다 — clip 이나 shape 는 담을 수 없다. + * Nav3 기본값도 `scaleOut` 하나뿐이라 라운딩은 어느 쪽에도 없다. 축소만으로 겹침이 풀린다는 것이 + * #1869 의 완료 조건이고, 라운딩이 따로 필요하면 별도 축으로 받는다. + */ +public val PredictiveBackPopEnter: EnterTransition = + fadeIn( + // material3 motionScheme.defaultEffectsSpec() 을 반영한 값 그대로다. + animationSpec = spring(dampingRatio = 1.0f, stiffness = 1600.0f), + ) + +/** + * 뒤로 밀려나는 앞 화면. `targetScale = 0.7f` 로 줄어들어 뒤 화면 위에 떠 보인다. + * + * 배경은 [PredictiveBackPopEnter] 의 KDoc 에 함께 적었다. + */ +public val PredictiveBackPopExit: ExitTransition = scaleOut(targetScale = 0.7f) diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/popup/AfternoteActionMenu.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/popup/AfternoteActionMenu.kt new file mode 100644 index 0000000000..7397488029 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/popup/AfternoteActionMenu.kt @@ -0,0 +1,152 @@ +package com.afternote.core.ui.popup + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.afternote.core.ui.R +import com.afternote.core.ui.theme.AfternoteDesign +import androidx.compose.ui.window.Popup as ComposePopup + +/** 액션 메뉴 한 줄. [label] 은 이미 해석된 문구다 — 호출부가 리소스를 고를 수 있게 String 으로 받는다. */ +data class ActionMenuItem( + val label: String, + val onClick: () -> Unit, +) + +/** 시안 확정값 (#643): 흰 카드 · 8dp 라운드 · 8dp 그림자 · 항목 패딩 16dp 전방향. */ +private val ActionMenuShape = RoundedCornerShape(8.dp) +private val ActionMenuShadowElevation = 8.dp +private val ActionMenuItemPadding = 16.dp +private const val ACTION_MENU_SHADOW_ALPHA = 0.15f + +/** + * 「더보기(⋯)」 앵커에서 펼쳐지는 공용 액션 팝업 메뉴 (#643). + * + * afternote·mindrecord·timeletter 에 4벌로 흩어져 있던 «수정/삭제» 팝업의 정본이다. + * 시각 계약은 이 컴포저블이 쥐고, **등장 위치는 [popupPositionProvider] 로만** 결정한다 — + * 기본값은 창 우측 15dp · 앵커 아래 12dp ([rememberFixedRightPopupPositionProvider]). + * + * Material3 [androidx.compose.material3.DropdownMenu] 는 그림자·항목 최소 높이(48dp)·컨테이너 + * 색이 가이드라인 값으로 강제돼 있어 시안(흰 배경·8dp 라운드)을 맞추려면 우회가 지저분해진다. + * 그래서 `androidx.compose.ui.window.Popup` 위에 직접 쌓는다. 대신 등장 페이드·스케일 + * 애니메이션은 없다. + * + * **dismiss 계약**: 항목을 누르면 [onDismissRequest] 를 **먼저** 부르고 그다음 + * [ActionMenuItem.onClick] 을 부른다. 항목 콜백이 다이얼로그를 띄우는 흔한 경우에 + * 메뉴가 뒤에 남는 것을 호출부마다 막지 않아도 되게 한다. + * + * @param items 위에서부터 그릴 순서 그대로. **비면 호출부 오류다** (`require`) — + * 「항목을 숨긴다」는 판단은 호출부가 리스트에서 빼는 것으로 표현하되, 열 항목이 하나도 + * 없는 메뉴를 여는 호출은 있어서는 안 된다. 너비는 가장 긴 항목(`IntrinsicSize.Max`)에 맞춘다. + */ +@Composable +fun AfternoteActionMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + items: List, + modifier: Modifier = Modifier, + popupPositionProvider: PopupPositionProvider = rememberFixedRightPopupPositionProvider(), +) { + require(items.isNotEmpty()) { "AfternoteActionMenu 는 항목이 하나 이상 있어야 한다" } + if (!expanded) return + + ComposePopup( + popupPositionProvider = popupPositionProvider, + onDismissRequest = onDismissRequest, + properties = PopupProperties(focusable = true), + ) { + AfternoteActionMenuCard( + items = + items.map { item -> + ActionMenuItem(label = item.label) { + onDismissRequest() + item.onClick() + } + }, + modifier = modifier, + ) + } +} + +/** 팝업 내부 카드의 그림자·둥글기·배경·항목 배치를 구현한다. */ +@Composable +private fun AfternoteActionMenuCard( + items: List, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .shadow( + elevation = ActionMenuShadowElevation, + shape = ActionMenuShape, + clip = true, + spotColor = AfternoteDesign.colors.black.copy(alpha = ACTION_MENU_SHADOW_ALPHA), + ).background(AfternoteDesign.colors.white) + .width(IntrinsicSize.Max), + ) { + items.forEach { item -> + ActionMenuRow(item = item, modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun ActionMenuRow( + item: ActionMenuItem, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .clickable(role = Role.Button, onClick = item.onClick) + .padding(ActionMenuItemPadding), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = item.label, + style = AfternoteDesign.typography.bodyBase, + color = AfternoteDesign.colors.gray9, + ) + } +} + +/** + * 「수정하기 → 삭제하기」 표준 쌍 (#643 확정 순서). + * + * 문구가 core 리소스라 호출 모듈이 `core.ui.R` 을 직접 import 하지 않아도 된다. + * + * @param onEditClick **null 이면 수정 항목 자체를 그리지 않는다** — 편집이 없는 메뉴(수신자 행)와 + * 「핸들러를 안 넘겨 죽은 버튼이 그려지는」 미배선을 타입으로 구분하기 위해 플래그 + no-op + * 디폴트 대신 nullable 핸들러 하나로 모델링한다 (#1388). + */ +@Composable +fun editDeleteActionMenuItems( + onEditClick: (() -> Unit)?, + onDeleteClick: () -> Unit, +): List { + val editLabel = stringResource(R.string.core_ui_action_menu_edit) + val deleteLabel = stringResource(R.string.core_ui_action_menu_delete) + return buildList { + if (onEditClick != null) { + add(ActionMenuItem(label = editLabel, onClick = onEditClick)) + } + add(ActionMenuItem(label = deleteLabel, onClick = onDeleteClick)) + } +} diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/popup/ErrorPopup.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/popup/ErrorPopup.kt index a508c8530c..b8b3940eae 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/popup/ErrorPopup.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/popup/ErrorPopup.kt @@ -28,7 +28,8 @@ private const val ICON_CONTAINER_ALPHA = 0.12f /** * 오류 안내 팝업 — 아이콘 원 + 제목 + 본문 + 단일 액션. 시안의 오류 팝업 4종(네트워크 연결 - * 오류·서버 오류·업로드 실패·접근 권한 없음, `3628:23827`)이 공유하는 골격이다. + * 오류·서버 오류·업로드 실패·접근 권한 없음, `3628:23827`)이 공유하는 골격이다. 접근 권한 없음(403)은 + * 아직 생산자가 없어 세우지 않았다. * * [Popup] 과 별개인 이유 — 그쪽은 "메시지 + 버튼" 골격이라 아이콘·제목 슬롯이 없고, * [PopupType] 은 enum 이라 타입별 필수 값을 담을 수 없다. @@ -76,9 +77,59 @@ fun NetworkErrorPopup( ) } -/** Dialog 래퍼 없이 카드 본체만 렌더링합니다. 프리뷰 및 테스트 전용. */ +/** + * 서버가 응답했지만 요청을 처리하지 못한 실패 안내(시안 `3628:23785`). 5xx 처럼 사용자가 고칠 수 + * 없는 장애가 대상이라 액션은 재시도 하나다 — 서버가 준 거절 문구는 이쪽이 아니라 호출처가 + * 자기 문구로 안내한다. + */ +@Composable +fun ServerErrorPopup( + onRetry: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + AfternoteErrorPopup( + iconRes = R.drawable.core_ui_ic_server, + title = stringResource(R.string.core_ui_server_error_title), + description = stringResource(R.string.core_ui_server_error_description), + buttonText = stringResource(R.string.core_ui_server_error_retry), + onButtonClick = onRetry, + onDismiss = onDismiss, + modifier = modifier, + ) +} + +/** + * 파일 업로드 실패 안내(시안 `3628:23795`). [ServerErrorPopup] 과 갈라 둔 이유 — 사용자가 방금 고른 + * 파일이 올라가지 못했다는 사실이 "서버에 문제가 발생했다" 보다 구체적이고, 재시도의 대상도 + * 화면 전체가 아니라 그 첨부 하나다. + */ +@Composable +fun UploadErrorPopup( + onRetry: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + AfternoteErrorPopup( + iconRes = R.drawable.core_ui_ic_upload_cloud, + title = stringResource(R.string.core_ui_upload_error_title), + description = stringResource(R.string.core_ui_upload_error_description), + buttonText = stringResource(R.string.core_ui_upload_error_retry), + onButtonClick = onRetry, + onDismiss = onDismiss, + modifier = modifier, + ) +} + +/** + * Dialog 래퍼 없이 카드 본체만 렌더링하는 [AfternoteErrorPopup] 내부 구현. + * + * 파일 밖으로 열지 않는다 — 공개 계약은 [AfternoteErrorPopup] 과 그 위에 선 + * [NetworkErrorPopup]·[ServerErrorPopup]·[UploadErrorPopup] 이고, 시각 회귀 baseline 도 + * 그 진입점을 그려서 잡는다 (#1672). + */ @Composable -internal fun AfternoteErrorPopupContent( +private fun AfternoteErrorPopupContent( @DrawableRes iconRes: Int, title: String, description: String, diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/popup/Popup.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/popup/Popup.kt index 59123c3af4..d7eb0b625b 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/popup/Popup.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/popup/Popup.kt @@ -70,10 +70,13 @@ fun Popup( } /** - * Dialog 래퍼 없이 카드 본체만 렌더링합니다. 프리뷰 및 테스트 전용. + * Dialog 래퍼 없이 카드 본체만 렌더링하는 [Popup] 내부 구현. + * + * 파일 밖으로 열지 않는다 — 팝업의 공개 계약은 [Popup] 하나이고, 문구·버튼 동작 검증도 + * [Popup] 을 그려서 한다 (#1672). */ @Composable -internal fun PopupContent( +private fun PopupContent( type: PopupType, message: String, onConfirm: () -> Unit, diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverListLoadFailure.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverListLoadFailure.kt new file mode 100644 index 0000000000..2093a002e9 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverListLoadFailure.kt @@ -0,0 +1,48 @@ +package com.afternote.core.ui.receiver + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.R +import com.afternote.core.ui.button.AfternoteButton +import com.afternote.core.ui.theme.AfternoteDesign + +/** + * 수신자 목록 조회 실패 안내 (#2045). 보여 줄 행이 없을 때 목록 자리를 통째로 대체한다. + * + * 애프터노트 에디터의 수신자 선택과 설정 수신자 목록(관리·사후 전달 조건 선택)이 같은 문구·구성을 + * 각자 갖고 있던 것을 올렸다. [ReceiverSelectScreen] 의 `listReplacement` 슬롯에 그대로 끼운다. + * + * @param onRetryClick «다시 시도» 클릭. 목록을 다시 조회하는 일은 소비 기능이 맡는다. + */ +@Composable +public fun ReceiverListLoadFailure( + onRetryClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.core_ui_receiver_list_load_failed), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray8, + textAlign = TextAlign.Center, + ) + AfternoteButton( + text = stringResource(R.string.core_ui_receiver_list_retry), + onClick = onRetryClick, + modifier = Modifier.padding(top = 16.dp, start = 20.dp, end = 20.dp), + ) + } +} diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverProfileRow.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverProfileRow.kt new file mode 100644 index 0000000000..b1a106267f --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverProfileRow.kt @@ -0,0 +1,57 @@ +package com.afternote.core.ui.receiver + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.ProfileImage +import com.afternote.core.ui.theme.AfternoteDesign + +/** + * 수신자 한 명을 프로필 이미지 + 이름 + 관계로 그리는 행 뼈대. + * + * [trailing] 만 소비자가 채운다 — 선택 화면([ReceiverSelectScreen])은 체크박스를, + * 설정 관리 화면(feature:setting `ReceiverManageScreen`, #631)은 이동 화살표를 넣는다. + * 행 전체가 [onClick] 을 갖는 클릭 영역이다. + */ +@Composable +fun ReceiverProfileRow( + name: String, + relation: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + trailing: @Composable () -> Unit = {}, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ProfileImage(size = 50.dp) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = AfternoteDesign.typography.captionLargeB, + ) + Spacer(modifier = Modifier.padding(top = 5.dp)) + Text( + text = relation, + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray8, + ) + } + trailing() + } +} diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverSelectScreen.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverSelectScreen.kt index 2065eb7a03..b3177b165f 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverSelectScreen.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverSelectScreen.kt @@ -1,65 +1,103 @@ package com.afternote.core.ui.receiver -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import com.afternote.core.common.util.KoreanConsonantUtil import com.afternote.core.ui.AfternoteTextField import com.afternote.core.ui.KoreanConsonantIndex -import com.afternote.core.ui.ProfileImage import com.afternote.core.ui.R import com.afternote.core.ui.TextFieldType import com.afternote.core.ui.button.AfternoteButton import com.afternote.core.ui.button.AfternoteButtonType import com.afternote.core.ui.button.AfternoteCircularCheckbox import com.afternote.core.ui.button.CheckboxState -import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import kotlinx.coroutines.launch /** - * 수신자 선택 공용 화면 (#791, 시안 3631:24820). + * 수신자 선택 공용 화면 (#791, 시안 3631:24820) — 복수 선택. * - * 검색 필드 + 초성 인덱스(ㄱ~ㅎ) + 단일 선택 목록 + 하단 완료 버튼 조립. + * 검색 필드 + 초성 인덱스(ㄱ~ㅎ) + 선택 목록 + 하단 완료 버튼 조립. * 데이터 조회·ViewModel·내비게이션은 소비 기능이 소유하고, 이 화면은 - * 목록·현재 선택·콜백을 받는다. 선택/해제 규칙(같은 항목 재탭 시 해제 등)은 + * 목록·현재 선택·콜백을 받는다. 선택/해제 규칙(재탭 시 해제 등)은 * 소비자가 [onReceiverToggle] 에서 결정한다. * + * [selectedReceiverIds] 가 비면 «아무도 선택하지 않음» 이다 — 완료 버튼이 비활성이고 + * [onConfirmClick] 도 나가지 않는다. null 로 «없음» 을 표현하지 않는다 (#1426). + * + * 단일 선택 소비자(설정 수신자 목록)는 아래의 `selectedReceiverId` 오버로드를 그대로 쓴다 — + * 복수화(#1426)는 새 오버로드를 더한 것이지 기존 계약을 바꾼 게 아니다. + * * 문구 3종은 시안(3631:24820) 기준 공용 기본값을 쓴다 — 작성 플로우(애프터노트·타임레터· - * 마음의 기록)는 모두 같은 문구라 그대로 두면 된다. 설정만 "수신자 목록" 으로 [title] 을 - * 덮는데, 그 화면은 #631 로 선택이 아닌 관리 화면이 되면서 이 컴포넌트 소비를 그만둔다. + * 마음의 기록)는 모두 같은 문구라 그대로 두면 된다. 설정은 "수신자 목록" 으로 [title] 을 + * 덮는데, #631 이후로는 사후 전달 조건 진입 시의 선택 플로우에서만 이 컴포넌트를 소비한다. * * @param listReplacement 검색 필드 아래 목록 영역을 통째로 대체할 상태 화면 — * 로딩·조회 실패·빈 목록처럼 소비 기능마다 다른 상태를 끼운다. null 이면 목록을 그린다. */ @Composable +fun ReceiverSelectScreen( + receivers: List, + selectedReceiverIds: List, + onReceiverToggle: (Long) -> Unit, + onBackClick: () -> Unit, + onConfirmClick: (List) -> Unit, + modifier: Modifier = Modifier, + title: String = stringResource(R.string.core_ui_receiver_select_title), + searchPlaceholder: String = stringResource(R.string.core_ui_receiver_select_search_placeholder), + confirmText: String = stringResource(R.string.core_ui_receiver_select_confirm), + listReplacement: (@Composable () -> Unit)? = null, +) { + ReceiverSelectScaffold( + receivers = receivers, + selectedReceiverIds = selectedReceiverIds, + onReceiverToggle = onReceiverToggle, + onBackClick = onBackClick, + onConfirmClick = { onConfirmClick(selectedReceiverIds) }, + modifier = modifier, + title = title, + searchPlaceholder = searchPlaceholder, + confirmText = confirmText, + listReplacement = listReplacement, + ) +} + +/** + * 수신자 선택 공용 화면 — 단일 선택 오버로드. + * + * 설정 수신자 목록처럼 한 명만 돌려주는 소비 경로가 쓴다. 복수 오버로드에 [selectedReceiverId] + * 를 담아 위임할 뿐이라, 그림과 완료 버튼 활성 조건은 두 모드가 같은 구현을 공유한다. + */ +@Composable fun ReceiverSelectScreen( receivers: List, selectedReceiverId: Long?, @@ -71,6 +109,33 @@ fun ReceiverSelectScreen( searchPlaceholder: String = stringResource(R.string.core_ui_receiver_select_search_placeholder), confirmText: String = stringResource(R.string.core_ui_receiver_select_confirm), listReplacement: (@Composable () -> Unit)? = null, +) { + ReceiverSelectScaffold( + receivers = receivers, + selectedReceiverIds = listOfNotNull(selectedReceiverId), + onReceiverToggle = onReceiverToggle, + onBackClick = onBackClick, + onConfirmClick = { selectedReceiverId?.let(onConfirmClick) }, + modifier = modifier, + title = title, + searchPlaceholder = searchPlaceholder, + confirmText = confirmText, + listReplacement = listReplacement, + ) +} + +@Composable +private fun ReceiverSelectScaffold( + receivers: List, + selectedReceiverIds: List, + onReceiverToggle: (Long) -> Unit, + onBackClick: () -> Unit, + onConfirmClick: () -> Unit, + modifier: Modifier, + title: String, + searchPlaceholder: String, + confirmText: String, + listReplacement: (@Composable () -> Unit)?, ) { Scaffold( modifier = modifier, @@ -84,8 +149,13 @@ fun ReceiverSelectScreen( bottomBar = { AfternoteButton( text = confirmText, - onClick = { selectedReceiverId?.let(onConfirmClick) }, - type = if (selectedReceiverId != null) AfternoteButtonType.Default else AfternoteButtonType.Un, + onClick = onConfirmClick, + type = + if (selectedReceiverIds.isNotEmpty()) { + AfternoteButtonType.Default + } else { + AfternoteButtonType.Un + }, modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), ) }, @@ -112,7 +182,7 @@ fun ReceiverSelectScreen( ReceiverSelectList( receivers = receivers, searchQuery = searchState.text.toString(), - selectedReceiverId = selectedReceiverId, + selectedReceiverIds = selectedReceiverIds, onReceiverToggle = onReceiverToggle, ) } @@ -124,42 +194,61 @@ fun ReceiverSelectScreen( private fun ReceiverSelectList( receivers: List, searchQuery: String, - selectedReceiverId: Long?, + selectedReceiverIds: List, onReceiverToggle: (Long) -> Unit, ) { val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() - var selectedConsonant by remember { mutableStateOf(null) } - val groupedReceivers = + // 목록에 그릴 행을 초성과 함께 한 줄로 펴 둔다 — LazyColumn 도 인덱스 맵도 이 목록만 본다. + val receiverRows = remember(receivers, searchQuery) { val filtered = if (searchQuery.isBlank()) receivers else receivers.filter { it.name.contains(searchQuery) } - KoreanConsonantUtil.groupByInitialConsonant(filtered) { it.name } + KoreanConsonantUtil + .groupByInitialConsonant(filtered) { it.name } + .flatMap { (consonant, items) -> items.map { consonant to it } } } - // 섹션 헤더 없이 항목만 그리므로 스크롤 인덱스도 items.size 로만 누적한다. + // 점프 위치는 그룹 길이를 따로 더하지 않고 위에서 편 행 순서에서 그대로 읽는다 — + // 계산과 렌더가 갈라져 있으면 렌더 구조가 바뀔 때 인덱스만 남아 어긋난다 (#939). val consonantIndexMap = - remember(groupedReceivers) { - var index = 0 + remember(receiverRows) { buildMap { - groupedReceivers.forEach { (consonant, items) -> - put(consonant, index) - index += items.size + receiverRows.forEachIndexed { index, (consonant, _) -> + if (consonant !in this) put(consonant, index) } } } - LaunchedEffect(listState, consonantIndexMap) { - snapshotFlow { listState.firstVisibleItemIndex } - .collect { firstVisibleItemIndex -> - selectedConsonant = - consonantIndexMap.entries - .filter { it.value <= firstVisibleItemIndex } - .maxByOrNull { it.value } - ?.key + // A tap may stop before its section reaches the top when the list clamps at its end. + // Keep that explicit target until the user scrolls the list or changes its contents. + var tappedConsonant by remember(receiverRows) { mutableStateOf(null) } + val scrolledConsonant by remember(listState, consonantIndexMap) { + derivedStateOf { + if (!listState.canScrollForward && listState.canScrollBackward) { + consonantIndexMap.keys.lastOrNull() + } else { + consonantIndexMap.entries + .lastOrNull { it.value <= listState.firstVisibleItemIndex } + ?.key } + } } + val listScrollConnection = + remember(receiverRows) { + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset { + if (source == NestedScrollSource.UserInput && available.y != 0f) { + tappedConsonant = null + } + return Offset.Zero + } + } + } Row(modifier = Modifier.fillMaxSize()) { LazyColumn( @@ -167,16 +256,15 @@ private fun ReceiverSelectList( modifier = Modifier .weight(1f) + .nestedScroll(listScrollConnection) .padding(start = 20.dp), ) { - groupedReceivers.forEach { (_, items) -> - items(items, key = { it.id }) { receiver -> - ReceiverSelectRow( - receiver = receiver, - selected = receiver.id == selectedReceiverId, - onToggle = { onReceiverToggle(receiver.id) }, - ) - } + items(receiverRows, key = { (_, receiver) -> receiver.id }) { (_, receiver) -> + ReceiverSelectRow( + receiver = receiver, + isSelected = receiver.id in selectedReceiverIds, + onToggle = { onReceiverToggle(receiver.id) }, + ) } item { Spacer(modifier = Modifier.padding(14.dp)) } } @@ -188,9 +276,9 @@ private fun ReceiverSelectList( contentAlignment = Alignment.Center, ) { KoreanConsonantIndex( - selectedConsonant = selectedConsonant, + selectedConsonant = tappedConsonant ?: scrolledConsonant, onConsonantSelect = { consonant -> - selectedConsonant = consonant + tappedConsonant = consonant consonantIndexMap[consonant]?.let { index -> coroutineScope.launch { listState.scrollToItem(index) } } @@ -203,36 +291,23 @@ private fun ReceiverSelectList( @Composable private fun ReceiverSelectRow( receiver: ReceiverSelectItem, - selected: Boolean, + isSelected: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier, ) { - Row( - modifier = - modifier - .fillMaxWidth() - .clickable(onClick = onToggle) - .padding(top = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - ProfileImage(size = 50.dp) - Spacer(modifier = Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = receiver.name, - style = AfternoteDesign.typography.captionLargeB, - ) - Spacer(modifier = Modifier.padding(top = 5.dp)) - Text( - text = receiver.relation, - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray8, + ReceiverProfileRow( + name = receiver.name, + relation = receiver.relation, + onClick = onToggle, + // 체크 아이콘엔 contentDescription 이 없어 «선택됨» 을 읽을 다른 통로가 없다. + // 복수 선택에서는 어느 행이 선택돼 있는지가 화면의 핵심 정보라 행에 노출한다 (#1426). + modifier = modifier.semantics { selected = isSelected }, + trailing = { + AfternoteCircularCheckbox( + state = if (isSelected) CheckboxState.Default else CheckboxState.None, + onClick = onToggle, + size = 20.dp, ) - } - AfternoteCircularCheckbox( - state = if (selected) CheckboxState.Default else CheckboxState.None, - onClick = onToggle, - size = 20.dp, - ) - } + }, + ) } diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/scaffold/FlowStepScaffold.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/scaffold/FlowStepScaffold.kt index 8ee0ed4a5b..1f6c1b1d20 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/scaffold/FlowStepScaffold.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/scaffold/FlowStepScaffold.kt @@ -2,10 +2,10 @@ package com.afternote.core.ui.scaffold import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -28,9 +28,13 @@ import com.afternote.core.ui.topbar.DetailTopBar * * **레이아웃 토큰** (모듈 공통): * - horizontal padding 20dp - * - bottom 버튼 49dp 마진 (제스처 바: 49dp / 구형 네비 바: bar height + 49dp) + * - 하단 버튼은 시스템 내비게이션 바 또는 키보드 위로 49dp 간격 * - content 영역은 [ColumnScope] — 위에서 아래로 쌓이는 일반 흐름 가정 * + * **시스템 여백**: Scaffold 바깥에서 내비게이션 바와 키보드 inset을 처리한다. + * 상위에서 소비한 영역은 자동으로 제외되므로 루트 여백의 크기를 직접 빼지 않는다. + * 하단 버튼은 디자인 간격만 관리하고, content에도 Scaffold 여백의 소비를 전달한다. + * * **진행 인디케이터**: * - [currentStep] 과 [totalSteps] 가 모두 non-null 이면 상단에 [FlowStepProgressBar] 표시 * - 둘 중 하나라도 null 이면 미표시 (완료 화면·단일 입력 화면 등) @@ -63,7 +67,9 @@ fun FlowStepScaffold( modifier = modifier .addFocusCleaner(focusManager) - .fillMaxSize(), + .fillMaxSize() + .navigationBarsPadding() + .imePadding(), containerColor = Color.Transparent, topBar = { DetailTopBar( @@ -78,10 +84,6 @@ fun FlowStepScaffold( if (snackbarHostState != null) SnackbarHost(hostState = snackbarHostState) }, bottomBar = { - // 제스처 바를 쓰는 경우 화면 하단으로부터 49dp, 구형 네비게이션 바를 쓰는 경우 바로부터 49dp. - val navBarHeight = - WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - val bottomPadding = if (navBarHeight <= 30.dp) 49.dp else navBarHeight + 49.dp AfternoteButton( text = actionButtonText, onClick = { @@ -92,7 +94,7 @@ fun FlowStepScaffold( modifier = Modifier .padding(horizontal = horizontalPadding) - .padding(bottom = bottomPadding), + .padding(bottom = 49.dp), ) }, ) { innerPadding -> @@ -100,6 +102,7 @@ fun FlowStepScaffold( modifier = Modifier .padding(innerPadding) + .consumeWindowInsets(innerPadding) .padding(horizontal = horizontalPadding), ) { // local val 박아서 smart-cast 안정 — 향후 람다 캡처 추가되어도 깨지지 않음. diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/theme/AfternoteTypography.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/theme/AfternoteTypography.kt index 538638c538..5c92211b8f 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/theme/AfternoteTypography.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/theme/AfternoteTypography.kt @@ -55,6 +55,50 @@ private val BodyLineBreak = LineBreak.Paragraph.copy(wordBreak = LineBreak.WordB /** 제목용. [LineBreak.Heading] 은 이미 `WordBreak.Phrase` 를 포함한다. */ private val HeadingLineBreak = LineBreak.Heading +/** + * 앱 타이포 정본. + * + * **이름에 `B` 가 붙은 스타일이 `FontWeight.Bold` 가 아닌 것은 의도다 — 되돌리지 말 것.** + * + * 나눔바른고딕은 가로획을 세로획의 81% 로 그리는 서체다. 홑자모 아웃라인 실측(upem 1000): + * + * | 페이스 | `ㅣ` 세로획 | `ㅡ` 가로획 | 가로/세로 | + * |---|---|---|---| + * | Regular | 90 | 73 | 0.81 | + * | Bold | 125 | 103 | 0.82 | + * + * **두 페이스의 비율은 같다 — Bold 가 더 비대칭인 것이 아니라 획이 39% 굵을 뿐이다.** + * 참고로 Apple SD 고딕 Neo 는 같은 비가 웨이트별로 0.88~0.92 라 나눔보다 대비가 작다. + * + * 문제는 렌더링에서 갈린다. 플랫폼이 획을 정수 픽셀로 맞추는데, **Bold 는 세로획이 가로획보다 + * 항상 1~2px 두껍게 떨어지고 Regular 은 작은 크기에서 둘이 같은 정수로 떨어진다.** 같은 기기 + * 같은 설정에서 `ㅣ`/`ㅡ` 만 띄워 크기별로 잰 결과(밀도 420 · 글꼴 배율 0.8, sp→px 2.1): + * + * | em px | Bold 세로/가로 | 비 | Regular 세로/가로 | 비 | + * |---|---|---|---|---| + * | 21 | 3 / 2 | 0.67 | 2 / 2 | **1.00** | + * | 29 | 4 / 2 | **0.50** | 2 / 2 | **1.00** | + * | 34 | 4 / 3 | 0.75 | 3 / 3 | **1.00** | + * | 42 | 5 / 4 | 0.80 | 4 / 3 | 0.75 | + * | 59 | 7 / 6 | 0.86 | 5 / 5 | **1.00** | + * + * 즉 Bold 가 불균일을 만드는 것이 아니라 원래 있던 대비를 픽셀 단위로 드러낸다. 본문 크기대에서 + * 가장 나쁘고(em 29 에서 0.50), Regular 은 바로 그 구간에서 1.00 이다. + * + * **Bold 를 쓸 수 있는 하한은 em 58px 이다** — 그래야 가로획이 6px 이 되어 1px 차이가 17% 로 + * 떨어진다. 기본 설정(밀도 480 · 글꼴 1.0)에서 약 19sp, 위 표를 잰 기기(밀도 420 · 글꼴 배율 + * 0.8, sp→px 2.1)에서는 약 28sp 다. 본문 크기대는 어느 쪽에서도 그 아래지만 제목은 아니다. + * 기본 설정에서 `h1`(28sp) · `h2`(24sp) · `h3`(20sp) 가 하한 위고, 배율 0.8 기기에서도 `h1` 은 + * 58.8px 로 하한을 넘긴다. 그러니 이 하한이 닫는 것은 18sp 이하뿐이고, 제목이 Regular 인 근거는 + * 획 대비가 아니라 시안이다. + * + * 시안은 `H2`·`H3`·`BodyLarge(B)`·`BodySmall(B)`·`CaptionLarge(B)`·`PrimaryButton` 을 전부 + * Regular 페이스로 쓴다 — 정본 페이지 TEXT 노드 1,789건 전량이 `NanumBarunGothic` 이고 + * `NanumBarunGothicBold` 는 0건이다. + * + * Bold 폰트 파일 등록([nanumBarunGothic])은 그대로 둔다 — 마음의 기록 편집기에서 사용자가 + * 직접 거는 볼드(`SpanStyle(fontWeight = FontWeight.Bold)`)가 그 파일을 쓴다. + */ data class AfternoteTypography( val h1: TextStyle = TextStyle( @@ -69,7 +113,7 @@ data class AfternoteTypography( val h2: TextStyle = TextStyle( fontFamily = nanumBarunGothic, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Normal, fontSize = 24.sp, lineHeight = 30.sp, letterSpacing = (-0.0025).em, @@ -79,7 +123,7 @@ data class AfternoteTypography( val h3: TextStyle = TextStyle( fontFamily = nanumBarunGothic, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Normal, fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = (-0.0025).em, @@ -89,7 +133,7 @@ data class AfternoteTypography( val bodyLargeB: TextStyle = TextStyle( fontFamily = nanumBarunGothic, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Normal, fontSize = 18.sp, lineHeight = 24.sp, lineBreak = BodyLineBreak, @@ -113,10 +157,14 @@ data class AfternoteTypography( lineBreak = BodyLineBreak, localeList = KoreanLocale, ), + /** + * **[bodySmallR] 과 모든 필드가 같다 — 지금은 이름만 다른 같은 값이다.** 합치지 않고 + * 남기는 이유는 [captionLargeB] 와 같다. 상세는 그쪽 KDoc. + */ val bodySmallB: TextStyle = TextStyle( fontFamily = nanumBarunGothic, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp, lineBreak = BodyLineBreak, @@ -134,23 +182,13 @@ data class AfternoteTypography( val primaryButton: TextStyle = TextStyle( fontFamily = nanumBarunGothic, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = (-0.0025).em, lineBreak = BodyLineBreak, localeList = KoreanLocale, ), - val secondaryButton: TextStyle = - TextStyle( - fontFamily = nanumBarunGothic, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 20.sp, - letterSpacing = (-0.0025).em, - lineBreak = BodyLineBreak, - localeList = KoreanLocale, - ), val footnoteCaption: TextStyle = TextStyle( fontFamily = nanumBarunGothic, @@ -160,10 +198,30 @@ data class AfternoteTypography( lineBreak = BodyLineBreak, localeList = KoreanLocale, ), + /** + * **[captionLargeR] 과 모든 필드가 같다 — 지금은 이름만 다른 같은 값이다.** + * ([bodySmallB]/[bodySmallR] 도 마찬가지다. [bodyLargeB]/[bodyLargeR] 는 줄높이가 달라 해당 없다.) + * + * **중복을 만든 것은 이 코드가 아니라 시안이다.** 정본 페이지에서도 `CaptionLarge(B)` 와 + * `CaptionLarge-R` 의 값이 같다. 코드가 Bold 를 쓰던 동안에만 둘이 갈려 보였고, 위 KDoc 대로 + * 웨이트를 시안에 맞추면서 그 착시가 걷혔을 뿐이다. + * + * **그런데도 합치지 않고 남긴다.** 두 가지 때문이다. + * + * 1. 합치려면 호출부 94곳(`bodySmallB` 58 · `captionLargeB` 36)을 고쳐야 하는데 8개 모듈에 + * 걸쳐 있고 담당자가 셋이다. 웨이트를 맞추는 이 변경의 범위를 넘는다. + * 2. 「값이 같으니 하나로 합친다」는 **시안이 두 이름을 계속 나눠 둘 것인지 확인한 뒤**에 + * 내릴 판정이다. 지금 합쳤다가 시안이 다시 갈리면 94곳을 두 번 고치게 된다. + * + * 그 확인과 통합은 #1862 로 뗐다. + * + * **그때까지 둘이 조용히 갈라지지 않도록 `AfternoteTypographyPairTest` 가 동일성을 잠근다** — + * 한쪽만 고치면 그 테스트가 깨지므로, 갈라놓는 것도 합치는 것도 의식적인 판정이 된다. + */ val captionLargeB: TextStyle = TextStyle( fontFamily = nanumBarunGothic, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 18.sp, lineBreak = BodyLineBreak, diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/theme/Color.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/theme/Color.kt index 603d1ff7c7..1102a3d0a7 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/theme/Color.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/theme/Color.kt @@ -22,6 +22,22 @@ private val Gray4 = Color(0xFFBDBDBD) private val Gray5 = Color(0xFF9E9E9E) +/** + * 라이트 테마의 `gray5` 대체값. **[Gray5] 를 라이트에서 쓰면 본문 대비가 기준 미달이다.** + * + * 팔레트 반전에서 `gray5` 만 자기 자신으로 매핑되는데(아래 [darkColors]), 배경이 뒤집히므로 + * 같은 `#9E9E9E` 라도 두 테마의 대비가 갈린다. 갤럭시 S25(Android 16) 실기기 캡처 실측: + * + * | 테마 | 배경 | 대비 | + * |---|---|---| + * | 라이트 | `#FAFAFA` | 2.57:1 — WCAG 본문 최소 4.5:1 미달 | + * | 다크 | `#212121` | 6.01:1 — 통과 | + * + * 그래서 라이트만 이 값(4.89:1)으로 낮추고 다크는 [Gray5] 를 그대로 둔다. + * 시안도 `#9E9E9E` 라 값이 다른 것은 의도된 차이다 — 통일 금지. + */ +private val Gray5OnLight = Color(0xFF6E6E6E) + private val Gray6 = Color(0xFF757575) private val Gray7 = Color(0xFF616161) @@ -63,7 +79,7 @@ internal fun lightColors() = gray2 = Gray2, gray3 = Gray3, gray4 = Gray4, - gray5 = Gray5, + gray5 = Gray5OnLight, // 본문 대비 기준 — 상세는 Gray5OnLight KDoc gray6 = Gray6, gray7 = Gray7, gray8 = Gray8, @@ -93,7 +109,7 @@ internal fun darkColors() = gray2 = Gray8, gray3 = Gray7, gray4 = Gray6, - gray5 = Gray5, // 중간은 그대로 + gray5 = Gray5, // 중간은 반전 없음 — 다크는 이 값으로 대비 6.01:1 통과 gray6 = Gray4, gray7 = Gray3, gray8 = Gray2, diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/theme/Theme.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/theme/Theme.kt index acb514a961..e153043a13 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/theme/Theme.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/theme/Theme.kt @@ -38,8 +38,22 @@ fun AfternoteTheme( colors: AfternoteColors = lightColors(), darkColors: AfternoteColors = darkColors(), typography: AfternoteTypography = AfternoteTypography(), - // 시스템 설정이 다크 모드인지 확인 - isDarkTheme: Boolean = isSystemInDarkTheme(), + /** + * 다크 팔레트를 물릴지. **기본값은 라이트 고정이다** (#1719). + * + * 배선만 있고 화면이 안 맞춰진 중간 상태가 양쪽 끝보다 나빴다 — 실측에서 「마음의 기록」 + * 헤더가 검정 배경에 검정 글자로 거의 보이지 않았고, 주간 요약 카드는 배경만 밝은 채로 + * 남아 라벨이 저대비로 흐려졌다. 토큰을 쓰지 않고 색을 박은 자리가 반전을 따라가지 + * 못해서다(`feature/mindrecord/presentation` main 에만 29곳). + * + * 시안의 다크 팔레트가 확정되고 하드코딩 색이 토큰으로 옮겨지면 기본값을 + * [isSystemInDarkTheme] 으로 되돌린다. [darkColors] 와 이 파라미터는 그때를 위해 남겨 + * 둔다 — 값을 넘기면 지금도 다크로 그릴 수 있다. + * + * **되돌릴 때 `app` 의 `enableLightEdgeToEdge()` 도 같이 되돌린다.** 이 잠금만 풀면 + * 시스템바 아이콘이 라이트로 고정된 채 배경만 다크가 되어 반대 방향으로 안 보인다. + */ + isDarkTheme: Boolean = false, content: @Composable () -> Unit, ) { val currentColor = if (isDarkTheme) darkColors else colors @@ -64,7 +78,7 @@ fun AfternoteTheme( // Typography: MaterialTheme.typography.* → AfternoteDesign.typography.* (see AfternoteTypography in AfternoteTypography.kt) // e.g. headlineLarge→h1, headlineMedium→h2, headlineSmall→h3, bodyLarge→bodyLargeB, bodyMedium→bodyLargeR, // titleMedium→bodyBase, bodySmall→bodySmallR, titleSmall→bodySmallB, labelLarge→primaryButton, -// inter→secondaryButton, labelSmall→footnoteCaption, displayLarge→captionLargeB, +// labelSmall→footnoteCaption, displayLarge→captionLargeB, // displayMedium→captionLargeR, displaySmall→mono object AfternoteDesign { val colors: AfternoteColors diff --git a/feature/timeletter/presentation/src/main/res/drawable/ic_align_center.xml b/core/ui/src/main/res/drawable/core_ui_ic_align_center.xml similarity index 100% rename from feature/timeletter/presentation/src/main/res/drawable/ic_align_center.xml rename to core/ui/src/main/res/drawable/core_ui_ic_align_center.xml diff --git a/feature/timeletter/presentation/src/main/res/drawable/ic_align_left.xml b/core/ui/src/main/res/drawable/core_ui_ic_align_left.xml similarity index 100% rename from feature/timeletter/presentation/src/main/res/drawable/ic_align_left.xml rename to core/ui/src/main/res/drawable/core_ui_ic_align_left.xml diff --git a/feature/timeletter/presentation/src/main/res/drawable/ic_align_right.xml b/core/ui/src/main/res/drawable/core_ui_ic_align_right.xml similarity index 100% rename from feature/timeletter/presentation/src/main/res/drawable/ic_align_right.xml rename to core/ui/src/main/res/drawable/core_ui_ic_align_right.xml diff --git a/core/ui/src/main/res/drawable/core_ui_ic_server.xml b/core/ui/src/main/res/drawable/core_ui_ic_server.xml new file mode 100644 index 0000000000..6dbb7c7b6d --- /dev/null +++ b/core/ui/src/main/res/drawable/core_ui_ic_server.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/core_ui_ic_upload_cloud.xml b/core/ui/src/main/res/drawable/core_ui_ic_upload_cloud.xml new file mode 100644 index 0000000000..de5ccfaccb --- /dev/null +++ b/core/ui/src/main/res/drawable/core_ui_ic_upload_cloud.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index f83a92f544..4bcc36bb95 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -30,9 +30,20 @@ --> 수신인 지정 여부 확인 중 + 네트워크 연결 오류 인터넷 연결을 확인한 후 다시 시도해 주세요. 다시 시도하기 + 서버 오류 + 서버에 문제가 발생했습니다.\n잠시 후 다시 시도해 주세요. + 다시 시도하기 + 업로드 실패 + 파일 업로드에 실패했습니다.\n다시 시도해 주세요. + 다시 시도하기 설정 + 수신자 목록을 불러오지 못했습니다. + 다시 시도 + + 목록을 새로 불러오지 못했습니다. 지금 보이는 내용이 최신이 아닐 수 있어요. + 다시 시도 + + 수정하기 + 삭제하기 + + + + diff --git a/feature/afternote/presentation/src/main/res/values/strings.xml b/feature/afternote/presentation/src/main/res/values/strings.xml index cff7cb3168..60ba2a0a99 100644 --- a/feature/afternote/presentation/src/main/res/values/strings.xml +++ b/feature/afternote/presentation/src/main/res/values/strings.xml @@ -59,10 +59,9 @@ %1$s 선택하기 - 수신자 목록을 불러오지 못했습니다. - 다시 시도 - - 등록된 수신자가 없습니다.\n설정에서 수신자를 먼저 등록해 주세요. + + 등록된 수신자가 없습니다. + 수신자를 등록하고 쉽게 관리해 보세요. 제목 내용 @@ -103,8 +102,6 @@ %1$s에 대한 기록을 삭제하시겠습니까?\n삭제 시, 되돌릴 수 없습니다. 취소하기 삭제하기 - 수정하기 - 삭제하기 아직 등록된 애프터노트가 없어요.\n아래 연필 버튼을 눌러 애프터노트를 등록해 보세요. 프로필 사진 @@ -123,8 +120,6 @@ 에 대한\n%1$s님의 기록 에 대한\n기록 이 추억 노트는 지정된 신뢰할 연락처에게만 공유되며,\n안전하게 보관됩니다. - - 추모 영상 영상을 재생할 수 있는 앱이 없습니다. 영상 주소가 올바르지 않아 재생할 수 없습니다. @@ -154,6 +149,13 @@ 저장에 실패했습니다. 검색 실패 수신자 목록을 불러오지 못해 선택한 수신자를 반영하지 못했습니다. 잠시 후 다시 선택해 주세요. + + 기존 내용을 불러오지 못했습니다.\n불러오기 전에는 저장할 수 없어요. + + 기존 내용을 불러오는 중입니다.\n잠시 후 다시 시도해 주세요. + 다시 불러오기 영상 미리보기를 올리지 못했습니다. 영상에서 미리보기를 만들지 못했습니다. 다시 시도 diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTest.kt index ef12e99d93..d613340591 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTest.kt @@ -2,8 +2,8 @@ package com.afternote.feature.afternote.presentation.editor.memorial import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.presentation.COMPACT_DEVICE_SPEC -import com.afternote.feature.afternote.presentation.navigation.AfternoteLightTheme import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDisplay import com.android.tools.screenshot.PreviewTest @@ -22,7 +22,7 @@ private val SAMPLE_SONGS = @Preview(showBackground = true) @Composable internal fun addSongScreenScreenshot() { - AfternoteLightTheme { + AfternoteTheme { AddSongScreen( uiState = AddSongUiState(songs = SAMPLE_SONGS), onSearchQueryChange = {}, @@ -42,7 +42,7 @@ internal fun addSongScreenScreenshot() { @Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) @Composable internal fun addSongScreenCompactScreenshot() { - AfternoteLightTheme { + AfternoteTheme { AddSongScreen( uiState = AddSongUiState(songs = SAMPLE_SONGS), onSearchQueryChange = {}, diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTest.kt index 23226f8ecf..8a47bcbc68 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTest.kt @@ -49,3 +49,18 @@ internal fun memorialMediaSourceBottomSheetRemovableScreenshot() { ) } } + +/** 첨부가 있는 사진 슬롯 — 문구가 «사진 삭제» 로 갈린다. 서버 사진에도 이 항목이 열린다 (#1597). */ +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun memorialMediaSourceBottomSheetPhotoRemovableScreenshot() { + AfternoteTheme { + MemorialMediaSourceBottomSheet( + target = MemorialMediaTarget.PHOTO, + onPickFromGallery = {}, + onCapture = {}, + onRemove = {}, + ) + } +} diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTest.kt index f0d83dfb1e..2a15f76fe5 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTest.kt @@ -1,5 +1,6 @@ package com.afternote.feature.afternote.presentation.home +import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -32,6 +33,8 @@ internal fun emptyHomeBodyScreenshot() { nextStep = null, emptyListDescription = stringResource(R.string.afternote_empty_list_body), onTypeSelected = {}, + // 화면은 이 상태를 `when` 위에서 만들어 넘긴다 (#1635). 골든이 보는 렌더는 종전과 같다. + filterRowScrollState = rememberScrollState(), ) } } diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginScreenScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginScreenScreenshotTest.kt index 9357b088d3..f0deb41765 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginScreenScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginScreenScreenshotTest.kt @@ -9,7 +9,7 @@ import com.android.tools.screenshot.PreviewTest /** * [FingerprintLoginScreen] 의 시각 회귀 baseline. * - * `AfternoteNavGraph` 의 startDestination 이지만 앱 잠금이 켜지고 생체인증이 등록된 기기에서만 + * `AfternoteNavHost` 로컬 스택의 바닥(시작 화면)이지만 앱 잠금이 켜지고 생체인증이 등록된 기기에서만 * 실제로 노출된다 — 실기 QA 로는 지문을 등록해야 도달하므로 baseline 이 유일한 상시 감시축이다. */ @PreviewTest diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/GalleryDetailScreenScreenshotTestKt/galleryDetailScreenScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/GalleryDetailScreenScreenshotTestKt/galleryDetailScreenScreenshot_748aa731_0.png index 33d8ea10a3..b34cb9859a 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/GalleryDetailScreenScreenshotTestKt/galleryDetailScreenScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/GalleryDetailScreenScreenshotTestKt/galleryDetailScreenScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenScreenshot_748aa731_0.png index 5eb0808caa..e84703c5f8 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenWithVideoScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenWithVideoScreenshot_748aa731_0.png index b40f41362f..be9a6f6ea0 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenWithVideoScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenWithVideoScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/account/AccountDetailScreenScreenshotTestKt/accountDetailBusinessFallbackScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/account/AccountDetailScreenScreenshotTestKt/accountDetailBusinessFallbackScreenshot_748aa731_0.png index b8aec8fdb1..a9a8d928f5 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/account/AccountDetailScreenScreenshotTestKt/accountDetailBusinessFallbackScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/account/AccountDetailScreenScreenshotTestKt/accountDetailBusinessFallbackScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/account/AccountDetailScreenScreenshotTestKt/accountDetailScreenScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/account/AccountDetailScreenScreenshotTestKt/accountDetailScreenScreenshot_748aa731_0.png index 316b670445..468b94479c 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/account/AccountDetailScreenScreenshotTestKt/accountDetailScreenScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/account/AccountDetailScreenScreenshotTestKt/accountDetailScreenScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/UnimplementedCategoryContentScreenshotTestKt/unimplementedCategoryContentScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/UnimplementedCategoryContentScreenshotTestKt/unimplementedCategoryContentScreenshot_748aa731_0.png index b3673cd26b..ec30f4e438 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/UnimplementedCategoryContentScreenshotTestKt/unimplementedCategoryContentScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/UnimplementedCategoryContentScreenshotTestKt/unimplementedCategoryContentScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithRecipientScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithRecipientScreenshot_748aa731_0.png index 5dc0bfda4c..ae1d272f54 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithRecipientScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithRecipientScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithoutRecipientScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithoutRecipientScreenshot_748aa731_0.png index bab4dfdfae..75762159d3 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithoutRecipientScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithoutRecipientScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/gallery/GalleryAndFileEditorContentScreenshotTestKt/galleryAndFileEditorContentScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/gallery/GalleryAndFileEditorContentScreenshotTestKt/galleryAndFileEditorContentScreenshot_748aa731_0.png index 321b0e0ca0..616a0b103d 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/gallery/GalleryAndFileEditorContentScreenshotTestKt/galleryAndFileEditorContentScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/gallery/GalleryAndFileEditorContentScreenshotTestKt/galleryAndFileEditorContentScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTestKt/addSongScreenCompactScreenshot_c2abd7e6_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTestKt/addSongScreenCompactScreenshot_c2abd7e6_0.png index 155c3d8448..63c924aad6 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTestKt/addSongScreenCompactScreenshot_c2abd7e6_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTestKt/addSongScreenCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTestKt/addSongScreenScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTestKt/addSongScreenScreenshot_748aa731_0.png index bfadc1ae81..c3f545f9e5 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTestKt/addSongScreenScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTestKt/addSongScreenScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialEditorContentScreenshotTestKt/memorialEditorContentScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialEditorContentScreenshotTestKt/memorialEditorContentScreenshot_748aa731_0.png index 5bf6c1d84c..b1aa45e615 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialEditorContentScreenshotTestKt/memorialEditorContentScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialEditorContentScreenshotTestKt/memorialEditorContentScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTestKt/memorialMediaSourceBottomSheetPhotoRemovableScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTestKt/memorialMediaSourceBottomSheetPhotoRemovableScreenshot_748aa731_0.png new file mode 100644 index 0000000000..b0bc40a9da Binary files /dev/null and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTestKt/memorialMediaSourceBottomSheetPhotoRemovableScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPhotoUploadScreenshotTestKt/memorialPhotoUploadEmptyScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPhotoUploadScreenshotTestKt/memorialPhotoUploadEmptyScreenshot_748aa731_0.png index fdfbd2f3ca..d6fc7c66a6 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPhotoUploadScreenshotTestKt/memorialPhotoUploadEmptyScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPhotoUploadScreenshotTestKt/memorialPhotoUploadEmptyScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryEditModeScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryEditModeScreenshot_748aa731_0.png index 25adaffcfe..1a81efca94 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryEditModeScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryEditModeScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryEditModeSelectionScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryEditModeSelectionScreenshot_748aa731_0.png index e4f1341c1a..68de4f0f1b 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryEditModeSelectionScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryEditModeSelectionScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryEmptyScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryEmptyScreenshot_748aa731_0.png index 51445d37ef..58a6ba0a53 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryEmptyScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryEmptyScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryWithSongsScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryWithSongsScreenshot_748aa731_0.png index cbe002d60d..bc82a3bd23 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryWithSongsScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntryScreenshotTestKt/memorialPlaylistEntryWithSongsScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialVideoUploadScreenshotTestKt/memorialVideoUploadEmptyScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialVideoUploadScreenshotTestKt/memorialVideoUploadEmptyScreenshot_748aa731_0.png index 0ab2d42759..2b6a0fa3cc 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialVideoUploadScreenshotTestKt/memorialVideoUploadEmptyScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/memorial/MemorialVideoUploadScreenshotTestKt/memorialVideoUploadEmptyScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/message/EditorMessageSectionScreenshotTestKt/editorMessageSectionEmptyScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/message/EditorMessageSectionScreenshotTestKt/editorMessageSectionEmptyScreenshot_748aa731_0.png index d065ba393b..a6b312042a 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/message/EditorMessageSectionScreenshotTestKt/editorMessageSectionEmptyScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/message/EditorMessageSectionScreenshotTestKt/editorMessageSectionEmptyScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/message/EditorMessageSectionScreenshotTestKt/editorMessageSectionRegisteredCollapsedScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/message/EditorMessageSectionScreenshotTestKt/editorMessageSectionRegisteredCollapsedScreenshot_748aa731_0.png index abaa668108..8d5584cb41 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/message/EditorMessageSectionScreenshotTestKt/editorMessageSectionRegisteredCollapsedScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/message/EditorMessageSectionScreenshotTestKt/editorMessageSectionRegisteredCollapsedScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/message/EditorMessageSectionScreenshotTestKt/editorMessageSectionRegisteredExpandedScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/message/EditorMessageSectionScreenshotTestKt/editorMessageSectionRegisteredExpandedScreenshot_748aa731_0.png index e852f6a631..be12b50c32 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/message/EditorMessageSectionScreenshotTestKt/editorMessageSectionRegisteredExpandedScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/message/EditorMessageSectionScreenshotTestKt/editorMessageSectionRegisteredExpandedScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/processing/ProcessingMethodCheckboxScreenshotTestKt/processingMethodCheckboxScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/processing/ProcessingMethodCheckboxScreenshotTestKt/processingMethodCheckboxScreenshot_748aa731_0.png index d248389215..cc7639f68b 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/processing/ProcessingMethodCheckboxScreenshotTestKt/processingMethodCheckboxScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/processing/ProcessingMethodCheckboxScreenshotTestKt/processingMethodCheckboxScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionSheetEmptySearchScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionSheetEmptySearchScreenshot_748aa731_0.png index 4048ce812c..5b5ec7ec96 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionSheetEmptySearchScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionSheetEmptySearchScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionSheetScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionSheetScreenshot_748aa731_0.png index 97da95ed93..6c2434cae1 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionSheetScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetScreenshotTestKt/editorServiceSelectionSheetScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/selection/SelectionDropdownScreenshotTestKt/selectionDropdownPlaceholderScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/selection/SelectionDropdownScreenshotTestKt/selectionDropdownPlaceholderScreenshot_748aa731_0.png index 7a8aeafae0..ee0405b884 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/selection/SelectionDropdownScreenshotTestKt/selectionDropdownPlaceholderScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/selection/SelectionDropdownScreenshotTestKt/selectionDropdownPlaceholderScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScreenshotTestKt/afternoteCategoryRowAllCompactScreenshot_c2abd7e6_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScreenshotTestKt/afternoteCategoryRowAllCompactScreenshot_c2abd7e6_0.png index e4d6ea7942..bf2ed4776b 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScreenshotTestKt/afternoteCategoryRowAllCompactScreenshot_c2abd7e6_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScreenshotTestKt/afternoteCategoryRowAllCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScreenshotTestKt/afternoteCategoryRowAllLargeFontScreenshot_39fcf3fa_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScreenshotTestKt/afternoteCategoryRowAllLargeFontScreenshot_39fcf3fa_0.png index 428f0fe2ec..02730bd7d0 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScreenshotTestKt/afternoteCategoryRowAllLargeFontScreenshot_39fcf3fa_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScreenshotTestKt/afternoteCategoryRowAllLargeFontScreenshot_39fcf3fa_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScreenshotTestKt/afternoteCategoryRowAllScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScreenshotTestKt/afternoteCategoryRowAllScreenshot_748aa731_0.png index cd14c1598d..60d4023129 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScreenshotTestKt/afternoteCategoryRowAllScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScreenshotTestKt/afternoteCategoryRowAllScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTestKt/emptyHomeBodyScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTestKt/emptyHomeBodyScreenshot_748aa731_0.png index 4411222780..79e69e3e0f 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTestKt/emptyHomeBodyScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTestKt/emptyHomeBodyScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/GalleryReceivedDetailScreenScreenshotTestKt/galleryReceivedDetailScreenScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/GalleryReceivedDetailScreenScreenshotTestKt/galleryReceivedDetailScreenScreenshot_748aa731_0.png index c376baadee..b2addf2b75 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/GalleryReceivedDetailScreenScreenshotTestKt/galleryReceivedDetailScreenScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/GalleryReceivedDetailScreenScreenshotTestKt/galleryReceivedDetailScreenScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenScreenshot_748aa731_0.png index c612826cd0..11ee8ebdd1 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenWithVideoScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenWithVideoScreenshot_748aa731_0.png index 6b56f2ddb5..2ada176a12 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenWithVideoScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenWithVideoScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenScreenshot_748aa731_0.png index 43ae02e4b1..786c03482b 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenWithCredentialsScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenWithCredentialsScreenshot_748aa731_0.png index 15dd6b1823..f55f69b272 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenWithCredentialsScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenWithCredentialsScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenWithIdOnlyScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenWithIdOnlyScreenshot_748aa731_0.png index 4ff25de761..ff9e8bad9e 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenWithIdOnlyScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenWithIdOnlyScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenCompactScreenshot_c2abd7e6_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenCompactScreenshot_c2abd7e6_0.png index b7ca497376..646a15e417 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenCompactScreenshot_c2abd7e6_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenEmptyScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenEmptyScreenshot_748aa731_0.png index 06e5fa915d..a73faccd1e 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenEmptyScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenEmptyScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenScreenshot_748aa731_0.png index 3517610a21..4323f0b87a 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/playlist/MemorialPlaylistScreenScreenshotTestKt/memorialPlaylistScreenScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/component/ErrorListBodyScreenshotTestKt/errorListBodyScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/component/ErrorListBodyScreenshotTestKt/errorListBodyScreenshot_748aa731_0.png index 21f820ea7f..7b6881343b 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/component/ErrorListBodyScreenshotTestKt/errorListBodyScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/component/ErrorListBodyScreenshotTestKt/errorListBodyScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/DetailLoadErrorContentScreenshotTestKt/detailLoadErrorContentRetryScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/DetailLoadErrorContentScreenshotTestKt/detailLoadErrorContentRetryScreenshot_748aa731_0.png index a038b378f7..0cb37aa264 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/DetailLoadErrorContentScreenshotTestKt/detailLoadErrorContentRetryScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/DetailLoadErrorContentScreenshotTestKt/detailLoadErrorContentRetryScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailScreenshotTestKt/memorialVideoThumbnailDarkScreenshot_76fd4e5f_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailScreenshotTestKt/memorialVideoThumbnailDarkScreenshot_76fd4e5f_0.png index 71228400fe..69b02c7852 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailScreenshotTestKt/memorialVideoThumbnailDarkScreenshot_76fd4e5f_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailScreenshotTestKt/memorialVideoThumbnailDarkScreenshot_76fd4e5f_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/MessageSectionScreenshotTestKt/messageSectionEmptyScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/MessageSectionScreenshotTestKt/messageSectionEmptyScreenshot_748aa731_0.png index a3eef5ced6..965aee2a24 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/MessageSectionScreenshotTestKt/messageSectionEmptyScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/MessageSectionScreenshotTestKt/messageSectionEmptyScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginScreenScreenshotTestKt/fingerprintLoginScreenCompactScreenshot_c2abd7e6_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginScreenScreenshotTestKt/fingerprintLoginScreenCompactScreenshot_c2abd7e6_0.png index 8a8896baaa..37dbce5fa2 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginScreenScreenshotTestKt/fingerprintLoginScreenCompactScreenshot_c2abd7e6_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginScreenScreenshotTestKt/fingerprintLoginScreenCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginScreenScreenshotTestKt/fingerprintLoginScreenScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginScreenScreenshotTestKt/fingerprintLoginScreenScreenshot_748aa731_0.png index 5885da10bc..39e695283c 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginScreenScreenshotTestKt/fingerprintLoginScreenScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginScreenScreenshotTestKt/fingerprintLoginScreenScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt index b2a8e45352..f9be259976 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt @@ -38,7 +38,6 @@ import com.afternote.feature.afternote.domain.model.author.DetailCredentials import com.afternote.feature.afternote.domain.model.author.DetailReceiver import com.afternote.feature.afternote.domain.model.author.DetailTimestamps import com.afternote.feature.afternote.domain.model.author.ProcessingMethod -import com.afternote.feature.afternote.domain.model.author.ReceiverRefPayload import com.afternote.feature.afternote.domain.repository.author.MediaInput import com.afternote.feature.afternote.domain.repository.author.MediaKind import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository @@ -59,6 +58,7 @@ import com.afternote.feature.afternote.presentation.editor.SaveAfternotePayloadB import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorState import com.afternote.feature.afternote.presentation.editor.state.rememberAfternoteEditorState +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import kotlinx.coroutines.launch import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals @@ -80,8 +80,15 @@ class AfternoteAuthorExtendedTest { @get:Rule val composeRule = createComposeRule() + /** + * 상세 → 수정 → 저장을 실제 화면으로 완주하며, **사용자가 만진 필드만** 요청에 실리는지 본다 (#1617). + * + * 여기서 손대는 것은 계정 ID 와 처리 방법 둘뿐이다. 제목·남기실 말씀·수신자는 프리필된 채로 + * 두므로 페이로드에서 빠져야 하고, **계정 정보 안에서도 안 고친 비밀번호는 빠져야 한다** — + * 종전에는 폼 스냅샷이 통째로 실려, 에디터를 연 뒤 서버가 바뀌면 만진 적 없는 값이 덮였다. + */ @Test - fun detailEdit_updatesExactPrefilledPayloadThroughEditorRoute() { + fun detailEdit_sendsOnlyTouchedFieldsThroughEditorRoute() { val repository = FakeAfternoteRepository.strict().apply { onGetDetail = { Result.success(authorDetail()) } @@ -138,15 +145,16 @@ class AfternoteAuthorExtendedTest { val (updatedId, payload) = repository.updateCalls.single() assertEquals(73L, updatedId) assertEquals(AfternoteType.SOCIAL_NETWORK, payload.type) - assertEquals("Instagram", payload.title) + // 만진 둘 — 처리 방법과 계정 ID. assertEquals(listOf("계정 보존"), payload.processingMethods) - assertEquals( - listOf(LeaveMessageBlock(title = "마지막 말", body = "기억해 줘")), - payload.leaveMessageBlocks, - ) assertEquals("edited@example.test", payload.credentials?.id) - assertEquals("old-password", payload.credentials?.password) - assertEquals(listOf(ReceiverRefPayload(7L)), payload.receivers) + // 계정 정보 안에서도 슬롯별로 갈린다: 서버가 id·비밀번호를 따로 갱신하므로, 안 고친 + // 비밀번호를 함께 실으면 그 사이 다른 기기가 바꾼 값이 낡은 값으로 되돌아간다 (#1617). + assertNull("비밀번호는 고친 적이 없다", payload.credentials?.password) + // 안 만진 나머지 — 키째 빠져야 서버가 기존 값을 유지한다. + assertNull("제목을 고친 적이 없다", payload.title) + assertNull("남기실 말씀을 고친 적이 없다", payload.leaveMessageBlocks) + assertNull("수신자를 고친 적이 없다", payload.receiverIds) assertNull(payload.memorial) assertEquals(listOf(73L, 73L), repository.requestedDetailIds) assertEquals("73", routedItemId) @@ -353,7 +361,9 @@ private fun AuthorEditorForUpdate( setType = viewModel::setType, setService = viewModel::setService, setMemorialPhoto = viewModel::setMemorialPhoto, + removeMemorialPhoto = viewModel::removeMemorialPhoto, setMemorialVideo = viewModel::setMemorialVideo, + removeMemorialVideo = viewModel::removeMemorialVideo, addReceiverIfAbsent = viewModel::addReceiverIfAbsent, applyPrefill = viewModel::applyPrefill, setMemorialThumbnail = viewModel::setMemorialThumbnail, @@ -484,7 +494,7 @@ private fun detailViewModel( itemId: Long, ): AfternoteDetailViewModel = AfternoteDetailViewModel( - savedStateHandle = SavedStateHandle(mapOf("itemId" to itemId)), + route = AfternoteRoute.DetailRoute(itemId = itemId), afternoteRepository = repository, userRepository = afternoteAuthorUserRepository(), userProfileRepository = afternoteAuthorUserProfileRepository(), @@ -496,6 +506,11 @@ private fun editorViewModel( itemId: Long, ): AfternoteEditorViewModel = AfternoteEditorViewModel( + route = + AfternoteRoute.EditorFlowRoute( + itemId = itemId, + initialType = AfternoteType.SOCIAL_NETWORK, + ), savedStateHandle = afternoteEditorSavedStateHandle( initialType = AfternoteType.SOCIAL_NETWORK, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt index 604d8ee01b..463ef7ca6a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt @@ -281,6 +281,7 @@ private fun implementedCoverageViewModel( photoInputs: MutableList = mutableListOf(), ): AfternoteEditorViewModel = AfternoteEditorViewModel( + route = savedStateHandle.editorFlowRoute(), savedStateHandle = savedStateHandle, userRepository = afternoteAuthorUserRepository(), afternoteRepository = repository, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt index ba6f283159..cf1abb0655 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt @@ -137,6 +137,7 @@ class AfternoteAuthorTest { savedStateHandle: SavedStateHandle, ): AfternoteEditorViewModel = AfternoteEditorViewModel( + route = savedStateHandle.editorFlowRoute(), savedStateHandle = savedStateHandle, userRepository = afternoteAuthorUserRepository(), afternoteRepository = repository, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTestFixtures.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTestFixtures.kt index c85ee9d145..13dc4b63ec 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTestFixtures.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTestFixtures.kt @@ -2,13 +2,25 @@ package com.afternote.feature.afternote.presentation import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.testing.FakeUserProfileRepository +import com.afternote.core.domain.testing.FakeUserProfileCacheRepository import com.afternote.core.domain.testing.FakeUserRepository import com.afternote.core.model.user.Receiver import com.afternote.core.model.user.User -import com.afternote.core.model.user.UserConnectedAccount -import com.afternote.core.model.user.UserPushSetting import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute + +/** + * 이관 전 route 인자를 나르던 [SavedStateHandle] 에서 Nav3 진입 키를 만든다. + * + * 에디터 ViewModel 은 route 를 assisted 로 받고([AfternoteRoute.EditorFlowRoute]) 폼 스냅샷은 계속 + * [SavedStateHandle] 에 넣는다 — 두 벌로 쪼개면 테스트마다 같은 값을 두 번 적어야 해서, 기존 + * 핸들 픽스처에서 키를 파생한다. + */ +internal fun SavedStateHandle.editorFlowRoute(): AfternoteRoute.EditorFlowRoute = + AfternoteRoute.EditorFlowRoute( + itemId = get("itemId"), + initialType = requireNotNull(get("initialType")) { "initialType 이 없는 에디터 핸들" }, + ) internal fun afternoteEditorSavedStateHandle( initialType: AfternoteType, @@ -26,26 +38,20 @@ internal fun afternoteAuthorUserRepository(): FakeUserRepository = FakeUserRepository.strict().apply { profile = TEST_USER receiverState.value = listOf(TEST_RECEIVER) - pushSetting = TEST_PUSH_SETTING - connectedAccounts = testConnectedAccounts(profile.email) onReceiverListFlow = null onGetReceivers = null onCreateReceiver = null onGetMyProfile = null onUpdateMyProfile = null - onDeleteAccount = null - onGetMyPushSettings = null - onUpdateMyPushSettings = null - onGetConnectedAccounts = { testConnectedAccounts(profile.email) } } /** - * 작성자 흐름이 쓰는 [FakeUserProfileRepository] — 이름 캐시 두 멤버만 열어 둔다. + * 작성자 흐름이 쓰는 [FakeUserProfileCacheRepository] — 이름 캐시 두 멤버만 열어 둔다. * 패스키 멤버까지 열면 상세·에디터가 건드리지 않는 계약이 조용히 통과한다. */ -internal fun afternoteAuthorUserProfileRepository(cachedUserName: String? = null): FakeUserProfileRepository = - FakeUserProfileRepository.strict().also { +internal fun afternoteAuthorUserProfileRepository(cachedUserName: String? = null): FakeUserProfileCacheRepository = + FakeUserProfileCacheRepository.strict().also { it.cachedUserName = cachedUserName it.onGetCachedUserName = null it.onSaveUserName = null @@ -59,8 +65,4 @@ internal object NoopAuthorErrorReporter : ErrorReporter { } private val TEST_USER = User("테스트 사용자", "test@afternote.local", null, null) -private val TEST_RECEIVER = Receiver(7L, "김수신", "가족", "fake-auth-7") -private val TEST_PUSH_SETTING = UserPushSetting(true, true, true) - -private fun testConnectedAccounts(email: String): UserConnectedAccount = - UserConnectedAccount(true, false, false, false, false, email, null, null, null, null) +private val TEST_RECEIVER = Receiver(7L, "김수신", "가족") diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt index b727ac3bfb..118ea4a8d7 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt @@ -2,7 +2,7 @@ package com.afternote.feature.afternote.presentation.detail import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.testing.FakeUserProfileRepository +import com.afternote.core.domain.testing.FakeUserProfileCacheRepository import com.afternote.core.domain.testing.FakeUserRepository import com.afternote.feature.afternote.domain.model.author.Detail import com.afternote.feature.afternote.domain.model.author.DetailContent @@ -12,6 +12,7 @@ import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.presentation.NoopAuthorErrorReporter import com.afternote.feature.afternote.presentation.afternoteAuthorUserProfileRepository import com.afternote.feature.afternote.presentation.afternoteAuthorUserRepository +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -419,10 +420,10 @@ class AfternoteDetailViewModelTest { repository: FakeAfternoteRepository, errorReporter: ErrorReporter = NoopAuthorErrorReporter, userRepository: FakeUserRepository = afternoteAuthorUserRepository(), - userProfileRepository: FakeUserProfileRepository = afternoteAuthorUserProfileRepository(), + userProfileRepository: FakeUserProfileCacheRepository = afternoteAuthorUserProfileRepository(), ): AfternoteDetailViewModel = AfternoteDetailViewModel( - savedStateHandle = SavedStateHandle(mapOf("itemId" to 73L)), + route = AfternoteRoute.DetailRoute(itemId = 73L), afternoteRepository = repository, userRepository = userRepository, userProfileRepository = userProfileRepository, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailHeaderTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailHeaderTest.kt new file mode 100644 index 0000000000..d4495f8da7 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailHeaderTest.kt @@ -0,0 +1,109 @@ +package com.afternote.feature.afternote.presentation.detail + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.presentation.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import com.afternote.core.ui.R as CoreUiR + +/** + * 추억 노트 상세 헤더 블록을 정본 시안 + * [node 4327:72819](https://www.figma.com/design/UP9ZR186jHvRBicjA2SOea/?node-id=4327-72819) 에 고정한다 (#463 5번). + * + * 종전 코드는 타이틀 아래에 «회색 카드 + 중앙 정렬 프로필» 을 따로 쌓았는데, 그 카드는 시안에 없다. + * 시안은 타이틀 오른쪽에 겹친 원 스택을 붙이고 「최종 작성일」을 타이틀 바로 아래로 올린다. + * 되돌아가면 프로필이 다시 타이틀 «아래» 로 내려가므로, 여기서 «같은 행» 을 잠근다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], qualifiers = "w480dp-h2000dp") +class MemorialDetailHeaderTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `프로필 원 스택을 타이틀 오른쪽 같은 행에 놓는다`() { + setContent(finalWriteDate = "2025.11.26") + + val title = composeRule.onNodeWithText(TITLE).getUnclippedBoundsInRoot() + val profile = composeRule.onNodeWithContentDescription(profileDescription()).getUnclippedBoundsInRoot() + + assertTrue( + "프로필은 타이틀 오른쪽에 있어야 한다 (타이틀 right ${title.right}, 프로필 left ${profile.left})", + profile.left >= title.right, + ) + assertTrue( + "프로필은 타이틀과 같은 행이어야 한다 (타이틀 ${title.top}~${title.bottom}, 프로필 ${profile.top}~${profile.bottom})", + profile.top < title.bottom, + ) + } + + @Test + fun `최종 작성일을 타이틀 바로 아래 헤더 안에 그린다`() { + setContent(finalWriteDate = "2025.11.26") + + val title = composeRule.onNodeWithText(TITLE).getUnclippedBoundsInRoot() + val writtenDate = composeRule.activity.getString(R.string.afternote_last_written_date, "2025.11.26") + val date = composeRule.onNodeWithText(writtenDate).getUnclippedBoundsInRoot() + + assertTrue( + "최종 작성일은 타이틀과 같은 열에서 시작해야 한다 — 카드 안으로 다시 들어가면 왼쪽이 어긋난다 " + + "(타이틀 left ${title.left}, 날짜 left ${date.left})", + date.left == title.left, + ) + // 시안 실측 간격 4. 종전 카드 배치에서는 24(Spacer) + 16(카드 패딩) 이 끼어 있었다. + assertEquals(4.dp, date.top - title.bottom) + } + + @Test + fun `최종 작성일 값이 없으면 그 줄을 그리지 않는다`() { + setContent(finalWriteDate = "") + + val prefixOnly = composeRule.activity.getString(R.string.afternote_last_written_date, "") + + composeRule.onNodeWithText(prefixOnly).assertDoesNotExist() + composeRule.onNodeWithText(TITLE).assertExists() + } + + @Test + fun `겹친 장식 원은 접근성 트리에 노출하지 않는다`() { + setContent(finalWriteDate = "2025.11.26") + + // 시안의 원은 셋이지만 실데이터가 붙는 것은 맨 앞 하나뿐이다. 뒤 둘은 장식이라 + // contentDescription 이 없어야 한다 — 있으면 스크린리더가 "프로필 이미지" 를 세 번 읽는다. + composeRule.onAllNodesWithContentDescription(profileDescription()).assertCountEquals(1) + } + + private fun setContent(finalWriteDate: String) { + composeRule.setContent { + AfternoteTheme { + MemorialDetailScreen( + onBackClick = {}, + onEditClick = {}, + onDeleteConfirm = {}, + onVideoClick = {}, + content = MemorialDetailContent(finalWriteDate = finalWriteDate), + userName = "서영", + ) + } + } + } + + private fun profileDescription(): String = composeRule.activity.getString(CoreUiR.string.core_ui_content_description_profile_image) + + private companion object { + const val TITLE = "추억 노트에 대한\n서영님의 기록" + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenInteractionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenInteractionTest.kt index 1cb69352ba..2506bed457 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenInteractionTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenInteractionTest.kt @@ -3,11 +3,11 @@ package com.afternote.feature.afternote.presentation.detail import androidx.activity.ComponentActivity import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.presentation.R import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -15,6 +15,10 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +/** + * 영상 카드는 제목 라벨을 품은 단일 Button 으로 화면 semantics 에 실린다. + * 카드를 집는 기준도 그 공개 semantics 다 — 테스트 전용 tag 를 두지 않는다 (#1673). + */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) class MemorialDetailScreenInteractionTest { @@ -40,7 +44,7 @@ class MemorialDetailScreenInteractionTest { } composeRule - .onNodeWithTag(MEMORIAL_VIDEO_CARD_TEST_TAG) + .onNodeWithText(videoCardLabel()) .performScrollTo() .assertHasClickAction() .performClick() @@ -63,7 +67,7 @@ class MemorialDetailScreenInteractionTest { } } - composeRule.onNodeWithTag(MEMORIAL_VIDEO_CARD_TEST_TAG).assertDoesNotExist() + composeRule.onNodeWithText(videoCardLabel()).assertDoesNotExist() } @Test @@ -87,4 +91,6 @@ class MemorialDetailScreenInteractionTest { .performScrollTo() .assertExists() } + + private fun videoCardLabel(): String = composeRule.activity.getString(R.string.afternote_editor_funeral_video_label) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormPrefillTest.kt index 2519bc4b7a..4ef63d670c 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormPrefillTest.kt @@ -1,16 +1,18 @@ package com.afternote.feature.afternote.presentation.editor +import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock +import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput import com.afternote.feature.afternote.domain.model.author.Detail import com.afternote.feature.afternote.domain.model.author.DetailContent import com.afternote.feature.afternote.domain.model.author.DetailCredentials import com.afternote.feature.afternote.domain.model.author.DetailTimestamps import com.afternote.feature.afternote.domain.model.author.MemorialSongPayload import com.afternote.feature.afternote.domain.model.author.playlist.DetailSong -import com.afternote.feature.afternote.domain.model.author.playlist.MemorialDetail import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia import com.afternote.feature.afternote.presentation.editor.memorial.Song import com.afternote.feature.afternote.presentation.editor.model.EditorContentPrefill +import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -69,22 +71,19 @@ class AfternoteEditorFormPrefillTest { leaveMessageBlocks = listOf(LeaveMessageBlock(title = "가족에게", body = "잘 지내")), content = DetailContent.Memorial( - memorial = - MemorialDetail( - songs = - listOf( - DetailSong( - title = "노래", - artist = "가수", - coverUrl = "cover", - ), - ), - media = - MemorialMedia( - photoUrl = "photo", - videoUrl = "video", - thumbnailUrl = "thumbnail", - ), + songs = + listOf( + DetailSong( + title = "노래", + artist = "가수", + coverUrl = "cover", + ), + ), + media = + MemorialMedia( + photoUrl = "photo", + videoUrl = "video", + thumbnailUrl = "thumbnail", ), ), ), @@ -100,8 +99,11 @@ class AfternoteEditorFormPrefillTest { @Test fun `메모리얼 저장은 UI 선택 키를 도메인 입력에 포함하지 않는다`() { - val payload = - AfternoteEditorFormMapper.buildMemorialWritePayload( + val input = + AfternoteEditorFormMapper.buildCreateInput( + type = AfternoteType.MEMORIAL, + payload = RegisterAfternotePayload(serviceName = "추억 노트", date = "2026-08-21"), + selectedReceiverIds = emptyList(), playlistSongs = listOf( Song( @@ -115,7 +117,10 @@ class AfternoteEditorFormPrefillTest { artist = "가수", ), ), - ) + memorialVideoUrl = null, + memorialThumbnailUrl = null, + memorialPhotoUrl = null, + ) as CreateAfternoteInput.Memorial assertEquals( listOf( @@ -130,7 +135,7 @@ class AfternoteEditorFormPrefillTest { coverUrl = null, ), ), - payload.songs, + input.payload.memorial.songs, ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt index b7652244e6..cb68335e94 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt @@ -8,8 +8,10 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.presentation.editor.memorial.Song import com.afternote.feature.afternote.presentation.editor.model.EditorContentPrefill import com.afternote.feature.afternote.presentation.editor.model.EditorFormPrefill +import com.afternote.feature.afternote.presentation.editorFlowRoute import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -17,6 +19,7 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test @@ -26,11 +29,11 @@ import org.robolectric.annotation.Config import java.lang.reflect.Proxy /** - * 추억 노트 미디어 슬롯의 삭제 경로 (#1114) — `setMemorialPhoto(null)`/`setMemorialVideo(null)`. + * 추억 노트 미디어 슬롯의 삭제 경로 (#1114, #1597) — + * `removeMemorialPhoto()`/`removeMemorialVideo()`. * - * 삭제는 새 세터가 아니라 기존 nullable 세터의 null 인자로 표현된다. 여기서 굳히는 규칙: - * 영상 삭제는 파생 썸네일을 함께 비우고, 사진 삭제는 로컬 픽만 지워 서버 사진 표시로 되돌리며, - * 삭제된 상태는 SavedState 스냅샷에도 그대로 실린다. + * 삭제는 세터의 null 인자가 아니라 이름 있는 연산이다(#1717). 삭제는 로컬 첨부와 서버 원본을 + * 함께 비우고, 그 상태는 SavedState 스냅샷에 실린다. 서버 삭제는 저장 시 명시적 null 로 나간다(#1597). */ @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -49,67 +52,108 @@ class AfternoteEditorMemorialMediaRemoveTest { } @Test - fun `영상을 삭제하면 파생 썸네일도 함께 비운다`() { + fun `신규 영상을 삭제하면 파생 썸네일도 함께 비운다`() { val viewModel = viewModel(memorialSavedStateHandle()) viewModel.setMemorialVideo("content://videos/farewell") viewModel.setMemorialThumbnail("https://cdn.test/thumbnail.jpg") - viewModel.setMemorialVideo(null) + viewModel.removeMemorialVideo() assertNull(viewModel.currentForm().displayedMemorialVideo) } @Test - fun `사진 삭제는 로컬 픽만 지우고 서버 사진 표시로 되돌린다`() { + fun `사진 삭제는 로컬 픽과 서버 원본을 함께 비운다`() { val viewModel = viewModel(memorialSavedStateHandle()) viewModel.applyPrefill(memorialPrefill(photoUrl = "https://cdn.test/portrait.jpg")) viewModel.setMemorialPhoto("content://photos/replacement") assertEquals("content://photos/replacement", viewModel.currentForm().displayMemorialPhotoUri()) - viewModel.setMemorialPhoto(null) + viewModel.removeMemorialPhoto() - // 서버 사진은 BE 수정 계약이 삭제를 표현하지 못해 폼에서도 지우지 않는다 — - // 표시·저장 모두 서버 사진 유지로 일관되게 남는다. assertNull(viewModel.currentForm().pickedMemorialPhotoUri) - assertEquals("https://cdn.test/portrait.jpg", viewModel.currentForm().memorialPhotoUrl) - assertEquals("https://cdn.test/portrait.jpg", viewModel.currentForm().displayMemorialPhotoUri()) + assertNull(viewModel.currentForm().memorialPhotoUrl) + assertNull(viewModel.currentForm().displayMemorialPhotoUri()) + } + + @Test + fun `영상 삭제는 로컬 픽과 서버 원본과 썸네일을 함께 비운다`() { + val viewModel = viewModel(memorialSavedStateHandle()) + viewModel.applyPrefill( + memorialPrefill( + videoUrl = "https://cdn.test/farewell.mp4", + thumbnailUrl = "https://cdn.test/server-thumbnail.jpg", + ), + ) + viewModel.setMemorialVideo("content://videos/replacement") + viewModel.setMemorialThumbnail("https://cdn.test/local-thumbnail.jpg") + + viewModel.removeMemorialVideo() + + assertNull(viewModel.currentForm().displayedMemorialVideo) + assertFalse(viewModel.currentForm().canRemoveMemorialVideo) } @Test - fun `신규 작성에서 사진을 삭제하면 슬롯이 완전히 빈다`() { + fun `서버 미디어만 있어도 사진과 영상을 삭제할 수 있다`() { val viewModel = viewModel(memorialSavedStateHandle()) - viewModel.setMemorialPhoto("content://photos/new") + viewModel.applyPrefill( + memorialPrefill( + photoUrl = "https://cdn.test/portrait.jpg", + videoUrl = "https://cdn.test/farewell.mp4", + thumbnailUrl = "https://cdn.test/thumbnail.jpg", + ), + ) - viewModel.setMemorialPhoto(null) + viewModel.removeMemorialPhoto() + viewModel.removeMemorialVideo() + assertNull(viewModel.currentForm().pickedMemorialPhotoUri) + assertNull(viewModel.currentForm().memorialPhotoUrl) assertNull(viewModel.currentForm().displayMemorialPhotoUri()) + assertNull(viewModel.currentForm().displayedMemorialVideo?.url) + assertNull(viewModel.currentForm().displayedMemorialVideo?.thumbnailUrl) } @Test - fun `삭제된 첨부는 프로세스 복원 후에도 비어 있다`() { + fun `삭제한 서버 미디어 스냅샷은 상세 재조회 없는 복원에서도 비어 있다`() { val handle = memorialSavedStateHandle() val first = viewModel(handle) - first.setMemorialVideo("content://videos/farewell") - first.setMemorialPhoto("content://photos/new") - first.setMemorialVideo(null) - first.setMemorialPhoto(null) + first.applyPrefill( + memorialPrefill( + photoUrl = "https://cdn.test/portrait.jpg", + videoUrl = "https://cdn.test/farewell.mp4", + thumbnailUrl = "https://cdn.test/thumbnail.jpg", + playlistSongs = listOf(Song("detail:0", "노래", "가수")), + ), + ) + first.removeMemorialVideo() + first.removeMemorialPhoto() val restored = viewModel(handle).currentForm() assertNull(restored.displayedMemorialVideo) assertNull(restored.pickedMemorialPhotoUri) + assertNull(restored.memorialPhotoUrl) + assertNull(restored.displayMemorialPhotoUri()) + assertEquals(listOf("노래"), restored.memorialPlaylistSongs.map { it.title }) } private fun memorialSavedStateHandle(): SavedStateHandle = SavedStateHandle(mapOf("initialType" to AfternoteType.MEMORIAL)) - private fun memorialPrefill(photoUrl: String?): EditorFormPrefill = + private fun memorialPrefill( + photoUrl: String? = null, + videoUrl: String? = null, + thumbnailUrl: String? = null, + playlistSongs: List = emptyList(), + ): EditorFormPrefill = EditorFormPrefill( content = EditorContentPrefill.Memorial( - videoUrl = null, - thumbnailUrl = null, + videoUrl = videoUrl, + thumbnailUrl = thumbnailUrl, photoUrl = photoUrl, - playlistSongs = emptyList(), + playlistSongs = playlistSongs, ), leaveMessageBlocks = emptyList(), receivers = emptyList(), @@ -117,6 +161,7 @@ class AfternoteEditorMemorialMediaRemoveTest { private fun viewModel(savedStateHandle: SavedStateHandle): AfternoteEditorViewModel = AfternoteEditorViewModel( + route = savedStateHandle.editorFlowRoute(), savedStateHandle = savedStateHandle, userRepository = repositoryProxy(), afternoteRepository = repositoryProxy(), diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt new file mode 100644 index 0000000000..3603e8e72c --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt @@ -0,0 +1,173 @@ +package com.afternote.feature.afternote.presentation.editor + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.author.Detail +import com.afternote.feature.afternote.domain.model.author.DetailContent +import com.afternote.feature.afternote.domain.model.author.DetailTimestamps +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload +import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +/** + * 기준 스냅샷 없이 수정 저장이 나가지 않는지에 대한 회귀 가드 (#1617). + * + * 상세 조회가 실패하면 폼이 **비어 있는 채로** 열린다. 그 상태에서 저장하면 「안 건드림」과 「전부 + * 지움」을 가릴 기준이 없어, 빈 처리 방법·수신자·남기실 말씀이 그대로 삭제 지시로 나간다 — 이 이슈가 + * 잡으려던 바로 그 사고다. 게다가 제목만 채우면 GALLERY·MEMORIAL 은 Validator 도 통과한다. + * + * 그래서 저장을 **시작하지 않고** 사용자에게 다시 불러오라고 알린다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteEditorMissingBaselineTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `상세를 못 받은 채 저장하면 요청을 보내지 않고 알린다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.failure(IOException("상세 조회 실패")) } + } + val viewModel = viewModel(repository) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + viewModel.saveAfternote( + payload = RegisterAfternotePayload(serviceName = "구글 포토", date = "2026-08-30"), + selectedReceiverIds = emptyList(), + memorialMedia = SaveAfternoteMemorialMedia(), + ) + runCurrent() + + assertTrue("기준 없는 수정은 서버로 나가면 안 된다", repository.updateCalls.isEmpty()) + assertEquals( + AfternoteEditorError.PrefillUnavailable, + viewModel.uiState.value.error, + ) + } + + @Test + fun `상세를 받았으면 저장이 정상으로 나간다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.success(galleryDetail()) } + onUpdate = { id, _ -> Result.success(id) } + } + val viewModel = viewModel(repository) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + // 화면이 prefill 을 폼에 반영했다는 통보. 이걸 빼면 저장이 «아직 읽는 중» + // ([AfternoteEditorError.PrefillNotReady]) 으로 막혀 기준 판정까지 가지 못한다 (#705). + viewModel.onPrefillConsumed() + runCurrent() + + viewModel.saveAfternote( + payload = RegisterAfternotePayload(serviceName = "새 제목", date = "2026-08-30"), + selectedReceiverIds = emptyList(), + memorialMedia = SaveAfternoteMemorialMedia(), + ) + runCurrent() + + assertEquals(1, repository.updateCalls.size) + assertEquals( + "새 제목", + repository.updateCalls + .single() + .second.title, + ) + } + + /** + * 저장 차단 안내는 저장 실패 문구와 달라야 한다 — 사용자가 할 일이 다르다. + * + * 문구는 #705 가 세운 prefill 조회 실패 문구를 함께 쓴다. 「기준이 없다」와 「기존 내용을 + * 읽지 못했다」는 사용자에게 같은 사실이고 할 일도 같다 — 다시 불러오는 것이다. + */ + @Test + fun `기준 없음은 저장 실패와 다른 문구로 안내한다`() { + assertEquals( + R.string.afternote_editor_prefill_load_failed, + AfternoteEditorError.PrefillUnavailable.messageResId(), + ) + } + + private fun galleryDetail() = + Detail( + id = EDIT_ID, + serviceName = "구글 포토", + timestamps = DetailTimestamps(updatedAt = "2026-08-30"), + receivers = emptyList(), + leaveMessageBlocks = emptyList(), + content = DetailContent.Gallery(processingMethods = listOf("파일 전달")), + ) + + private fun viewModel(afternoteRepository: FakeAfternoteRepository): AfternoteEditorViewModel = + AfternoteEditorViewModel( + route = AfternoteRoute.EditorFlowRoute(itemId = EDIT_ID, initialType = AfternoteType.GALLERY_AND_FILES), + savedStateHandle = + SavedStateHandle( + mapOf( + "initialType" to AfternoteType.GALLERY_AND_FILES, + "itemId" to EDIT_ID, + ), + ), + userRepository = FakeUserRepository.strict(), + afternoteRepository = afternoteRepository, + memorialThumbnailUploadRepository = + MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, + resolveMemorialMediaForSave = + ResolveMemorialMediaForSaveUseCase( + MemorialMediaUploadRepository { _, _ -> Result.success(null) }, + ), + errorReporter = NoopErrorReporter, + ) + + private object NoopErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit + } + + private companion object { + const val EDIT_ID = 73L + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt new file mode 100644 index 0000000000..7604ed116f --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt @@ -0,0 +1,202 @@ +package com.afternote.feature.afternote.presentation.editor + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.model.user.Receiver +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository +import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.lang.reflect.Proxy + +/** + * 수신자 선택 화면이 돌려준 «확정된 전체» 를 폼에 반영하는 계약 (#1426). + * + * 반환 채널이 단일 `Long` 이던 시절엔 한 번 진입에 한 명만 담겼다. 복수로 열면서 반영 규칙도 + * «추가» 에서 «교체» 로 바뀐다 — 화면이 폼의 현재 수신자를 선택 상태로 열기 때문에, 거기서 푼 + * 수신자가 폼에 남으면 사용자의 해제가 조용히 무시된다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteEditorMultiReceiverSelectionTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `선택 화면 한 번으로 수신자 여럿을 폼에 담는다`() = + runTest(dispatcher) { + val viewModel = viewModel(repositoryWith(DAUGHTER, FRIEND)) + runCurrent() + + viewModel.applySelectedReceivers(listOf(DAUGHTER_ID, FRIEND_ID)) + runCurrent() + + assertEquals( + listOf(DAUGHTER_ID, FRIEND_ID), + viewModel.currentForm().afternoteEditReceivers.map { it.id }, + ) + assertEquals( + listOf("김수신", "박친구"), + viewModel.currentForm().afternoteEditReceivers.map { it.name }, + ) + } + + @Test + fun `이미 폼에 있는 수신자를 다시 확정해도 중복으로 쌓이지 않는다`() = + runTest(dispatcher) { + val viewModel = viewModel(repositoryWith(DAUGHTER, FRIEND)) + viewModel.addReceiverIfAbsent(DAUGHTER_ID, "김수신", "딸") + runCurrent() + + viewModel.applySelectedReceivers(listOf(DAUGHTER_ID, FRIEND_ID)) + runCurrent() + + assertEquals( + listOf(DAUGHTER_ID, FRIEND_ID), + viewModel.currentForm().afternoteEditReceivers.map { it.id }, + ) + } + + @Test + fun `선택에서 빠진 기존 수신자는 폼에서도 빠진다`() = + runTest(dispatcher) { + val viewModel = viewModel(repositoryWith(DAUGHTER, FRIEND)) + viewModel.addReceiverIfAbsent(DAUGHTER_ID, "김수신", "딸") + viewModel.addReceiverIfAbsent(FRIEND_ID, "박친구", "친구") + runCurrent() + + viewModel.applySelectedReceivers(listOf(FRIEND_ID)) + runCurrent() + + assertEquals( + listOf(FRIEND_ID), + viewModel.currentForm().afternoteEditReceivers.map { it.id }, + ) + } + + @Test + fun `이미 폼에 있는 수신자는 다시 조회하지 않고 반영한다`() = + runTest(dispatcher) { + val repository = repositoryWith(DAUGHTER) + val viewModel = viewModel(repository) + viewModel.addReceiverIfAbsent(DAUGHTER_ID, "김수신", "딸") + runCurrent() + + viewModel.applySelectedReceivers(listOf(DAUGHTER_ID)) + runCurrent() + + assertEquals( + listOf(DAUGHTER_ID), + viewModel.currentForm().afternoteEditReceivers.map { it.id }, + ) + assertEquals("폼이 이미 들고 있는 표시값을 두고 재조회할 이유가 없다", 0, repository.getReceiversCalls) + } + + @Test + fun `해석하지 못한 수신자만 빠지고 나머지 선택은 반영된다`() = + runTest(dispatcher) { + val viewModel = viewModel(repositoryWith(DAUGHTER)) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + viewModel.applySelectedReceivers(listOf(DAUGHTER_ID, UNKNOWN_ID)) + runCurrent() + + assertEquals( + listOf(DAUGHTER_ID), + viewModel.currentForm().afternoteEditReceivers.map { it.id }, + ) + assertEquals( + AfternoteEditorError.ReceiverSelectionUnavailable, + viewModel.uiState.value.error, + ) + } + + @Test + fun `선택을 그대로 확정하면 폼도 그대로다`() = + runTest(dispatcher) { + val viewModel = viewModel(repositoryWith(DAUGHTER, FRIEND)) + backgroundScope.launch { viewModel.uiState.collect {} } + viewModel.addReceiverIfAbsent(DAUGHTER_ID, "김수신", "딸") + viewModel.addReceiverIfAbsent(FRIEND_ID, "박친구", "친구") + runCurrent() + + viewModel.applySelectedReceivers(listOf(DAUGHTER_ID, FRIEND_ID)) + runCurrent() + + assertEquals( + listOf(DAUGHTER_ID, FRIEND_ID), + viewModel.currentForm().afternoteEditReceivers.map { it.id }, + ) + assertNull("정상 반영에는 오류를 세우지 않는다", viewModel.uiState.value.error) + } + + private fun repositoryWith(vararg receivers: Receiver): FakeUserRepository = + FakeUserRepository.strict().apply { onGetReceivers = { receivers.toList() } } + + private fun viewModel(userRepository: FakeUserRepository): AfternoteEditorViewModel = + AfternoteEditorViewModel( + route = AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.SOCIAL_NETWORK), + savedStateHandle = SavedStateHandle(mapOf("initialType" to AfternoteType.SOCIAL_NETWORK)), + userRepository = userRepository, + afternoteRepository = unusedProxy(), + memorialThumbnailUploadRepository = + MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, + resolveMemorialMediaForSave = + ResolveMemorialMediaForSaveUseCase( + MemorialMediaUploadRepository { _, _ -> error("미디어 저장이 호출되면 안 됩니다") }, + ), + errorReporter = NoopErrorReporter, + ) + + private inline fun unusedProxy(): T = + Proxy.newProxyInstance( + T::class.java.classLoader, + arrayOf(T::class.java), + ) { _, method, _ -> error("${T::class.java.simpleName}.${method.name} 호출은 이 테스트에서 예상하지 않았습니다") } as T + + private object NoopErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit + } + + private companion object { + const val DAUGHTER_ID = 7L + const val FRIEND_ID = 11L + const val UNKNOWN_ID = 99L + val DAUGHTER = Receiver(DAUGHTER_ID, "김수신", "딸") + val FRIEND = Receiver(FRIEND_ID, "박친구", "친구") + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt new file mode 100644 index 0000000000..e7ec98a6c1 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt @@ -0,0 +1,501 @@ +package com.afternote.feature.afternote.presentation.editor + +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.LeaveMessageBlock +import com.afternote.feature.afternote.domain.model.author.AfternoteAccountCredentials +import com.afternote.feature.afternote.domain.model.author.Detail +import com.afternote.feature.afternote.domain.model.author.DetailContent +import com.afternote.feature.afternote.domain.model.author.DetailCredentials +import com.afternote.feature.afternote.domain.model.author.DetailReceiver +import com.afternote.feature.afternote.domain.model.author.DetailTimestamps +import com.afternote.feature.afternote.domain.model.author.FieldPatch +import com.afternote.feature.afternote.domain.model.author.MemorialSongPayload +import com.afternote.feature.afternote.domain.model.author.MemorialVideoPayload +import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput +import com.afternote.feature.afternote.domain.model.author.playlist.DetailSong +import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia +import com.afternote.feature.afternote.presentation.editor.memorial.Song +import com.afternote.feature.afternote.presentation.editor.message.EditorMessageTextBlock +import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 수정 요청이 **사용자가 만진 슬롯만** 싣는지 고정한다 (#1617). + * + * 종전에는 폼이 든 전체 스냅샷이 매번 통째로 나갔다. 에디터를 연 뒤 서버가 바뀌면, 사용자가 건드린 + * 적도 없는 필드가 낡은 로컬 값으로 덮였다. + * + * 고정하는 축은 셋이다. + * 1. 안 건드린 슬롯은 페이로드에서 빠진다 → 요청에서 키째 빠진다 → 서버가 「유지」로 읽는다. + * 2. **비운 것은 안 건드린 것과 다르다** — 빈 목록·명시적 삭제는 그대로 실린다 (#1596·#1599). + * 3. **판정 단위는 서버가 반영하는 단위와 같다** — 계정 정보는 id·비밀번호를 따로, 플레이리스트는 + * 사진·영상·곡을 따로 잰다. 객체째 재면 안 건드린 형제 슬롯이 낡은 값으로 딸려 나간다. + */ +class AfternoteEditorPartialUpdateTest { + private val socialDetail = + Detail( + id = 7L, + serviceName = "인스타그램", + timestamps = DetailTimestamps(updatedAt = "2026-08-30"), + receivers = + listOf( + DetailReceiver(receiverId = 11L, name = "엄마", relation = "가족"), + DetailReceiver(receiverId = 22L, name = "동생", relation = "가족"), + ), + leaveMessageBlocks = listOf(LeaveMessageBlock(title = "가족에게", body = "고마웠어")), + content = + DetailContent.SocialNetwork( + credentials = DetailCredentials(id = "account", password = "pw"), + processingMethods = listOf("계정 삭제", "사진 백업"), + ), + ) + + private val memorialDetail = + Detail( + id = 9L, + serviceName = "추억 노트", + timestamps = DetailTimestamps(updatedAt = "2026-08-30"), + receivers = emptyList(), + leaveMessageBlocks = emptyList(), + content = + DetailContent.Memorial( + songs = listOf(DetailSong(title = "곡", artist = "가수", coverUrl = null)), + media = + MemorialMedia( + photoUrl = "https://cdn.test/afternotes/photo.jpg", + videoUrl = "https://cdn.test/afternotes/video.mp4", + thumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + ), + ), + ) + + /** 상세를 그대로 되돌린 폼 — 「아무것도 안 건드린 사용자」다. */ + private fun untouchedSocialPayload( + serviceName: String = "인스타그램", + accountId: String = "account", + password: String = "pw", + processingMethods: List = listOf("계정 삭제", "사진 백업"), + messageBlocks: List = + listOf(EditorMessageTextBlock(title = "가족에게", body = "고마웠어", isRegistered = true)), + ) = RegisterAfternotePayload( + serviceName = serviceName, + date = "2026-08-30", + accountId = accountId, + password = password, + messageBlocks = messageBlocks, + processingMethods = processingMethods, + ) + + private fun buildSocialUpdate( + payload: RegisterAfternotePayload = untouchedSocialPayload(), + selectedReceiverIds: List = listOf(11L, 22L), + detail: Detail = socialDetail, + ) = AfternoteEditorFormMapper.buildUpdatePayload( + type = AfternoteType.SOCIAL_NETWORK, + payload = payload, + selectedReceiverIds = selectedReceiverIds, + playlistSongs = emptyList(), + memorialMediaUrls = MemorialMediaUrls(), + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(detail), + ) + + @Test + fun `제목만 고치면 제목 말고는 아무 필드도 실리지 않는다`() { + val updated = buildSocialUpdate(payload = untouchedSocialPayload(serviceName = "새 제목")) + + assertEquals("새 제목", updated.title) + assertNull("처리 방법을 만진 적이 없다", updated.processingMethods) + assertNull("남기실 말씀을 만진 적이 없다", updated.leaveMessageBlocks) + assertNull("계정 정보를 만진 적이 없다", updated.credentials) + assertNull("수신자를 만진 적이 없다", updated.receiverIds) + assertNull("추억 노트가 아니므로 애초에 없다", updated.memorial) + } + + @Test + fun `아무것도 안 고치면 어느 필드도 실리지 않는다`() { + val updated = buildSocialUpdate() + + assertNull(updated.title) + assertNull(updated.processingMethods) + assertNull(updated.leaveMessageBlocks) + assertNull(updated.credentials) + assertNull(updated.receiverIds) + } + + /** 순서가 뜻을 갖지 않는 수신자는 정렬이 달라졌다고 「고쳤다」로 읽지 않는다. */ + @Test + fun `수신자 순서만 바뀐 것은 변경이 아니다`() { + val updated = buildSocialUpdate(selectedReceiverIds = listOf(22L, 11L)) + + assertNull(updated.receiverIds) + } + + @Test + fun `수신자를 실제로 지우면 남은 목록이 실린다`() { + val updated = buildSocialUpdate(selectedReceiverIds = listOf(11L)) + + assertEquals(listOf(11L), updated.receiverIds) + } + + @Test + fun `수신자를 전부 빼면 빈 목록이 실려 전부 삭제로 나간다`() { + val updated = buildSocialUpdate(selectedReceiverIds = emptyList()) + + assertEquals(emptyList(), updated.receiverIds) + } + + @Test + fun `처리 방법을 전부 빼면 빈 목록이 실려 전부 삭제로 나간다`() { + val updated = buildSocialUpdate(payload = untouchedSocialPayload(processingMethods = emptyList())) + + assertEquals(emptyList(), updated.processingMethods) + } + + /** + * 서버는 `actions` 원소를 검증 없이 저장하므로 `[""]` 가 실제로 남아 있을 수 있다. 비교 전에 + * 빈 문자열을 걷어내면 그 행을 지운 저장이 「양쪽 다 빈 목록」으로 상쇄돼 **삭제가 사라진다.** + */ + @Test + fun `서버에 빈 문자열 처리 방법만 있을 때 그 행을 지우면 삭제가 나간다`() { + val detailWithBlank = + socialDetail.copy( + content = + DetailContent.SocialNetwork( + credentials = DetailCredentials(id = "account", password = "pw"), + processingMethods = listOf(""), + ), + ) + val updated = + buildSocialUpdate( + payload = untouchedSocialPayload(processingMethods = emptyList()), + detail = detailWithBlank, + ) + + assertEquals(emptyList(), updated.processingMethods) + } + + @Test + fun `남기실 말씀을 전부 빼면 빈 목록이 실려 전부 삭제로 나간다`() { + val updated = buildSocialUpdate(payload = untouchedSocialPayload(messageBlocks = emptyList())) + + assertEquals(emptyList(), updated.leaveMessageBlocks) + } + + /** + * 서버가 id 와 비밀번호를 **따로** 갱신하므로(`CredentialsRelationSupport.update`), 안 고친 쪽은 + * 실으면 안 된다. 함께 실으면 그 사이 다른 기기가 바꾼 id 가 낡은 값으로 되돌아간다. + */ + @Test + fun `비밀번호만 고치면 아이디는 실리지 않는다`() { + val updated = buildSocialUpdate(payload = untouchedSocialPayload(password = "새 비밀번호")) + + assertEquals("새 비밀번호", updated.credentials?.password) + assertNull("아이디는 만진 적이 없다", updated.credentials?.id) + assertNull("제목은 그대로다", updated.title) + } + + @Test + fun `아이디만 고치면 비밀번호는 실리지 않는다`() { + val updated = buildSocialUpdate(payload = untouchedSocialPayload(accountId = "새 아이디")) + + assertEquals("새 아이디", updated.credentials?.id) + assertNull("비밀번호는 만진 적이 없다", updated.credentials?.password) + } + + /** + * 에디터가 띄워 두는 빈 입력 칸은 도메인으로 넘어가기 전에 버려진다. 그 결과가 서버 원본과 + * 같다면 「고쳤다」가 아니다 — 여기서 흔들리면 저장할 때마다 남기실 말씀이 다시 실린다. + */ + @Test + fun `빈 입력 칸과 앞뒤 공백은 변경으로 세지 않는다`() { + val updated = + buildSocialUpdate( + payload = + untouchedSocialPayload( + messageBlocks = + listOf( + EditorMessageTextBlock(title = " 가족에게 ", body = " 고마웠어 ", isRegistered = true), + EditorMessageTextBlock(title = "", body = "", isRegistered = false), + ), + ), + ) + + assertNull(updated.leaveMessageBlocks) + } + + private fun buildMemorialUpdate( + serviceName: String = "추억 노트", + playlistSongs: List = + listOf(Song(selectionKey = "detail:0", title = "곡", artist = "가수", albumCoverUrl = null)), + memorialMediaUrls: MemorialMediaUrls = + MemorialMediaUrls( + memorialVideoUrl = "https://cdn.test/afternotes/video.mp4", + memorialThumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + memorialPhotoUrl = "https://cdn.test/afternotes/photo.jpg", + ), + ) = AfternoteEditorFormMapper.buildUpdatePayload( + type = AfternoteType.MEMORIAL, + payload = RegisterAfternotePayload(serviceName = serviceName, date = "2026-08-30"), + selectedReceiverIds = emptyList(), + playlistSongs = playlistSongs, + memorialMediaUrls = memorialMediaUrls, + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(memorialDetail), + ) + + /** + * 이슈가 든 재현 경로 그대로다 — 곡을 건드리지 않은 「제목만 수정」이 곡을 지우면 안 된다. + * `playlist` 키가 아예 나가지 않아야 서버가 곡·사진·영상을 통째로 유지한다. + */ + @Test + fun `추억 노트도 제목만 고치면 플레이리스트를 말하지 않는다`() { + val updated = buildMemorialUpdate(serviceName = "새 제목") + + assertEquals("새 제목", updated.title) + assertNull("곡을 건드린 적이 없다", updated.memorial) + } + + /** + * **곡만 고친 저장이 사진·영상을 건드리면 안 된다.** + * + * 사진·영상 슬롯은 기본값이 없는 DTO 로 나가던 동안 `null` 이 곧 삭제였다. 곡을 바꿨다는 이유로 + * 셋을 함께 실으면, 그 사이 다른 기기가 올린 사진·영상이 명시적 null 로 지워진다. + */ + @Test + fun `곡만 고치면 사진과 영상 슬롯은 말하지 않는다`() { + val updated = + buildMemorialUpdate( + playlistSongs = + listOf(Song(selectionKey = "detail:0", title = "새 곡", artist = "가수", albumCoverUrl = null)), + ) + + val memorial = requireNotNull(updated.memorial) { "곡을 고쳤으므로 플레이리스트가 실려야 한다" } + assertEquals( + listOf(MemorialSongPayload(title = "새 곡", artist = "가수", coverUrl = null)), + memorial.songs, + ) + assertEquals("사진을 건드린 적이 없다", FieldPatch.Unchanged, memorial.memorialPhotoUrl) + assertEquals("영상을 건드린 적이 없다", FieldPatch.Unchanged, memorial.memorialVideo) + } + + @Test + fun `곡을 전부 빼면 빈 곡 목록만 실리고 미디어는 말하지 않는다`() { + val updated = buildMemorialUpdate(playlistSongs = emptyList()) + + val memorial = requireNotNull(updated.memorial) { "곡을 지웠으므로 플레이리스트가 실려야 한다" } + assertTrue("곡을 지운 것은 명시적 삭제다", memorial.songs?.isEmpty() == true) + assertEquals(FieldPatch.Unchanged, memorial.memorialPhotoUrl) + assertEquals(FieldPatch.Unchanged, memorial.memorialVideo) + } + + /** 선택 키는 화면 전용 식별자라 서버로 나가지 않는다 — 재조회로 값이 달라져도 변경이 아니다. */ + @Test + fun `곡 선택 키가 달라도 곡 내용이 같으면 변경이 아니다`() { + val updated = + buildMemorialUpdate( + playlistSongs = + listOf(Song(selectionKey = "search:가수|곡|0", title = "곡", artist = "가수", albumCoverUrl = null)), + ) + + assertNull(updated.memorial) + } + + /** 사진만 지운 저장은 사진 슬롯만 삭제로 말하고, 곡·영상은 건드리지 않는다. */ + @Test + fun `영정 사진만 지우면 사진 슬롯만 삭제로 나간다`() { + val updated = + buildMemorialUpdate( + memorialMediaUrls = + MemorialMediaUrls( + memorialVideoUrl = "https://cdn.test/afternotes/video.mp4", + memorialThumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + memorialPhotoUrl = null, + ), + ) + + val memorial = requireNotNull(updated.memorial) { "사진을 지웠으므로 플레이리스트가 실려야 한다" } + assertEquals(FieldPatch.Set(null), memorial.memorialPhotoUrl) + assertNull("곡을 건드린 적이 없다", memorial.songs) + assertEquals("영상을 건드린 적이 없다", FieldPatch.Unchanged, memorial.memorialVideo) + } + + @Test + fun `추모 영상만 바꾸면 영상 슬롯만 실린다`() { + val updated = + buildMemorialUpdate( + memorialMediaUrls = + MemorialMediaUrls( + memorialVideoUrl = "https://cdn.test/afternotes/new-video.mp4", + memorialThumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + memorialPhotoUrl = "https://cdn.test/afternotes/photo.jpg", + ), + ) + + val memorial = requireNotNull(updated.memorial) { "영상을 바꿨으므로 플레이리스트가 실려야 한다" } + assertEquals( + FieldPatch.Set( + MemorialVideoPayload( + videoUrl = "https://cdn.test/afternotes/new-video.mp4", + thumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + ), + ), + memorial.memorialVideo, + ) + assertEquals("사진을 건드린 적이 없다", FieldPatch.Unchanged, memorial.memorialPhotoUrl) + assertNull("곡을 건드린 적이 없다", memorial.songs) + } + + /** 추억 노트는 처리 방법·수신자·계정 정보를 갖지 않는다. 폼에 값이 남아 있어도 싣지 않는다. */ + @Test + fun `추억 노트는 폼에 남은 처리 방법과 수신자와 계정 값을 싣지 않는다`() { + val updated = + AfternoteEditorFormMapper.buildUpdatePayload( + type = AfternoteType.MEMORIAL, + payload = untouchedSocialPayload(serviceName = "추억 노트", messageBlocks = emptyList()), + selectedReceiverIds = listOf(11L), + playlistSongs = + listOf(Song(selectionKey = "detail:0", title = "곡", artist = "가수", albumCoverUrl = null)), + memorialMediaUrls = + MemorialMediaUrls( + memorialVideoUrl = "https://cdn.test/afternotes/video.mp4", + memorialThumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + memorialPhotoUrl = "https://cdn.test/afternotes/photo.jpg", + ), + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(memorialDetail), + ) + + assertEquals(UpdateAfternoteInput(type = AfternoteType.MEMORIAL), updated) + } + + @Test + fun `추억 노트에서 남기실 말씀만 고치면 플레이리스트를 말하지 않는다`() { + val updated = + AfternoteEditorFormMapper.buildUpdatePayload( + type = AfternoteType.MEMORIAL, + payload = + RegisterAfternotePayload( + serviceName = "추억 노트", + date = "2026-08-30", + messageBlocks = listOf(EditorMessageTextBlock(title = "", body = "새 말씀", isRegistered = false)), + ), + selectedReceiverIds = emptyList(), + playlistSongs = + listOf(Song(selectionKey = "detail:0", title = "곡", artist = "가수", albumCoverUrl = null)), + memorialMediaUrls = + MemorialMediaUrls( + memorialVideoUrl = "https://cdn.test/afternotes/video.mp4", + memorialThumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + memorialPhotoUrl = "https://cdn.test/afternotes/photo.jpg", + ), + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(memorialDetail), + ) + + assertEquals( + UpdateAfternoteInput( + type = AfternoteType.MEMORIAL, + leaveMessageBlocks = listOf(LeaveMessageBlock(title = null, body = "새 말씀")), + ), + updated, + ) + } + + private val businessDetail = + socialDetail.copy( + serviceName = "회사 메일", + content = + DetailContent.Business( + credentials = DetailCredentials(id = "work", password = "pw"), + processingMethods = listOf("계정 삭제"), + ), + ) + + private fun buildBusinessUpdate( + accountId: String = "work", + password: String = "pw", + selectedReceiverIds: List = listOf(11L, 22L), + ) = AfternoteEditorFormMapper.buildUpdatePayload( + type = AfternoteType.BUSINESS, + payload = + untouchedSocialPayload( + serviceName = "회사 메일", + accountId = accountId, + password = password, + processingMethods = listOf("계정 삭제"), + ), + selectedReceiverIds = selectedReceiverIds, + playlistSongs = emptyList(), + memorialMediaUrls = MemorialMediaUrls(), + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(businessDetail), + ) + + @Test + fun `비즈니스도 아무것도 안 고치면 어느 필드도 실리지 않는다`() { + assertEquals(UpdateAfternoteInput(type = AfternoteType.BUSINESS), buildBusinessUpdate()) + } + + @Test + fun `비즈니스에서 비밀번호와 수신자만 고치면 그 둘만 실린다`() { + val updated = buildBusinessUpdate(password = "새 비밀번호", selectedReceiverIds = listOf(22L)) + + assertEquals( + UpdateAfternoteInput( + type = AfternoteType.BUSINESS, + credentials = AfternoteAccountCredentials(password = "새 비밀번호"), + receiverIds = listOf(22L), + ), + updated, + ) + } + + private val galleryDetail = + socialDetail.copy( + serviceName = "사진첩", + content = DetailContent.Gallery(processingMethods = listOf("사진 백업")), + ) + + private fun buildGalleryUpdate( + accountId: String = "", + processingMethods: List = listOf("사진 백업"), + selectedReceiverIds: List = listOf(11L, 22L), + ) = AfternoteEditorFormMapper.buildUpdatePayload( + type = AfternoteType.GALLERY_AND_FILES, + payload = + untouchedSocialPayload( + serviceName = "사진첩", + accountId = accountId, + password = "", + processingMethods = processingMethods, + ), + selectedReceiverIds = selectedReceiverIds, + playlistSongs = emptyList(), + memorialMediaUrls = MemorialMediaUrls(), + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(galleryDetail), + ) + + @Test + fun `갤러리도 아무것도 안 고치면 어느 필드도 실리지 않는다`() { + assertEquals(UpdateAfternoteInput(type = AfternoteType.GALLERY_AND_FILES), buildGalleryUpdate()) + } + + /** 갤러리는 계정 정보를 갖지 않는다. 폼에 아이디가 남아 있어도 계정 정보로 싣지 않는다. */ + @Test + fun `갤러리는 폼에 남은 아이디를 계정 정보로 싣지 않는다`() { + assertNull(buildGalleryUpdate(accountId = "남은 아이디").credentials) + } + + @Test + fun `갤러리에서 처리 방법과 수신자를 비우면 빈 목록이 실린다`() { + val updated = buildGalleryUpdate(processingMethods = emptyList(), selectedReceiverIds = emptyList()) + + assertEquals( + UpdateAfternoteInput( + type = AfternoteType.GALLERY_AND_FILES, + processingMethods = emptyList(), + receiverIds = emptyList(), + ), + updated, + ) + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt new file mode 100644 index 0000000000..f72b85f393 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt @@ -0,0 +1,370 @@ +package com.afternote.feature.afternote.presentation.editor + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.repository.UserRepository +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.LeaveMessageBlock +import com.afternote.feature.afternote.domain.model.author.Detail +import com.afternote.feature.afternote.domain.model.author.DetailContent +import com.afternote.feature.afternote.domain.model.author.DetailCredentials +import com.afternote.feature.afternote.domain.model.author.DetailTimestamps +import com.afternote.feature.afternote.domain.repository.author.MediaInput +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.afternoteEditorSavedStateHandle +import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload +import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError +import com.afternote.feature.afternote.presentation.editorFlowRoute +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException +import java.lang.reflect.Proxy + +/** + * 수정 진입 prefill 조회 실패가 «빈 정상 에디터» 로 수렴하지 않는지에 대한 회귀 가드 (#705). + * + * 종전 구현은 실패 시 `isPrefillLoading = false` 만 세워 skeleton 을 걷었다 — 화면에는 신규 작성과 + * 구분되지 않는 빈 폼이 남고, 저장을 누르면 수정(PATCH)이 그 빈 값으로 나가 **기존 기록을 덮었다.** + * 여기서 잠그는 셋은 오류 상태 유지 · 재시도 · 저장 차단이다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteEditorPrefillFailureTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `prefill 실패는 빈 폼이 아니라 오류 상태로 남는다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val viewModel = viewModel(alwaysFailingRepository(), reporter) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + val state = viewModel.uiState.value + assertFalse("skeleton 에 갇히면 안 된다", state.isPrefillLoading) + assertTrue("빈 폼으로 넘기지 말고 실패를 남긴다", state.isPrefillFailed) + assertEquals(listOf("prefill_load"), reporter.recordedStages) + assertEquals(listOf(IOException::class.java.name), reporter.recordedErrorTypes) + } + + @Test + fun `prefill 실패 상태에서는 저장이 나가지 않는다`() = + runTest(dispatcher) { + val repository = alwaysFailingRepository() + val viewModel = viewModel(repository, RecordingErrorReporter()) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + viewModel.saveAfternote( + payload = SAVE_PAYLOAD, + selectedReceiverIds = listOf(7L), + memorialMedia = SaveAfternoteMemorialMedia(), + ) + runCurrent() + + assertTrue("기존 기록을 빈 값으로 덮는 수정이 나가면 안 된다", repository.updateCalls.isEmpty()) + assertEquals(AfternoteEditorError.PrefillUnavailable, viewModel.uiState.value.error) + } + + @Test + fun `다시 불러오기가 성공하면 오류를 걷고 폼을 채운다`() = + runTest(dispatcher) { + var shouldFail = true + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { + if (shouldFail) Result.failure(IOException("상세 조회 실패")) else Result.success(DETAIL) + } + } + val viewModel = viewModel(repository, RecordingErrorReporter()) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + assertTrue(viewModel.uiState.value.isPrefillFailed) + + shouldFail = false + viewModel.retryPrefill() + runCurrent() + + val state = viewModel.uiState.value + assertFalse("성공한 재시도는 오류 상태를 걷는다", state.isPrefillFailed) + assertNotNull("폼에 실을 prefill 이 도착해야 한다", state.pendingPrefill) + assertEquals(2, repository.requestedDetailIds.size) + } + + @Test + fun `재시도가 성공한 뒤에는 저장이 다시 나간다`() = + runTest(dispatcher) { + var shouldFail = true + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { + if (shouldFail) Result.failure(IOException("상세 조회 실패")) else Result.success(DETAIL) + } + onUpdate = { id, _ -> Result.success(id) } + } + val viewModel = viewModel(repository, RecordingErrorReporter()) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + shouldFail = false + viewModel.retryPrefill() + runCurrent() + // 화면이 pendingPrefill 을 폼·TextFieldState 에 실은 뒤 통보하는 단계. 이걸 거쳐야 + // isPrefillLoading 이 내려간다 — 저장 가드가 «읽는 중» 도 막으므로 (#705) 여기서 + // 생략하면 폼이 아직 비어 있는 상태의 저장이 되어 정상 경로가 아니다. + viewModel.onPrefillConsumed() + runCurrent() + viewModel.saveAfternote( + payload = SAVE_PAYLOAD, + selectedReceiverIds = listOf(7L), + memorialMedia = SaveAfternoteMemorialMedia(), + ) + runCurrent() + + assertEquals(listOf(ITEM_ID), repository.updateCalls.map { it.first }) + assertNull(viewModel.uiState.value.error) + } + + @Test + fun `prefill 실패 안내는 저장 실패와 다른 문구를 쓴다`() { + assertEquals( + R.string.afternote_editor_prefill_load_failed, + AfternoteEditorError.PrefillUnavailable.messageResId(), + ) + } + + @Test + fun `prefill 실패 동안에는 이탈 기준선을 잡지 않는다`() { + assertTrue( + shouldDeferEditorBaselineCapture( + isPrefillLoading = false, + isProcessingMethodDefaultsInitializing = false, + isPrefillFailed = true, + ), + ) + } + + @Test + fun `prefill 이 아직 도착하지 않았으면 저장이 나가지 않는다`() = + runTest(dispatcher) { + // 상세 GET 이 영영 끝나지 않는 저장소 — skeleton 이 떠 있는 창을 그대로 붙잡아 둔다. + val neverCompleting = + FakeAfternoteRepository.strict().apply { + onGetDetail = { awaitCancellation() } + onUpdate = { _, _ -> error("prefill 이 도착하기 전에 수정이 나가면 안 됩니다") } + } + val viewModel = viewModel(neverCompleting, RecordingErrorReporter()) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + assertTrue("전제: 아직 prefill 을 읽는 중이어야 한다", viewModel.uiState.value.isPrefillLoading) + assertFalse("이 경로는 실패가 아니다", viewModel.uiState.value.isPrefillFailed) + + viewModel.saveAfternote( + payload = SAVE_PAYLOAD, + selectedReceiverIds = listOf(7L), + memorialMedia = SaveAfternoteMemorialMedia(), + ) + runCurrent() + + assertTrue( + "느린 상세 GET 을 앞질러 저장하면 기존 기록이 빈 폼으로 덮인다", + neverCompleting.updateCalls.isEmpty(), + ) + assertEquals( + "아직 읽는 중인 사용자에게 «불러오지 못했습니다» 라고 말하면 사실과 다르다", + AfternoteEditorError.PrefillNotReady, + viewModel.uiState.value.error, + ) + } + + @Test + fun `읽는 중과 실패는 서로 다른 문구로 갈린다`() { + assertEquals( + R.string.afternote_editor_prefill_not_ready, + AfternoteEditorError.PrefillNotReady.messageResId(), + ) + assertNotEquals( + "저장을 막는 이유가 같아도 사용자에게 할 말이 다르다", + AfternoteEditorError.PrefillUnavailable.messageResId(), + AfternoteEditorError.PrefillNotReady.messageResId(), + ) + } + + /** + * `retryPrefill` 의 널 가지를 잠근다. + * + * 신규 작성 진입은 itemId 가 없어 prefill 자체가 돌지 않고, 화면도 실패 상태에서만 재시도 버튼을 + * 그리므로 이 호출은 실제로는 도달하지 않는다. 그래도 함수가 public 이라 밖에서 부를 수 있고, + * 조기 반환을 지우면 조회가 나가거나 강제 언랩이 필요해진다 — 그 회귀를 여기서 잡는다. + */ + @Test + fun `신규 작성 진입에서 재시도를 불러도 상세 조회가 나가지 않는다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { error("신규 작성에는 읽어 올 기존 기록이 없습니다") } + } + val viewModel = + AfternoteEditorViewModel( + route = AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.SOCIAL_NETWORK), + savedStateHandle = + afternoteEditorSavedStateHandle(initialType = AfternoteType.SOCIAL_NETWORK, itemId = null), + userRepository = unusedProxy(), + afternoteRepository = repository, + memorialThumbnailUploadRepository = + MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, + resolveMemorialMediaForSave = + ResolveMemorialMediaForSaveUseCase( + MemorialMediaUploadRepository { input, _ -> + if (input == MediaInput.None) Result.success(null) else error("미디어 업로드가 호출되면 안 됩니다") + }, + ), + errorReporter = RecordingErrorReporter(), + ) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + assertFalse("전제: 신규 작성은 prefill 을 돌리지 않는다", viewModel.uiState.value.isPrefillLoading) + + viewModel.retryPrefill() + runCurrent() + + assertFalse("재시도가 신규 작성에서 skeleton 을 세우면 안 된다", viewModel.uiState.value.isPrefillLoading) + assertFalse("실패 상태로도 넘어가면 안 된다", viewModel.uiState.value.isPrefillFailed) + } + + @Test + fun `prefill 진행 중에는 등록 버튼이 잠긴다`() { + assertFalse( + "skeleton 이 떠 있는 동안 등록이 눌리면 ViewModel 가드까지 가서야 막힌다", + isEditorSubmitEnabled(isSaving = false, isPrefillFailed = false, isPrefillLoading = true), + ) + assertTrue( + "prefill 이 끝난 정상 편집 상태에서는 잠기지 않는다", + isEditorSubmitEnabled(isSaving = false, isPrefillFailed = false, isPrefillLoading = false), + ) + } + + private fun alwaysFailingRepository(): FakeAfternoteRepository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.failure(IOException("상세 조회 실패")) } + onUpdate = { _, _ -> error("prefill 을 못 읽은 채 수정이 나가면 안 됩니다") } + } + + private fun viewModel( + repository: FakeAfternoteRepository, + errorReporter: ErrorReporter, + ): AfternoteEditorViewModel { + val editorHandle = editorSavedStateHandle() + return AfternoteEditorViewModel( + route = editorHandle.editorFlowRoute(), + savedStateHandle = editorHandle, + userRepository = unusedProxy(), + afternoteRepository = repository, + memorialThumbnailUploadRepository = + MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, + resolveMemorialMediaForSave = + ResolveMemorialMediaForSaveUseCase( + // 이 테스트의 폼에는 추억 노트 미디어가 없다 — None 만 해석하고 업로드는 닫아 둔다. + MemorialMediaUploadRepository { input, _ -> + if (input == MediaInput.None) { + Result.success(null) + } else { + error("미디어 업로드가 호출되면 안 됩니다") + } + }, + ), + errorReporter = errorReporter, + ) + } + + private fun editorSavedStateHandle(): SavedStateHandle = + afternoteEditorSavedStateHandle( + initialType = AfternoteType.SOCIAL_NETWORK, + itemId = ITEM_ID, + ) + + private inline fun unusedProxy(): T = + Proxy.newProxyInstance( + T::class.java.classLoader, + arrayOf(T::class.java), + ) { _, method, _ -> error("${T::class.java.simpleName}.${method.name} 호출은 이 테스트에서 예상하지 않았습니다") } as T + + /** + * [ErrorReporter] 는 예외를 redact 하므로 원문으로는 단언할 수 없다 — 남는 것은 `afternote_stage` 와 + * 창구가 붙이는 `error_type` 뿐이다. + */ + private class RecordingErrorReporter : ErrorReporter { + val recordedStages = mutableListOf() + val recordedErrorTypes = mutableListOf() + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + attributes["afternote_stage"]?.let(recordedStages::add) + attributes["error_type"]?.let(recordedErrorTypes::add) + } + } + + private companion object { + const val ITEM_ID = 42L + + /** 검증(SOCIAL_NETWORK 계정 필수)을 통과하는 최소 페이로드 — 막히는 지점이 prefill 가드임을 분명히 한다. */ + val SAVE_PAYLOAD = + RegisterAfternotePayload( + serviceName = "인스타그램", + date = "2026.08.30", + accountId = "account", + password = "password", + ) + + val DETAIL = + Detail( + id = ITEM_ID, + serviceName = "인스타그램", + timestamps = DetailTimestamps(updatedAt = "2026-08-30"), + receivers = emptyList(), + leaveMessageBlocks = listOf(LeaveMessageBlock(title = null, body = "부탁해")), + content = + DetailContent.SocialNetwork( + credentials = DetailCredentials(id = "account", password = "password"), + processingMethods = listOf("계정 삭제"), + ), + ) + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt new file mode 100644 index 0000000000..8b7a0e896d --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -0,0 +1,139 @@ +package com.afternote.feature.afternote.presentation.editor + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.author.Detail +import com.afternote.feature.afternote.domain.model.author.DetailContent +import com.afternote.feature.afternote.domain.model.author.DetailTimestamps +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.presentation.editor.model.EditorContentPrefill +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * **알려진 한계를 드러내는 가드** — 프로세스 사망 복원이 사용자의 미저장 편집을 서버 값으로 덮는다. + * + * 복원 경로는 두 갈래가 겹친다: `SavedStateHandle` 의 폼 스냅샷이 편집분을 되살리고, 곧이어 + * `init` 의 상세 재조회가 `pendingPrefill` 을 다시 발행해 UI 가 폼을 서버 값으로 덮어쓴다. + * 이 테스트는 그 두 번째 발행이 **폼이 더러운지 묻지 않고** 일어난다는 사실을 고정한다. + * + * 이 축은 #1617 의 부분 PATCH 가 만든 것이 아니고 고치지도 않는다 — 스냅샷 복원과 프리필 재적용이 + * 함께 있던 종전부터의 동작이다. 부분 PATCH 는 오히려 이 경로를 **덜 위험하게** 만든다: 덮인 폼은 + * 서버 값과 같아져 「바뀐 것 없음」으로 아무 슬롯도 실리지 않고, 프리필이 도착하기 전 저장을 + * 시도하면 기준이 없다는 이유로 [AfternoteEditorMissingBaselineTest] 의 가드가 막는다. + * + * 제대로 고치려면 「복원된 편집이 있으면 프리필을 덮어쓰지 않거나 사용자에게 고를 기회를 준다」는 + * 화면 흐름 결정이 필요하다 — 편집 유실 안내·충돌 해소 UI 는 #1617 의 해야 할 일 2번(서버 거절 시 + * 화면 동작)과 같은 결의 별도 축이라 그쪽에서 다룬다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteEditorProcessDeathPrefillTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `복원된 편집이 있어도 상세 재조회 프리필이 다시 발행된다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.success(serverDetail()) } + } + val viewModel = viewModel(repository) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + // 복원 직후의 폼은 사용자가 고친 제목을 들고 있다. + assertEquals(EDITED_SERVICE, viewModel.currentForm().selectedService) + + val prefill = viewModel.uiState.value.pendingPrefill + assertNotNull("상세 재조회는 폼이 더러운지 묻지 않고 프리필을 발행한다", prefill) + assertEquals( + "그 프리필은 서버 값이라, UI 가 적용하면 사용자의 편집이 사라진다", + SERVER_SERVICE, + (prefill?.content as EditorContentPrefill.Gallery).serviceName, + ) + } + + private fun serverDetail() = + Detail( + id = EDIT_ID, + serviceName = SERVER_SERVICE, + timestamps = DetailTimestamps(updatedAt = "2026-08-30"), + receivers = emptyList(), + leaveMessageBlocks = emptyList(), + content = DetailContent.Gallery(processingMethods = listOf("파일 전달")), + ) + + /** 프로세스 사망 전에 저장돼 있던 폼 스냅샷 — 사용자가 제목을 고쳐 둔 상태다. */ + private fun restoredSnapshot(): String = + """ + {"type":"GALLERY_AND_FILES","selectedService":"$EDITED_SERVICE","receivers":[], + "processingMethods":[],"memorialPlaylistSongs":[]} + """.trimIndent().replace("\n", "") + + private fun viewModel(afternoteRepository: FakeAfternoteRepository): AfternoteEditorViewModel = + AfternoteEditorViewModel( + route = AfternoteRoute.EditorFlowRoute(itemId = EDIT_ID, initialType = AfternoteType.GALLERY_AND_FILES), + savedStateHandle = + SavedStateHandle( + mapOf( + "initialType" to AfternoteType.GALLERY_AND_FILES, + "itemId" to EDIT_ID, + "editor_form_snapshot_v6" to restoredSnapshot(), + ), + ), + userRepository = FakeUserRepository.strict(), + afternoteRepository = afternoteRepository, + memorialThumbnailUploadRepository = + MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, + resolveMemorialMediaForSave = + ResolveMemorialMediaForSaveUseCase( + MemorialMediaUploadRepository { _, _ -> Result.success(null) }, + ), + errorReporter = NoopErrorReporter, + ) + + private object NoopErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit + } + + private companion object { + const val EDIT_ID = 73L + const val EDITED_SERVICE = "사용자가 고친 제목" + const val SERVER_SERVICE = "구글 포토" + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt index 5fcac38002..bf16a8850b 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt @@ -10,6 +10,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload +import com.afternote.feature.afternote.presentation.editorFlowRoute import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -91,6 +92,7 @@ class AfternoteEditorProcessingMethodDefaultsTest { private fun viewModel(savedStateHandle: SavedStateHandle): AfternoteEditorViewModel = AfternoteEditorViewModel( + route = savedStateHandle.editorFlowRoute(), savedStateHandle = savedStateHandle, userRepository = repositoryProxy(), afternoteRepository = repositoryProxy(), diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt index 40423b1c2b..edaec4efd9 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt @@ -11,6 +11,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnai import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -115,6 +116,7 @@ class AfternoteEditorReceiverSelectionRecoveryTest { private fun viewModel(userRepository: FakeUserRepository): AfternoteEditorViewModel = AfternoteEditorViewModel( + route = AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.SOCIAL_NETWORK), savedStateHandle = SavedStateHandle(mapOf("initialType" to AfternoteType.SOCIAL_NETWORK)), userRepository = userRepository, afternoteRepository = unusedProxy(), @@ -142,6 +144,6 @@ class AfternoteEditorReceiverSelectionRecoveryTest { private companion object { const val RECEIVER_ID = 7L - val RECEIVER = Receiver(RECEIVER_ID, "김수신", "딸", "fake-auth-7") + val RECEIVER = Receiver(RECEIVER_ID, "김수신", "딸") } } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt index cd8ef66d5b..c7a82854b7 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt @@ -10,8 +10,10 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.presentation.editor.state.MemorialVideoAttachment +import com.afternote.feature.afternote.presentation.editorFlowRoute import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -41,12 +43,7 @@ class AfternoteEditorReceiverSnapshotTest { } @Test - fun `v4 영상 편집 상태는 왕복 후 새 선택 제거 시 서버 원본으로 돌아간다`() { - val persisted = - MemorialVideoAttachment( - url = "https://cdn.test/farewell.mp4", - thumbnailUrl = "https://cdn.test/farewell-thumb.jpg", - ) + fun `v6 영상 편집 상태는 왕복 뒤에도 출처를 유지하고 삭제는 슬롯을 비운다`() { val selection = MemorialVideoAttachment( url = "content://videos/replacement", @@ -61,11 +58,8 @@ class AfternoteEditorReceiverSnapshotTest { { "type":"MEMORIAL", "memorialVideo":{ - "persisted":{ - "url":"${persisted.url}", - "thumbnailUrl":"${persisted.thumbnailUrl}" - }, - "selection":{ + "type":"pending_upload", + "video":{ "url":"${selection.url}", "thumbnailUrl":"${selection.thumbnailUrl}" } @@ -77,7 +71,7 @@ class AfternoteEditorReceiverSnapshotTest { val viewModel = viewModel(savedStateHandle) assertEquals(selection, viewModel.currentForm().displayedMemorialVideo) - assertTrue(viewModel.currentForm().canDiscardMemorialVideoSelection) + assertTrue(viewModel.currentForm().canRemoveMemorialVideo) viewModel.setMemorialThumbnail("https://cdn.test/round-trip-thumb.jpg") val roundTrippedRaw = requireNotNull(savedStateHandle.get(SNAPSHOT_KEY)) @@ -90,17 +84,19 @@ class AfternoteEditorReceiverSnapshotTest { roundTripped.displayedMemorialVideo, ) assertEquals(MediaInput.Local(selection.url), roundTripped.memorialVideo?.toMediaInput()) - assertTrue(roundTripped.canDiscardMemorialVideoSelection) + assertTrue(roundTripped.canRemoveMemorialVideo) - restoredViewModel.setMemorialVideo(null) + restoredViewModel.removeMemorialVideo() - assertEquals(persisted, restoredViewModel.currentForm().displayedMemorialVideo) - assertEquals(MediaInput.Remote(persisted.url), restoredViewModel.currentForm().memorialVideo?.toMediaInput()) - assertFalse(restoredViewModel.currentForm().canDiscardMemorialVideoSelection) + // 삭제는 슬롯을 비운다 — 저장 시 명시적 null 로 나간다(#1597). + assertNull(restoredViewModel.currentForm().displayedMemorialVideo) + assertEquals(MediaInput.None, restoredViewModel.currentForm().memorialVideo?.toMediaInput()) + assertFalse(restoredViewModel.currentForm().canRemoveMemorialVideo) } private fun viewModel(savedStateHandle: SavedStateHandle): AfternoteEditorViewModel = AfternoteEditorViewModel( + route = savedStateHandle.editorFlowRoute(), savedStateHandle = savedStateHandle, userRepository = repositoryProxy(), afternoteRepository = repositoryProxy(), @@ -120,6 +116,6 @@ class AfternoteEditorReceiverSnapshotTest { ) { _, method, _ -> error("${T::class.java.simpleName}.${method.name} 호출은 이 테스트에서 예상하지 않았습니다") } as T private companion object { - const val SNAPSHOT_KEY = "editor_form_snapshot_v4" + const val SNAPSHOT_KEY = "editor_form_snapshot_v6" } } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt new file mode 100644 index 0000000000..a8ba6767a2 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt @@ -0,0 +1,251 @@ +package com.afternote.feature.afternote.presentation.editor + +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.author.Detail +import com.afternote.feature.afternote.domain.model.author.DetailContent +import com.afternote.feature.afternote.domain.model.author.DetailTimestamps +import com.afternote.feature.afternote.domain.model.author.FieldPatch +import com.afternote.feature.afternote.domain.model.author.playlist.DetailSong +import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia +import com.afternote.feature.afternote.domain.repository.author.MediaInput +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.presentation.NoopAuthorErrorReporter +import com.afternote.feature.afternote.presentation.afternoteAuthorUserRepository +import com.afternote.feature.afternote.presentation.afternoteEditorSavedStateHandle +import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo +import com.afternote.feature.afternote.presentation.editor.state.EditorFormState +import com.afternote.feature.afternote.presentation.editorFlowRoute +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +/** 수정 상세의 서버 미디어 삭제가 저장·재진입까지 유지되는지 검증한다(#1597). */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteEditorServerMediaDeleteSaveTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `서버 사진과 영상을 지운 전체 폼을 저장하면 payload와 새 수정 진입에서도 빈다`() = + runTest(dispatcher) { + val repository = FakeAfternoteRepository(initialDetails = mapOf(AFTERNOTE_ID to serverMemorialDetail())) + val first = viewModel(repository) + collectState(first) + applyLoadedPrefill(first) + assertServerMediaAndSongs(first.currentForm()) + + first.removeMemorialPhoto() + first.removeMemorialVideo() + val deletedForm = first.currentForm() + + assertDeletedMediaAndSongs(deletedForm) + first.saveAfternote( + payload = validMemorialPayload(), + selectedReceiverIds = emptyList(), + memorialMedia = deletedForm.fullMemorialMediaForSave(), + ) + advanceUntilIdle() + + val updatePayload = repository.updateCalls.single().second + val memorial = requireNotNull(updatePayload.memorial) + assertEquals(FieldPatch.Set(null), memorial.memorialPhotoUrl) + assertEquals(FieldPatch.Set(null), memorial.memorialVideo) + assertNull(memorial.songs) // Unchanged songs remain on the server. + assertEquals(AFTERNOTE_ID, first.uiState.value.savedId) + + val updatedMedia = repository.details.getValue(AFTERNOTE_ID).memorialMedia() + assertNull(updatedMedia.photoUrl) + assertNull(updatedMedia.videoUrl) + assertNull(updatedMedia.thumbnailUrl) + + val reentered = viewModel(repository) + collectState(reentered) + applyLoadedPrefill(reentered) + + assertDeletedMediaAndSongs(reentered.currentForm()) + } + + @Test + fun `저장이 실패해도 삭제한 폼을 유지해 같은 null 미디어로 재시도한다`() = + runTest(dispatcher) { + val updateResults = + ArrayDeque( + listOf( + Result.failure(IOException("offline")), + Result.success(AFTERNOTE_ID), + ), + ) + val repository = + FakeAfternoteRepository(initialDetails = mapOf(AFTERNOTE_ID to serverMemorialDetail())).apply { + onUpdate = { _, _ -> updateResults.removeFirst() } + } + val viewModel = viewModel(repository) + collectState(viewModel) + applyLoadedPrefill(viewModel) + viewModel.removeMemorialPhoto() + viewModel.removeMemorialVideo() + + viewModel.saveCurrentMemorialForm() + advanceUntilIdle() + + assertNotNull(viewModel.uiState.value.error) + assertNull(viewModel.uiState.value.savedId) + assertDeletedMediaAndSongs(viewModel.currentForm()) + + viewModel.saveCurrentMemorialForm() + advanceUntilIdle() + + assertEquals(AFTERNOTE_ID, viewModel.uiState.value.savedId) + assertEquals(2, repository.updateCalls.size) + repository.updateCalls.forEach { (_, payload) -> + val memorial = requireNotNull(payload.memorial) + assertEquals(FieldPatch.Set(null), memorial.memorialPhotoUrl) + assertEquals(FieldPatch.Set(null), memorial.memorialVideo) + assertNull(memorial.songs) + } + } + + private fun TestScope.collectState(viewModel: AfternoteEditorViewModel) { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + } + + private fun TestScope.applyLoadedPrefill(viewModel: AfternoteEditorViewModel) { + advanceUntilIdle() + val prefill = requireNotNull(viewModel.uiState.value.pendingPrefill) + viewModel.applyPrefill(prefill) + viewModel.onPrefillConsumed() + advanceUntilIdle() + } + + private fun AfternoteEditorViewModel.saveCurrentMemorialForm() { + val form = currentForm() + saveAfternote( + payload = validMemorialPayload(), + selectedReceiverIds = emptyList(), + memorialMedia = form.fullMemorialMediaForSave(), + ) + } + + private fun EditorFormState.fullMemorialMediaForSave(): SaveAfternoteMemorialMedia = + SaveAfternoteMemorialMedia( + memorialVideo = memorialVideo ?: EditableMemorialVideo.empty(), + memorialPhotoUrl = memorialPhotoUrl, + pickedMemorialPhotoUri = pickedMemorialPhotoUri, + ) + + private fun assertServerMediaAndSongs(form: EditorFormState) { + assertEquals("https://cdn.test/portrait.jpg", form.memorialPhotoUrl) + assertEquals("https://cdn.test/farewell.mp4", form.displayedMemorialVideo?.url) + assertEquals("https://cdn.test/thumbnail.jpg", form.displayedMemorialVideo?.thumbnailUrl) + assertEquals(listOf("배경음악"), form.memorialPlaylistSongs.map { it.title }) + } + + private fun assertDeletedMediaAndSongs(form: EditorFormState) { + assertNull(form.pickedMemorialPhotoUri) + assertNull(form.memorialPhotoUrl) + assertNull(form.displayMemorialPhotoUri()) + assertNull(form.displayedMemorialVideo?.url) + assertNull(form.displayedMemorialVideo?.thumbnailUrl) + assertEquals(listOf("배경음악"), form.memorialPlaylistSongs.map { it.title }) + } + + private fun viewModel(repository: FakeAfternoteRepository): AfternoteEditorViewModel { + val savedStateHandle = + afternoteEditorSavedStateHandle( + initialType = AfternoteType.MEMORIAL, + itemId = AFTERNOTE_ID, + ) + return AfternoteEditorViewModel( + route = savedStateHandle.editorFlowRoute(), + savedStateHandle = savedStateHandle, + userRepository = afternoteAuthorUserRepository(), + afternoteRepository = repository, + memorialThumbnailUploadRepository = + MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, + resolveMemorialMediaForSave = + ResolveMemorialMediaForSaveUseCase( + MemorialMediaUploadRepository { input, _ -> + when (input) { + MediaInput.None -> Result.success(null) + is MediaInput.Remote -> Result.success(input.url) + is MediaInput.Local -> error("삭제 저장에서 로컬 미디어를 업로드하면 안 됩니다") + } + }, + ), + errorReporter = NoopAuthorErrorReporter, + ) + } + + private fun validMemorialPayload(): RegisterAfternotePayload = + RegisterAfternotePayload( + serviceName = "추억 노트", + date = "2026.08.30", + ) + + private fun serverMemorialDetail(): Detail = + Detail( + id = AFTERNOTE_ID, + serviceName = "추억 노트", + timestamps = DetailTimestamps(updatedAt = "2026.08.30"), + receivers = emptyList(), + leaveMessageBlocks = emptyList(), + content = + DetailContent.Memorial( + songs = + listOf( + DetailSong( + title = "배경음악", + artist = "작곡가", + coverUrl = "https://cdn.test/cover.jpg", + ), + ), + media = + MemorialMedia( + photoUrl = "https://cdn.test/portrait.jpg", + videoUrl = "https://cdn.test/farewell.mp4", + thumbnailUrl = "https://cdn.test/thumbnail.jpg", + ), + ), + ) + + private fun Detail.memorialMedia(): MemorialMedia = (content as DetailContent.Memorial).media + + private companion object { + const val AFTERNOTE_ID = 1597L + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorSubmitGateTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorSubmitGateTest.kt new file mode 100644 index 0000000000..25272ba9e0 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorSubmitGateTest.kt @@ -0,0 +1,74 @@ +package com.afternote.feature.afternote.presentation.editor + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.editor.state.EditorFormState +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * «등록» 액션이 진행 상태를 화면에 싣는지에 대한 회귀 가드 (#705). + * + * 종전에는 ViewModel 의 `isSaving` 이 Screen 에 전달되지 않아 저장 왕복 중에도 유휴 상태와 똑같이 + * 보였고, 연타가 그대로 통과했다. prefill 실패 중 잠금도 같은 배선을 쓴다 — 그쪽은 눌리면 빈 폼이 + * 기존 기록을 덮으므로 화면 차원의 차단이 필요하다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteEditorSubmitGateTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `등록이 열려 있으면 클릭이 저장으로 이어진다`() { + var clicks = 0 + setEditor(isSubmitEnabled = true) { clicks += 1 } + + composeRule.onNodeWithText(submitLabel).assertIsDisplayed().performClick() + + composeRule.runOnIdle { assertEquals(1, clicks) } + } + + @Test + fun `저장 중이거나 prefill 을 못 읽었으면 등록 클릭이 통하지 않는다`() { + var clicks = 0 + setEditor(isSubmitEnabled = false) { clicks += 1 } + + composeRule.onNodeWithText(submitLabel).assertIsDisplayed().performClick() + + composeRule.runOnIdle { assertEquals("잠긴 등록은 저장을 시작하지 않는다", 0, clicks) } + } + + private val submitLabel: String + get() = composeRule.activity.getString(R.string.afternote_editor_submit) + + private fun setEditor( + isSubmitEnabled: Boolean, + onRegisterClick: () -> Unit, + ) { + composeRule.setContent { + AfternoteTheme { + AfternoteEditorScreen( + form = EditorFormState(), + onBackClick = {}, + onRegisterClick = onRegisterClick, + snackbarMessage = null, + onSnackbarMessageConsumed = {}, + validationMessage = null, + onValidationMessageConsumed = {}, + content = {}, + isSubmitEnabled = isSubmitEnabled, + ) + } + } + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt index e6295111ea..20869c74b1 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt @@ -9,6 +9,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -150,6 +151,7 @@ class AfternoteEditorThumbnailRetryTest { private fun viewModel(uploads: ThumbnailUploads = ThumbnailUploads()): AfternoteEditorViewModel = AfternoteEditorViewModel( + route = AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.MEMORIAL), savedStateHandle = SavedStateHandle(mapOf("initialType" to AfternoteType.MEMORIAL)), userRepository = repositoryProxy(), afternoteRepository = repositoryProxy(), diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt index bf40660bf1..d1cdf84eac 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt @@ -11,6 +11,7 @@ import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMedi import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.editor.state.AfternoteValidationError +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -102,6 +103,7 @@ class AfternoteEditorValidationEventTest { private fun viewModel(): AfternoteEditorViewModel = AfternoteEditorViewModel( + route = AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.SOCIAL_NETWORK), savedStateHandle = SavedStateHandle(mapOf("initialType" to AfternoteType.SOCIAL_NETWORK)), userRepository = repositoryProxy(), afternoteRepository = repositoryProxy(), diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt index c1a007ac55..10bd398cdd 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt @@ -36,7 +36,9 @@ class EditorContentSignatureCompletenessTest { setType = {}, setService = {}, setMemorialPhoto = {}, + removeMemorialPhoto = {}, setMemorialVideo = {}, + removeMemorialVideo = {}, addReceiverIfAbsent = { _, _, _ -> }, applyPrefill = {}, setMemorialThumbnail = {}, @@ -193,28 +195,40 @@ class EditorContentSignatureCompletenessTest { } /** - * 서버 원본은 prefill 이 채우고 그 뒤 변하지 않으므로 지금은 지문에 넣어도 결과가 같다. - * 이 카나리는 그 우연이 아니라 「사용자 입력만 센다」는 계약을 고정한다 — 저장 성공 후 서버 값 - * 새로고침처럼 원본만 갱신되는 경로가 생기면, 제외가 없는 순간 손대지 않은 폼이 «변경됨» 이 된다. + * #1561 은 「prefill 이 채운 서버 원본은 지문에서 제외된다」 는 카나리로 «사용자 입력만 센다» 를 잠갔다. + * #1597 이 서버 원본 삭제를 열면서 그 존재 자체가 사용자가 바꿀 수 있는 사실이 됐으므로 지문은 수정 + * 진입 기준선과 비교해 서버 원본을 포함한다. 저장 성공 후 서버 값만 새로고침하는 경로가 생기면 그때 + * 기준선을 다시 잡아야 한다 — 제외로 되돌리면 서버 삭제가 이탈 경고 없이 사라진다. */ @Test - fun `prefill 이 채운 서버 원본은 지문에서 제외된다`() { + fun `서버 영상 삭제는 지문에 반영되고 서버 썸네일만 달라지면 제외된다`() { val state = newState() - val pristine = editorContentSignature(EditorFormState(typeForm = AfternoteTypeForm.Memorial()), state) + val serverVideo = + MemorialVideoAttachment( + url = "https://cdn.test/farewell.mp4", + thumbnailUrl = "https://cdn.test/farewell-thumb.jpg", + ) + val withServerVideo = + editorContentSignature( + EditorFormState(typeForm = AfternoteTypeForm.Memorial(video = EditableMemorialVideo.fromServer(serverVideo))), + state, + ) + assertNotEquals( + "서버 원본 삭제는 저장하지 않고 이탈할 때 경고해야 한다", + withServerVideo, + editorContentSignature(EditorFormState(typeForm = AfternoteTypeForm.Memorial()), state), + ) assertEquals( - "서버 원본은 prefill 이 채운 값이라 사용자 입력이 아니다", - pristine, + "서버 썸네일은 영상에서 파생된 값이라 지문에서 제외한다", + withServerVideo, editorContentSignature( EditorFormState( typeForm = AfternoteTypeForm.Memorial( video = - EditableMemorialVideo.fromPersisted( - MemorialVideoAttachment( - url = "https://cdn.test/farewell.mp4", - thumbnailUrl = "https://cdn.test/farewell-thumb.jpg", - ), + EditableMemorialVideo.fromServer( + serverVideo.copy(thumbnailUrl = "https://cdn.test/another-thumb.jpg"), ), ), ), diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorLeaveMessageBlocksTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorLeaveMessageBlocksTest.kt index 37745e93da..c2f737e706 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorLeaveMessageBlocksTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorLeaveMessageBlocksTest.kt @@ -3,6 +3,11 @@ package com.afternote.feature.afternote.presentation.editor import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput +import com.afternote.feature.afternote.domain.model.author.Detail +import com.afternote.feature.afternote.domain.model.author.DetailContent +import com.afternote.feature.afternote.domain.model.author.DetailCredentials +import com.afternote.feature.afternote.domain.model.author.DetailTimestamps +import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia import com.afternote.feature.afternote.presentation.editor.message.EditorMessageTextBlock import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.editor.state.AfternoteTypeForm @@ -93,7 +98,11 @@ class EditorLeaveMessageBlocksTest { ), selectedReceiverIds = emptyList(), playlistSongs = emptyList(), - memorialMedia = MemorialMediaUrls(), + memorialMediaUrls = MemorialMediaUrls(), + // 여기서 보는 것은 블록을 *어떻게 빚는가* 다. 말씀이 없던 노트를 기준으로 삼아 + // 「달라졌다」가 확실하게 성립하게 두고, 빚어진 모양만 본다 — 안 건드린 필드를 빼는 + // 축은 AfternoteEditorPartialUpdateTest 가 따로 고정한다 (#1617). + baseline = emptyBaseline(AfternoteType.SOCIAL_NETWORK), ) assertEquals( @@ -130,7 +139,10 @@ class EditorLeaveMessageBlocksTest { payload = payloadOf(EditorMessageTextBlock(title = "가족에게", body = "잘 지내")), selectedReceiverIds = emptyList(), playlistSongs = emptyList(), - memorialMedia = MemorialMediaUrls(), + memorialMediaUrls = MemorialMediaUrls(), + // 여기서 보는 것은 블록을 *어떻게 빚는가* 다. 말씀이 없던 노트를 기준으로 삼아 + // 「달라졌다」가 확실하게 성립하게 두고, 빚어진 모양만 본다 (#1617). + baseline = emptyBaseline(AfternoteType.MEMORIAL), ) assertEquals( @@ -170,4 +182,32 @@ class EditorLeaveMessageBlocksTest { date = "2026.08.07", messageBlocks = blocks.toList(), ) + + /** 말씀도 내용도 없던 노트 — 폼에 무엇이 들어 있든 「달라졌다」가 성립하는 기준. */ + private fun emptyBaseline(type: AfternoteType) = + AfternoteEditorFormMapper.buildUpdateBaseline( + Detail( + id = 1L, + serviceName = "", + timestamps = DetailTimestamps(updatedAt = "2026-08-30"), + receivers = emptyList(), + leaveMessageBlocks = emptyList(), + content = + when (type) { + AfternoteType.MEMORIAL -> { + DetailContent.Memorial( + songs = emptyList(), + media = MemorialMedia(null, null, null), + ) + } + + else -> { + DetailContent.SocialNetwork( + credentials = DetailCredentials(id = "", password = ""), + processingMethods = emptyList(), + ) + } + }, + ), + ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorPrefillErrorBodyTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorPrefillErrorBodyTest.kt new file mode 100644 index 0000000000..088126fcdf --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorPrefillErrorBodyTest.kt @@ -0,0 +1,38 @@ +package com.afternote.feature.afternote.presentation.editor + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.presentation.R +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** prefill 실패 본문이 사유와 복구 수단을 모두 내놓는지 (#705). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class EditorPrefillErrorBodyTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `실패 사유와 다시 불러오기를 함께 보여주고 클릭을 전달한다`() { + var retries = 0 + composeRule.setContent { + AfternoteTheme { EditorPrefillErrorBody(onRetry = { retries += 1 }) } + } + + composeRule.onNodeWithText(string(R.string.afternote_editor_prefill_load_failed)).assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.afternote_editor_prefill_retry)).performClick() + + composeRule.runOnIdle { assertEquals(1, retries) } + } + + private fun string(resId: Int): String = composeRule.activity.getString(resId) +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongSearchProgressTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongSearchProgressTest.kt new file mode 100644 index 0000000000..02474ebfd4 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongSearchProgressTest.kt @@ -0,0 +1,65 @@ +package com.afternote.feature.afternote.presentation.editor.memorial + +import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasProgressBarRangeInfo +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDisplay +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 곡 검색 진행 상태가 화면에 도달하는지에 대한 회귀 가드 (#705). + * + * 종전에는 [AddSongUiState.isLoading] 을 아무도 소비하지 않아 «아직 오는 중» 과 «결과 0건» 이 + * 똑같은 빈 목록으로 보였다 — 사용자는 검색이 안 되는 화면으로 읽는다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AddSongSearchProgressTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `검색이 왕복 중이면 진행 표시자를 그린다`() { + setScreen(AddSongUiState(searchQuery = "노래", isLoading = true)) + + composeRule.onNode(indeterminateProgress).assertIsDisplayed() + } + + @Test + fun `결과가 도착하면 진행 표시자를 걷는다`() { + setScreen( + AddSongUiState( + searchQuery = "노래", + isLoading = false, + songs = listOf(PlaylistSongDisplay("search:0", "노래", "가수", null)), + ), + ) + + composeRule.onNode(indeterminateProgress).assertDoesNotExist() + } + + // 표시자를 테스트 태그가 아니라 진행 시맨틱스로 잡는다 — 태그를 위해 프로덕션 선언을 + // 공개하면 visibility 가드(#1678)가 잡고, 무엇보다 스크린리더가 읽는 계약이 곧 표식이다. + private val indeterminateProgress get() = hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate) + + private fun setScreen(uiState: AddSongUiState) { + composeRule.setContent { + AfternoteTheme { + AddSongScreen( + uiState = uiState, + onSearchQueryChange = {}, + onErrorConsumed = {}, + onBackClick = {}, + onSongsAdded = {}, + ) + } + } + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaRemovableTargetsTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaRemovableTargetsTest.kt index 84d6941c5a..93e8ae2451 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaRemovableTargetsTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaRemovableTargetsTest.kt @@ -10,9 +10,8 @@ import org.junit.Test /** * 소스 시트 삭제 항목의 노출 판정 (#1114). * - * 핵심 경계는 "서버에 이미 저장된 미디어는 삭제 대상이 아니다" 이다 — BE 수정(PATCH) 계약이 - * null 필드를 "기존 값 유지" 로 해석해 삭제를 표현할 수 없으므로, 폼만 비우는 삭제 항목을 - * 원격 미디어에 노출하면 저장 후 되살아나는 거짓 삭제가 된다. + * #1597부터 BE가 미디어 키 생략(유지)과 명시적 null(삭제)을 구분하고 FE도 null 키를 보존하므로, + * 출처가 아니라 현재 슬롯의 표시값 존재 여부가 경계다. */ class MemorialMediaRemovableTargetsTest { private fun memorialForm( @@ -28,7 +27,7 @@ class MemorialMediaRemovableTargetsTest { photoUrl = photoUrl, video = EditableMemorialVideo - .fromPersisted(MemorialVideoAttachment.ofOrNull(persistedVideoUrl)) + .fromServer(MemorialVideoAttachment.ofOrNull(persistedVideoUrl)) .let { video -> selectedVideoUrl?.let(video::withSelection) ?: video }, ), ) @@ -58,10 +57,10 @@ class MemorialMediaRemovableTargetsTest { } @Test - fun `서버에 저장된 미디어만 있으면 삭제 대상이 아니다`() { + fun `서버에 저장된 미디어만 있어도 슬롯별 삭제 대상이 된다`() { // 수정 진입 prefill — 사진은 photoUrl, 영상은 원격 URL 로 온다. assertEquals( - emptySet(), + setOf(MemorialMediaTarget.PHOTO, MemorialMediaTarget.VIDEO), memorialForm( photoUrl = "https://cdn.test/portrait.jpg", persistedVideoUrl = "https://cdn.test/farewell.mp4", diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialVideoServerOriginTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialVideoServerOriginTest.kt index e089cda286..18a4379366 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialVideoServerOriginTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialVideoServerOriginTest.kt @@ -1,15 +1,16 @@ package com.afternote.feature.afternote.presentation.editor.memorial +import com.afternote.feature.afternote.domain.repository.author.MediaInput import com.afternote.feature.afternote.presentation.editor.model.EditorContentPrefill import com.afternote.feature.afternote.presentation.editor.state.AfternoteTypeForm import com.afternote.feature.afternote.presentation.editor.state.EditorFormState import com.afternote.feature.afternote.presentation.editor.state.MemorialVideoAttachment import com.afternote.feature.afternote.presentation.editor.state.withMemorialThumbnail import com.afternote.feature.afternote.presentation.editor.state.withMemorialVideo +import com.afternote.feature.afternote.presentation.editor.state.withMemorialVideoRemoved import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Test /** @@ -21,8 +22,8 @@ import org.junit.Test * 표현하지 못한다). * * 지금은 하나의 편집 값 객체가 서버 원본(`persisted`)과 이번 세션의 교체분(`selection`)을 함께 - * 기억한다. 삭제가 교체분만 걷어내면 표시는 서버 원본으로 저절로 돌아간다. 지울 수 없는 것을 지운 - * 척하지 않는 쪽이, 사후 전달이라 되돌릴 기회가 제한적인 이 도메인에서 안전하다. + * 기억해 저장 출처를 URL 모양이 아니라 상태로 판정한다. 삭제는 두 층을 함께 비우고, 빈 폼은 저장 시 + * 명시적 `null` 로 나간다(#1597) — BE 가 그것을 삭제로 읽으므로 폼이 지운 척만 하는 상태가 없다. */ class MemorialVideoServerOriginTest { private val serverVideo = "https://cdn.test/farewell.mp4" @@ -48,7 +49,7 @@ class MemorialVideoServerOriginTest { val state = prefilledEditForm() assertEquals(serverAttachment, state.displayedMemorialVideo) - assertFalse(state.canDiscardMemorialVideoSelection) + assertEquals(MediaInput.Remote(serverVideo), state.memorialVideo?.toMediaInput()) } @Test @@ -57,38 +58,31 @@ class MemorialVideoServerOriginTest { // 교체한 영상의 썸네일은 아직 없다 — 원본 썸네일을 물려주면 다른 영상의 그림이 붙는다. assertEquals(MemorialVideoAttachment(url = localVideo), state.displayedMemorialVideo) - assertTrue(state.canDiscardMemorialVideoSelection) + assertEquals(MediaInput.Local(localVideo), state.memorialVideo?.toMediaInput()) } @Test - fun `교체분을 지우면 빈 슬롯이 아니라 서버 영상으로 돌아간다`() { + fun `교체분을 지우면 슬롯이 비고 저장 시 명시적 null 로 나간다`() { val removed = prefilledEditForm() .withMemorialVideo(localVideo) - .withMemorialVideo(null) - - // 종전에는 여기서 영상 칸이 null 이 되어 폼이 빈 슬롯을 보여줬다. 저장하면 서버 영상이 그대로 - // 남아 재진입 시 되살아났다 — 폼이 서버 상태를 두고 거짓말을 한 자리다. - assertEquals(serverAttachment, removed.displayedMemorialVideo) - } - - @Test - fun `서버 영상으로 돌아가면 삭제 항목도 다시 감춰진다`() { - val removed = - prefilledEditForm() - .withMemorialVideo(localVideo) - .withMemorialVideo(null) + .withMemorialVideoRemoved() + // #1406 은 서버 삭제가 불가능하던 동안 「삭제 시 서버 영상 표시로 복귀」 를 대체 수단으로 택했고 + // #1561 이 그것을 잠갔다. #1597 이 명시적 null 삭제를 열어 그 대체 수단은 근거를 잃었다 — + // 삭제는 이름대로 슬롯을 비우고, 서버 삭제는 저장이 한다. 저장 없이 나가면 서버 영상은 그대로다. + assertNull(removed.displayedMemorialVideo) + assertEquals(MediaInput.None, removed.memorialVideo?.toMediaInput()) assertEquals(emptySet(), removed.removableMemorialMediaTargets()) } @Test - fun `서버 원본이 없으면 삭제는 종전대로 슬롯을 비운다`() { - // 생성 모드 — 되돌아갈 서버 상태가 없으므로 지운 그대로가 사실이다. + fun `서버 원본이 없어도 삭제는 같은 모양으로 슬롯을 비운다`() { + // 생성 모드 — 서버 원본 유무와 무관하게 삭제 결과는 하나다. val removed = EditorFormState(typeForm = AfternoteTypeForm.Memorial()) .withMemorialVideo(localVideo) - .withMemorialVideo(null) + .withMemorialVideoRemoved() assertNull(removed.displayedMemorialVideo) } @@ -100,10 +94,10 @@ class MemorialVideoServerOriginTest { val afterLateThumbnail = EditorFormState(typeForm = AfternoteTypeForm.Memorial()) .withMemorialVideo(localVideo) - .withMemorialVideo(null) + .withMemorialVideoRemoved() .withMemorialThumbnail("https://cdn.test/late-thumb.jpg") - assertFalse(afterLateThumbnail.canDiscardMemorialVideoSelection) + assertFalse(afterLateThumbnail.canRemoveMemorialVideo) assertNull(afterLateThumbnail.displayedMemorialVideo) } } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialVideoUploadInteractionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialVideoUploadInteractionTest.kt index 3571a1027b..3861a362db 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialVideoUploadInteractionTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialVideoUploadInteractionTest.kt @@ -4,9 +4,10 @@ import androidx.activity.ComponentActivity import androidx.compose.ui.test.click import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.performTouchInput import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.presentation.R import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -14,6 +15,10 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +/** + * 빈 영상 카드는 «영상 추가» contentDescription 을 단 하나의 클릭 영역으로 노출한다. + * 카드를 집는 기준도 그 공개 semantics 다 — 테스트 전용 tag 를 두지 않는다 (#1673). + */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) class MemorialVideoUploadInteractionTest { @@ -44,7 +49,7 @@ class MemorialVideoUploadInteractionTest { // 카드 정중앙은 플러스 배지 자리다. 실제 포인터 입력으로 자식이 이벤트를 삼키지 않는지 본다. composeRule - .onNodeWithTag(MEMORIAL_VIDEO_ADD_TEST_TAG, useUnmergedTree = true) + .onNodeWithContentDescription(composeRule.activity.getString(R.string.afternote_editor_funeral_video_cd_add)) .performTouchInput { click() } composeRule.runOnIdle { assertEquals(1, addClicks) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/message/LeaveMessageEditorItemTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/message/LeaveMessageEditorItemTest.kt index 23be118722..5973c8b238 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/message/LeaveMessageEditorItemTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/message/LeaveMessageEditorItemTest.kt @@ -1,16 +1,34 @@ package com.afternote.feature.afternote.presentation.editor.message import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.v2.createComposeRule +import com.afternote.feature.afternote.presentation.editor.model.EditorContentPrefill +import com.afternote.feature.afternote.presentation.editor.model.EditorFormPrefill import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorState import com.afternote.feature.afternote.presentation.editor.state.EditorFormState -import com.afternote.feature.afternote.presentation.editor.state.editorMessagesSaver +import com.afternote.feature.afternote.presentation.editor.state.rememberAfternoteEditorState import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Rule import org.junit.Test - +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 남기실 말씀 목록의 소유자 계약. + * + * 프리필 반영은 [AfternoteEditorState.applyFormPrefill], 화면 재생성 보존은 + * `rememberAfternoteEditorState` 의 `rememberSaveable` 로 판정한다 — 둘 다 화면이 실제로 부르는 + * 진입점이고, 그 안의 목록 교체·Saver 는 구현 세부다 (#1673). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) class LeaveMessageEditorItemTest { + @get:Rule + val composeRule = createComposeRule() + @Test fun `초기 상태에는 빈 입력 항목을 자동으로 만들지 않는다`() { assertTrue(editorState().editorMessages.isEmpty()) @@ -84,7 +102,7 @@ class LeaveMessageEditorItemTest { fun `빈 프리필은 빈 목록을 유지한다`() { val state = editorStateWithMessage() - state.replaceEditorMessages(emptyList()) + state.applyFormPrefill(prefill(messages = emptyList())) assertTrue(state.editorMessages.isEmpty()) } @@ -93,13 +111,16 @@ class LeaveMessageEditorItemTest { fun `등록된 말씀을 복원해도 빈 입력 항목을 자동으로 추가하지 않는다`() { val state = editorState() - state.replaceEditorMessages( - listOf( - EditorMessageTextBlock( - title = "가족에게", - body = "전하고 싶은 말", - isRegistered = true, - ), + state.applyFormPrefill( + prefill( + messages = + listOf( + EditorMessageTextBlock( + title = "가족에게", + body = "전하고 싶은 말", + isRegistered = true, + ), + ), ), ) @@ -108,31 +129,37 @@ class LeaveMessageEditorItemTest { } @Test - fun `목록 Saver는 입력과 등록 여부를 복원하고 펼침 상태는 접는다`() { - val editing = - LeaveMessageEditorItem( - titleState = TextFieldState("작성 중"), - contentState = TextFieldState("초안"), - ) - val registered = - LeaveMessageEditorItem( - titleState = TextFieldState("가족에게"), - contentState = TextFieldState("전하고 싶은 말"), - ) - assertTrue(registered.tryRegister()) - registered.toggleBodyVisibility() - val messages = mutableStateListOf(editing, registered) - - val saved = editorMessagesSaver.run { SaverScope { true }.save(messages) } - val restored = requireNotNull(editorMessagesSaver.restore(requireNotNull(saved))) - - assertEquals(2, restored.size) - assertEquals("작성 중", restored[0].titleState.text.toString()) - assertEquals("초안", restored[0].contentState.text.toString()) - assertEquals(LeaveMessageEditorItemState.EDITING, restored[0].state) - assertEquals("가족에게", restored[1].titleState.text.toString()) - assertEquals("전하고 싶은 말", restored[1].contentState.text.toString()) - assertEquals(LeaveMessageEditorItemState.REGISTERED_COLLAPSED, restored[1].state) + fun `화면 재생성에도 입력과 등록 여부는 남고 펼침 상태는 접힌다`() { + val restorationTester = StateRestorationTester(composeRule) + lateinit var state: AfternoteEditorState + restorationTester.setContent { state = rememberAfternoteEditorState() } + + composeRule.runOnIdle { + state.addEditorMessage() + state.addEditorMessage() + val editing = state.editorMessages[0] + val registered = state.editorMessages[1] + editing.titleState.edit { replace(0, length, "작성 중") } + editing.contentState.edit { replace(0, length, "초안") } + registered.titleState.edit { replace(0, length, "가족에게") } + registered.contentState.edit { replace(0, length, "전하고 싶은 말") } + state.registerEditorMessage(registered) + registered.toggleBodyVisibility() + assertEquals(LeaveMessageEditorItemState.REGISTERED_EXPANDED, registered.state) + } + + restorationTester.emulateSavedInstanceStateRestore() + + composeRule.runOnIdle { + val restored = state.editorMessages + assertEquals(2, restored.size) + assertEquals("작성 중", restored[0].titleState.text.toString()) + assertEquals("초안", restored[0].contentState.text.toString()) + assertEquals(LeaveMessageEditorItemState.EDITING, restored[0].state) + assertEquals("가족에게", restored[1].titleState.text.toString()) + assertEquals("전하고 싶은 말", restored[1].contentState.text.toString()) + assertEquals(LeaveMessageEditorItemState.REGISTERED_COLLAPSED, restored[1].state) + } } @Test @@ -150,6 +177,17 @@ class LeaveMessageEditorItemTest { assertEquals(LeaveMessageEditorItemState.REGISTERED_COLLAPSED, message.state) } + private fun prefill(messages: List) = + EditorFormPrefill( + content = + EditorContentPrefill.Gallery( + serviceName = "구글 포토", + processingMethods = emptyList(), + ), + leaveMessageBlocks = messages, + receivers = emptyList(), + ) + private fun editorState() = AfternoteEditorState( idState = TextFieldState(), @@ -159,7 +197,9 @@ class LeaveMessageEditorItemTest { setType = {}, setService = {}, setMemorialPhoto = {}, + removeMemorialPhoto = {}, setMemorialVideo = {}, + removeMemorialVideo = {}, addReceiverIfAbsent = { _, _, _ -> }, applyPrefill = {}, setMemorialThumbnail = {}, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverScreenTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverScreenTest.kt new file mode 100644 index 0000000000..0f478c89e7 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverScreenTest.kt @@ -0,0 +1,71 @@ +package com.afternote.feature.afternote.presentation.editor.receiver + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.afternote.core.ui.theme.AfternoteTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * 수신자 선택 화면 상태별 표시 계약 (#1427). + * + * 0건 상태의 시안 문구를 고정한다 (시안 4163:20979). + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class SelectReceiverScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `수신자 0건이면 시안 문구를 띄운다`() { + setContent(SelectReceiverUiState(receivers = emptyList())) + + composeRule.onNodeWithText("등록된 수신자가 없습니다.").assertIsDisplayed() + composeRule.onNodeWithText("수신자를 등록하고 쉽게 관리해 보세요.").assertIsDisplayed() + } + + @Test + fun `조회 실패는 다시 시도를 띄운다`() { + setContent(SelectReceiverUiState(loadFailed = true)) + + composeRule.onNodeWithText("수신자 목록을 불러오지 못했습니다.").assertIsDisplayed() + composeRule.onNodeWithText("다시 시도").assertIsDisplayed() + } + + @Test + fun `로딩 중에는 0건 문구를 먼저 띄우지 않는다`() { + setContent(SelectReceiverUiState(isLoading = true, receivers = emptyList())) + + composeRule.onNodeWithText("등록된 수신자가 없습니다.").assertDoesNotExist() + } + + @Test + fun `수신자가 있으면 0건 문구를 그리지 않는다`() { + setContent( + SelectReceiverUiState(receivers = listOf(AfternoteEditorReceiver(id = 1L, name = "김혜성", label = "아들"))), + ) + + composeRule.onNodeWithText("등록된 수신자가 없습니다.").assertDoesNotExist() + } + + private fun setContent(uiState: SelectReceiverUiState) { + composeRule.setContent { + AfternoteTheme { + SelectReceiverScreen( + uiState = uiState, + onBackClick = {}, + onReceiverToggle = {}, + onRetryClick = {}, + onConfirmClick = {}, + ) + } + } + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt index e3bb9c8147..f835d72307 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt @@ -1,5 +1,6 @@ package com.afternote.feature.afternote.presentation.editor.receiver +import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.testing.FakeUserRepository import com.afternote.core.model.user.Receiver @@ -15,7 +16,6 @@ import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -24,7 +24,7 @@ import org.junit.Test * 수신자 선택 화면 ViewModel 계약 (#540). * * 이 화면의 목록이 죽으면 신규 작성의 수신자 지정 경로가 통째로 끊긴다(서버는 수신자 최소 - * 1명을 요구한다 — 400 code 1615). 목록 로드 성공·실패·재시도와 단일 선택 규칙을 고정한다. + * 1명을 요구한다 — 400 code 1615). 목록 로드 성공·실패·재시도와 복수 선택 규칙(#1426)을 고정한다. * * `GET users/receivers` 는 액세스 토큰으로 호출자를 식별하므로 userId 를 요구하지 않는다 — * userId 부재 시나리오 자체가 존재하지 않는 것이 이 구현의 계약이다(#935 진단 흡수분). @@ -50,12 +50,12 @@ class SelectReceiverViewModelTest { FakeUserRepository( receivers = listOf( - Receiver(1L, "김혜성", "아들", "auth-1"), - Receiver(2L, "박경민", "친구", "auth-2"), + Receiver(1L, "김혜성", "아들"), + Receiver(2L, "박경민", "친구"), ), ) - val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter) + val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) runCurrent() val state = viewModel.uiState.value @@ -72,12 +72,12 @@ class SelectReceiverViewModelTest { val gate = CompletableDeferred>() val repository = FakeUserRepository(onGetReceivers = { gate.await() }) - val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter) + val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) runCurrent() assertTrue(viewModel.uiState.value.isLoading) - gate.complete(listOf(Receiver(1L, "김혜성", "아들", "auth-1"))) + gate.complete(listOf(Receiver(1L, "김혜성", "아들"))) runCurrent() assertFalse(viewModel.uiState.value.isLoading) @@ -94,7 +94,7 @@ class SelectReceiverViewModelTest { val reporter = RecordingErrorReporter() val repository = FakeUserRepository(onGetReceivers = { error("server down") }) - val viewModel = SelectReceiverViewModel(repository, reporter) + val viewModel = SelectReceiverViewModel(repository, reporter, SavedStateHandle()) runCurrent() val state = viewModel.uiState.value @@ -109,11 +109,11 @@ class SelectReceiverViewModelTest { runTest { val repository = FakeUserRepository( - receivers = listOf(Receiver(1L, "김혜성", "아들", "auth-1")), + receivers = listOf(Receiver(1L, "김혜성", "아들")), onGetReceivers = { error("server down") }, ) - val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter) + val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) runCurrent() assertTrue(viewModel.uiState.value.loadFailed) @@ -132,70 +132,189 @@ class SelectReceiverViewModelTest { val viewModel = viewModelWithReceivers() viewModel.toggleReceiverSelection(1L) - assertEquals(1L, viewModel.uiState.value.selectedReceiverId) + assertEquals(listOf(1L), viewModel.uiState.value.selectedReceiverIds) viewModel.toggleReceiverSelection(1L) - assertNull(viewModel.uiState.value.selectedReceiverId) + assertEquals(emptyList(), viewModel.uiState.value.selectedReceiverIds) } @Test - fun `다른 수신자를 탭하면 선택이 교체되는 단일 선택이다`() = + fun `여러 수신자를 탭하면 탭한 순서대로 함께 선택된다`() = + runTest { + val viewModel = viewModelWithReceivers() + + viewModel.toggleReceiverSelection(2L) + viewModel.toggleReceiverSelection(1L) + + assertEquals(listOf(2L, 1L), viewModel.uiState.value.selectedReceiverIds) + } + + @Test + fun `복수 선택 중 하나만 다시 탭하면 그 항목만 해제된다`() = runTest { val viewModel = viewModelWithReceivers() viewModel.toggleReceiverSelection(1L) viewModel.toggleReceiverSelection(2L) + viewModel.toggleReceiverSelection(1L) + + assertEquals(listOf(2L), viewModel.uiState.value.selectedReceiverIds) + } + + @Test + fun `이미 폼에 있는 수신자는 선택 상태로 열린다`() = + runTest { + val viewModel = viewModelWithReceivers(formReceiverIds = listOf(1L, 2L)) + + assertEquals(listOf(1L, 2L), viewModel.uiState.value.selectedReceiverIds) + } + + @Test + fun `폼에 담겨 있던 수신자는 최초 한 번만 체크해 사용자가 푼 체크를 되살리지 않는다`() = + runTest { + val viewModel = viewModelWithReceivers(formReceiverIds = listOf(1L)) + + viewModel.toggleReceiverSelection(1L) + viewModel.applyPreselection(listOf(1L)) + + assertEquals(emptyList(), viewModel.uiState.value.selectedReceiverIds) + } + + @Test + fun `폼에 담겨 있던 수신자를 탭해도 중복으로 쌓이지 않는다`() = + runTest { + val viewModel = viewModelWithReceivers(formReceiverIds = listOf(1L)) + + viewModel.toggleReceiverSelection(1L) + viewModel.toggleReceiverSelection(1L) + + assertEquals(listOf(1L), viewModel.uiState.value.selectedReceiverIds) + } + + @Test + fun `폼에 담겨 있던 수신자 중 목록에 없는 것은 목록이 오면 빠진다`() = + runTest { + // 미저장 폼을 둔 채 설정에서 수신자를 지우고 돌아온 경우 — 폼엔 남았지만 목록엔 없다. + val gate = CompletableDeferred>() + val repository = FakeUserRepository(onGetReceivers = { gate.await() }) + val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) + runCurrent() + + viewModel.applyPreselection(listOf(1L, 99L)) - assertEquals(2L, viewModel.uiState.value.selectedReceiverId) + gate.complete( + listOf( + Receiver(1L, "김혜성", "아들"), + Receiver(2L, "박경민", "친구"), + ), + ) + runCurrent() + + assertEquals(listOf(1L), viewModel.uiState.value.selectedReceiverIds) } @Test - fun `재조회로 목록에서 사라진 수신자 선택은 해제된다`() = + fun `재조회로 목록에서 사라진 수신자 선택만 해제되고 나머지는 남는다`() = runTest { val repository = FakeUserRepository( receivers = listOf( - Receiver(1L, "김혜성", "아들", "auth-1"), - Receiver(2L, "박경민", "친구", "auth-2"), + Receiver(1L, "김혜성", "아들"), + Receiver(2L, "박경민", "친구"), ), ) - val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter) + val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) runCurrent() + viewModel.toggleReceiverSelection(1L) viewModel.toggleReceiverSelection(2L) - repository.receiverState.value = listOf(Receiver(1L, "김혜성", "아들", "auth-1")) + repository.receiverState.value = listOf(Receiver(1L, "김혜성", "아들")) viewModel.refresh() runCurrent() - assertNull(viewModel.uiState.value.selectedReceiverId) + assertEquals(listOf(1L), viewModel.uiState.value.selectedReceiverIds) } @Test fun `재조회 후에도 목록에 남아 있는 수신자 선택은 유지된다`() = runTest { val repository = - FakeUserRepository(receivers = listOf(Receiver(1L, "김혜성", "아들", "auth-1"))) - val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter) + FakeUserRepository(receivers = listOf(Receiver(1L, "김혜성", "아들"))) + val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) runCurrent() viewModel.toggleReceiverSelection(1L) viewModel.refresh() runCurrent() - assertEquals(1L, viewModel.uiState.value.selectedReceiverId) + assertEquals(listOf(1L), viewModel.uiState.value.selectedReceiverIds) + } + + // ── 프로세스 재생성 (#1427) ──────────────────────────────────────────────────── + + @Test + fun `프로세스가 재생성돼도 화면 내 선택은 복원된다`() = + runTest { + val savedStateHandle = SavedStateHandle() + val repository = + FakeUserRepository( + receivers = + listOf( + Receiver(1L, "김혜성", "아들"), + Receiver(2L, "박경민", "친구"), + ), + ) + val beforeDeath = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, savedStateHandle) + runCurrent() + beforeDeath.toggleReceiverSelection(2L) + + val restored = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, savedStateHandle) + runCurrent() + + assertEquals(listOf(2L), restored.uiState.value.selectedReceiverIds) + } + + @Test + fun `프로세스가 재생성돼도 사용자가 푼 폼 수신자는 다시 체크되지 않는다`() = + runTest { + val savedStateHandle = SavedStateHandle() + val repository = + FakeUserRepository( + receivers = + listOf( + Receiver(1L, "김혜성", "아들"), + Receiver(2L, "박경민", "친구"), + ), + ) + val beforeDeath = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, savedStateHandle) + beforeDeath.applyPreselection(listOf(1L)) + runCurrent() + beforeDeath.toggleReceiverSelection(1L) + + // 재생성 뒤 Route 의 LaunchedEffect 가 같은 폼 수신자로 다시 부른다. + val restored = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, savedStateHandle) + restored.applyPreselection(listOf(1L)) + runCurrent() + + assertEquals(emptyList(), restored.uiState.value.selectedReceiverIds) } - private fun viewModelWithReceivers(): SelectReceiverViewModel { + /** + * 프로덕션 순서대로 만든다 — 폼에 담겨 있던 수신자([formReceiverIds])가 화면이 뜨자마자 먼저 들어오고, + * 목록 응답은 그 뒤에 온다. + */ + private fun viewModelWithReceivers(formReceiverIds: List = emptyList()): SelectReceiverViewModel { val repository = FakeUserRepository( receivers = listOf( - Receiver(1L, "김혜성", "아들", "auth-1"), - Receiver(2L, "박경민", "친구", "auth-2"), + Receiver(1L, "김혜성", "아들"), + Receiver(2L, "박경민", "친구"), ), ) - val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter) + val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) + if (formReceiverIds.isNotEmpty()) viewModel.applyPreselection(formReceiverIds) dispatcher.scheduler.runCurrent() return viewModel } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetTest.kt index b0268fc66f..719daf2fd1 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetTest.kt @@ -1,25 +1,24 @@ package com.afternote.feature.afternote.presentation.editor.selection -import android.content.Context +import androidx.activity.ComponentActivity import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.semantics.Role import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextReplacement -import androidx.test.core.app.ApplicationProvider import com.afternote.core.ui.testing.MinimumTouchTargetSize import com.afternote.core.ui.testing.assertAccessibleClickTargets import com.afternote.core.ui.testing.scanEnabledClickTargets import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.shared.util.AfternoteServiceCatalog import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -30,37 +29,67 @@ import org.robolectric.annotation.Config @Config(sdk = [35]) class EditorServiceSelectionSheetTest { @get:Rule - val composeRule = createComposeRule() + val composeRule = createAndroidComposeRule() + /** + * 검색 규칙은 시트가 실제로 그리는 행 목록으로 판정한다 — 행은 catalog 순서대로 쌓이므로 + * 활성 클릭 타깃의 트리 순서가 곧 표시 순서다. + */ @Test fun `검색은 trim과 대소문자 무시 substring을 적용하고 catalog 순서를 유지한다`() { val services = listOf("Alpha", "beta", "ALPHABET", "gamma") + val queryState = TextFieldState() + composeRule.setContent { + AfternoteTheme { + EditorServiceSelectionSheetContent( + title = "소셜 네트워크 서비스 선택", + type = AfternoteType.SOCIAL_NETWORK, + services = services, + searchQueryState = queryState, + onServiceSelected = {}, + ) + } + } + + composeRule.onNodeWithText("서비스 검색하기").performTextInput(" pHa ") + assertEquals(listOf("Alpha", "ALPHABET"), serviceRowNames()) - assertEquals( - listOf("Alpha", "ALPHABET"), - filterEditorServiceOptions(services, " pHa "), - ) - assertEquals(services, filterEditorServiceOptions(services, " \n ")) + composeRule.onNodeWithText(" pHa ").performTextReplacement(" ") + assertEquals(services, serviceRowNames()) } + /** + * 카테고리 → 시트 제목 배선. 서비스 선택이 없는 카테고리(추억 노트·재산 처리)는 시트 자체를 열지 않는다. + */ @Test - fun `카테고리별 sheet title은 확정 문구를 사용한다`() { - val context = ApplicationProvider.getApplicationContext() - - assertEquals( - "소셜 네트워크 서비스 선택", - context.getString(requireNotNull(AfternoteType.SOCIAL_NETWORK.serviceSelectionSheetTitleResOrNull())), - ) - assertEquals( - "갤러리 및 파일 서비스 선택", - context.getString(requireNotNull(AfternoteType.GALLERY_AND_FILES.serviceSelectionSheetTitleResOrNull())), - ) - assertEquals( - "비즈니스 서비스 선택", - context.getString(requireNotNull(AfternoteType.BUSINESS.serviceSelectionSheetTitleResOrNull())), - ) - assertNull(AfternoteType.MEMORIAL.serviceSelectionSheetTitleResOrNull()) - assertNull(AfternoteType.ESTATE.serviceSelectionSheetTitleResOrNull()) + fun `sheet title은 카테고리별 확정 문구를 쓰고 미지원 카테고리는 시트를 열지 않는다`() { + val type = mutableStateOf(AfternoteType.SOCIAL_NETWORK) + composeRule.setContent { + AfternoteTheme { + EditorServiceSelectionSheet( + visible = true, + type = type.value, + services = emptyList(), + searchQueryState = rememberTextFieldState(), + onDismissRequest = {}, + onServiceSelected = {}, + ) + } + } + + composeRule.onNodeWithText("소셜 네트워크 서비스 선택").assertExists() + + composeRule.runOnIdle { type.value = AfternoteType.GALLERY_AND_FILES } + composeRule.onNodeWithText("갤러리 및 파일 서비스 선택").assertExists() + + composeRule.runOnIdle { type.value = AfternoteType.BUSINESS } + composeRule.onNodeWithText("비즈니스 서비스 선택").assertExists() + + composeRule.runOnIdle { type.value = AfternoteType.MEMORIAL } + composeRule.onNodeWithText("서비스 선택", substring = true).assertDoesNotExist() + + composeRule.runOnIdle { type.value = AfternoteType.ESTATE } + composeRule.onNodeWithText("서비스 선택", substring = true).assertDoesNotExist() } @Test @@ -138,4 +167,11 @@ class EditorServiceSelectionSheetTest { assertEquals(Role.Button, field.role) assertFalse(field.isSmallerThan(MinimumTouchTargetSize)) } + + /** 시트 본문이 그리는 서비스 행 이름을 표시 순서대로 모은다. 검색 입력창은 Button 이 아니라 빠진다. */ + private fun serviceRowNames(): List = + composeRule + .scanEnabledClickTargets() + .filter { it.role == Role.Button } + .map { it.name } } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteEditorServiceSelectionStateTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteEditorServiceSelectionStateTest.kt index 0ce02da3d2..fcf7a35be5 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteEditorServiceSelectionStateTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteEditorServiceSelectionStateTest.kt @@ -63,7 +63,9 @@ class AfternoteEditorServiceSelectionStateTest { setType = setType, setService = setService, setMemorialPhoto = {}, + removeMemorialPhoto = {}, setMemorialVideo = {}, + removeMemorialVideo = {}, addReceiverIfAbsent = { _, _, _ -> }, applyPrefill = {}, setMemorialThumbnail = {}, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialVideoTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialVideoTest.kt index 6dba810296..25387a4038 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialVideoTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialVideoTest.kt @@ -1,104 +1,88 @@ package com.afternote.feature.afternote.presentation.editor.state import com.afternote.feature.afternote.domain.repository.author.MediaInput +import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Test -/** 서버 원본과 이번 편집의 교체분을 함께 보존하는 영상 편집 값 객체의 상태 계약. */ +/** 추모 영상 칸의 편집 상태 계약 — 저장 때 올려야 하는지, 이미 올라가 있는지, 없는지. */ class EditableMemorialVideoTest { - private val persisted = + private val serverVideo = MemorialVideoAttachment( url = "https://cdn.test/farewell.mp4", thumbnailUrl = "https://cdn.test/farewell-thumb.jpg", ) - private val selection = + private val pickedVideo = MemorialVideoAttachment( url = "content://videos/replacement", thumbnailUrl = "https://cdn.test/replacement-thumb.jpg", ) - private fun selected(attachment: MemorialVideoAttachment): EditableMemorialVideo = + private fun picked(attachment: MemorialVideoAttachment): EditableMemorialVideo = EditableMemorialVideo .empty() .withSelection(attachment.url) .let { video -> attachment.thumbnailUrl?.let(video::withSelectionThumbnail) ?: video } - private fun persistedAndSelection(): EditableMemorialVideo = - EditableMemorialVideo - .fromPersisted(persisted) - .withSelection(selection.url) - .let { video -> selection.thumbnailUrl?.let(video::withSelectionThumbnail) ?: video } - @Test - fun `네 가지 상태는 표시 영상과 저장 입력을 스스로 결정한다`() { + fun `세 상태는 표시 영상과 저장 입력을 스스로 결정한다`() { data class Case( val name: String, val subject: EditableMemorialVideo, val displayed: MemorialVideoAttachment?, - val canDiscardSelection: Boolean, + val canRemove: Boolean, val mediaInput: MediaInput, ) val cases = listOf( Case( - name = "빈 상태", + name = "영상 없음", subject = EditableMemorialVideo.empty(), displayed = null, - canDiscardSelection = false, + canRemove = false, mediaInput = MediaInput.None, ), Case( - name = "서버 원본만", - subject = EditableMemorialVideo.fromPersisted(persisted), - displayed = persisted, - canDiscardSelection = false, - mediaInput = MediaInput.Remote(persisted.url), - ), - Case( - name = "새 선택만", - subject = selected(selection), - displayed = selection, - canDiscardSelection = true, - mediaInput = MediaInput.Local(selection.url), + name = "이미 올라간 영상", + subject = EditableMemorialVideo.fromServer(serverVideo), + displayed = serverVideo, + canRemove = true, + mediaInput = MediaInput.Remote(serverVideo.url), ), Case( - name = "서버 원본과 새 선택 모두", - subject = persistedAndSelection(), - displayed = selection, - canDiscardSelection = true, - mediaInput = MediaInput.Local(selection.url), + name = "올릴 영상", + subject = picked(pickedVideo), + displayed = pickedVideo, + canRemove = true, + mediaInput = MediaInput.Local(pickedVideo.url), ), ) cases.forEach { case -> assertEquals("${case.name}: 표시 영상", case.displayed, case.subject.displayed) assertEquals( - "${case.name}: 새 선택 취소 가능 여부", - case.canDiscardSelection, - case.subject.canDiscardSelection, + "${case.name}: 삭제 항목 노출 여부", + case.canRemove, + case.subject.canRemove, ) assertEquals("${case.name}: 저장 입력", case.mediaInput, case.subject.toMediaInput()) } } @Test - fun `새 선택을 제거하면 보존한 서버 원본으로 돌아간다`() { - val restored = - persistedAndSelection().discardSelection() + fun `서버 영상 위에 새 영상을 고르면 서버 영상은 잊고 올릴 영상만 남는다`() { + val replaced = EditableMemorialVideo.fromServer(serverVideo).withSelection(pickedVideo.url) - assertEquals(persisted, restored.displayed) - assertFalse(restored.canDiscardSelection) - assertEquals(MediaInput.Remote(persisted.url), restored.toMediaInput()) + // #2114: 넷째 상태(Replaced)를 없앴다. 서버 원본을 뒤에 들고 있어도 읽는 경로가 없었다. + assertEquals(picked(MemorialVideoAttachment(url = pickedVideo.url)), replaced) + assertEquals(MediaInput.Local(pickedVideo.url), replaced.toMediaInput()) } @Test fun `저장 출처는 URL 모양이 아니라 객체가 기억한 편집 상태로 결정한다`() { val remoteShapedSelection = "https://picker.test/new-video.mp4" - val localShapedPersisted = "content://migrated/server-video" + val localShapedServerVideo = "content://migrated/server-video" assertEquals( MediaInput.Local(remoteShapedSelection), @@ -108,48 +92,93 @@ class EditableMemorialVideoTest { .toMediaInput(), ) assertEquals( - MediaInput.Remote(localShapedPersisted), + MediaInput.Remote(localShapedServerVideo), EditableMemorialVideo - .fromPersisted(MemorialVideoAttachment(url = localShapedPersisted)) + .fromServer(MemorialVideoAttachment(url = localShapedServerVideo)) .toMediaInput(), ) } @Test - fun `새 영상을 고르면 이전 교체분 썸네일은 물려주지 않는다`() { - val selected = - persistedAndSelection().withSelection("content://videos/another") + fun `새 영상을 고르면 이전 선택의 썸네일은 물려주지 않는다`() { + val selected = picked(pickedVideo).withSelection("content://videos/another") assertEquals(MemorialVideoAttachment(url = "content://videos/another"), selected.displayed) assertEquals(MediaInput.Local("content://videos/another"), selected.toMediaInput()) - assertEquals(persisted, selected.discardSelection().displayed) } @Test - fun `썸네일은 교체분에만 붙고 교체분이 없으면 늦은 결과를 버린다`() { + fun `썸네일은 올릴 영상에만 붙고 올릴 영상이 없으면 늦은 결과를 버린다`() { val withThumbnail = EditableMemorialVideo - .fromPersisted(persisted) - .withSelection(selection.url) + .fromServer(serverVideo) + .withSelection(pickedVideo.url) .withSelectionThumbnail("https://cdn.test/new-thumb.jpg") assertEquals("https://cdn.test/new-thumb.jpg", withThumbnail.displayed?.thumbnailUrl) val afterLateThumbnail = EditableMemorialVideo - .fromPersisted(persisted) + .fromServer(serverVideo) .withSelectionThumbnail("https://cdn.test/late-thumb.jpg") - assertEquals(persisted, afterLateThumbnail.displayed) + assertEquals(serverVideo, afterLateThumbnail.displayed) } @Test - fun `사용자 입력 조각은 서버 원본과 자동 파생 썸네일을 제외한다`() { - val userEntered = - persistedAndSelection().userEnteredPart() + fun `썸네일을 떼도 출처는 남는다`() { + assertEquals( + EditableMemorialVideo.fromServer(MemorialVideoAttachment(url = serverVideo.url)), + EditableMemorialVideo.fromServer(serverVideo).withoutThumbnail(), + ) + // 서버 영상도 지문에 남아야 수정 진입 기준선과 비교해 서버 영상 삭제가 변경으로 잡힌다(#1597). + assertEquals( + EditableMemorialVideo.empty().withSelection(pickedVideo.url), + picked(pickedVideo).withoutThumbnail(), + ) + } - assertEquals(MemorialVideoAttachment(url = selection.url), userEntered.displayed) - assertTrue(userEntered.canDiscardSelection) - assertNull(userEntered.discardSelection().displayed) + @Test + fun `세 상태 모두 JSON 왕복 뒤에 같은 상태로 돌아온다`() { + // SavedStateHandle 이 실제로 쓰는 설정 그대로 — AfternoteEditorViewModel.formSnapshotJson. + val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + val states = + mapOf( + "영상 없음" to EditableMemorialVideo.empty(), + "이미 올라간 영상" to EditableMemorialVideo.fromServer(serverVideo), + "올릴 영상" to picked(pickedVideo), + ) + + states.forEach { (name, state) -> + val roundTripped = + json.decodeFromString( + EditableMemorialVideo.serializer(), + json.encodeToString(EditableMemorialVideo.serializer(), state), + ) + + assertEquals(name, state, roundTripped) + assertEquals(name, state.displayed, roundTripped.displayed) + assertEquals(name, state.canRemove, roundTripped.canRemove) + assertEquals(name, state.toMediaInput(), roundTripped.toMediaInput()) + } + } + + @Test + fun `판별자 값은 클래스 이름이 아니라 고정 문자열이다`() { + val json = Json { encodeDefaults = true } + + assertEquals("""{"type":"no_video"}""", json.encodeToString(EditableMemorialVideo.serializer(), EditableMemorialVideo.empty())) + assertEquals( + """{"type":"uploaded","video":{"url":"${serverVideo.url}","thumbnailUrl":"${serverVideo.thumbnailUrl}"}}""", + json.encodeToString(EditableMemorialVideo.serializer(), EditableMemorialVideo.fromServer(serverVideo)), + ) + assertEquals( + """{"type":"pending_upload","video":{"url":"${pickedVideo.url}","thumbnailUrl":"${pickedVideo.thumbnailUrl}"}}""", + json.encodeToString(EditableMemorialVideo.serializer(), picked(pickedVideo)), + ) } } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorReceiverIdContractTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorReceiverIdContractTest.kt index eb6082ecdc..0a19da33e2 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorReceiverIdContractTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorReceiverIdContractTest.kt @@ -1,7 +1,7 @@ package com.afternote.feature.afternote.presentation.editor.state import com.afternote.core.model.user.Receiver -import com.afternote.feature.afternote.presentation.editor.mapper.toAfternoteEditorReceiver +import com.afternote.feature.afternote.presentation.editor.mapper.toAfternoteEditorReceivers import com.afternote.feature.afternote.presentation.editor.receiver.AfternoteEditorReceiver import com.afternote.feature.afternote.presentation.editor.receiver.AfternoteEditorReceiverListState import org.junit.Assert.assertEquals @@ -12,9 +12,9 @@ import org.junit.Test class EditorReceiverIdContractTest { @Test fun `등록 수신자의 Long id 를 문자열 변환 없이 에디터 모델로 옮긴다`() { - val receiver = Receiver(Long.MAX_VALUE, "김수신", "딸", "auth-code") + val receiver = Receiver(Long.MAX_VALUE, "김수신", "딸") - assertEquals(Long.MAX_VALUE, receiver.toAfternoteEditorReceiver().id) + assertEquals(Long.MAX_VALUE, listOf(receiver).toAfternoteEditorReceivers().single().id) } @Test diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowInteractionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowInteractionTest.kt index e26c630f05..c82870b796 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowInteractionTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowInteractionTest.kt @@ -3,19 +3,21 @@ package com.afternote.feature.afternote.presentation.home import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.width import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.assertHasNoClickAction import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.unit.dp +import com.afternote.core.ui.testing.scanEnabledClickTargets import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.shared.util.TYPE_FILTER_TABS import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -28,8 +30,15 @@ class AfternoteCategoryRowInteractionTest { @get:Rule val composeRule = createAndroidComposeRule() + /** + * 더보기 화살표가 장식이라는 계약은 «접근성 트리에 아무것도 싣지 않는다» 로 판정한다. + * 활성 클릭 타깃이 탭 [TYPE_FILTER_TABS] 개뿐이면 화살표는 클릭도 이름도 만들지 않은 것이다. + * + * 화살표가 실제로 그려지는지(스크롤 가능 분기)는 screenshotTest 골든 + * `afternoteCategoryRowAllCompactScreenshot` 이 본다 — 장식은 semantics 로 셀 수 없다. + */ @Test - fun `더보기 화살표는 장식이고 카테고리 탭은 계속 동작한다`() { + fun `더보기 화살표는 semantics를 만들지 않고 카테고리 탭은 계속 동작한다`() { var selectedTab: AfternoteType? = null // Robolectric 폰트 폭에 기대지 않고 스크롤 가능 분기를 확정한다. composeRule.setContent { @@ -41,10 +50,12 @@ class AfternoteCategoryRowInteractionTest { } } - composeRule - .onNodeWithTag(AFTERNOTE_CATEGORY_MORE_INDICATOR_TEST_TAG, useUnmergedTree = true) - .assertExists() - .assertHasNoClickAction() + val clickTargets = composeRule.scanEnabledClickTargets() + assertEquals(TYPE_FILTER_TABS.size, clickTargets.size) + assertTrue( + "탭이 아닌 클릭 타깃이 생겼다: ${clickTargets.filter { it.role != Role.Tab }}", + clickTargets.all { it.role == Role.Tab }, + ) composeRule.onNodeWithContentDescription("더 보기").assertDoesNotExist() val socialNetwork = composeRule.activity.getString(R.string.afternote_category_social_network) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScrollPreservationTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScrollPreservationTest.kt new file mode 100644 index 0000000000..8df5abf4f0 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteCategoryRowScrollPreservationTest.kt @@ -0,0 +1,148 @@ +package com.afternote.feature.afternote.presentation.home + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.shared.component.AfternoteListContent +import com.afternote.feature.afternote.presentation.shared.component.ListItemUiModel +import kotlinx.coroutines.flow.flowOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * #1635 ② — 카테고리 행의 가로 스크롤 위치가 본문 분기를 건너 살아남는지. + * + * 탭 5개(전체·소셜네트워크·비즈니스·갤러리 및 파일·추모)의 폭 합이 기본 화면 폭을 넘어 이 행은 실제로 + * 스크롤된다. 종전에는 [AfternoteTypeFilterRow] 가 `rememberScrollState()` 를 **자기 안에서** 만들어, + * 본문이 바뀌면(0건 → 로딩 → 목록) 그 서브트리가 폐기되면서 위치가 0 으로 돌아갔다. 게다가 호출부가 + * 둘이라(목록 경로·0건 경로) `remember` 슬롯 자체가 서로 달랐다. **행을 오른쪽으로 밀어 끝 탭을 고르면 + * 전환 후 방금 고른 탭이 화면 밖에 있었다.** + * + * 여기서 재현하는 것은 실제 전환 순서 그대로다 — 무필터 0건([EmptyHomeBody]) → 전환 중 + * ([ReloadingBody]) → 필터 결과([AfternoteListContent]). 화면이 `when` 위에서 만들어 셋에 꿰는 그 + * [ScrollState] 를 테스트가 대신 들고 있다. + * + * [ScrollState.maxValue] 를 매 단계 함께 단언하는 이유: 넘긴 상태를 본문이 행까지 전달하지 않고 행이 + * 다시 자기 것을 만들면, 넘긴 쪽 상태는 **레이아웃에 붙지 않아** `maxValue` 가 초깃값 + * ([Int.MAX_VALUE])에 머문다. `value` 만 보면 그 회귀가 통과해 버린다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteCategoryRowScrollPreservationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private enum class Stage { EMPTY, RELOADING, FILTERED } + + @Test + fun `본문이 바뀌어도 카테고리 행의 가로 스크롤 위치가 남는다`() { + val scrollState = ScrollState(initial = 0) + var stage by mutableStateOf(Stage.EMPTY) + composeRule.setContent { AfternoteTheme { Bodies(stage, scrollState) } } + composeRule.waitForIdle() + + val maxValue = scrollState.maxValue + assertTrue( + "카테고리 행이 스크롤되지 않아 이 회귀를 잴 수 없다 (maxValue=$maxValue)", + maxValue in 1.. 0) + + stage = Stage.RELOADING + composeRule.waitForIdle() + assertEquals("전환 중 로딩 본문에서 가로 스크롤이 초기화됐다", scrolled, scrollState.value) + assertEquals("전환 중 로딩 본문이 넘겨받은 스크롤 상태를 행까지 전달하지 않았다", maxValue, scrollState.maxValue) + + stage = Stage.FILTERED + composeRule.waitForIdle() + assertEquals("전환이 끝나자 가로 스크롤이 초기화됐다", scrolled, scrollState.value) + assertEquals("목록 본문이 넘겨받은 스크롤 상태를 행까지 전달하지 않았다", maxValue, scrollState.maxValue) + } + + @Composable + private fun Bodies( + stage: Stage, + scrollState: ScrollState, + ) { + // 탭 폭 합이 확실히 넘치도록 폭을 고정한다 — Robolectric 폰트 폭에 기대지 않는다. + Box( + modifier = + Modifier + .width(ROW_WIDTH) + .fillMaxHeight(), + ) { + when (stage) { + Stage.EMPTY -> { + EmptyHomeBody( + headerDescription = stringResource(R.string.afternote_home_header_description), + nextStep = null, + emptyListDescription = stringResource(R.string.afternote_empty_list_body), + onTypeSelected = {}, + filterRowScrollState = scrollState, + modifier = Modifier.fillMaxSize(), + ) + } + + Stage.RELOADING -> { + ReloadingBody( + headerDescription = stringResource(R.string.afternote_home_header_description), + nextStep = null, + selectedType = AfternoteType.MEMORIAL, + onTypeSelected = {}, + filterRowScrollState = scrollState, + modifier = Modifier.fillMaxSize(), + ) + } + + Stage.FILTERED -> { + AfternoteListContent( + items = flowOf(PagingData.empty()).collectAsLazyPagingItems(), + selectedType = AfternoteType.MEMORIAL, + onTypeSelected = {}, + onListItemClick = { _, _ -> }, + filterRowScrollState = scrollState, + ) + } + } + } + } + + private companion object { + /** + * 탭 5개가 들어가지 않는 폭. 이 행이 실제로 스크롤되는 조건을 확정한다 — Robolectric 폰트 폭에 + * 기대지 않는다(`AfternoteCategoryRowInteractionTest` 와 같은 수법). + * + * **넉넉히 좁혀야 한다.** 200dp 로 재 보면 넘침이 9px 뿐이라, 끝까지 밀어 더보기 화살표가 사라지는 + * 순간 그 폭(16dp + 여백)이 탭 쪽으로 돌아가 행이 다 들어가고 `maxValue` 가 0 이 된다 — + * [ScrollState.maxValue] 세터가 위치를 0 으로 깎아, 회귀가 없어도 이 테스트가 빨개진다. + */ + val ROW_WIDTH = 100.dp + + /** 오른쪽으로 미는 양. 끝 탭을 고르려면 사용자가 하는 그 동작이다. */ + const val SCROLL_DELTA = 40f + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeBodyStateTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeBodyStateTest.kt new file mode 100644 index 0000000000..73f31a2ee2 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeBodyStateTest.kt @@ -0,0 +1,266 @@ +package com.afternote.feature.afternote.presentation.home + +import androidx.paging.LoadState +import com.afternote.feature.afternote.domain.AfternoteType +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * #1634 — 목록 화면의 본문 분기 «순서» 회귀 가드. + * + * 종전에는 `refreshState is LoadState.Error && itemCount == 0` 이 목록 경로보다 앞서, 카테고리 필터를 + * 건 채 조회가 실패하면 전면 에러가 **카테고리 행까지 걷어 갔다.** 화면에 남는 조작은 «다시 시도» + * 하나뿐이라 다른 카테고리로도 「전체」로도 나갈 수 없었다 — 바로 아래 줄 주석이 「막다른 상태 방지」라고 + * 적어 둔 그 상태를 윗 분기가 뚫고 있었다. + * + * 화면을 띄우지 않고 [afternoteHomeBodyState] 로 판정하는 이유는 그 KDoc 참조 — 이 모듈에서 화면 단위 + * Paging 판정은 Robolectric 실행 순번에 걸린다. 각 본문이 실제로 무엇을 그리는지는 + * `AfternoteHomeFilteredErrorTest`·`AfternoteHomeEmptyHeaderTest` 가 본문 컴포저블을 직접 띄워 본다. + * + * #1635 로 «상단을 이미 보고 있었는가» 축이 하나 붙었다 — 카테고리 전환·재시도가 만드는 + * `refresh = Loading` + `itemCount == 0` 을 첫 진입과 같이 판정하면 헤더·필터 행이 통째로 사라진다. + * 그 축의 되먹임([drawsTopChrome])까지 여기서 함께 고정한다. + */ +class AfternoteHomeBodyStateTest { + @Test + fun `필터를 건 채 조회가 실패하면 카테고리 행이 남는 본문을 고른다`() { + val state = + afternoteHomeBodyState( + refreshState = loadError(), + itemCount = 0, + selectedType = AfternoteType.BUSINESS, + chromeAlreadyVisible = true, + ) + + assertEquals(AfternoteHomeBodyState.FilteredError(AfternoteType.BUSINESS), state) + } + + /** 완료 조건 — 필터 없는 전면 실패의 종전 렌더는 그대로 둔다. 「전체」가 기본 상태라 돌아갈 곳이 없다. */ + @Test + fun `필터 없는 전면 실패는 종전대로 전면 에러 본문이다`() { + val state = + afternoteHomeBodyState( + refreshState = loadError(), + itemCount = 0, + selectedType = null, + chromeAlreadyVisible = false, + ) + + assertEquals(AfternoteHomeBodyState.Error, state) + } + + @Test + fun `이미 그린 목록이 있으면 새로고침 실패에도 목록을 그대로 그린다`() { + val state = + afternoteHomeBodyState( + refreshState = loadError(), + itemCount = 3, + selectedType = null, + chromeAlreadyVisible = true, + ) + + assertEquals(AfternoteHomeBodyState.List, state) + } + + @Test + fun `보여 줄 것이 없는 첫 로드는 로딩 본문이다`() { + val state = + afternoteHomeBodyState( + refreshState = LoadState.Loading, + itemCount = 0, + selectedType = null, + chromeAlreadyVisible = false, + ) + + assertEquals(AfternoteHomeBodyState.InitialLoading, state) + } + + @Test + fun `이미 그린 목록의 새로고침은 목록을 유지한다`() { + val state = + afternoteHomeBodyState( + refreshState = LoadState.Loading, + itemCount = 3, + selectedType = null, + chromeAlreadyVisible = true, + ) + + assertEquals(AfternoteHomeBodyState.List, state) + } + + /** 실패가 아닌 «성공했는데 0건» 은 종전대로 목록 경로에 남는다 — 카테고리 행은 그쪽이 그린다 (#567·#1633). */ + @Test + fun `필터 결과가 0건이면 목록 경로에 남긴다`() { + val state = + afternoteHomeBodyState( + refreshState = LoadState.NotLoading(endOfPaginationReached = true), + itemCount = 0, + selectedType = AfternoteType.BUSINESS, + chromeAlreadyVisible = true, + ) + + assertEquals(AfternoteHomeBodyState.List, state) + } + + @Test + fun `0건이고 필터도 없으면 첫 진입 빈 본문이다`() { + val state = + afternoteHomeBodyState( + refreshState = LoadState.NotLoading(endOfPaginationReached = true), + itemCount = 0, + selectedType = null, + chromeAlreadyVisible = true, + ) + + assertEquals(AfternoteHomeBodyState.Empty, state) + } + + /** + * #1635 ① — 카테고리 전환 중 상단이 사라지지 않는다. + * + * 전환은 새 Paging 세대를 만들고 그 첫 상태가 `Loading` 이다. 0건 상태에서 전환하면 `itemCount` 도 + * 0 이라 종전에는 첫 진입과 똑같이 판정돼 헤더·필터 행이 통째로 로딩 화면에 덮였다. + */ + @Test + fun `상단을 보고 있던 중의 로드는 상단을 남기는 본문이다`() { + val state = + afternoteHomeBodyState( + refreshState = LoadState.Loading, + itemCount = 0, + selectedType = AfternoteType.BUSINESS, + chromeAlreadyVisible = true, + ) + + assertEquals(AfternoteHomeBodyState.Reloading(AfternoteType.BUSINESS), state) + } + + /** 「전체」로 돌아오는 전환도 같은 자리를 지난다 — 이쪽 선택 탭은 정의상 `null` 이다. */ + @Test + fun `전체로 돌아오는 전환도 상단을 남기고 전체 탭을 들고 간다`() { + val state = + afternoteHomeBodyState( + refreshState = LoadState.Loading, + itemCount = 0, + selectedType = null, + chromeAlreadyVisible = true, + ) + + assertEquals(AfternoteHomeBodyState.Reloading(null), state) + } + + /** 완료 조건 — 첫 진입의 종전 렌더(전체 로딩)는 그대로 둔다. 아직 보여 준 상단이 없다. */ + @Test + fun `상단을 보여 준 적이 없으면 종전대로 전체 로딩이다`() { + val state = + afternoteHomeBodyState( + refreshState = LoadState.Loading, + itemCount = 0, + selectedType = AfternoteType.BUSINESS, + chromeAlreadyVisible = false, + ) + + assertEquals(AfternoteHomeBodyState.InitialLoading, state) + } + + /** + * #1634 가 남긴 구멍 — 필터 실패에서 «다시 시도» 를 누르면 그 순간 상단이 사라졌다. + * 재시도도 `refresh = Loading` + `itemCount == 0` 을 지난다. + */ + @Test + fun `필터 실패에서 재시도를 눌러도 카테고리 행이 남는다`() { + val settled = + afternoteHomeBodyState( + refreshState = loadError(), + itemCount = 0, + selectedType = AfternoteType.BUSINESS, + chromeAlreadyVisible = false, + ) + val retrying = + afternoteHomeBodyState( + refreshState = LoadState.Loading, + itemCount = 0, + selectedType = AfternoteType.BUSINESS, + chromeAlreadyVisible = settled.drawsTopChrome(showsHeaderOnEmptyList = true), + ) + + assertEquals(AfternoteHomeBodyState.FilteredError(AfternoteType.BUSINESS), settled) + assertEquals(AfternoteHomeBodyState.Reloading(AfternoteType.BUSINESS), retrying) + } + + /** + * 되먹임이 발산하지 않는다 — 로딩 갈래가 내는 두 상태의 상단 유무가 각자 자기 입력과 같다. + * (`true` → [AfternoteHomeBodyState.Reloading] → `true`, `false` → InitialLoading → `false`) + */ + @Test + fun `로딩 판정은 상단 유무 되먹임에서 고정점이다`() { + listOf(true, false).forEach { chrome -> + val state = + afternoteHomeBodyState( + refreshState = LoadState.Loading, + itemCount = 0, + selectedType = null, + chromeAlreadyVisible = chrome, + ) + + assertEquals(chrome, state.drawsTopChrome(showsHeaderOnEmptyList = true)) + } + } + + /** + * #1633 이 세운 수신자 안전장치를 로딩 축에서도 다시 고정한다. + * + * 수신자는 0건에 상단을 그리지 않는다(`showsHeaderOnEmptyList = false`). 그 상태에서 카테고리를 + * 고르면 되먹임이 `false` 라 종전대로 전체 로딩을 지난다 — 발신자 헤더·필터 행이 로딩 본문을 통해 + * 수신자 화면으로 새지 않는다. + */ + @Test + fun `수신자 0건에서 카테고리를 골라도 로딩 본문으로 상단이 새지 않는다`() { + val receiverEmpty = + afternoteHomeBodyState( + refreshState = LoadState.NotLoading(endOfPaginationReached = true), + itemCount = 0, + selectedType = null, + chromeAlreadyVisible = false, + ) + val receiverChrome = receiverEmpty.drawsTopChrome(showsHeaderOnEmptyList = false) + val authorChrome = receiverEmpty.drawsTopChrome(showsHeaderOnEmptyList = true) + + assertEquals(AfternoteHomeBodyState.Empty, receiverEmpty) + assertEquals(false, receiverChrome) + assertEquals(true, authorChrome) + assertEquals( + AfternoteHomeBodyState.InitialLoading, + afternoteHomeBodyState( + refreshState = LoadState.Loading, + itemCount = 0, + selectedType = AfternoteType.BUSINESS, + chromeAlreadyVisible = receiverChrome, + ), + ) + } + + /** 상단이 없던 전면 실패에서 재시도해도 상단이 깜빡 나타났다 사라지지 않는다. */ + @Test + fun `필터 없는 전면 실패의 재시도는 종전대로 전체 로딩이다`() { + val settled = + afternoteHomeBodyState( + refreshState = loadError(), + itemCount = 0, + selectedType = null, + chromeAlreadyVisible = false, + ) + + assertEquals(false, settled.drawsTopChrome(showsHeaderOnEmptyList = true)) + assertEquals( + AfternoteHomeBodyState.InitialLoading, + afternoteHomeBodyState( + refreshState = LoadState.Loading, + itemCount = 0, + selectedType = null, + chromeAlreadyVisible = settled.drawsTopChrome(showsHeaderOnEmptyList = true), + ), + ) + } + + private fun loadError(): LoadState.Error = LoadState.Error(IllegalStateException("조회 실패")) +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEmptyCopyTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEmptyCopyTest.kt index 408dfb4fd2..26fd97befe 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEmptyCopyTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEmptyCopyTest.kt @@ -1,6 +1,7 @@ package com.afternote.feature.afternote.presentation.home import androidx.activity.ComponentActivity +import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule @@ -87,6 +88,7 @@ class AfternoteHomeEmptyCopyTest { nextStep = null, emptyListDescription = stringRes(R.string.afternote_empty_list_body), onTypeSelected = {}, + filterRowScrollState = rememberScrollState(), ) } @@ -99,6 +101,7 @@ class AfternoteHomeEmptyCopyTest { selectedType = AfternoteType.SOCIAL_NETWORK, onTypeSelected = {}, onListItemClick = { _, _ -> }, + filterRowScrollState = rememberScrollState(), ) } } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEmptyHeaderTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEmptyHeaderTest.kt index 978647abb9..040f66401f 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEmptyHeaderTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEmptyHeaderTest.kt @@ -2,6 +2,7 @@ package com.afternote.feature.afternote.presentation.home import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -75,6 +76,7 @@ class AfternoteHomeEmptyHeaderTest { nextStep = NextStep(text = NEXT_STEP_TEXT, onClick = { tapped = true }), emptyListDescription = stringRes(R.string.afternote_empty_list_body), onTypeSelected = {}, + filterRowScrollState = rememberScrollState(), modifier = Modifier.fillMaxSize(), ) } @@ -165,6 +167,7 @@ class AfternoteHomeEmptyHeaderTest { nextStep = null, emptyListDescription = stringRes(R.string.afternote_empty_list_body), onTypeSelected = onTypeSelected, + filterRowScrollState = rememberScrollState(), modifier = Modifier.fillMaxSize(), ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeFilteredErrorTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeFilteredErrorTest.kt new file mode 100644 index 0000000000..97b47ccda7 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeFilteredErrorTest.kt @@ -0,0 +1,159 @@ +package com.afternote.feature.afternote.presentation.home + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * #1634 — 카테고리 필터를 건 채 조회가 실패한 상태에서 빠져나갈 수단이 남는지. + * + * 종전에는 이 상태가 전면 에러([com.afternote.feature.afternote.presentation.shared.component.ErrorListBody])로 + * 덮여 카테고리 행이 사라졌고, 화면에 남는 조작은 «다시 시도» 하나뿐이었다. 서버가 계속 실패하는 동안 + * 사용자는 자기가 고른 카테고리에 갇혀 다른 카테고리로도 「전체」로도 나갈 수 없었다. + * + * 어떤 상태에서 이 본문이 선택되는지(분기 순서)는 `AfternoteHomeBodyStateTest` 가 따로 고정한다 — + * 화면째 띄우면 Paging 첫 상태(Loading)를 걷는 이펙트가 Robolectric 실행 순번에 걸린다 + * ([AfternoteHomeBodyState] KDoc). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteHomeFilteredErrorTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `필터 조회 실패 본문에도 카테고리 행이 남는다`() { + composeRule.setContent { AfternoteTheme { AuthorFilteredErrorBody() } } + + composeRule.onNodeWithText(string(R.string.afternote_category_all)).assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.afternote_category_business)).assertIsDisplayed() + } + + /** 실패해도 고른 카테고리는 그대로다 — 어디서 실패했는지 모르면 어디로 나갈지도 못 고른다. */ + @Test + fun `필터 조회 실패 본문은 고른 카테고리를 선택 상태로 유지한다`() { + composeRule.setContent { AfternoteTheme { AuthorFilteredErrorBody() } } + + composeRule.onNodeWithText(string(R.string.afternote_category_business)).assertIsSelected() + composeRule.onNodeWithText(string(R.string.afternote_category_all)).assertIsNotSelected() + } + + /** + * 막다른 상태를 여는 실제 수단 — 「전체」 탭이 호출부까지 전달돼야 새 조회가 걸린다 + * ([AfternoteHomeViewModel.selectTab] 이 `selectedType` 을 바꾸면 Paging 흐름이 다시 시작된다). + */ + @Test + fun `필터 조회 실패 본문에서 전체를 고르면 호출부로 전달된다`() { + var called = false + var selected: AfternoteType? = AfternoteType.BUSINESS + composeRule.setContent { + AfternoteTheme { + AuthorFilteredErrorBody(onTypeSelected = { + called = true + selected = it + }) + } + } + + composeRule.onNodeWithText(string(R.string.afternote_category_all)).performClick() + + assertTrue("「전체」 탭이 호출부로 전달되지 않았다", called) + assertEquals(null, selected) + } + + @Test + fun `필터 조회 실패 본문은 실패 문구와 재시도를 그대로 보여준다`() { + var retried = false + composeRule.setContent { + AfternoteTheme { AuthorFilteredErrorBody(onRetry = { retried = true }) } + } + + composeRule.onNodeWithText(string(R.string.afternote_home_load_error)).assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.afternote_home_retry)).assertIsDisplayed().performClick() + + assertTrue("재시도가 호출부로 전달되지 않았다", retried) + } + + /** + * 실패를 «0건» 으로 위장하지 않는다. + * + * 카테고리 행을 살리려고 이 상태를 목록 경로로 흘리면 `afternote_home_filtered_empty` + * («이 카테고리에 등록된 애프터노트가 없어요»)가 떠서, 서버 응답을 못 받은 것을 «없다» 고 단정하고 + * 재시도 수단까지 사라진다 — 무음 실패(#705)와 같은 부류다. 그 «정리» 가 들어오면 여기가 빨개진다. + */ + @Test + fun `필터 조회 실패를 0건 문구로 덮지 않는다`() { + composeRule.setContent { AfternoteTheme { AuthorFilteredErrorBody() } } + + composeRule.onNodeWithText(string(R.string.afternote_home_filtered_empty)).assertDoesNotExist() + composeRule.onNodeWithText(string(R.string.afternote_empty_list_body)).assertDoesNotExist() + } + + /** + * 이 목록 화면은 수신자와 공유물이라 발신자 조각이 새면 그대로 수신자에게 간다 (#620·#1175). + * 이 본문이 그리는 헤더 문구는 호출부가 넘긴 것뿐이다. + */ + @Test + fun `수신자 호출부의 필터 실패 본문에는 발신자 문구가 없다`() { + composeRule.setContent { + AfternoteTheme { + FilteredErrorBody( + headerDescription = stringRes(R.string.afternote_receiver_afternote_list_header_description), + nextStep = null, + selectedType = AfternoteType.BUSINESS, + onTypeSelected = {}, + onRetry = {}, + filterRowScrollState = rememberScrollState(), + modifier = Modifier.fillMaxSize(), + ) + } + } + + composeRule + .onNodeWithText(string(R.string.afternote_receiver_afternote_list_header_description)) + .assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.afternote_home_header_description)).assertDoesNotExist() + composeRule.onNodeWithText(string(R.string.afternote_home_next_step_section_title)).assertDoesNotExist() + } + + private fun string(resId: Int): String = composeRule.activity.getString(resId) + + @Composable + private fun stringRes(resId: Int): String = stringResource(resId) + + /** [AfternoteHomeScreen] 이 «필터가 걸린 조회 실패» 에서 그리는 본문. 작성자 호출부 구성이다. */ + @Composable + private fun AuthorFilteredErrorBody( + onTypeSelected: (AfternoteType?) -> Unit = {}, + onRetry: () -> Unit = {}, + ) { + FilteredErrorBody( + headerDescription = stringRes(R.string.afternote_home_header_description), + nextStep = null, + selectedType = AfternoteType.BUSINESS, + onTypeSelected = onTypeSelected, + onRetry = onRetry, + filterRowScrollState = rememberScrollState(), + modifier = Modifier.fillMaxSize(), + ) + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeListFailureReportingTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeListFailureReportingTest.kt new file mode 100644 index 0000000000..ebe8668066 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeListFailureReportingTest.kt @@ -0,0 +1,104 @@ +package com.afternote.feature.afternote.presentation.home + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.net.SocketTimeoutException + +/** + * 목록 로드 실패가 텔레메트리에 남는지, 그리고 재시도 연타가 보관함을 밀어내지 않는지 (#705). + * + * Paging 은 실패를 `LoadState.Error` 로만 알리고 삼킨다 — 화면이 넘겨 주지 않으면 [AfternotePagingSource] + * 의 실패가 콘솔에 흔적을 남기지 않는다. 반대로 실패마다 그대로 기록하면 «다시 시도» 연타 한 번에 + * Crashlytics non-fatal 보관 한도(최근 8건)가 같은 실패로 채워져 실제 장애가 밀려난다. + * + * [ErrorReporter] 는 예외를 redact 하므로 단언은 `afternote_stage` · `error_type` 속성으로 한다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AfternoteHomeListFailureReportingTest { + /** `viewModelScope` 가 Main 디스패처를 요구한다 — 계측 자체는 코루틴을 쓰지 않는다. */ + @Before + fun setUp() { + Dispatchers.setMain(StandardTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `목록 실패는 list_load 단계로 기록한다`() { + val reporter = RecordingErrorReporter() + val viewModel = viewModel(reporter) + + viewModel.onListLoadFailed(IOException("목록 조회 실패")) + + assertEquals(listOf("list_load"), reporter.recordedStages) + assertEquals(listOf(IOException::class.java.name), reporter.recordedErrorTypes) + } + + @Test + fun `같은 장애가 이어지는 동안 재시도를 반복해도 한 번만 기록한다`() { + val reporter = RecordingErrorReporter() + val viewModel = viewModel(reporter) + + repeat(5) { viewModel.onListLoadFailed(IOException("목록 조회 실패")) } + + assertEquals(1, reporter.recordedStages.size) + } + + @Test + fun `장애 종류가 바뀌면 새 사건으로 기록한다`() { + val reporter = RecordingErrorReporter() + val viewModel = viewModel(reporter) + + viewModel.onListLoadFailed(IOException("연결 실패")) + viewModel.onListLoadFailed(SocketTimeoutException("응답 없음")) + + assertEquals( + listOf(IOException::class.java.name, SocketTimeoutException::class.java.name), + reporter.recordedErrorTypes, + ) + } + + @Test + fun `성공한 로드가 실패 구간을 닫아 다음 실패를 다시 기록한다`() { + val reporter = RecordingErrorReporter() + val viewModel = viewModel(reporter) + + viewModel.onListLoadFailed(IOException("목록 조회 실패")) + viewModel.onListLoadSucceeded() + viewModel.onListLoadFailed(IOException("목록 조회 실패")) + + assertEquals(listOf("list_load", "list_load"), reporter.recordedStages) + } + + private fun viewModel(reporter: ErrorReporter): AfternoteHomeViewModel = + AfternoteHomeViewModel( + afternoteRepository = FakeAfternoteRepository(), + errorReporter = reporter, + ) + + private class RecordingErrorReporter : ErrorReporter { + val recordedStages = mutableListOf() + val recordedErrorTypes = mutableListOf() + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + attributes["afternote_stage"]?.let(recordedStages::add) + attributes["error_type"]?.let(recordedErrorTypes::add) + } + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeRefreshErrorTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeRefreshErrorTest.kt new file mode 100644 index 0000000000..317aff6114 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeRefreshErrorTest.kt @@ -0,0 +1,56 @@ +package com.afternote.feature.afternote.presentation.home + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.ui.R +import com.afternote.core.ui.loading.ListRefreshErrorBanner +import com.afternote.core.ui.theme.AfternoteTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 목록이 남아 있는 상태의 새로고침 실패가 무음으로 끝나지 않는지에 대한 회귀 가드 (#705). + * + * 종전 [AfternoteHomeScreen] 은 `itemCount > 0` 이면 `LoadState.Error` 를 어디에도 싣지 않아, + * 당겨 새로고침이 실패해도 화면은 이전 목록 그대로였다. + * + * **여기서 잠그는 것은 배너 자체의 계약뿐이다.** 표시 여부를 정하는 판정은 화면 파일의 private + * 함수라 테스트가 부르지 않는다 — 테스트가 부르려고 프로덕션 공개 범위를 넓히지 않는다(#1678). + * + * 화면을 그려서 세 갈래를 확인하는 방법은 **이 소스셋에서** 두 번 시도했고 CI 에서 + * `ComposeTimeoutException` 으로 무너졌다. `collectAsLazyPagingItems` 가 로드 상태를 화면까지 + * 옮기지 못한 채 끝나서 `waitUntil` 로 기다려도 배너가 나타나지 않는다 — Robolectric 클래스가 + * 누적되면 단언 시점까지 컴포지션 이펙트가 진행되지 않는다(#1443 이 실측한 기전). + * + * 그래서 **세 갈래는 계측으로 올라갔다.** `app` 의 `AfternoteHomeRefreshBannerAndroidTest` 가 + * 실기에서 실 Paging 을 태워 「실패+목록 있음 → 배너」·「실패+0건 → 전면 오류」·「실패 아님 → + * 둘 다 아님」을 잠근다 (#1790). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteHomeRefreshErrorTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `배너는 실패 안내와 다시 시도를 함께 내놓는다`() { + var retries = 0 + composeRule.setContent { + AfternoteTheme { ListRefreshErrorBanner(onRetry = { retries += 1 }) } + } + + composeRule.onNodeWithText(string(R.string.core_ui_list_refresh_error)).assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.core_ui_list_refresh_retry)).performClick() + + composeRule.runOnIdle { assertEquals(1, retries) } + } + + private fun string(resId: Int): String = composeRule.activity.getString(resId) +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeReloadingTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeReloadingTest.kt new file mode 100644 index 0000000000..74c635ccf3 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeReloadingTest.kt @@ -0,0 +1,166 @@ +package com.afternote.feature.afternote.presentation.home + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * #1635 ① — 카테고리 전환 중에도 상단(헤더·카테고리 필터 행)이 남고 본문만 로딩이 되는지. + * + * 종전에는 전환이 만든 새 Paging 세대의 첫 상태(`refresh = Loading`)에서 0건이면 첫 진입과 똑같이 + * 판정돼 화면이 통째로 [com.afternote.core.ui.loading.LoadingBody] 로 덮였다. 그 동안 화면에는 + * **조작할 것이 하나도 없고**, 방금 탭한 카테고리가 어디로 갔는지도 보이지 않는다. + * + * 어떤 상태에서 이 본문이 선택되는지(전환·재시도 ↔ 첫 진입)는 `AfternoteHomeBodyStateTest` 가 따로 + * 고정한다 — 화면째 띄우면 Paging 첫 상태를 걷는 이펙트가 Robolectric 실행 순번에 걸린다 + * ([AfternoteHomeBodyState] KDoc). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteHomeReloadingTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `전환 중 로딩 본문에도 헤더와 카테고리 행이 남는다`() { + composeRule.setContent { AfternoteTheme { AuthorReloadingBody() } } + + composeRule.onNodeWithText(string(R.string.afternote_home_title)).assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.afternote_home_header_description)).assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.afternote_category_all)).assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.afternote_category_business)).assertIsDisplayed() + } + + /** 본문은 로딩으로 바뀐다 — 상단만 남기고 데이터 영역은 여전히 «불러오는 중» 이어야 한다. */ + @Test + fun `전환 중 로딩 본문은 데이터 영역에 로딩 표시를 그린다`() { + composeRule.setContent { AfternoteTheme { AuthorReloadingBody() } } + + composeRule + .onNode(SemanticsMatcher.expectValue(SemanticsProperties.ProgressBarRangeInfo, ProgressBarRangeInfo.Indeterminate)) + .assertExists() + } + + /** 방금 탭한 카테고리가 로딩 중에도 선택 상태로 보여야 «어디로 가는 중인지» 를 알 수 있다. */ + @Test + fun `전환 중 로딩 본문은 방금 고른 카테고리를 선택 상태로 그린다`() { + composeRule.setContent { AfternoteTheme { AuthorReloadingBody() } } + + composeRule.onNodeWithText(string(R.string.afternote_category_business)).assertIsSelected() + composeRule.onNodeWithText(string(R.string.afternote_category_all)).assertIsNotSelected() + } + + /** 「전체」로 돌아오는 전환도 이 본문을 지난다 — 그때 선택 탭은 「전체」다. */ + @Test + fun `전체로 돌아오는 전환 중에는 전체 탭이 선택 상태다`() { + composeRule.setContent { AfternoteTheme { AuthorReloadingBody(selectedType = null) } } + + composeRule.onNodeWithText(string(R.string.afternote_category_all)).assertIsSelected() + } + + /** + * 남긴 행이 «눌러도 아무 일 없는» 장식이면 로딩이 길어질 때 그 자체가 막다른 상태가 된다 + * (#620·#777 과 같은 규칙). 응답을 기다리는 동안에도 다른 카테고리로 갈아탈 수 있어야 한다. + */ + @Test + fun `전환 중에도 다른 카테고리를 고르면 호출부로 전달된다`() { + var called = false + var selected: AfternoteType? = AfternoteType.BUSINESS + composeRule.setContent { + AfternoteTheme { + AuthorReloadingBody(onTypeSelected = { + called = true + selected = it + }) + } + } + + composeRule.onNodeWithText(string(R.string.afternote_category_all)).performClick() + + assertTrue("카테고리 탭이 호출부로 전달되지 않았다", called) + assertEquals(null, selected) + } + + /** + * #1633 이 갈라 둔 0건 문구 두 종(#567)이 로딩 상태로 새지 않는다. + * + * 아직 응답이 오지 않은 것을 «없다» 고 단정하면 무음 실패(#705)와 같은 부류가 된다. + */ + @Test + fun `전환 중 로딩 본문은 0건 문구를 쓰지 않는다`() { + composeRule.setContent { AfternoteTheme { AuthorReloadingBody() } } + + composeRule.onNodeWithText(string(R.string.afternote_home_filtered_empty)).assertDoesNotExist() + composeRule.onNodeWithText(string(R.string.afternote_empty_list_body)).assertDoesNotExist() + composeRule.onNodeWithText(string(R.string.afternote_home_load_error)).assertDoesNotExist() + } + + /** + * 이 목록 화면은 수신자와 공유물이라 발신자 조각이 새면 그대로 수신자에게 간다 (#620·#1175). + * 이 본문이 그리는 헤더 문구는 호출부가 넘긴 것뿐이다. + */ + @Test + fun `수신자 호출부의 전환 중 로딩 본문에는 발신자 문구가 없다`() { + composeRule.setContent { + AfternoteTheme { + ReloadingBody( + headerDescription = stringRes(R.string.afternote_receiver_afternote_list_header_description), + nextStep = null, + selectedType = AfternoteType.BUSINESS, + onTypeSelected = {}, + filterRowScrollState = rememberScrollState(), + modifier = Modifier.fillMaxSize(), + ) + } + } + + composeRule + .onNodeWithText(string(R.string.afternote_receiver_afternote_list_header_description)) + .assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.afternote_home_header_description)).assertDoesNotExist() + composeRule.onNodeWithText(string(R.string.afternote_home_next_step_section_title)).assertDoesNotExist() + } + + private fun string(resId: Int): String = composeRule.activity.getString(resId) + + @Composable + private fun stringRes(resId: Int): String = stringResource(resId) + + /** [AfternoteHomeScreen] 이 «상단을 보고 있던 중의 로드» 에서 그리는 본문. 작성자 호출부 구성이다. */ + @Composable + private fun AuthorReloadingBody( + selectedType: AfternoteType? = AfternoteType.BUSINESS, + onTypeSelected: (AfternoteType?) -> Unit = {}, + ) { + ReloadingBody( + headerDescription = stringRes(R.string.afternote_home_header_description), + nextStep = null, + selectedType = selectedType, + onTypeSelected = onTypeSelected, + filterRowScrollState = rememberScrollState(), + modifier = Modifier.fillMaxSize(), + ) + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteEditorFlowLocalNavActionsTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteEditorFlowLocalNavActionsTest.kt new file mode 100644 index 0000000000..5305ac05fd --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteEditorFlowLocalNavActionsTest.kt @@ -0,0 +1,68 @@ +package com.afternote.feature.afternote.presentation.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 에디터 흐름 로컬 백스택 회귀 기준 (#1601 · #1698). + * + * 흐름 네 화면은 하나의 entry 안에서 스택을 이룬다 — 바닥에서의 back 은 흐름 자체를 닫고, + * 수신자 선택은 결과를 공유 ViewModel 에 넘긴 뒤 에디터로 돌아온다. + */ +class AfternoteEditorFlowLocalNavActionsTest { + private var exits = 0 + private var savedHome = 0 + private val confirmedReceivers = mutableListOf>() + private val flowStack = NavBackStack(AfternoteRoute.EditorRoute) + private val actions = + AfternoteEditorFlowLocalNavActions( + flowStack = flowStack, + navigationCallbacks = FeatureNavigationCallbacks { exits += 1 }, + onReceiversSelected = { confirmedReceivers += it }, + onSaveSuccessNavigateHome = { savedHome += 1 }, + ) + + private fun stack(): List = flowStack.map { it::class.simpleName!! } + + @Test + fun `추억 플레이리스트와 곡 추가는 에디터 위에 쌓인다`() { + actions.navigateToMemorialPlaylist() + actions.navigateToAddSong() + + assertEquals(listOf("EditorRoute", "MemorialPlaylistRoute", "AddSongRoute"), stack()) + + actions.popBack() + assertEquals(listOf("EditorRoute", "MemorialPlaylistRoute"), stack()) + } + + @Test + fun `수신자 선택은 확정한 전체 목록을 공유 ViewModel 에 넘기고 에디터로 돌아온다`() { + actions.navigateToSelectReceiver() + assertEquals(listOf("EditorRoute", "SelectReceiverRoute"), stack()) + + // 확정은 «추가분» 이 아니라 폼 수신자 전체다 — 한 번의 쓰기로 넘어가야 교체가 성립한다 (#1426). + actions.popBackWithSelectedReceivers(receiverIds = listOf(7L, 9L)) + + assertEquals(listOf(listOf(7L, 9L)), confirmedReceivers) + assertEquals(listOf("EditorRoute"), stack()) + } + + @Test + fun `흐름 바닥에서의 뒤로가기는 흐름을 통째로 닫는다`() { + actions.popBack() + + assertEquals(listOf("EditorRoute"), stack()) + assertEquals(1, exits) + } + + @Test + fun `저장 성공은 바깥 스택이 홈까지 되감도록 넘긴다`() { + actions.popToAfternoteHome() + + assertEquals(1, savedHome) + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteLocalNavActionsTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteLocalNavActionsTest.kt new file mode 100644 index 0000000000..586fa3beb4 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteLocalNavActionsTest.kt @@ -0,0 +1,116 @@ +package com.afternote.feature.afternote.presentation.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.afternote.core.ui.bottombar.BottomNavTab +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 애프터노트 작성자 로컬 백스택 경계 회귀 기준 (#1601 · #1698). + * + * 지문 관문은 이 스택의 **시작 화면**이다 — 인증을 지나면 관문이 남으면 안 되고, 홈에서의 + * 뒤로가기는 관문이 아니라 그래프 밖으로 나가야 한다. Nav2 시절 `AuthBoundaryBackStackTest` + * 와 `BottomTabBackStackRestorationTest` 가 루트 백스택으로 보던 몫이다. + */ +class AfternoteLocalNavActionsTest { + private var exits = 0 + private val backStack = NavBackStack(AfternoteRoute.FingerprintLoginRoute) + private val external = RecordingExternalActions() + private val actions = + AfternoteLocalNavActions( + backStack = backStack, + navigationCallbacks = FeatureNavigationCallbacks { exits += 1 }, + externalActions = external, + ) + + private fun stack(): List = backStack.map { it::class.simpleName!! } + + @Test + fun `스택은 지문 관문에서 시작한다`() { + assertEquals(listOf("FingerprintLoginRoute"), stack()) + } + + @Test + fun `지문 관문은 인증 성공 뒤 백스택에서 사라진다`() { + actions.replaceFingerprintLoginWithAfternoteHome() + + assertEquals(listOf("AfternoteHomeRoute"), stack()) + + // 애프터노트 홈에서 뒤로가기는 지문 관문이 아니라 그래프 밖으로 나간다. + actions.popBack() + assertEquals(listOf("AfternoteHomeRoute"), stack()) + assertEquals(1, exits) + } + + @Test + fun `상세와 에디터 흐름은 홈 위에 쌓인다`() { + actions.replaceFingerprintLoginWithAfternoteHome() + + actions.navigateToAfternoteDetail(itemId = 7L) + actions.navigateToEditorForEdit(itemId = 7L, initialType = AfternoteType.MEMORIAL) + + assertEquals( + listOf("AfternoteHomeRoute", "DetailRoute", "EditorFlowRoute"), + stack(), + ) + } + + @Test + fun `에디터 진입 키가 수정 대상과 종류를 나른다`() { + actions.navigateToEditorForEdit(itemId = 42L, initialType = AfternoteType.MEMORIAL) + val edit = backStack.last() as AfternoteRoute.EditorFlowRoute + assertEquals(42L, edit.itemId) + assertEquals(AfternoteType.MEMORIAL, edit.initialType) + + actions.navigateToNewEditor(initialType = AfternoteType.SOCIAL_NETWORK) + val create = backStack.last() as AfternoteRoute.EditorFlowRoute + assertEquals(null, create.itemId) + assertEquals(AfternoteType.SOCIAL_NETWORK, create.initialType) + } + + @Test + fun `저장 성공은 홈 위 화면들을 걷어내고 홈만 남긴다`() { + actions.replaceFingerprintLoginWithAfternoteHome() + actions.navigateToAfternoteDetail(itemId = 7L) + actions.navigateToNewEditor(initialType = AfternoteType.MEMORIAL) + + actions.popToAfternoteHome() + + assertEquals(listOf("AfternoteHomeRoute"), stack()) + } + + @Test + fun `그래프 밖 이동은 스택을 건드리지 않고 셸로 나간다`() { + actions.navigateToSetting() + actions.navigateToBottomTab(BottomNavTab.HOME) + actions.onFingerprintAuthFailed("지문 인증에 실패했습니다") + + assertTrue(external.wentToSetting) + assertEquals(BottomNavTab.HOME, external.tab) + assertEquals("지문 인증에 실패했습니다", external.fingerprintError) + assertEquals(listOf("FingerprintLoginRoute"), stack()) + } + + private class RecordingExternalActions : AfternoteExternalActions { + var wentToSetting = false + var tab: BottomNavTab? = null + var fingerprintError: String? = null + + override fun navigateToBottomTab(tab: BottomNavTab) { + this.tab = tab + } + + override fun navigateToSetting() { + wentToSetting = true + } + + override fun onFingerprintAuthFailed(message: String) { + fingerprintError = message + } + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteListChromeTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteListChromeTest.kt index 468484c8a7..e588b7f317 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteListChromeTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteListChromeTest.kt @@ -1,6 +1,7 @@ package com.afternote.feature.afternote.presentation.receiver.afternotelist import androidx.activity.ComponentActivity +import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.test.assertIsDisplayed @@ -170,6 +171,7 @@ class ReceiverAfternoteListChromeTest { onListItemClick = { _, _ -> }, headerDescription = stringResource(R.string.afternote_receiver_afternote_list_header_description), nextStep = null, + filterRowScrollState = rememberScrollState(), ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteListMappingTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteListMappingTest.kt index 4c5651fe23..8902c7728c 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteListMappingTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteListMappingTest.kt @@ -1,32 +1,64 @@ package com.afternote.feature.afternote.presentation.receiver.afternotelist +import androidx.paging.AsyncPagingDataDiffer +import androidx.paging.PagingData +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.shared.component.ListItemUiModel import com.afternote.feature.afternote.presentation.shared.model.AfternoteService -import com.afternote.feature.afternote.presentation.shared.util.getIconResForType import com.afternote.feature.receiver.domain.model.AfterNoteListItem +import com.afternote.feature.receiver.domain.testing.FakeReceiverRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals +import org.junit.Before import org.junit.Test +import com.afternote.core.ui.R as CoreUiR /** * 수신 목록 카드가 발신자가 고른 서비스명을 보여준다는 계약 (이슈 #617, #753). * * 알려진 서비스 아이콘은 이름이, 카탈로그 밖 fallback과 필터 탭은 종류가 결정한다. + * 판정은 화면이 실제로 소비하는 [ReceiverAfternoteHomeViewModel.pagedAfternotes] 의 결과로 한다 — + * 목록 항목 매퍼는 그 안의 구현 세부다 (#1673). */ +@OptIn(ExperimentalCoroutinesApi::class) class ReceiverAfternoteListMappingTest { - private fun listItem( - serviceName: String, - type: AfternoteType = AfternoteType.SOCIAL_NETWORK, - ) = AfterNoteListItem( - id = 5, - serviceName = serviceName, - type = type, - lastUpdatedAt = "2026.07.29", - ) + private val dispatcher = UnconfinedTestDispatcher() + + /** 카탈로그 밖 이름이 떨어지는 종류별 아이콘. 정본은 카테고리 아이콘 시안(4163:19696 외)이다. */ + private val categoryIconResByType = + mapOf( + AfternoteType.SOCIAL_NETWORK to CoreUiR.drawable.core_ui_afternote_social_pattern, + AfternoteType.BUSINESS to CoreUiR.drawable.core_ui_afternote_business_pattern, + AfternoteType.ESTATE to CoreUiR.drawable.core_ui_afternote_business_pattern, + AfternoteType.GALLERY_AND_FILES to CoreUiR.drawable.core_ui_afternote_gallery_category_pattern, + AfternoteType.MEMORIAL to CoreUiR.drawable.core_ui_afternote_memorial_guideline, + ) + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } @Test fun `카드 주 텍스트는 서버가 준 서비스명이다`() { - val uiModel = listItem(serviceName = "인스타그램").toUiModel() + val uiModel = uiModelsOf(listItem(serviceName = "인스타그램")).single() assertEquals(5L, uiModel.id) assertEquals("인스타그램", uiModel.serviceName) @@ -34,12 +66,13 @@ class ReceiverAfternoteListMappingTest { @Test fun `어떤 종류에서도 enum 이름이 카드 주 텍스트로 새지 않는다`() { - AfternoteType.entries.forEach { type -> - val uiModel = listItem(serviceName = "네이버 메일", type = type).toUiModel() + val uiModels = uiModelsOf(*AfternoteType.entries.map { listItem("네이버 메일", it) }.toTypedArray()) + assertEquals(AfternoteType.entries.size, uiModels.size) + uiModels.forEach { uiModel -> assertNotEquals( - "$type 카드에 종류 enum 이름이 그대로 노출됐다", - type.name, + "${uiModel.type} 카드에 종류 enum 이름이 그대로 노출됐다", + uiModel.type.name, uiModel.serviceName, ) } @@ -48,10 +81,12 @@ class ReceiverAfternoteListMappingTest { @Test fun `카탈로그에 있는 서비스명이면 그 서비스 아이콘을 쓴다`() { val uiModel = - listItem( - serviceName = AfternoteService.INSTAGRAM.displayKey, - type = AfternoteType.SOCIAL_NETWORK, - ).toUiModel() + uiModelsOf( + listItem( + serviceName = AfternoteService.INSTAGRAM.displayKey, + type = AfternoteType.SOCIAL_NETWORK, + ), + ).single() assertEquals(AfternoteService.INSTAGRAM.iconResId, uiModel.iconResId) } @@ -59,14 +94,83 @@ class ReceiverAfternoteListMappingTest { /** 카탈로그 밖 이름은 #490 이전에 저장된 "직접 추가하기" 데이터에서만 온다. */ @Test fun `카탈로그에 없는 이름은 그 항목의 종류 아이콘으로 떨어진다`() { - AfternoteType.entries.forEach { type -> - val uiModel = - listItem( - serviceName = "내가 직접 적은 서비스", - type = type, - ).toUiModel() + val uiModels = uiModelsOf(*AfternoteType.entries.map { listItem("내가 직접 적은 서비스", it) }.toTypedArray()) + + assertEquals(AfternoteType.entries.size, uiModels.size) + uiModels.forEach { uiModel -> + assertEquals(categoryIconResByType.getValue(uiModel.type), uiModel.iconResId) + } + } + + private fun listItem( + serviceName: String, + type: AfternoteType = AfternoteType.SOCIAL_NETWORK, + ) = AfterNoteListItem( + id = 5, + serviceName = serviceName, + type = type, + lastUpdatedAt = "2026.07.29", + ) - assertEquals(getIconResForType(type), uiModel.iconResId) + /** ViewModel 이 화면에 흘리는 [PagingData] 를 그대로 받아 UI 모델 목록으로 편다. */ + private fun uiModelsOf(vararg items: AfterNoteListItem): List { + lateinit var result: List + runTest(dispatcher) { + val viewModel = + ReceiverAfternoteHomeViewModel( + FakeReceiverRepository(pagedAfterNotes = flowOf(PagingData.from(items.toList()))), + ) + val differ = + AsyncPagingDataDiffer( + diffCallback = UI_MODEL_DIFF, + updateCallback = NoopListUpdateCallback, + mainDispatcher = dispatcher, + workerDispatcher = dispatcher, + ) + val collection = launch { viewModel.pagedAfternotes.collect(differ::submitData) } + advanceUntilIdle() + result = differ.snapshot().items + collection.cancel() } + return result + } + + private companion object { + val UI_MODEL_DIFF = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ListItemUiModel, + newItem: ListItemUiModel, + ): Boolean = oldItem.id == newItem.id + + override fun areContentsTheSame( + oldItem: ListItemUiModel, + newItem: ListItemUiModel, + ): Boolean = oldItem == newItem + } + + val NoopListUpdateCallback = + object : ListUpdateCallback { + override fun onInserted( + position: Int, + count: Int, + ) = Unit + + override fun onRemoved( + position: Int, + count: Int, + ) = Unit + + override fun onMoved( + fromPosition: Int, + toPosition: Int, + ) = Unit + + override fun onChanged( + position: Int, + count: Int, + payload: Any?, + ) = Unit + } } } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModelTest.kt index ed75858d04..cd0766bfdb 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModelTest.kt @@ -3,6 +3,7 @@ package com.afternote.feature.afternote.presentation.receiver.detail import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail import com.afternote.feature.receiver.domain.testing.FakeReceiverRepository import kotlinx.coroutines.CompletableDeferred @@ -197,7 +198,7 @@ class ReceivedAfternoteDetailViewModelTest { errorReporter: ErrorReporter = RecordingErrorReporter(), ): ReceivedAfternoteDetailViewModel = ReceivedAfternoteDetailViewModel( - savedStateHandle = SavedStateHandle(mapOf("afternoteId" to 42L)), + route = ReceivedAfternoteRoute.DetailRoute(afternoteId = 42L), receiverRepository = repository, errorReporter = errorReporter, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedMemorialVideoSectionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedMemorialVideoSectionTest.kt new file mode 100644 index 0000000000..02a15affc1 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedMemorialVideoSectionTest.kt @@ -0,0 +1,65 @@ +package com.afternote.feature.afternote.presentation.receiver.detail + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollTo +import com.afternote.core.ui.theme.AfternoteTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 수신자 상세의 영상 구역이 영상 유무로 갈리는 지점을 고정한다 (#1781). + * + * 영상이 없을 때 구역 자체가 사라지는 건 시안 결정이고(#274), 그 결정이 `ReceiverVideoSection` 의 + * 「영상 없음」 플레이스홀더를 현재 호출부에서 닿지 않게 만든다. 이 단언이 깨지는 순간 플레이스홀더가 + * 화면에 드러나므로, 치수를 썸네일과 같은 상수에서 끌어다 쓰는 근거가 여기 걸려 있다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ReceivedMemorialVideoSectionTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `영상이 없으면 영상 구역을 통째로 숨긴다`() { + composeRule.setContent { + AfternoteTheme { + MemorialReceivedDetailScreen( + senderName = "김발신", + onNavigateToFullList = {}, + onNavigateToPlaylist = {}, + onBackClick = {}, + albumCovers = emptyList(), + ) + } + } + + composeRule.onNodeWithText(FUNERAL_VIDEO_LABEL).assertDoesNotExist() + } + + @Test + fun `영상이 있으면 영상 구역을 그린다`() { + composeRule.setContent { + AfternoteTheme { + MemorialReceivedDetailScreen( + senderName = "김발신", + onNavigateToFullList = {}, + onNavigateToPlaylist = {}, + onBackClick = {}, + albumCovers = emptyList(), + memorialVideoUrl = "https://cdn.example.com/memorial.mp4", + ) + } + } + + composeRule.onNodeWithText(FUNERAL_VIDEO_LABEL).performScrollTo().assertIsDisplayed() + } + + private companion object { + const val FUNERAL_VIDEO_LABEL = "장례식에 남길 영상" + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceivedAfternoteLocalNavActionsTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceivedAfternoteLocalNavActionsTest.kt new file mode 100644 index 0000000000..7326d02f27 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceivedAfternoteLocalNavActionsTest.kt @@ -0,0 +1,56 @@ +package com.afternote.feature.afternote.presentation.receiver.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import org.junit.Assert.assertEquals +import org.junit.Test + +/** 수신 애프터노트 로컬 백스택 회귀 기준 (#1698). */ +class ReceivedAfternoteLocalNavActionsTest { + private var exits = 0 + private val backStack = NavBackStack(ReceivedAfternoteRoute.ListRoute) + private val actions = + ReceivedAfternoteLocalNavActions( + backStack = backStack, + navigationCallbacks = FeatureNavigationCallbacks { exits += 1 }, + ) + + private fun stack(): List = backStack.map { it::class.simpleName!! } + + @Test + fun `상세와 추억 플레이리스트는 목록 위에 쌓인다`() { + actions.navigateToDetail(afternoteId = 11L) + actions.navigateToMemorialPlaylist(afternoteId = 11L) + + assertEquals(listOf("ListRoute", "DetailRoute", "MemorialPlaylistRoute"), stack()) + } + + @Test + fun `상세의 애프터노트 확인하기는 목록을 두 번 쌓지 않는다`() { + actions.navigateToDetail(afternoteId = 11L) + + actions.navigateToList() + + // 목록 → 상세 → 목록 왕복이라, 뒤로가기가 방금 나온 상세로 되돌아가면 안 된다 (#777). + assertEquals(listOf("ListRoute"), stack()) + } + + @Test + fun `목록이 없는 진입에서도 목록 하나만 남는다`() { + backStack.clear() + backStack.add(ReceivedAfternoteRoute.DetailRoute(afternoteId = 11L)) + + actions.navigateToList() + + assertEquals(listOf("ListRoute"), stack()) + } + + @Test + fun `스택 바닥에서의 뒤로가기는 스택을 비우지 않고 셸에 넘긴다`() { + actions.popBack() + + assertEquals(listOf("ListRoute"), stack()) + assertEquals(1, exits) + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt index baf1f2a771..e508ac4f14 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail import com.afternote.feature.receiver.domain.model.ReceivedPlaylistDetail import com.afternote.feature.receiver.domain.model.ReceivedPlaylistSong @@ -193,7 +194,7 @@ class ReceiverMemorialPlaylistViewModelTest { errorReporter: ErrorReporter = RecordingErrorReporter(), ): ReceiverMemorialPlaylistViewModel = ReceiverMemorialPlaylistViewModel( - savedStateHandle = SavedStateHandle(mapOf("afternoteId" to afternoteId)), + route = ReceivedAfternoteRoute.MemorialPlaylistRoute(afternoteId = afternoteId), receiverRepository = repository, errorReporter = errorReporter, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailAspectRatioTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailAspectRatioTest.kt new file mode 100644 index 0000000000..c1b0d53f70 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailAspectRatioTest.kt @@ -0,0 +1,76 @@ +package com.afternote.feature.afternote.presentation.shared.detail + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * [MemorialVideoThumbnail] 이 폭에 관계없이 시안 종횡비를 지키는지 고정한다 (#1780). + * + * 종전 코드는 `fillMaxWidth().height(183.dp)` 라 폭만 유연했고, 화면 폭에 따라 종횡비가 1.36:1까지 + * 흔들렸다. 내용물이 `ContentScale.Crop` 이라 모양이 흔들리면 잘려 나가는 그림도 기기마다 달라진다. + * + * 실기기 폭(화면 폭 − 72)이 350dp 를 넘지 않으므로 좁은 폭 쪽이 회귀의 실제 무대다. 고정 높이로 + * 되돌아가면 두 번째 테스트가 깨진다 — 240dp 폭에서 높이가 183dp 로 남기 때문이다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], qualifiers = "w480dp-h2000dp") +class MemorialVideoThumbnailAspectRatioTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `폭이 시안과 같은 350dp 면 시안 높이 183dp 로 수렴한다`() { + val (width, height) = boundsAtWidth(350) + + assertEquals(350f, width, WIDTH_TOLERANCE_DP) + assertEquals(183f, height, HEIGHT_TOLERANCE_DP) + } + + @Test + fun `폭이 좁아져도 시안 종횡비를 유지한다`() { + val (width, height) = boundsAtWidth(240) + + assertEquals(240f, width, WIDTH_TOLERANCE_DP) + // 프로덕션 상수를 참조하지 않고 시안 수치를 다시 적는다 — 상수가 잘못 바뀌어도 이 단언은 깨진다. + assertEquals(350f / 183f, width / height, RATIO_TOLERANCE) + } + + /** 폭 [widthDp] 인 부모 안에 썸네일을 그리고 실제 레이아웃 크기를 dp 로 돌려준다. */ + private fun boundsAtWidth(widthDp: Int): Pair { + composeRule.setContent { + AfternoteTheme { + Box(modifier = Modifier.width(widthDp.dp)) { + MemorialVideoThumbnail( + thumbnailUrl = null, + modifier = Modifier.testTag(THUMBNAIL_TAG), + ) + } + } + } + + val bounds = composeRule.onNodeWithTag(THUMBNAIL_TAG, useUnmergedTree = true).getUnclippedBoundsInRoot() + return (bounds.right - bounds.left).value to (bounds.bottom - bounds.top).value + } + + private companion object { + const val THUMBNAIL_TAG = "memorialVideoThumbnailUnderTest" + + /** 레이아웃은 픽셀 정수로 반올림되므로 dp 단언에는 1px 미만의 여유가 필요하다. */ + const val WIDTH_TOLERANCE_DP = 0.5f + const val HEIGHT_TOLERANCE_DP = 0.5f + const val RATIO_TOLERANCE = 0.01f + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailTest.kt index 61717a3e3a..18f156206e 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailTest.kt @@ -2,23 +2,27 @@ package com.afternote.feature.afternote.presentation.shared.detail import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.presentation.detail.MemorialDetailContent import com.afternote.feature.afternote.presentation.detail.MemorialDetailScreen import com.afternote.feature.afternote.presentation.receiver.detail.MemorialReceivedDetailScreen +import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config /** * 추모 영상 썸네일 오버레이(#463 3번)를 고정한다. * - * 시안([node 4327:72864](https://www.figma.com/design/UP9ZR186jHvRBicjA2SOea/?node-id=4327-72864))은 - * 썸네일 좌하단에 「추모 영상」 라벨을 두는데 코드에는 그라디언트와 재생 아이콘만 있었다. + * 썸네일은 그라디언트와 중앙 재생 버튼만 그린다. 시안 + * ([node 4327:72864](https://www.figma.com/design/UP9ZR186jHvRBicjA2SOea/?node-id=4327-72864))의 + * 좌하단 라벨 「추모 영상」은 바로 위 8dp 간격의 섹션 헤더 「장례식에 남길 영상」과 정보가 겹쳐 + * 뺐다(#1779) — 되살아나지 않는지 **문자열 자원 부재**로 단언한다. 텍스트 조회로는 못 잡는다 + * (라벨이 `clearAndSetSemantics {}` 로 시맨틱을 비우고 있었다). 자세한 사유는 그 테스트의 KDoc. * * 발신자·수신자 상세가 **같은 한 벌**을 그리는지도 함께 단언한다 — 두 벌로 갈라져 있던 탓에 * 한쪽만 고치면 다른 쪽에 결손이 남는 구조였다. @@ -29,38 +33,29 @@ class MemorialVideoThumbnailTest { @get:Rule val composeRule = createComposeRule() + /** + * 뺀 라벨을 되살리는 회귀를 **문자열 자원 부재**로 막는다 (#1779). + * + * **`onNodeWithText` 로는 못 잡는다.** 라벨은 접근성 병합 노드가 장황해지지 않도록 + * `clearAndSetSemantics {}` 로 시맨틱을 비우고 있었다 — 그래서 텍스트 조회에 안 걸리고, + * 그 단언은 라벨이 **있는** 코드에 대고 돌려도 초록이다(실측). 태그로만 조회할 수 있었는데 + * 이 변경이 그 태그 파일을 함께 지웠다. + * + * 남는 확실한 신호는 **문자열 자원**이다. 라벨을 되살리려면 `afternote_memorial_video_overlay_label` + * 을 다시 넣어야 하므로, 그 이름이 해석되면 되살아난 것이다. + */ @Test - fun `썸네일 좌하단에 추모 영상 라벨을 그린다`() { - composeRule.setContent { - AfternoteTheme { - MemorialVideoThumbnail(thumbnailUrl = null) - } - } + fun `오버레이 라벨 문자열 자원이 되살아나지 않는다`() { + val context = RuntimeEnvironment.getApplication() - composeRule - .onNodeWithTag(MEMORIAL_VIDEO_OVERLAY_LABEL_TEST_TAG, useUnmergedTree = true) - .assertExists() - } + val identifier = + context.resources.getIdentifier( + "afternote_memorial_video_overlay_label", + "string", + context.packageName, + ) - @Test - fun `장식 라벨을 카드 접근성 설명에 합치지 않는다`() { - composeRule.setContent { - AfternoteTheme { - MemorialDetailScreen( - onBackClick = {}, - onEditClick = {}, - onDeleteConfirm = {}, - onVideoClick = {}, - content = - MemorialDetailContent( - memorialVideoUrl = "https://cdn.example.com/memorial.mp4", - ), - userName = "서영", - ) - } - } - - composeRule.onNodeWithText("추모 영상").assertDoesNotExist() + assertEquals("라벨 문자열이 돌아왔다 — 섹션 헤더 「장례식에 남길 영상」과 중복이라 뺀 것이다 (#1779)", 0, identifier) } @Test @@ -89,7 +84,7 @@ class MemorialVideoThumbnailTest { } @Test - fun `발신자 상세의 영상 카드가 같은 오버레이를 그린다`() { + fun `발신자 상세의 영상 카드가 같은 썸네일을 그린다`() { composeRule.setContent { AfternoteTheme { MemorialDetailScreen( @@ -107,12 +102,12 @@ class MemorialVideoThumbnailTest { } composeRule - .onNodeWithTag(MEMORIAL_VIDEO_OVERLAY_LABEL_TEST_TAG, useUnmergedTree = true) + .onNodeWithContentDescription("영상 재생", useUnmergedTree = true) .assertExists() } @Test - fun `수신자 상세의 영상 카드가 같은 오버레이를 그린다`() { + fun `수신자 상세의 영상 카드가 같은 썸네일을 그린다`() { composeRule.setContent { AfternoteTheme { MemorialReceivedDetailScreen( @@ -127,7 +122,7 @@ class MemorialVideoThumbnailTest { } composeRule - .onNodeWithTag(MEMORIAL_VIDEO_OVERLAY_LABEL_TEST_TAG, useUnmergedTree = true) + .onNodeWithContentDescription("영상 재생", useUnmergedTree = true) .assertExists() } } diff --git a/feature/home/presentation/build.gradle.kts b/feature/home/presentation/build.gradle.kts index 578ffe8ae0..fb4e6b31ca 100644 --- a/feature/home/presentation/build.gradle.kts +++ b/feature/home/presentation/build.gradle.kts @@ -20,6 +20,15 @@ dependencies { implementation(projects.core.ui) implementation(projects.feature.mindrecord.domain) implementation(projects.feature.mindrecord.presentation) + // 수신자 홈이 3 피처를 모으는 집계 대시보드라 생긴 의존 (#1462). + // domain 은 아래 방향이라 순환이 없다. receiver.presentation 은 실패 계측 + // (ReceiverFailureStage·recordReceiverFailure) 하나 때문인데, 그 계측은 수신자 + // 진입 인프라의 일부라 feature:receiver 에 남는 편이 맞다 — mindrecord.presentation + // 을 이미 같은 이유로 의존하고 있다. + implementation(projects.feature.receiver.domain) + implementation(projects.feature.receiver.presentation) + implementation(projects.feature.afternote.domain) + implementation(projects.feature.timeletter.domain) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.runtime.compose) @@ -27,6 +36,10 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(testFixtures(projects.core.domain)) testImplementation(testFixtures(projects.feature.mindrecord.domain)) + // 수신자 홈 테스트가 쓰던 fixture·스캐너를 함께 옮긴다 (#1462). + testImplementation(testFixtures(projects.feature.receiver.domain)) + testImplementation(testFixtures(projects.feature.timeletter.domain)) + testImplementation(testFixtures(projects.core.ui)) // 자리표시자가 화면에 도달하는지는 실제로 그려 봐야 확인된다 (#562). // 로딩 중 배지가 «미완료» 로 확정되지 않는지는 실제로 그려 봐야 확인된다 (#698). diff --git a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt index a15aee156d..25436ba8d4 100644 --- a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt @@ -92,6 +92,9 @@ interface HomeTabActions { fun onMemoriesSectionClick() + /** MEMORIES 카드의 「그날의 기록 다시 읽기」 — 카드가 가리키는 그 기록의 상세 (#793). */ + fun onMemoriesRecordDetailClick(recordId: Long) + fun onSettingClick() fun onRetryLoad() @@ -112,6 +115,8 @@ private object HomeTabActionsNoop : HomeTabActions { override fun onMemoriesSectionClick() {} + override fun onMemoriesRecordDetailClick(recordId: Long) {} + override fun onSettingClick() {} override fun onRetryLoad() {} @@ -307,6 +312,7 @@ private fun HomeTabScrollContent( homeTabMindRecordMemoriesSection( onMemoriesSectionClick = actions::onMemoriesSectionClick, + onRecordDetailClick = actions::onMemoriesRecordDetailClick, ) } } diff --git a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGrid.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGrid.kt index 2b7dec1465..a2c7cb7ffd 100644 --- a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGrid.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGrid.kt @@ -47,8 +47,8 @@ fun WeeklySummaryGrid( * `null` 은 «아직 모름» 이라 숫자 대신 대시를 그린다. 0 은 확정값이라 그대로 그린다. */ recordedCount: Int?, - onImageClick: () -> Unit = {}, - onCountCardClick: () -> Unit = {}, + onImageClick: () -> Unit, + onCountCardClick: () -> Unit, ) { BoxWithConstraints(modifier = modifier.fillMaxWidth()) { val gap = 8.dp @@ -152,6 +152,10 @@ fun WeeklySummaryGrid( @Composable private fun WeeklySummaryGridPreview() { AfternoteTheme { - WeeklySummaryGrid(recordedCount = 3) + WeeklySummaryGrid( + recordedCount = 3, + onCountCardClick = {}, + onImageClick = {}, + ) } } diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeActions.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeActions.kt similarity index 86% rename from feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeActions.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeActions.kt index 2c3b63f735..2f36d99ec2 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeActions.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeActions.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home +package com.afternote.feature.home.presentation.receiver /** * 수신자 홈에서 외부 라우팅으로 빠지는 액션 묶음. diff --git a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeDownloadErrorPopupHost.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeDownloadErrorPopupHost.kt new file mode 100644 index 0000000000..4991016827 --- /dev/null +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeDownloadErrorPopupHost.kt @@ -0,0 +1,47 @@ +package com.afternote.feature.home.presentation.receiver + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.afternote.core.ui.popup.AfternoteErrorPopup +import com.afternote.core.ui.popup.NetworkErrorPopup +import com.afternote.core.ui.popup.ServerErrorPopup +import com.afternote.feature.home.presentation.R +import com.afternote.feature.home.presentation.receiver.model.ReceiverDownloadErrorPopup +import com.afternote.core.ui.R as CoreUiR + +/** + * 내려받기 실패 팝업 3종을 그린다 (#446 · #1737). + * + * 저장 실패만 시안 4종(네트워크·서버·업로드·권한, `3628:23827`)에 대응 항목이 없다. + * 업로드 팝업을 돌려쓰면 화면에 「파일 업로드에 실패했습니다」 가 뜨는데, 이 경로는 + * 내려받은 파일을 로컬에 저장하는 중이라 사실과 다르다. 그래서 같은 골격 + * ([AfternoteErrorPopup])에 이 화면의 저장 실패 문구를 실어 그린다 — 시안 밖의 새 모양이 + * 아니라 4종이 공유하는 그 골격이다. + */ +@Composable +internal fun ReceiverDownloadErrorPopupHost( + popup: ReceiverDownloadErrorPopup, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + when (popup) { + ReceiverDownloadErrorPopup.NETWORK -> { + NetworkErrorPopup(onRetry = onRetry, onDismiss = onDismiss) + } + + ReceiverDownloadErrorPopup.SERVER -> { + ServerErrorPopup(onRetry = onRetry, onDismiss = onDismiss) + } + + ReceiverDownloadErrorPopup.SAVE -> { + AfternoteErrorPopup( + iconRes = CoreUiR.drawable.core_ui_ic_server, + title = stringResource(R.string.home_receiver_download_save_error_title), + description = stringResource(R.string.home_receiver_download_save_error_description), + buttonText = stringResource(CoreUiR.string.core_ui_server_error_retry), + onButtonClick = onRetry, + onDismiss = onDismiss, + ) + } + } +} diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeEntry.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeEntry.kt similarity index 95% rename from feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeEntry.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeEntry.kt index 389c53a300..4b3c875167 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeEntry.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeEntry.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home +package com.afternote.feature.home.presentation.receiver import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeEvent.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeEvent.kt similarity index 62% rename from feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeEvent.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeEvent.kt index 2dd80b2eec..c2963e316a 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeEvent.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeEvent.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home +package com.afternote.feature.home.presentation.receiver /** 수신자 홈 화면 사용자 이벤트. */ sealed interface ReceiverHomeEvent { @@ -10,5 +10,8 @@ sealed interface ReceiverHomeEvent { data object ConfirmDownload : ReceiverHomeEvent + /** 오류 팝업의 「다시 시도하기」 — 실패한 그 단계를 다시 건다 (#1737). */ + data object RetryDownload : ReceiverHomeEvent + data object ConsumeDownloadResult : ReceiverHomeEvent } diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreen.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeScreen.kt similarity index 75% rename from feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreen.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeScreen.kt index 6f47593dbd..54f743c691 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreen.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home +package com.afternote.feature.home.presentation.receiver import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -31,13 +31,14 @@ import com.afternote.core.ui.popup.Popup import com.afternote.core.ui.popup.PopupType import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.HomeTopBar -import com.afternote.feature.receiver.presentation.R -import com.afternote.feature.receiver.presentation.home.component.AfternoteSection -import com.afternote.feature.receiver.presentation.home.component.MindRecordSection -import com.afternote.feature.receiver.presentation.home.component.SenderMessageHeroCard -import com.afternote.feature.receiver.presentation.home.component.TimeLetterSection -import com.afternote.feature.receiver.presentation.home.model.ReceiverDownloadState -import com.afternote.feature.receiver.presentation.home.model.ReceiverHomeUiState +import com.afternote.feature.home.presentation.R +import com.afternote.feature.home.presentation.receiver.component.AfternoteSection +import com.afternote.feature.home.presentation.receiver.component.MindRecordSection +import com.afternote.feature.home.presentation.receiver.component.SenderMessageHeroCard +import com.afternote.feature.home.presentation.receiver.component.TimeLetterSection +import com.afternote.feature.home.presentation.receiver.model.FailedWithRetry +import com.afternote.feature.home.presentation.receiver.model.ReceiverDownloadState +import com.afternote.feature.home.presentation.receiver.model.ReceiverHomeUiState /** * 수신자 홈 화면 — 한 마디 + 마음의 기록·타임레터·애프터노트 카드 + 모든 기록 내려받기 버튼. @@ -116,7 +117,7 @@ private fun SuccessContent( // senderName 가 blank 면 헤더·Hero 카드 모두 안 그림 — "故 님이 남기신 기록" 같은 깨진 문구 회피. if (state.senderName.isNotBlank()) { Text( - text = stringResource(R.string.receiver_home_sender_record_title, state.senderName), + text = stringResource(R.string.home_receiver_sender_record_title, state.senderName), style = AfternoteDesign.typography.h2, color = AfternoteDesign.colors.gray9, ) @@ -142,7 +143,7 @@ private fun SuccessContent( onGoClick = actions.onNavigateToAfternote, ) AfternoteButton( - text = stringResource(R.string.receiver_home_download_all_button), + text = stringResource(R.string.home_receiver_download_all_button), onClick = { onEvent(ReceiverHomeEvent.RequestDownload) }, type = AfternoteButtonType.Default, ) @@ -153,10 +154,13 @@ private fun SuccessContent( /** * 내려받기 확인 팝업 + 결과 처리. * - * 실패 안내는 잠정 Snackbar 다 — 서버 작업 실패의 정본은 #446 공통 에러 팝업이라 컴포넌트가 - * 나오면 그쪽으로 재정렬된다 (#1391, #713 전례). `showSnackbar` 는 표출이 끝날 때까지 suspend - * 하므로 소비 이벤트는 표출 뒤에 보낸다 — 표출 중에는 [state] 가 Failed 로 유지돼 effect 가 - * 재시작되지 않고, 소비로 Idle 이 되면 이미 완료된 effect 만 정리된다 (#664 AddSongScreen 컨벤션). + * 재시도로 풀릴 수 있는 실패는 #446 공통 오류 팝업으로 안내한다 (#1737). 「다시 시도하기」 는 + * 실패한 그 단계를 다시 걸고, 그 판단은 ViewModel 이 한다. + * + * 재시도해도 같은 실패(미구현 내보내기, #1726)만 Snackbar 로 남는다. `showSnackbar` 는 표출이 + * 끝날 때까지 suspend 하므로 소비 이벤트는 표출 뒤에 보낸다 — 표출 중에는 [state] 가 Failed 로 + * 유지돼 effect 가 재시작되지 않고, 소비로 Idle 이 되면 이미 완료된 effect 만 정리된다 + * (#664 AddSongScreen 컨벤션). */ @Composable private fun DownloadDialogHost( @@ -171,7 +175,7 @@ private fun DownloadDialogHost( if (showDialog) { Popup( type = PopupType.Variant2, - message = stringResource(R.string.receiver_home_download_all_dialog_message), + message = stringResource(R.string.home_receiver_download_all_dialog_message), onConfirm = { onEvent(ReceiverHomeEvent.ConfirmDownload) }, onDismiss = { onEvent(ReceiverHomeEvent.DismissDownload) }, isLoading = state is ReceiverDownloadState.InProgress, @@ -179,6 +183,14 @@ private fun DownloadDialogHost( } when (state) { + is FailedWithRetry -> { + ReceiverDownloadErrorPopupHost( + popup = state.popup, + onRetry = { onEvent(ReceiverHomeEvent.RetryDownload) }, + onDismiss = { onEvent(ReceiverHomeEvent.ConsumeDownloadResult) }, + ) + } + is ReceiverDownloadState.Failed -> { val message = stringResource(state.messageRes) LaunchedEffect(state) { @@ -193,7 +205,16 @@ private fun DownloadDialogHost( } } - else -> { } + ReceiverDownloadState.Idle, + ReceiverDownloadState.Confirming, + ReceiverDownloadState.InProgress, + -> { + // 이 셋은 여기서 할 일이 없다 — 위 `Popup` 이 따로 보고 있고, 소비할 결과도 + // 아직 없다. 종전에는 `else -> { }` 였는데, 그러면 상태가 하나 늘 때 새 상태도 + // 조용히 여기로 흘러 아무 일도 일어나지 않는다. 이 `when` 이 하는 일이 결과 + // 소비(`ConsumeDownloadResult`)라서, 소비되지 못한 상태는 화면을 그 자리에 + // 가둔다. 이름으로 적어 두면 상태가 늘 때 컴파일이 막는다 (#1767). + } } } @@ -211,14 +232,14 @@ private fun ErrorState( ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = stringResource(R.string.receiver_home_error_message), + text = stringResource(R.string.home_receiver_error_message), style = AfternoteDesign.typography.bodySmallR, color = AfternoteDesign.colors.gray7, ) Spacer(modifier = Modifier.height(16.dp)) TextButton(onClick = onRetry) { Text( - text = stringResource(R.string.receiver_home_retry), + text = stringResource(R.string.home_receiver_retry), style = AfternoteDesign.typography.captionLargeB, color = AfternoteDesign.colors.gray9, ) diff --git a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeViewModel.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeViewModel.kt new file mode 100644 index 0000000000..834bbf0e79 --- /dev/null +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeViewModel.kt @@ -0,0 +1,324 @@ +package com.afternote.feature.home.presentation.receiver + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.ui.icon.AfternoteSourceIcon +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.home.presentation.R +import com.afternote.feature.home.presentation.receiver.model.FailedWithRetry +import com.afternote.feature.home.presentation.receiver.model.MindRecordSummary +import com.afternote.feature.home.presentation.receiver.model.ReceiverDownloadErrorPopup +import com.afternote.feature.home.presentation.receiver.model.ReceiverDownloadState +import com.afternote.feature.home.presentation.receiver.model.ReceiverHomeUiState +import com.afternote.feature.home.presentation.receiver.model.SenderMessage +import com.afternote.feature.home.presentation.usecase.GetReceiverHomeSummaryUseCase +import com.afternote.feature.home.presentation.usecase.ReceiverHomeSummary +import com.afternote.feature.home.presentation.usecase.ReceiverHomeSummaryResult +import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords +import com.afternote.feature.receiver.domain.error.ReceiverFailure +import com.afternote.feature.receiver.domain.model.AfterNoteListItem +import com.afternote.feature.receiver.domain.model.ReceivedExportBundle +import com.afternote.feature.receiver.domain.repository.ReceiverRepository +import com.afternote.feature.receiver.presentation.reporting.ReceiverFailureStage +import com.afternote.feature.receiver.presentation.reporting.recordReceiverFailure +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * 수신자 홈 화면 ViewModel. + * + * 한 마디·각 섹션 카운트·애프터노트 아이콘 목록을 한 번에 모아 단일 [ReceiverHomeUiState]로 노출하고, + * 모든 기록 내려받기 다이얼로그/요청 상태도 동일 객체에 합친다. + */ +@HiltViewModel +class ReceiverHomeViewModel + @Inject + internal constructor( + private val getReceiverHomeSummary: GetReceiverHomeSummaryUseCase, + // 내려받기는 홈 집계와 다른 축이다 — 조회가 아니라 사용자가 누른 명령이라 UseCase 밖에 둔다. + private val receiverRepository: ReceiverRepository, + private val errorReporter: ErrorReporter, + ) : ViewModel() { + private val _uiState = MutableStateFlow(ReceiverHomeUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + /** 진행 중인 홈 로드 — 첫 진입 이후의 ON_RESUME 이 실행 중인 로드와 겹치면 건너뛰기 위한 가드. */ + private var loadJob: Job? = null + + /** + * 다음 [refreshOnReturn] 이 첫 ON_RESUME(진입 자체)인지. 첫 resume 은 init 로드와 같은 + * 진입이므로 갱신하지 않는다 — Job 가드만으로는 init 로드가 빨리 끝난 뒤 도착한 첫 + * resume 이 순차 재조회를 건다. VM 필드인 이유는 + * [com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel] 과 + * 동일 — 프로세스 사망 후 복원에서도 init 로드와 수명이 일치한다. + */ + private var isFirstResume = true + + init { + loadHome() + } + + /** + * 다른 화면에서 홈으로 복귀했을 때의 자동 갱신 (#701). + * + * [ReceiverHomeEvent.Retry] 와 두 가지가 다르다 — 로딩을 방출하지 않고, 실패해도 보고 있던 + * 화면을 유지한다(부분 실패도 구멍 난 새 화면 대신 완결된 기존 화면을 남긴다). + * 첫 ON_RESUME(진입 자체)은 [isFirstResume] 로 스킵하고, 그 이후의 resume 이 실행 중인 + * 로드와 겹치면 진행 중인 Job 으로 건너뛴다. + */ + fun refreshOnReturn() { + if (isFirstResume) { + isFirstResume = false + return + } + if (loadJob?.isActive == true) return + loadHome(showsLoading = false, keepsStateOnFailure = true) + } + + fun onEvent(event: ReceiverHomeEvent) { + when (event) { + ReceiverHomeEvent.Retry -> { + loadHome() + } + + ReceiverHomeEvent.RequestDownload -> { + updateDownload(ReceiverDownloadState.Confirming) + } + + ReceiverHomeEvent.DismissDownload -> { + updateDownload(ReceiverDownloadState.Idle) + } + + ReceiverHomeEvent.ConfirmDownload -> { + downloadAll() + } + + ReceiverHomeEvent.RetryDownload -> { + retryDownload() + } + + ReceiverHomeEvent.ConsumeDownloadResult -> { + lastDownloadAttempt = null + updateDownload(ReceiverDownloadState.Idle) + } + } + } + + private fun loadHome( + showsLoading: Boolean = true, + keepsStateOnFailure: Boolean = false, + ) { + loadJob?.cancel() + if (showsLoading) { + _uiState.value = ReceiverHomeUiState.Loading + } + loadJob = + viewModelScope.launch { + loadHomeInternal(keepsStateOnFailure) + } + } + + private suspend fun loadHomeInternal(keepsStateOnFailure: Boolean) { + when (val result = getReceiverHomeSummary()) { + is ReceiverHomeSummaryResult.AllFailed -> { + // 화면을 유지하는 자동 갱신 실패도 기록한다 — 콘솔이 유일한 관측 지점이다. + errorReporter.recordReceiverFailure( + ReceiverFailureStage.RECEIVER_HOME_LOAD, + result.failure.firstCause, + ) + _uiState.update { current -> + if (keepsStateOnFailure && current is ReceiverHomeUiState.Success) { + // 자동 갱신 실패: 잘 보고 있던 홈을 에러 화면으로 대체하지 않는다. + current + } else { + ReceiverHomeUiState.Error(result.failure.firstCause) + } + } + } + + is ReceiverHomeSummaryResult.Loaded -> { + val failure = result.failure + if (failure != null) { + // 부분 실패는 0·빈 값으로 덮여 화면에도 콘솔에도 흔적이 남지 않던 구간 — + // 여기서만 기록한다. + errorReporter.recordReceiverFailure( + stage = ReceiverFailureStage.RECEIVER_HOME_PARTIAL_LOAD, + throwable = failure.firstCause, + failedSources = failure.failedSources, + ) + if (keepsStateOnFailure && _uiState.value is ReceiverHomeUiState.Success) { + // 자동 갱신의 부분 실패: 성공한 소스만 반영하면 실패한 섹션이 null 로 + // 꺼져 잘 보이던 카운트가 이유 없이 사라진다 — 완결된 기존 화면을 둔다. + return + } + } + showSummary(result.summary) + } + } + } + + private fun showSummary(summary: ReceiverHomeSummary) { + val senderMessageInfo = summary.senderMessage + // senderName / senderMessage 둘 다 blank 가드 — sender 가 이름·메시지 미입력 케이스 대응. + // ".orEmpty()" 만으로는 공백(" ") 통과해 "故 님이 남기신 기록" / "님의 한 마디" UI 깨짐. + val senderName = senderMessageInfo?.senderName?.takeIf { it.isNotBlank() }.orEmpty() + val senderMessageBody = senderMessageInfo?.message?.takeIf { it.isNotBlank() } + + _uiState.update { current -> + ReceiverHomeUiState.Success( + senderName = senderName, + senderMessage = + senderMessageBody?.let { + // orEmpty(): null 이면 "" — createdAt 미제공(구버전 서버) 시 날짜 슬롯만 비워 보이게. + // senderMessageInfo 에 ?. 가 없어도 안전: senderMessageBody(= info?.message?…) 가 + // non-null 인 이 블록에선 안전 호출 체인의 대우로 info 도 non-null 이 보장되고, + // K2 가 이를 smart cast 한다 (Kotlin 2.0+ "Local variables and further scopes"). + // ?. 를 붙이면 "여기서 null 일 수 있다" 는 거짓 신호가 되어 생략. + SenderMessage(date = senderMessageInfo.createdAt.orEmpty(), body = it) + }, + mindRecord = summary.mindRecords?.toHomeSummary(), + timeLetterTotalCount = summary.timeLetterTotalCount, + afternoteTotalCount = summary.afternotes?.totalCount, + afternoteIcons = + summary.afternotes + ?.items + .orEmpty() + .toAfternoteIcons(), + // 자동 갱신이 화면을 교체해도 진행 중인 내려받기 다이얼로그·진행 상태는 잃지 않는다. + download = (current as? ReceiverHomeUiState.Success)?.download ?: ReceiverDownloadState.Idle, + ) + } + } + + /** + * 오류 팝업의 「다시 시도하기」 가 되돌릴 마지막 시도 (#1737). + * + * 내려받기는 두 단계다 — 서버에서 받아 오고, 그 결과를 파일로 저장한다. 저장에서 + * 실패했는데 처음부터 다시 걸면 이미 성공한 조회를 한 번 더 태우고, 「다시 시도하기」 가 + * 이름과 달리 처음부터 하기가 된다. 실패한 그 단계만 다시 걸도록 시도를 들고 있는다. + */ + private var lastDownloadAttempt: (suspend () -> Unit)? = null + + private fun downloadAll() { + runDownloadAttempt { downloadThenSave() } + } + + private fun retryDownload() { + val attempt = lastDownloadAttempt ?: return + runDownloadAttempt(attempt) + } + + private fun runDownloadAttempt(attempt: suspend () -> Unit) { + lastDownloadAttempt = attempt + updateDownload(ReceiverDownloadState.InProgress) + viewModelScope.launch { attempt() } + } + + private suspend fun downloadThenSave() { + receiverRepository + .downloadReceivedExport() + .onSuccess { bundle -> saveBundle(bundle) } + .onFailure { e -> + errorReporter.recordReceiverFailure(ReceiverFailureStage.RECEIVED_EXPORT_DOWNLOAD, e) + updateDownload(e.toDownloadFailure(R.string.home_receiver_download_all_failed)) + } + } + + private suspend fun saveBundle(bundle: ReceivedExportBundle) { + // 저장 단계의 재시도 대상은 이 저장뿐이다 — 조회는 이미 성공했다. + lastDownloadAttempt = { saveBundle(bundle) } + receiverRepository + .saveReceivedExportToFile(bundle) + .onSuccess { updateDownload(ReceiverDownloadState.Done) } + .onFailure { e -> + errorReporter.recordReceiverFailure(ReceiverFailureStage.RECEIVED_EXPORT_SAVE, e) + updateDownload( + // 저장은 로컬 쓰기라 서버·네트워크 갈래로 안내하지 않는다. + if (e is ReceiverFailure.ExportNotSupported) { + ReceiverDownloadState.Failed(R.string.home_receiver_download_all_save_failed) + } else { + FailedWithRetry(ReceiverDownloadErrorPopup.SAVE) + }, + ) + } + } + + private fun updateDownload(next: ReceiverDownloadState) { + _uiState.update { state -> + if (state is ReceiverHomeUiState.Success) state.copy(download = next) else state + } + } + } + +private const val MAX_AFTERNOTE_ICONS = 4 + +/** + * 내려받기 실패를 팝업 갈래로 옮긴다 (#446). + * + * 미구현 내보내기는 재시도해도 같은 실패라 팝업 대신 기존 안내를 유지한다 (#1726). + * 사용자 거절과 전달 조건 미충족도 「다시 시도하기」 가 답이 아니라 같은 자리로 보낸다 — + * 내려받기 경로에서 서버가 그 둘을 주는 계약은 없지만, `when` 을 exhaustive 하게 두어 + * 수신자 실패 유형이 늘면 여기가 컴파일 에러로 잡히게 한다. + */ +private fun Throwable.toDownloadFailure( + @StringRes fallbackMessageRes: Int, +): ReceiverDownloadState = + when (this as? ReceiverFailure) { + is ReceiverFailure.NetworkUnavailable -> { + FailedWithRetry(ReceiverDownloadErrorPopup.NETWORK) + } + + is ReceiverFailure.UnexpectedServerFailure -> { + FailedWithRetry(ReceiverDownloadErrorPopup.SERVER) + } + + is ReceiverFailure.ExportNotSupported, + is ReceiverFailure.UserRejection, + is ReceiverFailure.DeliveryConditionNotMet, + -> { + ReceiverDownloadState.Failed(fallbackMessageRes) + } + + // 번역되지 않은 실패(로컬 예외 등) — 사유를 모르는 채 다른 안내를 할 근거가 없다. + null -> { + FailedWithRetry(ReceiverDownloadErrorPopup.SERVER) + } + } + +private fun ReceiverMindRecords.toHomeSummary(): MindRecordSummary = + MindRecordSummary( + dailyQuestionCount = dailyQuestions.size, + diaryCount = diaries.size, + ) + +private fun List.toAfternoteIcons(): List = + asSequence() + // `type` 은 non-null 이라 `mapNotNull` 이 걸러 낼 것이 없다 (#1332 · #1452). + // 남겨 두면 「종류를 모르는 항목이 섞여 들어온다」는 없는 사정을 읽게 한다. + .map { it.type } + .distinct() + .take(MAX_AFTERNOTE_ICONS) + .map { it.toSourceIcon() } + .toList() + +/** + * 애프터노트 종류를 홈이 그릴 아이콘으로 옮긴다 (#926). + * + * res id 를 상태에 담지 않는다 — 담으면 ViewModel 이 Android 리소스에 묶이고, 형제 + * feature 의 `R` 을 가로질러 참조하게 된다. 어떤 그림인지는 core:ui 가 안다. + */ +private fun AfternoteType.toSourceIcon(): AfternoteSourceIcon = + when (this) { + AfternoteType.SOCIAL_NETWORK -> AfternoteSourceIcon.SocialNetwork + AfternoteType.GALLERY_AND_FILES -> AfternoteSourceIcon.GalleryAndFiles + AfternoteType.MEMORIAL -> AfternoteSourceIcon.Memorial + AfternoteType.BUSINESS, AfternoteType.ESTATE -> AfternoteSourceIcon.Other + } diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/AfternoteSection.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/AfternoteSection.kt similarity index 87% rename from feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/AfternoteSection.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/AfternoteSection.kt index fe1d8b69de..e210950be4 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/AfternoteSection.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/AfternoteSection.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home.component +package com.afternote.feature.home.presentation.receiver.component import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -22,7 +22,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.afternote.core.ui.icon.AfternoteSourceIcon import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.feature.receiver.presentation.R +import com.afternote.feature.home.presentation.R private const val MAX_VISIBLE_ICONS = 4 private val ICON_SIZE = 32.dp @@ -41,14 +41,14 @@ fun AfternoteSection( ) { HomeSectionCard( modifier = modifier, - title = stringResource(R.string.receiver_home_afternote_section_title), - description = stringResource(R.string.receiver_home_afternote_section_desc), + title = stringResource(R.string.home_receiver_afternote_section_title), + description = stringResource(R.string.home_receiver_afternote_section_desc), countLine = rememberCountLine( - prefix = stringResource(R.string.receiver_home_afternote_count_prefix, countText(totalCount)), - suffix = stringResource(R.string.receiver_home_afternote_count_suffix), + prefix = stringResource(R.string.home_receiver_afternote_count_prefix, countText(totalCount)), + suffix = stringResource(R.string.home_receiver_afternote_count_suffix), ), - buttonText = stringResource(R.string.receiver_home_afternote_section_button), + buttonText = stringResource(R.string.home_receiver_afternote_section_button), onButtonClick = onGoClick, middleContent = { if (totalCount == null) return@HomeSectionCard @@ -108,7 +108,7 @@ private fun ExtraCountChip(count: Int) { contentAlignment = Alignment.Center, ) { Text( - text = stringResource(R.string.receiver_home_afternote_extra_count, count), + text = stringResource(R.string.home_receiver_afternote_extra_count, count), style = AfternoteDesign.typography.captionLargeB, color = AfternoteDesign.colors.white, ) diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/HomeSectionCard.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/HomeSectionCard.kt similarity index 95% rename from feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/HomeSectionCard.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/HomeSectionCard.kt index 423b3a46e3..78edf29b44 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/HomeSectionCard.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/HomeSectionCard.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home.component +package com.afternote.feature.home.presentation.receiver.component import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -20,7 +20,7 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.feature.receiver.presentation.R +import com.afternote.feature.home.presentation.R /** * 마음의 기록·타임레터·애프터노트 카드의 공통 컨테이너. @@ -96,4 +96,4 @@ fun rememberCountLine( * 두 번 적혀 한쪽만 고쳐질 자리가 된다. */ @Composable -fun countText(count: Int?): String = count?.toString() ?: stringResource(R.string.receiver_home_section_count_unavailable) +fun countText(count: Int?): String = count?.toString() ?: stringResource(R.string.home_receiver_section_count_unavailable) diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordSection.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/MindRecordSection.kt similarity index 73% rename from feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordSection.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/MindRecordSection.kt index bdbb94fad2..4f46cfcd69 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordSection.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/MindRecordSection.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home.component +package com.afternote.feature.home.presentation.receiver.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -9,8 +9,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.afternote.feature.receiver.presentation.R -import com.afternote.feature.receiver.presentation.home.model.MindRecordSummary +import com.afternote.feature.home.presentation.R +import com.afternote.feature.home.presentation.receiver.model.MindRecordSummary import com.afternote.core.ui.R as CoreUiR /** @@ -22,18 +22,18 @@ fun MindRecordSection( onGoClick: () -> Unit, modifier: Modifier = Modifier, ) { - val totalLabel = stringResource(R.string.receiver_home_mindrecord_total_label) + val totalLabel = stringResource(R.string.home_receiver_mindrecord_total_label) HomeSectionCard( modifier = modifier, - title = stringResource(R.string.receiver_home_mindrecord_section_title), - description = stringResource(R.string.receiver_home_mindrecord_section_desc), + title = stringResource(R.string.home_receiver_mindrecord_section_title), + description = stringResource(R.string.home_receiver_mindrecord_section_desc), countLine = rememberCountLine( prefix = - stringResource(R.string.receiver_home_mindrecord_count_prefix, countText(summary?.totalCount)), - suffix = stringResource(R.string.receiver_home_mindrecord_count_suffix), + stringResource(R.string.home_receiver_mindrecord_count_prefix, countText(summary?.totalCount)), + suffix = stringResource(R.string.home_receiver_mindrecord_count_suffix), ), - buttonText = stringResource(R.string.receiver_home_mindrecord_section_button), + buttonText = stringResource(R.string.home_receiver_mindrecord_section_button), onButtonClick = onGoClick, middleContent = { // 조회 실패에도 카드를 숨기지 않는다 — 섹션 레이아웃 유지가 시안 확정값이고, @@ -45,14 +45,14 @@ fun MindRecordSection( ) { MindRecordStatCard( iconResId = CoreUiR.drawable.core_ui_ic_mindrecord, - label = stringResource(R.string.receiver_home_mindrecord_daily_question), + label = stringResource(R.string.home_receiver_mindrecord_daily_question), totalLabel = totalLabel, count = summary?.dailyQuestionCount, modifier = Modifier.weight(1f), ) MindRecordStatCard( iconResId = CoreUiR.drawable.core_ui_ic_diary, - label = stringResource(R.string.receiver_home_mindrecord_diary), + label = stringResource(R.string.home_receiver_mindrecord_diary), totalLabel = totalLabel, count = summary?.diaryCount, modifier = Modifier.weight(1f), diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCard.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/MindRecordStatCard.kt similarity index 95% rename from feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCard.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/MindRecordStatCard.kt index 252204c62c..0d1a4f181d 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCard.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/MindRecordStatCard.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home.component +package com.afternote.feature.home.presentation.receiver.component import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -21,7 +21,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.feature.receiver.presentation.R +import com.afternote.feature.home.presentation.R /** * 마음의 기록 섹션 안에 들어가는 작은 통계 카드: 아이콘 → 라벨 → TOTAL → 카운트 (수직 정렬). @@ -78,7 +78,7 @@ fun MindRecordStatCard( ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = count?.toString() ?: stringResource(R.string.receiver_home_section_count_unavailable), + text = count?.toString() ?: stringResource(R.string.home_receiver_section_count_unavailable), style = AfternoteDesign.typography.h3, color = AfternoteDesign.colors.gray9, ) diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/SectionGoButton.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/SectionGoButton.kt similarity index 96% rename from feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/SectionGoButton.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/SectionGoButton.kt index b10c28e694..d77cbb2dcf 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/SectionGoButton.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/SectionGoButton.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home.component +package com.afternote.feature.home.presentation.receiver.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCard.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/SenderMessageHeroCard.kt similarity index 65% rename from feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCard.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/SenderMessageHeroCard.kt index ca4bb5026e..12570f450f 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCard.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/SenderMessageHeroCard.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home.component +package com.afternote.feature.home.presentation.receiver.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -19,7 +19,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.feature.receiver.presentation.R +import com.afternote.feature.home.presentation.R /** * 고인이 남긴 한 마디 + 작성일 + 본문을 보여주는 그라데이션 카드. @@ -44,15 +44,30 @@ fun SenderMessageHeroCard( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { + // 제목에만 weight 를 준다 — Row 는 가중치 없는 자식을 **먼저** 재므로, 작성일이 자기 + // 폭을 온전히 받고 제목이 남은 폭을 쓴다. 둘 다 가중치가 없던 종전에는 긴 제목이 + // 가용 폭을 다 써 뒤에 재는 작성일이 0dp 를 받았고, 큰 글씨(배율 2.0)와 8글자 이름이 + // 겹치면 「2026.09.13」 이 열 줄로 쪼개졌다 (#2032). + // + // fill = false 라 제목은 제 폭만 차지한다 — 짧은 이름에서는 SpaceBetween 이 종전과 + // 같은 자리에 둘을 놓는다. end padding 은 제목과 작성일이 맞붙지 않게 하는 최소 간격이고, + // 제목이 그 폭을 다 쓰지 않는 경우에는 보이는 위치를 바꾸지 않는다. Text( - text = stringResource(R.string.receiver_home_hero_title, senderName), + text = stringResource(R.string.home_receiver_hero_title, senderName), style = AfternoteDesign.typography.bodySmallB, color = AfternoteDesign.colors.gray7, + modifier = + Modifier + .weight(1f, fill = false) + .padding(end = 8.dp), ) + // 날짜는 형식이 고정돼 있어 줄을 나눌 이유가 없다 — 한 줄로 못 담을 만큼 좁아지면 + // 쪼개는 대신 잘라서, 한 글자씩 세로로 서는 모양이 어떤 배율에서도 다시 나오지 않게 한다. Text( text = date, style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray6, + maxLines = 1, ) } Spacer(modifier = Modifier.height(12.dp)) diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/TimeLetterSection.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/TimeLetterSection.kt similarity index 58% rename from feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/TimeLetterSection.kt rename to feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/TimeLetterSection.kt index e8e8174962..4f92f7c3dd 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/component/TimeLetterSection.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/component/TimeLetterSection.kt @@ -1,9 +1,9 @@ -package com.afternote.feature.receiver.presentation.home.component +package com.afternote.feature.home.presentation.receiver.component import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import com.afternote.feature.receiver.presentation.R +import com.afternote.feature.home.presentation.R /** 타임레터 섹션 — 단순 카드. */ @Composable @@ -14,15 +14,15 @@ fun TimeLetterSection( ) { HomeSectionCard( modifier = modifier, - title = stringResource(R.string.receiver_home_timeletter_section_title), - description = stringResource(R.string.receiver_home_timeletter_section_desc), + title = stringResource(R.string.home_receiver_timeletter_section_title), + description = stringResource(R.string.home_receiver_timeletter_section_desc), countLine = rememberCountLine( prefix = - stringResource(R.string.receiver_home_timeletter_count_prefix, countText(totalCount)), - suffix = stringResource(R.string.receiver_home_timeletter_count_suffix), + stringResource(R.string.home_receiver_timeletter_count_prefix, countText(totalCount)), + suffix = stringResource(R.string.home_receiver_timeletter_count_suffix), ), - buttonText = stringResource(R.string.receiver_home_timeletter_section_button), + buttonText = stringResource(R.string.home_receiver_timeletter_section_button), onButtonClick = onGoClick, ) } diff --git a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/model/ReceiverHomeUiState.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/model/ReceiverHomeUiState.kt new file mode 100644 index 0000000000..0449626146 --- /dev/null +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/receiver/model/ReceiverHomeUiState.kt @@ -0,0 +1,103 @@ +package com.afternote.feature.home.presentation.receiver.model + +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import com.afternote.core.ui.icon.AfternoteSourceIcon + +/** + * 수신자 홈 화면 UI 상태. + * + * 한 화면당 단일 UI State 객체로, 로드 상태 + 모든 기록 내려받기 다이얼로그 상태를 함께 보유한다. + */ +sealed interface ReceiverHomeUiState { + data object Loading : ReceiverHomeUiState + + @Immutable + data class Success( + val senderName: String, + val senderMessage: SenderMessage?, + /** null은 조회 실패, 0은 정상 조회된 빈 목록이다. */ + val mindRecord: MindRecordSummary?, + /** null은 조회 실패, 0은 정상 조회된 빈 목록이다. */ + val timeLetterTotalCount: Int?, + /** null은 조회 실패, 0은 정상 조회된 빈 목록이다. */ + val afternoteTotalCount: Int?, + val afternoteIcons: List, + val download: ReceiverDownloadState = ReceiverDownloadState.Idle, + ) : ReceiverHomeUiState + + data class Error( + val throwable: Throwable, + ) : ReceiverHomeUiState +} + +@Immutable +data class SenderMessage( + val date: String, + val body: String, +) + +@Immutable +data class MindRecordSummary( + val dailyQuestionCount: Int, + val diaryCount: Int, +) { + val totalCount: Int + get() = dailyQuestionCount + diaryCount +} + +@Immutable +sealed interface ReceiverDownloadState { + data object Idle : ReceiverDownloadState + + data object Confirming : ReceiverDownloadState + + data object InProgress : ReceiverDownloadState + + data object Done : ReceiverDownloadState + + /** + * 재시도해도 같은 결과인 실패 — 팝업 대신 기존 안내를 유지한다. + * + * 미구현 내보내기([com.afternote.feature.receiver.domain.error.ReceiverFailure.ExportNotSupported], + * #1726)가 여기 온다. 「다시 시도하기」 를 주면 같은 실패를 반복하게 만든다. + */ + data class Failed( + @param:StringRes val messageRes: Int, + ) : ReceiverDownloadState +} + +/** + * 재시도로 풀릴 수 있는 실패 — 공통 오류 팝업으로 안내한다 (#446 · #1737). + * + * 「다시 시도하기」 는 처음부터가 아니라 **실패한 그 단계**를 다시 건다. 그 단계를 + * 상태에 담지 않는 이유는 재시도가 화면의 결정이 아니라서다 — 무엇을 다시 걸지는 + * ViewModel 이 마지막 시도로 들고 있고, 화면은 갈래만 보고 팝업을 고른다. + * + * [ReceiverDownloadState] 안이 아니라 밖에 둔 것은 가시성 때문이다 — 인터페이스 안의 + * 중첩 선언은 `internal` 을 붙일 수 없고, 이 갈래는 모듈 밖으로 나갈 계약이 아니다. + */ +internal data class FailedWithRetry( + val popup: ReceiverDownloadErrorPopup, +) : ReceiverDownloadState + +/** + * 내려받기 실패를 그릴 공통 오류 팝업의 갈래 (#446). + * + * 수신자 화면의 `ReceiverErrorPopup` 과 같은 모델이지만 그쪽은 receiver 모듈 안에 갇혀 있고 + * (`internal`), 갈래도 다르다 — 이쪽은 업로드가 아니라 **내려받은 파일을 로컬에 저장**하는 + * 단계가 따로 있다. + * + * 이 갈래를 고르는 것도 그리는 것도 `feature:home:presentation` 안이라 `internal` 이다 — + * 화면 밖으로 나가는 계약이 아니다 (docs/convention/production-visibility.md). + */ +internal enum class ReceiverDownloadErrorPopup { + /** 서버에 닿지도 못한 실패 — 안내는 "연결을 확인하라". */ + NETWORK, + + /** 서버가 응답했지만 내려주지 못한 실패 — 안내는 "잠시 후 다시". */ + SERVER, + + /** 내려받은 뒤 파일로 저장하지 못한 실패 — 서버 문제가 아니므로 갈라 둔다. */ + SAVE, +} diff --git a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt index 9e871ad575..0cb8ecbb48 100644 --- a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt @@ -1,7 +1,8 @@ package com.afternote.feature.home.presentation.usecase import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.home.presentation.usecase.HomeSummary import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository @@ -27,15 +28,16 @@ import javax.inject.Inject class GetHomeSummaryUseCase @Inject constructor( - private val userRepository: UserRepository, + private val myProfileRepository: MyProfileRepository, + private val userReceiverRepository: UserReceiverRepository, private val dailyQuestionRepository: DailyQuestionRepository, private val getWeeklyRecordCount: GetWeeklyRecordCountUseCase, ) { suspend operator fun invoke(): Result = runCatchingCancellable { coroutineScope { - val profileDeferred = async { userRepository.getMyProfile() } - val receiversDeferred = async { userRepository.getReceivers() } + val profileDeferred = async { myProfileRepository.getMyProfile() } + val receiversDeferred = async { userReceiverRepository.getReceivers() } val todayQuestionDeferred = async { dailyQuestionRepository.getToday() } // 예산을 **async 안에서** 건다. 밖에서 걸면 `await()` 만 끊기고 이 async 는 // 여전히 coroutineScope 의 자식이라, scope 가 그 자식을 끝까지 기다린다 — diff --git a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetReceiverHomeSummaryUseCase.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetReceiverHomeSummaryUseCase.kt new file mode 100644 index 0000000000..2b03c53dd7 --- /dev/null +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetReceiverHomeSummaryUseCase.kt @@ -0,0 +1,133 @@ +package com.afternote.feature.home.presentation.usecase + +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords +import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository +import com.afternote.feature.receiver.domain.model.AfterNotesListResult +import com.afternote.feature.receiver.domain.model.SenderMessageInfo +import com.afternote.feature.receiver.domain.repository.ReceiverRepository +import com.afternote.feature.timeletter.domain.repository.ReceiverTimeLetterRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import javax.inject.Inject + +/** + * 수신자 홈이 필요로 하는 네 출처를 병렬 조회하고 **부분 실패 정책까지** 소유한다 (#1689). + * + * 서버에 수신자 홈 전용 엔드포인트가 없어 클라이언트가 합성한다. 합성에 붙는 판정이 둘이고 + * 종전에는 `ReceiverHomeViewModel` 안에 있었다. + * + * 1. **전부 실패해야 전체 실패다.** 하나라도 성공하면 그 출처만으로 화면을 그린다 — 애프터노트 + * 한 건이 실패했다고 잘 온 타임레터·마음기록까지 오류 화면으로 덮으면 잃는 것이 더 크다. + * 2. **실패한 출처의 이름을 남긴다.** 부분 실패는 0·빈 값으로 덮여 화면에도 콘솔에도 흔적이 + * 남지 않던 구간이라, 무엇이 빠졌는지는 이 결과만 안다. + * + * 아이콘 선택·문자열·화면 상태·telemetry 기록은 **여기 없다** — 그것들은 화면의 몫이라 + * ViewModel 에 남는다. 이 결과는 Android 리소스도 UI 모델도 참조하지 않는다. + * + * [GetHomeSummaryUseCase] 와 같은 자리에 두는 이유도 같다 — 여러 feature 의 domain 을 함께 + * 끌어와야 해서 domain 패키지에 두면 레이어 가드에 걸린다. + * + * 이 집계를 소비하는 프로덕션 코드는 같은 모듈의 [ReceiverHomeViewModel] 뿐이라 `internal` + * 이다 (docs/convention/production-visibility.md). 전체 실패 → 재시도 → 부분 성공 회귀도 + * 모듈 밖 계측이 아니라 같은 모듈의 Robolectric Compose 테스트 + * (`ReceiverHomePartialFailureTest`)가 잇는다. + */ +internal class GetReceiverHomeSummaryUseCase + @Inject + constructor( + private val receiverRepository: ReceiverRepository, + private val mindRecordReceiverRepository: MindRecordReceiverRepository, + private val receiverTimeLetterRepository: ReceiverTimeLetterRepository, + ) { + suspend operator fun invoke(): ReceiverHomeSummaryResult = + coroutineScope { + val afternotes = async { receiverRepository.getReceivedAfterNotes() } + val mindRecords = async { mindRecordReceiverRepository.getAll() } + // 이 저장소만 Result 가 아니라 값을 던진다 — 취소를 실패로 바꾸지 않도록 감싼다. + val timeLetters = + async { + runCatchingCancellable { receiverTimeLetterRepository.getReceivedTimeLetters() } + } + val message = async { receiverRepository.loadSenderMessage() } + + val afternotesResult = afternotes.await() + val mindRecordsResult = mindRecords.await() + val timeLettersResult = timeLetters.await() + val messageResult = message.await() + + val failedSources = + buildList { + if (afternotesResult.isFailure) add(SOURCE_AFTERNOTES) + if (mindRecordsResult.isFailure) add(SOURCE_MIND_RECORDS) + if (timeLettersResult.isFailure) add(SOURCE_TIME_LETTERS) + if (messageResult.isFailure) add(SOURCE_SENDER_MESSAGE) + } + val firstCause = + afternotesResult.exceptionOrNull() + ?: mindRecordsResult.exceptionOrNull() + ?: timeLettersResult.exceptionOrNull() + ?: messageResult.exceptionOrNull() + + val failure = firstCause?.let { SourceFailure(firstCause = it, failedSources = failedSources) } + if (failure != null && failedSources.size == SOURCE_COUNT) { + return@coroutineScope ReceiverHomeSummaryResult.AllFailed(failure) + } + + ReceiverHomeSummaryResult.Loaded( + summary = + ReceiverHomeSummary( + afternotes = afternotesResult.getOrNull(), + mindRecords = mindRecordsResult.getOrNull(), + timeLetterTotalCount = timeLettersResult.getOrNull()?.totalCount, + senderMessage = messageResult.getOrNull(), + ), + failure = failure, + ) + } + + private companion object { + const val SOURCE_AFTERNOTES = "afternotes" + const val SOURCE_MIND_RECORDS = "mind_records" + const val SOURCE_TIME_LETTERS = "time_letters" + const val SOURCE_SENDER_MESSAGE = "sender_message" + + /** 홈 한 화면을 그리려고 던지는 요청 수 — 전부 실패해야 전체 실패다. */ + const val SOURCE_COUNT = 4 + } + } + +/** 네 출처에서 온 값. 실패한 출처는 `null` 이고, 무엇이 실패했는지는 [SourceFailure] 가 안다. */ +internal data class ReceiverHomeSummary( + val afternotes: AfterNotesListResult?, + val mindRecords: ReceiverMindRecords?, + val timeLetterTotalCount: Int?, + val senderMessage: SenderMessageInfo?, +) + +/** + * 실패한 출처들과 그중 첫 원인. + * + * 첫 원인을 따로 드는 이유는 telemetry 가 예외 하나를 요구해서다 — 네 개를 다 올리면 + * 보관 한도(최근 8건)를 한 번의 오프라인이 채운다. + */ +internal data class SourceFailure( + val firstCause: Throwable, + val failedSources: List, +) + +internal sealed interface ReceiverHomeSummaryResult { + /** + * 화면을 그릴 수 있는 상태. [failure] 가 `null` 이 아니면 **부분 실패** 다 — 성공한 출처는 + * [summary] 에 들어 있고 실패한 자리는 `null` 이다. + */ + data class Loaded( + val summary: ReceiverHomeSummary, + val failure: SourceFailure?, + ) : ReceiverHomeSummaryResult + + /** 네 출처가 모두 실패해 그릴 것이 없는 상태. */ + data class AllFailed( + val failure: SourceFailure, + ) : ReceiverHomeSummaryResult +} diff --git a/feature/home/presentation/src/main/res/values/strings.xml b/feature/home/presentation/src/main/res/values/strings.xml index 496404c40f..4f7498ea53 100644 --- a/feature/home/presentation/src/main/res/values/strings.xml +++ b/feature/home/presentation/src/main/res/values/strings.xml @@ -13,4 +13,40 @@ 다시 시도 – + + + 故 %1$s님이 남기신 기록 + %1$s님의 한 마디 + TOTAL + 데일리 질문 + 일기 + +%1$d + 마음의 기록 + 고인의 일상적인 생각과 감정, 일기들입니다. + 마음의 기록 확인하러 가기 + 타임레터 + 고인이 특별한 날에\n수신자님에게 작성한 편지입니다. + 라이프 이벤트 레터 확인하러 가기 + 애프터노트 + 고인이 사후 정리하고자 하는 데이터입니다. + 애프터노트 확인하러 가기 + – + 모든 기록 내려받기 + 모든 기록을 내려받으시겠습니까? + 모든 기록 내려받기에 실패했습니다. + + 파일 저장에 실패했습니다. + 저장 실패 + + 파일 저장에 실패했습니다.\n다시 시도해 주세요. + + 정보를 불러오지 못했습니다. + 다시 시도 + "%1$s개 " + 마음의 기록이 있습니다. + "%1$s개 " + 라이프 이벤트 레터가 있습니다. + %1$s개 + 의 애프터노트가 있습니다. diff --git a/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTest.kt b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTest.kt index 80c5985b97..508bc3330d 100644 --- a/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTest.kt +++ b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/WeeklySummaryGridScreenshotTest.kt @@ -13,6 +13,11 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun weeklySummaryGridScreenshot() { AfternoteTheme { - WeeklySummaryGrid(recordedCount = 3, modifier = Modifier.padding(16.dp)) + WeeklySummaryGrid( + recordedCount = 3, + modifier = Modifier.padding(16.dp), + onCountCardClick = {}, + onImageClick = {}, + ) } } diff --git a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTest.kt b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeScreenScreenshotTest.kt similarity index 91% rename from feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTest.kt rename to feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeScreenScreenshotTest.kt index d8b2e702c9..75989ec771 100644 --- a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTest.kt +++ b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeScreenScreenshotTest.kt @@ -1,12 +1,12 @@ -package com.afternote.feature.receiver.presentation.home +package com.afternote.feature.home.presentation.receiver import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.icon.AfternoteSourceIcon import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.receiver.presentation.home.model.MindRecordSummary -import com.afternote.feature.receiver.presentation.home.model.ReceiverHomeUiState -import com.afternote.feature.receiver.presentation.home.model.SenderMessage +import com.afternote.feature.home.presentation.receiver.model.MindRecordSummary +import com.afternote.feature.home.presentation.receiver.model.ReceiverHomeUiState +import com.afternote.feature.home.presentation.receiver.model.SenderMessage import com.android.tools.screenshot.PreviewTest /** baseline 은 화면 픽셀만 본다 — 외부 라우팅은 눌리지 않으므로 빈 액션으로 채운다. */ diff --git a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/AfternoteSectionScreenshotTest.kt b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/AfternoteSectionScreenshotTest.kt similarity index 92% rename from feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/AfternoteSectionScreenshotTest.kt rename to feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/AfternoteSectionScreenshotTest.kt index f8d11804c4..30837cfa2f 100644 --- a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/AfternoteSectionScreenshotTest.kt +++ b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/AfternoteSectionScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home.component +package com.afternote.feature.home.presentation.receiver.component import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable diff --git a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordSectionScreenshotTest.kt b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/MindRecordSectionScreenshotTest.kt similarity index 85% rename from feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordSectionScreenshotTest.kt rename to feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/MindRecordSectionScreenshotTest.kt index 45c785571c..cd52f5ec7b 100644 --- a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordSectionScreenshotTest.kt +++ b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/MindRecordSectionScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home.component +package com.afternote.feature.home.presentation.receiver.component import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -6,7 +6,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.receiver.presentation.home.model.MindRecordSummary +import com.afternote.feature.home.presentation.receiver.model.MindRecordSummary import com.android.tools.screenshot.PreviewTest @PreviewTest diff --git a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCardScreenshotTest.kt b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/MindRecordStatCardScreenshotTest.kt similarity index 93% rename from feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCardScreenshotTest.kt rename to feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/MindRecordStatCardScreenshotTest.kt index 08685d3a1e..b20e42b057 100644 --- a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCardScreenshotTest.kt +++ b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/MindRecordStatCardScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home.component +package com.afternote.feature.home.presentation.receiver.component import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable diff --git a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/SectionGoButtonScreenshotTest.kt b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/SectionGoButtonScreenshotTest.kt similarity index 92% rename from feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/SectionGoButtonScreenshotTest.kt rename to feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/SectionGoButtonScreenshotTest.kt index f00d5ddc4f..d9570b6db6 100644 --- a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/SectionGoButtonScreenshotTest.kt +++ b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/SectionGoButtonScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home.component +package com.afternote.feature.home.presentation.receiver.component import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable diff --git a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCardScreenshotTest.kt b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/SenderMessageHeroCardScreenshotTest.kt similarity index 94% rename from feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCardScreenshotTest.kt rename to feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/SenderMessageHeroCardScreenshotTest.kt index b7ebed0ec1..838780af46 100644 --- a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCardScreenshotTest.kt +++ b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/SenderMessageHeroCardScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home.component +package com.afternote.feature.home.presentation.receiver.component import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable diff --git a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/TimeLetterSectionScreenshotTest.kt b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/TimeLetterSectionScreenshotTest.kt similarity index 90% rename from feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/TimeLetterSectionScreenshotTest.kt rename to feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/TimeLetterSectionScreenshotTest.kt index f76049e39a..4847deb56f 100644 --- a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/home/component/TimeLetterSectionScreenshotTest.kt +++ b/feature/home/presentation/src/screenshotTest/kotlin/com/afternote/feature/home/presentation/receiver/component/TimeLetterSectionScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home.component +package com.afternote.feature.home.presentation.receiver.component import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable diff --git a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenErrorScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenErrorScreenshot_748aa731_0.png index 9c9d775198..57eafb964a 100644 Binary files a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenErrorScreenshot_748aa731_0.png and b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenErrorScreenshot_748aa731_0.png differ diff --git a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png index 241e6fcb9e..abd1a5d9cb 100644 Binary files a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png and b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png differ diff --git a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenSuccessScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenSuccessScreenshot_748aa731_0.png index 35b2ae2df5..6237eedb2c 100644 Binary files a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenSuccessScreenshot_748aa731_0.png and b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenSuccessScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenLoadingScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenLoadingScreenshot_748aa731_0.png similarity index 100% rename from feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenLoadingScreenshot_748aa731_0.png rename to feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenLoadingScreenshot_748aa731_0.png diff --git a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenPartialFailureScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenPartialFailureScreenshot_748aa731_0.png new file mode 100644 index 0000000000..4cdd770e7c Binary files /dev/null and b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenPartialFailureScreenshot_748aa731_0.png differ diff --git a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenSuccessScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenSuccessScreenshot_748aa731_0.png new file mode 100644 index 0000000000..294f336f7d Binary files /dev/null and b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenSuccessScreenshot_748aa731_0.png differ diff --git a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/AfternoteSectionScreenshotTestKt/afternoteSectionScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/AfternoteSectionScreenshotTestKt/afternoteSectionScreenshot_748aa731_0.png new file mode 100644 index 0000000000..2f2c18bcec Binary files /dev/null and b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/AfternoteSectionScreenshotTestKt/afternoteSectionScreenshot_748aa731_0.png differ diff --git a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/MindRecordSectionScreenshotTestKt/mindRecordSectionScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/MindRecordSectionScreenshotTestKt/mindRecordSectionScreenshot_748aa731_0.png new file mode 100644 index 0000000000..db79d32dc5 Binary files /dev/null and b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/MindRecordSectionScreenshotTestKt/mindRecordSectionScreenshot_748aa731_0.png differ diff --git a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/MindRecordStatCardScreenshotTestKt/mindRecordStatCardScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/MindRecordStatCardScreenshotTestKt/mindRecordStatCardScreenshot_748aa731_0.png new file mode 100644 index 0000000000..25b81dfbde Binary files /dev/null and b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/MindRecordStatCardScreenshotTestKt/mindRecordStatCardScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/SectionGoButtonScreenshotTestKt/sectionGoButtonScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/SectionGoButtonScreenshotTestKt/sectionGoButtonScreenshot_748aa731_0.png similarity index 100% rename from feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/SectionGoButtonScreenshotTestKt/sectionGoButtonScreenshot_748aa731_0.png rename to feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/SectionGoButtonScreenshotTestKt/sectionGoButtonScreenshot_748aa731_0.png diff --git a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/SenderMessageHeroCardScreenshotTestKt/senderMessageHeroCardScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/SenderMessageHeroCardScreenshotTestKt/senderMessageHeroCardScreenshot_748aa731_0.png new file mode 100644 index 0000000000..aa0614d30d Binary files /dev/null and b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/SenderMessageHeroCardScreenshotTestKt/senderMessageHeroCardScreenshot_748aa731_0.png differ diff --git a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/TimeLetterSectionScreenshotTestKt/timeLetterSectionScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/TimeLetterSectionScreenshotTestKt/timeLetterSectionScreenshot_748aa731_0.png new file mode 100644 index 0000000000..3e0c00c0d7 Binary files /dev/null and b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/receiver/component/TimeLetterSectionScreenshotTestKt/timeLetterSectionScreenshot_748aa731_0.png differ diff --git a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt index 769ce04f22..f7169a19a0 100644 --- a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt @@ -249,7 +249,10 @@ private class Fixture { HomeTabViewModel( getHomeSummary = GetHomeSummaryUseCase( - userRepository = server.userRepository, + // 같은 페이크가 두 좁은 계약을 다 구현한다 — UserRepository 가 둘을 + // 상속하므로, 이 테스트가 쥔 완료 시점 제어가 그대로 유지된다 (#1742). + myProfileRepository = server.userRepository, + userReceiverRepository = server.userRepository, dailyQuestionRepository = server.dailyQuestionRepository, // 주간 기록 수는 이 테스트의 관심사가 아니다 — 실패로 고정해 보조 호출 // 실패가 홈 전체를 깨뜨리지 않는다는 계약도 함께 태운다 (#562). @@ -379,7 +382,6 @@ private val TEST_RECEIVER = receiverId = 1L, name = "수신자", relation = "가족", - authCode = "auth-code", ) /** diff --git a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeWeeklyCountBudgetTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeWeeklyCountBudgetTest.kt index 09585c6389..9e0a54bbc6 100644 --- a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeWeeklyCountBudgetTest.kt +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeWeeklyCountBudgetTest.kt @@ -1,6 +1,7 @@ package com.afternote.feature.home.presentation -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.model.user.Receiver import com.afternote.core.model.user.User import com.afternote.feature.home.presentation.usecase.GetHomeSummaryUseCase @@ -58,7 +59,8 @@ class HomeWeeklyCountBudgetTest { private fun useCase(weeklyDelayMillis: Long): GetHomeSummaryUseCase = GetHomeSummaryUseCase( - userRepository = StubUserRepository, + myProfileRepository = StubMyProfileRepository, + userReceiverRepository = StubUserReceiverRepository, dailyQuestionRepository = StubDailyQuestionRepository, getWeeklyRecordCount = GetWeeklyRecordCountUseCase(SlowWeeklyReportRepository(weeklyDelayMillis)), ) @@ -83,18 +85,29 @@ private class SlowWeeklyReportRepository( } } -/** UserRepository 는 표면이 넓다 — 이 시나리오가 타는 호출만 답한다. */ -private val StubUserRepository: UserRepository = +// 두 계약 다 이 시나리오가 타는 호출 하나씩만 답한다. 프록시가 무는 표면이 곧 계약 크기라, +// 다른 멤버가 호출되면 `error` 로 떨어진다 (#1741·#1742). +private val StubMyProfileRepository: MyProfileRepository = Proxy.newProxyInstance( - UserRepository::class.java.classLoader, - arrayOf(UserRepository::class.java), + MyProfileRepository::class.java.classLoader, + arrayOf(MyProfileRepository::class.java), ) { _, method, _ -> when (method.name) { "getMyProfile" -> User(name = "효기", email = "user@example.com", phone = null, profileImageUrl = null) + else -> error("Unexpected call: ${method.name}") + } + } as MyProfileRepository + +private val StubUserReceiverRepository: UserReceiverRepository = + Proxy.newProxyInstance( + UserReceiverRepository::class.java.classLoader, + arrayOf(UserReceiverRepository::class.java), + ) { _, method, _ -> + when (method.name) { "getReceivers" -> emptyList() else -> error("Unexpected call: ${method.name}") } - } as UserRepository + } as UserReceiverRepository private object StubDailyQuestionRepository : DailyQuestionRepository { override suspend fun getList( diff --git a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeDownloadSnackbarTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeDownloadSnackbarTest.kt new file mode 100644 index 0000000000..a42c1fc3e8 --- /dev/null +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeDownloadSnackbarTest.kt @@ -0,0 +1,149 @@ +package com.afternote.feature.home.presentation.receiver + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.home.presentation.R +import com.afternote.feature.home.presentation.receiver.model.FailedWithRetry +import com.afternote.feature.home.presentation.receiver.model.ReceiverDownloadErrorPopup +import com.afternote.feature.home.presentation.receiver.model.ReceiverDownloadState +import com.afternote.feature.home.presentation.receiver.model.ReceiverHomeUiState +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import com.afternote.core.ui.R as CoreUiR + +/** 이 테스트의 관심 밖인 외부 라우팅을 채우는 no-op 묶음. */ +private val noopActions = + ReceiverHomeActions( + onNavigateToMindRecord = {}, + onNavigateToTimeLetter = {}, + onNavigateToAfternote = {}, + ) + +/** + * 수신자 홈 내려받기 실패 안내가 **어디로 나가는지** (#1391 → #446 · #1737). + * + * 종전 Toast 는 Compose semantics 에 잡히지 않아 어떤 테스트로도 단언할 수 없었고(#1391 이 + * Snackbar 로 옮겼다), 지금은 재시도로 풀릴 수 있는 실패가 공통 오류 팝업으로 나간다. + * **둘의 갈림이 이 파일의 계약이다** — 재시도해도 같은 실패(미구현 내보내기)만 Snackbar 로 남는다. + * + * 문구는 리소스 원문으로 대조한다 (모듈 테스트 관례). + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class ReceiverHomeDownloadSnackbarTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `내려받기 서버 실패는 서버 오류 팝업으로 안내한다`() { + setHomeContent(FailedWithRetry(ReceiverDownloadErrorPopup.SERVER)) + + composeRule.onNodeWithText(string(CoreUiR.string.core_ui_server_error_title)).assertIsDisplayed() + } + + @Test + fun `연결 실패는 네트워크 오류 팝업으로 안내한다`() { + setHomeContent(FailedWithRetry(ReceiverDownloadErrorPopup.NETWORK)) + + composeRule.onNodeWithText(string(CoreUiR.string.core_ui_network_error_title)).assertIsDisplayed() + } + + /** + * 저장 실패는 시안 4종에 대응 항목이 없다. 업로드 팝업을 돌려쓰면 「파일 업로드에 + * 실패했습니다」 가 뜨는데 이 경로는 내려받은 파일을 저장하는 중이라 사실과 다르다. + */ + @Test + fun `파일 저장 실패는 저장 실패 문구로 안내한다`() { + setHomeContent(FailedWithRetry(ReceiverDownloadErrorPopup.SAVE)) + + composeRule.onNodeWithText(string(R.string.home_receiver_download_save_error_title)).assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.home_receiver_download_save_error_description)).assertIsDisplayed() + assertEquals( + "저장 실패에 업로드 팝업 문구가 뜬다", + 0, + composeRule.onAllNodesWithText(string(CoreUiR.string.core_ui_upload_error_title)).fetchSemanticsNodes().size, + ) + } + + @Test + fun `다시 시도하기는 재시도 이벤트를 올린다`() { + val events = mutableListOf() + setHomeContent( + download = FailedWithRetry(ReceiverDownloadErrorPopup.SERVER), + onEvent = { events += it }, + ) + + composeRule.onNodeWithText(string(CoreUiR.string.core_ui_server_error_retry)).performClick() + + assertEquals(listOf(ReceiverHomeEvent.RetryDownload), events) + } + + /** + * 재시도 수단이 없는 스낵바 경로가 재시도를 안내하면 안 된다 — 팝업 전용 문구와 리소스를 나눈 이유다. + */ + @Test + fun `내보내기 미지원 저장 실패 스낵바는 재시도를 안내하지 않는다`() { + setHomeContent(ReceiverDownloadState.Failed(R.string.home_receiver_download_all_save_failed)) + + composeRule.onNodeWithText(string(R.string.home_receiver_download_all_save_failed)).assertIsDisplayed() + assertEquals( + "재시도 수단이 없는 스낵바가 재시도를 안내한다", + 0, + composeRule + .onAllNodesWithText(string(R.string.home_receiver_download_save_error_description)) + .fetchSemanticsNodes() + .size, + ) + } + + /** 재시도해도 같은 실패(#1726)는 팝업이 아니라 종전 안내로 남는다. */ + @Test + fun `재시도로 풀리지 않는 실패는 스낵바로 표출된다`() { + setHomeContent(ReceiverDownloadState.Failed(R.string.home_receiver_download_all_failed)) + + composeRule.onNodeWithText("모든 기록 내려받기에 실패했습니다.").assertIsDisplayed() + assertEquals( + "재시도가 답이 아닌 실패에 재시도 팝업이 떴다", + 0, + composeRule.onAllNodesWithText(string(CoreUiR.string.core_ui_server_error_retry)).fetchSemanticsNodes().size, + ) + } + + private fun string(resId: Int): String = ApplicationProvider.getApplicationContext().getString(resId) + + private fun setHomeContent( + download: ReceiverDownloadState, + onEvent: (ReceiverHomeEvent) -> Unit = {}, + ) { + composeRule.setContent { + AfternoteTheme { + ReceiverHomeScreen( + uiState = + ReceiverHomeUiState.Success( + senderName = "박서연", + senderMessage = null, + mindRecord = null, + timeLetterTotalCount = null, + afternoteTotalCount = null, + afternoteIcons = emptyList(), + download = download, + ), + onEvent = onEvent, + actions = noopActions, + ) + } + } + } +} diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeHeaderTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeHeaderTest.kt similarity index 97% rename from feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeHeaderTest.kt rename to feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeHeaderTest.kt index b6dcf38554..eaf8715723 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeHeaderTest.kt +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeHeaderTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home +package com.afternote.feature.home.presentation.receiver import androidx.activity.ComponentActivity import androidx.compose.ui.test.assertCountEquals @@ -11,7 +11,7 @@ import androidx.compose.ui.test.onNodeWithTag import com.afternote.core.ui.R import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.core.ui.topbar.PROFILE_ICON_TEST_TAG -import com.afternote.feature.receiver.presentation.home.model.ReceiverHomeUiState +import com.afternote.feature.home.presentation.receiver.model.ReceiverHomeUiState import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith diff --git a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomePartialFailureTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomePartialFailureTest.kt new file mode 100644 index 0000000000..5a1157af7f --- /dev/null +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomePartialFailureTest.kt @@ -0,0 +1,239 @@ +package com.afternote.feature.home.presentation.receiver + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.home.presentation.R +import com.afternote.feature.home.presentation.receiver.model.ReceiverHomeUiState +import com.afternote.feature.home.presentation.usecase.GetReceiverHomeSummaryUseCase +import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords +import com.afternote.feature.mindrecord.domain.testing.FakeMindRecordReceiverRepository +import com.afternote.feature.receiver.domain.model.AfterNoteListItem +import com.afternote.feature.receiver.domain.model.AfterNotesListResult +import com.afternote.feature.receiver.domain.model.SenderMessageInfo +import com.afternote.feature.receiver.domain.testing.FakeReceiverRepository +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetterList +import com.afternote.feature.timeletter.domain.testing.FakeReceiverTimeLetterRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** 이 테스트의 관심 밖인 외부 라우팅을 채우는 no-op 묶음. */ +private val noopHomeActions = + ReceiverHomeActions( + onNavigateToMindRecord = {}, + onNavigateToTimeLetter = {}, + onNavigateToAfternote = {}, + ) + +/** + * 수신자 홈의 **전체 실패 → 재시도 → 부분 성공** 을 화면까지 태워 확인한다 (#1689). + * + * 네 출처의 완료 시점을 테스트가 쥔다 — 그래야 「전부 실패해야 전체 실패」 와 「하나라도 성공하면 + * 그 출처로 그린다」 가 같은 실행 안에서 갈린다. 종전에는 app 계측 테스트였는데, 집계 계약이 + * 이 모듈 안으로 닫히면서(#1689) 조립도 계약 옆으로 왔다 — app 은 이 화면을 조립할 이유가 없다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34]) +class ReceiverHomePartialFailureTest { + @get:Rule + val composeRule = createComposeRule() + + private val context get() = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + // 네 조회가 `await()` 에 걸린 뒤 테스트가 완료 순서를 정하는 구조라, 디스패치 지연이 없는 + // 편이 판정이 선명하다 — 완료가 곧 재개다. + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `전체 실패 뒤 재시도한 부분 성공은 살아남은 출처를 그리고 두 단계를 모두 기록한다`() { + val afterNoteResults = ArrayDeque>>() + val senderMessageResults = ArrayDeque>>() + val repository = + FakeReceiverRepository.strict().apply { + onGetReceivedAfterNotes = { afterNoteResults.removeFirst().await() } + onLoadSenderMessage = { senderMessageResults.removeFirst().await() } + } + val mindRecordResults = ArrayDeque>>() + val mindRecordRepository = + FakeMindRecordReceiverRepository(onGetAll = { mindRecordResults.removeFirst().await() }) + val timeLetterResults = ArrayDeque>>() + val timeLetterRepository = + FakeReceiverTimeLetterRepository.strict().apply { + onGetReceivedTimeLetters = { timeLetterResults.removeFirst().await().getOrThrow() } + } + + fun homeCallCounts(): List = + listOf( + repository.getReceivedAfterNotesCalls, + mindRecordRepository.getAllCalls, + timeLetterRepository.getReceivedTimeLettersCalls, + repository.loadSenderMessageCalls, + ) + + val allFailureAttempt = enqueueAttempt(afterNoteResults, mindRecordResults, timeLetterResults, senderMessageResults) + val partialAttempt = enqueueAttempt(afterNoteResults, mindRecordResults, timeLetterResults, senderMessageResults) + val reporter = PartialFailureErrorReporter() + val viewModel = + ReceiverHomeViewModel( + getReceiverHomeSummary = + GetReceiverHomeSummaryUseCase( + receiverRepository = repository, + mindRecordReceiverRepository = mindRecordRepository, + receiverTimeLetterRepository = timeLetterRepository, + ), + receiverRepository = repository, + errorReporter = reporter, + ) + + composeRule.setContent { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + AfternoteTheme { + ReceiverHomeScreen(uiState = uiState, onEvent = viewModel::onEvent, actions = noopHomeActions) + } + } + + assertEquals(listOf(1, 1, 1, 1), homeCallCounts()) + assertSame(ReceiverHomeUiState.Loading, viewModel.uiState.value) + + val offline = IllegalStateException("offline") + allFailureAttempt.complete( + afterNotes = Result.failure(offline), + mindRecords = Result.failure(offline), + timeLetters = Result.failure(offline), + senderMessage = Result.failure(offline), + ) + composeRule.onNodeWithText(context.getString(R.string.home_receiver_error_message)).assertIsDisplayed() + composeRule.onNodeWithText(context.getString(R.string.home_receiver_retry)).performClick() + + assertEquals(listOf(2, 2, 2, 2), homeCallCounts()) + assertSame(ReceiverHomeUiState.Loading, viewModel.uiState.value) + + partialAttempt.complete( + afterNotes = + Result.success( + AfterNotesListResult( + items = + listOf( + AfterNoteListItem(1L, "Google Drive", AfternoteType.GALLERY_AND_FILES, null), + AfterNoteListItem(2L, "추억 노트", AfternoteType.MEMORIAL, null), + ), + totalCount = 2, + ), + ), + mindRecords = Result.failure(IllegalStateException("mind records unavailable")), + timeLetters = Result.success(ReceivedTimeLetterList(timeLetters = emptyList(), totalCount = 8)), + senderMessage = + Result.success( + SenderMessageInfo(senderName = "이발신", message = "언제나 응원할게", createdAt = "2026.08.22"), + ), + ) + + composeRule + .onNodeWithText(context.getString(R.string.home_receiver_sender_record_title, "이발신")) + .assertIsDisplayed() + composeRule.onNodeWithText("언제나 응원할게").assertIsDisplayed() + composeRule + .onAllNodes(hasText(context.getString(R.string.home_receiver_section_count_unavailable))) + .apply { + assertCountEquals(2) + this[0].performScrollTo().assertIsDisplayed() + } + composeRule.onNodeWithText("8개 라이프 이벤트 레터가 있습니다.").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("2개의 애프터노트가 있습니다.").performScrollTo().assertIsDisplayed() + + assertEquals(2, reporter.failures.size) + assertEquals("receiver_home_load", reporter.failures[0].attributes["receiver_stage"]) + assertEquals("receiver_home_partial_load", reporter.failures[1].attributes["receiver_stage"]) + assertEquals("mind_records", reporter.failures[1].attributes["receiver_failed_sources"]) + assertEquals(listOf(2, 2, 2, 2), homeCallCounts()) + } +} + +/** 홈 한 번의 로드가 물리는 네 대기열에 결과 게이트를 한 벌씩 건다. */ +private fun enqueueAttempt( + afterNoteResults: ArrayDeque>>, + mindRecordResults: ArrayDeque>>, + timeLetterResults: ArrayDeque>>, + senderMessageResults: ArrayDeque>>, +): PendingAttempt { + val attempt = + PendingAttempt( + afterNotes = CompletableDeferred(), + mindRecords = CompletableDeferred(), + timeLetters = CompletableDeferred(), + senderMessage = CompletableDeferred(), + ) + afterNoteResults.addLast(attempt.afterNotes) + mindRecordResults.addLast(attempt.mindRecords) + timeLetterResults.addLast(attempt.timeLetters) + senderMessageResults.addLast(attempt.senderMessage) + return attempt +} + +private class PendingAttempt( + val afterNotes: CompletableDeferred>, + val mindRecords: CompletableDeferred>, + val timeLetters: CompletableDeferred>, + val senderMessage: CompletableDeferred>, +) { + fun complete( + afterNotes: Result, + mindRecords: Result, + timeLetters: Result, + senderMessage: Result, + ) { + this.afterNotes.complete(afterNotes) + this.mindRecords.complete(mindRecords) + this.timeLetters.complete(timeLetters) + this.senderMessage.complete(senderMessage) + } +} + +private class PartialFailureErrorReporter : ErrorReporter { + data class Failure( + val throwable: Throwable, + val attributes: Map, + ) + + val failures = mutableListOf() + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + failures += Failure(throwable, attributes) + } +} diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModelTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeViewModelTest.kt similarity index 70% rename from feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModelTest.kt rename to feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeViewModelTest.kt index d272fd6c0d..acc5541390 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModelTest.kt +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/ReceiverHomeViewModelTest.kt @@ -1,14 +1,19 @@ -package com.afternote.feature.receiver.presentation.home +package com.afternote.feature.home.presentation.receiver import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.feature.home.presentation.R +import com.afternote.feature.home.presentation.receiver.model.FailedWithRetry +import com.afternote.feature.home.presentation.receiver.model.ReceiverDownloadErrorPopup +import com.afternote.feature.home.presentation.receiver.model.ReceiverDownloadState +import com.afternote.feature.home.presentation.receiver.model.ReceiverHomeUiState +import com.afternote.feature.home.presentation.usecase.GetReceiverHomeSummaryUseCase import com.afternote.feature.mindrecord.domain.model.MindRecordType import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords import com.afternote.feature.mindrecord.domain.testing.FakeMindRecordReceiverRepository +import com.afternote.feature.receiver.domain.error.ReceiverFailure import com.afternote.feature.receiver.domain.model.AfterNotesListResult import com.afternote.feature.receiver.domain.model.SenderMessageInfo import com.afternote.feature.receiver.domain.testing.FakeReceiverRepository -import com.afternote.feature.receiver.presentation.home.model.ReceiverDownloadState -import com.afternote.feature.receiver.presentation.home.model.ReceiverHomeUiState import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetterList import com.afternote.feature.timeletter.domain.testing.FakeReceiverTimeLetterRepository import kotlinx.coroutines.Dispatchers @@ -27,6 +32,7 @@ import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import java.io.IOException import com.afternote.feature.mindrecord.domain.model.MindRecordSummary as DomainMindRecordSummary /** 수신자 홈이 각 기능의 실제 domain repository 결과를 구분해 합치는 계약 회귀 가드 (#610). */ @@ -141,6 +147,154 @@ class ReceiverHomeViewModelTest { assertNull(reported.attributes["receiver_failed_sources"]) } + @Test + fun `내려받기 확인 - 미지원 실패 상태로 귀결되고 파일 저장과 오류 보고를 하지 않음`() = + runTest(dispatcher) { + val fixture = Fixture() + fixture.timeLetter.onGetReceivedTimeLetters = { timeLetters(totalCount = 0) } + fixture.receiver.onDownloadReceivedExport = { + Result.failure(ReceiverFailure.ExportNotSupported()) + } + val viewModel = fixture.viewModel() + advanceUntilIdle() + + viewModel.onEvent(ReceiverHomeEvent.RequestDownload) + assertEquals( + ReceiverDownloadState.Confirming, + (viewModel.uiState.value as ReceiverHomeUiState.Success).download, + ) + + viewModel.onEvent(ReceiverHomeEvent.ConfirmDownload) + advanceUntilIdle() + + val state = viewModel.uiState.value as ReceiverHomeUiState.Success + assertEquals( + ReceiverDownloadState.Failed(R.string.home_receiver_download_all_failed), + state.download, + ) + assertEquals(1, fixture.receiver.downloadCalls) + assertTrue(fixture.receiver.savedBundles.isEmpty()) + assertTrue(fixture.reporter.failures.isEmpty()) + } + + @Test + fun `내려받기 확인 - 일반 실패는 내려받기 단계 오류로 보고`() = + runTest(dispatcher) { + val fixture = Fixture() + fixture.timeLetter.onGetReceivedTimeLetters = { timeLetters(totalCount = 0) } + fixture.receiver.onDownloadReceivedExport = { + Result.failure(IllegalStateException("내려받기 실패")) + } + val viewModel = fixture.viewModel() + advanceUntilIdle() + + viewModel.onEvent(ReceiverHomeEvent.RequestDownload) + viewModel.onEvent(ReceiverHomeEvent.ConfirmDownload) + advanceUntilIdle() + + val state = viewModel.uiState.value as ReceiverHomeUiState.Success + // 번역되지 않은 실패는 사유를 모르므로 서버 갈래로 안내한다 (#1737). + assertEquals( + FailedWithRetry(ReceiverDownloadErrorPopup.SERVER), + state.download, + ) + assertTrue(fixture.receiver.savedBundles.isEmpty()) + val reported = fixture.reporter.failures.single() + assertEquals("received_export_download", reported.attributes["receiver_stage"]) + } + + @Test + fun `내려받기 확인 - 연결 실패는 네트워크 팝업 갈래가 된다`() = + runTest(dispatcher) { + val fixture = Fixture() + fixture.timeLetter.onGetReceivedTimeLetters = { timeLetters(totalCount = 0) } + fixture.receiver.onDownloadReceivedExport = { + Result.failure(ReceiverFailure.NetworkUnavailable(IOException("offline"))) + } + val viewModel = fixture.viewModel() + advanceUntilIdle() + + viewModel.onEvent(ReceiverHomeEvent.RequestDownload) + viewModel.onEvent(ReceiverHomeEvent.ConfirmDownload) + advanceUntilIdle() + + val state = viewModel.uiState.value as ReceiverHomeUiState.Success + assertEquals( + FailedWithRetry(ReceiverDownloadErrorPopup.NETWORK), + state.download, + ) + } + + /** + * 저장 실패의 재시도는 **저장만** 다시 건다. 처음부터 다시 걸면 이미 성공한 조회를 한 번 + * 더 태우고, 「다시 시도하기」 가 이름과 달리 처음부터 하기가 된다 (#1737). + */ + @Test + fun `내려받기 확인 - 저장 실패의 재시도는 조회를 다시 하지 않는다`() = + runTest(dispatcher) { + val fixture = Fixture() + fixture.timeLetter.onGetReceivedTimeLetters = { timeLetters(totalCount = 0) } + var saveFails = true + fixture.receiver.onDownloadReceivedExport = { Result.success(fixture.receiver.exportBundle) } + fixture.receiver.onSaveReceivedExportToFile = { + if (saveFails) Result.failure(IllegalStateException("저장 실패")) else Result.success(Unit) + } + val viewModel = fixture.viewModel() + advanceUntilIdle() + + viewModel.onEvent(ReceiverHomeEvent.RequestDownload) + viewModel.onEvent(ReceiverHomeEvent.ConfirmDownload) + advanceUntilIdle() + + assertEquals( + FailedWithRetry(ReceiverDownloadErrorPopup.SAVE), + (viewModel.uiState.value as ReceiverHomeUiState.Success).download, + ) + assertEquals(1, fixture.receiver.downloadCalls) + + saveFails = false + viewModel.onEvent(ReceiverHomeEvent.RetryDownload) + advanceUntilIdle() + + assertEquals( + ReceiverDownloadState.Done, + (viewModel.uiState.value as ReceiverHomeUiState.Success).download, + ) + assertEquals("재시도가 조회를 다시 걸었다", 1, fixture.receiver.downloadCalls) + assertEquals(2, fixture.receiver.savedBundles.size) + } + + @Test + fun `내려받기 확인 - 조회 실패의 재시도는 조회부터 다시 건다`() = + runTest(dispatcher) { + val fixture = Fixture() + fixture.timeLetter.onGetReceivedTimeLetters = { timeLetters(totalCount = 0) } + var downloadFails = true + fixture.receiver.onDownloadReceivedExport = { + if (downloadFails) { + Result.failure(IllegalStateException("내려받기 실패")) + } else { + Result.success(fixture.receiver.exportBundle) + } + } + fixture.receiver.onSaveReceivedExportToFile = { Result.success(Unit) } + val viewModel = fixture.viewModel() + advanceUntilIdle() + + viewModel.onEvent(ReceiverHomeEvent.RequestDownload) + viewModel.onEvent(ReceiverHomeEvent.ConfirmDownload) + advanceUntilIdle() + + downloadFails = false + viewModel.onEvent(ReceiverHomeEvent.RetryDownload) + advanceUntilIdle() + + assertEquals( + ReceiverDownloadState.Done, + (viewModel.uiState.value as ReceiverHomeUiState.Success).download, + ) + assertEquals(2, fixture.receiver.downloadCalls) + } // region 재진입 갱신 (#701) @Test @@ -307,9 +461,13 @@ private class Fixture { fun viewModel(): ReceiverHomeViewModel = ReceiverHomeViewModel( + getReceiverHomeSummary = + GetReceiverHomeSummaryUseCase( + receiverRepository = receiver, + mindRecordReceiverRepository = mindRecord, + receiverTimeLetterRepository = timeLetter, + ), receiverRepository = receiver, - mindRecordReceiverRepository = mindRecord, - receiverTimeLetterRepository = timeLetter, errorReporter = reporter, ) } diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/component/ReceiverHomeCountFallbackTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/component/ReceiverHomeCountFallbackTest.kt similarity index 95% rename from feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/component/ReceiverHomeCountFallbackTest.kt rename to feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/component/ReceiverHomeCountFallbackTest.kt index 963eac5b38..d8d7064339 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/component/ReceiverHomeCountFallbackTest.kt +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/component/ReceiverHomeCountFallbackTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.receiver.presentation.home.component +package com.afternote.feature.home.presentation.receiver.component import androidx.activity.ComponentActivity import androidx.compose.ui.test.assertCountEquals @@ -7,7 +7,7 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.receiver.presentation.home.model.MindRecordSummary +import com.afternote.feature.home.presentation.receiver.model.MindRecordSummary import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith diff --git a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/component/ReceiverHomeTouchTargetTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/component/ReceiverHomeTouchTargetTest.kt new file mode 100644 index 0000000000..861b6efe69 --- /dev/null +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/component/ReceiverHomeTouchTargetTest.kt @@ -0,0 +1,49 @@ +package com.afternote.feature.home.presentation.receiver.component + +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.ui.testing.MinimumTouchTargetSize +import com.afternote.core.ui.testing.assertAccessibleClickTargets +import com.afternote.core.ui.testing.scanEnabledClickTargets +import com.afternote.core.ui.theme.AfternoteTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 수신자 홈 섹션 이동 버튼의 클릭 타깃 가드. + * + * 종전에는 `feature:receiver` 의 `ReceiverFlowTouchTargetTest` 가 서류 슬롯 카드와 한 테스트에서 + * 같은 규약을 걸었다. 수신자 홈이 `feature:home` 으로 옮겨지며(#1462) 두 컴포넌트가 다른 + * 모듈이 됐으므로 단언을 그대로 둔 채 이쪽 몫만 옮겼다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ReceiverHomeTouchTargetTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `section go button is a named button target that meets the minimum size`() { + var goClicks = 0 + composeRule.setContent { + AfternoteTheme { + SectionGoButton(text = "Go", onClick = { goClicks++ }) + } + } + + composeRule.assertAccessibleClickTargets() + val goTarget = composeRule.scanEnabledClickTargets().single { it.name == "Go" } + assertEquals(Role.Button, goTarget.role) + assertFalse(goTarget.isSmallerThan(MinimumTouchTargetSize)) + + composeRule.onNodeWithText("Go").performClick() + assertEquals(1, goClicks) + } +} diff --git a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/component/SenderMessageHeroCardLayoutTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/component/SenderMessageHeroCardLayoutTest.kt new file mode 100644 index 0000000000..cc9962c4d6 --- /dev/null +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/receiver/component/SenderMessageHeroCardLayoutTest.kt @@ -0,0 +1,115 @@ +package com.afternote.feature.home.presentation.receiver.component + +import android.content.Context +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.home.presentation.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * 「한 마디」 카드의 제목·작성일 배치 (#2032). + * + * 종전에는 제목과 작성일이 가중치 없이 한 `Row` 에 나란히 놓여, **긴 제목이 가용 폭을 다 쓰면 + * 뒤에 측정되는 작성일이 0dp 를 받았다.** 큰 글씨(배율 2.0)와 8글자 이름이 겹치는 조합에서 + * `2026.09.13` 이 열 줄로 한 글자씩 쪼개졌고, 카드 높이가 400dp 대까지 늘어 다음 섹션이 화면 + * 밖으로 밀렸다. 날짜 노드의 경계도 0 이라 접근성에서도 짚이지 않았다. + * + * 여기서 보는 것은 **폭 배분**이다 — 글자 크기·색이 아니라 「작성일이 자기 폭을 먼저 받고 한 줄로 + * 남는가」와 「제목도 폭을 잃지 않는가」다. 두 축을 함께 봐야 한쪽을 고치며 다른 쪽을 0 으로 + * 만드는 회귀가 잡힌다. + * + * 카드 폭 320dp 는 재현 조건 그대로다 — 360dp 화면에서 홈 좌우 여백 20dp 씩을 뺀 값이다. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class SenderMessageHeroCardLayoutTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `큰 글씨와 긴 이름에서도 작성일이 한 줄로 폭을 받는다`() { + setCard(senderName = LONG_NAME, fontScale = 2f) + + val date = textLayoutOf(DATE) + assertEquals("작성일이 한 글자씩 쪼개졌다", 1, date.lineCount) + assertTrue("작성일이 0 너비를 받았다", date.size.width > 0) + } + + @Test + fun `큰 글씨와 긴 이름에서도 제목이 폭을 잃지 않는다`() { + setCard(senderName = LONG_NAME, fontScale = 2f) + + val title = textLayoutOf(titleText(LONG_NAME)) + assertTrue("제목이 0 너비를 받았다", title.size.width > 0) + // 한 글자씩 세로로 쪼개지지 않았는지 — 글자 수보다 줄이 적어야 한다. + assertTrue("제목이 한 글자씩 쪼개졌다", title.lineCount < titleText(LONG_NAME).length) + } + + /** 기본 배율·짧은 이름의 종전 표현을 지킨다 — 제목과 작성일이 한 줄씩이다. */ + @Test + fun `기본 배율의 짧은 이름은 제목과 작성일이 각각 한 줄이다`() { + setCard(senderName = SHORT_NAME, fontScale = 1f) + + assertEquals(1, textLayoutOf(titleText(SHORT_NAME)).lineCount) + assertEquals(1, textLayoutOf(DATE).lineCount) + } + + private fun setCard( + senderName: String, + fontScale: Float, + ) { + composeRule.setContent { + val base = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(base.density, fontScale)) { + AfternoteTheme { + SenderMessageHeroCard( + senderName = senderName, + date = DATE, + message = MESSAGE, + modifier = Modifier.width(CARD_WIDTH), + ) + } + } + } + } + + private fun textLayoutOf(text: String): TextLayoutResult { + val layouts = mutableListOf() + composeRule + .onNodeWithText(text) + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } + return layouts.single() + } + + private fun titleText(senderName: String): String = + ApplicationProvider + .getApplicationContext() + .getString(R.string.home_receiver_hero_title, senderName) + + private companion object { + val CARD_WIDTH = 320.dp + const val LONG_NAME = "김가나다라마바사" + const val SHORT_NAME = "이준혁" + const val DATE = "2026.09.13" + const val MESSAGE = "잘 지내길 바랄게." + } +} diff --git a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/usecase/GetReceiverHomeSummaryUseCaseTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/usecase/GetReceiverHomeSummaryUseCaseTest.kt new file mode 100644 index 0000000000..feec568943 --- /dev/null +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/usecase/GetReceiverHomeSummaryUseCaseTest.kt @@ -0,0 +1,246 @@ +package com.afternote.feature.home.presentation.usecase + +import com.afternote.feature.mindrecord.domain.model.MindRecordSummary +import com.afternote.feature.mindrecord.domain.model.MindRecordType +import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords +import com.afternote.feature.mindrecord.domain.testing.FakeMindRecordReceiverRepository +import com.afternote.feature.receiver.domain.model.AfterNotesListResult +import com.afternote.feature.receiver.domain.model.SenderMessageInfo +import com.afternote.feature.receiver.domain.testing.FakeReceiverRepository +import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetterList +import com.afternote.feature.timeletter.domain.testing.FakeReceiverTimeLetterRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.util.concurrent.atomic.AtomicBoolean + +/** + * 수신자 홈 4-source 집계 정책 (#1689). + * + * 종전에는 이 판정이 `ReceiverHomeViewModel` 안에 있어 화면 상태를 통해서만 볼 수 있었다. + * **전부 실패해야 전체 실패**이고 **무엇이 실패했는지**가 남아야 하는데, 그 둘은 UI 상태가 + * 아니라 집계의 결론이다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class GetReceiverHomeSummaryUseCaseTest { + @Test + fun `네 출처가 모두 성공하면 값이 그대로 실린다`() = + runTest { + val result = useCase().invoke() + + val loaded = result as ReceiverHomeSummaryResult.Loaded + assertNull("성공만 있는데 실패가 남았다", loaded.failure) + assertEquals(6, loaded.summary.afternotes?.totalCount) + assertEquals(4, loaded.summary.timeLetterTotalCount) + assertEquals( + 2, + loaded.summary.mindRecords + ?.dailyQuestions + ?.size, + ) + assertEquals("박서연", loaded.summary.senderMessage?.senderName) + } + + @Test + fun `한 출처만 실패하면 나머지는 그대로 살고 실패한 이름이 남는다`() = + runTest { + val result = + useCase( + receiver = + receiverRepository().apply { + onGetReceivedAfterNotes = { Result.failure(IOException("애프터노트 실패")) } + }, + ).invoke() + + val loaded = result as ReceiverHomeSummaryResult.Loaded + assertEquals(listOf("afternotes"), loaded.failure?.failedSources) + assertEquals("애프터노트 실패", loaded.failure?.firstCause?.message) + assertNull("실패한 출처가 값을 남겼다", loaded.summary.afternotes) + // 성공한 셋은 그대로 살아 있다 — 하나의 실패로 화면을 비우지 않는다. + assertEquals(4, loaded.summary.timeLetterTotalCount) + assertEquals( + 2, + loaded.summary.mindRecords + ?.dailyQuestions + ?.size, + ) + assertEquals("박서연", loaded.summary.senderMessage?.senderName) + } + + @Test + fun `출처마다 실패해도 나머지가 살아남는다`() = + runTest { + val failures = + mapOf( + "afternotes" to { + r: FakeReceiverRepository, + m: FakeMindRecordReceiverRepository, + t: FakeReceiverTimeLetterRepository, + -> + r.onGetReceivedAfterNotes = { Result.failure(IOException("실패")) } + }, + "mind_records" to { + _: FakeReceiverRepository, + m: FakeMindRecordReceiverRepository, + _: FakeReceiverTimeLetterRepository, + -> + m.onGetAll = { Result.failure(IOException("실패")) } + }, + "time_letters" to { + _: FakeReceiverRepository, + _: FakeMindRecordReceiverRepository, + t: FakeReceiverTimeLetterRepository, + -> + t.onGetReceivedTimeLetters = { throw IOException("실패") } + }, + "sender_message" to { + r: FakeReceiverRepository, + _: FakeMindRecordReceiverRepository, + _: FakeReceiverTimeLetterRepository, + -> + r.onLoadSenderMessage = { Result.failure(IOException("실패")) } + }, + ) + + failures.forEach { (source, breakOne) -> + val receiver = receiverRepository() + val mindRecord = mindRecordRepository() + val timeLetter = timeLetterRepository() + breakOne(receiver, mindRecord, timeLetter) + + val loaded = useCase(receiver, mindRecord, timeLetter).invoke() as ReceiverHomeSummaryResult.Loaded + + assertEquals(source, listOf(source), loaded.failure?.failedSources) + } + } + + @Test + fun `둘이 실패해도 전체 실패가 아니다`() = + runTest { + val result = + useCase( + receiver = + receiverRepository().apply { + onGetReceivedAfterNotes = { Result.failure(IOException("애프터노트 실패")) } + onLoadSenderMessage = { Result.failure(IOException("메시지 실패")) } + }, + ).invoke() + + val loaded = result as ReceiverHomeSummaryResult.Loaded + assertEquals(listOf("afternotes", "sender_message"), loaded.failure?.failedSources) + assertEquals(4, loaded.summary.timeLetterTotalCount) + } + + @Test + fun `네 출처가 모두 실패해야 전체 실패다`() = + runTest { + val result = + useCase( + receiver = + receiverRepository().apply { + onGetReceivedAfterNotes = { Result.failure(IOException("애프터노트 실패")) } + onLoadSenderMessage = { Result.failure(IOException("메시지 실패")) } + }, + mindRecord = mindRecordRepository().apply { onGetAll = { Result.failure(IOException("기록 실패")) } }, + timeLetter = + timeLetterRepository().apply { + onGetReceivedTimeLetters = { throw IOException("타임레터 실패") } + }, + ).invoke() + + val allFailed = result as ReceiverHomeSummaryResult.AllFailed + assertEquals( + listOf("afternotes", "mind_records", "time_letters", "sender_message"), + allFailed.failure.failedSources, + ) + // 첫 원인이 유실되지 않는다 — telemetry 가 예외 하나를 요구한다. + assertEquals("애프터노트 실패", allFailed.failure.firstCause.message) + } + + /** + * 취소를 실패 폴백으로 바꾸지 않는다. 타임레터 저장소만 `Result` 가 아니라 값을 던져 + * `runCatchingCancellable` 로 감싸는데, 그 자리가 `CancellationException` 까지 삼키면 + * 화면을 떠난 뒤에도 「타임레터 조회 실패」 가 남는다. + */ + @Test + fun `취소되면 실패로 접히지 않고 구조화된 동시성이 함께 끊긴다`() = + runTest { + val started = CompletableDeferred() + val timeLetterCancelled = AtomicBoolean(false) + val timeLetter = + timeLetterRepository().apply { + onGetReceivedTimeLetters = { + started.complete(Unit) + try { + awaitCancellation() + } finally { + timeLetterCancelled.set(true) + } + } + } + + val job = async { useCase(timeLetter = timeLetter).invoke() } + started.await() + job.cancel() + advanceUntilIdle() + + assertTrue("취소가 자식까지 내려가지 않았다", timeLetterCancelled.get()) + assertTrue("취소가 결과로 접혔다", job.isCancelled) + } + + private fun useCase( + receiver: FakeReceiverRepository = receiverRepository(), + mindRecord: FakeMindRecordReceiverRepository = mindRecordRepository(), + timeLetter: FakeReceiverTimeLetterRepository = timeLetterRepository(), + ) = GetReceiverHomeSummaryUseCase( + receiverRepository = receiver, + mindRecordReceiverRepository = mindRecord, + receiverTimeLetterRepository = timeLetter, + ) + + private fun receiverRepository() = + FakeReceiverRepository.strict().apply { + onGetReceivedAfterNotes = { Result.success(AfterNotesListResult(items = emptyList(), totalCount = 6)) } + onLoadSenderMessage = { + Result.success( + SenderMessageInfo(senderName = "박서연", message = "잘 지내렴", createdAt = "2026-09-01"), + ) + } + } + + private fun mindRecordRepository() = + FakeMindRecordReceiverRepository().apply { + onGetAll = { + Result.success( + ReceiverMindRecords( + dailyQuestions = List(2) { index -> mindRecordSummary(index.toLong()) }, + diaries = emptyList(), + ), + ) + } + } + + private fun mindRecordSummary(id: Long) = + MindRecordSummary( + id = id, + type = MindRecordType.DAILY_QUESTION, + title = "제목", + content = "내용", + recordDate = "2026-08-22", + isDraft = false, + createdAt = "2026.08.22 금", + ) + + private fun timeLetterRepository() = + FakeReceiverTimeLetterRepository.strict().apply { + onGetReceivedTimeLetters = { ReceivedTimeLetterList(timeLetters = emptyList(), totalCount = 4) } + } +} diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt index c58609710e..558da11498 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt @@ -24,12 +24,23 @@ data class DiaryCreateRequestDto( @SerialName("title") val title: String, @SerialName("content") val content: String, @SerialName("isDraft") val isDraft: Boolean, - @SerialName("todayMood") val todayMood: TodayMoodDto, + /** + * 오늘의 기분. **임시저장이면 생략할 수 있다** — `isDraft=true` 에서 서버가 필수 검증을 + * 걷었다 (`Afternote-BE#243` → PR #267, 2026-08-30 머지). 정식 등록(`isDraft=false`)은 + * 여전히 필수이고 누락 시 400/`1400` 이다. 그 구분은 화면이 지킨다 (#1065). + */ + @SerialName("todayMood") val todayMood: TodayMoodDto?, // imageUrl 은 계약에 없어 걷었다 — 보내도 서버가 버리고 응답에도 키가 없다. 목록 // 썸네일은 본문 HTML 의 첫 img 에서 뽑는다 (#1024, 데일리질문 #549 와 같은 규칙). // 생성 API 에서 `null` 과 빈 목록은 모두 "수신자 없음" 으로 정규화되고, 작성 UI 는 항상 // 목록을 갖고 있다. 빈 목록을 그대로 보내면 되므로 nullable 로 낮추지 않는다 (#789). @SerialName("receiverIds") val receiverIds: List, + /** + * 기록일 `yyyy-MM-dd`. 미전송이면 서버가 오늘(Asia/Seoul)로 채우고, **미래 날짜는 + * 400(code 2101)** 이다 (`Afternote-BE#244`, PR #262). 작성 화면이 항상 값을 갖고 + * 있어 nullable 로 낮추지 않는다 (#1008). + */ + @SerialName("date") val date: String, ) @Serializable @@ -37,9 +48,12 @@ data class DiaryUpdateRequestDto( @SerialName("title") val title: String, @SerialName("content") val content: String, @SerialName("isDraft") val isDraft: Boolean, - @SerialName("todayMood") val todayMood: TodayMoodDto, + /** 임시저장이면 생략 가능하다 — 생성 요청과 같은 규칙이다 (#1065). */ + @SerialName("todayMood") val todayMood: TodayMoodDto?, /** null 이면 기존 수신자 유지, 빈 목록이면 전체 해제 (서버 규칙). */ @SerialName("receiverIds") val receiverIds: List? = null, + /** 기록일 `yyyy-MM-dd`. **null 이면 기존 기록일 유지** (서버 규칙). 미래 날짜는 400(code 2101). */ + @SerialName("date") val date: String? = null, ) /** @@ -75,9 +89,17 @@ data class DiaryListItemDto( @SerialName("createdAt") val createdAt: String, // Swagger `DiaryResponse` 에 없는 필드 — 서버가 주기 시작하면 쓰이고, 아니면 계속 null. @SerialName("imageUrl") val imageUrl: String? = null, - // 저장 컬럼이 필수라 응답도 항상 채워진다. AI 가 매기는 `emotion` 과 달리 사용자가 직접 - // 고른 값이고, 한글 값이 관측된 쪽도 `emotion` 이지 이 필드가 아니다 (#591, #789). - @SerialName("todayMood") val todayMood: TodayMoodDto, + // **임시저장에는 없을 수 있다.** 종전에는 저장 컬럼이 필수라 응답도 항상 채워졌는데, + // BE#243(PR #267)이 `isDraft=true` 의 기분 필수를 걷으면서 `today_mood` 가 NULL 인 + // 임시저장이 생긴다. 필수로 두면 그런 일기가 하나라도 있는 달의 목록 전체가 + // MissingFieldException 으로 날아간다 (#1065). + // + // 기본값은 두지 않는다 — 「고른 적 없음」을 SOSO 같은 값으로 접으면 사용자가 고르지 + // 않은 기분이 주간리포트 집계와 감정 분석 입력에 들어간다. null 은 그 자체가 의미다. + // + // AI 가 매기는 `emotion` 과 달리 사용자가 직접 고른 값이고, 한글 값이 관측된 쪽도 + // `emotion` 이지 이 필드가 아니다 (#591, #789). + @SerialName("todayMood") val todayMood: TodayMoodDto?, // 기본값을 두지 않는다 — 키가 빠지면 false 로 접혀 임시저장이 목록에 샌다 (#789). @SerialName("isDraft") @JsonNames("draft") diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt index a5aaad9aa3..2c7b2764e4 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt @@ -18,7 +18,7 @@ fun DiaryListItemDto.toDomain(): Diary = content = content, date = date, createdAt = createdAt, - todayMood = todayMood.toDomain(), + todayMood = todayMood?.toDomain(), imageUrl = imageUrl, isDraft = isDraft, receiverNames = receivers.map { it.name }, @@ -50,8 +50,10 @@ fun DiaryCreatePayload.toRequest(): DiaryCreateRequestDto = title = title, content = content, isDraft = isDraft, - todayMood = todayMood.toDto(), + todayMood = todayMood?.toDto(), receiverIds = receiverIds, + // LocalDate.toString() 이 ISO-8601 `yyyy-MM-dd` 다 — 서버 계약과 같은 형식이다. + date = date.toString(), ) fun DiaryUpdatePayload.toRequest(): DiaryUpdateRequestDto = @@ -59,6 +61,7 @@ fun DiaryUpdatePayload.toRequest(): DiaryUpdateRequestDto = title = title, content = content, isDraft = isDraft, - todayMood = todayMood.toDto(), + todayMood = todayMood?.toDto(), receiverIds = receiverIds, + date = date?.toString(), ) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt index c85356d6f9..bfef7a8fee 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt @@ -42,7 +42,7 @@ private val WEEK_RECORD_TYPES_NOT_COUNTED = setOf("DEEP_THOUGHT") * 대소문자는 가리지 않는다 — 명세 enum 은 대문자지만, 종류 판별이 표기 하나로 뒤집혀 * 캘린더에서 일기가 통째로 사라지는 실패는 폭이 너무 크다. */ -fun WeeklyReportDayDto.toDomain(): WeeklyReportDay = +private fun WeeklyReportDayDto.toDomain(): WeeklyReportDay = WeeklyReportDay( diaryId = diaryId, day = day, @@ -57,7 +57,7 @@ fun WeeklyReportDayDto.toDomain(): WeeklyReportDay = * 오늘로 메우지 않는다 — 그러면 파싱 못 한 기록이 **오늘 작성한 것처럼** HISTORY 카드에 * 앉아 필드 이상을 감춘다. 로그도 에러 표시도 없는 조용한 오표시였다 (#547). */ -fun WeeklyReportDailyQuestionDto.toDomainOrNull(): WeeklyReportDailyQuestion? { +private fun WeeklyReportDailyQuestionDto.toDomainOrNull(): WeeklyReportDailyQuestion? { val parsedDate = parseServerDateOrNull(date) ?: run { Log.w(TAG, "주간리포트 데일리질문 날짜를 해석하지 못해 목록에서 제외한다: raw=$date") diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionImageContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionImageContractTest.kt index 4417cb9ac0..6c2ade0083 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionImageContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionImageContractTest.kt @@ -1,6 +1,8 @@ package com.afternote.feature.mindrecord.data.dto +import com.afternote.core.network.model.ApiException import com.afternote.core.network.model.BaseResponse +import com.afternote.core.network.model.requireData import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals @@ -16,6 +18,11 @@ import org.junit.Test * 을 명시한다 — 본문 이미지는 `content` HTML 에 담긴다. * * 실서버 왕복 실측(2026-08-23)에서도 `imageUrl` 은 무시되고 `` 는 그대로 살아남았다. + * + * **봉투는 [requireData] 로 연다** — 프로덕션이 쓰는 그 함수다 (#1506). 종전에는 `.data!!` 로 + * 직접 꺼내, 파일 이름과 테스트 문구가 「서버 응답 계약」을 표방하면서도 실제로는 역직렬화만 + * 확인했다. 봉투가 `status != 200` 이거나 `data` 가 비어 와도 `NullPointerException` 이 날 뿐, + * 앱이 실제로 내는 [ApiException] 과는 다른 실패가 된다. 그 두 갈래를 아래에서 직접 고정한다. */ class DailyQuestionImageContractTest { private val json = @@ -63,7 +70,7 @@ class DailyQuestionImageContractTest { .decodeFromString( BaseResponse.serializer(ListSerializer(DailyQuestionListItemDto.serializer())), body, - ).data!! + ).requireData() .single() assertEquals( @@ -87,9 +94,53 @@ class DailyQuestionImageContractTest { .decodeFromString( BaseResponse.serializer(ListSerializer(DailyQuestionListItemDto.serializer())), body, - ).data!! + ).requireData() .single() assertEquals(1L, item.dailyQuestionId) } + + @Test + fun `봉투가 실패면 payload 가 아니라 ApiException 이 된다`() { + // 프로덕션은 `status != 200` 을 봉투 계약 위반으로 보고 던진다. `.data!!` 로 꺼내면 + // 이 갈래가 통째로 지나가고, 화면은 «성공했는데 목록이 비었다» 로 읽는다 (#1506). + val body = + """ + { "status": 500, "code": 500, "message": "서버 오류", "data": null } + """.trimIndent() + + val thrown = + runCatching { + json + .decodeFromString( + BaseResponse.serializer(ListSerializer(DailyQuestionListItemDto.serializer())), + body, + ).requireData() + }.exceptionOrNull() + + assertEquals(ApiException::class.java, thrown?.javaClass) + assertEquals(500, (thrown as ApiException).status) + } + + @Test + fun `봉투는 200 인데 payload 가 비면 그것도 계약 위반이다`() { + // 서버가 성공이라 말해 놓고 data 를 안 실은 경우다. status 는 200 그대로 남긴다 — + // 전송은 성공했고 어긴 것은 봉투 계약이라, 재시도 분기가 5xx 와 갈려야 한다. + val body = + """ + { "status": 200, "code": 200, "data": null } + """.trimIndent() + + val thrown = + runCatching { + json + .decodeFromString( + BaseResponse.serializer(ListSerializer(DailyQuestionListItemDto.serializer())), + body, + ).requireData() + }.exceptionOrNull() + + assertEquals(ApiException::class.java, thrown?.javaClass) + assertEquals(200, (thrown as ApiException).status) + } } diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt index 33ecc0c575..dbdc05e9cf 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt @@ -128,9 +128,31 @@ class DiaryListContractTest { } @Test - fun `todayMood 가 빠지면 실패한다`() { - // 저장 컬럼이 필수라 응답에도 항상 있다. 한글 값이 관측된 쪽은 AI 가 매기는 - // `emotion` 이지 사용자가 고르는 이 필드가 아니다 (#591, #789). + fun `임시저장의 명시적 null 기분은 정상 파싱된다`() { + // BE#243(PR #267) 뒤로 임시저장은 `today_mood` 가 NULL 일 수 있다. 필수로 두면 + // 그런 일기가 하나라도 있는 달의 목록 전체가 파싱 실패로 날아간다 (#1065). + val body = + """ + { + "status": 200, "code": 200, + "data": { "monthDiaryCount": 1, "weeklyDominantMood": null, + "diaries": [{ "diaryId": 1, "title": "쓰다 만 제목", "content": "", + "date": "2026-09-07", "createdAt": "2026-09-07T10:00:00", + "todayMood": null, "isDraft": true, "receivers": [] }] } + } + """.trimIndent() + + val decoded = json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) + + val diary = decoded.data!!.diaries.single() + assertNull(diary.todayMood) + assertEquals(true, diary.isDraft) + } + + @Test + fun `todayMood 키 자체가 빠지면 실패한다`() { + // 값이 조건부인 것과 키가 사라진 것은 다르다 — 후자는 계약 변경이라 드러나야 한다. + // 한글 값이 관측된 쪽은 AI 가 매기는 `emotion` 이지 사용자가 고르는 이 필드가 아니다 (#591, #789). val body = """ { diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt index 0d3bcde9f0..1235549701 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt @@ -122,12 +122,46 @@ class MindRecordDtoContractTest { isDraft = false, todayMood = TodayMoodDto.SOSO, receiverIds = emptyList(), + date = "2026-08-01", ), ) assertEquals(true, encoded.contains("\"receiverIds\":[]")) } + @Test + fun `생성은 기록일을 항상 싣고 수정은 생략할 수 있다`() { + // 서버는 생성에서 미전송이면 오늘로 채우고, 수정에서 생략이면 기존 값을 유지한다 + // (Afternote-BE#244, PR #262). 그 차이가 타입에 그대로 드러나야 한다 (#1008). + val created = + json.encodeToString( + DiaryCreateRequestDto.serializer(), + DiaryCreateRequestDto( + title = "t", + content = "c", + isDraft = false, + todayMood = TodayMoodDto.SOSO, + receiverIds = emptyList(), + date = "2026-08-01", + ), + ) + assertEquals(true, created.contains("\"date\":\"2026-08-01\"")) + + val omitted = + json.encodeToString( + DiaryUpdateRequestDto.serializer(), + DiaryUpdateRequestDto( + title = "t", + content = "c", + isDraft = false, + todayMood = TodayMoodDto.SOSO, + receiverIds = null, + date = null, + ), + ) + assertEquals(false, omitted.contains("\"date\"")) + } + @Test fun `PATCH 는 생략과 명시적 null 이 코드에서 구분된다`() { // 선택 필드라 nullable 은 유지하되 기본값은 없다 — 호출부가 모든 인자를 적어야 한다. diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt index 2ecd1ce16b..73fdd55a0f 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt @@ -2,6 +2,7 @@ package com.afternote.feature.mindrecord.data.dto import com.afternote.core.network.model.BaseResponse import com.afternote.feature.mindrecord.data.mapper.toDomain +import com.afternote.feature.mindrecord.domain.model.WeeklyReportDay import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.MissingFieldException import kotlinx.serialization.json.Json @@ -34,7 +35,7 @@ class WeeklyReportContractTest { // 카운트·요약·목록 키는 모두 계약이라 기본값이 없다 (#789) — 주(week) 형태만 바꿔가며 // 보기 위해 나머지 필수 키는 여기서 채운다. - private fun decodeWeek(weekJson: String): List { + private fun decodeReport(weekJson: String): WeeklyReportDto { val body = """ { "status": 200, "code": 200, "data": { @@ -45,14 +46,21 @@ class WeeklyReportContractTest { return json .decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) .data!! - .week } + private fun decodeWeek(weekJson: String): List = decodeReport(weekJson).week + + /** + * 도메인으로 접힌 주(week). 단건 매퍼가 아니라 **공개 소유자인 [WeeklyReportDto.toDomain]** + * 을 통과시킨다 — 「이 원소가 일기로 접히는가」는 그 주가 리포트에 실리는 방식의 일부다 (#1674). + */ + private fun decodeWeekDomain(weekJson: String): List = decodeReport(weekJson).toDomain().week + @Test fun `현재 와이어 형태의 DIARY 원소가 일기로 접힌다`() { - val week = decodeWeek("""[{ "diaryId": 13, "day": 3, "type": "DIARY", "emotion": "SAD" }]""") + val week = decodeWeekDomain("""[{ "diaryId": 13, "day": 3, "type": "DIARY", "emotion": "SAD" }]""") - val day = week.single().toDomain() + val day = week.single() assertEquals(13L, day.diaryId) assertEquals(3, day.day) assertTrue("type=DIARY 는 일기다 — 여기서 false 면 캘린더 점이 통째로 사라진다", day.isDiary) @@ -61,22 +69,22 @@ class WeeklyReportContractTest { @Test fun `DIARY 가 아닌 종류는 일기가 아니다`() { val week = - decodeWeek( + decodeWeekDomain( """ [{ "diaryId": 1, "day": 1, "type": "DAILY_QUESTION", "emotion": null }, { "diaryId": 2, "day": 2, "type": "DEEP_THOUGHT", "emotion": null }] """.trimIndent(), ) - assertEquals(listOf(false, false), week.map { it.toDomain().isDiary }) + assertEquals(listOf(false, false), week.map { it.isDiary }) } @Test fun `명세에 없는 새 종류가 와도 그 주가 죽지 않고 일기 아님으로 접힌다`() { // type 을 enum 으로 받으면 여기서 그 주 전체가 MissingFieldException 으로 날아간다. - val week = decodeWeek("""[{ "diaryId": 1, "day": 1, "type": "FUTURE_KIND", "emotion": null }]""") + val week = decodeWeekDomain("""[{ "diaryId": 1, "day": 1, "type": "FUTURE_KIND", "emotion": null }]""") - assertEquals(false, week.single().toDomain().isDiary) + assertEquals(false, week.single().isDiary) } @Test @@ -99,9 +107,9 @@ class WeeklyReportContractTest { fun `클라가 모르는 기분 값은 그 원소의 기분만 비운다`() { // 서버가 한글 감정을 내려준 전례가 있다 (#591). #789 로 기본값을 걷어냈어도 // **값** 확장까지 실패로 만들지는 않는다 — 이모지 한 칸 대신 그 주가 통째로 날아간다. - val week = decodeWeek("""[{ "diaryId": 1, "day": 1, "type": "DIARY", "emotion": "슬픔" }]""") + val week = decodeWeekDomain("""[{ "diaryId": 1, "day": 1, "type": "DIARY", "emotion": "슬픔" }]""") - val day = week.single().toDomain() + val day = week.single() assertTrue(day.isDiary) assertNull(day.emotion) } diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/mapper/WeeklyReportDateMapperTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/mapper/WeeklyReportDateMapperTest.kt index 7dab5c8a54..f2fd5976b6 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/mapper/WeeklyReportDateMapperTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/mapper/WeeklyReportDateMapperTest.kt @@ -1,8 +1,9 @@ package com.afternote.feature.mindrecord.data.mapper import com.afternote.feature.mindrecord.data.dto.WeeklyReportDailyQuestionDto +import com.afternote.feature.mindrecord.data.dto.WeeklyReportDto import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import java.time.LocalDate @@ -12,52 +13,65 @@ import java.time.LocalDate * 종전에는 이 지식이 `WeeklyReportViewModel` 에 있었고, 실패 처리도 소비처마다 갈렸다 — * 집계 경로는 버리고 표시 경로는 `LocalDate.now()` 로 메워 파싱 못 한 기록이 **오늘 * 작성한 것처럼** 카드에 앉았다. 이제 한 곳에서 정한다: **해석 못 하면 제외.** + * + * 단건 매퍼(`toDomainOrNull`)가 아니라 **공개 소유자인 [WeeklyReportDto.toDomain] 을 통해** + * 본다. 날짜 해석은 그 자체가 목적이 아니라 «주간 리포트의 데일리질문 목록에 무엇이 남는가» + * 의 일부라, 목록에서 빠지는 것까지가 계약이다 (#1674). */ class WeeklyReportDateMapperTest { - private fun dto(date: String) = WeeklyReportDailyQuestionDto(title = "질문", content = "답변", date = date) - @Test fun `요일이 붙은 서버 기본 포맷을 해석한다`() { // 실서버가 이 형태로 내려준다 (2026-08-23 실측). - assertEquals(LocalDate.of(2026, 8, 23), dto("2026.08.23 일").toDomainOrNull()?.date) + assertEquals(listOf(LocalDate.of(2026, 8, 23)), dates("2026.08.23 일")) } @Test fun `ISO 날짜도 해석한다`() { - assertEquals(LocalDate.of(2026, 3, 21), dto("2026-03-21").toDomainOrNull()?.date) + assertEquals(listOf(LocalDate.of(2026, 3, 21)), dates("2026-03-21")) } @Test fun `시각이 붙은 ISO 도 해석한다`() { // ISO_DATE 만 두면 뒤가 남아 실패한다 — 세 포맷을 모두 허용하는 이유다. - assertEquals(LocalDate.of(2026, 3, 25), dto("2026-03-25T20:13:42").toDomainOrNull()?.date) + assertEquals(listOf(LocalDate.of(2026, 3, 25)), dates("2026-03-25T20:13:42")) } @Test - fun `해석하지 못한 날짜는 오늘로 메우지 않고 제외한다`() { + fun `해석하지 못한 날짜는 오늘로 메우지 않고 목록에서 뺀다`() { // 오늘로 메우면 그 기록이 오늘 작성한 것처럼 HISTORY 카드에 앉아 이상을 감춘다. - assertNull(dto("알 수 없음").toDomainOrNull()) - assertNull(dto("").toDomainOrNull()) + assertTrue(report("알 수 없음").dailyQuestions.isEmpty()) + assertTrue(report("").dailyQuestions.isEmpty()) } @Test fun `제외돼도 나머지 항목은 살아남는다`() { // 실패의 폭이 목록 전체가 되면 안 된다. - val kept = - listOf(dto("2026.08.23 일"), dto("깨진 값"), dto("2026-08-22")) - .mapNotNull { it.toDomainOrNull() } - assertEquals( listOf(LocalDate.of(2026, 8, 23), LocalDate.of(2026, 8, 22)), - kept.map { it.date }, + dates("2026.08.23 일", "깨진 값", "2026-08-22"), ) } @Test fun `제목과 본문은 그대로 통과한다`() { - val domain = dto("2026.08.23 일").toDomainOrNull() + val question = report("2026.08.23 일").dailyQuestions.single() - assertEquals("질문", domain?.title) - assertEquals("답변", domain?.content) + assertEquals("질문", question.title) + assertEquals("답변", question.content) } + + private fun dates(vararg rawDates: String) = report(*rawDates).dailyQuestions.map { it.date } + + private fun report(vararg rawDates: String) = + WeeklyReportDto( + dailyQuestionAmount = rawDates.size, + diaryAmount = 0, + summaryText = "요약", + week = emptyList(), + dailyQuestions = + rawDates.map { date -> + WeeklyReportDailyQuestionDto(title = "질문", content = "답변", date = date) + }, + emotions = emptyList(), + ).toDomain() } diff --git a/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryDraftMoodRequestTest.kt b/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryDraftMoodRequestTest.kt new file mode 100644 index 0000000000..04a711ffc7 --- /dev/null +++ b/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryDraftMoodRequestTest.kt @@ -0,0 +1,75 @@ +package com.afternote.feature.mindrecord.data.mapper + +import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload +import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload +import com.afternote.feature.mindrecord.domain.model.TodayMood +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * 임시저장의 「기분 미선택」이 와이어까지 그대로 가는지 (#1065). + * + * 종전 FE 는 미선택을 `SOSO` 로 채워 보냈다. 그러면 **고른 적 없는 값이 사용자 데이터가 + * 된다** — 이어쓰기로 열면 「그냥그래」가 이미 선택돼 보이고, 주간리포트 기분 집계와 감정 + * 분석 입력에도 들어간다. 그래서 폴백을 걷었는데, 그때는 서버가 임시저장에도 기분을 + * 요구해서 화면이 미선택 자체를 막아야 했다. + * + * `Afternote-BE#243` → PR #267(2026-08-30 머지)이 그 검증을 걷었다. 이제 미선택은 + * **명시적 null** 로 나간다 — 키를 생략하지 않는다. 서버 규칙상 생략과 null 이 같더라도, + * 「고르지 않았다」를 값으로 말하는 편이 나중에 두 의미가 갈릴 때 안전하다. + * + * 매퍼 반환값이 아니라 **직렬화된 본문**을 본다 — 실제로 서버가 받는 것이 그것이다. + */ +class DiaryDraftMoodRequestTest { + /** 앱이 실제로 쓰는 설정과 같게 둔다 (core:network NetworkModule). */ + private val json = + Json { + ignoreUnknownKeys = true + } + + @Test + fun `기분을 안 고른 임시저장은 todayMood 를 null 로 보낸다`() { + val body = json.encodeToString(createPayload(mood = null).toRequest()) + + assertTrue(body, body.contains("\"todayMood\":null")) + assertTrue("임시저장 표시가 빠졌다: $body", body.contains("\"isDraft\":true")) + } + + @Test + fun `기분을 고른 임시저장은 그 값을 그대로 보낸다`() { + val body = json.encodeToString(createPayload(mood = TodayMood.SAD).toRequest()) + + assertTrue(body, body.contains("\"todayMood\":\"SAD\"")) + } + + @Test + fun `이어쓰기 수정도 미선택을 null 로 보낸다`() { + // 이어쓰기는 PATCH 를 탄다 — 생성 경로만 열고 수정 경로를 닫아 두면 이어쓰다 + // 기분을 지운 임시저장을 저장할 수 없다. + val body = + json.encodeToString( + DiaryUpdatePayload( + title = "쓰다 만 제목", + content = "", + isDraft = true, + todayMood = null, + receiverIds = null, + ).toRequest(), + ) + + assertTrue(body, body.contains("\"todayMood\":null")) + } + + private fun createPayload(mood: TodayMood?) = + DiaryCreatePayload( + title = "쓰다 만 제목", + content = "", + isDraft = true, + todayMood = mood, + receiverIds = emptyList(), + date = LocalDate.of(2026, 9, 7), + ) +} diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt index dea157622d..d767233374 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt @@ -1,5 +1,7 @@ package com.afternote.feature.mindrecord.domain.model +import java.time.LocalDate + enum class TodayMood { HAPPY, SOSO, @@ -13,8 +15,13 @@ data class Diary( /** 사용자가 고른 일기 날짜 (yyyy-MM-dd). 서버가 항상 채우는 값이다 (#789). */ val date: String, val createdAt: String, - /** 사용자가 직접 고른 오늘의 기분. 저장 컬럼이 필수라 응답에도 항상 있다 (#789). */ - val todayMood: TodayMood, + /** + * 사용자가 직접 고른 오늘의 기분. + * + * **임시저장에는 없을 수 있다** — 「아직 안 골랐다」를 표현할 자리다. 서버가 `isDraft=true` + * 의 기분 필수를 걷었고(BE#243 → PR #267), 정식 등록은 여전히 필수다 (#1065). + */ + val todayMood: TodayMood?, /** * 목록 카드 썸네일. **서버 응답에 이 키가 없어 항상 null 이다** — 표시 단계에서 본문 * HTML 의 첫 img 로 채운다 (#1024). 계약이 넓어지면 그때 서버 값이 우선한다. @@ -35,16 +42,25 @@ data class DiaryCreatePayload( val title: String, val content: String, val isDraft: Boolean, - val todayMood: TodayMood, + /** 임시저장이면 null 로 보낼 수 있다. 정식 등록은 화면이 값을 강제한다 (#1065). */ + val todayMood: TodayMood?, /** 이 일기를 전달받을 수신자 ID 목록. 미선택 시 빈 목록 — 서버가 "수신자 없음" 으로 본다. */ val receiverIds: List, + /** + * 사용자가 고른 기록일. 서버는 미전송 시 오늘(Asia/Seoul)로 채우지만 작성 화면은 항상 + * 값을 갖고 있어 그대로 싣는다 (#1008). + * + * **미래 날짜는 서버가 400(code 2101)으로 거절한다.** 과거는 제한이 없다. + */ + val date: LocalDate, ) data class DiaryUpdatePayload( val title: String, val content: String, val isDraft: Boolean, - val todayMood: TodayMood, + /** 임시저장이면 null 로 보낼 수 있다. 정식 등록은 화면이 값을 강제한다 (#1065). */ + val todayMood: TodayMood?, /** * 이 일기를 전달받을 수신자 ID 목록. 생성 경로와 같은 형태다 (#955). * @@ -53,4 +69,12 @@ data class DiaryUpdatePayload( * 기존 지정을 지워 버린다. */ val receiverIds: List? = null, + /** + * 기록일. **`null` 이면 서버가 기존 값을 유지한다** (`DiaryUpdateRequest.date` — 생략 시 유지). + * + * 화면에 뜬 날짜가 서버에서 온 것이거나 사용자가 직접 고른 것일 때만 싣는다. 프리필이 + * 날짜를 못 가져온 채로 «오늘» 을 실어 보내면 기록일이 조용히 오늘로 옮겨진다 — 수신자 + * 목록을 빈 목록으로 보내면 안 되는 것과 같은 종류의 함정이다 (#1008). + */ + val date: LocalDate? = null, ) diff --git a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt index 6814dd9c85..69c4b19024 100644 --- a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt +++ b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt @@ -69,12 +69,15 @@ class FakeDiaryRepository( onUpdate?.let { return it(id, payload) } diaries.replaceAll { diary -> if (diary.diaryId == id) { - // date·imageUrl 은 수정 요청 계약에서 빠졌다 (#955) — 기존 값을 유지한다. + // imageUrl 은 수정 요청 계약에 없다 (#955) — 기존 값을 유지한다. + // date 는 2026-08-29 부터 계약에 있고 **생략(null)이면 기존 값 유지**다 + // (Afternote-BE#244, PR #262). 서버와 같은 규칙으로 흉내 낸다 (#1008). diary.copy( title = payload.title, content = payload.content, todayMood = payload.todayMood, isDraft = payload.isDraft, + date = payload.date?.toString() ?: diary.date, ) } else { diary diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 3060d711f5..a7e30e4867 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -41,6 +41,8 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(testFixtures(projects.core.domain)) + // 클릭 타깃 접근성 스캐너 (#1167). 크기·이름·역할(#1179)과 중첩 클릭(#1669) 계약을 여기서 지킨다. + testImplementation(testFixtures(projects.core.ui)) testImplementation(testFixtures(projects.feature.mindrecord.domain)) // 캘린더 날짜 셀 상호작용을 JVM 에서 실제로 눌러 확인한다 (#724). diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyCalendar.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyCalendar.kt index b072dafd7f..5df56a3cb1 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyCalendar.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyCalendar.kt @@ -74,7 +74,19 @@ fun DailyCalendar( ) } Text( - text = stringResource(MindRecordR.string.mindrecord_calendar_answered_count, answeredDays.size), + // 같은 캘린더를 두 탭이 쓰는데 문구가 하나뿐이라, 일기 탭에서도 + // 「N개의 답변 완료」로 읽혔다 — 일기는 답변이 아니다 (#1712). + text = + stringResource( + // 캘린더를 쓰는 것은 두 탭뿐이지만 sealed 를 열거해 둔다 — else 로 두면 + // 새 카테고리가 조용히 「기록 완료」로 떨어진다 (#1712 리뷰). + when (type) { + MindRecordCategoryUi.DailyQuestion -> MindRecordR.string.mindrecord_calendar_answered_count + MindRecordCategoryUi.Diary -> MindRecordR.string.mindrecord_calendar_recorded_count + MindRecordCategoryUi.WeeklyReport -> MindRecordR.string.mindrecord_calendar_recorded_count + }, + answeredDays.size, + ), style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.black.copy(alpha = 0.35f), ) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyQuestionListCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyQuestionListCard.kt index 772e1f6d52..562ee559e8 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyQuestionListCard.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DailyQuestionListCard.kt @@ -33,6 +33,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.afternote.core.ui.popup.AfternoteActionMenu +import com.afternote.core.ui.popup.editDeleteActionMenuItems import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.presentation.R @@ -50,10 +52,17 @@ private val DateFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") fun DailyQuestionListCard( answer: DailyQuestion, modifier: Modifier = Modifier, - /** 카드 전체 탭 — 저장된 기록 본문을 여는 상세 화면으로 간다 (#759). */ - onClick: () -> Unit = {}, - onEdit: () -> Unit = {}, - onDelete: () -> Unit = {}, + /** + * 카드 전체 탭 — 저장된 기록 본문을 여는 상세 화면으로 간다 (#759). + * + * **`null` 이면 클릭 자체를 붙이지 않는다** (#1540 리뷰). + * + * no-op 을 넘기면 `Role.Button` 이 그대로 실려 스크린리더가 「버튼」으로 읽는데 눌러도 + * 아무 일이 없다 — 읽기 전용으로 쓰는 자리(주간 리포트 HISTORY)가 그랬다. + */ + onClick: (() -> Unit)?, + onEdit: (() -> Unit)?, + onDelete: (() -> Unit)?, ) { OutlinedCard( colors = @@ -62,7 +71,10 @@ fun DailyQuestionListCard( ), border = BorderStroke(1.dp, color = AfternoteDesign.colors.gray2), shape = CardShape, - modifier = modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onClick), + modifier = + modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier), ) { if (answer.imageUrl != null) { ThumbnailCardContent(answer = answer, onEdit = onEdit, onDelete = onDelete) @@ -76,8 +88,8 @@ fun DailyQuestionListCard( @Composable private fun TextCardContent( answer: DailyQuestion, - onEdit: () -> Unit, - onDelete: () -> Unit, + onEdit: (() -> Unit)?, + onDelete: (() -> Unit)?, ) { Column( modifier = Modifier.padding(16.dp), @@ -106,8 +118,8 @@ private fun TextCardContent( @Composable private fun ThumbnailCardContent( answer: DailyQuestion, - onEdit: () -> Unit, - onDelete: () -> Unit, + onEdit: (() -> Unit)?, + onDelete: (() -> Unit)?, ) { Row( modifier = Modifier.padding(12.dp), @@ -149,8 +161,8 @@ private fun ThumbnailCardContent( @Composable private fun CardHeaderRow( date: LocalDate, - onEdit: () -> Unit, - onDelete: () -> Unit, + onEdit: (() -> Unit)?, + onDelete: (() -> Unit)?, ) { var menuExpanded by remember { mutableStateOf(false) } @@ -172,27 +184,30 @@ private fun CardHeaderRow( ) } - Box { - Icon( - painter = painterResource(R.drawable.mindrecord_horizontal), - tint = AfternoteDesign.colors.gray5, - contentDescription = stringResource(R.string.mindrecord_more_menu_cd), - modifier = - Modifier - .size(20.dp) - .clickable(role = Role.Button) { menuExpanded = true }, - ) - if (menuExpanded) { - RecordActionPopup( - onDismiss = { menuExpanded = false }, - onDelete = { - menuExpanded = false - onDelete() - }, - onEdit = { - menuExpanded = false - onEdit() - }, + // **핸들러가 없으면 «더보기» 자체를 그리지 않는다** (#1540). + // + // 종전에는 `= {}` 디폴트라, 핸들러를 안 넘긴 화면(주간 리포트 HISTORY)에서도 메뉴가 + // 뜨고 «수정»·«삭제» 를 눌러도 아무 일이 없었다. 눌러도 아무 일 없는 버튼을 그리는 + // 대신 없는 상호작용은 보여 주지 않는다. + if (onEdit != null && onDelete != null) { + Box { + Icon( + painter = painterResource(R.drawable.mindrecord_horizontal), + tint = AfternoteDesign.colors.gray5, + contentDescription = stringResource(R.string.mindrecord_more_menu_cd), + modifier = + Modifier + .size(20.dp) + .clickable(role = Role.Button) { menuExpanded = true }, + ) + AfternoteActionMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + items = + editDeleteActionMenuItems( + onEditClick = onEdit, + onDeleteClick = onDelete, + ), ) } } @@ -210,6 +225,9 @@ private fun DailyQuestionCardPreview() { content = "아무 말 없이 그저 나의 곁을 지켜주는 아내가 고맙다.", date = LocalDate.now(), ), + onClick = {}, + onDelete = {}, + onEdit = {}, ) } } @@ -226,6 +244,9 @@ private fun DailyQuestionCardWithImagePreview() { date = LocalDate.now(), imageUrl = "https://example.com/image.jpg", ), + onClick = {}, + onDelete = {}, + onEdit = {}, ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryCard.kt index 48b54d6a4d..8e07ab1058 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryCard.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryCard.kt @@ -33,6 +33,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.afternote.core.ui.popup.AfternoteActionMenu +import com.afternote.core.ui.popup.editDeleteActionMenuItems import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.presentation.R @@ -53,9 +55,9 @@ fun DiaryCard( diary: DailyDiary, modifier: Modifier = Modifier, /** 카드 전체 탭 — 저장된 기록 본문을 여는 상세 화면으로 간다 (#759). */ - onClick: () -> Unit = {}, - onEdit: () -> Unit = {}, - onDelete: () -> Unit = {}, + onClick: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, ) { var menuExpanded by remember { mutableStateOf(false) } @@ -135,19 +137,15 @@ fun DiaryCard( .size(20.dp) .clickable(role = Role.Button) { menuExpanded = true }, ) - if (menuExpanded) { - RecordActionPopup( - onDismiss = { menuExpanded = false }, - onDelete = { - menuExpanded = false - onDelete() - }, - onEdit = { - menuExpanded = false - onEdit() - }, - ) - } + AfternoteActionMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + items = + editDeleteActionMenuItems( + onEditClick = onEdit, + onDeleteClick = onDelete, + ), + ) } } @@ -189,6 +187,9 @@ private fun DiaryCardPreview() { emotion = "😊", imageUrl = "https://example.com/image.jpg", ), + onClick = {}, + onDelete = {}, + onEdit = {}, ) } } @@ -205,6 +206,9 @@ private fun DiaryCardNoImagePreview() { date = LocalDate.now(), emotion = "😊", ), + onClick = {}, + onDelete = {}, + onEdit = {}, ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryComponent.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryComponent.kt index e5eb2f5925..7e7a21d728 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryComponent.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DiaryComponent.kt @@ -31,6 +31,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.afternote.core.ui.popup.AfternoteActionMenu +import com.afternote.core.ui.popup.editDeleteActionMenuItems import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.presentation.R @@ -51,9 +53,9 @@ fun DiaryComponent( diary: DailyDiary, modifier: Modifier = Modifier, /** 카드 전체 탭 — 저장된 기록 본문을 여는 상세 화면으로 간다 (#759). */ - onClick: () -> Unit = {}, - onEdit: () -> Unit = {}, - onDelete: () -> Unit = {}, + onClick: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, ) { var menuExpanded by remember { mutableStateOf(false) } @@ -122,19 +124,15 @@ fun DiaryComponent( .size(20.dp) .clickable(role = Role.Button) { menuExpanded = true }, ) - if (menuExpanded) { - RecordActionPopup( - onDismiss = { menuExpanded = false }, - onDelete = { - menuExpanded = false - onDelete() - }, - onEdit = { - menuExpanded = false - onEdit() - }, - ) - } + AfternoteActionMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + items = + editDeleteActionMenuItems( + onEditClick = onEdit, + onDeleteClick = onDelete, + ), + ) } } @@ -171,6 +169,9 @@ private fun DiaryComponentPreview() { emotion = "😊", imageUrl = "https://example.com/image.jpg", ), + onClick = {}, + onDelete = {}, + onEdit = {}, ) } } @@ -187,6 +188,9 @@ private fun DiaryComponentNoImagePreview() { date = LocalDate.now(), emotion = "😊", ), + onClick = {}, + onDelete = {}, + onEdit = {}, ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/InsightCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/InsightCard.kt deleted file mode 100644 index 4084137743..0000000000 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/InsightCard.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.afternote.feature.mindrecord.presentation.component - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.mindrecord.presentation.R - -@Composable -fun InsightCard( - modifier: Modifier = Modifier, - bodyText: String = stringResource(R.string.mindrecord_insight_card_default_body), -) { - OutlinedCard( - colors = - CardDefaults.cardColors( - containerColor = Color(0xFFFFFFFF), - ), - border = BorderStroke(1.dp, color = Color(0xFF000000).copy(alpha = 0.05f)), - modifier = modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "INSIGHTS", - style = AfternoteDesign.typography.mono, - color = AfternoteDesign.colors.gray6, - ) - - Spacer(modifier = Modifier.height(17.dp)) - - Text( - text = bodyText, - style = AfternoteDesign.typography.bodySmallB, - color = AfternoteDesign.colors.black.copy(alpha = 0.7f), - ) - - Spacer(modifier = Modifier.height(17.dp)) - - Text( - text = stringResource(R.string.mindrecord_insight_card_footer), - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray6, - ) - } - } -} - -@Preview(showBackground = true) -@Composable -private fun InsightCardPreview() { - AfternoteTheme { - InsightCard() - } -} diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/KeyBoardToolBar.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/KeyBoardToolBar.kt index cd3da5582d..3160c99471 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/KeyBoardToolBar.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/KeyBoardToolBar.kt @@ -13,6 +13,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -25,6 +28,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight @@ -42,12 +50,23 @@ fun BottomToolbar( onTextStyleClick: () -> Unit, onAlignChange: (TextAlign) -> Unit, modifier: Modifier = Modifier, - onLinkClick: () -> Unit = {}, - onSaveDraftClick: () -> Unit = {}, - onDraftCountClick: () -> Unit = {}, + onLinkClick: () -> Unit, + onSaveDraftClick: () -> Unit, + onDraftCountClick: () -> Unit, + /** + * 지금 문단의 정렬. 이 바의 정렬 3종은 «셋 중 하나» 라 **누가 켜져 있는지**가 semantics 의 + * 일부다 — 값이 없으면 스크린리더는 셋 다 똑같이 「버튼」으로만 읽는다 (#1179). + */ + textAlign: TextAlign = TextAlign.Start, /** 임시저장 개수. `null` 은 아직 모름(조회 중·실패) — 0 으로 단정하지 않는다. */ draftCount: Int? = null, ) { + val textSettingsLabel = stringResource(R.string.mindrecord_toolbar_text_settings) + val draftCountLabel = + draftCount + ?.let { stringResource(R.string.mindrecord_toolbar_draft_list_cd, it) } + ?: stringResource(R.string.mindrecord_toolbar_draft_list_unknown_cd) + Row( modifier = modifier @@ -64,7 +83,12 @@ fun BottomToolbar( } Spacer(modifier = Modifier.width(8.dp)) - IconButton(onClick = onTextStyleClick) { + IconButton( + onClick = onTextStyleClick, + // 「T」 는 눈으로 볼 때만 뜻이 통하는 기호다. 낭독되면 「T, 버튼」 — 무엇을 여는 + // 버튼인지 알 수 없다. 패널 제목으로 이미 쓰는 문구를 이름으로 준다 (#1179 리뷰). + modifier = Modifier.semantics { contentDescription = textSettingsLabel }, + ) { Text( text = "T", style = AfternoteDesign.typography.bodyLargeB, @@ -73,23 +97,30 @@ fun BottomToolbar( } Spacer(modifier = Modifier.width(8.dp)) - IconButton(onClick = { onAlignChange(TextAlign.Start) }) { - Icon( - painter = painterResource(R.drawable.mindrecord_align_left), - contentDescription = stringResource(R.string.mindrecord_toolbar_align_left_cd), - ) - } - IconButton(onClick = { onAlignChange(TextAlign.Center) }) { - Icon( - painter = painterResource(R.drawable.mindrecord_align_center), - contentDescription = stringResource(R.string.mindrecord_toolbar_align_center_cd), - ) - } - IconButton(onClick = { onAlignChange(TextAlign.End) }) { - Icon( - painter = painterResource(R.drawable.mindrecord_align_right), - contentDescription = stringResource(R.string.mindrecord_toolbar_align_right_cd), - ) + // 정렬 아이콘 3종은 `core:ui` 승격본을 쓴다 (#1404). 이 모듈만 Material 기성 채움 + // 패스를 쓰던 것을 시안의 스트로크 2.4 아웃라인 판으로 모았다(4327:76948). + // 스타일 패널의 정렬 pill 과 **같은 셋 중 하나** 선택이다. 종전에는 이쪽만 맨 + // `IconButton` 이라 역할도 선택 상태도 안 실렸다 — 작성 화면에 늘 보이는 건 이쪽이다 (#1179 리뷰). + Row(modifier = Modifier.selectableGroup(), verticalAlignment = Alignment.CenterVertically) { + ALIGN_ITEMS.forEach { (align, resources) -> + val (iconRes, labelRes) = resources + // `IconButton` 안쪽이 이미 `clickable(role = Button)` 이라 `selectable` 을 겹치면 + // 제스처와 indication 이 두 겹이 된다 — 사각 ripple 이 원형 뒤에 비치고 D-pad + // 정지점도 둘이 된다. semantics 만 덮어써 계약은 같게 두고 겹침을 없앤다 (#1179 리뷰). + IconButton( + onClick = { onAlignChange(align) }, + modifier = + Modifier.semantics { + role = Role.RadioButton + selected = textAlign.orStart() == align + }, + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = stringResource(labelRes), + ) + } + } } Spacer(modifier = Modifier.weight(1f)) @@ -99,7 +130,7 @@ fun BottomToolbar( text = stringResource(R.string.mindrecord_toolbar_draft_label), style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray6, - modifier = Modifier.clickable(onClick = onSaveDraftClick), + modifier = Modifier.clickable(role = Role.Button, onClick = onSaveDraftClick), ) Spacer(modifier = Modifier.width(4.dp)) Text( @@ -108,7 +139,12 @@ fun BottomToolbar( text = draftCount?.toString() ?: UNKNOWN_DRAFT_COUNT, style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray4, - modifier = Modifier.clickable(onClick = onDraftCountClick), + modifier = + Modifier + .clickable(role = Role.Button, onClick = onDraftCountClick) + // 숫자만 낭독하면 「1, 버튼」 이라 무엇의 1 인지, 눌러서 어디로 가는지 알 수 없다. + // 수량 미확인('–')도 마찬가지라 그 경우까지 문구로 갈라 준다 (#1179 리뷰). + .semantics { contentDescription = draftCountLabel }, ) } } @@ -130,8 +166,8 @@ fun TextStyleToolbar( onTextStyleChange: (TextStyleType) -> Unit, styleState: TextStyleState, modifier: Modifier = Modifier, - onLinkClick: () -> Unit = {}, - onTypeClick: () -> Unit = {}, + onLinkClick: () -> Unit, + onTypeClick: () -> Unit, ) { Column( modifier = @@ -179,7 +215,14 @@ fun TextStyleToolbar( ) } - IconActionButton(onClick = onTypeClick) { + // 이 「T」 는 아직 어디에도 연결돼 있지 않다(`onTypeClick` 기본값이 `{}` 이고 + // 호출부가 넘기지 않는다). 그래도 클릭 semantics 는 실려 있어 낭독되므로, + // 「T, 버튼」 으로 남겨 두는 대신 아래 글자 종류 행이 하는 일로 이름을 붙였다. + // 무엇을 여는 버튼인지 시안으로 확정되지 않은 자리다 — docs/qa/assumptions.md 참조. + IconActionButton( + onClick = onTypeClick, + label = stringResource(R.string.mindrecord_toolbar_text_type_cd), + ) { Text( text = "T", style = AfternoteDesign.typography.bodyLargeB, @@ -188,7 +231,7 @@ fun TextStyleToolbar( } AlignPill( - selected = styleState.textAlign, + selected = styleState.textAlign.orStart(), onAlignChange = onAlignChange, ) @@ -217,7 +260,7 @@ private fun CloseButton(onClick: () -> Unit) { .size(20.dp) .clip(CircleShape) .background(AfternoteDesign.colors.gray2) - .clickable(onClick = onClick), + .clickable(role = Role.Button, onClick = onClick), contentAlignment = Alignment.Center, ) { Icon( @@ -232,13 +275,17 @@ private fun CloseButton(onClick: () -> Unit) { @Composable private fun IconActionButton( onClick: () -> Unit, + label: String? = null, content: @Composable BoxScope.() -> Unit, ) { Box( modifier = Modifier .size(24.dp) - .clickable(onClick = onClick), + .clickable(role = Role.Button, onClick = onClick) + // 자식이 기호 하나뿐인 슬롯은 그 기호가 그대로 이름이 된다(「T, 버튼」). + // 목적을 말하는 라벨이 있으면 그것으로 덮는다 (#1179 리뷰). + .then(if (label == null) Modifier else Modifier.semantics { contentDescription = label }), contentAlignment = Alignment.Center, content = content, ) @@ -249,26 +296,28 @@ private fun AlignPill( selected: TextAlign, onAlignChange: (TextAlign) -> Unit, ) { - val items = - listOf( - TextAlign.Start to R.drawable.mindrecord_align_left, - TextAlign.Center to R.drawable.mindrecord_align_center, - TextAlign.End to R.drawable.mindrecord_align_right, - ) Row( modifier = Modifier .height(44.dp) .clip(CircleShape) .background(AfternoteDesign.colors.gray2) - .padding(horizontal = 6.dp), + .padding(horizontal = 6.dp) + // 셋 중 하나를 고르는 그룹이다 — 이게 없으면 스크린리더가 「N 개 중 M 번째」를 + // 읽어 주지 못한다 (#1179 · core:ui `AfternoteRadioGroup` 과 같은 관용구). + .selectableGroup(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - items.forEach { (align, iconRes) -> + ALIGN_ITEMS.forEach { (align, resources) -> + val (iconRes, labelRes) = resources PillIconSlot( selected = selected == align, onClick = { onAlignChange(align) }, + singleChoice = true, + // 아이콘이 유일한 라벨이라 `contentDescription = null` 이면 이름 없는 버튼이 된다 + // (스캐너 실측: 이름 누락 3건). 이미 있던 문자열을 여기서도 쓴다. + label = stringResource(labelRes), ) { Icon( painter = painterResource(iconRes), @@ -299,7 +348,12 @@ private fun StylePill( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - PillIconSlot(selected = styleState.isBold, onClick = onBoldClick) { + PillIconSlot( + selected = styleState.isBold, + onClick = onBoldClick, + label = stringResource(R.string.mindrecord_toolbar_bold_cd), + singleChoice = false, + ) { Text( text = "B", fontWeight = FontWeight.Bold, @@ -307,7 +361,12 @@ private fun StylePill( style = AfternoteDesign.typography.bodyLargeB, ) } - PillIconSlot(selected = styleState.isItalic, onClick = onItalicClick) { + PillIconSlot( + selected = styleState.isItalic, + onClick = onItalicClick, + label = stringResource(R.string.mindrecord_toolbar_italic_cd), + singleChoice = false, + ) { Text( text = "I", fontStyle = FontStyle.Italic, @@ -315,7 +374,12 @@ private fun StylePill( style = AfternoteDesign.typography.bodyLargeR, ) } - PillIconSlot(selected = styleState.isUnderline, onClick = onUnderlineClick) { + PillIconSlot( + selected = styleState.isUnderline, + onClick = onUnderlineClick, + label = stringResource(R.string.mindrecord_toolbar_underline_cd), + singleChoice = false, + ) { Text( text = "U", textDecoration = TextDecoration.Underline, @@ -323,7 +387,12 @@ private fun StylePill( style = AfternoteDesign.typography.bodyLargeR, ) } - PillIconSlot(selected = styleState.isStrikethrough, onClick = onStrikethroughClick) { + PillIconSlot( + selected = styleState.isStrikethrough, + onClick = onStrikethroughClick, + label = stringResource(R.string.mindrecord_toolbar_strikethrough_cd), + singleChoice = false, + ) { Text( text = "S", textDecoration = TextDecoration.LineThrough, @@ -338,6 +407,8 @@ private fun StylePill( private fun PillIconSlot( selected: Boolean, onClick: () -> Unit, + label: String, + singleChoice: Boolean, content: @Composable BoxScope.() -> Unit, ) { val size = if (selected) 33.6.dp else 28.dp @@ -353,7 +424,15 @@ private fun PillIconSlot( it } }.clip(CircleShape) - .clickable(onClick = onClick) + .then( + // **정렬은 «셋 중 하나», 서식은 «각각 켜고 끄기»** 라 semantics 가 다르다 (#1179). + // 종전에는 둘 다 맨 `clickable` 이라 역할도 상태도 실리지 않았다. + if (singleChoice) { + Modifier.selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + } else { + Modifier.toggleable(value = selected, role = Role.Checkbox, onValueChange = { onClick() }) + }, + ).semantics { contentDescription = label } Box( modifier = baseModifier, contentAlignment = Alignment.Center, @@ -390,7 +469,9 @@ private fun HeaderTypeRow( Modifier .fillMaxWidth() .height(42.dp) - .padding(vertical = 6.dp), + .padding(vertical = 6.dp) + // 넷이 한 묶음의 상호배타 선택이다 (#1179). + .selectableGroup(), verticalAlignment = Alignment.CenterVertically, ) { items.forEachIndexed { index, item -> @@ -425,7 +506,8 @@ private fun HeaderTypeSegment( Box( modifier = modifier - .clickable(onClick = onClick) + // 넷 중 하나를 고르는 자리다 — 버튼이 아니라 선택이라 상태가 실려야 한다 (#1179). + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) .padding(horizontal = 4.dp, vertical = 2.dp), contentAlignment = Alignment.Center, ) { @@ -464,3 +546,27 @@ private data class HeaderTypeItem( /** 임시저장 개수를 아직 모를 때 자리 표시 — `DraftDateFormatter` 의 미지정 표기와 같은 문자다. */ private const val UNKNOWN_DRAFT_COUNT = "\u2013" + +/** + * 정렬 3종의 값·아이콘·라벨. 하단 바와 스타일 패널이 **같은 표**를 읽는다 — 갈라 두면 한쪽만 + * 라벨이 바뀌어도 스크린리더가 두 툴바를 다르게 읽는다 (#1179 리뷰). + */ +private val ALIGN_ITEMS = + listOf( + TextAlign.Start to (CoreUiR.drawable.core_ui_ic_align_left to R.string.mindrecord_toolbar_align_left_cd), + TextAlign.Center to (CoreUiR.drawable.core_ui_ic_align_center to R.string.mindrecord_toolbar_align_center_cd), + TextAlign.End to (CoreUiR.drawable.core_ui_ic_align_right to R.string.mindrecord_toolbar_align_right_cd), + ) + +/** + * 정렬 미지정을 왼쪽으로 읽는다 (#1179 리뷰). + * + * richeditor-compose 1.0.0 의 기본 문단은 `ParagraphStyle()` 이라 `textAlign` 이 + * [TextAlign.Unspecified] 다. non-null value class 여서 elvis 로는 걸러지지 않는다 — 종전 + * 호출부의 `?: TextAlign.Start` 는 컴파일러가 「항상 왼쪽 피연산자」라고 경고하던 죽은 코드였다. + * + * 그대로 두면 작성 화면 **첫 상태**에서 정렬 3종의 `selected` 가 모두 false 가 되어 스크린리더가 + * 「선택 안 됨」을 셋 다 읽는다. 정규화를 **«셋 중 하나» 를 선언한 이 파일**에 두어, 어느 호출부가 + * 무엇을 넘기든 계약이 유지되게 한다. + */ +internal fun TextAlign.orStart(): TextAlign = if (this == TextAlign.Unspecified) TextAlign.Start else this diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/MediaSelectBottomSheet.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/MediaSelectBottomSheet.kt deleted file mode 100644 index 6bb3bf0725..0000000000 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/MediaSelectBottomSheet.kt +++ /dev/null @@ -1,175 +0,0 @@ -package com.afternote.feature.mindrecord.presentation.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.mindrecord.presentation.R -import com.afternote.core.ui.R as CoreUiR - -/** - * 키보드 툴바 링크(미디어) 버튼에서 호출되는 "미디어 추가하기" 메인 메뉴 바텀시트. - * - * Figma 노드 4327:72281 (media_select variant) — 헤더(드래그 핸들 + 제목) + 4개 항목 - * (이미지/음성/파일/링크 추가하기). 항목 사이 #E0E0E0 디바이더. - * - * "링크 추가하기" 를 누르면 [LinkBottomSheet] 가 후속으로 뜨도록 호출부에서 분기한다. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MediaSelectBottomSheet( - onDismiss: () -> Unit, - onImageClick: () -> Unit, - onVoiceClick: () -> Unit, - onFileClick: () -> Unit, - onLinkClick: () -> Unit, - modifier: Modifier = Modifier, -) { - ModalBottomSheet( - onDismissRequest = onDismiss, - containerColor = AfternoteDesign.colors.gray1, - dragHandle = { - Box( - modifier = - Modifier - .padding(top = 12.dp, bottom = 8.dp) - .width(40.dp) - .height(4.dp) - .clip(CircleShape) - .background(Color(0xFFCCCCCC)), - ) - }, - ) { - MediaSelectContent( - onImageClick = onImageClick, - onVoiceClick = onVoiceClick, - onFileClick = onFileClick, - onLinkClick = onLinkClick, - modifier = modifier, - ) - } -} - -@Composable -private fun MediaSelectContent( - onImageClick: () -> Unit, - onVoiceClick: () -> Unit, - onFileClick: () -> Unit, - onLinkClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = - modifier - .fillMaxWidth() - .padding(bottom = 32.dp), - ) { - // 헤더 — 가운데 정렬, py=6 - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.mindrecord_media_sheet_title), - style = AfternoteDesign.typography.bodyBase, - color = AfternoteDesign.colors.gray6, - ) - } - - // 4개 항목 — 각 row p=16 gap=10, 사이 #E0E0E0 디바이더 - MediaItem( - icon = painterResource(CoreUiR.drawable.core_ui_ic_image), - text = stringResource(R.string.mindrecord_media_sheet_image), - onClick = onImageClick, - ) - HorizontalDivider(thickness = 1.dp, color = AfternoteDesign.colors.gray3) - MediaItem( - icon = painterResource(CoreUiR.drawable.core_ui_ic_mic), - text = stringResource(R.string.mindrecord_media_sheet_voice), - onClick = onVoiceClick, - ) - HorizontalDivider(thickness = 1.dp, color = AfternoteDesign.colors.gray3) - MediaItem( - icon = painterResource(CoreUiR.drawable.core_ui_ic_file), - text = stringResource(R.string.mindrecord_media_sheet_file), - onClick = onFileClick, - ) - HorizontalDivider(thickness = 1.dp, color = AfternoteDesign.colors.gray3) - MediaItem( - icon = painterResource(CoreUiR.drawable.core_ui_ic_link), - text = stringResource(R.string.mindrecord_media_sheet_link), - onClick = onLinkClick, - ) - HorizontalDivider(thickness = 1.dp, color = AfternoteDesign.colors.gray3) - } -} - -@Composable -private fun MediaItem( - icon: Painter, - text: String, - onClick: () -> Unit, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - painter = icon, - contentDescription = null, - tint = AfternoteDesign.colors.gray9, - modifier = Modifier.size(24.dp), - ) - Text( - text = text, - style = AfternoteDesign.typography.bodyBase, - color = AfternoteDesign.colors.gray9, - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun MediaSelectBottomSheetPreview() { - AfternoteTheme { - MediaSelectContent( - onImageClick = {}, - onVoiceClick = {}, - onFileClick = {}, - onLinkClick = {}, - ) - } -} diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/MemoriesCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/MemoriesCard.kt index 5ae919d2ac..4e84a5cfbc 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/MemoriesCard.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/MemoriesCard.kt @@ -46,7 +46,11 @@ import com.afternote.feature.mindrecord.presentation.R * * @param question 최근 기록의 제목(데일리질문이면 질문 원문). null 이면 표시할 기록이 없다. * @param answer 그 기록의 본문 미리보기. - * @param onReadAgainClick "그날의 기록 다시 읽기" 목적지. **최종 확정은 아직이다** — Figma 의 + * @param onReadAgainClick "그날의 기록 다시 읽기" 목적지 — 카드가 가리키는 그 기록의 상세. + * **`null` 이면 버튼 자체를 그리지 않는다** (#793 리뷰). 열 기록이 없는데 버튼을 남기면 + * 문구가 약속한 「그날의 기록」이 아닌 곳으로 보내게 된다. + * + * (옛 메모) 최종 확정은 아직이다 — Figma 의 * 이 버튼에 프로토타입 연결이 없다. 다만 비워 두면 버튼이 클릭을 삼켜 카드 안에서 버튼만 * 죽은 영역이 되므로, 홈 호출부는 카드 자신의 목적지(추억 공간)를 물려준다 (#793). */ @@ -55,7 +59,7 @@ fun MemoriesCard( modifier: Modifier = Modifier, question: String? = null, answer: String? = null, - onReadAgainClick: () -> Unit = {}, + onReadAgainClick: (() -> Unit)?, ) { val cardShape = RoundedCornerShape(8.dp) val buttonShape = RoundedCornerShape(20.dp) @@ -120,28 +124,32 @@ fun MemoriesCard( Spacer(modifier = Modifier.height(16.dp)) } - Row( - modifier = - Modifier - .border(width = 1.dp, color = AfternoteDesign.colors.gray3, shape = buttonShape) - .clip(buttonShape) - .background(color = AfternoteDesign.colors.gray2) - .clickable(role = Role.Button, onClick = onReadAgainClick) - .padding(horizontal = 17.dp, vertical = 9.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.mindrecord_read_again), - style = AfternoteDesign.typography.inter, - color = AfternoteDesign.colors.gray7, - ) + // **열 기록이 없으면 버튼을 그리지 않는다** (#793 리뷰). 문구가 「그날의 기록」을 + // 약속하는데 0건에서 다른 곳으로 보내면 그 약속이 깨진다. + if (onReadAgainClick != null) { + Row( + modifier = + Modifier + .border(width = 1.dp, color = AfternoteDesign.colors.gray3, shape = buttonShape) + .clip(buttonShape) + .background(color = AfternoteDesign.colors.gray2) + .clickable(role = Role.Button, onClick = onReadAgainClick) + .padding(horizontal = 17.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.mindrecord_read_again), + style = AfternoteDesign.typography.inter, + color = AfternoteDesign.colors.gray7, + ) - Spacer(modifier = Modifier.width(9.dp)) + Spacer(modifier = Modifier.width(9.dp)) - RightArrowIcon( - modifier = Modifier.size(width = 4.dp, height = 7.dp), - tint = AfternoteDesign.colors.gray5, - ) + RightArrowIcon( + modifier = Modifier.size(width = 4.dp, height = 7.dp), + tint = AfternoteDesign.colors.gray5, + ) + } } } } @@ -154,15 +162,16 @@ private fun MemoriesCardPreview() { MemoriesCard( question = "내 인생에서 가장 소중했던 순간은?", answer = "아이가 태어났을 때...", + onReadAgainClick = {}, ) } } -/** 기록 0건 — 시안에 변형이 없어 질문·답변 줄 없이 이미지와 버튼만 남는다 (#559). */ +/** 기록 0건 — 질문·답변 줄도, 「다시 읽기」 버튼도 없다. 열 기록이 없기 때문이다 (#559 · #793). */ @Preview(showBackground = true, name = "기록 0건") @Composable private fun MemoriesCardEmptyPreview() { AfternoteTheme { - MemoriesCard() + MemoriesCard(onReadAgainClick = null) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverMindRecordTopBar.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverMindRecordTopBar.kt index a946800aa8..ef04c27287 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverMindRecordTopBar.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverMindRecordTopBar.kt @@ -16,8 +16,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordFilter /** @@ -33,6 +38,7 @@ fun ReceiverMindRecordTopBar( onFilterClick: () -> Unit, modifier: Modifier = Modifier, ) { + val filterLabel = stringResource(R.string.mindrecord_receiver_filter_cd) Row( modifier = modifier @@ -52,11 +58,14 @@ fun ReceiverMindRecordTopBar( } Spacer(modifier = Modifier.weight(1f)) Box( + // 이 타깃은 자식이 없어 **이름이 빈 문자열**이고 역할도 없었다 — 스크린리더가 + // 「버튼」 이라고조차 못 읽는다. 스캐너 실측으로 드러났다 (#1179 리뷰). modifier = Modifier .size(24.dp) .clip(CircleShape) - .clickable(onClick = onFilterClick), + .clickable(role = Role.Button, onClick = onFilterClick) + .semantics { contentDescription = filterLabel }, ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverSelectBottomSheet.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverSelectBottomSheet.kt index 7e5af656f6..8582e3360e 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverSelectBottomSheet.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverSelectBottomSheet.kt @@ -230,8 +230,8 @@ private fun ReceiverSelectContentPreview() { ReceiverSelectContent( receivers = listOf( - Receiver(receiverId = 1, name = "김소희", relation = "딸", authCode = ""), - Receiver(receiverId = 2, name = "박채연", relation = "조카", authCode = ""), + Receiver(receiverId = 1, name = "김소희", relation = "딸"), + Receiver(receiverId = 2, name = "박채연", relation = "조카"), ), selectedReceiverIds = setOf(1L), onToggle = {}, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/RecordActionPopup.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/RecordActionPopup.kt deleted file mode 100644 index b20e9f0add..0000000000 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/RecordActionPopup.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.afternote.feature.mindrecord.presentation.component - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.mindrecord.presentation.R - -/** - * 마음의 기록 카드의 ... 버튼에서 뜨는 액션 팝업. - * Figma 노드 2249:14756 — 흰 배경 / 8dp radius / hard shadow / width 120dp / 항목 padding 16dp. - */ -@Composable -fun RecordActionPopup( - onDismiss: () -> Unit, - onDelete: () -> Unit, - onEdit: () -> Unit, - modifier: Modifier = Modifier, -) { - Popup( - alignment = Alignment.TopEnd, - onDismissRequest = onDismiss, - properties = PopupProperties(focusable = true), - ) { - Surface( - modifier = modifier.width(120.dp), - shape = RoundedCornerShape(8.dp), - color = Color.White, - shadowElevation = 10.dp, - ) { - Column { - PopupItem( - label = stringResource(R.string.mindrecord_record_action_delete), - onClick = onDelete, - ) - PopupItem( - label = stringResource(R.string.mindrecord_record_action_edit), - onClick = onEdit, - ) - } - } - } -} - -@Composable -private fun PopupItem( - label: String, - onClick: () -> Unit, -) { - Text( - text = label, - style = AfternoteDesign.typography.bodyBase, - color = AfternoteDesign.colors.gray9, - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(16.dp), - ) -} - -@Preview(showBackground = true) -@Composable -private fun RecordActionPopupPreview() { - AfternoteTheme { - Surface( - modifier = Modifier.width(120.dp), - shape = RoundedCornerShape(8.dp), - color = Color.White, - shadowElevation = 10.dp, - ) { - Column { - PopupItem(label = "삭제하기", onClick = {}) - PopupItem(label = "수정하기", onClick = {}) - } - } - } -} diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/TodayQuestionCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/TodayQuestionCard.kt index 09e47ccdc1..dee71c4b27 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/TodayQuestionCard.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/TodayQuestionCard.kt @@ -51,7 +51,7 @@ fun TodayQuestionCard( modifier: Modifier = Modifier, questionText: String? = null, isQuestionLoading: Boolean = false, - onAnswerClick: () -> Unit = {}, + onAnswerClick: () -> Unit, ) { Column( modifier = @@ -168,6 +168,7 @@ private fun TodayQuestionCardPreview() { dateText = "2026.04.10", questionText = "오늘 내가 배운\n가장 작은 교훈은 무엇인가요?", modifier = Modifier.padding(16.dp), + onAnswerClick = {}, ) } } @@ -180,6 +181,7 @@ private fun TodayQuestionCardUnavailablePreview() { dateText = "2026.04.10", questionText = null, modifier = Modifier.padding(16.dp), + onAnswerClick = {}, ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyEmotionCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyEmotionCard.kt deleted file mode 100644 index 33be8f4345..0000000000 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyEmotionCard.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.afternote.feature.mindrecord.presentation.component - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.mindrecord.presentation.R - -@Composable -fun WeeklyEmotionCard(modifier: Modifier = Modifier) { - OutlinedCard( - colors = - CardDefaults.cardColors( - containerColor = AfternoteDesign.colors.gray2, - ), - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(100.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 17.dp, vertical = 9.dp), - ) { - Text( - text = stringResource(R.string.mindrecord_weekly_report_avg_emotion_weekly), - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray9, - ) - - Text( - text = stringResource(R.string.mindrecord_weekly_report_avg_mood_good), - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray9, - ) - } - } -} - -@Preview(showBackground = true) -@Composable -private fun WeeklyEmotionCardPreview() { - AfternoteTheme { - WeeklyEmotionCard() - } -} diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportCard.kt deleted file mode 100644 index 8f781828a9..0000000000 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportCard.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.afternote.feature.mindrecord.presentation.component - -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.mindrecord.presentation.R - -@Composable -fun WeeklyReportCard(modifier: Modifier = Modifier) { - Box( - modifier = - modifier.border( - 1.dp, - color = AfternoteDesign.colors.gray2, - shape = RoundedCornerShape(6.dp), - ), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(21.dp), - ) { - Text( - text = stringResource(R.string.mindrecord_weekly_report_title), - style = AfternoteDesign.typography.bodyLargeB, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.mindrecord_weekly_report_subtitle), - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray5, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Row(verticalAlignment = Alignment.CenterVertically) { - Column { - Text( - text = "42", - style = - AfternoteDesign.typography.h2.copy( - lineHeight = 32.sp, - ), - ) - - Text( - text = stringResource(R.string.mindrecord_weekly_report_total_record), - style = - AfternoteDesign.typography.captionLargeR.copy( - fontSize = 11.sp, - lineHeight = 16.sp, - ), - color = AfternoteDesign.colors.black.copy(alpha = 0.35f), - ) - } - Spacer(modifier = Modifier.width(24.dp)) - VerticalDivider(modifier = Modifier.height(30.dp)) - Spacer(modifier = Modifier.width(24.dp)) - Column { - Text( - text = "7", - style = - AfternoteDesign.typography.h2.copy( - lineHeight = 32.sp, - ), - ) - - Text( - text = stringResource(R.string.mindrecord_weekly_report_this_week), - style = - AfternoteDesign.typography.captionLargeR.copy( - fontSize = 11.sp, - lineHeight = 16.sp, - ), - color = AfternoteDesign.colors.black.copy(alpha = 0.35f), - ) - } - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun WeeklyReportCardPreview() { - AfternoteTheme { WeeklyReportCard() } -} diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportReviewCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportReviewCard.kt index fe1af8b9d1..e72511979f 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportReviewCard.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyReportReviewCard.kt @@ -46,7 +46,7 @@ fun WeeklyReportReviewCard( modifier: Modifier = Modifier, selectedMonday: LocalDate? = null, weekOptions: List = emptyList(), - onWeekSelect: (LocalDate) -> Unit = {}, + onWeekSelect: (LocalDate) -> Unit, dateRange: String = "2025.11.10. - 2025.11.16.", counts: List> = listOf( @@ -65,13 +65,19 @@ fun WeeklyReportReviewCard( modifier = modifier .fillMaxWidth() - .height(200.dp) .clip(RoundedCornerShape(6.dp)), ) { Column( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() + // 높이를 **못 박지 않고 최소값으로** 둔다. 종전에는 카드가 `height(200.dp)` 고정이라 + // 사용자가 글자 크기를 키우면 내용이 200dp 를 넘고 마지막 행(카운트 라벨)부터 + // 잘려 나갔다 — 실측: 폰트 배율 1.3 에서 「데일리 질문」·「일기」 아랫부분이 잘린다. + // 저시력 사용자가 정확히 이 화면에서 값을 잃는 자리다 (#1718). + // + // 기본 배율에서는 내용이 200dp 보다 작아 시안 높이가 그대로 유지된다. + .heightIn(min = CardMinHeight) .drawWithCache { val brush = Brush.radialGradient( @@ -214,6 +220,11 @@ private fun weekLabel(monday: LocalDate): String = @Composable private fun WeeklyReportScreenPreview() { AfternoteTheme { - WeeklyReportReviewCard() + WeeklyReportReviewCard( + onWeekSelect = {}, + ) } } + +/** 시안 높이(4327:99448 계열). 기본 배율에서는 내용이 이보다 작아 이 값이 그대로 카드 높이가 된다. */ +private val CardMinHeight = 200.dp diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt index a7f1b68506..212227cbbd 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextField.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -30,6 +31,9 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontStyle @@ -39,6 +43,8 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.afternote.core.ui.sheet.MediaSelectBottomSheet +import com.afternote.core.ui.sheet.MediaSheetItem import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.presentation.R @@ -48,10 +54,10 @@ import com.afternote.feature.mindrecord.presentation.util.escapeHtml import com.afternote.feature.mindrecord.presentation.util.mediaDisplayName import com.afternote.feature.mindrecord.presentation.util.mediaImageSize import com.afternote.feature.mindrecord.presentation.util.toBodyLinkHrefOrNull -import com.afternote.feature.mindrecord.presentation.util.toUploadedFileKey import com.mohamedrejeb.richeditor.model.rememberRichTextState import com.mohamedrejeb.richeditor.ui.BasicRichTextEditor import kotlinx.coroutines.launch +import com.afternote.core.ui.R as CoreUiR /** * HTML 직렬화 가능한 리치 텍스트 입력 영역. @@ -66,14 +72,14 @@ import kotlinx.coroutines.launch * - 사용자 입력이 발생하면 [onValueChange] 로 직렬화된 HTML 문자열을 emit 한다. * 서버 페이로드의 `content` 필드에 그대로 실어 보내면 된다. */ -@OptIn(ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable fun WriteTextField( modifier: Modifier = Modifier, value: String? = null, onValueChange: ((String) -> Unit)? = null, - onSaveDraftClick: () -> Unit = {}, - onDraftCountClick: () -> Unit = {}, + onSaveDraftClick: () -> Unit, + onDraftCountClick: () -> Unit, draftCount: Int? = null, /** * 갤러리에서 고른 이미지를 서버에 업로드하고 URL 을 반환하는 업로더 @@ -81,6 +87,9 @@ fun WriteTextField( * * **null 을 넘기지 않는다.** 업로더가 없으면 로컬 `content://` URI 가 본문과 저장 데이터에 * 그대로 들어가는데, 그 주소는 다른 기기·수신자에게 아무 의미가 없다 (#731). + * + * **돌려주는 값은 업로드된 전체 URL 이다** — 그대로 본문 `src`·`href` 에 들어가고, 서버가 + * 기대하는 `fileKey` 로 바꾸는 것은 제출 직전 `toWireContent` 한 곳뿐이다 (#1125). */ onImagePicked: (suspend (uriString: String) -> String?)? = null, /** @@ -122,7 +131,7 @@ fun WriteTextField( isItalic = state.currentSpanStyle.fontStyle == FontStyle.Italic, isUnderline = state.currentSpanStyle.textDecoration?.contains(TextDecoration.Underline) == true, isStrikethrough = state.currentSpanStyle.textDecoration?.contains(TextDecoration.LineThrough) == true, - textAlign = state.currentParagraphStyle.textAlign ?: TextAlign.Start, + textAlign = state.currentParagraphStyle.textAlign.orStart(), textStyle = currentTextStyleType(state.currentSpanStyle.fontSize.value), ) @@ -168,11 +177,13 @@ fun WriteTextField( mediaError = MediaErrorMessage(R.string.mindrecord_write_media_upload_failed, displayName) return@launch } - // 본문에 넣는 값은 업로드 URL 이 아니라 **fileKey** 다. 서버가 본문의 미디어 - // 참조를 훑어 staging → permanent 로 옮기고 전체 URL 로 재작성하는데, 전체 URL 을 - // 넣으면 그 앞에 호스트를 한 번 더 붙여 접근 불가한 주소가 저장된다 (#549·#731). - // `img src` 와 `a href` 에 같은 규칙이 적용되는 것을 실서버로 확인했다. - val fileKey = uploadedUrl.toUploadedFileKey() + // **에디터에는 업로드 URL 을 넣는다.** 서버에 나가는 값은 fileKey 여야 하지만 + // (전체 URL 을 보내면 그 앞에 호스트를 한 번 더 붙여 접근 불가한 주소가 저장된다 — + // #549·#731 실서버 실측), 그 변환은 제출 직전 `toWireContent` 한 곳에서 한다. + // + // 종전에는 여기서 URL 의 스킴·호스트를 떼어 키를 역산했다. 변환 지점이 둘이 되고, + // CDN 이 경로 프리픽스를 붙이면 조용히 틀린 키가 됐다. 이제 ViewModel 이 presigned + // 응답의 `fileKey` 를 URL 과 짝지어 들고 있다가 제출 때 그대로 치환한다 (#1125). val html = if (asImage) { // 크기를 비워 두면 직렬화 때 width="0" height="0" 이 붙어 어디서도 보이지 @@ -182,11 +193,11 @@ fun WriteTextField( // `displayName` 은 파일을 넘긴 앱(content provider)이 정하는 값이라 따옴표가 // 들어올 수 있다. 이스케이프하지 않으면 `alt` 가 그 자리에서 닫히고 뒤따르는 // `width`/`height` 가 값으로 먹혀 **이미지가 저장된 본문에서 사라진다** (#1067 리뷰). - "\"${displayName.escapeHtml()}\"" } else { // 링크 텍스트도 같은 출처다 — 이름 속 태그가 마크업으로 살아난다. - "${displayName.escapeHtml()}" + "${displayName.escapeHtml()}" } keepEditorFocus { state.setHtml(state.toHtml() + html) } attachments += displayName @@ -206,6 +217,12 @@ fun WriteTextField( attachMedia(uri, asImage = false) } + // 본문 편집기의 접근 가능한 이름. 눈에 보이는 안내 문구는 아래에서 **형제 노드**로 그려서 + // 편집기 자신의 semantics 에는 잡히지 않는다 — 그대로 두면 스크린리더가 화면의 대부분을 + // 차지하는 이 타깃을 이름 없이 읽는다 (#1179 리뷰의 후보 전량 스캔에서 드러났다). + // 보이는 문구와 읽히는 이름을 같은 문자열로 묶는다. + val editorLabel = stringResource(R.string.mindrecord_write_field_placeholder) + Column(modifier = modifier.fillMaxSize()) { Box( modifier = @@ -221,6 +238,7 @@ fun WriteTextField( Modifier .fillMaxSize() .focusRequester(editorFocusRequester) + .semantics { contentDescription = editorLabel } .padding(16.dp), ) // 첨부 목록과 오류 문구는 같은 자리를 두고 다투면 안 된다 — 종전에는 정렬·패딩이 @@ -246,7 +264,10 @@ fun WriteTextField( Text( text = stringResource(R.string.mindrecord_write_field_placeholder), color = AfternoteDesign.colors.gray4, - modifier = Modifier.padding(16.dp), + // 같은 문자열이 편집기의 이름으로 이미 실려 있다. 이 형제 노드를 접근성 + // 트리에 남기면 비어 있을 때 같은 문장을 두 번 읽는다 (#1179 리뷰). + // Material 의 TextField 도 placeholder 를 필드 노드에 합치고 따로 짚지 않는다. + modifier = Modifier.padding(16.dp).clearAndSetSemantics {}, ) } Text( @@ -281,12 +302,19 @@ fun WriteTextField( onTextStyleChange = { type -> keepEditorFocus { state.addSpanStyle(type.toSpanStyle()) } }, + // **하단 툴바와 같은 배선이다.** 스타일 툴바가 열리면 하단 툴바를 덮으므로 + // 같은 두 affordance(링크·«T»)를 그대로 갖는다. 종전에는 두 인자를 넘기지 + // 않아 no-op 디폴트가 먹었고, **눌러도 아무 일이 없는 버튼 두 개**였다 (#1540). + onLinkClick = { sheet = KeyboardSheet.MediaSelect }, + onTypeClick = { showTextStyleToolbar = !showTextStyleToolbar }, ) } BottomToolbar( modifier = Modifier.imePadding(), onTextStyleClick = { showTextStyleToolbar = !showTextStyleToolbar }, + // 스타일 패널과 같은 값을 본다 — 두 툴바의 정렬 선택 상태가 갈리면 안 된다 (#1179). + textAlign = styleState.textAlign, onAlignChange = { align -> keepEditorFocus { state.addParagraphStyle(ParagraphStyle(textAlign = align)) } }, @@ -303,21 +331,42 @@ fun WriteTextField( } KeyboardSheet.MediaSelect -> { + // core:ui 정본을 쓴다. 종전에는 같은 시트를 모듈마다 다시 적어 4벌이었고, + // 그 사본들이 시안(4327:72281) 지오메트리에서 조금씩 어긋나 있었다 (#642 · #1615). MediaSelectBottomSheet( onDismiss = { sheet = KeyboardSheet.None }, - onImageClick = { - sheet = KeyboardSheet.None - imageLauncher.launch("image/*") - }, - onVoiceClick = { - sheet = KeyboardSheet.None - voiceLauncher.launch("audio/*") - }, - onFileClick = { - sheet = KeyboardSheet.None - fileLauncher.launch("*/*") - }, - onLinkClick = { sheet = KeyboardSheet.LinkAdd }, + items = + listOf( + MediaSheetItem( + iconRes = CoreUiR.drawable.core_ui_ic_image, + label = stringResource(CoreUiR.string.core_ui_media_sheet_image), + onClick = { + sheet = KeyboardSheet.None + imageLauncher.launch("image/*") + }, + ), + MediaSheetItem( + iconRes = CoreUiR.drawable.core_ui_ic_mic, + label = stringResource(CoreUiR.string.core_ui_media_sheet_voice), + onClick = { + sheet = KeyboardSheet.None + voiceLauncher.launch("audio/*") + }, + ), + MediaSheetItem( + iconRes = CoreUiR.drawable.core_ui_ic_file, + label = stringResource(CoreUiR.string.core_ui_media_sheet_file), + onClick = { + sheet = KeyboardSheet.None + fileLauncher.launch("*/*") + }, + ), + MediaSheetItem( + iconRes = CoreUiR.drawable.core_ui_ic_link, + label = stringResource(CoreUiR.string.core_ui_media_sheet_link), + onClick = { sheet = KeyboardSheet.LinkAdd }, + ), + ), ) } @@ -378,7 +427,10 @@ private fun currentTextStyleType(currentFontSize: Float): TextStyleType = @Composable private fun WriteTextFieldPreview() { AfternoteTheme { - WriteTextField() + WriteTextField( + onDraftCountClick = {}, + onSaveDraftClick = {}, + ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemoryDetailOverlay.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemoryDetailOverlay.kt index 8ee924c5ff..fe7e40380c 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemoryDetailOverlay.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemoryDetailOverlay.kt @@ -37,11 +37,13 @@ import com.afternote.core.ui.icon.RightArrowIcon import com.afternote.core.ui.modifierextention.shimmerLoadingPlaceholder import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.MindRecordType import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryItem +import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryRecordId import com.afternote.feature.mindrecord.presentation.util.htmlToPlainText @Composable -fun MemoryDetailOverlay( +internal fun MemoryDetailOverlay( memory: MemoryItem, onClose: () -> Unit, modifier: Modifier = Modifier, @@ -183,7 +185,7 @@ private fun MemoryDetailOverlayPreview() { MemoryDetailOverlay( memory = MemoryItem( - id = 1, + id = MemoryRecordId(MindRecordType.DIARY, 1L), imageUrl = "https://picsum.photos/400/600?random=1", title = "오늘의 기억", date = "2024.11.11", diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceCardField.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceCardField.kt index a7144a585a..a423cf0805 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceCardField.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceCardField.kt @@ -23,13 +23,15 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.MindRecordType import com.afternote.feature.mindrecord.presentation.model.memoryspace.CardTransform import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryItem +import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryRecordId @Composable -fun MemorySpaceCardField( +internal fun MemorySpaceCardField( memories: List, - onMemoryClick: (Long) -> Unit, + onMemoryClick: (MemoryRecordId) -> Unit, modifier: Modifier = Modifier, ) { var touchPosition by remember { mutableStateOf(null) } @@ -149,10 +151,38 @@ private fun MemorySpaceCardFieldPreview() { MemorySpaceCardField( memories = listOf( - MemoryItem(1L, "https://picsum.photos/400/600?random=1", "기억 1", "2024.11.11", "내용 1", listOf("태그1")), - MemoryItem(2L, "https://picsum.photos/400/600?random=2", "기억 2", "2024.11.12", "내용 2", listOf("태그2")), - MemoryItem(3L, "https://picsum.photos/400/600?random=3", "기억 3", "2024.11.13", "내용 3", listOf("태그3")), - MemoryItem(4L, "https://picsum.photos/400/600?random=4", "기억 4", "2024.11.14", "내용 4", listOf("태그4")), + MemoryItem( + MemoryRecordId(MindRecordType.DIARY, 1L), + "https://picsum.photos/400/600?random=1", + "기억 1", + "2024.11.11", + "내용 1", + listOf("태그1"), + ), + MemoryItem( + MemoryRecordId(MindRecordType.DAILY_QUESTION, 2L), + "https://picsum.photos/400/600?random=2", + "기억 2", + "2024.11.12", + "내용 2", + listOf("태그2"), + ), + MemoryItem( + MemoryRecordId(MindRecordType.DIARY, 3L), + "https://picsum.photos/400/600?random=3", + "기억 3", + "2024.11.13", + "내용 3", + listOf("태그3"), + ), + MemoryItem( + MemoryRecordId(MindRecordType.DAILY_QUESTION, 4L), + "https://picsum.photos/400/600?random=4", + "기억 4", + "2024.11.14", + "내용 4", + listOf("태그4"), + ), ), onMemoryClick = {}, ) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceHeader.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceHeader.kt index cd033f432e..3e505dae65 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceHeader.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpaceHeader.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme @@ -132,21 +133,45 @@ private fun BackPill(onBackClick: () -> Unit) { * * 시안(정리 Screen Design › 홈 › 기억공간)은 둘 다 150% 다 — 제목 11/16.5, 부제 9/13.5. * 그래서 `fontSize` 를 덮을 때는 `lineHeight` 를 **같이** 덮는다. + * + * **자간과 부제 색도 시안에 맞춘다** (#1548). `inter` 토큰의 `letterSpacing` 은 `-0.006em`(좁힘)인데 + * 시안은 두 문구 모두 **넓힘**이다 — 부호가 반대라 「MEMORY SPACE」 가 시안보다 붙어 보였다. + * + * | | 시안 tracking | em 환산 | 여기 값 | + * |---|---|---|---| + * | 제목 `4327:67831` | `+0.6145px` / 11px | +0.055864 | `0.0559.em` | + * | 부제 `4327:67833` | `+0.167px` / 9px | +0.018556 | `0.0186.em` | + * + * **토큰을 고치지 않고 이 자리에서만 덮는다.** `typography.inter` 는 afternote·home·mindrecord· + * timeletter 4개 모듈 12곳이 쓰고 담당도 갈려, 토큰을 움직이면 그 화면들이 전부 따라 움직인다 — + * 소비자 전수 시안 대조가 선행돼야 하는 별건이다. */ @Composable private fun HeaderTitle() { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = stringResource(R.string.mindrecord_memory_space_title), - style = AfternoteDesign.typography.inter.copy(fontSize = 11.sp, lineHeight = 16.5.sp), + style = + AfternoteDesign.typography.inter.copy( + fontSize = 11.sp, + lineHeight = 16.5.sp, + letterSpacing = 0.0559.em, + ), color = AfternoteDesign.colors.gray6, textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.mindrecord_memory_space_subtitle), - style = AfternoteDesign.typography.inter.copy(fontSize = 9.sp, lineHeight = 13.5.sp), - color = AfternoteDesign.colors.gray5, + style = + AfternoteDesign.typography.inter.copy( + fontSize = 9.sp, + lineHeight = 13.5.sp, + letterSpacing = 0.0186.em, + ), + // 시안(4327:67833)은 #757575 = gray6 다. 종전 gray5(#9E9E9E)는 한 단계 옅어 + // 같은 노드 묶음 안에서 제목만 진하고 부제만 흐렸다 (#1548). + color = AfternoteDesign.colors.gray6, // Column 의 CenterHorizontally 는 Text 상자를 가운데 놓을 뿐이고, 접힌 줄을 // 서로 가운데로 맞추는 것은 textAlign 이다 — 없으면 둘째 줄이 왼쪽에 붙는다. textAlign = TextAlign.Center, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpacePhotoCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpacePhotoCard.kt index 531909deb7..50a5c73049 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpacePhotoCard.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/memoryspace/MemorySpacePhotoCard.kt @@ -43,12 +43,14 @@ import coil3.compose.AsyncImage import com.afternote.core.ui.modifierextention.shimmerLoadingPlaceholder import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.MindRecordType import com.afternote.feature.mindrecord.presentation.model.memoryspace.CardTransform import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryItem +import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryRecordId import com.afternote.core.ui.R as CoreUiR @Composable -fun MemorySpacePhotoCard( +internal fun MemorySpacePhotoCard( memory: MemoryItem, transform: CardTransform, onClick: () -> Unit, @@ -192,7 +194,15 @@ private fun PressedOverlay( private fun MemorySpacePhotoCardPreview() { AfternoteTheme { MemorySpacePhotoCard( - memory = MemoryItem(1L, "https://picsum.photos/400/600?random=1", "기억 1", "2024.11.11", "미리보기", listOf("태그")), + memory = + MemoryItem( + MemoryRecordId(MindRecordType.DIARY, 1L), + "https://picsum.photos/400/600?random=1", + "기억 1", + "2024.11.11", + "미리보기", + listOf("태그"), + ), transform = CardTransform( offsetX = 0.dp, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/hometab/HomeTabMindRecordLazyItems.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/hometab/HomeTabMindRecordLazyItems.kt index 57e12406c8..4599964dce 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/hometab/HomeTabMindRecordLazyItems.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/hometab/HomeTabMindRecordLazyItems.kt @@ -58,9 +58,19 @@ fun LazyListScope.homeTabMindRecordTodayQuestion( } } -fun LazyListScope.homeTabMindRecordMemoriesSection(onMemoriesSectionClick: () -> Unit) { +/** + * @param onMemoriesSectionClick 카드·섹션을 누르면 갈 곳(추억 공간). + * @param onRecordDetailClick 「그날의 기록 다시 읽기」가 열 **그 기록의 상세** (#793). + */ +fun LazyListScope.homeTabMindRecordMemoriesSection( + onMemoriesSectionClick: () -> Unit, + onRecordDetailClick: (recordId: Long) -> Unit, +) { item(key = "mind_record_memories") { - HomeTabMindRecordMemoriesItem(onMemoriesSectionClick = onMemoriesSectionClick) + HomeTabMindRecordMemoriesItem( + onMemoriesSectionClick = onMemoriesSectionClick, + onRecordDetailClick = onRecordDetailClick, + ) } } @@ -72,6 +82,7 @@ fun LazyListScope.homeTabMindRecordMemoriesSection(onMemoriesSectionClick: () -> @Composable private fun HomeTabMindRecordMemoriesItem( onMemoriesSectionClick: () -> Unit, + onRecordDetailClick: (recordId: Long) -> Unit, viewModel: MemoriesCardViewModel = hiltViewModel(), ) { val memoriesClickLabel = stringResource(mindrecord_home_tab_memories_section_click_label) @@ -83,8 +94,14 @@ private fun HomeTabMindRecordMemoriesItem( viewModel.refreshOnReturn() } + val recordId = uiState.recordId + MemoriesSectionContent( onMemoriesSectionClick = onMemoriesSectionClick, + // 「그날의 기록 다시 읽기」는 **카드가 가리키는 그 기록**으로 간다 (#793). + // 0건이면 `null` 이라 버튼 자체가 안 그려진다 — 다른 곳으로 보내면 문구가 약속한 + // 「그날의 기록」이 아니게 된다 (리뷰 지적). + onReadAgainClick = recordId?.let { id -> { onRecordDetailClick(id) } }, question = uiState.question, // 본문은 에디터가 HTML 로 직렬화해 저장한다 — 카드에는 태그를 걷어 낸 미리보기만. answer = uiState.answer?.htmlToPlainText()?.takeIf { it.isNotBlank() }, @@ -97,6 +114,7 @@ private fun HomeTabMindRecordMemoriesItem( @Composable internal fun MemoriesSectionContent( onMemoriesSectionClick: () -> Unit, + onReadAgainClick: (() -> Unit)?, clickLabel: String, interactionSource: MutableInteractionSource, question: String? = null, @@ -117,16 +135,13 @@ internal fun MemoriesSectionContent( ) { AfternoteSectionHeader(title = stringResource(mindrecord_home_tab_memories_section_title)) Spacer(modifier = Modifier.height(12.dp)) - // 버튼은 **자기 컨테이너와 같은 곳**으로 보낸다 (#793). - // - // 목적지가 미확정이라 비워 뒀는데, 버튼이 자체 clickable 이라 클릭을 삼켜 카드 - // 안에서 버튼만 죽은 영역이 됐다 — 카드 여백을 누르면 추억 공간이 열리고, 가장 - // 눌러 보고 싶은 버튼만 아무 일도 안 한다. 새 목적지를 지어내는 게 아니라 이미 - // 동작하는 카드의 목적지를 물려주는 것이라, 확정이 오면 이 인자만 바꾸면 된다. + // 버튼 문구가 「**그날의** 기록 다시 읽기」라 목적지는 카드가 보여 주는 그 한 건의 + // 상세다 (#793). 카드·섹션 전체는 종전대로 추억 공간으로 간다 — 「그날」이 없는 + // 넓은 자리라 목록 성격의 목적지가 맞는다. MemoriesCard( question = question, answer = answer, - onReadAgainClick = onMemoriesSectionClick, + onReadAgainClick = onReadAgainClick, ) } } @@ -152,6 +167,7 @@ private fun HomeTabMindRecordMemoriesItemPreview() { AfternoteTheme { MemoriesSectionContent( onMemoriesSectionClick = {}, + onReadAgainClick = {}, clickLabel = "", interactionSource = remember { MutableInteractionSource() }, question = "내 인생에서 가장 소중했던 순간은?", diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt index a4412f456b..9ca3784530 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt @@ -61,7 +61,9 @@ fun Diary.toUi(): DailyDiary? { title = title, date = resolvedDate, content = content, - emotion = todayMood.toEmoji(), + // 임시저장은 기분이 없을 수 있다 — 캘린더·카드가 이미 null 을 「이모지 없음」으로 + // 그린다(점으로 대체). 아무 값이나 채우면 고른 적 없는 기분이 화면에 뜬다 (#1065). + emotion = todayMood?.toEmoji(), // 서버 계약에 `imageUrl` 이 없다 — 요청에 실어 보내도 버려지고 응답에도 키가 없어 // 항상 null 이다. 데일리질문과 같은 규칙으로 본문 HTML 의 첫 img 에서 뽑는다 // (#1024 · #549). DTO 가 값을 주는 날이 오면 그쪽을 우선한다. diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/memoryspace/MemoryItem.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/memoryspace/MemoryItem.kt index 945337ba19..ab21ecb9f4 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/memoryspace/MemoryItem.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/memoryspace/MemoryItem.kt @@ -5,12 +5,13 @@ package com.afternote.feature.mindrecord.presentation.model.memoryspace * * 서버에 전용 계약이 없어 사용자의 실제 기록(일기·데일리질문 답변)에서 조립한다. * - * @param id 원본 기록 ID. 종류가 섞이므로 [MemoryItem] 안에서만 유일하면 된다. + * @param id 원본 기록 식별자. 두 출처의 숫자 ID 공간이 겹치므로 종류를 함께 든다 + * ([MemoryRecordId], #1693). * @param imageUrl 기록에 첨부된 대표 이미지. 없는 기록도 있어 nullable — 카드는 플레이스홀더로 렌더한다. * @param content 원본 HTML 본문. 표시 시점에 `htmlToPlainText()` 로 변환한다. */ -data class MemoryItem( - val id: Long, +internal data class MemoryItem( + val id: MemoryRecordId, val imageUrl: String?, val title: String, val date: String, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/memoryspace/MemoryRecord.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/memoryspace/MemoryRecord.kt new file mode 100644 index 0000000000..fed9ae6f1c --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/model/memoryspace/MemoryRecord.kt @@ -0,0 +1,35 @@ +package com.afternote.feature.mindrecord.presentation.model.memoryspace + +import com.afternote.feature.mindrecord.domain.model.MindRecordType +import java.time.LocalDate + +/** + * 추억 공간 카드가 될 기록 하나 — 두 출처(일기·데일리질문)를 한 줄로 세운 뒤의 모습. + * + * 표시용 문자열이 아니라 [date] 를 `LocalDate` 로 든다. 정렬 키가 필요해서다 — + * "2026.08.23" 같은 표시 문자열로는 두 출처를 섞어 최신순으로 세울 수 없다. + */ +internal data class MemoryRecord( + val id: MemoryRecordId, + val date: LocalDate, + val title: String, + val content: String, + val imageUrl: String?, + /** 일기의 오늘의 기분. 데일리질문에는 없다. */ + val emotion: String?, +) + +/** + * 추억 공간 안에서 기록 하나를 가리키는 식별자 (#1693). + * + * **두 출처의 ID 공간이 겹친다** — 일기 12번과 데일리질문 12번이 동시에 있을 수 있다. + * 종전에는 데일리질문 ID 를 음수로 접어 갈랐는데(`id = -ui.id`), 그 우회는 두 가지가 + * 나쁘다. 서버 ID 가 양수라는 가정에 기대고, 카드에서 원본으로 되돌아갈 때 부호를 다시 + * 뒤집어야 한다는 지식이 화면마다 필요해진다. + * + * 종류를 값으로 들면 그 우회가 사라진다 — 같은 숫자가 와도 [type] 이 다르면 다른 기록이다. + */ +internal data class MemoryRecordId( + val type: MindRecordType, + val value: Long, +) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt index ac386ccd2b..17f9e31069 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt @@ -4,7 +4,7 @@ package com.afternote.feature.mindrecord.presentation.navigation * NavHost 루트에서 마인드레코드 서브그래프로 넘기는 네비게이션 명령 모음. * * [com.afternote.feature.onboarding.presentation.navigation.OnboardingNavActions]· - * [com.afternote.feature.afternote.presentation.author.navigation.AfternoteNavActions]와 동일한 + * [com.afternote.feature.afternote.presentation.navigation.AfternoteNavActions]와 동일한 * “actions 객체 단일 전달” 패턴이다. */ interface MindRecordNavActions { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt index 5213572a4e..40781e6a7c 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt @@ -19,6 +19,18 @@ import java.time.YearMonth * * [Route.MindRecord]는 바텀 탭의 기록 허브이고, [Route.MemorySpace]는 홈 MEMORIES 등에서 * 직접 진입하는 몰입형 화면으로 IA상 분리되어 있지만, 소속 모듈·`composable` 정의는 여기서만 관리한다. + * + * ### 이 매핑을 무엇이 지키나 (#1562) + * + * 매핑이 전부 `() -> Unit` 이라 **서로 바꿔 붙여도 컴파일이 통과한다** — #1311 이 이름을 `popBack` + * 하나로 합치면서 이름이 오배선을 드러내던 방어가 사라졌다. 두 층이 나눠 지킨다. + * + * - `onBackClick` 5줄 — `MindRecordBackStackAndroidTest` 가 각 화면의 뒤로가기를 **실제로 눌러** + * `routeOf()` 로 복귀 route 를 대조한다. + * - `onSubmitSuccess` 2줄 — 제출 성공 경로라 계측이 지나가지 않는다(서버 제출을 태워야 해 비용이 + * 값을 넘는다). 대신 `MindRecordNavGraphWiringKonsistTest` 가 **이 소스의 매핑 자체**를 본다. + * + * 즉 계측은 「그 명령이 무엇을 하나」를, konsist 는 「어느 명령에 붙었나」를 본다. */ fun NavGraphBuilder.mindRecordNavGraph(actions: MindRecordNavActions) { composable { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReporting.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReporting.kt index 4cda23c977..63748a0009 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReporting.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReporting.kt @@ -47,6 +47,49 @@ enum class MindRecordFailureStage( */ MEDIA_UPLOAD("media_upload"), + /** + * 기록 목록 조회(일기·데일리질문). **화면이 오류로 바뀌는 실패만** 여기로 온다 — + * 재진입 갱신 실패는 보고 있던 목록을 그대로 두므로 승격하지 않는다. + * + * 목록 실패는 흔한 편이라 전부 올리면 non-fatal 한도(최근 8건)를 잡음으로 채워 + * 실제 장애를 밀어낸다. 「사용자가 오류 화면을 마주한 경우」로 줄여 그 선을 지킨다 (#964). + */ + RECORD_LIST_LOAD("record_list_load"), + + /** + * 기록 삭제. 되돌릴 수 없는 동작이라 실패하면 목록과 서버 상태가 어긋난 채 남는다 — + * 임시저장 삭제([DRAFT_DELETE])와 같은 성질이다. + */ + RECORD_DELETE("record_delete"), + + /** + * 저장된 기록 상세 열람 (#759). 「내가 쓴 기록이 안 열린다」 는 자리라, 읽기지만 + * 사용자 데이터에 닿지 못한 실패다. + */ + RECORD_DETAIL_LOAD("record_detail_load"), + + /** + * 작성 화면이 여는 **오늘의 질문**(신규)·**기존 답변**(수정) 조회 (#964). + * + * 목록 조회와 달리 여기 실패하면 화면이 오류 문구만 남고 **쓸 수가 없다** — 사용자가 + * 「오늘 질문이 안 뜬다」로 마주하는 자리다. 그래서 읽기지만 승격 대상이다. + */ + DAILY_QUESTION_LOAD("daily_question_load"), + + /** + * 추억 공간 집계 조회. 네 출처(일기 최근 3개월 + 데일리질문)를 병렬로 모으는 자리라 + * **부분 실패는 삼키고**(카드가 한 장이라도 차면 그대로 보여 준다), 합친 결과가 비었고 + * 실패 출처가 하나라도 있을 때만 올라온다. + */ + MEMORY_SPACE_LOAD("memory_space_load"), + + /** + * 주간 리포트 조회. 실패하면 탭 전체가 오류 화면이 되고, 서버가 Gemini 를 다시 호출하는 + * 자리라(Afternote-BE#118) 재시도 비용도 크다 — 무엇이 실패했는지 남지 않으면 그 비용이 + * 어디서 새는지 알 수 없다 (#1882). + */ + WEEKLY_REPORT_LOAD("weekly_report_load"), + /** * 수신자가 받은 기록 열람. 유가족이 «지금 못 여는» 상황이라 읽기 실패지만 승격 가치가 * 높다 — 재현할 계정도 조건도 우리 손에 없어 실기 QA 로 잡히지 않는다. @@ -59,7 +102,8 @@ enum class MindRecordFailureStage( * * 사용자 입력 오류·사용자 취소처럼 오류가 아닌 경로는 호출부에서 걸러 넘기지 않는다. * 코루틴 취소는 [ErrorReporter] 가 창구에서 거르므로 호출부가 다시 막지 않아도 된다 — - * 이 모듈의 저장소는 `runCatching` 으로 취소까지 실패로 바꿔 돌려주는 자리가 있다. + * 「취소는 에러가 아니다」는 **정책**이라 계측 지점이 늘어도 한 곳에서 지킨다. 호출부마다 + * 걸러 달라고 하면 새 지점이 생길 때마다 빠진다. */ fun ErrorReporter.recordMindRecordFailure( stage: MindRecordFailureStage, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreen.kt index 78f8dd5a72..cea90db75b 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreen.kt @@ -20,6 +20,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -35,12 +37,14 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.MindRecordType import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.component.memoryspace.MemoryDetailOverlay import com.afternote.feature.mindrecord.presentation.component.memoryspace.MemorySpaceCardField import com.afternote.feature.mindrecord.presentation.component.memoryspace.MemorySpaceGridBackground import com.afternote.feature.mindrecord.presentation.component.memoryspace.MemorySpaceHeader import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryItem +import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryRecordId import com.afternote.feature.mindrecord.presentation.viewmodel.MemorySpaceUiState import com.afternote.feature.mindrecord.presentation.viewmodel.MemorySpaceViewModel @@ -96,7 +100,12 @@ internal fun MemorySpaceContent( statusText: String? = null, onRetryClick: (() -> Unit)? = null, ) { - var selectedMemoryId: Long? by rememberSaveable { mutableStateOf(null) } + // 카드 선택은 종류까지 함께 든다 — 두 출처의 숫자 ID 가 겹쳐 숫자만으로는 엉뚱한 + // 기록이 열린다 (#1693). 종전처럼 프로세스 사망 뒤에도 열려 있던 카드가 복원되도록 + // 저장 형식은 Saver 로 준다. + var selectedMemoryId: MemoryRecordId? by rememberSaveable(stateSaver = MemoryRecordIdSaver) { + mutableStateOf(null) + } val selectedMemory = selectedMemoryId?.let { id -> memories.firstOrNull { it.id == id } } LaunchedEffect(memories, selectedMemoryId) { @@ -205,7 +214,7 @@ private fun MemorySpaceScreenPreview() { memories = listOf( MemoryItem( - 1L, + MemoryRecordId(MindRecordType.DIARY, 1L), "https://picsum.photos/400/600?random=1", "기억 1", "2024.11.11", @@ -213,7 +222,7 @@ private fun MemorySpaceScreenPreview() { listOf("태그"), ), MemoryItem( - 2L, + MemoryRecordId(MindRecordType.DAILY_QUESTION, 2L), "https://picsum.photos/400/600?random=2", "기억 2", "2024.11.12", @@ -221,7 +230,7 @@ private fun MemorySpaceScreenPreview() { emptyList(), ), MemoryItem( - 3L, + MemoryRecordId(MindRecordType.DIARY, 3L), "https://picsum.photos/400/600?random=3", "기억 3", "2024.11.13", @@ -229,7 +238,7 @@ private fun MemorySpaceScreenPreview() { emptyList(), ), MemoryItem( - 4L, + MemoryRecordId(MindRecordType.DAILY_QUESTION, 4L), "https://picsum.photos/400/600?random=4", "기억 4", "2024.11.14", @@ -268,3 +277,21 @@ private fun MemorySpaceScreenErrorPreview() { ) } } + +/** + * [MemoryRecordId] 를 `rememberSaveable` 이 담을 수 있는 형태로 옮긴다 (#1693). + * + * 종전에는 선택 상태가 `Long?` 이라 그대로 저장됐다. 종류를 함께 들면서 저장 형식이 필요해졌는데, + * 화면 하나의 선택 상태를 위해 모델에 Parcelize 를 물리지는 않는다 — 저장 형식은 이 화면의 사정이다. + */ +private val MemoryRecordIdSaver: Saver = + listSaver( + save = { id -> if (id == null) emptyList() else listOf(id.type.name, id.value) }, + restore = { saved -> + if (saved.isEmpty()) { + null + } else { + MemoryRecordId(type = MindRecordType.valueOf(saved[0] as String), value = saved[1] as Long) + } + }, + ) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordFilterSheet.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordFilterSheet.kt index bab6ae7b33..c029d2929b 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordFilterSheet.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordFilterSheet.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -29,9 +31,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordFilter import com.afternote.feature.mindrecord.presentation.viewmodel.SortOrder @@ -59,6 +66,8 @@ fun ReceiverMindRecordFilterSheet( var fromText by remember { mutableStateOf(current.fromDate.orEmpty()) } var toText by remember { mutableStateOf(current.toDate.orEmpty()) } + val dragHandleLabel = stringResource(R.string.mindrecord_receiver_filter_drag_handle_cd) + ModalBottomSheet( onDismissRequest = onDismiss, containerColor = Color.White, @@ -71,7 +80,11 @@ fun ReceiverMindRecordFilterSheet( .width(40.dp) .height(4.dp) .clip(CircleShape) - .background(AfternoteDesign.colors.gray2), + .background(AfternoteDesign.colors.gray2) + // material3 는 dragHandle 슬롯에 시트 접기·펴기 클릭 액션을 얹는다. 기본 + // `BottomSheetDefaults.DragHandle` 대신 자체 Box 를 넣으면서 그 이름이 + // 통째로 비었다 — 스캐너 실측으로 드러났다 (#1179 리뷰). + .semantics { contentDescription = dragHandleLabel }, ) }, ) { @@ -129,7 +142,8 @@ private fun SortSection( Column { Text(text = "정렬", style = AfternoteDesign.typography.bodySmallB, color = AfternoteDesign.colors.gray9) Spacer(modifier = Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // 둘 중 하나를 고르는 그룹이다 — 없으면 스크린리더가 「N 개 중 M 번째」를 못 읽는다 (#1179). + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.selectableGroup()) { SortOrder.entries.forEach { order -> SortChip( label = order.label, @@ -158,7 +172,8 @@ private fun SortChip( .clip(RoundedCornerShape(50)) .border(1.dp, AfternoteDesign.colors.gray2, RoundedCornerShape(50)) .background(bg) - .clickable(onClick = onClick) + // 색만 바뀌던 칩이라 **선택 상태가 semantics 에 없었다** — 눈으로만 구분됐다 (#1179). + .selectable(selected = isSelected, role = Role.RadioButton, onClick = onClick) .padding(horizontal = 14.dp, vertical = 6.dp), ) } @@ -256,7 +271,7 @@ private fun ActionRow( Modifier .clip(RoundedCornerShape(6.dp)) .border(1.dp, AfternoteDesign.colors.gray2, RoundedCornerShape(6.dp)) - .clickable(onClick = onReset) + .clickable(role = Role.Button, onClick = onReset) .padding(horizontal = 20.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -271,7 +286,7 @@ private fun ActionRow( .weight(1f) .clip(RoundedCornerShape(6.dp)) .background(AfternoteDesign.colors.gray9) - .clickable(onClick = onApply) + .clickable(role = Role.Button, onClick = onApply) .padding(vertical = 12.dp), contentAlignment = Alignment.Center, ) { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt index 2edb001f08..d168e22ed0 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab @@ -39,6 +38,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.asString +import com.afternote.core.ui.loading.LoadingBody import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.core.ui.topbar.DetailTopBar @@ -63,7 +63,7 @@ import androidx.compose.foundation.lazy.grid.items as gridItems fun ReceiverMindRecordScreen( modifier: Modifier = Modifier, viewModel: ReceiverMindRecordViewModel = hiltViewModel(), - onBackClick: () -> Unit = {}, + onBackClick: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var filterSheetVisible by remember { mutableStateOf(false) } @@ -94,7 +94,7 @@ fun ReceiverMindRecordScreen( Box(modifier = Modifier.padding(paddingValues)) { when (val state = uiState) { ReceiverMindRecordUiState.Loading -> { - LoadingBox() + LoadingBody() } is ReceiverMindRecordUiState.Error -> { @@ -236,13 +236,6 @@ private fun DiaryGrid( } } -@Composable -private fun LoadingBox() { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } -} - @Composable private fun ErrorBox( message: String, @@ -309,7 +302,7 @@ private fun ReceiverMindRecordScreenPreview() { * * 필터·정렬로 목록에서 빠진 항목은 자연히 null 이 되어 시트가 닫힌다. */ -internal fun findOpenedRecord( +private fun findOpenedRecord( uiState: ReceiverMindRecordUiState, openedRecordId: Long?, ): MindRecordSummary? { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt index 12030c62da..e092d6c184 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt @@ -3,13 +3,13 @@ package com.afternote.feature.mindrecord.presentation.screen.sender import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -25,6 +25,8 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.asString +import com.afternote.core.ui.button.FAB.AfternoteFabContentBottomPadding +import com.afternote.core.ui.loading.LoadingBody import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.presentation.component.DailyCalendar @@ -50,7 +52,7 @@ fun DailyQuestionAnswerListScreen( onItemClick: (Long, YearMonth) -> Unit, modifier: Modifier = Modifier, isListView: Boolean = true, - onEditClick: (Long) -> Unit = {}, + onEditClick: (Long) -> Unit, viewModel: DailyQuestionListViewModel = hiltViewModel(), ) { // 갱신을 이 화면이 직접 건다. HomeScreen 이 VM 을 호이스팅해 대신 걸어 주면, 탭에 @@ -74,7 +76,7 @@ fun DailyQuestionAnswerListScreen( when (val state = uiState) { DailyQuestionListUiState.Loading -> { - LoadingBox(modifier) + LoadingBody(modifier) } is DailyQuestionListUiState.Error -> { @@ -121,10 +123,10 @@ private fun DailyQuestionListContent( modifier: Modifier = Modifier, todayQuestion: TodayQuestionUi? = null, /** 항목 탭 — 저장된 기록 본문을 여는 상세 화면 (#759). */ - onItemClick: (Long, YearMonth) -> Unit = { _, _ -> }, + onItemClick: (Long, YearMonth) -> Unit, /** «수정하기» — 정식 답변을 프리필한 작성 화면으로 (#582). */ - onEdit: (Long) -> Unit = {}, - onDelete: (Long) -> Unit = {}, + onEdit: (Long) -> Unit, + onDelete: (Long) -> Unit, ) { var yearMonth by remember { mutableStateOf(YearMonth.now()) } val onYearMonthChanged: (YearMonth) -> Unit = { yearMonth = it } @@ -167,6 +169,9 @@ private fun DailyQuestionListContent( LazyColumn( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp), + // FAB 이 콘텐츠 위에 뜨므로 목록이 스스로 그 자리를 비운다 — 안 그러면 마지막 + // 항목이 가려지고, 스크롤이 없을 만큼 항목이 적으면 볼 방법이 없다 (#1713). + contentPadding = PaddingValues(bottom = AfternoteFabContentBottomPadding), ) { // Figma 2671:15631 — 답변 목록 위의 "오늘의 추천 질문" 영역. item { @@ -211,13 +216,6 @@ private fun DailyQuestionListContent( } } -@Composable -private fun LoadingBox(modifier: Modifier = Modifier) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } -} - @Preview(showBackground = true) @Composable private fun DailyQuestionAnswerListScreenPreviewFalse() { @@ -226,6 +224,9 @@ private fun DailyQuestionAnswerListScreenPreviewFalse() { modifier = Modifier, isListView = false, answers = emptyList(), + onDelete = {}, + onEdit = {}, + onItemClick = { _, _ -> }, ) } } @@ -238,6 +239,9 @@ private fun DailyQuestionAnswerListScreenPreviewTrue() { modifier = Modifier, isListView = true, answers = emptyList(), + onDelete = {}, + onEdit = {}, + onItemClick = { _, _ -> }, ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt index 67222ebb63..82fd94f040 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt @@ -1,10 +1,20 @@ package com.afternote.feature.mindrecord.presentation.screen.sender +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Scaffold @@ -43,9 +53,9 @@ private val TopBarDateFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") @Composable fun DailyQuestionWriteScreen( modifier: Modifier = Modifier, - onSubmitSuccess: () -> Unit = {}, - onBackClick: () -> Unit = {}, - onDraftListClick: () -> Unit = {}, + onSubmitSuccess: () -> Unit, + onBackClick: () -> Unit, + onDraftListClick: () -> Unit, viewModel: DailyQuestionWriteViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -80,22 +90,36 @@ fun DailyQuestionWriteScreen( * screenshotTest 가 고정 상태를 그대로 렌더할 수 있도록 상태는 [uiState], 날짜는 [date] 로 * 받고 이벤트는 콜백으로 받는다. 제출 성공 신호는 화면 밖 이동이라 래퍼가 소유한다. */ +@OptIn(ExperimentalLayoutApi::class) @Composable internal fun DailyQuestionWriteScreenContent( uiState: DailyQuestionWriteUiState, date: LocalDate, modifier: Modifier = Modifier, - onBackClick: () -> Unit = {}, - onSubmit: () -> Unit = {}, - onSaveDraft: () -> Unit = {}, - onDraftListClick: () -> Unit = {}, - onAnswerChanged: (String) -> Unit = {}, + onBackClick: () -> Unit, + onSubmit: () -> Unit, + onSaveDraft: () -> Unit, + onDraftListClick: () -> Unit, + onAnswerChanged: (String) -> Unit, onMediaPicked: suspend (String) -> String? = { null }, - onRetryResumeDraft: () -> Unit = {}, + onRetryResumeDraft: () -> Unit, ) { // 배너 접힘은 이 화면 안에서만 의미가 있는 표시 상태다 — Content 가 소유한다. var questionExpanded by remember { mutableStateOf(true) } + // 키보드가 뜨면 질문 배너를 접는다 (#1860). + // + // 이 화면의 세로 배분은 «질문 배너 + 툴바가 먼저, 본문은 남는 것» 이다. edge-to-edge 라 + // 키보드가 떠도 윈도우는 줄지 않고 IME inset 만 오므로(#1849), 그 inset 만큼이 화면 안에서 + // 빠진다. 720x1280 @320dpi · font_scale 1.30 에서는 남는 높이가 0 이 돼 본문 칸이 통째로 + // 눌렸다 — 타이핑은 되는데 쓴 글이 안 보였다. + // + // 접는 것은 «자동 1회» 다. 사용자가 다시 펼칠 수 있고, 그 선택을 키보드가 다시 덮지 않는다. + val imeVisible = WindowInsets.isImeVisible + LaunchedEffect(imeVisible) { + if (imeVisible) questionExpanded = false + } + Scaffold( topBar = { // Figma 2372:22546 — 상단바: 뒤로가기 / 가운데 날짜 / 우측 저장 버튼 @@ -126,110 +150,148 @@ internal fun DailyQuestionWriteScreenContent( modifier = modifier, containerColor = Color.Transparent, ) { paddingValues -> - Column( + BoxWithConstraints( modifier = Modifier .padding(paddingValues) - .padding(horizontal = 20.dp), + // IME inset 을 본문 영역 전체에서 한 번에 뺀다. 종전에는 하단 툴바만 + // imePadding() 을 들어, 키보드 높이가 «툴바 아래 여백» 으로 화면 안에서 + // 빠지면서도 레이아웃 계산에서는 본문과 같은 자리를 두고 다퉜다. + // consumeWindowInsets 가 없으면 Scaffold 가 이미 넘긴 내비 바 몫을 + // imePadding() 이 한 번 더 더한다 (#1860 · #1891). + .consumeWindowInsets(paddingValues) + .imePadding() + .fillMaxSize(), ) { - // Figma 2372:22574 — 오늘의 추천 질문 배너 (접기/펼치기) - val questionLoadErrorText = uiState.questionLoadError?.asString() - val questionText = - when { - uiState.isQuestionLoading -> stringResource(R.string.mindrecord_daily_question_write_loading) - questionLoadErrorText != null -> questionLoadErrorText - uiState.questionContent.isNotEmpty() -> uiState.questionContent - else -> stringResource(R.string.mindrecord_daily_question_write_none) - } - DailyQuestionBanner( - questionText = questionText, - expanded = questionExpanded, - onToggle = { questionExpanded = !questionExpanded }, - dayNumber = uiState.questionDay, - ) - - // 이어쓰기 조회 실패는 저장 실패와 다른 시점·다른 원인이라 따로 알린다. 삼키면 - // 사용자가 빈 화면을 «임시저장 없음» 으로 읽고 저장해 기존 임시저장을 덮는다 (#1018). - val draftResumeErrorText = uiState.draftResumeError?.asString() - if (draftResumeErrorText != null) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = draftResumeErrorText, - color = AfternoteDesign.colors.error, - style = AfternoteDesign.typography.captionLargeR, - ) - // 실패 동안 저장이 막히므로 화면 안에서 풀 수단을 준다 — 없으면 쓴 답변을 들고 - // 갇힌다. 이 모듈의 재시도는 TextButton 으로 그린다 (#1019). - TextButton(onClick = onRetryResumeDraft) { - Text( - text = stringResource(R.string.mindrecord_error_retry), - style = AfternoteDesign.typography.bodySmallB, - color = AfternoteDesign.colors.gray9, + // 본문 칸은 «남는 것» 을 받는 마지막 자리라, 위쪽이 다 먹으면 0 이 된다. 위쪽이 + // 가져갈 수 있는 높이에 상한을 걸어 본문 자리를 먼저 떼어 둔다 — 상한을 넘는 + // 안내 문구는 그 안에서 스크롤된다 (#1860). + val headerMaxHeight = (maxHeight - MinimumWriteAreaHeight).coerceAtLeast(0.dp) + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + ) { + Column( + modifier = + Modifier + .heightIn(max = headerMaxHeight) + .verticalScroll(rememberScrollState()), + ) { + // Figma 2372:22574 — 오늘의 추천 질문 배너 (접기/펼치기) + val questionLoadErrorText = uiState.questionLoadError?.asString() + val questionText = + when { + uiState.isQuestionLoading -> stringResource(R.string.mindrecord_daily_question_write_loading) + questionLoadErrorText != null -> questionLoadErrorText + uiState.questionContent.isNotEmpty() -> uiState.questionContent + else -> stringResource(R.string.mindrecord_daily_question_write_none) + } + DailyQuestionBanner( + questionText = questionText, + expanded = questionExpanded, + onToggle = { questionExpanded = !questionExpanded }, + dayNumber = uiState.questionDay, ) - } - } - val errorMessage = (uiState.submitState as? SubmitState.Failed)?.message?.asString() - if (errorMessage != null) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = errorMessage, - color = AfternoteDesign.colors.error, - style = AfternoteDesign.typography.captionLargeR, - ) - } + // 이어쓰기 조회 실패는 저장 실패와 다른 시점·다른 원인이라 따로 알린다. 삼키면 + // 사용자가 빈 화면을 «임시저장 없음» 으로 읽고 저장해 기존 임시저장을 덮는다 (#1018). + val draftResumeErrorText = uiState.draftResumeError?.asString() + if (draftResumeErrorText != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = draftResumeErrorText, + color = AfternoteDesign.colors.error, + style = AfternoteDesign.typography.captionLargeR, + ) + // 실패 동안 저장이 막히므로 화면 안에서 풀 수단을 준다 — 없으면 쓴 답변을 들고 + // 갇힌다. 이 모듈의 재시도는 TextButton 으로 그린다 (#1019). + TextButton(onClick = onRetryResumeDraft) { + Text( + text = stringResource(R.string.mindrecord_error_retry), + style = AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + } + } - // 저장·업로드 진행 상태를 알린다 — 종전에는 액션만 잠기고 표시가 없어 - // 사용자가 무반응으로 인식했다 (#716). - val progressText = - when { - uiState.submitState is SubmitState.InProgress -> { - stringResource(R.string.mindrecord_write_saving) + val errorMessage = (uiState.submitState as? SubmitState.Failed)?.message?.asString() + if (errorMessage != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorMessage, + color = AfternoteDesign.colors.error, + style = AfternoteDesign.typography.captionLargeR, + ) } - uiState.isUploadingImage -> { - stringResource(R.string.mindrecord_write_uploading_image) + // 저장·업로드 진행 상태를 알린다 — 종전에는 액션만 잠기고 표시가 없어 + // 사용자가 무반응으로 인식했다 (#716). + val progressText = + when { + uiState.submitState is SubmitState.InProgress -> { + stringResource(R.string.mindrecord_write_saving) + } + + uiState.isUploadingImage -> { + stringResource(R.string.mindrecord_write_uploading_image) + } + + else -> { + null + } + } + if (progressText != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = progressText, + color = AfternoteDesign.colors.gray6, + style = AfternoteDesign.typography.captionLargeR, + ) } - else -> { - null + val uploadError = uiState.imageUploadError?.asString() + if (uploadError != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = uploadError, + color = AfternoteDesign.colors.error, + style = AfternoteDesign.typography.captionLargeR, + ) } } - if (progressText != null) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = progressText, - color = AfternoteDesign.colors.gray6, - style = AfternoteDesign.typography.captionLargeR, - ) - } - val uploadError = uiState.imageUploadError?.asString() - if (uploadError != null) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = uploadError, - color = AfternoteDesign.colors.error, - style = AfternoteDesign.typography.captionLargeR, + Spacer(modifier = Modifier.height(16.dp)) + // 프리필이 늦게 도착해도 WriteTextField 가 value 변경에 반응해 다시 시드한다 — + // key() 로 컴포넌트를 재생성하지 않는다 (#1018). + WriteTextField( + value = uiState.answer, + onValueChange = onAnswerChanged, + onSaveDraftClick = onSaveDraft, + onDraftCountClick = onDraftListClick, + draftCount = uiState.draftCount, + onImagePicked = onMediaPicked, + onMediaPicked = onMediaPicked, ) } - - Spacer(modifier = Modifier.height(16.dp)) - // 프리필이 늦게 도착해도 WriteTextField 가 value 변경에 반응해 다시 시드한다 — - // key() 로 컴포넌트를 재생성하지 않는다 (#1018). - WriteTextField( - value = uiState.answer, - onValueChange = onAnswerChanged, - onSaveDraftClick = onSaveDraft, - onDraftCountClick = onDraftListClick, - draftCount = uiState.draftCount, - onImagePicked = onMediaPicked, - onMediaPicked = onMediaPicked, - ) } } } +/** + * 본문 입력 칸과 하단 툴바가 함께 서기 위한 최소 높이 (#1860). + * + * 하단 툴바는 아이콘 버튼 행이라 글꼴 배율을 따라 자란다 — 배율 1.30 실측 120dp. 거기에 + * 에디터 안쪽 여백(위아래 16dp)과 본문 한 줄을 더했다. 좁은 화면에서 이만큼도 안 남으면 + * 질문 배너와 안내 문구 쪽이 줄고 넘치는 부분은 그 영역 안에서 스크롤된다 — 본문 칸이 + * 먼저 사라지던 종전 배분을 뒤집는다. + * + * 툴바가 더 자라면 이 값도 모자라진다. 그 순간을 눈이 아니라 테스트가 잡도록 + * `DailyQuestionWriteKeyboardSpaceTest` 가 실측 조합으로 본문 높이를 단언한다. + */ +private val MinimumWriteAreaHeight = 200.dp + @Preview(showBackground = true) @Composable private fun DailyQuestionWriteScreenPreview() { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryScreen.kt index 5bd494e3d9..8ace1a0e89 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryScreen.kt @@ -3,6 +3,7 @@ package com.afternote.feature.mindrecord.presentation.screen.sender import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -14,7 +15,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -33,6 +33,8 @@ import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.AfternoteSectionHeader import com.afternote.core.ui.asString +import com.afternote.core.ui.button.FAB.AfternoteFabContentBottomPadding +import com.afternote.core.ui.loading.LoadingBody import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.presentation.R @@ -67,7 +69,7 @@ fun DiaryScreen( * «수정하기» — 기록 ID 와 **보고 있는 달**. 달을 빼면 프리필이 이번 달 목록에서 그 * 기록을 찾다 실패하고, 빈 화면에서 저장하면 원본을 덮어쓸 수 있다 (#582 리뷰). */ - onEditClick: (Long, YearMonth) -> Unit = { _, _ -> }, + onEditClick: (Long, YearMonth) -> Unit, viewModel: DiaryListViewModel = hiltViewModel(), ) { // 갱신을 이 화면이 직접 건다. HomeScreen 이 VM 을 호이스팅해 대신 걸어 주면, 탭에 @@ -80,7 +82,7 @@ fun DiaryScreen( when (val state = uiState) { DiaryListUiState.Loading -> { - LoadingBox(modifier) + LoadingBody(modifier) } is DiaryListUiState.Error -> { @@ -134,11 +136,11 @@ internal fun DiaryListContent( monthDiaryCount: Int = 0, weeklyMoodEmoji: String? = null, /** 항목 탭 — 저장된 기록 본문을 여는 상세 화면 (#759). */ - onItemClick: (Long, YearMonth) -> Unit = { _, _ -> }, + onItemClick: (Long, YearMonth) -> Unit, /** «수정하기» — 기록 ID 와 이 화면이 보고 있는 달. 달은 여기서만 알 수 있다 (#582). */ - onEdit: (Long, YearMonth) -> Unit = { _, _ -> }, - onDelete: (Long) -> Unit = {}, - onYearMonthChanged: (YearMonth) -> Unit = {}, + onEdit: (Long, YearMonth) -> Unit, + onDelete: (Long) -> Unit, + onYearMonthChanged: (YearMonth) -> Unit, ) { // 기록이 없다고 조기 반환하지 않는다. 종전에는 빈 상태가 캘린더를 통째로 대체해 // 월 이동 버튼까지 사라졌고, 기록이 있는 달로 돌아갈 방법이 없었다 (#724). @@ -158,7 +160,12 @@ internal fun DiaryListContent( ?: currentMonthDiaries if (isListView) { - LazyColumn(modifier = modifier) { + LazyColumn( + modifier = modifier, + // FAB 이 콘텐츠 위에 뜨므로 목록이 스스로 그 자리를 비운다 — 안 그러면 마지막 + // 항목이 가려지고, 스크롤이 없을 만큼 항목이 적으면 볼 방법이 없다 (#1713). + contentPadding = PaddingValues(bottom = AfternoteFabContentBottomPadding), + ) { item { DailyCalendar( year = yearMonth.year, @@ -178,7 +185,8 @@ internal fun DiaryListContent( item { // core 정본을 쓴다. 종전에는 같은 구조를 인라인으로 다시 적어, 색이 // black.copy(0.4f)·M3 baseline divider 로 토큰에서 벗어나 있었다 (#634). - AfternoteSectionHeader(title = "DAILY ANSWER") + // 일기 목록인데 데일리질문 헤더가 붙어 있었고, 문자열도 코드 리터럴이었다 (#1712). + AfternoteSectionHeader(title = stringResource(R.string.mindrecord_diary_list_section_header)) Spacer(modifier = Modifier.height(10.dp)) } @@ -204,6 +212,9 @@ internal fun DiaryListContent( } else { // Figma 2671:16732 — 일기 카드 형: 리포트 카드 + 2열 masonry 그리드 LazyVerticalStaggeredGrid( + // FAB 이 콘텐츠 위에 뜨므로 목록이 스스로 그 자리를 비운다 — 안 그러면 마지막 + // 항목이 가려지고, 스크롤이 없을 만큼 항목이 적으면 볼 방법이 없다 (#1713). + contentPadding = PaddingValues(bottom = AfternoteFabContentBottomPadding), modifier = modifier, columns = StaggeredGridCells.Fixed(2), verticalItemSpacing = 8.dp, @@ -228,13 +239,6 @@ internal fun DiaryListContent( } } -@Composable -private fun LoadingBox(modifier: Modifier = Modifier) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } -} - @Preview(showBackground = true) @Composable private fun DiaryScreenPreviewTrue() { @@ -245,6 +249,10 @@ private fun DiaryScreenPreviewTrue() { diaries = emptyList(), // 프리뷰는 고정 월로 렌더한다 — YearMonth.now() 면 달이 바뀔 때마다 결과가 달라진다. yearMonth = PreviewYearMonth, + onDelete = {}, + onEdit = { _, _ -> }, + onItemClick = { _, _ -> }, + onYearMonthChanged = {}, ) } } @@ -258,6 +266,10 @@ private fun DiaryScreenPreviewFalse() { isListView = false, diaries = emptyList(), yearMonth = PreviewYearMonth, + onDelete = {}, + onEdit = { _, _ -> }, + onItemClick = { _, _ -> }, + onYearMonthChanged = {}, ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt index b3259d8c71..74a5475691 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt @@ -1,5 +1,6 @@ package com.afternote.feature.mindrecord.presentation.screen.sender +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -11,6 +12,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button @@ -35,12 +38,16 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.R import com.afternote.core.ui.asString +import com.afternote.core.ui.calendar.BottomSheetCalendar import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.core.ui.topbar.DetailTopBar @@ -58,13 +65,14 @@ private val WriteDateFormatter = DateTimeFormatter.ofPattern("yyyy년 M월 d일" @Composable fun DiaryWriteScreen( modifier: Modifier = Modifier, - onSubmitSuccess: () -> Unit = {}, - onBackClick: () -> Unit = {}, - onDraftListClick: () -> Unit = {}, + onSubmitSuccess: () -> Unit, + onBackClick: () -> Unit, + onDraftListClick: () -> Unit, viewModel: DiaryWriteViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var showReceiverSheet by remember { mutableStateOf(false) } + var showDateSheet by remember { mutableStateOf(false) } val currentOnSubmitSuccess by rememberUpdatedState(onSubmitSuccess) LaunchedEffect(uiState.submitState) { @@ -85,6 +93,7 @@ fun DiaryWriteScreen( onContentChanged = viewModel::onContentChanged, onMoodSelected = viewModel::onMoodSelected, onReceiverRowClick = { showReceiverSheet = true }, + onDateRowClick = { showDateSheet = true }, onMediaPicked = viewModel::uploadMedia, ) @@ -100,6 +109,20 @@ fun DiaryWriteScreen( onDismiss = { showReceiverSheet = false }, ) } + + if (showDateSheet) { + BottomSheetCalendar( + title = stringResource(MindRecordR.string.mindrecord_write_date_picker_title), + initialDate = uiState.date, + // 미래 날짜는 서버가 400(code 2101)으로 거절한다. 시트는 core:ui 공용이라 + // 달력에서 막지 못하므로 ViewModel 이 받아서 사유와 함께 되돌린다 (#1008). + onDateSelect = { + viewModel.onDateSelected(it) + showDateSheet = false + }, + onDismiss = { showDateSheet = false }, + ) + } } /** @@ -117,14 +140,15 @@ fun DiaryWriteScreen( internal fun DiaryWriteScreenContent( uiState: DiaryWriteUiState, modifier: Modifier = Modifier, - onBackClick: () -> Unit = {}, - onSubmit: () -> Unit = {}, - onSaveDraft: () -> Unit = {}, - onDraftListClick: () -> Unit = {}, - onTitleChanged: (String) -> Unit = {}, - onContentChanged: (String) -> Unit = {}, - onMoodSelected: (TodayMood) -> Unit = {}, - onReceiverRowClick: () -> Unit = {}, + onBackClick: () -> Unit, + onSubmit: () -> Unit, + onSaveDraft: () -> Unit, + onDraftListClick: () -> Unit, + onTitleChanged: (String) -> Unit, + onContentChanged: (String) -> Unit, + onMoodSelected: (TodayMood) -> Unit, + onReceiverRowClick: () -> Unit, + onDateRowClick: () -> Unit, onMediaPicked: suspend (String) -> String? = { null }, ) { Scaffold( @@ -184,7 +208,7 @@ internal fun DiaryWriteScreenContent( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onReceiverRowClick) + .clickable(role = Role.Button, onClick = onReceiverRowClick) .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -237,15 +261,15 @@ internal fun DiaryWriteScreenContent( } // Figma 2671:17922 — 날짜 선택 행 ("yyyy년 M월 d일" + 아래 화살표) - // **표시 전용이다.** 서버는 생성·수정 어느 쪽에서도 date 를 받지 않고 기록 날짜를 - // 요청 시각으로 정한다 (실측 2026-08-25: POST·PATCH 에 date 를 실어도 저장분은 - // 오늘, Swagger 스키마에도 필드가 없다). 고를 수 있게 두면 사용자는 고른 날짜로 - // 남았다고 믿는데 실제로는 오늘로 저장된다 — 고르지 못하게 해 거짓을 없앤다 (#1008). // - // BE 가 date 를 수용하면 이 자리에 피커를 되돌린다. 이어쓰기 프리필이 채워 주는 - // 원래 날짜를 보여 주는 역할은 그대로다. + // 서버가 2026-08-29 부터 생성·수정 양쪽에서 `date` 를 받는다 (Afternote-BE#244, PR #262). + // 그 전까지는 고른 날짜가 요청에 실리지 않아 «고를 수 있지만 반영되지 않는» 상태였고, + // #1121 이 그 동안 이 행을 표시 전용으로 잠가 뒀다. 계약이 왔으므로 시안대로 되돌린다 (#1008). Row( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .clickable(role = Role.Button, onClick = onDateRowClick), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -254,6 +278,12 @@ internal fun DiaryWriteScreenContent( style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray9, ) + Icon( + painter = painterResource(R.drawable.core_ui_arrowdown), + contentDescription = null, + tint = AfternoteDesign.colors.gray9, + modifier = Modifier.size(16.dp), + ) } HorizontalDivider() @@ -278,8 +308,11 @@ internal fun DiaryWriteScreenContent( }, ) + // 기분은 «셋 중 하나» 다. 정렬 아이콘과 같은 처방으로 selectableGroup 안의 + // Role.RadioButton 으로 읽히게 한다 — 종전에는 맨 clickable 이라 역할도 선택 + // 상태도 실리지 않았다 (#1179). Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().selectableGroup(), verticalAlignment = Alignment.CenterVertically, ) { val selectedMood = uiState.mood @@ -300,21 +333,28 @@ internal fun DiaryWriteScreenContent( ) Spacer(modifier = Modifier.width(12.dp)) - MoodChip("😊", selected = uiState.mood == TodayMood.HAPPY) { - onMoodSelected(TodayMood.HAPPY) - } + MoodChip( + mood = TodayMood.HAPPY, + selected = uiState.mood == TodayMood.HAPPY, + onClick = { onMoodSelected(TodayMood.HAPPY) }, + ) Spacer(modifier = Modifier.width(8.dp)) - MoodChip("😐", selected = uiState.mood == TodayMood.SOSO) { - onMoodSelected(TodayMood.SOSO) - } + MoodChip( + mood = TodayMood.SOSO, + selected = uiState.mood == TodayMood.SOSO, + onClick = { onMoodSelected(TodayMood.SOSO) }, + ) Spacer(modifier = Modifier.width(8.dp)) - MoodChip("😢", selected = uiState.mood == TodayMood.SAD) { - onMoodSelected(TodayMood.SAD) - } + MoodChip( + mood = TodayMood.SAD, + selected = uiState.mood == TodayMood.SAD, + onClick = { onMoodSelected(TodayMood.SAD) }, + ) } val errorMessage = (uiState.submitState as? SubmitState.Failed)?.message?.asString() + ?: uiState.dateError?.asString() ?: uiState.draftLoadError?.asString() if (errorMessage != null) { Text( @@ -376,12 +416,20 @@ internal fun DiaryWriteScreenContent( } } +/** + * 기분 하나를 고르는 칩. + * + * 이름은 이모지가 아니라 [TodayMood.label] 의 낱말이다 — 이모지만 실으면 스크린리더가 + * "우는 얼굴, 버튼" 처럼 그림을 묘사할 뿐 «슬픔을 고른다» 를 말하지 못한다 (#1179 리뷰). + * 선택 여부는 색으로만 구분되던 것을 [Modifier.selectable] 의 selected 로도 싣는다. + */ @Composable private fun MoodChip( - emoji: String, + mood: TodayMood, selected: Boolean, onClick: () -> Unit, ) { + val label = stringResource(mood.labelRes()) Box( modifier = Modifier @@ -393,13 +441,22 @@ private fun MoodChip( Color(0xFF000000).copy(0.05f) }, ).size(32.dp) - .clickable(onClick = onClick), + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .semantics { contentDescription = label }, contentAlignment = Alignment.Center, ) { - Text(text = emoji) + Text(text = mood.emoji()) } } +@StringRes +private fun TodayMood.labelRes(): Int = + when (this) { + TodayMood.HAPPY -> MindRecordR.string.mindrecord_diary_write_mood_happy + TodayMood.SOSO -> MindRecordR.string.mindrecord_diary_write_mood_soso + TodayMood.SAD -> MindRecordR.string.mindrecord_diary_write_mood_sad + } + private fun TodayMood.emoji(): String = when (this) { TodayMood.HAPPY -> "😊" diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DraftListScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DraftListScreen.kt index edbfc7491f..85529a4b45 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DraftListScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DraftListScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -44,6 +43,7 @@ import com.afternote.core.ui.button.AfternoteButton import com.afternote.core.ui.button.AfternoteButtonType import com.afternote.core.ui.button.AfternoteCircularCheckbox import com.afternote.core.ui.button.CheckboxState +import com.afternote.core.ui.loading.LoadingBody import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.core.ui.topbar.DetailTopBar @@ -69,11 +69,11 @@ private const val DELETED_TOAST_DURATION_MS = 2_000L @Composable fun DraftListScreen( modifier: Modifier = Modifier, - onBackClick: () -> Unit = {}, + onBackClick: () -> Unit, /** 일기 draft 탭 → 이어쓰기. (draftId, 해당 달 `yyyy-MM`) 전달. */ - onDiaryDraftClick: (Long, String) -> Unit = { _, _ -> }, + onDiaryDraftClick: (Long, String) -> Unit, /** 데일리질문 draft 탭 → 이어쓰기. 당일이 지난 draft 는 이 경로로만 열 수 있다 (#770). */ - onDailyQuestionDraftClick: (Long) -> Unit = {}, + onDailyQuestionDraftClick: (Long) -> Unit, viewModel: DraftListViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -162,9 +162,7 @@ fun DraftListScreen( Box(modifier = Modifier.padding(paddingValues)) { when (val state = uiState) { DraftListUiState.Loading -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } + LoadingBody() } is DraftListUiState.Error -> { @@ -314,7 +312,7 @@ private fun DraftRow( selectionMode: Boolean, selected: Boolean, onToggleSelect: () -> Unit, - onClick: () -> Unit = {}, + onClick: () -> Unit, ) { Row( modifier = @@ -446,6 +444,7 @@ private fun DraftRowPreview() { selectionMode = true, selected = true, onToggleSelect = {}, + onClick = {}, ) AfternoteButton( text = "전체 삭제", diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt index ca163dcd4d..0a7ce19cea 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt @@ -47,12 +47,12 @@ import com.afternote.feature.mindrecord.presentation.R as MindRecordR @Composable fun HomeScreen( modifier: Modifier = Modifier, - onWriteClick: (MindRecordCategoryUi) -> Unit = {}, + onWriteClick: (MindRecordCategoryUi) -> Unit, // 목록 항목 탭 → 상세(열람) 화면. (기록 ID, 일기 여부, 목록이 보고 있던 달) (#759). - onRecordClick: (Long, Boolean, YearMonth) -> Unit = { _, _, _ -> }, + onRecordClick: (Long, Boolean, YearMonth) -> Unit, // 목록의 «수정하기» → 프리필한 작성 화면 (#582). - onEditDailyQuestion: (Long) -> Unit = {}, - onEditDiary: (Long, YearMonth) -> Unit = { _, _ -> }, + onEditDailyQuestion: (Long) -> Unit, + onEditDiary: (Long, YearMonth) -> Unit, ) { // Figma 2757:16116 — 마음의 기록 탭은 데일리 질문 / 일기 / 주간리포트 3개 val categories = @@ -80,11 +80,6 @@ fun HomeScreen( // 오가면 아이콘은 그대로인데 표시가 뒤바뀐 것처럼 보였다 (#724). var dailyQuestionListView by rememberSaveable { mutableStateOf(true) } var diaryListView by rememberSaveable { mutableStateOf(true) } - val isListView = - when (selectedCategory) { - MindRecordCategoryUi.DailyQuestion -> dailyQuestionListView - else -> diaryListView - } // 탭 VM 은 **여기서 만들지 않는다.** 호이스팅하면 선택하지 않은 탭의 `init` 조회까지 // 진입 즉시 나간다 — 마음의 기록 첫 진입 한 번에 요청이 7건 나간 가장 큰 원인이었다. @@ -100,20 +95,38 @@ fun HomeScreen( TitleTopBar( title = stringResource(MindRecordR.string.mindrecord_home_title), actions = { - // 주간리포트는 리스트/캘린더 두 보기가 없다 — 스위치를 눌러도 화면이 - // 바뀌지 않으니 아예 노출하지 않는다. 컨트롤과 동작을 맞춘다 (#723). - // 같은 이유로 아래 FAB 도 이 탭에서 숨긴다. - if (selectedCategory != MindRecordCategoryUi.WeeklyReport) { + // 보기 상태를 탭마다 따로 기억하므로(#724) «어느 탭의 상태를 읽고 쓰는가» 가 + // 갈린다. 종전에는 읽기와 쓰기가 서로 다른 `when` 에 있었고 둘 다 `else` 로 + // 일기 상태를 가리켜, 주간리포트가 일기의 보기 상태를 빌려 쓰고 있었다 — + // 탭이 하나 늘면 그 빌림이 새 탭으로 조용히 이어진다. 한 `when` 으로 합쳐 + // 탭이 늘면 컴파일이 막게 한다 (#1765). + // + // 스위처 호출은 한 자리로 남긴다. 갈래마다 따로 부르면 탭을 옮길 때 컴포지션 + // 자리가 바뀌어 `animateDpAsState` 가 새로 시작되고, 표시가 미끄러지지 않고 튄다. + val viewMode = + when (selectedCategory) { + MindRecordCategoryUi.DailyQuestion -> { + ViewModeBinding(dailyQuestionListView) { dailyQuestionListView = it } + } + + MindRecordCategoryUi.Diary -> { + ViewModeBinding(diaryListView) { diaryListView = it } + } + + // 주간리포트는 리스트/캘린더 두 보기가 없다 — 스위치를 눌러도 화면이 + // 바뀌지 않으니 아예 노출하지 않는다. 컨트롤과 동작을 맞춘다 (#723). + // 같은 이유로 아래 FAB 도 이 탭에서 숨긴다. + MindRecordCategoryUi.WeeklyReport -> { + null + } + } + + if (viewMode != null) { ViewModeSwitcher( - isListView = isListView, + isListView = viewMode.isListView, image1 = R.drawable.core_ui_list, image2 = R.drawable.core_ui_calendar, - onViewChange = { listView -> - when (selectedCategory) { - MindRecordCategoryUi.DailyQuestion -> dailyQuestionListView = listView - else -> diaryListView = listView - } - }, + onViewChange = viewMode.onViewChange, ) } }, @@ -192,7 +205,7 @@ fun HomeScreen( ) } - else -> { + MindRecordCategoryUi.WeeklyReport -> { WeeklyReportScreen() } } @@ -205,6 +218,22 @@ fun HomeScreen( @Composable private fun HomeScreenPreview() { AfternoteTheme { - HomeScreen() + HomeScreen( + onEditDailyQuestion = {}, + onEditDiary = { _, _ -> }, + onRecordClick = { _, _, _ -> }, + onWriteClick = {}, + ) } } + +/** + * 보기 전환 스위처가 읽고 쓸 탭별 상태 한 쌍. + * + * 탭마다 상태를 따로 기억하지만(#724) 스위처 자체는 한 자리에서만 부르기 위한 묶음이다 — + * 갈래마다 스위처를 따로 부르면 탭 이동이 컴포지션 자리를 바꿔 표시 애니메이션이 새로 시작된다. + */ +private class ViewModeBinding( + val isListView: Boolean, + val onViewChange: (Boolean) -> Unit, +) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt index 37f0c5bb29..900884a39c 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -28,6 +27,7 @@ import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.AfternoteSectionHeader import com.afternote.core.ui.asString +import com.afternote.core.ui.loading.LoadingBody import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus @@ -58,7 +58,7 @@ fun WeeklyReportScreen( when (val state = uiState) { WeeklyReportUiState.Loading -> { - LoadingBox(modifier) + LoadingBody(modifier) } is WeeklyReportUiState.Error -> { @@ -150,7 +150,17 @@ private fun WeeklyReportContent( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { AfternoteSectionHeader(title = "HISTORY") state.dailyQuestions.forEach { dailyQuestion -> - DailyQuestionListCard(answer = dailyQuestion) + // 주간 리포트는 읽기 전용 요약이다 — 편집·삭제는 데일리질문 목록의 몫이라 + // 여기서는 «더보기» 를 그리지 않는다 (#1540). + // 주간 리포트는 **읽기 전용 요약**이다 — 편집·삭제도, 카드 탭도 없다. + // 셋 다 `null` 이라 클릭 semantics 자체가 안 붙는다: no-op 을 넘기면 + // 스크린리더가 「버튼」으로 읽는데 눌러도 아무 일이 없다 (#1540 리뷰). + DailyQuestionListCard( + answer = dailyQuestion, + onClick = null, + onEdit = null, + onDelete = null, + ) } } } @@ -188,7 +198,7 @@ private fun recordedSummary( * 이름을 먼저 찾고 기록일수는 그 뒤에서 찾는다. 이름에 "3일" 같은 문자열이 들어 있어도 * 강조 구간이 겹치지 않는다. 찾지 못하면 강조를 생략한다 — 문장은 그대로 보인다. */ -internal fun recordedSummaryHighlights( +private fun recordedSummaryHighlights( sentence: String, userName: String, daysText: String, @@ -207,13 +217,6 @@ internal fun recordedSummaryHighlights( return ranges } -@Composable -private fun LoadingBox(modifier: Modifier = Modifier) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } -} - @Composable private fun ErrorBox( message: String, @@ -314,7 +317,7 @@ private fun WeeklyReportErrorContent( * 전부가 아니라는 사실이 요약 문구보다 중요하다. */ @Composable -internal fun emotionCardDescription(state: WeeklyReportUiState.Success): String = +private fun emotionCardDescription(state: WeeklyReportUiState.Success): String = when (state.emotionAnalysisStatus) { EmotionAnalysisStatus.PENDING -> { if (state.emotionKeywords.isEmpty()) { @@ -338,7 +341,7 @@ internal fun emotionCardDescription(state: WeeklyReportUiState.Success): String } } - else -> { + EmotionAnalysisStatus.NOTHING_TO_ANALYZE, EmotionAnalysisStatus.COMPLETED -> { if (state.emotionKeywords.isNotEmpty()) { state.summaryText } else { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt new file mode 100644 index 0000000000..de867930af --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt @@ -0,0 +1,129 @@ +package com.afternote.feature.mindrecord.presentation.usecase + +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import javax.inject.Inject + +/** + * 임시저장 **일괄 삭제 정책** (#1693). + * + * 종전에는 `DraftListViewModel` 안에 있었다. UI 상태 갱신과 뒤섞여 있었지만 실제로 하는 일은 + * 두 저장소에 걸친 **파괴적 동작의 조율**이다 — 되돌릴 수 없고, 부분 실패가 정상 경로이며, + * 어느 항목이 실패했는지를 «지금도 남아 있는가» 로 다시 판정해야 한다. + * + * ### 왜 실패 목록을 그대로 돌려주지 않는가 + * + * 실패했지만 **재조회에서도 사라진 항목은 실패로 치지 않는다.** 이미 없는 것을 지우려다 404 가 + * 난 경우가 여기다 — 사용자가 원한 결과는 이뤄졌고, 남아 있지도 않은 항목을 「다시 선택해 + * 주세요」 라고 하면 「목록은 비었는데 1개 선택」 같은 상태가 된다. + * + * 그래서 삭제 결과만으로 끝내지 않고 **재조회 결과와 대조**한 뒤 판정한다. 이 대조가 이 + * 동작의 핵심이고, 삭제 호출 자체는 그 재료일 뿐이다. + * + * ### 계측을 여기서 하지 않는 이유 + * + * 실패 원인([Outcome.Failure.cause])을 값으로 실어 올리고 기록은 호출부에 맡긴다. 계측은 + * 「무엇을 어느 화면에서」 라는 UI 맥락과 함께 남아야 의미가 있다 (#964 · #1693). + */ +class DeleteMindRecordDraftsUseCase + @Inject + constructor( + private val diaryRepository: DiaryRepository, + private val dailyQuestionRepository: DailyQuestionRepository, + ) { + /** + * [targets] 를 **병렬로** 지우고, 실패 전건과 그중 아직 남아 있는 것을 나눠 돌려준다. + * + * @param targets 지울 대상. 비어 있으면 아무 것도 하지 않고 [Outcome.Deleted] 를 돌린다. + * @param survivorsAfterDelete 삭제 뒤 다시 조회한 목록의 키. 재조회 자체가 실패했으면 + * `null` — 그때는 무엇이 남았는지 알 수 없어 대조할 수 없으므로 [Outcome.Unknown] 이다. + * @param onFailures 실패 전건을 **재조회 전에** 한 번 넘긴다. 계측이 여기 걸린다. + * 반환값으로만 넘기면 재조회 도중 스코프가 취소될 때(사용자가 화면을 벗어날 때) + * 삭제는 이미 서버에 반영됐는데 실패는 한 건도 안 남는 창이 열린다 (#1693 리뷰). + * 보고 주체는 여전히 호출부다 — UseCase 는 계측을 알지 않는다. + */ + suspend fun delete( + targets: List, + survivorsAfterDelete: suspend () -> Set?, + onFailures: (List) -> Unit = {}, + ): Outcome { + if (targets.isEmpty()) return Outcome.Deleted(failures = emptyList(), remaining = emptyList()) + + // 항목별 결과를 항목과 짝지어 받는다 — 무엇이 실패했는지 알아야 다시 선택해 줄 수 있다. + val results = + coroutineScope { + targets.map { target -> async { target to deleteOne(target) } }.awaitAll() + } + val failures = + results.mapNotNull { (target, result) -> + result.exceptionOrNull()?.let { Failure(target = target, cause = it) } + } + + // 재조회 **전에** 넘긴다 — 그 사이 취소되면 실패가 통째로 사라진다. + onFailures(failures) + + val survivors = survivorsAfterDelete() ?: return Outcome.Unknown(failures = failures) + return Outcome.Deleted( + failures = failures, + remaining = failures.filter { it.target in survivors }, + ) + } + + private suspend fun deleteOne(target: Target): Result = + when (target.category) { + Category.Diary -> diaryRepository.delete(target.id) + Category.DailyQuestion -> dailyQuestionRepository.delete(target.id) + } + + /** + * 지울 대상의 **안정된 식별자**. + * + * 두 저장소의 id 는 서로 독립이라 숫자가 겹칠 수 있다 — 종류를 함께 들지 않으면 + * 일기 3번과 데일리질문 3번이 같은 항목으로 취급된다. + */ + data class Target( + val category: Category, + val id: Long, + ) + + /** 실제로 지울 수 있는 종류. 화면의 «전체» 필터 라벨은 여기 없다 — 항목이 아니라 필터다. */ + enum class Category { Diary, DailyQuestion } + + data class Failure( + val target: Target, + val cause: Throwable, + ) + + sealed interface Outcome { + /** + * 이번 삭제에서 난 **실패 전건**. 재조회 대조와 무관하게 그대로 싣는다. + * + * 계측이 보는 것은 이쪽이다. 「이미 사라진 항목의 404」처럼 화면에 안 보이는 실패일수록 + * 계측이 유일한 흔적이라, 대조로 걸러 내면 콘솔에서도 사라진다 (#964·#1693). + */ + val failures: List + + /** + * 삭제와 재조회가 모두 끝났다. + * + * 화면이 쓰는 것은 [remaining] 이다 — 비어 있으면 사용자가 원한 결과가 전부 이뤄졌다. + * [failures] 와 갈리는 이유는 위 KDoc 에 있다. + */ + data class Deleted( + override val failures: List, + /** [failures] 중 재조회 목록에 **아직 남아 있는** 것. 다시 선택해 줄 대상이다. */ + val remaining: List, + ) : Outcome + + /** + * 재조회가 실패해 무엇이 남았는지 모른다. 목록을 그릴 수 없으므로 화면은 오류로 간다. + * 그래도 [failures] 는 계측을 위해 그대로 싣는다. + */ + data class Unknown( + override val failures: List, + ) : Outcome + } + } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/GetMemorySpaceUseCase.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/GetMemorySpaceUseCase.kt new file mode 100644 index 0000000000..413a10ee5b --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/GetMemorySpaceUseCase.kt @@ -0,0 +1,128 @@ +package com.afternote.feature.mindrecord.presentation.usecase + +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.model.Diary +import com.afternote.feature.mindrecord.domain.model.MindRecordType +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.presentation.mapper.toUi +import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryRecord +import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryRecordId +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import java.time.YearMonth +import javax.inject.Inject + +/** + * 추억 공간의 **집계 정본** (#1693). + * + * 서버에 "추억" 전용 계약이 없어 사용자의 실제 기록을 모아 카드로 만든다. 그 조립에 붙는 + * 정책이 넷인데 종전에는 전부 `MemorySpaceViewModel` 안에 있었다. + * + * 1. **비대칭 조회 범위** — 일기는 `yearMonth` 가 필수라 최근 [DIARY_MONTH_WINDOW] 개월만 + * 달마다 따로 조회하고, 데일리질문은 `date` 를 생략해 전체 기간을 한 번에 받는다. + * 카드가 몇 장뿐이라 그보다 과거까지 훑으면 화면에 쓰지도 않을 요청만 늘어난다. + * 2. **부분 실패** — 카드가 한 장이라도 채워지면 실패를 삼킨다. 장식 화면을 한 출처의 + * 실패로 통째 비우면 잃는 것이 더 크다. 반면 **합친 결과가 비었는데 실패한 출처가 + * 있으면** 그 실패를 올린다 — 실패한 쪽에 기록이 있었을 수 있어 0건으로 확정하면 + * 「아직 담긴 기록이 없어요」 로 오인하고 재시도 경로까지 사라진다. + * 3. **초안 제외** — `GET /diary` 는 `draftOnly` 를 생략하면 그 달 전체(임시저장 포함)를 + * 내려주므로 클라가 거른다. 데일리질문은 생략 시 서버가 제출 완료만 주므로 필요 없다. + * 두 API 의 기본값이 다르다. + * 4. **정렬·상한** — 두 출처를 최신순 한 줄로 세워 [limit] 장까지 남긴다. + * + * 이 집계를 소비하는 프로덕션 코드는 같은 모듈의 [MemorySpaceViewModel] 뿐이라 `internal` + * 이다 (docs/convention/production-visibility.md). 화면 왕복 회귀도 모듈 밖 계측이 아니라 + * 같은 모듈의 Robolectric Compose 테스트(`MemorySpaceDetailOverlayTest`)가 잇는다. + */ +internal class GetMemorySpaceUseCase + @Inject + constructor( + private val diaryRepository: DiaryRepository, + private val dailyQuestionRepository: DailyQuestionRepository, + ) { + /** + * @param currentMonth 일기 조회의 기준 달. 종전에는 [YearMonth.now] 를 안에서 직접 + * 불러 **어느 달을 요청했는가를 벽시계 없이 단언할 수 없었다** — 기대값을 만들려고 + * 테스트가 같은 `now()` 를 부르면 구현과 기대가 같은 실수를 공유하고, 달이 바뀌는 + * 자정에는 판정 자체가 흔들린다. 기본값이 있어 호출부는 종전 그대로다 + * ([LoadMindRecordDraftsUseCase] 와 같은 이유·같은 형태). + * @param limit 카드 배치(`MemorySpaceCardField`)가 자리를 잡는 장수. + */ + suspend fun invoke( + currentMonth: YearMonth = YearMonth.now(), + limit: Int = MEMORY_CARD_LIMIT, + ): Result> = + runCatchingCancellable { + coroutineScope { + val diaryDeferred = + recentMonths(currentMonth).map { month -> + async { diaryRepository.getList(yearMonth = month.toString()) } + } + val questionDeferred = async { dailyQuestionRepository.getList() } + + val diaryResults = diaryDeferred.awaitAll() + val questionResult = questionDeferred.await() + + val diaries = + diaryResults + .mapNotNull { it.getOrNull() } + .flatMap { it.diaries } + .filterNot { it.isDraft } + val questions = questionResult.getOrNull().orEmpty() + + val records = + (diaries.mapNotNull { it.toMemoryRecord() } + questions.mapNotNull { it.toMemoryRecord() }) + .sortedByDescending { it.date } + .take(limit) + + if (records.isEmpty()) { + val failure = + diaryResults.firstNotNullOfOrNull { it.exceptionOrNull() } + ?: questionResult.exceptionOrNull() + if (failure != null) throw failure + } + + records + } + } + + private fun recentMonths(currentMonth: YearMonth): List = + (0 until DIARY_MONTH_WINDOW).map { currentMonth.minusMonths(it.toLong()) } + + /** 날짜를 못 정한 일기는 정렬 키가 없어 카드로 만들지 않는다 ([toUi] 가 null 을 돌린다). */ + private fun Diary.toMemoryRecord(): MemoryRecord? { + val ui = toUi() ?: return null + return MemoryRecord( + id = MemoryRecordId(type = MindRecordType.DIARY, value = ui.id), + date = ui.date, + title = ui.title, + content = ui.content, + imageUrl = ui.imageUrl, + emotion = ui.emotion, + ) + } + + /** 날짜를 못 정한 데일리질문도 같다 — 정렬 키가 없어 카드로 만들지 않는다 (#751). */ + private fun DailyQuestion.toMemoryRecord(): MemoryRecord? { + val ui = toUi() ?: return null + return MemoryRecord( + id = MemoryRecordId(type = MindRecordType.DAILY_QUESTION, value = ui.id), + date = ui.date, + title = ui.title, + content = ui.content, + imageUrl = ui.imageUrl, + // 데일리질문에는 오늘의 기분이 없다 — 시안에도 그 자리가 없다. + emotion = null, + ) + } + + private companion object { + /** 카드 배치(`MemorySpaceCardField`)가 4장까지만 자리를 잡는다. */ + const val MEMORY_CARD_LIMIT = 4 + + const val DIARY_MONTH_WINDOW = 3 + } + } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordDraftLoader.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/LoadMindRecordDraftsUseCase.kt similarity index 70% rename from feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordDraftLoader.kt rename to feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/LoadMindRecordDraftsUseCase.kt index 2ec06fe2cb..78f38cf33e 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordDraftLoader.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/LoadMindRecordDraftsUseCase.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.mindrecord.presentation.viewmodel +package com.afternote.feature.mindrecord.presentation.usecase import com.afternote.core.common.result.runCatchingCancellable import com.afternote.feature.mindrecord.domain.model.DailyQuestion @@ -11,16 +11,16 @@ import java.time.YearMonth import javax.inject.Inject /** - * 임시저장의 **조회 범위 정본**. + * 임시저장의 **조회 범위 정본** (#769 · #1693). * * 임시저장 목록 화면과 작성 툴바의 카운트가 같은 범위를 봐야 한다 — 목록에 5건이 뜨는데 * 툴바가 3을 보이면 어느 쪽이 맞는지 알 수 없다. 두 화면이 각자 조회하면 범위가 갈라지므로 - * 여기 한 곳에만 둔다 (#769). + * 여기 한 곳에만 둔다. * * 일기는 `yearMonth` 가 필수라 **이번 달**만, 데일리질문은 `date` 를 생략해 **전체 기간**을 * 받는다. 서버 계약이 그렇게 갈려 있어 범위가 비대칭인 것이지 의도한 구분은 아니다. */ -class MindRecordDraftLoader +class LoadMindRecordDraftsUseCase @Inject constructor( private val diaryRepository: DiaryRepository, @@ -31,14 +31,19 @@ class MindRecordDraftLoader * * 한쪽이라도 조회에 실패하면 실패로 올린다 — 빈 목록으로 흡수하면 "실패" 와 "0건" 이 * 같은 화면이 되어 복구 수단이 사라진다. + * + * @param currentMonth 일기를 조회할 달. 종전에는 [YearMonth.now] 를 이 안에서 직접 + * 불렀는데, 그러면 **어느 달을 요청했는가를 벽시계 없이 단언할 수 없다** — 기대값을 + * 만들려고 테스트가 같은 `now()` 를 다시 부르면 구현과 기대가 같은 실수를 공유하고, + * 달이 바뀌는 자정에는 판정 자체가 흔들린다. 기본값이 있어 호출부는 종전 그대로다 (#1693). */ - suspend fun load(): Result = + suspend fun load(currentMonth: YearMonth = YearMonth.now()): Result = runCatchingCancellable { coroutineScope { val diariesDeferred = async { diaryRepository - .getList(yearMonth = YearMonth.now().toString(), draftOnly = true) + .getList(yearMonth = currentMonth.toString(), draftOnly = true) .getOrThrow() .diaries } @@ -54,7 +59,7 @@ class MindRecordDraftLoader } /** 툴바 카운트용 — 개수만 필요할 때. */ - suspend fun count(): Result = load().map { it.total } + suspend fun count(currentMonth: YearMonth = YearMonth.now()): Result = load(currentMonth).map { it.total } data class Drafts( val diaries: List, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCase.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCase.kt new file mode 100644 index 0000000000..bd15ba00ec --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCase.kt @@ -0,0 +1,118 @@ +package com.afternote.feature.mindrecord.presentation.usecase + +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus +import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +/** + * 주간 리포트 **조회·제한 폴링 정책** (#725 · #1693). + * + * 감정 분석은 저장 직후 비동기로 돌고 **완료를 알리는 채널이 없다.** 탭에 머무는 동안 결과가 + * 반영되려면 제한된 재조회가 필요하다. 종전에는 이 규칙이 `WeeklyReportViewModel` 안에서 UI + * 상태 갱신과 뒤섞여 있어, 「몇 번·언제 조회하는가」를 화면을 띄우지 않고는 검증할 수 없었다. + * + * ### 무한 폴링을 하지 않는다 + * + * [POLL_ATTEMPTS] 회만 시도하고 그 뒤에는 화면 이탈·복귀나 사용자의 재시도에 맡긴다. + * 그리고 **이번 주에만** 돈다 — 폴링의 근거가 「저장 직후 비동기 분석」이라 지난 주를 보는 + * 동안 8초마다 조회가 나갈 이유가 없다. + * + * ### 한 번 실패했다고 남은 시도를 버리지 않는다 + * + * `PENDING` 화면에는 재시도 수단이 없어(카드는 `FAILED` 전용) 여기서 포기하면 화면에 머무는 + * 동안 복구할 길이 사라진다. 그 시도만 소모하고 다음 간격을 기다린다. + * + * ### 취소 + * + * 사용자가 다른 주를 고르면 호출부가 수집을 취소하고, 그 취소가 대기까지 함께 끊는다. + * 취소를 실패로 바꾸지 않는다 — 화면을 떠난 것뿐인데 오류가 뜨면 안 된다. + */ +class ObserveWeeklyReportUseCase + @Inject + constructor( + private val repository: WeeklyReportRepository, + private val userRepository: MyProfileRepository, + ) { + /** + * [monday] 주의 리포트를 내보낸다. + * + * 첫 방출은 리포트·프로필 **병렬** 조회 결과다. 실패하면 그것으로 끝난다 — 조회가 + * 안 되는데 폴링할 이유가 없다. + * + * 성공했고 이번 주이고 분석이 `PENDING` 이면, 완료되거나 [POLL_ATTEMPTS] 를 소진할 + * 때까지 갱신된 리포트를 이어서 내보낸다. + * + * @param today 「이번 주」 판정 기준. 종전에는 [LocalDate.now] 를 구현이 직접 불러 + * **지난 주에는 폴링하지 않는다는 규칙을 벽시계 없이 검증할 수 없었다** (#1693). + */ + fun observe( + monday: LocalDate, + today: LocalDate = LocalDate.now(), + ): Flow> = + flow { + val first = + runCatchingCancellable { + coroutineScope { + val reportDeferred = async { repository.getWeeklyReport(date = monday.apiDate()).getOrThrow() } + val profileDeferred = async { userRepository.getMyProfile() } + Snapshot(report = reportDeferred.await(), profileName = profileDeferred.await().name) + } + } + emit(first) + + val loaded = first.getOrNull() ?: return@flow + if (monday != today.with(DayOfWeek.MONDAY)) return@flow + + var status = loaded.report.analysisStatus + repeat(POLL_ATTEMPTS) { + if (status != EmotionAnalysisStatus.PENDING) return@flow + delay(POLL_INTERVAL_MILLIS) + val report = repository.getWeeklyReport(date = monday.apiDate()).getOrNull() + currentCoroutineContext().ensureActive() + if (report == null) return@repeat + status = report.analysisStatus + emit(Result.success(loaded.copy(report = report))) + } + } + + private fun LocalDate.apiDate(): String = format(API_DATE_FORMATTER) + + /** + * 한 주의 화면 재료. 이름은 첫 조회에서만 오고 폴링은 리포트만 갱신한다 — + * 프로필은 8초마다 다시 받을 이유가 없다. + */ + data class Snapshot( + val report: WeeklyReport, + val profileName: String, + ) + + private companion object { + /** 화면에 머무는 동안만 시도하는 횟수. 무한 폴링을 막는 상한이다. */ + const val POLL_ATTEMPTS = 8 + const val POLL_INTERVAL_MILLIS = 8_000L + + private val API_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + } + } + +/** + * 서버가 진행 상태를 주지 않았으면 «모른다» — 0 건으로 확정하지 않는다 (#725). + * + * 폴링을 계속할지 정하는 값이라 정책과 같은 자리에 둔다. 화면(`WeeklyReportViewModel`)도 + * 카드에 그대로 실어 보여 주므로 모듈 안에서 공유한다 (#1693). + */ +internal val WeeklyReport.analysisStatus: EmotionAnalysisStatus + get() = emotionAnalysis?.status ?: EmotionAnalysisStatus.UNKNOWN diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt index efa8e858ee..101b2bdcd3 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt @@ -45,7 +45,8 @@ fun String.firstHtmlImageSrcOrNull(): String? = private val HTML_IMG_SRC = Regex("""]*?\bsrc\s*=\s*["']([^"']*)["']""", RegexOption.IGNORE_CASE) /** - * 업로드 직후 받은 파일 URL 을 **서버가 본문에서 기대하는 형태**(fileKey) 로 바꾼다. + * 제출 직전, **이번 작성 중 업로드한** 이미지의 `src` 를 서버가 기대하는 fileKey 로 바꾼다 + * (#549 · #1016 · #1125). * * `POST /files/presigned-url` 은 `fileKey`(`mindrecords/staging/13/uuid.png`) 와 * `fileUrl`(그 앞에 CDN 호스트가 붙은 전체 URL) 을 함께 준다. 저장 시 서버는 본문의 @@ -58,30 +59,51 @@ private val HTML_IMG_SRC = Regex("""]*?\bsrc\s*=\s*["']([^"']*)["']""", * 저장된 값: https://cdn.example.net/https://cdn.example.net/mindrecords/permanent/13/a.png → 403 * ``` * - * 스킴과 호스트만 떼면 fileKey 가 되므로 경로 규칙(`mindrecords/staging/...`)을 코드에 - * 박지 않는다 — 서버가 디렉터리 구조를 바꿔도 따라간다. - * - * 다만 이 방식은 `fileUrl == "<스킴>://<호스트>/" + fileKey` 를 가정한다. **권위 있는 - * 출처는 presigned 응답의 `fileKey` 인데 `PhotoUploadRepository.upload()` 가 `fileUrl` 만 - * 돌려주며 버린다** — CDN 이 경로 프리픽스나 쿼리스트링을 붙이면 조용히 틀린 키가 된다. - * 반환을 넓히는 것은 `core:data` 범위라 별건으로 둔다 (#1017). - */ -fun String.toUploadedFileKey(): String = - // 스킴이 없으면 substringAfter("://") 가 원문을 그대로 돌려주고, 이어지는 - // substringAfter('/') 가 첫 경로 세그먼트를 잘라먹는다 — 조용히 틀린 키가 나간다. - if (contains("://")) substringAfter("://").substringAfter('/') else this - -/** - * 제출 직전, **이번 작성 중 업로드한** 이미지의 `src` 만 fileKey 로 바꾼다 (#549·#1016). + * **서버가 준 키를 그대로 쓴다.** 종전에는 URL 에서 스킴·호스트를 떼어 키를 역산했는데, + * 그건 `upload()` 가 `fileUrl` 만 돌려주던 시절의 우회였다. `fileUrl == "<스킴>://<호스트>/" + fileKey` + * 를 가정하므로 CDN 이 경로 프리픽스를 붙이거나 쿼리스트링이 붙으면 **조용히 틀린 키가 나가고**, + * 증상은 #549 가 고친 「저장은 됐는데 이미지가 안 뜬다」와 같다. #1017 이 `upload()` 반환을 + * `UploadedFile(fileUrl, fileKey)` 로 넓혀 그 가정이 필요 없어졌다 (#1125). * * 이미 저장돼 본문에 들어 있는 영구 URL 은 건드리지 않는다 — 서버가 그대로 통과시키고, * 키로 바꾸면 이미 옮겨진 파일을 다시 옮기려다 실패한다. 그래서 경로 패턴으로 훑지 않고 * 이번에 받은 URL 만 정확히 치환한다. * * 데일리질문과 일기가 같은 규칙을 쓴다 — 한쪽에만 있어서 일기 본문 이미지가 깨졌다 (#1016). + * + * @param uploadedFileKeysByUrl 이번 작성에서 업로드한 `fileUrl` → 서버가 준 `fileKey` 대응. + */ +fun String.toWireContent(uploadedFileKeysByUrl: Map): String { + if (uploadedFileKeysByUrl.isEmpty()) return this + return MEDIA_REFERENCE.replace(this) { match -> + val (attribute, quote, rawValue) = match.destructured + // **속성값을 되돌린 뒤 맞춘다.** 리치 에디터가 직렬화하면서 `&` 를 `&` 로 바꾸므로, + // 원문 URL 로 그대로 찾으면 쿼리가 둘 이상인 주소(`?x=1&y=2`)를 놓친다 — 그러면 전체 URL 이 + // 그대로 서버로 나가 #549 의 이중 호스트·403 이 재발한다 (#1125 리뷰, 실측 확인). + val fileKey = uploadedFileKeysByUrl[rawValue.unescapeHtmlAttribute()] + if (fileKey == null) match.value else "$attribute=$quote$fileKey$quote" + } +} + +/** `src`·`href` 속성 하나. 따옴표는 " 와 ' 둘 다 받는다. */ +private val MEDIA_REFERENCE = Regex("""\b(src|href)\s*=\s*(["'])(.*?)\2""", RegexOption.IGNORE_CASE) + +/** + * 속성값에 들어간 엔티티를 원문으로 되돌린다. + * + * [escapeHtml] 의 정확한 역은 아니다 — 그쪽은 `'` 를 일부러 건드리지 않는데(richeditor 가 + * `'` 를 `&#39;` 로 굳혀 버려서다) 여기서는 `'` 도 되돌린다. 리치 에디터가 내는 + * 형태를 받아내는 쪽이라 넓게 잡아 둔다. + * + * `&` 를 **마지막에** 되돌린다. 먼저 되돌리면 `&lt;` 가 `<` 를 거쳐 `<` 로 두 번 + * 풀린다. */ -fun String.toWireContent(uploadedImageUrls: Set): String = - uploadedImageUrls.fold(this) { content, url -> content.replace(url, url.toUploadedFileKey()) } +private fun String.unescapeHtmlAttribute(): String = + replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") /** * 태그만 있고 보이는 글자가 없으면 비었다고 본다. diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt index 8be88d2955..57bba7f522 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt @@ -2,6 +2,7 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.DailyQuestion import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion @@ -9,6 +10,8 @@ import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepositor import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.mapper.toUi +import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage +import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive @@ -27,6 +30,7 @@ class DailyQuestionListViewModel constructor( private val repository: DailyQuestionRepository, private val changeTracker: MindRecordChangeTracker, + private val errorReporter: ErrorReporter, ) : ViewModel() { private val internalState = MutableStateFlow(InternalState()) private var loadJob: Job? = null @@ -82,7 +86,9 @@ class DailyQuestionListViewModel load() } // 실패를 무시하면 항목이 그대로 남은 채 아무 안내도 없어 고장처럼 보인다 (#716). - .onFailure { + .onFailure { throwable -> + // 되돌릴 수 없는 동작이라 콘솔에도 남긴다 (#964). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.RECORD_DELETE, throwable) internalState.update { it.copy(deleteError = UiText.Resource(R.string.mindrecord_error_delete_failed)) } @@ -126,6 +132,22 @@ class DailyQuestionListViewModel if (listResult.isFailure) { val message = UiText.Resource(R.string.mindrecord_error_daily_question_list_failed) + // 오류 화면을 마주한 경우에만 올린다 — 재진입 갱신 실패는 보고 있던 + // 목록을 그대로 두므로 승격하지 않는다 (#964). + // + // 판정을 update 람다 **밖에서** 한다. `MutableStateFlow.update` 는 CAS + // 재시도 때 람다를 다시 평가하므로 안에 두면 이중 보고가 될 자리다. + // 같은 파일 delete() 와 일기 쪽 DiaryListViewModel 도 이 모양이다 (#964 리뷰). + val showsErrorScreen = + !(keepsStateOnFailure && internalState.value.loadPhase is LoadPhase.Loaded) + if (showsErrorScreen) { + listResult.exceptionOrNull()?.let { throwable -> + errorReporter.recordMindRecordFailure( + MindRecordFailureStage.RECORD_LIST_LOAD, + throwable, + ) + } + } internalState.update { current -> if (keepsStateOnFailure && current.loadPhase is LoadPhase.Loaded) { current diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt index b71c2e4d33..36833d46f5 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt @@ -25,8 +25,17 @@ data class DailyQuestionWriteUiState( /** * 본문이 서버에서 채워졌는지. 리치 에디터는 [answer] 를 **초기 시드로만** 읽으므로, * 비동기 프리필이 끝난 뒤 에디터를 다시 만들어야 내용이 보인다 (#582). + * + * 프리필이 **성공했을 때만** 선다 — 수정 진입의 저장 잠금도 이 값을 본다 (#2028). */ val contentLoaded: Boolean = false, + /** + * 기존 답변을 고치러 들어왔는지 (#2028). + * + * 저장 잠금과 실패 복구가 둘 다 이 값을 본다 — 복구가 진입을 모르면 「대상 레코드도 없고 + * 오늘 질문도 없다」를 신규 작성으로 읽어 **오늘 질문으로 대상을 갈아치운다.** + */ + val isEditingExistingAnswer: Boolean = false, val isQuestionLoading: Boolean = true, val questionLoadError: UiText? = null, /** 이어쓸 임시저장 본문을 불러오는 중 (#923). */ @@ -40,13 +49,23 @@ data class DailyQuestionWriteUiState( */ val draftResumeError: UiText? = null, val submitState: SubmitState = SubmitState.Idle, - /** 이미지 업로드 진행 중 — 끝나기 전에 저장하면 이미지 없이 기록이 먼저 올라간다 (#716). */ - val isUploadingImage: Boolean = false, + /** + * 아직 끝나지 않은 이미지 업로드 수 (#2029 · #2030). + * + * Boolean 하나였을 때는 첨부를 잇따라 고르면 **먼저 끝난 하나가 잠금을 통째로 풀었다** — + * 아직 올라가는 중인 첨부가 있는데도 저장이 열려, 그 이미지가 빠진 본문이 먼저 나갔다. + * 성공·실패·취소 어느 쪽으로 끝나든 자기 몫만 내려놓도록 수로 센다. + */ + val uploadingImageCount: Int = 0, /** 이미지 업로드 실패 안내. 조용히 null 로 흡수하지 않는다 (#716). */ val imageUploadError: UiText? = null, /** 툴바 "임시저장 N" 표시값. `null` 은 아직 모름(조회 중·실패) (#769). */ val draftCount: Int? = null, ) { + /** 끝나지 않은 업로드가 하나라도 있는지. 화면과 저장 잠금이 함께 본다. */ + val isUploadingImage: Boolean + get() = uploadingImageCount > 0 + /** * `questionId` 유무는 여기서 보지 않는다. 조건에 넣으면 오늘 질문 조회가 실패했을 때 * 저장 버튼이 그냥 죽어 있어 원인을 알 수 없다 (#565). 대신 [DailyQuestionWriteViewModel.submit] @@ -74,8 +93,12 @@ data class DailyQuestionWriteUiState( // 빈 에디터(`

`)도 isNotBlank() 라 버튼이 살아 있고, draftId 가 null 인 // 채 POST 로 나가 서버 upsert 가 기존 임시저장을 빈 본문으로 덮는다 — // 이 PR 이 막으려던 바로 그 유실이다 (#1018 리뷰). 일기 화면의 - // `!(isEditingDraft && draftLoadError != null)` 과 같은 성질이다. - draftResumeError == null + // `!(isEditingExistingRecord && !draftLoaded)` 과 같은 성질이다. + draftResumeError == null && + // 수정 진입은 대상을 **읽은 뒤에만** 저장을 연다. 못 읽은 채 저장하면 draftId 가 + // null 이라 PATCH 가 아니라 POST 로 나가고, 원래 답변이 아닌 다른 질문에 글이 + // 남는다 (#2028). 일기 화면과 같은 「실패했는가」가 아니라 「읽었는가」 판정이다. + !(isEditingExistingAnswer && !contentLoaded) } sealed interface SubmitState { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt index bf60eb3420..29f3c88b8a 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt @@ -15,6 +15,7 @@ import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import com.afternote.feature.mindrecord.presentation.util.isHtmlBlank import com.afternote.feature.mindrecord.presentation.util.toWireContent import dagger.hilt.android.lifecycle.HiltViewModel @@ -33,7 +34,7 @@ class DailyQuestionWriteViewModel savedStateHandle: SavedStateHandle, private val repository: DailyQuestionRepository, private val photoUploadRepository: PhotoUploadRepository, - private val draftLoader: MindRecordDraftLoader, + private val draftLoader: LoadMindRecordDraftsUseCase, private val errorReporter: ErrorReporter, ) : ViewModel() { private val _uiState = MutableStateFlow(DailyQuestionWriteUiState()) @@ -48,6 +49,7 @@ class DailyQuestionWriteViewModel // 수정 진입이면 대상 레코드를 프리필하고, 신규면 오늘 질문을 부른다 (#582). // 임시저장은 draftOnly=true 로만 내려오므로 어느 목록을 볼지 isDraft 로 가른다 (#770). if (editingAnswerId != null) { + _uiState.update { it.copy(isEditingExistingAnswer = true) } loadAnswer(editingAnswerId, route.isDraft) } else { loadTodayQuestion() @@ -75,23 +77,38 @@ class DailyQuestionWriteViewModel .mapCatching { list -> list.first { it.dailyQuestionId == answerId } } .onSuccess { answer -> _uiState.update { + // **사용자가 이미 쓴 본문은 덮지 않는다** (#2031). 입력창은 프리필을 + // 기다리는 동안에도 쓸 수 있어서, 늦게 도착한 원본을 무조건 실으면 + // 방금 친 글이 서버 값으로 되돌아간다. 오늘 초안 이어쓰기(`resumeDraft`)가 + // 이미 같은 규칙을 쓰고 있었고, 대상 ID 로 들어온 수정만 빠져 있었다. + // + // 질문 문구와 대상 ID 는 사용자 입력이 아니라 서버가 정하는 값이라 + // 언제나 싣는다 — 그래야 저장이 올바른 레코드로 나간다. it.copy( draftId = answer.dailyQuestionId, questionContent = answer.title, - answer = answer.content, + answer = if (it.answer.isHtmlBlank()) answer.content else it.answer, isQuestionLoading = false, + // 「프리필이 도착했다」는 사실은 무엇을 실었는지와 무관하게 선다 — + // 저장 잠금(#2028)이 이 값을 보므로, 입력해 둔 사용자가 저장하지 + // 못하는 상태로 굳으면 안 된다. contentLoaded = true, ) } }.onFailure { e -> + // 여기서 실패하면 화면이 오류 문구만 남고 쓸 수가 없다 — 사용자가 + // 「질문이 안 뜬다」로 마주하는 자리라 올린다 (#964). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.DAILY_QUESTION_LOAD, e) _uiState.update { it.copy( isQuestionLoading = false, + // 예외 문구를 화면에 싣지 않는다 — 직렬화 예외·서버 원문·영문 + // 스택 용어가 그대로 사용자에게 간다. 종전의 «값이 있으면 값, + // 없으면 fallback» 변형은 `e.message` 가 있으면 아래 안내 + // 문자열을 아예 쓰지 않았다. 원문은 바로 위 + // `recordMindRecordFailure` 로 이미 남는다 (#1339 선례, #1882). questionLoadError = - UiText.DynamicOrResource( - value = e.message, - fallbackResId = R.string.mindrecord_error_daily_question_today_failed, - ), + UiText.Resource(R.string.mindrecord_error_daily_question_today_failed), ) } } @@ -123,6 +140,8 @@ class DailyQuestionWriteViewModel } if (today.isDraft) resumeDraft() }.onFailure { e -> + // 신규 진입에서 오늘 질문을 못 받으면 작성 자체가 막힌다 (#964). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.DAILY_QUESTION_LOAD, e) _uiState.update { it.copy( isQuestionLoading = false, @@ -200,8 +219,11 @@ class DailyQuestionWriteViewModel } } - /** 이번 작성 중 업로드한 이미지의 원본 URL. 제출 시 fileKey 로 바꿀 대상이다 (#549). */ - private val uploadedImageUrls = mutableSetOf() + // 이번 작성에서 업로드한 `fileUrl` → 서버가 준 `fileKey`. 키를 URL 에서 역산하지 않는다 — + // presigned 응답이 준 값을 그대로 들고 있다가 제출 직전에 치환한다 (toWireContent, #1125). + // SavedStateHandle 에 실어 프로세스 사망을 건너뛴다 — 에디터 본문이 살아 돌아오는데 + // 표만 비면 전체 URL 이 그대로 서버로 간다 (#1125 리뷰). + private val uploadedFileKeysByUrl = UploadedFileKeys(savedStateHandle) fun onAnswerChanged(text: String) { _uiState.update { it.copy(answer = text) } @@ -215,32 +237,38 @@ class DailyQuestionWriteViewModel * 실어 보냈지만, 그 필드는 계약에 없어 서버가 통째로 무시했다 (#549). * * 다만 **저장 시 나가는 값은 이 URL 이 아니다.** 서버는 본문의 `img src` 에서 fileKey - * 를 기대하므로, 여기서 받은 URL 을 [uploadedImageUrls] 에 기억해 뒀다가 제출 직전에 + * 를 기대하므로, 여기서 받은 URL 을 [uploadedFileKeysByUrl] 에 기억해 뒀다가 제출 직전에 * 키 형태로 바꾼다 ([toWireContent]). 미리보기는 전체 URL 이라야 뜬다. */ suspend fun uploadMedia(uriString: String): String? { // 실패 문구의 수명은 «다음 업로드 시작까지» 다 — 화면에 걷는 수단이 따로 없고, // 걷는 함수만 두면 호출부 0건인 죽은 코드가 된다 (#1019 리뷰 지적). - _uiState.update { it.copy(isUploadingImage = true, imageUploadError = null) } - return photoUploadRepository - .upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY) - .onSuccess { uploaded -> - // 계약에 imageUrl 이 없어 상태로 들지 않는다 — 본문 img 로 들어가고, - // 제출 직전 fileKey 로 바뀔 수 있게 기억만 해 둔다 (#549). - uploadedImageUrls += uploaded.fileUrl - _uiState.update { it.copy(isUploadingImage = false) } - }.onFailure { e -> - // 첨부가 빠진 채 저장이 이어질 수 있는 자리라 남긴다 (#964). - errorReporter.recordMindRecordFailure(MindRecordFailureStage.MEDIA_UPLOAD, e) - // null 로 흡수하면 사용자는 이미지가 붙은 줄 알고 저장한다 (#716). - _uiState.update { - it.copy( - isUploadingImage = false, - imageUploadError = UiText.Resource(R.string.mindrecord_error_image_upload_failed), - ) - } - }.getOrNull() - ?.fileUrl + _uiState.update { + it.copy(uploadingImageCount = it.uploadingImageCount + 1, imageUploadError = null) + } + // finally 로 내려놓는다 — 취소도 여기를 지난다. 작성 화면의 scope 가 업로드를 + // 소유하므로 구성 변경·화면 이탈이면 코루틴만 끊기고 `Result` 는 오지 않는다. + // 그 경로에 내려놓을 자리가 없어 잠금이 남고, 사용자는 관계없는 첨부를 한 번 더 + // 성공시켜야만 저장할 수 있었다 (#2030). + try { + return photoUploadRepository + .upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY) + .onSuccess { uploaded -> + // 계약에 imageUrl 이 없어 상태로 들지 않는다 — 본문 img 로 들어가고, + // 제출 직전 fileKey 로 바뀔 수 있게 기억만 해 둔다 (#549). + uploadedFileKeysByUrl[uploaded.fileUrl] = uploaded.fileKey + }.onFailure { e -> + // 첨부가 빠진 채 저장이 이어질 수 있는 자리라 남긴다 (#964). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.MEDIA_UPLOAD, e) + // null 로 흡수하면 사용자는 이미지가 붙은 줄 알고 저장한다 (#716). + _uiState.update { + it.copy(imageUploadError = UiText.Resource(R.string.mindrecord_error_image_upload_failed)) + } + }.getOrNull() + ?.fileUrl + } finally { + _uiState.update { it.copy(uploadingImageCount = (it.uploadingImageCount - 1).coerceAtLeast(0)) } + } } /** @@ -282,7 +310,13 @@ class DailyQuestionWriteViewModel // 알리고 조회를 다시 걸어 사용자가 재시도할 수 있게 한다 (#565). failSubmit(R.string.mindrecord_error_daily_question_missing) // 이미 조회 중이면 그대로 둔다 — 연타로 같은 요청을 겹쳐 쌓지 않는다. - if (!state.isQuestionLoading) loadTodayQuestion() + // + // **무엇을 다시 부르는지는 진입이 정한다** (#2028). 수정·이어쓰기에서 오늘 질문을 + // 부르면 questionId 가 채워지면서 대상이 오늘로 바뀌고, 다음 저장이 원래 답변의 + // PATCH 가 아니라 오늘 질문의 신규 POST 로 나간다 — 고치던 글이 다른 질문에 남는다. + if (!state.isQuestionLoading) { + if (editingAnswerId != null) loadAnswer(editingAnswerId, route.isDraft) else loadTodayQuestion() + } return } if (!state.canSubmit) return @@ -296,7 +330,7 @@ class DailyQuestionWriteViewModel id = state.draftId, payload = DailyQuestionUpdatePayload( - content = state.answer.toWireContent(uploadedImageUrls), + content = state.answer.toWireContent(uploadedFileKeysByUrl.snapshot()), isDraft = isDraft, questionId = questionId, ), @@ -304,7 +338,7 @@ class DailyQuestionWriteViewModel } else { repository.create( DailyQuestionCreatePayload( - content = state.answer.toWireContent(uploadedImageUrls), + content = state.answer.toWireContent(uploadedFileKeysByUrl.snapshot()), isDraft = isDraft, // 생성 경로는 questionId 가 반드시 있다 — 위 가드가 null 을 걸렀다. questionId = requireNotNull(questionId), @@ -328,7 +362,7 @@ class DailyQuestionWriteViewModel // 제출이 성공하면 staging 키는 이미 permanent 로 옮겨졌다. 남겨 두면 // 같은 ViewModel 로 두 번 제출될 때(예: 임시저장 뒤 화면에 머무는 흐름) // 이미 옮겨진 파일을 다시 옮기려다 실패한다 (#549). - uploadedImageUrls.clear() + uploadedFileKeysByUrl.clear() }.onFailure { e -> // 저장이 upsert 라 실패가 기존 임시저장 상태와도 얽힌다 (#964·#1018). errorReporter.recordMindRecordFailure(MindRecordFailureStage.DAILY_QUESTION_SUBMIT, e) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt index 3e3448c59d..d70bf0052c 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt @@ -2,12 +2,15 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.DiaryList import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.mapper.toUi +import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage +import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive @@ -27,6 +30,7 @@ class DiaryListViewModel constructor( private val repository: DiaryRepository, private val changeTracker: MindRecordChangeTracker, + private val errorReporter: ErrorReporter, ) : ViewModel() { private val internalState = MutableStateFlow(InternalState()) private var loadJob: Job? = null @@ -87,8 +91,11 @@ class DiaryListViewModel // 목록이 통째 교체되며 LazyColumn 스크롤이 맨 위로 돌아간다. // 다만 재조회가 실패하면 삭제한 항목이 그대로 남아 보이므로 에러는 드러낸다. load(internalState.value.yearMonth, showsLoading = false) - }.onFailure { + }.onFailure { throwable -> // 실패를 무시하면 항목이 그대로 남은 채 아무 안내도 없어 고장처럼 보인다 (#716). + // 되돌릴 수 없는 동작이라 콘솔에도 남긴다 — 화면만 알리고 끝내면 + // 나중에 「왜 안 지워졌나」를 물을 곳이 없다 (#964). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.RECORD_DELETE, throwable) internalState.update { it.copy(deleteError = UiText.Resource(R.string.mindrecord_error_delete_failed)) } @@ -134,6 +141,18 @@ class DiaryListViewModel it.copy(loadPhase = LoadPhase.Loaded(result), deleteError = null) } }.onFailure { e -> + // **사용자가 오류 화면을 마주한 경우에만** 올린다. 재진입 갱신 실패는 + // 보고 있던 목록을 그대로 두므로 승격하지 않는다 — 화면 이탈이 잦아 + // 전부 올리면 한도를 잡음으로 채운다 (#964). + // + // 판정을 update 람다 **밖에서** 한다. `MutableStateFlow.update` 는 CAS + // 재시도 때 람다를 다시 평가하므로, 안에 두면 이중 보고가 될 자리다 + // (지금은 쓰기가 전부 Main.immediate 라 재시도가 없다, #964 리뷰). + val showsErrorScreen = + !(keepsStateOnFailure && internalState.value.loadPhase is LoadPhase.Loaded) + if (showsErrorScreen) { + errorReporter.recordMindRecordFailure(MindRecordFailureStage.RECORD_LIST_LOAD, e) + } internalState.update { current -> if (keepsStateOnFailure && current.loadPhase is LoadPhase.Loaded) { current diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt index 3a181136cb..e62d8ed64e 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt @@ -12,11 +12,23 @@ data class DiaryWriteUiState( val content: String = "", val mood: TodayMood? = null, /** - * 화면에 표시할 기록 날짜. **표시 전용이다** — 서버는 생성·수정 어느 쪽에서도 이 값을 - * 받지 않고 기록 날짜를 요청 시각으로 정한다 (#1008). 이어쓰기 프리필이 원래 날짜를 - * 채워 주는 자리라 상태 자체는 남긴다. + * 화면에 표시하고 서버로 보낼 기록일. 사용자가 날짜 행에서 고른다 (#1008). + * + * 서버는 2026-08-29 부터 생성·수정 양쪽에서 `date` 를 받는다 (`Afternote-BE#244`, PR #262). + * 미래 날짜는 400(code 2101)이라 [dateError] 로 미리 막는다. */ val date: LocalDate = LocalDate.now(), + /** + * [date] 를 **수정 요청에 실어도 되는지.** 프리필이 서버 날짜를 준 뒤이거나 사용자가 직접 + * 고른 뒤에만 true 다. + * + * 신규 작성은 언제나 싣는다(기본값이 오늘이고 그대로 저장되는 것이 맞다). 문제는 수정 + * 경로다 — 프리필이 실패한 채 «오늘» 을 실어 보내면 기존 기록일이 조용히 오늘로 옮겨진다. + * 그럴 땐 키를 생략해 서버가 기존 값을 유지하게 한다 (#1008). + */ + val isDateChosen: Boolean = false, + /** 고를 수 없는 날짜를 짚었을 때의 안내. 서버가 400 을 주기 전에 화면에서 먼저 막는다 (#1008). */ + val dateError: UiText? = null, /** `GET /users/receivers` 로 불러온 내 수신인 목록. */ val receivers: List = emptyList(), /** @@ -39,42 +51,58 @@ data class DiaryWriteUiState( /** draft 프리필 완료 플래그. 에디터(content) 재시드 트리거로 사용. */ val draftLoaded: Boolean = false, val draftLoadError: UiText? = null, - /** 이미지 업로드 진행 중 — 끝나기 전에 저장하면 이미지 없이 기록이 먼저 올라간다 (#716). */ - val isUploadingImage: Boolean = false, + /** + * 아직 끝나지 않은 이미지 업로드 수 (#2029 · #2030). + * + * Boolean 하나였을 때는 첨부를 잇따라 고르면 **먼저 끝난 하나가 잠금을 통째로 풀었다** — + * 아직 올라가는 중인 첨부가 있는데도 저장이 열려, 그 이미지가 빠진 본문이 먼저 나갔다. + * 성공·실패·취소 어느 쪽으로 끝나든 자기 몫만 내려놓도록 수로 센다. + */ + val uploadingImageCount: Int = 0, /** 이미지 업로드 실패 안내. 조용히 null 로 흡수하지 않는다 (#716). */ val imageUploadError: UiText? = null, val submitState: SubmitState = SubmitState.Idle, - /** 이어쓰기(기존 draft PATCH) 진입인지. 신규 작성이면 덮어쓸 기존 내용이 없다. */ - val isEditingDraft: Boolean = false, + /** + * 기존 기록을 고치러 들어왔는지 — **임시저장 이어쓰기와 정식 기록 수정을 함께 센다.** + * + * 신규 작성이면 덮어쓸 기존 내용이 없다. 종전에는 이어쓰기일 때만 섰는데, 저장 경로는 + * 두 진입이 똑같이 PATCH 라 정식 수정이 프리필 가드 밖에 있었다 (#2027). + */ + val isEditingExistingRecord: Boolean = false, /** 툴바 "임시저장 N" 표시값. `null` 은 아직 모름(조회 중·실패) (#769). */ val draftCount: Int? = null, ) { + /** 끝나지 않은 업로드가 하나라도 있는지. 화면과 저장 잠금이 함께 본다. */ + val isUploadingImage: Boolean + get() = uploadingImageCount > 0 + /** 정식 등록 조건 — 제목·본문·기분이 모두 있어야 한다. */ val canSubmit: Boolean get() = missingForSubmit() == null && isReady /** - * 임시저장 조건. - * - * «미완성 보존» 이 목적이라 제목·본문 중 하나만 있어도 저장하려 했지만, **서버가 - * 임시저장에도 제목·본문·기분을 모두 요구한다** — 실서버 실측(2026-08-24): + * 임시저장 조건 — **제목·본문 중 하나만 있어도 된다. 기분은 안 골라도 된다.** * - * ``` - * POST /diary {"title":"제목만","content":"","isDraft":true,…} → 400 "내용은 필수입니다." - * POST /diary {"title":"","content":"

본문만

",…} → 400 "제목은 필수입니다." - * POST /diary {"title":"제목","content":"

본문

"} → 400 "오늘의 기분은 필수입니다." - * ``` + * 「미완성 보존」이 임시저장의 목적인데 종전에는 정식 등록과 같은 세 가지를 요구했다. + * 서버가 `isDraft=true` 에도 셋을 전부 검증했기 때문이다 — 보내면 400 이 되는 조건을 + * «저장 가능» 으로 표시하면 버튼이 고장 난 것과 같아 같은 조건을 걸어 뒀다. * - * 보내면 400 이 되는 조건을 «저장 가능» 으로 표시하면 버튼이 고장 난 것과 같아진다. - * 서버가 `isDraft=true` 에서 검증을 완화해 주기 전까지는 같은 조건을 요구한다 (#1065). + * 그 제약이 풀렸다. `Afternote-BE#243` → PR #267(2026-08-30 머지)이 임시저장의 필수 + * 검증을 걷었다 — 제목·본문·기분을 생략할 수 있고 `today_mood` 는 NULL 로 저장된다. + * 정식 등록(`isDraft=false`)은 셋 다 필수이고 누락 시 400/`1400` 이다 (#1065). * - * 정식 등록과 갈리는 지점은 남는다 — 실패 사유를 각각 다른 문구로 알린다. + * 완전히 빈 폼까지 열지는 않는다. 남길 것이 하나도 없는 저장은 사용자가 의도한 적 없는 + * 빈 임시저장을 목록에 쌓고, 이어쓰기 목록에서 무엇인지 알아볼 수도 없다. */ val canSaveDraft: Boolean get() = missingForDraft() == null && isReady - /** 임시저장을 막는 첫 번째 누락 항목 (없으면 null). 현재는 정식 등록과 같은 세 가지다. */ - fun missingForDraft(): Int? = missingForSubmit() + /** + * 임시저장을 막는 첫 번째 누락 항목 (없으면 null). + * + * 제목과 본문이 **둘 다** 비었을 때만 막는다 — 기분은 보지 않는다. + */ + fun missingForDraft(): Int? = R.string.mindrecord_write_diary_missing_draft_content.takeIf { title.isBlank() && content.isHtmlBlank() } private val isReady: Boolean get() = @@ -82,9 +110,12 @@ data class DiaryWriteUiState( !isDraftLoading && // 업로드 중 저장하면 본문에 아직 안 들어간 이미지가 빠진 채 나간다 (#716). !isUploadingImage && - // 프리필이 실패했는데 저장하면, 보지 못한 기존 draft 내용을 빈 폼으로 PATCH 해 - // 덮어쓴다. 이어쓰기 진입에서 프리필이 실패한 동안은 저장을 막는다 (#716). - !(isEditingDraft && draftLoadError != null) + // 프리필이 실패했는데 저장하면, 보지 못한 기존 내용을 빈 폼으로 PATCH 해 + // 덮어쓴다. 수정 진입은 프리필이 **성공한 뒤에만** 저장을 연다 (#716 · #2027). + // + // 「실패했는가」가 아니라 「읽었는가」로 본다 — 실패 플래그만 보면 조회가 끝나지 + // 않은 창(취소·재시작)에서 오류도 없고 프리필도 없는 상태가 열린다. + !(isEditingExistingRecord && !draftLoaded) /** * 등록을 막는 첫 번째 누락 항목 (없으면 null). diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt index 9d77d2118f..1a8d0167b2 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt @@ -8,7 +8,7 @@ import androidx.navigation.toRoute import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.PhotoUploadRepository -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload @@ -19,6 +19,8 @@ import com.afternote.feature.mindrecord.presentation.mapper.toUi import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import com.afternote.feature.mindrecord.presentation.util.isHtmlBlank import com.afternote.feature.mindrecord.presentation.util.toWireContent import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -45,8 +47,8 @@ class DiaryWriteViewModel savedStateHandle: SavedStateHandle, private val repository: DiaryRepository, private val photoUploadRepository: PhotoUploadRepository, - private val userRepository: UserRepository, - private val draftLoader: MindRecordDraftLoader, + private val userRepository: UserReceiverRepository, + private val draftLoader: LoadMindRecordDraftsUseCase, private val errorReporter: ErrorReporter, ) : ViewModel() { private val route = savedStateHandle.toRoute() @@ -58,9 +60,9 @@ class DiaryWriteViewModel val uiState: StateFlow = _uiState.asStateFlow() init { - // 이어쓰기(임시저장)일 때만 «이어쓰는 중» 으로 표시한다 — 정식 기록 수정은 아니다 (#582). - if (editingDiaryId != null && route.isDraft) { - _uiState.update { it.copy(isEditingDraft = true) } + // 이어쓰기든 정식 기록 수정이든 덮어쓸 원본이 있다 — 프리필 가드는 둘을 함께 본다 (#2027). + if (editingDiaryId != null) { + _uiState.update { it.copy(isEditingExistingRecord = true) } } loadReceivers() loadDraftCount() @@ -85,6 +87,22 @@ class DiaryWriteViewModel _uiState.update { it.copy(content = value) } } + /** + * 날짜 행에서 고른 기록일을 싣는다 (#1008). + * + * **미래 날짜는 받지 않는다.** 서버가 400(code 2101)으로 거절하므로, 상태에 넣으면 + * 사용자는 저장을 눌러 봐야 실패를 안다. 고르는 순간 사유와 함께 막는다. + */ + fun onDateSelected(date: LocalDate) { + if (date.isAfter(LocalDate.now())) { + _uiState.update { + it.copy(dateError = UiText.Resource(R.string.mindrecord_error_diary_future_date)) + } + return + } + _uiState.update { it.copy(date = date, isDateChosen = true, dateError = null) } + } + fun onMoodSelected(mood: TodayMood) { _uiState.update { it.copy(mood = mood) } } @@ -99,8 +117,11 @@ class DiaryWriteViewModel } } - /** 이번 작성 중 업로드한 이미지의 원본 URL. 제출 시 fileKey 로 바꿀 대상이다 (#1016). */ - private val uploadedImageUrls = mutableSetOf() + // 이번 작성에서 업로드한 `fileUrl` → 서버가 준 `fileKey`. 키를 URL 에서 역산하지 않는다 — + // presigned 응답이 준 값을 그대로 들고 있다가 제출 직전에 치환한다 (toWireContent, #1125). + // SavedStateHandle 에 실어 프로세스 사망을 건너뛴다 — 에디터 본문이 살아 돌아오는데 + // 표만 비면 전체 URL 이 그대로 서버로 간다 (#1125 리뷰). + private val uploadedFileKeysByUrl = UploadedFileKeys(savedStateHandle) /** * 에디터에서 고른 **미디어**(사진·음성·파일)를 presigned URL 로 업로드하고 **미리보기에 @@ -121,25 +142,31 @@ class DiaryWriteViewModel suspend fun uploadMedia(uriString: String): String? { // 실패 문구의 수명은 «다음 업로드 시작까지» 다 — 화면에 걷는 수단이 따로 없고, // 걷는 함수만 두면 호출부 0건인 죽은 코드가 된다 (#1019 리뷰 지적). - _uiState.update { it.copy(isUploadingImage = true, imageUploadError = null) } - return photoUploadRepository - .upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY) - .onSuccess { uploaded -> - // 제출 직전 fileKey 로 바꿀 대상이다 (#1016). - uploadedImageUrls += uploaded.fileUrl - _uiState.update { it.copy(isUploadingImage = false) } - }.onFailure { e -> - // 첨부가 빠진 채 저장이 이어질 수 있는 자리라 남긴다 (#964). - errorReporter.recordMindRecordFailure(MindRecordFailureStage.MEDIA_UPLOAD, e) - // null 로 흡수하면 사용자는 이미지가 붙은 줄 알고 저장한다 (#716). - _uiState.update { - it.copy( - isUploadingImage = false, - imageUploadError = UiText.Resource(R.string.mindrecord_error_image_upload_failed), - ) - } - }.getOrNull() - ?.fileUrl + _uiState.update { + it.copy(uploadingImageCount = it.uploadingImageCount + 1, imageUploadError = null) + } + // finally 로 내려놓는다 — 취소도 여기를 지난다. 작성 화면의 scope 가 업로드를 + // 소유하므로 구성 변경·화면 이탈이면 코루틴만 끊기고 `Result` 는 오지 않는다. + // 그 경로에 내려놓을 자리가 없어 잠금이 남고, 사용자는 관계없는 첨부를 한 번 더 + // 성공시켜야만 저장할 수 있었다 (#2030). + try { + return photoUploadRepository + .upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY) + .onSuccess { uploaded -> + // 제출 직전 fileKey 로 바꿀 대상이다 (#1016). + uploadedFileKeysByUrl[uploaded.fileUrl] = uploaded.fileKey + }.onFailure { e -> + // 첨부가 빠진 채 저장이 이어질 수 있는 자리라 남긴다 (#964). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.MEDIA_UPLOAD, e) + // null 로 흡수하면 사용자는 이미지가 붙은 줄 알고 저장한다 (#716). + _uiState.update { + it.copy(imageUploadError = UiText.Resource(R.string.mindrecord_error_image_upload_failed)) + } + }.getOrNull() + ?.fileUrl + } finally { + _uiState.update { it.copy(uploadingImageCount = (it.uploadingImageCount - 1).coerceAtLeast(0)) } + } } fun submit(isDraft: Boolean = false) { @@ -163,8 +190,12 @@ class DiaryWriteViewModel } // 고르지 않은 기분을 지어내지 않는다. 지어내면 그것이 사용자 데이터가 되고 // (이어쓰기로 열면 «그냥그래» 가 이미 선택돼 보인다) 주간리포트 집계와 감정 - // 분석 입력에도 그대로 들어간다. 위 가드가 미선택을 이미 막는다. - val mood = state.mood ?: return + // 분석 입력에도 그대로 들어간다. + // + // 임시저장은 미선택 그대로 null 을 싣는다 — 서버가 그 자리를 열어 뒀다 + // (BE#243 → PR #267). 정식 등록은 위 `missingForSubmit` 가드가 미선택을 막는다. + val mood = state.mood + if (!isDraft && mood == null) return viewModelScope.launch { _uiState.update { it.copy(submitState = SubmitState.InProgress) } @@ -175,30 +206,34 @@ class DiaryWriteViewModel payload = DiaryUpdatePayload( title = state.title, - content = state.content.toWireContent(uploadedImageUrls), + content = state.content.toWireContent(uploadedFileKeysByUrl.snapshot()), isDraft = isDraft, todayMood = mood, // 생성 경로와 같은 규칙. 빈 선택을 빈 목록으로 보내면 서버가 // 전체 해제로 읽어, 수신자를 건드리지 않은 편집이 기존 지정을 // 지운다 — 목록 응답에 수신자가 없어 되살릴 수도 없다. receiverIds = state.selectedReceiverIds.toList().takeIf { it.isNotEmpty() }, + // 화면 값이 서버에서 왔거나 사용자가 고른 것일 때만 싣는다. + // 그 밖에는 키를 생략해 서버가 기존 기록일을 유지한다 (#1008). + date = state.date.takeIf { state.isDateChosen }, ), ) } else { repository.create( DiaryCreatePayload( title = state.title, - content = state.content.toWireContent(uploadedImageUrls), + content = state.content.toWireContent(uploadedFileKeysByUrl.snapshot()), isDraft = isDraft, todayMood = mood, receiverIds = state.selectedReceiverIds.toList(), + date = state.date, ), ) } result .onSuccess { // 서버가 permanent 로 옮겼으니 이 URL 들은 더 이상 치환 대상이 아니다. - uploadedImageUrls.clear() + uploadedFileKeysByUrl.clear() _uiState.update { it.copy(submitState = SubmitState.Succeeded) } // 임시저장이 하나 늘었으니 툴바 숫자도 따라가야 한다 (#769). if (isDraft) loadDraftCount() @@ -280,14 +315,28 @@ class DiaryWriteViewModel ).mapCatching { list -> list.diaries.first { it.diaryId == diaryId } } .onSuccess { draft -> _uiState.update { + // **사용자가 이미 손댄 칸은 덮지 않는다** (#2031). 입력창은 프리필을 + // 기다리는 동안에도 편집할 수 있어서, 늦게 도착한 원본을 무조건 실으면 + // 방금 친 글이 서버 값으로 되돌아간다. 오늘 초안 이어쓰기(`resumeDraft`)가 + // 이미 같은 규칙을 쓰고 있었고, 대상 ID 로 들어온 수정만 빠져 있었다. + // + // 빈 칸 판정은 칸마다 다르다 — 본문은 에디터가 아무것도 안 써도 + // `

` 를 내보내므로 태그를 걷어 낸 [isHtmlBlank] 로 본다. + val prefillDate = draft.toUi()?.date it.copy( - title = draft.title, - content = draft.content, - mood = draft.todayMood, - // 표시 전용 값이다 — 서버가 날짜를 주면 그걸 보여 주고, 안 주면 - // 화면에 이미 떠 있던 값을 유지한다. 고르는 수단은 없다 (#1008). - date = draft.toUi()?.date ?: it.date, + title = if (it.title.isBlank()) draft.title else it.title, + content = if (it.content.isHtmlBlank()) draft.content else it.content, + mood = it.mood ?: draft.todayMood, + // 서버가 준 기록일을 그대로 보여 주고, 그때부터 수정 요청에도 싣는다. + // 프리필이 날짜를 못 주면 화면 값(오늘)을 유지하되 `isDateChosen` 은 + // false 로 남겨, 수정이 기존 기록일을 오늘로 밀지 않게 한다 (#1008). + // 사용자가 이미 고른 날짜가 있으면 그쪽이 이긴다 (#2031). + date = if (it.isDateChosen) it.date else prefillDate ?: it.date, + isDateChosen = it.isDateChosen || prefillDate != null, isDraftLoading = false, + // 「프리필이 도착했다」는 사실은 무엇을 실었는지와 무관하게 선다 — + // 저장 잠금(#2027)이 이 값을 보므로, 입력해 둔 사용자가 저장하지 + // 못하는 상태로 굳으면 안 된다. draftLoaded = true, ) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt index 1c5753168f..2569007c03 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt @@ -4,17 +4,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.ui.UiText -import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository -import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.mapper.toUi import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure +import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -25,16 +22,15 @@ import javax.inject.Inject /** * 임시저장 목록 ViewModel. * - * 조회 범위는 [MindRecordDraftLoader] 가 정본으로 들고 있다 — 작성 툴바의 카운트가 같은 범위를 + * 조회 범위는 [LoadMindRecordDraftsUseCase] 가 정본으로 들고 있다 — 작성 툴바의 카운트가 같은 범위를 * 봐야 목록 건수와 어긋나지 않는다 (#769). */ @HiltViewModel class DraftListViewModel @Inject constructor( - private val loader: MindRecordDraftLoader, - private val diaryRepository: DiaryRepository, - private val dailyQuestionRepository: DailyQuestionRepository, + private val loadDrafts: LoadMindRecordDraftsUseCase, + private val deleteDrafts: DeleteMindRecordDraftsUseCase, private val errorReporter: ErrorReporter, ) : ViewModel() { private val _uiState = MutableStateFlow(DraftListUiState.Loading) @@ -71,59 +67,66 @@ class DraftListViewModel _uiState.update { if (it is DraftListUiState.Success) it.copy(isDeleting = true) else it } - // 항목별 결과를 항목과 짝지어 받는다 — 무엇이 실패했는지 알아야 다시 선택해 줄 수 있다. - val results = - coroutineScope { - items - .map { item -> async { item to deleteOne(item) } } - .awaitAll() - } - // 되돌릴 수 없는 동작이고, 부분 실패는 목록과 서버 상태를 어긋나게 둔다 — - // 화면은 사용자에게 알리지만 콘솔에는 아무 흔적도 남지 않았다 (#964). - // 계측과 «실패 목록» 을 한 순회에 둔다 — 갈라 두면 나중에 한쪽만 고친다. - val failed = - results - .onEach { (_, result) -> - result.exceptionOrNull()?.let { throwable -> - errorReporter.recordMindRecordFailure(MindRecordFailureStage.DRAFT_DELETE, throwable) - } - }.filter { (_, result) -> result.isFailure } - .map { (item, _) -> item } - val refreshed = collectDrafts() + // «무엇을 지웠는지·무엇이 남았는지» 판정은 [DeleteMindRecordDraftsUseCase] 가 갖는다. + // 여기 남는 것은 「그 판정을 화면의 어떤 상태로 보일까」 뿐이다 (#1693). + var refreshed: List? = null + val outcome = + deleteDrafts.delete( + targets = items.mapNotNull { it.toDeleteTarget() }, + survivorsAfterDelete = { + refreshed = collectDrafts() + refreshed?.mapNotNullTo(mutableSetOf()) { it.toDeleteTarget() } + }, + // 되돌릴 수 없는 동작이라 실패는 콘솔에도 남겨야 한다 — 화면만 알리고 + // 끝내면 나중에 「왜 안 지워졌나」를 물을 곳이 없다 (#964). 화면에 안 + // 보이는 실패(이미 사라진 항목의 404)까지 기록한다 — 그쪽일수록 계측이 + // 유일한 흔적이다. + // + // **재조회 전에** 부르는 훅이다. 반환값으로 받아 여기서 기록하면 재조회 + // 도중 스코프가 취소될 때 삭제는 반영됐는데 흔적이 안 남는다 (#1693 리뷰). + onFailures = { failures -> + failures.forEach { failure -> + errorReporter.recordMindRecordFailure( + MindRecordFailureStage.DRAFT_DELETE, + failure.cause, + ) + } + }, + ) + + // 재조회 결과가 없으면 **현 목록을 유지한다**. UseCase 는 targets 가 비면 + // survivorsAfterDelete 를 부르지 않고 Deleted 를 돌리므로 `refreshed` 가 null 인 + // 채 여기 올 수 있다 — orEmpty() 로 두면 목록이 빈 리스트로 갈린다. 지금은 위 + // items.isEmpty() 가드가 막고 있지만 그 가드에 기대지 않는다 (#1693 리뷰). + val loaded = refreshed ?: (_uiState.value as? DraftListUiState.Success)?.items _uiState.value = - if (refreshed != null) { - // 실패했지만 재조회에서도 사라진 항목은 알리지 않는다 — 이미 없는 것을 지우려다 - // 404 가 난 경우가 여기다. 사용자가 원한 결과는 이뤄졌고, 남아 있지도 않은 항목을 - // 다시 선택해 주면 "목록은 비었는데 1개 선택" 같은 상태가 된다. - val remainingKeys = refreshed.mapTo(mutableSetOf()) { it.category to it.id } - val actionableFailures = failed.filter { (it.category to it.id) in remainingKeys } - - DraftListUiState.Success( - items = refreshed, - deleteOutcome = - if (actionableFailures.isEmpty()) { - DraftDeleteOutcome.AllDeleted - } else { - DraftDeleteOutcome.SomeFailed(actionableFailures) - }, - ) - } else { - DraftListUiState.Error(UiText.Resource(R.string.mindrecord_error_generic)) + when (outcome) { + is DeleteMindRecordDraftsUseCase.Outcome.Deleted -> { + DraftListUiState.Success( + items = loaded.orEmpty(), + // 화면은 **남은 것**만 본다 — 이미 사라진 항목의 404 까지 «실패» 로 + // 보이면 사용자가 다시 지울 수 없는 것을 다시 고르게 된다. + // 계측은 onFailures 훅이 재조회 전에 전건을 이미 올렸다 (#1693). + deleteOutcome = + if (outcome.remaining.isEmpty()) { + DraftDeleteOutcome.AllDeleted + } else { + DraftDeleteOutcome.SomeFailed( + outcome.remaining.mapNotNull { failure -> failure.target.toDraftItem(loaded) }, + ) + }, + ) + } + + // 재조회가 실패해 무엇이 남았는지 모른다 — 목록을 그릴 수 없다. + is DeleteMindRecordDraftsUseCase.Outcome.Unknown -> { + DraftListUiState.Error(UiText.Resource(R.string.mindrecord_error_generic)) + } } } } - private suspend fun deleteOne(item: DraftItem): Result = - when (item.category) { - DraftCategory.Diary -> diaryRepository.delete(item.id) - - DraftCategory.DailyQuestion -> dailyQuestionRepository.delete(item.id) - - // All 은 필터 라벨용 — 실제 항목 카테고리로는 등장하지 않는다. - DraftCategory.All -> Result.success(Unit) - } - fun deleteAll() { val current = _uiState.value as? DraftListUiState.Success ?: return delete(current.items) @@ -155,7 +158,7 @@ class DraftListViewModel } private suspend fun collectDrafts(): List? = - loader + loadDrafts .load() // 사용자가 «쓰다 만 글» 을 찾으러 들어오는 화면이라, 실패하면 작성물이 사라진 // 것처럼 보인다 — #519 가 실제로 그 형태의 결함이었다 (#964). @@ -188,4 +191,30 @@ class DraftListViewModel } (diaryItems + dailyQuestionItems).sortedByDescending { it.date } }.getOrNull() + + /** 화면 항목 → 삭제 대상. «전체» 는 필터 라벨이라 지울 대상이 아니다. */ + private fun DraftItem.toDeleteTarget(): DeleteMindRecordDraftsUseCase.Target? = + when (category) { + DraftCategory.Diary -> { + DeleteMindRecordDraftsUseCase.Target(DeleteMindRecordDraftsUseCase.Category.Diary, id) + } + + DraftCategory.DailyQuestion -> { + DeleteMindRecordDraftsUseCase.Target(DeleteMindRecordDraftsUseCase.Category.DailyQuestion, id) + } + + DraftCategory.All -> { + null + } + } + + /** + * 실패한 대상을 화면이 다시 선택해 줄 수 있는 항목으로 되돌린다. + * + * 재조회 목록에서 같은 키를 찾는다 — 그쪽이 서버가 방금 돌려준 최신 제목·본문이다. + * UseCase 가 «아직 남아 있는» 실패만 올리므로 못 찾는 경우는 없지만, 그렇더라도 + * 목록 자체는 그려야 하므로 그 항목만 조용히 뺀다. + */ + private fun DeleteMindRecordDraftsUseCase.Target.toDraftItem(refreshed: List?): DraftItem? = + refreshed.orEmpty().firstOrNull { it.toDeleteTarget() == this } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemoriesCardViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemoriesCardViewModel.kt index 09c6a29033..96e1822894 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemoriesCardViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemoriesCardViewModel.kt @@ -58,7 +58,11 @@ class MemoriesCardViewModel // 서버를 만나도 초안이 카드에 오르지 않도록 `!isDraft` 재확인은 남겨둔다. val latest = questions.firstOrNull { !it.isDraft } _uiState.update { - it.copy(question = latest?.title, answer = latest?.content) + it.copy( + recordId = latest?.dailyQuestionId, + question = latest?.title, + answer = latest?.content, + ) } } } @@ -67,6 +71,13 @@ class MemoriesCardViewModel /** null 이면 표시할 기록이 없다 — 카드가 질문·답변 줄을 그리지 않는다 (0건 시안 미확정, #559). */ data class MemoriesCardUiState( + /** + * 카드가 가리키는 그 기록의 id. 「그날의 기록 다시 읽기」가 열 상세의 대상이다 (#793). + * + * 0건이면 null 이고, 그때 버튼은 상세가 아니라 카드와 같은 곳(추억 공간)으로 간다 — + * 열 기록이 없는데 상세로 보내면 빈 화면이 된다. + */ + val recordId: Long? = null, val question: String? = null, val answer: String? = null, ) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceUiState.kt index 0109d642bf..6fb20624fd 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceUiState.kt @@ -3,7 +3,7 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.annotation.StringRes import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryItem -sealed interface MemorySpaceUiState { +internal sealed interface MemorySpaceUiState { data object Loading : MemorySpaceUiState /** [memories] 가 비어 있으면 아직 기록이 없는 사용자다 — 화면이 빈 상태 안내를 띄운다. */ diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceViewModel.kt index 5f1a2c67d4..a701cb0637 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceViewModel.kt @@ -2,47 +2,38 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.feature.mindrecord.domain.model.DailyQuestion -import com.afternote.feature.mindrecord.domain.model.Diary -import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository -import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.core.common.reporting.ErrorReporter import com.afternote.feature.mindrecord.presentation.R -import com.afternote.feature.mindrecord.presentation.mapper.toUi import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryItem +import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryRecord +import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage +import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure +import com.afternote.feature.mindrecord.presentation.usecase.GetMemorySpaceUseCase import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import java.time.LocalDate -import java.time.YearMonth import java.time.format.DateTimeFormatter import javax.inject.Inject /** * 추억 공간(MEMORY SPACE) 화면. * - * 서버에 "추억" 전용 계약이 없어 사용자의 실제 기록을 모아 카드로 만든다 — 일기와 - * 데일리질문 답변 두 출처를 합쳐 최신순으로 [MEMORY_CARD_LIMIT] 장까지 노출한다. - * 종전에는 `기억 1~4` / `https://mock.image/N` 더미를 방출했다 (#559). - * - * 일기 조회는 `yearMonth` 가 필수라 기간 전체를 한 번에 받을 수 없다. 카드가 몇 장뿐이라 - * 최근 [DIARY_MONTH_WINDOW] 개월만 조회한다 — 그보다 과거까지 훑으면 화면에 쓰지도 않을 - * 요청이 달마다 한 건씩 늘어난다. 데일리질문은 `date` 생략 시 전체 기간이 온다. + * 서버에 "추억" 전용 계약이 없어 사용자의 실제 기록을 모아 카드로 만든다. **무엇을 모으고 + * 무엇을 버리는지는 [GetMemorySpaceUseCase] 가 안다** (#1693) — 비대칭 조회 범위, 부분 실패, + * 초안 제외, 정렬과 상한이 그쪽 정책이다. 여기 남는 것은 화면 상태와 카드 문구, 그리고 + * 실패 승격뿐이다. */ @HiltViewModel class MemorySpaceViewModel @Inject - constructor( - private val diaryRepository: DiaryRepository, - private val dailyQuestionRepository: DailyQuestionRepository, + internal constructor( + private val getMemorySpace: GetMemorySpaceUseCase, + private val errorReporter: ErrorReporter, ) : ViewModel() { - private val _uiState = MutableStateFlow(MemorySpaceUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() + private val mutableUiState = MutableStateFlow(MemorySpaceUiState.Loading) + internal val uiState: StateFlow = mutableUiState.asStateFlow() init { load() @@ -52,113 +43,34 @@ class MemorySpaceViewModel private fun load() { viewModelScope.launch { - _uiState.value = MemorySpaceUiState.Loading - runCatchingCancellable { collectMemories() } - .onSuccess { memories -> _uiState.value = MemorySpaceUiState.Success(memories) } - .onFailure { - _uiState.value = MemorySpaceUiState.Error(R.string.mindrecord_error_memory_space_failed) + mutableUiState.value = MemorySpaceUiState.Loading + getMemorySpace + .invoke() + .onSuccess { records -> + mutableUiState.value = MemorySpaceUiState.Success(records.map { it.toCard() }) + }.onFailure { throwable -> + // 부분 실패는 UseCase 가 삼키므로, 여기 오는 것은 **합친 결과가 비었고 + // 실패 출처가 하나라도 있을 때**다 — 화면이 통째로 비는 자리라 승격 + // 가치가 높다. 출처는 넷이다: 일기 최근 3개월이 달마다 하나씩, + // 데일리질문이 하나 (#964 리뷰). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.MEMORY_SPACE_LOAD, throwable) + mutableUiState.value = MemorySpaceUiState.Error(R.string.mindrecord_error_memory_space_failed) } } } - /** - * 두 출처를 병렬 조회해 최신순 카드로 만든다. - * - * 부분 실패는 카드가 한 장이라도 채워졌을 때만 삼킨다 — 카드 4장짜리 장식 - * 화면을 한 출처의 실패로 통째 비우면 잃는 것이 더 크다. 반면 **합친 결과가 - * 비었는데 실패한 출처가 있으면** 그 실패를 올린다 — 실패한 쪽에 기록이 있었을 수 - * 있어 0건으로 확정해 버리면 ‘아직 담긴 기록이 없어요’ 로 오인하고 재시도 경로까지 사라진다. - */ - private suspend fun collectMemories(): List = - coroutineScope { - val diaryDeferred = - recentMonths().map { month -> - async { diaryRepository.getList(yearMonth = month.toString()) } - } - val questionDeferred = async { dailyQuestionRepository.getList() } - - val diaryResults = diaryDeferred.awaitAll() - val questionResult = questionDeferred.await() - - val diaries = - diaryResults - .mapNotNull { it.getOrNull() } - .flatMap { it.diaries } - // 쓰다 만 초안은 전시하지 않는다. `GET /diary` 는 `draftOnly` 를 생략하면 - // 그 달 **전체**(임시저장 포함)를 내려주므로 클라가 걸러야 한다. - // 데일리질문(`GET /daily-questions`)은 생략 시 서버가 제출 완료만 주므로 - // 같은 필터가 필요 없다 — 두 API 의 기본값이 다르다. - .filterNot { it.isDraft } - val questions = questionResult.getOrNull().orEmpty() - - val memories = - (diaries.mapNotNull { it.toDatedMemory() } + questions.mapNotNull { it.toDatedMemory() }) - .sortedByDescending { it.date } - .take(MEMORY_CARD_LIMIT) - .map { it.item } - - if (memories.isEmpty()) { - val failure = - diaryResults.firstNotNullOfOrNull { it.exceptionOrNull() } - ?: questionResult.exceptionOrNull() - if (failure != null) throw failure - } - - memories - } - - private fun recentMonths(): List { - val thisMonth = YearMonth.now() - return (0 until DIARY_MONTH_WINDOW).map { thisMonth.minusMonths(it.toLong()) } - } - - /** 두 출처를 한 줄로 정렬하기 위한 운반 타입 — 표시용 날짜 문자열로는 비교가 안 된다. */ - private data class DatedMemory( - val date: LocalDate, - val item: MemoryItem, - ) - - /** 날짜를 못 정한 일기는 정렬 키가 없어 카드로 만들지 않는다 ([toUi] 가 null 을 돌린다). */ - private fun Diary.toDatedMemory(): DatedMemory? { - val ui = toUi() ?: return null - return DatedMemory( - date = ui.date, - item = - MemoryItem( - id = ui.id, - imageUrl = ui.imageUrl, - title = ui.title, - date = ui.date.format(CARD_DATE_FORMATTER), - content = ui.content, - tags = listOfNotNull(ui.emotion), - ), - ) - } - - /** 날짜를 못 정한 데일리질문도 같다 — 정렬 키가 없어 카드로 만들지 않는다 (#751). */ - private fun DailyQuestion.toDatedMemory(): DatedMemory? { - val ui = toUi() ?: return null - return DatedMemory( - date = ui.date, - item = - MemoryItem( - // 두 출처의 ID 공간이 겹친다 — 카드 선택이 엉뚱한 기록을 열지 않도록 - // 데일리질문은 음수로 접어 일기와 갈라 둔다 (서버 ID 는 양수). - id = -ui.id, - imageUrl = ui.imageUrl, - title = ui.title, - date = ui.date.format(CARD_DATE_FORMATTER), - content = ui.content, - tags = emptyList(), - ), + /** 카드 문구는 화면 몫이다 — 표시용 날짜 서식과 태그 자리를 여기서 만든다. */ + private fun MemoryRecord.toCard(): MemoryItem = + MemoryItem( + id = id, + imageUrl = imageUrl, + title = title, + date = date.format(CARD_DATE_FORMATTER), + content = content, + tags = listOfNotNull(emotion), ) - } - - companion object { - /** 카드 배치(`MemorySpaceCardField`)가 4장까지만 자리를 잡는다. */ - private const val MEMORY_CARD_LIMIT = 4 - private const val DIARY_MONTH_WINDOW = 3 - private val CARD_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") + private companion object { + val CARD_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt index eadacd8cb6..b512e0357d 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt @@ -156,7 +156,7 @@ class ReceiverMindRecordViewModel * 도메인 예외 타입만 본다. 전달 조건 미충족은 수신자가 할 수 있는 일이 없는 상태라 * (발신자가 조건을 설정해야 풀린다) 원문 대신 무엇을 기다리는지 알려 준다. */ -internal fun Throwable.toDomainMessage(): UiText = +private fun Throwable.toDomainMessage(): UiText = when (this) { is DeliveryNotReadyException -> UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready) else -> UiText.Resource(R.string.mindrecord_receiver_load_failed) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailViewModel.kt index e142f56d83..86b96c4e67 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository @@ -11,6 +12,8 @@ import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.mapper.toEmoji import com.afternote.feature.mindrecord.presentation.mapper.toUi import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute +import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage +import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure import com.afternote.feature.mindrecord.presentation.util.RecordContentBlock import com.afternote.feature.mindrecord.presentation.util.toRecordContentBlocks import dagger.hilt.android.lifecycle.HiltViewModel @@ -38,6 +41,7 @@ class RecordDetailViewModel savedStateHandle: SavedStateHandle, private val diaryRepository: DiaryRepository, private val dailyQuestionRepository: DailyQuestionRepository, + private val errorReporter: ErrorReporter, ) : ViewModel() { private val route = savedStateHandle.toRoute() @@ -69,7 +73,7 @@ class RecordDetailViewModel blocks = blocks, mood = diary.todayMood?.toEmoji(), ) - }.onFailure { failWithLoadError() } + }.onFailure { failWithLoadError(it) } } } @@ -97,15 +101,20 @@ class RecordDetailViewModel // 데일리질문에는 오늘의 기분이 없다 — 시안에도 그 줄이 없다. mood = null, ) - }.onFailure { failWithLoadError() } + }.onFailure { failWithLoadError(it) } } } - private fun failWithLoadError() { + /** + * 「내가 쓴 기록이 안 열린다」 는 자리다. 읽기지만 사용자 데이터에 닿지 못한 실패라 + * 콘솔에 남긴다 — 재현 조건(그 계정의 그 기록)이 우리 손에 없다 (#964). + */ + private fun failWithLoadError(throwable: Throwable) { + errorReporter.recordMindRecordFailure(MindRecordFailureStage.RECORD_DETAIL_LOAD, throwable) _uiState.value = RecordDetailUiState.Error(UiText.Resource(R.string.mindrecord_detail_load_failed)) } } /** 헤더 배경에 깔 이미지 — 본문 첫 이미지를 쓴다. 없으면 시안의 "이미지 X" 변형이다 (#759). */ -internal fun List.firstImageUrl(): String? = filterIsInstance().firstOrNull()?.url +private fun List.firstImageUrl(): String? = filterIsInstance().firstOrNull()?.url diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/UploadedFileKeys.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/UploadedFileKeys.kt new file mode 100644 index 0000000000..f56e904134 --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/UploadedFileKeys.kt @@ -0,0 +1,53 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle + +/** + * 이번 작성에서 업로드한 `fileUrl` → 서버가 준 `fileKey` (#1125). + * + * ### 왜 [SavedStateHandle] 에 싣는가 + * + * 제출 정확성이 이 대응표에 걸려 있다. 본문에는 미리보기가 뜨는 **전체 URL** 이 들어가고, + * 서버로 나갈 때 `toWireContent` 가 이 표를 보고 fileKey 로 바꾼다. 표가 비면 전체 URL 이 + * 그대로 나가 서버가 호스트를 한 번 더 붙이고 403 이 된다 (#549). + * + * 에디터 본문은 프로세스 사망을 **건너뛴다** — `rememberRichTextState()` 가 + * `rememberSaveable(saver = RichTextState.Saver)` 라 HTML 을 복원하고, 그 값이 다시 ViewModel + * 로 흘러든다. 그래서 표만 인메모리로 두면 복원된 본문에는 전체 URL 이 남아 있는데 바꿀 근거가 + * 사라진 상태가 된다. 표도 같은 수명을 가져야 짝이 맞는다 (#1125 리뷰). + * + * 값은 `fileUrl`·`fileKey` 문자열뿐이라 그대로 [SavedStateHandle] 에 실을 수 있다. + */ +internal class UploadedFileKeys( + private val savedStateHandle: SavedStateHandle, +) { + private val entries: MutableMap = + savedStateHandle.get>(KEY)?.toMutableMap() ?: mutableMapOf() + + /** 제출 직전 치환에 넘길 스냅샷. 호출부가 들고 있는 동안 바뀌지 않도록 복사해 준다. */ + fun snapshot(): Map = entries.toMap() + + operator fun set( + fileUrl: String, + fileKey: String, + ) { + entries[fileUrl] = fileKey + persist() + } + + /** 제출 성공 뒤 비운다 — 다음 작성이 남의 키를 물려받지 않게 한다. */ + fun clear() { + if (entries.isEmpty()) return + entries.clear() + persist() + } + + private fun persist() { + savedStateHandle[KEY] = HashMap(entries) + } + + private companion object { + /** 두 작성 화면이 각자 제 [SavedStateHandle] 을 쓰므로 키가 겹쳐도 섞이지 않는다. */ + const val KEY = "mindrecord_uploaded_file_keys" + } +} diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDays.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDays.kt index 6e8e050420..bbe6f3d88b 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDays.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDays.kt @@ -14,7 +14,7 @@ internal const val WEEK_LENGTH = 7 * 해당 주(월~일) 안에서 일자가 일치하는 날짜를 찾는다 — 7일 창 안에서 일자는 유일하다. * 범위 밖 일자면 null 을 돌려 집계에서 제외한다. */ -internal fun resolveDateInWeekOrNull( +private fun resolveDateInWeekOrNull( monday: LocalDate, dayOfMonth: Int, ): LocalDate? = diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt index d6549132af..0903536983 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt @@ -1,10 +1,8 @@ package com.afternote.feature.mindrecord.presentation.viewmodel -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus import com.afternote.feature.mindrecord.domain.model.TodayMood @@ -12,7 +10,6 @@ import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion import com.afternote.feature.mindrecord.domain.model.WeeklyReportDay import com.afternote.feature.mindrecord.domain.model.WeeklyReportEmotion -import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.model.DailyQuestion @@ -21,12 +18,12 @@ import com.afternote.feature.mindrecord.presentation.model.DayContent import com.afternote.feature.mindrecord.presentation.model.DayItem import com.afternote.feature.mindrecord.presentation.model.EmotionKeyword import com.afternote.feature.mindrecord.presentation.model.MindRecordCategoryUi +import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage +import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase +import com.afternote.feature.mindrecord.presentation.usecase.analysisStatus import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -44,9 +41,9 @@ import javax.inject.Inject class WeeklyReportViewModel @Inject constructor( - private val repository: WeeklyReportRepository, - private val userRepository: UserRepository, + private val observeWeeklyReport: ObserveWeeklyReportUseCase, private val changeTracker: MindRecordChangeTracker, + private val errorReporter: ErrorReporter, ) : ViewModel() { private val weekOptions: List = buildWeekOptions(today = LocalDate.now()) @@ -181,96 +178,50 @@ class WeeklyReportViewModel if (showsLoading) { internalState.update { it.copy(loadPhase = LoadPhase.Loading) } } - val result = - runCatchingCancellable { - coroutineScope { - val reportDeferred = - async { - repository - .getWeeklyReport(date = monday.format(API_DATE_FORMATTER)) - .getOrThrow() - } - val profileDeferred = async { userRepository.getMyProfile() } - reportDeferred.await() to profileDeferred.await() - } - } - // 새 로드가 이 Job 을 취소했다면 상태는 그쪽이 결정하므로 여기서 멈춘다. - // `runCatchingCancellable` 이 취소를 다시 던지므로 위에서 이미 빠져나가지만, - // `await()` 사이에 취소가 들어온 경우를 위해 남겨 둔다. - ensureActive() - result - .onSuccess { (report, profile) -> - loadedVersion = versionAtLoadStart - internalState.update { - it.copy(loadPhase = LoadPhase.Loaded(monday, report, profile.name)) - } - awaitEmotionAnalysis(monday, report.analysisStatus) - }.onFailure { e -> - internalState.update { current -> - if (keepsStateOnFailure && current.loadPhase is LoadPhase.Loaded) { - current - } else { + // 조회·폴링 정책은 [ObserveWeeklyReportUseCase] 가 갖는다. 여기 남는 것은 + // 「그 결과를 화면의 어떤 상태로 보일까」 뿐이다 (#1693). + // + // 첫 방출은 리포트·프로필 병렬 조회 결과이고, 이번 주에 분석이 진행 중이면 + // 갱신된 리포트가 이어서 온다. 새 로드가 이 Job 을 취소하면 수집도 함께 + // 끊기므로 «다른 주로 옮겨갔는데 옛 응답이 덮어쓰는» 일이 없다. + observeWeeklyReport.observe(monday).collect { result -> + ensureActive() + result + .onSuccess { snapshot -> + loadedVersion = versionAtLoadStart + // 방출이 첫 조회든 폴링이든 [Snapshot] 이 이름을 함께 싣는다 — + // 폴링은 `loaded.copy(report =)` 로 첫 조회의 이름을 그대로 들고 온다. + // 그래서 여기서 갈라 «이름은 첫 조회 것» 으로 두면, ON_RESUME + // 재조회(refreshOnReturn)가 방금 받은 새 이름을 버린다 — 설정에서 + // 이름을 바꾸고 돌아오면 주를 옮기기 전까지 옛 이름이 남는다 (#1693 리뷰). + internalState.update { current -> current.copy( - loadPhase = - LoadPhase.Failed( - message = - UiText.DynamicOrResource( - value = e.message, - fallbackResId = R.string.mindrecord_error_weekly_report_failed, - ), - monday = monday, - previous = previousLoaded, - ), + loadPhase = LoadPhase.Loaded(monday, snapshot.report, snapshot.profileName), ) } + }.onFailure { e -> + // 탭 전체가 오류 화면이 되는 자리다 — 무엇이 실패했는지 남긴다 (#1882). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.WEEKLY_REPORT_LOAD, e) + internalState.update { current -> + if (keepsStateOnFailure && current.loadPhase is LoadPhase.Loaded) { + current + } else { + current.copy( + loadPhase = + LoadPhase.Failed( + // 예외 문구를 화면에 싣지 않는다. 원문은 바로 위 + // 계측으로 남는다 (#1339 선례, #1882). + message = + UiText.Resource(R.string.mindrecord_error_weekly_report_failed), + monday = monday, + previous = previousLoaded, + ), + ) + } + } } - } - } - } - - /** - * 감정 분석이 끝나기를 화면에 머무른 채 기다린다. - * - * 분석은 저장 직후 비동기로 돌고 완료 신호를 주는 채널이 없어서, 탭에 머무르는 동안 - * 결과가 반영되려면 제한된 재조회가 필요하다 (#725). 무한 폴링은 하지 않는다 — - * [EMOTION_ANALYSIS_POLL_ATTEMPTS] 회만 시도하고, 그 뒤에는 화면 이탈·복귀 - * ([refreshOnReturn])나 사용자의 재시도에 맡긴다. - * - * 이 로직은 [load] 의 코루틴 안에서 돈다. 사용자가 다른 주를 고르면 그 `loadJob` - * 취소가 이 대기까지 함께 끊는다. - */ - private suspend fun awaitEmotionAnalysis( - monday: LocalDate, - initialStatus: EmotionAnalysisStatus, - ) { - // 폴링 근거는 "저장 직후 비동기 분석" 이라 이번 주에만 성립한다. 지난 주를 - // 골라 보는 동안 8초마다 조회가 나갈 이유가 없다. - if (monday != LocalDate.now().with(DayOfWeek.MONDAY)) return - - var status = initialStatus - repeat(EMOTION_ANALYSIS_POLL_ATTEMPTS) { - if (status != EmotionAnalysisStatus.PENDING) return - delay(EMOTION_ANALYSIS_POLL_INTERVAL_MILLIS) - val report = - repository - .getWeeklyReport(date = monday.format(API_DATE_FORMATTER)) - .getOrNull() - currentCoroutineContext().ensureActive() - // 한 번 실패했다고 남은 시도를 전부 버리지 않는다 — PENDING 화면에는 - // 재시도 수단이 없어(카드는 FAILED 전용) 화면에 머무는 동안 복구할 길이 - // 사라진다. 이번 시도만 소모하고 다음 간격을 기다린다. - if (report == null) return@repeat - status = report.analysisStatus - internalState.update { current -> - val phase = current.loadPhase - // 그 사이 다른 주로 옮겨갔으면 덮어쓰지 않는다. - if (phase is LoadPhase.Loaded && phase.monday == monday) { - current.copy(loadPhase = phase.copy(report = report)) - } else { - current } } - } } private fun buildWeekOptions( @@ -317,8 +268,14 @@ class WeeklyReportViewModel background = when (record?.emotion) { TodayMood.HAPPY -> DayBackground.Green + TodayMood.SAD -> DayBackground.Pink - else -> DayBackground.None + + // 보통은 배경을 주지 않는다 — 시안이 «좋음/나쁨» 두 끝만 색으로 가른다. + TodayMood.SOSO -> DayBackground.None + + // 그날 기록이 없거나, 있어도 감정을 고르지 않은 날. + null -> DayBackground.None }, ) } @@ -466,7 +423,3 @@ class WeeklyReportViewModel ) } } - -/** 서버가 진행 상태를 주지 않았으면 «모른다» — 0 건으로 확정하지 않는다 (#725). */ -private val WeeklyReport.analysisStatus: EmotionAnalysisStatus - get() = emotionAnalysis?.status ?: EmotionAnalysisStatus.UNKNOWN diff --git a/feature/mindrecord/presentation/src/main/res/drawable/mindrecord_align_center.xml b/feature/mindrecord/presentation/src/main/res/drawable/mindrecord_align_center.xml deleted file mode 100644 index d85d9411e3..0000000000 --- a/feature/mindrecord/presentation/src/main/res/drawable/mindrecord_align_center.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/feature/mindrecord/presentation/src/main/res/drawable/mindrecord_align_left.xml b/feature/mindrecord/presentation/src/main/res/drawable/mindrecord_align_left.xml deleted file mode 100644 index 99ba707488..0000000000 --- a/feature/mindrecord/presentation/src/main/res/drawable/mindrecord_align_left.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/feature/mindrecord/presentation/src/main/res/drawable/mindrecord_align_right.xml b/feature/mindrecord/presentation/src/main/res/drawable/mindrecord_align_right.xml deleted file mode 100644 index 7f65a69c9e..0000000000 --- a/feature/mindrecord/presentation/src/main/res/drawable/mindrecord_align_right.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index 4870d4a686..6ae9aacd05 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -26,13 +26,11 @@ 저장 - 삭제하기 이 기록을 삭제할까요? 삭제한 기록은 되돌릴 수 없어요. 삭제 취소 - 수정하기 더보기 메뉴 @@ -49,7 +47,12 @@ 일기 기록하기 제목을 입력하세요. 오늘의 기분 + + 기쁨 + 보통 + 슬픔 수신자 설정하기 + 기록 날짜 %s님에게 %1$s님 외 %2$d명에게 수신자 선택 @@ -67,6 +70,7 @@ %1$d년 %2$d월 %d개의 답변 완료 + %d개의 기록 완료 일 월 화 @@ -74,25 +78,25 @@ 목 금 토 + 기록 날짜 + + DAILY DIARY + 오늘 하루, \n누구에게 가장 고마웠나요? 답변하러가기 - 주간 리포트 - 이번 주 나의 기록들을 확인해 보세요. 총 기록 이번 주 이번 달 주간 평균 기분 - 주간 평균 감정 - 😊 좋음 %1$d월 %2$d주차 리포트 11월 2주차 리포트 - 이번주는 가족과 함께하는 시간을 가장 많이 기록하셨네요. 일상의 소중함을 느끼는 한주였던 것 같아요. - 매일 꾸준히 기록하는 습관이 정말 멋져요! 나의 감정 키워드 @@ -158,11 +160,6 @@ 링크 - 미디어 추가하기 - 이미지 추가하기 - 음성 추가하기 - 파일 추가하기 - 링크 추가하기 링크 추가하기 @@ -176,6 +173,16 @@ 임시저장 %d 임시저장 텍스트 설정 + 임시저장 목록 %1$d개 + 임시저장 목록, 수량 미확인 + 글자 종류 + 기록 필터 + 필터 시트 크기 조절 + + 굵게 + 기울임 + 밑줄 + 취소선 제목 머릿말 부머릿말 @@ -195,6 +202,7 @@ 일기를 불러오지 못했습니다. 일기 등록에 실패했습니다. 임시 저장된 일기를 불러오지 못했습니다. + 기록 날짜는 오늘까지만 고를 수 있습니다. 임시저장한 답변을 불러오지 못했어요. 이대로 저장하면 이전 임시저장이 덮어써집니다. 등록에 실패했습니다. 답변을 입력해 주세요. @@ -219,6 +227,7 @@ 제목을 입력해 주세요. + 제목이나 내용 중 하나는 적어 주세요. 본문을 입력해 주세요. 오늘의 기분을 골라 주세요. 임시저장할 내용이 없어요. diff --git a/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTest.kt b/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTest.kt index 7db63d6299..3f6042b70c 100644 --- a/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTest.kt +++ b/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTest.kt @@ -6,9 +6,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.MindRecordType import com.afternote.feature.mindrecord.presentation.COMPACT_DEVICE_SPEC import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryItem +import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryRecordId import com.android.tools.screenshot.PreviewTest /** @@ -112,8 +114,22 @@ internal fun memorySpaceScreenErrorCompactScreenshot() { */ private fun previewMemories(): List = listOf( - MemoryItem(1L, "https://example.com/1.jpg", "기억 1", "2024.11.11", "미리보기", listOf("태그")), - MemoryItem(2L, "https://example.com/2.jpg", "기억 2", "2024.11.12", "미리보기", emptyList()), - MemoryItem(3L, "https://example.com/3.jpg", "기억 3", "2024.11.13", "미리보기", emptyList()), - MemoryItem(4L, "https://example.com/4.jpg", "기억 4", "2024.11.14", "미리보기", emptyList()), + MemoryItem(MemoryRecordId(MindRecordType.DIARY, 1L), "https://example.com/1.jpg", "기억 1", "2024.11.11", "미리보기", listOf("태그")), + MemoryItem( + MemoryRecordId(MindRecordType.DAILY_QUESTION, 2L), + "https://example.com/2.jpg", + "기억 2", + "2024.11.12", + "미리보기", + emptyList(), + ), + MemoryItem(MemoryRecordId(MindRecordType.DIARY, 3L), "https://example.com/3.jpg", "기억 3", "2024.11.13", "미리보기", emptyList()), + MemoryItem( + MemoryRecordId(MindRecordType.DAILY_QUESTION, 4L), + "https://example.com/4.jpg", + "기억 4", + "2024.11.14", + "미리보기", + emptyList(), + ), ) diff --git a/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreenScreenshotTest.kt b/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreenScreenshotTest.kt index 3146f5b411..64d8591f49 100644 --- a/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreenScreenshotTest.kt +++ b/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreenScreenshotTest.kt @@ -25,6 +25,12 @@ internal fun dailyQuestionWriteScreenScreenshot() { uiState = dailyQuestionWriteScreenshotState(), date = SCREENSHOT_DATE, modifier = Modifier.fillMaxSize(), + onAnswerChanged = {}, + onBackClick = {}, + onDraftListClick = {}, + onRetryResumeDraft = {}, + onSaveDraft = {}, + onSubmit = {}, ) } } @@ -38,6 +44,12 @@ internal fun dailyQuestionWriteScreenCompactScreenshot() { uiState = dailyQuestionWriteScreenshotState(), date = SCREENSHOT_DATE, modifier = Modifier.fillMaxSize(), + onAnswerChanged = {}, + onBackClick = {}, + onDraftListClick = {}, + onRetryResumeDraft = {}, + onSaveDraft = {}, + onSubmit = {}, ) } } diff --git a/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTest.kt b/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTest.kt index b43307f8f5..fec5ecb775 100644 --- a/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTest.kt +++ b/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTest.kt @@ -25,6 +25,15 @@ internal fun diaryWriteScreenScreenshot() { DiaryWriteScreenContent( uiState = diaryWriteScreenshotState(), modifier = Modifier.fillMaxSize(), + onBackClick = {}, + onContentChanged = {}, + onDraftListClick = {}, + onMoodSelected = {}, + onReceiverRowClick = {}, + onDateRowClick = {}, + onSaveDraft = {}, + onSubmit = {}, + onTitleChanged = {}, ) } } @@ -37,6 +46,15 @@ internal fun diaryWriteScreenCompactScreenshot() { DiaryWriteScreenContent( uiState = diaryWriteScreenshotState(), modifier = Modifier.fillMaxSize(), + onBackClick = {}, + onContentChanged = {}, + onDraftListClick = {}, + onMoodSelected = {}, + onReceiverRowClick = {}, + onDateRowClick = {}, + onSaveDraft = {}, + onSubmit = {}, + onTitleChanged = {}, ) } } diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceHeaderLargeFontScreenshotTestKt/memorySpaceScreenCompactLargeFontScreenshot_4138e1bb_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceHeaderLargeFontScreenshotTestKt/memorySpaceScreenCompactLargeFontScreenshot_4138e1bb_0.png index 0e605829ca..effa3f29f5 100644 Binary files a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceHeaderLargeFontScreenshotTestKt/memorySpaceScreenCompactLargeFontScreenshot_4138e1bb_0.png and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceHeaderLargeFontScreenshotTestKt/memorySpaceScreenCompactLargeFontScreenshot_4138e1bb_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenCompactScreenshot_e7ca7c28_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenCompactScreenshot_e7ca7c28_0.png index 82243c2dd5..ee7ca35169 100644 Binary files a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenCompactScreenshot_e7ca7c28_0.png and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenCompactScreenshot_e7ca7c28_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenEmptyCompactScreenshot_e7ca7c28_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenEmptyCompactScreenshot_e7ca7c28_0.png index 732fb75506..1598213fe6 100644 Binary files a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenEmptyCompactScreenshot_e7ca7c28_0.png and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenEmptyCompactScreenshot_e7ca7c28_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenEmptyScreenshot_90705b76_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenEmptyScreenshot_90705b76_0.png index 320862653a..379001b8a9 100644 Binary files a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenEmptyScreenshot_90705b76_0.png and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenEmptyScreenshot_90705b76_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenErrorCompactScreenshot_e7ca7c28_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenErrorCompactScreenshot_e7ca7c28_0.png index 192a6f7d12..b12e48429d 100644 Binary files a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenErrorCompactScreenshot_e7ca7c28_0.png and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenErrorCompactScreenshot_e7ca7c28_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenErrorScreenshot_90705b76_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenErrorScreenshot_90705b76_0.png index bfd36b48b5..55ae92a780 100644 Binary files a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenErrorScreenshot_90705b76_0.png and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenErrorScreenshot_90705b76_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenScreenshot_90705b76_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenScreenshot_90705b76_0.png index d8370c7688..a0330a0555 100644 Binary files a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenScreenshot_90705b76_0.png and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreenScreenshotTestKt/memorySpaceScreenScreenshot_90705b76_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreenScreenshotTestKt/dailyQuestionWriteScreenCompactScreenshot_e7ca7c28_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreenScreenshotTestKt/dailyQuestionWriteScreenCompactScreenshot_e7ca7c28_0.png index 89f94f6122..eec0aac0f4 100644 Binary files a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreenScreenshotTestKt/dailyQuestionWriteScreenCompactScreenshot_e7ca7c28_0.png and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreenScreenshotTestKt/dailyQuestionWriteScreenCompactScreenshot_e7ca7c28_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreenScreenshotTestKt/dailyQuestionWriteScreenScreenshot_90705b76_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreenScreenshotTestKt/dailyQuestionWriteScreenScreenshot_90705b76_0.png index 58b03a9067..6d29547fd5 100644 Binary files a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreenScreenshotTestKt/dailyQuestionWriteScreenScreenshot_90705b76_0.png and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreenScreenshotTestKt/dailyQuestionWriteScreenScreenshot_90705b76_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenCompactScreenshot_e7ca7c28_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenCompactScreenshot_e7ca7c28_0.png index 66b8bfa793..95b4865770 100644 Binary files a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenCompactScreenshot_e7ca7c28_0.png and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenCompactScreenshot_e7ca7c28_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenScreenshot_90705b76_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenScreenshot_90705b76_0.png index 4c4ccff936..3bb15268af 100644 Binary files a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenScreenshot_90705b76_0.png and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenScreenshot_90705b76_0.png differ diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt index 58315040b8..79b36ce564 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt @@ -17,9 +17,9 @@ import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestionWriteScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryWriteScreen +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewModel -import com.afternote.feature.mindrecord.presentation.viewmodel.MindRecordDraftLoader import com.afternote.feature.mindrecord.presentation.viewmodel.SubmitState import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -58,6 +58,8 @@ class MindRecordFlowTest { DiaryWriteScreen( viewModel = viewModel, onSubmitSuccess = { successCalls += 1 }, + onBackClick = {}, + onDraftListClick = {}, ) } } @@ -98,7 +100,12 @@ class MindRecordFlowTest { val viewModel = diaryViewModel(FakeDiaryRepository()) composeRule.setContent { AfternoteTheme { - DiaryWriteScreen(viewModel = viewModel) + DiaryWriteScreen( + viewModel = viewModel, + onBackClick = {}, + onDraftListClick = {}, + onSubmitSuccess = {}, + ) } } @@ -133,14 +140,19 @@ class MindRecordFlowTest { uploadedUrl = "https://cdn.test/question.jpg", uploadedKey = "mindrecords/1/question.jpg", ), - draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository), errorReporter = reporter, ) // 실제 작성 화면을 띄운다 — 빈 테마만 compose 하고 submit() 을 직접 부르면 화면과 // ViewModel 사이 결선이 검증되지 않아, 계측 호출을 지워도 통과한다 (#964 리뷰). composeRule.setContent { AfternoteTheme { - DailyQuestionWriteScreen(viewModel = viewModel, onSubmitSuccess = { }) + DailyQuestionWriteScreen( + viewModel = viewModel, + onSubmitSuccess = { }, + onBackClick = {}, + onDraftListClick = {}, + ) } } composeRule.waitUntil(timeoutMillis = 5_000) { !viewModel.uiState.value.isQuestionLoading } @@ -188,21 +200,17 @@ class MindRecordFlowTest { uploadedKey = "mindrecords/1/image.jpg", ), userRepository = mindRecordFlowUserRepository(), - draftLoader = MindRecordDraftLoader(repository, FakeDailyQuestionRepository()), + draftLoader = LoadMindRecordDraftsUseCase(repository, FakeDailyQuestionRepository()), errorReporter = reporter, ) } private fun mindRecordFlowUserRepository(): FakeUserRepository = FakeUserRepository.strict().apply { - receiverState.value = listOf(Receiver(7L, "김수신", "가족", "fake-auth-7")) + receiverState.value = listOf(Receiver(7L, "김수신", "가족")) onReceiverListFlow = null onGetReceivers = null onCreateReceiver = null onGetMyProfile = null onUpdateMyProfile = null - onDeleteAccount = null - onGetMyPushSettings = null - onUpdateMyPushSettings = null - onGetConnectedAccounts = null } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt index 3bd5ae81ec..ca79f4a64e 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt @@ -1,5 +1,6 @@ package com.afternote.feature.mindrecord.presentation +import android.content.Context import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasText @@ -7,6 +8,7 @@ import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider import com.afternote.core.domain.testing.FakeUserRepository import com.afternote.core.model.user.User import com.afternote.core.ui.theme.AfternoteTheme @@ -38,11 +40,13 @@ import com.afternote.feature.mindrecord.presentation.screen.receiver.ReceiverMin import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DraftListScreen import com.afternote.feature.mindrecord.presentation.screen.sender.WeeklyReportScreen +import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryListUiState import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryListViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DraftListUiState import com.afternote.feature.mindrecord.presentation.viewmodel.DraftListViewModel -import com.afternote.feature.mindrecord.presentation.viewmodel.MindRecordDraftLoader import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordFilter import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordUiState import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordViewModel @@ -84,11 +88,15 @@ class MindRecordLifecycleTest { DiaryList(listOf(previousPublished), 1, TodayMood.SOSO), ) val repository = scriptedDiaryRepository(diaryLists) - val viewModel = DiaryListViewModel(repository, MindRecordChangeTracker()) + val viewModel = DiaryListViewModel(repository, MindRecordChangeTracker(), RecordingErrorReporter()) composeRule.setContent { AfternoteTheme { - DiaryScreen(viewModel = viewModel, onItemClick = { _, _ -> }) + DiaryScreen( + viewModel = viewModel, + onItemClick = { _, _ -> }, + onEditClick = { _, _ -> }, + ) } } @@ -154,15 +162,19 @@ class MindRecordLifecycleTest { ) val viewModel = DraftListViewModel( - loader = MindRecordDraftLoader(diaryRepository, dailyQuestionRepository), - diaryRepository = diaryRepository, - dailyQuestionRepository = dailyQuestionRepository, + loadDrafts = LoadMindRecordDraftsUseCase(diaryRepository, dailyQuestionRepository), + deleteDrafts = DeleteMindRecordDraftsUseCase(diaryRepository, dailyQuestionRepository), errorReporter = RecordingErrorReporter(), ) composeRule.setContent { AfternoteTheme { - DraftListScreen(viewModel = viewModel) + DraftListScreen( + viewModel = viewModel, + onBackClick = {}, + onDailyQuestionDraftClick = {}, + onDiaryDraftClick = { _, _ -> }, + ) } } @@ -212,7 +224,12 @@ class MindRecordLifecycleTest { val repository = FakeWeeklyReportRepository() repository.results.addLast(Result.failure(IllegalStateException("weekly offline"))) val userRepository = privateProfileRepository("테스트 사용자") - val viewModel = WeeklyReportViewModel(repository, userRepository, MindRecordChangeTracker()) + val viewModel = + WeeklyReportViewModel( + ObserveWeeklyReportUseCase(repository, userRepository), + MindRecordChangeTracker(), + RecordingErrorReporter(), + ) composeRule.setContent { AfternoteTheme { @@ -220,7 +237,16 @@ class MindRecordLifecycleTest { } } - composeRule.onNodeWithText("weekly offline").assertIsDisplayed() + // 이 자리는 종전에 `"weekly offline"`— 즉 **예외 원문**— 이 화면에 뜨는 것을 단언했다. + // 그게 이 테스트의 관심사(재조회 뒤 주차 인덱스가 밀리지 않는가)가 아니었을 뿐 아니라, + // 서버 오류 원문을 화면에 내지 않는다는 저장소 규약(#1339)과 정반대라 결함을 고정하고 + // 있었다. 오류 화면에 들어섰다는 사실은 안내 문자열로 확인한다 (#1882). + val failureCopy = + ApplicationProvider + .getApplicationContext() + .getString(R.string.mindrecord_error_weekly_report_failed) + composeRule.onNodeWithText(failureCopy).assertIsDisplayed() + composeRule.onNodeWithText("weekly offline").assertDoesNotExist() assertEquals(1, repository.requestedDates.size) val requestedMonday = LocalDate.parse(repository.requestedDates.single()) val wednesday = requestedMonday.plusDays(2) @@ -301,7 +327,10 @@ class MindRecordLifecycleTest { composeRule.setContent { AfternoteTheme { - ReceiverMindRecordScreen(viewModel = viewModel) + ReceiverMindRecordScreen( + viewModel = viewModel, + onBackClick = {}, + ) } } composeRule.onNodeWithText("질문 범위 최신").assertIsDisplayed() diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/a11y/RecordCardNestedClickTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/a11y/RecordCardNestedClickTest.kt new file mode 100644 index 0000000000..3583215bcf --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/a11y/RecordCardNestedClickTest.kt @@ -0,0 +1,225 @@ +package com.afternote.feature.mindrecord.presentation.a11y + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.testing.assertAccessibleClickTargets +import com.afternote.core.ui.testing.scanEnabledClickTargets +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.presentation.component.DailyQuestionListCard +import com.afternote.feature.mindrecord.presentation.component.DiaryCard +import com.afternote.feature.mindrecord.presentation.component.DiaryComponent +import com.afternote.feature.mindrecord.presentation.model.DailyDiary +import com.afternote.feature.mindrecord.presentation.model.DailyQuestion +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate + +/** + * 기록 카드 3종의 «더보기 메뉴» 중첩 클릭 계약 (#1669). + * + * ### 판정: 카드를 재구성하지 않고 스캐너 예외를 넓혔다 + * + * #1179 스캔이 세 카드에서 같은 형태를 잡았다 — 카드 전체가 상세로 가는 `clickable`(#759) + * 안에, 20dp «더보기 메뉴» 가 또 `clickable` 이다. + * + * 이 형태는 결함이 아니다. 「눌러서 이동하는 목록 행 + 그 끝의 오버플로 메뉴」는 Android + * 목록의 정본이고(Material3 `ListItem` 의 `trailingContent`), 안쪽 `clickable` 이 + * `mergeDescendants` 로 제 병합 경계를 세우기 때문에 TalkBack 이 카드와 버튼을 각각 별개로 + * 짚는다. 메뉴를 카드의 custom accessibility action 으로 옮기는 재구성(안 b)은 **눈으로 보는 + * 사용자에게서 아이콘을 뺏는** 대가를 치른다. + * + * 그래서 `core:ui` 스캐너에 예외를 뒀다. 다만 「끝단 버튼」 같은 넓은 이름이면 다음 사람이 + * 아무 데나 붙이므로, 예외가 통과시키는 형태를 두 축으로 좁혔다 (`isTrailingAccessoryOf`) — + * **끝단**(중심이 뒷절반) · **보조 크기**(너비 1/4 · 높이 1/2 이하). 여기에 호출부가 이름과 + * `Role.Button` 을 더한다. 컨테이너 **모양도 세로 위치도 보지 않는다** — 둘 다 같은 형태를 + * 형상 때문에 갈랐다. 반씩 나눠 가진 영역·세로로 긴 띠·Role 없는 아이콘은 그대로 위반으로 + * 남는다. 그 경계는 `TouchTargetAssertionsTest` 가 지킨다. + * + * 이 파일은 **세 카드가 그 예외 안에 실제로 들어오는지**를 지킨다. 카드가 세로로 길어지거나 + * 메뉴가 커지거나 위치가 앞쪽으로 옮겨지면 예외를 벗어나 다시 위반이 된다. + */ +private const val QUESTION_TITLE = "오늘 가장 고마웠던 사람은" +private const val DIARY_TITLE = "9월의 첫 기록" + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], qualifiers = "w360dp-h800dp-xhdpi") +class RecordCardNestedClickTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private var slot: MutableState? = null + + private enum class RecordCard( + val label: String, + /** 카드 본문에서 «메뉴가 아닌 자리» 를 짚기 위한 텍스트. */ + val bodyText: String, + ) { + DailyQuestionListCard("데일리질문 카드", QUESTION_TITLE), + DiaryCard("일기 카드", DIARY_TITLE), + DiaryComponent("일기 컴포넌트", DIARY_TITLE), + + /** 프로덕션 형상 — 2열 staggered grid 의 열 폭에 이미지가 붙어 세로가 폭을 넘는다. */ + DiaryCardInGrid("일기 카드(그리드 폭 + 이미지)", DIARY_TITLE), + } + + private var cardClicks = 0 + + @Test + fun `기록 카드 3종은 클릭 타깃 계약을 전부 지킨다`() { + RecordCard.entries.forEach { card -> + render(card) + runCatching { composeRule.assertAccessibleClickTargets() } + .onFailure { failure -> + throw AssertionError("${card.label}: ${failure.message}", failure) + } + } + } + + /** + * 카드가 두 타깃을 **각각** 내놓는지 — 카드 본체와 메뉴다. + * + * 위 테스트만으로는 메뉴가 통째로 사라져도 통과한다. 중첩을 «없애서» 계약을 지키는 것과 + * «예외로 인정받아» 지키는 것은 다르므로, 둘이 모두 살아 있음을 함께 못박는다. + */ + @Test + fun `카드 본체와 더보기 메뉴가 각각 짚인다`() { + RecordCard.entries.forEach { card -> + render(card) + val targets = composeRule.scanEnabledClickTargets() + + val menus = targets.filter { it.name == MORE_MENU_NAME } + assertEquals("${card.label}: 더보기 메뉴가 하나가 아니다: ${targets.map { it.name }}", 1, menus.size) + assertTrue( + "${card.label}: 카드 본체 타깃이 없다: ${targets.map { it.name }}", + targets.any { it.name != MORE_MENU_NAME }, + ) + // 메뉴는 카드 안에 있으므로 조상이 실재한다 — 예외로 통과한 것이지 중첩이 없는 게 아니다. + assertTrue( + "${card.label}: 더보기 메뉴가 카드보다 크다", + menus.single().layoutWidth < targets.first { it.name != MORE_MENU_NAME }.layoutWidth, + ) + } + } + + /** + * 예외의 근거는 «안쪽이 탭을 소비해 이중 발화가 없다» 는 런타임 사실인데, 스캐너가 이 + * 중첩에 영구히 눈을 감으므로 그 사실을 지키는 트립와이어가 따로 없었다 (#1669 리뷰). + * + * 세 카드를 모아 한 번에 보고한다 — `forEach` 안에서 바로 throw 하면 두 번째·세 번째 + * 카드의 결과가 가려진다. + */ + @Test + fun `더보기 메뉴를 눌러도 카드 본문 이동은 일어나지 않는다`() { + val violations = mutableListOf() + RecordCard.entries.forEach { card -> + cardClicks = 0 + render(card) + + composeRule.onNodeWithContentDescription(MORE_MENU_NAME).performClick() + composeRule.waitForIdle() + if (cardClicks != 0) violations += "${card.label}: 메뉴 탭이 카드 onClick 을 $cardClicks 회 불렀다" + + composeRule.onNodeWithText(card.bodyText).performClick() + composeRule.waitForIdle() + if (cardClicks != 1) violations += "${card.label}: 본문 탭이 카드 onClick 을 $cardClicks 회 불렀다" + } + + assertTrue(violations.joinToString("\n"), violations.isEmpty()) + } + + private fun render(card: RecordCard) { + val existing = slot + if (existing == null) { + val created = mutableStateOf(card) + slot = created + composeRule.setContent { + AfternoteTheme { + when (created.value) { + RecordCard.DailyQuestionListCard -> { + // 메뉴 항목 콜백은 이 테스트의 관심사가 아니다 — 다만 «더보기 메뉴» + // 자체가 그려져야 중첩 클릭을 볼 수 있으므로 no-op 이라도 넘긴다. + DailyQuestionListCard( + answer = SAMPLE_QUESTION, + onClick = { cardClicks++ }, + onEdit = {}, + onDelete = {}, + ) + } + + RecordCard.DiaryCard -> { + DiaryCard( + diary = SAMPLE_DIARY, + onClick = { cardClicks++ }, + onEdit = {}, + onDelete = {}, + ) + } + + RecordCard.DiaryComponent -> { + DiaryComponent( + diary = SAMPLE_DIARY, + onClick = { cardClicks++ }, + onEdit = {}, + onDelete = {}, + ) + } + + RecordCard.DiaryCardInGrid -> { + DiaryCard( + diary = SAMPLE_DIARY_WITH_IMAGE, + modifier = Modifier.width(GRID_COLUMN_WIDTH), + onClick = { cardClicks++ }, + onEdit = {}, + onDelete = {}, + ) + } + } + } + } + } else { + composeRule.runOnIdle { existing.value = card } + } + composeRule.waitForIdle() + } + + private companion object { + /** `mindrecord_more_menu_cd` 의 값. 세 카드가 같은 문자열을 쓴다. */ + const val MORE_MENU_NAME = "더보기 메뉴" + + val SAMPLE_DATE: LocalDate = LocalDate.of(2026, 9, 2) + + val SAMPLE_QUESTION = + DailyQuestion( + id = 1L, + title = QUESTION_TITLE, + date = SAMPLE_DATE, + content = "먼저 연락해 준 친구에게 고마웠다.", + ) + + val SAMPLE_DIARY = + DailyDiary( + id = 2L, + title = DIARY_TITLE, + date = SAMPLE_DATE, + content = "오늘은 오래 걸었다.", + ) + + /** `DiaryScreen` 의 `LazyVerticalStaggeredGrid(Fixed(2))` 열 폭 상한. */ + val GRID_COLUMN_WIDTH = 176.dp + + val SAMPLE_DIARY_WITH_IMAGE = SAMPLE_DIARY.copy(imageUrl = "https://cdn.example.net/a.png") + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/a11y/WriteToolbarAccessibilityTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/a11y/WriteToolbarAccessibilityTest.kt new file mode 100644 index 0000000000..84ebb0a4af --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/a11y/WriteToolbarAccessibilityTest.kt @@ -0,0 +1,422 @@ +package com.afternote.feature.mindrecord.presentation.a11y + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.testing.EnabledClickTarget +import com.afternote.core.ui.testing.MinimumTouchTargetSize +import com.afternote.core.ui.testing.scanEnabledClickTargets +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.presentation.component.BottomToolbar +import com.afternote.feature.mindrecord.presentation.component.DailyQuestionListCard +import com.afternote.feature.mindrecord.presentation.component.DiaryCard +import com.afternote.feature.mindrecord.presentation.component.DiaryComponent +import com.afternote.feature.mindrecord.presentation.component.MemoriesCard +import com.afternote.feature.mindrecord.presentation.component.ReceiverMindRecordTopBar +import com.afternote.feature.mindrecord.presentation.component.TextStyleToolbar +import com.afternote.feature.mindrecord.presentation.model.DailyDiary +import com.afternote.feature.mindrecord.presentation.model.DailyQuestion +import com.afternote.feature.mindrecord.presentation.model.TextStyleState +import com.afternote.feature.mindrecord.presentation.screen.receiver.ReceiverMindRecordFilterSheet +import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryWriteScreenContent +import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteUiState +import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordFilter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate + +/** + * 작성 툴바의 클릭 타깃 접근성 계약 (#1179). + * + * ### 이슈의 전제가 실측과 달랐다 + * + * #1179 는 「48dp 미만 11곳」을 후보로 들고 스캐너 실측 뒤 착수하라고 적었다. 재 보니 + * **크기 미달은 0건**이다 — `layout` 은 20~24dp 라도 시스템 최소 터치 타깃 확장이 붙어 + * `touch` 는 전부 48dp 이상이다. 그래서 `minimumInteractiveComponentSize` 를 덧붙이지 않았다. + * 붙였다면 레이아웃 footprint 만 48dp 로 밀려 시안과 어긋났을 것이다. + * + * 대신 같은 스캐너가 **다른 계약 위반**을 드러냈고 그쪽을 고쳤다. + * + * | 위반 | 건수 | 처방 | + * |---|---|---| + * | 접근 가능한 이름 누락 | 3 | 정렬 아이콘에 `contentDescription`(이미 있던 문자열) | + * | Role 누락 | 16 | 버튼은 `Role.Button`, 정렬·서식 스타일은 선택 semantics | + * | 중첩 클릭 | 3 | **이 PR 범위 밖** — 아래 참조 | + * + * ### 정렬과 서식은 semantics 가 다르다 + * + * 종전에는 둘 다 맨 `clickable` 이라 역할도 상태도 안 실렸다. 정렬은 «셋 중 하나»(`selectable` + * + `Role.RadioButton` + `selectableGroup`), 서식 B·I·U·S 는 «각각 켜고 끄기»(`toggleable` + + * `Role.Checkbox`) 다. 스크린리더가 두 묶음을 다르게 읽어야 한다. + * + * ### 중첩 클릭 3건을 여기서 안 본 이유 + * + * 카드 전체가 상세로 가는 `clickable` 인데(#759) 그 안의 «더보기 메뉴» 도 `clickable` 이라 + * 스캐너가 중첩으로 잡는다. 「행 + 끝단 액션」은 흔한 형태이고 스캐너도 텍스트필드 끝단 + * 버튼에는 예외를 두고 있어, **공용 스캐너의 예외를 넓힐지 카드를 재구성할지는 이 PR 이 + * 혼자 정할 문제가 아니다.** 별도 이슈로 남겼다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], qualifiers = "w360dp-h800dp-xhdpi") +class WriteToolbarAccessibilityTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private var alignSlot: MutableState? = null + private var candidateSlot: MutableState? = null + + @Test + fun `작성 툴바의 모든 클릭 타깃은 48dp 이상이고 이름과 역할을 갖는다`() { + renderToolbars() + + val targets = composeRule.scanEnabledClickTargets() + assertTrue("클릭 타깃을 하나도 못 찾았다", targets.isNotEmpty()) + + val tooSmall = targets.filter { it.width < MinimumTouchTargetSize || it.height < MinimumTouchTargetSize } + val unnamed = targets.filter { it.name.isBlank() } + val roleless = targets.filter { it.role == null } + + assertTrue("48dp 미달: ${tooSmall.describe()}", tooSmall.isEmpty()) + assertTrue("이름 없음: ${unnamed.describe()}", unnamed.isEmpty()) + assertTrue("Role 없음: ${roleless.describe()}", roleless.isEmpty()) + } + + /** + * 정렬 그룹은 **두 곳**에 있다 — 늘 보이는 하단 바와, 키보드 위 스타일 패널이다. 종전에는 + * 하단 바 쪽만 맨 `IconButton` 이라 선택 상태가 안 실렸는데, 작성 화면에서 실제로 늘 보이는 + * 건 그쪽이다. 그래서 한 툴바만 보지 않고 둘을 각각 렌더해 같은 계약을 건다 (#1179 리뷰). + */ + @Test + fun `정렬은 두 툴바 모두에서 단일 선택이다`() { + AlignedToolbar.entries.forEach { toolbar -> + renderAligned(toolbar, TextAlign.Center) + + val aligns = composeRule.scanEnabledClickTargets().filter { it.name in ALIGN_NAMES } + assertTrue("${toolbar.label}: 정렬 3개를 못 찾았다: ${aligns.describe()}", aligns.size == 3) + assertTrue("${toolbar.label}: 단일 선택이 아니다: ${aligns.describe()}", aligns.all { it.role == Role.RadioButton }) + + val selected = aligns.filter { it.selected == true } + assertTrue("${toolbar.label}: 선택된 정렬이 정확히 하나가 아니다: ${aligns.describe()}", selected.size == 1) + // 「하나가 켜져 있다」로는 부족하다 — 넘긴 값과 켜진 항목이 어긋나도 통과하기 때문이다. + assertEquals("${toolbar.label}: 넘긴 정렬과 켜진 항목이 다르다", "가운데 정렬", selected.single().name) + } + } + + /** + * 작성 화면 **첫 상태**. richeditor 의 기본 문단은 `textAlign` 이 `Unspecified` 라, 정규화가 + * 없으면 정렬 3종의 `selected` 가 모두 false 가 된다 — 스크린리더가 「선택 안 됨」을 셋 다 + * 읽어 «셋 중 하나» 계약이 처음부터 깨진다. 위 테스트는 `TextAlign.Center` 를 명시해서 + * 이 자리를 못 잡는다 (#1179 리뷰). + */ + @Test + fun `정렬을 고른 적 없어도 왼쪽 하나가 켜져 있다`() { + AlignedToolbar.entries.forEach { toolbar -> + renderAligned(toolbar, TextAlign.Unspecified) + + val aligns = composeRule.scanEnabledClickTargets().filter { it.name in ALIGN_NAMES } + val selected = aligns.filter { it.selected == true } + assertEquals("${toolbar.label}: 첫 상태에서 켜진 정렬이 정확히 하나가 아니다: ${aligns.describe()}", 1, selected.size) + assertEquals("${toolbar.label}: 첫 상태의 기본 정렬이 왼쪽이 아니다", "왼쪽 정렬", selected.single().name) + } + } + + @Test + fun `서식은 개별 토글이다`() { + renderStyleToolbarOnly() + val targets = composeRule.scanEnabledClickTargets() + + // 서식 넷 — 서로 독립인 토글이라 «선택» 이 아니라 «켜짐/꺼짐» 이다. + val styles = targets.filter { it.name in setOf("굵게", "기울임", "밑줄", "취소선") } + assertTrue("서식 4개를 못 찾았다: ${targets.map { it.name }}", styles.size == 4) + assertTrue("서식이 토글이 아니다: ${styles.describe()}", styles.all { it.role == Role.Checkbox }) + assertTrue("서식에 토글 상태가 없다: ${styles.describe()}", styles.all { it.toggleableState != null }) + } + + /** + * 기호 하나(「T」)나 숫자 하나(「1」)가 그대로 이름이 되던 타깃들. + * + * 「비어 있지 않다」만 보면 이런 이름도 통과한다 — 낭독하면 「T, 버튼」·「1, 버튼」 이라 + * 무엇을 하는 버튼인지 알 수 없다. 그래서 **정확한 이름**을 건다 (#1179 리뷰). + */ + @Test + fun `기호로만 읽히던 타깃이 목적을 말한다`() { + renderBottomToolbarOnly(draftCount = 3) + val names = composeRule.scanEnabledClickTargets().map { it.name } + + assertTrue("텍스트 설정 진입 버튼이 「T」로만 읽힌다: $names", "텍스트 설정" in names) + assertTrue("임시저장 수량이 숫자로만 읽힌다: $names", "임시저장 목록 3개" in names) + assertTrue("이름이 기호 하나로 남은 타깃이 있다: $names", names.none { it == "T" || it == "3" }) + } + + /** 수량을 모를 때(조회 중·실패)도 「–, 버튼」 대신 그 사실을 말해야 한다. */ + @Test + fun `임시저장 수량을 모를 때도 목적을 말한다`() { + renderBottomToolbarOnly(draftCount = null) + val names = composeRule.scanEnabledClickTargets().map { it.name } + + assertTrue("수량 미확인이 「–」로만 읽힌다: $names", "임시저장 목록, 수량 미확인" in names) + } + + /** + * #1179 가 든 후보 중 **툴바 밖** 것들. 두 툴바만 보면 나머지의 48dp 결론이 CI 에서 + * 재현되지 않는다 (#1179 리뷰). + * + * 여기서 실제로 위반이 여럿 나왔고 함께 고쳤다 — 수신자 상단바의 필터 타깃은 자식이 없어 + * **이름이 통째로 비어** 있었고, 필터 시트의 정렬 칩은 색만 바뀔 뿐 선택 상태가 semantics 에 + * 없었다. `DiaryWriteScreenContent` 까지 넓힌 뒤에는 본문 편집기의 이름 누락·수신자 행의 + * Role 누락·기분 칩 3종의 역할과 선택 상태 누락이 더 나왔다. 스캐너를 안 돌렸으면 전부 + * 안 보였다. + */ + @Test + fun `툴바 밖 후보도 48dp 이상이고 이름과 역할을 갖는다`() { + OtherCandidate.entries.forEach { candidate -> + renderCandidate(candidate) + val targets = composeRule.scanEnabledClickTargets().filterNot { it.name in FRAMEWORK_OWNED_NAMES } + assertTrue("${candidate.label}: 클릭 타깃을 하나도 못 찾았다", targets.isNotEmpty()) + + val tooSmall = targets.filter { it.width < MinimumTouchTargetSize || it.height < MinimumTouchTargetSize } + val unnamed = targets.filter { it.name.isBlank() } + // 텍스트 입력은 역할 대신 편집 semantics 로 읽히므로 공용 스캐너와 같은 기준으로 뺀다. + val roleless = targets.filter { it.role == null && !it.isEditableText && it.name !in SLOT_OWNED_NAMES } + + assertTrue("${candidate.label} 48dp 미달: ${tooSmall.describe()}", tooSmall.isEmpty()) + assertTrue("${candidate.label} 이름 없음: ${unnamed.describe()}", unnamed.isEmpty()) + assertTrue("${candidate.label} Role 없음: ${roleless.describe()}", roleless.isEmpty()) + } + } + + private enum class AlignedToolbar( + val label: String, + ) { + Bottom("하단 바"), + StylePanel("스타일 패널"), + } + + /** #1179 후보 중 툴바 밖에 있어 이 PR 이 새로 렌더하는 것들. */ + private enum class OtherCandidate( + val label: String, + ) { + DailyQuestionListCard("데일리질문 카드"), + DiaryCard("일기 카드"), + DiaryComponent("일기 컴포넌트"), + ReceiverMindRecordTopBar("수신자 상단바"), + MemoriesCard("MEMORIES 카드"), + ReceiverMindRecordFilterSheet("수신자 필터 시트"), + DiaryWriteScreen("일기 작성 화면"), + } + + /** + * `setContent` 는 rule 당 한 번만 부를 수 있다. 후보를 여럿 도는 테스트는 **상태를 바꿔** + * 같은 트리에서 갈아 끼운다 — 후보마다 rule 을 새로 만들면 한 테스트에 담을 수 없다. + */ + private fun renderAligned( + toolbar: AlignedToolbar, + textAlign: TextAlign, + ) { + val slot = alignSlot + if (slot == null) { + val created = mutableStateOf(toolbar) + alignSlot = created + composeRule.setContent { + AfternoteTheme { + when (created.value) { + AlignedToolbar.Bottom -> { + BottomToolbar( + onTextStyleClick = {}, + onAlignChange = {}, + onDraftCountClick = {}, + onSaveDraftClick = {}, + onLinkClick = {}, + textAlign = textAlign, + ) + } + + AlignedToolbar.StylePanel -> { + TextStyleToolbar( + onClose = {}, + onTypeClick = {}, + onLinkClick = {}, + onBoldClick = {}, + onItalicClick = {}, + onUnderlineClick = {}, + onStrikethroughClick = {}, + onAlignChange = {}, + onTextStyleChange = {}, + styleState = TextStyleState(textAlign = textAlign), + ) + } + } + } + } + } else { + composeRule.runOnIdle { slot.value = toolbar } + } + composeRule.waitForIdle() + } + + private fun renderBottomToolbarOnly(draftCount: Int?) { + composeRule.setContent { + AfternoteTheme { + BottomToolbar( + onTextStyleClick = {}, + onAlignChange = {}, + onDraftCountClick = {}, + onSaveDraftClick = {}, + onLinkClick = {}, + draftCount = draftCount, + ) + } + } + } + + private fun renderCandidate(candidate: OtherCandidate) { + val slot = candidateSlot + if (slot == null) { + val created = mutableStateOf(candidate) + candidateSlot = created + composeRule.setContent { + AfternoteTheme { + when (created.value) { + OtherCandidate.DailyQuestionListCard -> { + DailyQuestionListCard( + answer = SAMPLE_QUESTION, + onClick = {}, + onEdit = {}, + onDelete = {}, + ) + } + + OtherCandidate.DiaryCard -> { + DiaryCard(diary = SAMPLE_DIARY, onClick = {}, onEdit = {}, onDelete = {}) + } + + OtherCandidate.DiaryComponent -> { + DiaryComponent(diary = SAMPLE_DIARY, onClick = {}, onEdit = {}, onDelete = {}) + } + + OtherCandidate.ReceiverMindRecordTopBar -> { + ReceiverMindRecordTopBar(filter = ReceiverMindRecordFilter(), onFilterClick = {}) + } + + OtherCandidate.MemoriesCard -> { + // 다시 읽기 CTA 가 있는 판을 그린다 — null 이면 그 타깃 자체가 없어 + // 스캔이 카드 본체만 보게 된다 (#793 이 그 분기를 세웠다). + MemoriesCard(question = "질문", answer = "답변", onReadAgainClick = {}) + } + + OtherCandidate.ReceiverMindRecordFilterSheet -> { + ReceiverMindRecordFilterSheet( + current = ReceiverMindRecordFilter(), + onDismiss = {}, + onApply = {}, + onReset = {}, + ) + } + + // 화면 자체는 `hiltViewModel()` 을 물지만 `DiaryWriteScreenContent` 가 + // 상태만 받는 seam 으로 열려 있어 그쪽을 렌더한다. 상단바 완료 버튼과 + // 기분 선택처럼 툴바 밖에 있는 타깃이 여기에서만 드러난다. + OtherCandidate.DiaryWriteScreen -> { + DiaryWriteScreenContent( + uiState = DiaryWriteUiState(), + onBackClick = {}, + onSubmit = {}, + onSaveDraft = {}, + onDraftListClick = {}, + onTitleChanged = {}, + onContentChanged = {}, + onMoodSelected = {}, + onReceiverRowClick = {}, + onDateRowClick = {}, + ) + } + } + } + } + } else { + composeRule.runOnIdle { slot.value = candidate } + } + composeRule.waitForIdle() + } + + private fun renderStyleToolbarOnly() { + composeRule.setContent { + AfternoteTheme { + TextStyleToolbar( + onClose = {}, + onBoldClick = {}, + onItalicClick = {}, + onUnderlineClick = {}, + onStrikethroughClick = {}, + onAlignChange = {}, + onTextStyleChange = {}, + styleState = TextStyleState(), + onLinkClick = {}, + onTypeClick = {}, + ) + } + } + } + + private fun renderToolbars() { + composeRule.setContent { + AfternoteTheme { + Column { + BottomToolbar( + onTextStyleClick = {}, + onAlignChange = {}, + onLinkClick = {}, + onSaveDraftClick = {}, + onDraftCountClick = {}, + ) + TextStyleToolbar( + onClose = {}, + onBoldClick = {}, + onItalicClick = {}, + onUnderlineClick = {}, + onStrikethroughClick = {}, + onAlignChange = {}, + onTextStyleChange = {}, + styleState = TextStyleState(), + onLinkClick = {}, + onTypeClick = {}, + ) + } + } + } + } + + private fun List.describe(): String = + joinToString { "${it.name.ifBlank { "(이름없음)" }} ${it.width}x${it.height} role=${it.role}" } + + private companion object { + val ALIGN_NAMES = setOf("왼쪽 정렬", "가운데 정렬", "오른쪽 정렬") + + /** + * material3 가 스스로 만들어 넣는 타깃. 시트 바깥을 덮는 scrim 이고 이름·역할 모두 + * 라이브러리 소관이라 이 저장소에서 고칠 수 없다 — 판정에서 뺀다. + */ + val FRAMEWORK_OWNED_NAMES = setOf("Close sheet") + + /** + * 우리가 내용을 채우지만 **클릭 액션은 material3 가 얹는** 슬롯. 시트 접기·펴기 손잡이라 + * 버튼도 선택도 아니어서 Role 을 주면 오히려 틀리게 읽힌다. 이름은 우리가 채웠다. + */ + val SLOT_OWNED_NAMES = setOf("필터 시트 크기 조절") + + val SAMPLE_QUESTION = + DailyQuestion(title = "질문", date = LocalDate.of(2026, 1, 1), content = "답변") + val SAMPLE_DIARY = + DailyDiary(title = "일기", date = LocalDate.of(2026, 1, 1), content = "본문") + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/MediaSheetMigrationTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/MediaSheetMigrationTest.kt new file mode 100644 index 0000000000..ea2cabe567 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/MediaSheetMigrationTest.kt @@ -0,0 +1,84 @@ +package com.afternote.feature.mindrecord.presentation.component + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.ui.theme.AfternoteTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import com.afternote.core.ui.R as CoreUiR + +/** + * 「미디어 추가하기」 시트가 **core:ui 정본**으로 뜨는지 (#642 · #1615). + * + * 종전에는 같은 시트를 모듈마다 다시 적어 4벌이었고, 사본들이 시안(4327:72281) 지오메트리에서 + * 조금씩 어긋나 있었다. mindrecord 판은 행 좌우 여백·디바이더 폭이 정본과 달랐다. + * + * ### 왜 「4개 항목이 뜬다」로 끝내지 않는가 + * + * 항목 문구는 사본과 정본이 **글자까지 같다**(`미디어 추가하기`·`이미지 추가하기`…). 문구만 + * 보면 사본으로 되돌려도 통과한다. 그래서 **문자열이 어느 모듈 것인지**를 함께 본다 — + * `core_ui_media_sheet_*` 를 실제로 조회해 그 값으로 단언하므로, mindrecord 가 자기 사본 + * 문자열을 되살리면(그리고 그 값이 달라지면) 여기서 갈린다. + * + * 사본 자체가 지워진 것은 컴파일이 지킨다 — 파일이 없으므로 되살리려면 새로 만들어야 한다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class MediaSheetMigrationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `툴바 미디어 버튼이 core 정본 시트를 연다`() { + composeRule.setContent { AfternoteTheme { WriteTextField(onSaveDraftClick = {}, onDraftCountClick = {}) } } + val context = composeRule.activity + + openMediaSheet() + + composeRule.onNodeWithText(context.getString(CoreUiR.string.core_ui_media_sheet_title)).assertIsDisplayed() + listOf( + CoreUiR.string.core_ui_media_sheet_image, + CoreUiR.string.core_ui_media_sheet_voice, + CoreUiR.string.core_ui_media_sheet_file, + CoreUiR.string.core_ui_media_sheet_link, + ).forEach { label -> + // `assertIsDisplayed` 가 아니라 `assertExists` 다 — 정본 지오메트리에서는 시트가 + // 더 높아 Robolectric 기본 뷰포트에서 마지막 행이 화면 밖으로 나간다. 여기서 + // 보려는 것은 «항목 4개가 core 문자열로 실렸는가» 이지 뷰포트 안에 드는지가 아니다. + composeRule.onNodeWithText(context.getString(label)).assertExists() + } + } + + /** + * 「링크 추가하기」만 후속 시트로 이어지고 나머지 셋은 시스템 피커로 나간다 — 이관하면서 + * 항목 순서나 액션이 어긋나면 사용자가 엉뚱한 피커를 만난다. + */ + @Test + fun `링크 항목은 링크 입력 시트로 이어진다`() { + composeRule.setContent { AfternoteTheme { WriteTextField(onSaveDraftClick = {}, onDraftCountClick = {}) } } + val context = composeRule.activity + + openMediaSheet() + composeRule.onNodeWithText(context.getString(CoreUiR.string.core_ui_media_sheet_link)).performClick() + + // 미디어 시트가 닫히고 링크 입력 시트가 대신 뜬다. + composeRule.onNodeWithText(context.getString(CoreUiR.string.core_ui_media_sheet_title)).assertDoesNotExist() + } + + private fun openMediaSheet() { + composeRule + .onNodeWithContentDescription( + composeRule.activity.getString( + com.afternote.feature.mindrecord.presentation.R.string.mindrecord_toolbar_link_cd, + ), + ).performClick() + composeRule.waitForIdle() + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/RecordActionAffordanceTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/RecordActionAffordanceTest.kt new file mode 100644 index 0000000000..b9324c05e6 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/RecordActionAffordanceTest.kt @@ -0,0 +1,110 @@ +package com.afternote.feature.mindrecord.presentation.component + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.model.DailyQuestion +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate + +/** + * «더보기» 는 실제로 동작할 때만 그린다 (#1540). + * + * 종전에는 `onEdit`·`onDelete` 가 `= {}` 디폴트라, 핸들러를 넘기지 않은 화면에서도 메뉴가 + * 뜨고 «수정»·«삭제» 를 눌러도 아무 일이 없었다. 실제로 주간 리포트 HISTORY 가 그 상태였다 + * (`WeeklyReportScreen` 이 `DailyQuestionListCard(answer = …)` 만 넘기고 있었다). + * + * 디폴트를 걷어내 컴파일이 배선을 강제하게 했고, 상호작용이 없는 자리는 `null` 로 **버튼 + * 자체를 숨긴다.** 「눌러도 아무 일 없는 버튼」을 그리지 않는 것이 이 가드의 계약이다 + * (#582 · #618 · #722 · #777 전례). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RecordActionAffordanceTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val answer = + DailyQuestion( + title = "오늘 하루, 누구에게 가장 고마웠나요?", + content = "아무 말 없이 곁을 지켜주는 사람.", + date = LocalDate.of(2026, 8, 30), + ) + + @Test + fun `핸들러가 없으면 더보기를 그리지 않는다`() { + composeRule.setContent { + AfternoteTheme { + DailyQuestionListCard( + answer = answer, + onEdit = null, + onDelete = null, + onClick = {}, + ) + } + } + + composeRule + .onNodeWithContentDescription(moreMenuLabel()) + .assertDoesNotExist() + } + + @Test + fun `핸들러가 있으면 더보기를 그린다`() { + composeRule.setContent { + AfternoteTheme { + DailyQuestionListCard( + answer = answer, + onEdit = {}, + onDelete = {}, + onClick = {}, + ) + } + } + + composeRule + .onNodeWithContentDescription(moreMenuLabel()) + .assertIsDisplayed() + } + + @Test + fun `onClick 이 없으면 카드에 클릭 semantics 를 붙이지 않는다`() { + // no-op 을 넘기면 `Role.Button` 이 그대로 실려 스크린리더가 「버튼」으로 읽는데 눌러도 + // 아무 일이 없다 — 주간 리포트 HISTORY 가 그 상태였다 (#1540 리뷰). + composeRule.setContent { + AfternoteTheme { + DailyQuestionListCard(answer = answer, onClick = null, onEdit = null, onDelete = null) + } + } + + composeRule.onAllNodes(hasClickAction()).assertCountEquals(0) + } + + @Test + fun `onClick 이 있으면 카드가 눌린다`() { + var clicks = 0 + composeRule.setContent { + AfternoteTheme { + DailyQuestionListCard(answer = answer, onClick = { clicks += 1 }, onEdit = null, onDelete = null) + } + } + + composeRule.onAllNodes(hasClickAction()).assertCountEquals(1) + composeRule.onNodeWithText(answer.title).performClick() + assertEquals("카드 탭이 전달되지 않았다", 1, clicks) + } + + private fun moreMenuLabel(): String = composeRule.activity.getString(R.string.mindrecord_more_menu_cd) +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/RecordActionMenuMigrationTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/RecordActionMenuMigrationTest.kt new file mode 100644 index 0000000000..ebad3361e7 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/RecordActionMenuMigrationTest.kt @@ -0,0 +1,107 @@ +package com.afternote.feature.mindrecord.presentation.component + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.presentation.model.DailyQuestion +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate +import com.afternote.core.ui.R as CoreUiR +import com.afternote.feature.mindrecord.presentation.R as MindRecordR + +/** + * 「수정/삭제」 액션 메뉴가 **core:ui 정본**으로 뜨는지 (#643 · #1730). + * + * 종전에는 mindrecord 가 `RecordActionPopup` 을 따로 들고 있었고, 정본과 그림자(10dp ↔ 8dp)· + * 너비(120dp 고정 ↔ 가장 긴 항목)·**항목 순서**가 달랐다. 순서는 사용자가 눈으로 바로 아는 + * 차이라 두 벌이 남아 있는 동안 화면마다 «삭제» 가 위였다 아래였다 했다. + * + * ### 「두 항목이 뜬다」로 끝내지 않는 이유 + * + * 문구는 사본과 정본이 글자까지 같다(`수정하기`·`삭제하기`). 문구만 보면 사본을 되살려도 + * 통과한다. 그래서 **어느 모듈의 문자열인지**(`core_ui_action_menu_*` 를 실제로 조회)와 + * **위아래 순서**를 함께 본다. + * + * 사본이 지워진 것은 컴파일이 지킨다 — 파일이 없으므로 되살리려면 새로 만들어야 한다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RecordActionMenuMigrationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val answer = + DailyQuestion( + title = "오늘 하루, 누구에게 가장 고마웠나요?", + content = "아무 말 없이 곁을 지켜주는 사람.", + date = LocalDate.of(2026, 8, 30), + ) + + @Test + fun `더보기는 core 정본 메뉴를 수정 다음 삭제 순서로 연다`() { + renderCard() + openMenu() + + val context = composeRule.activity + val edit = composeRule.onNodeWithText(context.getString(CoreUiR.string.core_ui_action_menu_edit)) + val delete = composeRule.onNodeWithText(context.getString(CoreUiR.string.core_ui_action_menu_delete)) + + edit.assertIsDisplayed() + delete.assertIsDisplayed() + assertTrue( + "정본 순서는 「수정하기 → 삭제하기」 다", + edit.getUnclippedBoundsInRoot().top < delete.getUnclippedBoundsInRoot().top, + ) + } + + /** 정본은 항목 콜백보다 dismiss 를 **먼저** 부른다 — 호출부가 메뉴를 따로 닫지 않아도 된다. */ + @Test + fun `항목을 누르면 메뉴가 닫히고 콜백이 한 번 불린다`() { + var edits = 0 + renderCard(onEdit = { edits += 1 }) + openMenu() + + composeRule + .onNodeWithText(composeRule.activity.getString(CoreUiR.string.core_ui_action_menu_edit)) + .performClick() + + assertEquals(1, edits) + composeRule + .onNodeWithText(composeRule.activity.getString(CoreUiR.string.core_ui_action_menu_delete)) + .assertDoesNotExist() + } + + private fun renderCard( + onEdit: () -> Unit = {}, + onDelete: () -> Unit = {}, + ) { + composeRule.setContent { + AfternoteTheme { + DailyQuestionListCard( + answer = answer, + onEdit = onEdit, + onDelete = onDelete, + onClick = {}, + ) + } + } + } + + private fun openMenu() { + composeRule + .onNodeWithContentDescription(composeRule.activity.getString(MindRecordR.string.mindrecord_more_menu_cd)) + .performClick() + composeRule.waitForIdle() + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyCardFontScaleTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyCardFontScaleTest.kt new file mode 100644 index 0000000000..b69193b8c2 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/WeeklyCardFontScaleTest.kt @@ -0,0 +1,88 @@ +package com.afternote.feature.mindrecord.presentation.component + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 주간 요약 카드가 **사용자 글자 크기를 따라 자라는지** (#1718). + * + * 종전에는 카드가 `height(200.dp)` 로 못 박혀 있었다. 안의 내용은 텍스트 다섯 줄이고 전부 + * 사용자 폰트 배율을 따르므로, 배율을 올리면 내용이 200dp 를 넘고 마지막 행인 카운트 + * 라벨(「데일리 질문」·「일기」)부터 카드 경계에 잘렸다. 실기 실측에서 배율 1.3 에 재현됐다. + * + * 글자 크기를 키우는 것은 접근성 설정의 기본 사용이라, **저시력 사용자가 정확히 이 화면에서 + * 값을 잃는다.** + * + * ### 「자란다」만 보지 않는다 + * + * 높이를 그냥 `wrapContentHeight` 로 풀면 배율 1.0 에서 카드가 시안(200dp)보다 작아진다. + * 그래서 **기본 배율의 높이가 그대로인 것**과 **배율을 올리면 잘리지 않는 것**을 함께 본다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], qualifiers = "w360dp-h800dp-xhdpi") +class WeeklyCardFontScaleTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `기본 배율에서는 시안 높이를 그대로 쓴다`() { + renderCard(fontScale = 1.0f) + + val card = composeRule.onRoot().getUnclippedBoundsInRoot() + assertEquals(200.dp, card.bottom - card.top) + } + + /** + * 배율은 **2.0** 으로 잡는다. Android 접근성 설정의 최대치이고, Robolectric 폰트 메트릭이 + * 실기와 미세하게 달라 1.3 에서는 4dp 여유로 아슬하게 들어가 버린다(실측: 라벨 bottom + * 176dp / 내용 영역 180dp). 실기에서는 1.3 에서 이미 잘렸다 — 여기서는 경계를 넘기는 + * 값으로 «자라는가» 를 확실히 가른다. + */ + @Test + fun `글자를 키우면 카드가 함께 자라 라벨이 잘리지 않는다`() { + renderCard(fontScale = 2.0f) + + val card = composeRule.onRoot().getUnclippedBoundsInRoot() + val label = composeRule.onNodeWithText(diaryLabel()).getUnclippedBoundsInRoot() + + assertTrue( + "카드가 시안 높이에 묶여 자라지 않는다: ${card.bottom - card.top}", + card.bottom - card.top > 200.dp, + ) + assertTrue( + "카운트 라벨이 카드 밖으로 밀려 잘린다: 라벨 bottom=${label.bottom}, 카드 bottom=${card.bottom}", + label.bottom <= card.bottom, + ) + } + + private fun diaryLabel(): String = + composeRule.activity.getString( + com.afternote.feature.mindrecord.presentation.R.string.mindrecord_category_diary_title, + ) + + private fun renderCard(fontScale: Float) { + composeRule.setContent { + val base = LocalDensity.current + CompositionLocalProvider( + LocalDensity provides Density(density = base.density, fontScale = fontScale), + ) { + AfternoteTheme { WeeklyReportReviewCard(onWeekSelect = {}) } + } + } + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextFieldReseedTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextFieldReseedTest.kt index 6f4d51566c..2d9d8b0de6 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextFieldReseedTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/component/WriteTextFieldReseedTest.kt @@ -32,7 +32,13 @@ class WriteTextFieldReseedTest { fun `나중에 도착한 본문이 에디터에 실린다`() { var content by mutableStateOf(null) composeRule.setContent { - AfternoteTheme { WriteTextField(value = content) } + AfternoteTheme { + WriteTextField( + value = content, + onDraftCountClick = {}, + onSaveDraftClick = {}, + ) + } } composeRule.runOnIdle { content = "

서버에서 도착한 임시저장 본문

" } @@ -48,7 +54,12 @@ class WriteTextFieldReseedTest { var changes = 0 composeRule.setContent { AfternoteTheme { - WriteTextField(value = content, onValueChange = { changes += 1 }) + WriteTextField( + value = content, + onValueChange = { changes += 1 }, + onDraftCountClick = {}, + onSaveDraftClick = {}, + ) } } composeRule.waitForIdle() diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/hometab/MemoriesReadAgainTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/hometab/MemoriesReadAgainTest.kt index ad009dabe1..3cd7dc3405 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/hometab/MemoriesReadAgainTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/hometab/MemoriesReadAgainTest.kt @@ -14,11 +14,15 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * MEMORIES 카드의 "그날의 기록 다시 읽기" 가 죽은 영역이 아닌지 (#793). + * MEMORIES 카드의 "그날의 기록 다시 읽기" 목적지 (#793). * - * 목적지가 미확정이라 인자를 비워 뒀는데, 버튼이 자체 `clickable` 이라 클릭을 삼켰다. - * 결과는 «카드 여백을 누르면 추억 공간이 열리고, 정작 버튼만 아무 일도 안 한다» 였다. - * 최종 목적지가 확정될 때까지 버튼은 자기 컨테이너와 같은 곳으로 간다. + * 처음에는 목적지가 미확정이라 인자를 비워 뒀고, 버튼이 자체 `clickable` 이라 클릭을 삼켜 + * «카드 여백을 누르면 추억 공간이 열리고, 정작 버튼만 아무 일도 안 한다» 였다. + * + * 디자인 게이트가 해제(2026-08-28)돼 목적지를 확정했다 — 문구가 「**그날의** 기록 다시 읽기」라 + * **카드가 보여 주는 그 한 건의 상세**로 간다. 카드·섹션 전체는 종전대로 추억 공간이다. + * + * 두 목적지가 갈린다는 것 자체가 계약이라, 섹션 클릭과 버튼 클릭을 따로 센다. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) @@ -27,15 +31,17 @@ class MemoriesReadAgainTest { val composeRule = createAndroidComposeRule() @Test - fun `버튼을 누르면 카드와 같은 목적지로 간다`() { - var clicks = 0 + fun `버튼은 섹션과 다른 목적지로 간다`() { + var sectionClicks = 0 + var readAgainClicks = 0 // MutableInteractionSource 는 @RememberInComposition 이라 컴포지션 안에서 맨손으로 // 만들 수 없다 — 컴포지션마다 새 인스턴스가 생겨 상호작용 상태가 유실된다. val interactionSource = MutableInteractionSource() composeRule.setContent { AfternoteTheme { MemoriesSectionContent( - onMemoriesSectionClick = { clicks += 1 }, + onMemoriesSectionClick = { sectionClicks += 1 }, + onReadAgainClick = { readAgainClicks += 1 }, clickLabel = "추억 공간 열기", interactionSource = interactionSource, question = "가장 소중했던 순간은?", @@ -46,6 +52,51 @@ class MemoriesReadAgainTest { composeRule.onNodeWithText("그날의 기록 다시 읽기").performClick() - assertEquals(1, clicks) + // 버튼은 상세로만 간다 — 섹션 클릭까지 함께 세면 추억 공간이 겹쳐 열린다. + assertEquals("버튼이 상세로 가지 않았다", 1, readAgainClicks) + assertEquals("버튼 클릭이 섹션 클릭까지 태웠다", 0, sectionClicks) + } + + @Test + fun `카드 본문을 누르면 섹션 목적지로 간다`() { + var sectionClicks = 0 + var readAgainClicks = 0 + val interactionSource = MutableInteractionSource() + composeRule.setContent { + AfternoteTheme { + MemoriesSectionContent( + onMemoriesSectionClick = { sectionClicks += 1 }, + onReadAgainClick = { readAgainClicks += 1 }, + clickLabel = "추억 공간 열기", + interactionSource = interactionSource, + question = "가장 소중했던 순간은?", + answer = "아이가 태어났을 때.", + ) + } + } + + composeRule.onNodeWithText("가장 소중했던 순간은?").performClick() + + assertEquals("카드 본문이 섹션 목적지로 가지 않았다", 1, sectionClicks) + assertEquals("카드 본문이 상세까지 태웠다", 0, readAgainClicks) + } + + @Test + fun `기록이 0건이면 다시 읽기 버튼을 그리지 않는다`() { + // 문구가 「**그날의** 기록 다시 읽기」다 — 열 기록이 없는데 버튼을 남기면 그 약속을 + // 지킬 목적지가 없다. 다른 곳으로 보내는 대신 버튼을 안 그린다 (#793 리뷰). + val interactionSource = MutableInteractionSource() + composeRule.setContent { + AfternoteTheme { + MemoriesSectionContent( + onMemoriesSectionClick = {}, + onReadAgainClick = null, + clickLabel = "추억 공간 열기", + interactionSource = interactionSource, + ) + } + } + + composeRule.onNodeWithText("그날의 기록 다시 읽기").assertDoesNotExist() } } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReportingTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReportingTest.kt index 72f1ee3018..c2f5b98d58 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReportingTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReportingTest.kt @@ -46,8 +46,8 @@ class MindRecordFailureReportingTest { @Test fun `코루틴 취소는 기록하지 않는다`() { - // 이 모듈의 저장소는 runCatching 으로 취소까지 실패로 바꿔 돌려준다 — 화면 이탈이 - // 잦은 목록에서 그 취소가 전부 기록되면 보관 한도(최근 8건)를 잡음이 채운다. + // 「취소는 에러가 아니다」를 창구에서 지킨다. 화면 이탈이 잦은 목록에서 취소가 전부 + // 기록되면 보관 한도(최근 8건)를 잡음이 채운다 — 호출부가 하나라도 빠뜨리면 그렇게 된다. reporter.recordMindRecordFailure(MindRecordFailureStage.DRAFT_LIST_LOAD, CancellationException("화면 이탈")) assertTrue("취소가 기록됐다", reporter.failures.isEmpty()) diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/DiaryWriteDateDisplayTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/DiaryWriteDateDisplayTest.kt deleted file mode 100644 index 0d1528bf16..0000000000 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/DiaryWriteDateDisplayTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.afternote.feature.mindrecord.presentation.screen - -import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewModel -import org.junit.Assert.assertTrue -import org.junit.Test -import java.time.LocalDate - -/** - * 일기 기록 날짜가 «고를 수 있지만 반영되지 않는» 상태가 아닌지 (#1008). - * - * 서버는 생성·수정 어느 쪽에서도 `date` 를 받지 않고 기록 날짜를 요청 시각으로 정한다 - * (실측 2026-08-25: `POST`·`PATCH` 에 `date: "2026-08-01"` 을 실어 보내도 저장분은 - * `2026-08-25`, Swagger 스키마에도 필드가 없다). 그 상태에서 날짜 피커를 열어 두면 - * 사용자는 고른 날짜로 남았다고 믿는다. - * - * 화면에서 피커를 걷어냈으므로 **ViewModel 에 날짜를 바꾸는 창구가 남아 있으면 안 된다** — - * 남겨 두면 다음 사람이 그 함수를 보고 UI 를 되살린다. BE 가 `date` 를 수용하면 그때 함께 - * 되돌린다. - */ -class DiaryWriteDateDisplayTest { - @Test - fun `날짜를 바꾸는 공개 창구가 없다`() { - // "update" 가 "date" 를 부분 문자열로 품는다 — 날짜와 무관한 updateXxx 가 하나만 - // 생겨도 빨개지므로, 이름 규칙이 아니라 **LocalDate 를 받는 공개 함수**로 좁힌다. - val dateSetters = - DiaryWriteViewModel::class.java.methods - .filter { - java.lang.reflect.Modifier - .isPublic(it.modifiers) - }.filter { it.parameterTypes.any { type -> type == LocalDate::class.java } } - .map { it.name } - - assertTrue("날짜 변경 창구가 남아 있다: $dateSetters", dateSetters.isEmpty()) - } -} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceDetailOverlayTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceDetailOverlayTest.kt new file mode 100644 index 0000000000..cd98ad621f --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceDetailOverlayTest.kt @@ -0,0 +1,125 @@ +package com.afternote.feature.mindrecord.presentation.screen.memoryspace + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.Diary +import com.afternote.feature.mindrecord.domain.model.DiaryList +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.GetMemorySpaceUseCase +import com.afternote.feature.mindrecord.presentation.viewmodel.MemorySpaceViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.time.LocalDate +import java.time.YearMonth + +/** + * 추억 공간의 카드 → 상세 오버레이 → 닫기 → 돌아가기 왕복 (#1693). + * + * 종전에는 app 계측 테스트였다. 집계 UseCase 와 카드 모델이 이 모듈 안으로 닫히면서 + * 조립도 계약 옆으로 왔다 — app 이 이 화면을 손으로 조립할 이유가 없다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class MemorySpaceDetailOverlayTest { + @get:Rule + val composeRule = createComposeRule() + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `카드를 열면 본문과 기분 태그가 뜨고 닫은 뒤 돌아가기가 화면을 넘긴다`() { + val memoryDate = LocalDate.now() + val memory = + Diary( + diaryId = 501L, + title = "추억이 된 하루", + content = "이 순간은 나에게 특별한 의미가 있었습니다.", + date = memoryDate.toString(), + createdAt = memoryDate.toString(), + todayMood = TodayMood.HAPPY, + imageUrl = "https://afternote.test/memory.jpg", + isDraft = false, + ) + val diaryRepository = + FakeDiaryRepository( + onGetList = { yearMonth, _ -> + val diaries = + if (yearMonth == YearMonth.from(memoryDate).toString()) listOf(memory) else emptyList() + Result.success( + DiaryList( + diaries = diaries, + monthDiaryCount = diaries.size, + weeklyDominantMood = diaries.firstOrNull()?.todayMood, + ), + ) + }, + ) + val viewModel = + MemorySpaceViewModel( + getMemorySpace = + GetMemorySpaceUseCase( + diaryRepository = diaryRepository, + dailyQuestionRepository = FakeDailyQuestionRepository(), + ), + errorReporter = RecordingErrorReporter(), + ) + var backCalls = 0 + + composeRule.setContent { + AfternoteTheme { + MemorySpaceScreen(viewModel = viewModel, onBackClick = { backCalls += 1 }) + } + } + + // 제목은 상단바와 본문 헤더 두 곳에 있다 — 화면이 떴다는 확인이면 첫 노드로 족하다. + composeRule.onAllNodesWithText("MEMORY SPACE").onFirst().assertIsDisplayed() + composeRule.waitUntil { + composeRule.onAllNodesWithContentDescription("추억이 된 하루").fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithContentDescription("추억이 된 하루").performClick() + composeRule + .onNodeWithText("이 순간은 나에게 특별한 의미가 있었습니다.", substring = true) + .assertIsDisplayed() + // 태그는 사용자가 고른 오늘의 기분 이모지다 — 종전 더미의 `#평온` 은 출처가 없었다 (#559). + composeRule.onNodeWithText("#😊").assertIsDisplayed() + composeRule.onNodeWithContentDescription("닫기").performClick() + composeRule + .onNodeWithText("이 순간은 나에게 특별한 의미가 있었습니다.", substring = true) + .assertDoesNotExist() + + composeRule.onNodeWithText("돌아가기").performClick() + composeRule.runOnIdle { assertEquals(1, backCalls) } + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt index 6a0028fa15..b9297c6c9e 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt @@ -2,9 +2,21 @@ package com.afternote.feature.mindrecord.presentation.screen.receiver import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.error.DeliveryNotReadyException +import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords +import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository import com.afternote.feature.mindrecord.presentation.R -import com.afternote.feature.mindrecord.presentation.viewmodel.toDomainMessage +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordUiState +import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Before import org.junit.Test import java.io.IOException @@ -18,30 +30,58 @@ import java.io.IOException * * 화면은 **도메인 예외 타입만** 본다. 어떤 서버 코드가 그 타입이 되는지는 data 계층의 * `mapReceiverFailure` 가 알고, 그쪽 계약은 `MindRecordReceiverRepositoryImplTest` 가 잡는다. + * + * 변환 함수를 직접 부르지 않고 **ViewModel 이 내놓는 오류 상태**로 본다 — 문구가 맞아도 + * 그 값이 화면 상태에 실리지 않으면 사용자는 여전히 원문을 본다 (#1674). */ +@OptIn(ExperimentalCoroutinesApi::class) class ReceiverDeadEndTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + @Test fun `전달 조건 미충족은 기다리는 상태임을 알리는 문구가 된다`() { - assertEquals( - UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready), - DeliveryNotReadyException().toDomainMessage(), - ) + assertErrorMessage(DeliveryNotReadyException(), R.string.mindrecord_receiver_delivery_not_ready) } @Test fun `서버 원문을 화면 문구로 쓰지 않는다`() { // 결과 타입이 UiText.Resource 라 원문이 들어갈 자리 자체가 없다. - assertEquals( - UiText.Resource(R.string.mindrecord_receiver_load_failed), - IllegalStateException("서버 내부 오류가 발생했습니다.").toDomainMessage(), + assertErrorMessage( + IllegalStateException("서버 내부 오류가 발생했습니다."), + R.string.mindrecord_receiver_load_failed, ) } @Test fun `네트워크 예외도 같은 실패 문구로 수렴한다`() { - assertEquals( - UiText.Resource(R.string.mindrecord_receiver_load_failed), - IOException("timeout").toDomainMessage(), - ) + assertErrorMessage(IOException("timeout"), R.string.mindrecord_receiver_load_failed) } + + private fun assertErrorMessage( + failure: Throwable, + expected: Int, + ) = runTest(dispatcher) { + val viewModel = + ReceiverMindRecordViewModel( + repository = FailingReceiverRepository(failure), + errorReporter = RecordingErrorReporter(), + ) + + testScheduler.advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(UiText.Resource(expected), (state as ReceiverMindRecordUiState.Error).message) + } +} + +private class FailingReceiverRepository( + private val failure: Throwable, +) : MindRecordReceiverRepository { + override suspend fun getAll(): Result = Result.failure(failure) } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt index 40cef0447e..8df86a3c7e 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt @@ -5,15 +5,18 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.domain.model.MindRecordSummary import com.afternote.feature.mindrecord.domain.model.MindRecordType +import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository import com.afternote.feature.mindrecord.presentation.component.ReceiverRecordDetailSheet +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordFilter -import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordUiState +import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordViewModel import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -26,6 +29,10 @@ import org.robolectric.annotation.Config * 카드가 clickable 인데 콜백이 빈 람다라 탭해도 아무 일이 없었고, 목록에서 제목만 보이고 * `content`·`todayMood` 에 도달할 경로가 없었다. 서버는 목록 응답에 본문을 함께 주므로 * 화면이 들고 있는 항목을 펼치면 된다 — 추가 조회가 없다는 점도 함께 고정한다. + * + * 「탭한 기록을 어떻게 찾는가」는 화면 내부의 도우미가 아니라 **화면을 실제로 눌러** 본다. + * 도우미를 직접 부르면 카드 탭과 시트 표시 사이의 배선이 빠져도 초록이다 — 이 이슈가 고친 + * 결함이 정확히 그 배선이었다 (#1674). */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) @@ -56,39 +63,54 @@ class ReceiverRecordDetailTest { createdAt = "2026.07.28 화", ) - private fun success() = - ReceiverMindRecordUiState.Success( - dailyQuestions = listOf(answer), - diaries = listOf(diary), - filter = ReceiverMindRecordFilter(), - ) - @Test - fun `탭한 일기를 두 탭 목록에서 찾아낸다`() { - assertEquals(diary, findOpenedRecord(success(), 11L)) + fun `탭한 데일리질문의 본문이 열린다`() { + renderScreen() + + composeRule.onNodeWithText(answer.title).performClick() + + composeRule.onNodeWithText("제주.").assertIsDisplayed() } @Test - fun `탭한 데일리질문도 같은 경로로 찾는다`() { - assertEquals(answer, findOpenedRecord(success(), 22L)) + fun `일기 탭의 카드도 같은 경로로 열린다`() { + renderScreen() + + composeRule.onNodeWithText(DIARY_TAB).performClick() + composeRule.waitForIdle() + composeRule.onNodeWithText(diary.title).performClick() + + composeRule.onNodeWithText("강변을 한 시간 걸었다.").assertIsDisplayed() } @Test - fun `아무것도 안 눌렀으면 열지 않는다`() { - assertNull(findOpenedRecord(success(), null)) + fun `아무것도 안 눌렀으면 본문이 열리지 않는다`() { + renderScreen() + + assertEquals( + "탭하지 않았는데 본문이 떠 있다", + 0, + composeRule.onAllNodesWithText("제주.").fetchSemanticsNodes().size, + ) } @Test - fun `필터로 목록에서 빠진 항목은 열리지 않는다`() { + fun `필터로 목록에서 빠지면 열려 있던 본문이 닫힌다`() { // 시트가 열린 채 필터가 바뀌면 자연히 닫힌다 — 없는 기록을 계속 붙들지 않는다. - // 목록에 없던 id 를 넣으면 위의 «못 찾으면 null» 을 한 번 더 밟을 뿐이므로, - // **방금까지 열려 있던** 11L 을 그대로 두고 그 항목이 빠진 목록을 넣는다. - val openedId = 11L - assertEquals(diary, findOpenedRecord(success(), openedId)) + val viewModel = renderScreen() + composeRule.onNodeWithText(answer.title).performClick() + composeRule.onNodeWithText("제주.").assertIsDisplayed() - val filtered = success().copy(diaries = emptyList()) + composeRule.runOnIdle { + viewModel.applyFilter(ReceiverMindRecordFilter(fromDate = "2026-08-01", toDate = "2026-08-31")) + } + composeRule.waitForIdle() - assertNull(findOpenedRecord(filtered, openedId)) + assertEquals( + "목록에서 빠진 기록의 본문이 남아 있다", + 0, + composeRule.onAllNodesWithText("제주.").fetchSemanticsNodes().size, + ) } @Test @@ -122,4 +144,31 @@ class ReceiverRecordDetailTest { composeRule.onAllNodesWithText("😊").fetchSemanticsNodes().size, ) } + + private fun renderScreen(): ReceiverMindRecordViewModel { + val viewModel = + ReceiverMindRecordViewModel( + repository = FakeReceiverRepository(dailyQuestions = listOf(answer), diaries = listOf(diary)), + errorReporter = RecordingErrorReporter(), + ) + composeRule.setContent { + AfternoteTheme { + ReceiverMindRecordScreen(viewModel = viewModel, onBackClick = {}) + } + } + composeRule.waitForIdle() + return viewModel + } + + private companion object { + const val DIARY_TAB = "일기" + } +} + +private class FakeReceiverRepository( + private val dailyQuestions: List, + private val diaries: List, +) : MindRecordReceiverRepository { + override suspend fun getAll(): Result = + Result.success(ReceiverMindRecords(dailyQuestions = dailyQuestions, diaries = diaries)) } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/CalendarSelectionTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/CalendarSelectionTest.kt index 4781882d04..41bdc0042e 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/CalendarSelectionTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/CalendarSelectionTest.kt @@ -88,6 +88,8 @@ class CalendarSelectionTest { } composeRule.onNodeWithText("2026년 8월").assertIsDisplayed() - composeRule.onNodeWithText("0개의 답변 완료").assertIsDisplayed() + // 일기 탭이므로 「기록」이다 — 종전에는 두 탭이 문구를 공유해 여기서도 「답변」이었고, + // 이 단언이 그 오문을 기대값으로 굳히고 있었다 (#1712). + composeRule.onNodeWithText("0개의 기록 완료").assertIsDisplayed() } } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteKeyboardSpaceTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteKeyboardSpaceTest.kt new file mode 100644 index 0000000000..bd022e635d --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteKeyboardSpaceTest.kt @@ -0,0 +1,165 @@ +package com.afternote.feature.mindrecord.presentation.screen.sender + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.afternote.core.ui.UiText +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate + +/** + * 키보드가 떠도 본문 입력 칸이 남는지 (#1860). + * + * `enableEdgeToEdge()` 라 키보드가 떠도 윈도우는 줄지 않는다. 대신 IME inset 이 오고(#1849) + * 그만큼이 화면 안에서 빠진다. 본문 칸은 질문 배너와 하단 툴바가 쓰고 **남는 것**을 받는 + * 마지막 자리라, 좁은 화면에서 글꼴 배율까지 올리면 남는 것이 없어져 0 높이로 눌렸다 — + * 타이핑은 되는데 쓴 글이 보이지 않았다. + * + * 기준은 이슈의 실측 조합인 `720x1280 @320dpi · font_scale 1.30` 이고, 그때 키보드는 세로의 + * 46%(590px)다. Robolectric 에도 같은 inset 을 실어 같은 기하를 만든다 — 윈도우를 줄이는 + * 방식으로 흉내 내면 edge-to-edge 의 실제 배치와 달라져 결함이 재현되지 않는다. + * + * ### 좁은 화면만 보지 않는다 + * + * 질문 배너를 늘 접어 두면 넉넉한 화면에서 질문 전문이 사라지고, 본문 자리를 고정 높이로 + * 떼어 두면 기본 설정에서 본문이 오히려 좁아진다. 그래서 **키보드가 뜬 좁은 화면에서 본문이 + * 남는 것**과 **키보드가 없을 때 질문 전문과 넓은 본문이 그대로인 것**을 함께 본다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], qualifiers = "w360dp-h640dp-xhdpi") +class DailyQuestionWriteKeyboardSpaceTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `키보드가 뜨면 본문 입력 칸이 남는다`() { + render(fontScale = 1.3f) + showKeyboard() + + composeRule + .onNodeWithContentDescription(editorLabel()) + .assertIsDisplayed() + .assertHeightIsAtLeast(MinimumEditorHeight) + } + + /** + * 이어쓰기 실패·저장 진행 안내는 질문 배너와 같은 자리에 쌓인다. 이 문구들이 붙은 채로 + * 키보드가 떠도 본문이 밀려나면 안 된다 — 안내 영역 쪽이 줄어야 한다. + */ + @Test + fun `안내 문구가 쌓여도 본문 입력 칸이 남는다`() { + render( + fontScale = 1.3f, + state = + baseState().copy( + draftResumeError = UiText.Dynamic("임시저장을 불러오지 못했어요. 다시 시도해 주세요."), + uploadingImageCount = 1, + ), + ) + showKeyboard() + + composeRule + .onNodeWithContentDescription(editorLabel()) + .assertIsDisplayed() + .assertHeightIsAtLeast(MinimumEditorHeight) + } + + @Test + fun `키보드가 없으면 질문 전문과 넓은 본문이 그대로다`() { + render(fontScale = 1.0f) + + composeRule.onNodeWithText(QUESTION).assertIsDisplayed() + composeRule + .onNodeWithContentDescription(editorLabel()) + .assertHeightIsAtLeast(RoomyEditorHeight) + } + + private fun editorLabel(): String = composeRule.activity.getString(R.string.mindrecord_write_field_placeholder) + + /** 실기의 키보드 등장과 같게, 윈도우를 줄이는 대신 IME inset 을 싣는다. */ + private fun showKeyboard() { + composeRule.runOnUiThread { + val insets = + WindowInsetsCompat + .Builder() + .setVisible(WindowInsetsCompat.Type.ime(), true) + .setInsets(WindowInsetsCompat.Type.ime(), Insets.of(0, 0, 0, IME_HEIGHT_PX)) + .setInsets( + WindowInsetsCompat.Type.systemBars(), + Insets.of(0, STATUS_BAR_PX, 0, NAVIGATION_BAR_PX), + ).build() + ViewCompat.dispatchApplyWindowInsets(composeRule.activity.window.decorView, insets) + } + composeRule.waitForIdle() + } + + private fun baseState() = + DailyQuestionWriteUiState( + questionContent = QUESTION, + questionDay = 21, + isQuestionLoading = false, + ) + + private fun render( + fontScale: Float, + state: DailyQuestionWriteUiState = baseState(), + ) { + composeRule.setContent { + val base = LocalDensity.current + CompositionLocalProvider( + LocalDensity provides Density(density = base.density, fontScale = fontScale), + ) { + AfternoteTheme { + DailyQuestionWriteScreenContent( + uiState = state, + date = LocalDate.of(2026, 9, 7), + onBackClick = {}, + onSubmit = {}, + onSaveDraft = {}, + onDraftListClick = {}, + onAnswerChanged = {}, + onRetryResumeDraft = {}, + ) + } + } + } + composeRule.waitForIdle() + } + + private companion object { + const val QUESTION = "오늘 하루, 누구에게 가장 고마웠나요?" + + /** 720x1280 @320dpi 에서 키보드가 먹는 세로 46%. */ + const val IME_HEIGHT_PX = 590 + + /** 같은 기기의 상태바와 3버튼 내비 바. 둘 다 본문이 쓸 수 있는 높이를 줄인다. */ + const val STATUS_BAR_PX = 50 + const val NAVIGATION_BAR_PX = 96 + + /** + * 커서와 한 줄이 실제로 보이는 최소치. 에디터 안쪽 여백(위아래 16dp)에 배율 1.3 의 + * 본문 한 줄을 더한 값이다 — 이보다 작으면 여백만 남는다. + */ + val MinimumEditorHeight = 64.dp + + /** 키보드가 없을 때의 본문 높이. 안내 영역 상한이 기본 배치를 좁히지 않았는지 본다. */ + val RoomyEditorHeight = 300.dp + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/EmotionCardDescriptionTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/EmotionCardDescriptionTest.kt index 04f76e0c99..43a04c33da 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/EmotionCardDescriptionTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/EmotionCardDescriptionTest.kt @@ -2,16 +2,27 @@ package com.afternote.feature.mindrecord.presentation.screen.sender import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.model.user.User import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus -import com.afternote.feature.mindrecord.presentation.model.EmotionKeyword -import com.afternote.feature.mindrecord.presentation.viewmodel.WeeklyReportUiState +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis +import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.mindrecord.domain.model.WeeklyReportEmotion +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker +import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase +import com.afternote.feature.mindrecord.presentation.viewmodel.WeeklyReportViewModel +import kotlinx.coroutines.flow.flowOf +import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import java.time.LocalDate /** * 감정 카드 본문 문구가 **상태를 키워드보다 먼저 보는지** (#725 리뷰 지적). @@ -19,71 +30,102 @@ import java.time.LocalDate * 부분 성공(일부 완료 + 일부 대기)에서는 완료분 키워드가 `emotions` 에 실려 내려온다 — * BE `buildTopEmotions` 에 완료 게이트가 없다. 키워드 유무를 먼저 보면 아직 분석 중인데도 * 폴백 요약이 최종 요약처럼 확정되고, 대기 분기는 도달 불가가 된다. + * + * 문구를 만드는 함수를 직접 부르지 않고 **화면에 실제로 뜬 글**로 본다 — 분기가 맞아도 + * 그 값이 카드에 실리지 않으면 사용자는 다른 문장을 읽는다 (#1674). + * + * 뷰포트를 길게 잡는 이유는 감정 카드가 주간리포트 `LazyColumn` 의 아래쪽 항목이라서다 — + * 기본 높이에서는 컴포즈되지도 않아 「그 문구가 화면에 없다」가 «스크롤 밖» 과 구분되지 않는다. */ @RunWith(RobolectricTestRunner::class) -@Config(sdk = [35]) +@Config(sdk = [35], qualifiers = "w360dp-h2400dp-xhdpi") class EmotionCardDescriptionTest { @get:Rule val composeRule = createAndroidComposeRule() - private fun state( - status: EmotionAnalysisStatus, - keywords: List = emptyList(), - ) = WeeklyReportUiState.Success( - selectedMonday = LocalDate.of(2026, 8, 24), - weekOptions = emptyList(), - dateRange = "", - userName = "아담", - recordedDays = 1, - counts = emptyList(), - emotionAnalysisStatus = status, - weekDays = emptyList(), - emotionKeywords = keywords, - summaryText = "이번 주 기록을 바탕으로 인사이트를 준비 중이에요.", - dailyQuestions = emptyList(), - ) - - private fun describe(state: WeeklyReportUiState.Success): String { - lateinit var text: String - composeRule.setContent { - AfternoteTheme { text = emotionCardDescription(state) } - } - composeRule.waitForIdle() - return text - } - @Test fun `키워드가 이미 나왔어도 대기 중이면 최종 요약으로 확정하지 않는다`() { - val described = - describe( - state( - status = EmotionAnalysisStatus.PENDING, - keywords = listOf(EmotionKeyword(keyword = "가족", count = 60)), - ), - ) + // 대기 3건 중 1건만 성공 — 키워드는 이미 하나 내려와 있다. + renderScreen(analysis = EmotionAnalysis(total = 3, succeeded = 1, pending = 2, failed = 0), hasKeyword = true) - assert(described != "이번 주 기록을 바탕으로 인사이트를 준비 중이에요.") { - "부분 성공에서 폴백 요약이 최종 요약처럼 확정됐다: $described" - } + assertNotShown(SUMMARY_TEXT) + composeRule.onNodeWithText(string(R.string.mindrecord_weekly_report_summary_pending)).assertExists() } @Test fun `분석이 끝나야 서버 요약을 쓴다`() { - val described = - describe( - state( - status = EmotionAnalysisStatus.COMPLETED, - keywords = listOf(EmotionKeyword(keyword = "가족", count = 60)), - ), - ) + renderScreen(analysis = EmotionAnalysis(total = 1, succeeded = 1, pending = 0, failed = 0), hasKeyword = true) - assert(described == "이번 주 기록을 바탕으로 인사이트를 준비 중이에요.") { described } + composeRule.onNodeWithText(SUMMARY_TEXT).assertExists() } @Test fun `상태를 모르면 키워드 0건이라고 확정하지 않는다`() { - val described = describe(state(status = EmotionAnalysisStatus.UNKNOWN)) + // emotionAnalysis 가 없으면 상태는 UNKNOWN 이다 — 0 건으로 접지 않는다. + renderScreen(analysis = null, hasKeyword = false) - assert(!described.contains("나오지 않았")) { described } + // 「키워드가 나오지 않았어요」 는 0 건으로 확정하는 문구다 — UNKNOWN 에서는 쓰지 않는다. + assertEquals( + "상태를 모르는데 키워드 0건으로 확정했다", + 0, + composeRule.onAllNodesWithText("나오지 않았", substring = true).fetchSemanticsNodes().size, + ) + composeRule.onNodeWithText(string(R.string.mindrecord_emotion_card_unknown_description)).assertExists() } + + private fun string(resId: Int): String = composeRule.activity.getString(resId) + + private fun assertNotShown(text: String) = + assertEquals( + "화면에 뜨면 안 되는 문구가 떠 있다: $text", + 0, + composeRule.onAllNodesWithText(text).fetchSemanticsNodes().size, + ) + + private fun renderScreen( + analysis: EmotionAnalysis?, + hasKeyword: Boolean, + ) { + val viewModel = + WeeklyReportViewModel( + observeWeeklyReport = + ObserveWeeklyReportUseCase( + repository = EmotionCardWeeklyReportRepository(report(analysis, hasKeyword)), + userRepository = + FakeUserRepository.strict().apply { + onReceiverListFlow = { flowOf(emptyList()) } + onGetMyProfile = { + User(name = "아담", email = "a@b.c", phone = null, profileImageUrl = null) + } + }, + ), + changeTracker = MindRecordChangeTracker(), + errorReporter = RecordingErrorReporter(), + ) + composeRule.setContent { AfternoteTheme { WeeklyReportScreen(viewModel = viewModel) } } + composeRule.waitForIdle() + } + + private fun report( + analysis: EmotionAnalysis?, + hasKeyword: Boolean, + ) = WeeklyReport( + dailyQuestionAmount = 0, + diaryAmount = 1, + summaryText = SUMMARY_TEXT, + week = emptyList(), + dailyQuestions = emptyList(), + emotions = if (hasKeyword) listOf(WeeklyReportEmotion(keyword = "가족", percentage = 60)) else emptyList(), + emotionAnalysis = analysis, + ) + + private companion object { + const val SUMMARY_TEXT = "이번 주 기록을 바탕으로 인사이트를 준비 중이에요." + } +} + +private class EmotionCardWeeklyReportRepository( + private val report: WeeklyReport, +) : WeeklyReportRepository { + override suspend fun getWeeklyReport(date: String): Result = Result.success(report) } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/ListScreenCopyAndFabSpaceTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/ListScreenCopyAndFabSpaceTest.kt new file mode 100644 index 0000000000..91d41d5ace --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/ListScreenCopyAndFabSpaceTest.kt @@ -0,0 +1,124 @@ +package com.afternote.feature.mindrecord.presentation.screen.sender + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.button.FAB.AfternoteFabContentBottomPadding +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.presentation.component.DailyCalendar +import com.afternote.feature.mindrecord.presentation.model.DailyDiary +import com.afternote.feature.mindrecord.presentation.model.MindRecordCategoryUi +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate +import java.time.YearMonth + +/** + * 목록 화면의 **문구**와 **FAB 자리** 가드 (#1712 · #1713). + * + * 둘 다 #269 QA 회귀를 실기에서 돌다가 나온 것이다. 컴파일도 기존 테스트도 잡지 못했다 — + * 문구는 타입이 같고, 가림은 레이아웃이라 단언할 대상이 없었다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], qualifiers = "w360dp-h800dp-xhdpi") +class ListScreenCopyAndFabSpaceTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + /** + * 같은 캘린더를 두 탭이 쓰는데 카운트 문구가 하나뿐이라, 일기 탭에서도 「N개의 **답변** + * 완료」로 읽혔다. 두 탭을 **각각** 렌더해 서로의 문구를 쓰지 않는 것까지 본다 — + * 한쪽만 보면 둘을 도로 합쳐도 통과한다. + */ + @Test + fun `캘린더 카운트 문구가 탭마다 다르다`() { + renderCalendar(MindRecordCategoryUi.DailyQuestion) + composeRule.onNodeWithText("1개의 답변 완료").assertIsDisplayed() + composeRule.onNodeWithText("1개의 기록 완료").assertDoesNotExist() + + composeRule.runOnIdle { category.value = MindRecordCategoryUi.Diary } + composeRule.onNodeWithText("1개의 기록 완료").assertIsDisplayed() + composeRule.onNodeWithText("1개의 답변 완료").assertDoesNotExist() + } + + /** 일기 목록인데 데일리질문 헤더(`DAILY ANSWER`)가 붙어 있었다. */ + @Test + fun `일기 목록 섹션 헤더가 일기 문구다`() { + composeRule.setContent { + AfternoteTheme { + DiaryListContent( + isListView = true, + yearMonth = YearMonth.of(2026, 9), + diaries = listOf(diary()), + onItemClick = { _, _ -> }, + onEdit = { _, _ -> }, + onDelete = {}, + onYearMonthChanged = {}, + ) + } + } + + composeRule.onNodeWithText("DAILY ANSWER").assertDoesNotExist() + composeRule.onNodeWithText("DAILY DIARY").assertIsDisplayed() + } + + /** + * FAB 은 `Scaffold` 가 콘텐츠 위에 띄우고 자리를 예약해 주지 않는다. 목록이 스스로 + * 비우지 않으면 마지막 항목이 가려지는데, **스크롤이 없을 만큼 항목이 적으면 볼 방법이 + * 없다.** 여백이 FAB 을 실제로 덮고도 남는지 값으로 못 박는다. + */ + @Test + fun `목록 하단 여백이 FAB 을 덮고도 남는다`() { + assertTrue( + "FAB(56dp)보다 여백이 작으면 마지막 항목이 가려진다: $AfternoteFabContentBottomPadding", + AfternoteFabContentBottomPadding >= 56.dp, + ) + // 여백만 크면 되는 게 아니라 «FAB 지름 + 위아래 화면 여백» 이어야 바로 위에 붙지 않는다. + assertTrue( + "FAB 바로 위에 항목이 붙는다: $AfternoteFabContentBottomPadding", + AfternoteFabContentBottomPadding >= 56.dp + 16.dp * 2, + ) + } + + private val category = mutableStateOf(MindRecordCategoryUi.DailyQuestion) + + private fun renderCalendar(initial: MindRecordCategoryUi) { + category.value = initial + composeRule.setContent { + AfternoteTheme { + CalendarUnderTest(category.value) + } + } + } + + @Composable + private fun CalendarUnderTest(type: MindRecordCategoryUi) { + DailyCalendar( + year = 2026, + month = 9, + type = type, + onPrevMonth = {}, + onNextMonth = {}, + answeredDays = setOf(1), + emotionByDay = emptyMap(), + selectedDay = null, + onDayClick = {}, + ) + } + + private fun diary() = + DailyDiary( + id = 1L, + title = "일기", + date = LocalDate.of(2026, 9, 1), + content = "본문", + ) +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/RecordListMonthTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/RecordListMonthTest.kt index 5693eb8937..bccfb7b983 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/RecordListMonthTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/RecordListMonthTest.kt @@ -51,10 +51,14 @@ class RecordListMonthTest { ), ), onItemClick = { id, month -> clicked = id to month }, + onDelete = {}, + onEdit = { _, _ -> }, ) } } + // 목록은 캘린더가 먼저 오는 LazyColumn 이라 기록 카드가 화면 밖에서 시작할 수 있다. + // 스크롤 위치에 기대면 캘린더 높이가 조금만 달라져도 「노드가 없다」로 깨진다 (#1700). composeRule .onNode(hasScrollToNodeAction()) .performScrollToNode(hasText("지난달 기록")) diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportSummaryTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportSummaryTest.kt index 402504d101..dba8bbf8b2 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportSummaryTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportSummaryTest.kt @@ -1,23 +1,51 @@ package com.afternote.feature.mindrecord.presentation.screen.sender +import androidx.activity.ComponentActivity +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.text.AnnotatedString +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.model.user.User +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.mindrecord.domain.model.WeeklyReportDay +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase +import com.afternote.feature.mindrecord.presentation.viewmodel.WeeklyReportViewModel +import kotlinx.coroutines.flow.flowOf import org.junit.Assert.assertEquals +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import java.time.DayOfWeek +import java.time.LocalDate /** - * 주간리포트 요약 문구의 공백을 **컴파일된 리소스**로 고정한다 (#732). + * 주간리포트 요약 문구의 공백과 강조를 **화면에서** 고정한다 (#732). * * 소스 XML 만 보면 통과하는 종류의 결함이다 — 조각 리소스에 앞뒤 공백을 두면 aapt2 가 그것을 * 지워 APK 에서만 "이번 주,박서연님은3일의…" 로 붙는다. 그래서 Robolectric 으로 실제 리소스를 * 읽어 확인한다. + * + * 강조 구간은 내부 도우미를 부르지 않고 **화면이 실제로 그린 `AnnotatedString`** 에서 읽는다 — + * 구간 계산이 맞아도 그 값이 스타일로 실리지 않으면 화면은 강조 없이 보인다 (#1674). */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) class WeeklyReportSummaryTest { - private val context: android.content.Context = org.robolectric.RuntimeEnvironment.getApplication() + @get:Rule + val composeRule = createAndroidComposeRule() + + private val context: android.content.Context get() = composeRule.activity @Test fun `요약 문구는 시안대로 공백을 유지한다`() { @@ -28,35 +56,120 @@ class WeeklyReportSummaryTest { } @Test - fun `이름과 기록일수 구간을 각각 찾는다`() { - val sentence = "이번 주, 박서연 님은 3일의 마음을 기록하셨네요." - - val highlights = recordedSummaryHighlights(sentence, userName = "박서연", daysText = "3일") + fun `이름과 기록일수 구간을 각각 강조한다`() { + val highlighted = highlightedTexts(userName = "박서연", recordedDays = 3) - assertEquals(2, highlights.size) - assertEquals("박서연", sentence.substring(highlights[0].first, highlights[0].last + 1)) - assertEquals("3일", sentence.substring(highlights[1].first, highlights[1].last + 1)) + assertEquals(listOf("박서연", "3일"), highlighted) } @Test fun `이름에 기록일수와 같은 문자열이 들어 있어도 구간이 겹치지 않는다`() { - val sentence = "이번 주, 3일 님은 3일의 마음을 기록하셨네요." - - val highlights = recordedSummaryHighlights(sentence, userName = "3일", daysText = "3일") - - assertEquals(2, highlights.size) // 이름은 첫 번째 "3일", 기록일수는 그 뒤의 "3일" — 같은 구간을 두 번 잡지 않는다. - assertEquals(6, highlights[0].first) - assertEquals(12, highlights[1].first) + val sentence = summarySentence(userName = "3일", recordedDays = 3) + val ranges = highlightedRanges(userName = "3일", recordedDays = 3) + + assertEquals(2, ranges.size) + assertEquals(sentence.indexOf("3일"), ranges[0].first) + assertEquals(sentence.indexOf("3일", startIndex = ranges[0].second), ranges[1].first) } @Test fun `이름이 비어 있으면 기록일수만 강조한다`() { - val sentence = "이번 주, 님은 3일의 마음을 기록하셨네요." + val highlighted = highlightedTexts(userName = "", recordedDays = 3) + + assertEquals(listOf("3일"), highlighted) + } - val highlights = recordedSummaryHighlights(sentence, userName = "", daysText = "3일") + private fun summarySentence( + userName: String, + recordedDays: Int, + ): String { + val daysText = context.getString(R.string.mindrecord_weekly_report_days_format, recordedDays) + return context.getString(R.string.mindrecord_weekly_report_recorded, userName, daysText) + } - assertEquals(1, highlights.size) - assertEquals("3일", sentence.substring(highlights[0].first, highlights[0].last + 1)) + private fun highlightedTexts( + userName: String, + recordedDays: Int, + ): List { + val sentence = summarySentence(userName, recordedDays) + return highlightedRanges(userName, recordedDays).map { (start, end) -> sentence.substring(start, end) } } + + /** 화면이 그린 요약 문장에서 **강조색이 실린** 구간만 뽑는다. */ + private fun highlightedRanges( + userName: String, + recordedDays: Int, + ): List> { + var highlightColor = Color.Unspecified + renderScreen(userName = userName, recordedDays = recordedDays) { highlightColor = it } + + val sentence = summarySentence(userName, recordedDays) + val node = composeRule.onNodeWithText(sentence).fetchSemanticsNode() + val annotated: AnnotatedString = node.config[SemanticsProperties.Text].first() + return annotated.spanStyles + .filter { it.item.color == highlightColor } + .map { it.start to it.end } + } + + private fun renderScreen( + userName: String, + recordedDays: Int, + onHighlightColor: (Color) -> Unit, + ) { + val monday = LocalDate.now().with(DayOfWeek.MONDAY) + val viewModel = + WeeklyReportViewModel( + observeWeeklyReport = + ObserveWeeklyReportUseCase( + repository = SummaryWeeklyReportRepository(report(monday, recordedDays)), + userRepository = + FakeUserRepository.strict().apply { + onReceiverListFlow = { flowOf(emptyList()) } + onGetMyProfile = { + User(name = userName, email = "a@b.c", phone = null, profileImageUrl = null) + } + }, + ), + changeTracker = MindRecordChangeTracker(), + errorReporter = RecordingErrorReporter(), + ) + composeRule.setContent { + AfternoteTheme { + val color = AfternoteDesign.colors.b1 + SideEffect { onHighlightColor(color) } + WeeklyReportScreen(viewModel = viewModel) + } + } + composeRule.waitForIdle() + } + + /** 그 주 안에서 서로 다른 [recordedDays] 일에 기록이 있는 리포트. */ + private fun report( + monday: LocalDate, + recordedDays: Int, + ) = WeeklyReport( + dailyQuestionAmount = 0, + diaryAmount = recordedDays, + summaryText = "요약", + week = + (0 until recordedDays).map { offset -> + WeeklyReportDay( + diaryId = offset.toLong(), + day = monday.plusDays(offset.toLong()).dayOfMonth, + isDiary = true, + countsAsRecord = true, + emotion = null, + ) + }, + dailyQuestions = emptyList(), + emotions = emptyList(), + emotionAnalysis = null, + ) +} + +private class SummaryWeeklyReportRepository( + private val report: WeeklyReport, +) : WeeklyReportRepository { + override suspend fun getWeeklyReport(date: String): Result = Result.success(report) } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCaseTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCaseTest.kt new file mode 100644 index 0000000000..24f8cfea56 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCaseTest.kt @@ -0,0 +1,207 @@ +package com.afternote.feature.mindrecord.presentation.usecase + +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase.Category +import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase.Outcome +import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase.Target +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * 임시저장 **일괄 삭제 정책** 가드 (#1693). + * + * 되돌릴 수 없는 동작이고 부분 실패가 정상 경로라, 「무엇을 실패로 칠 것인가」가 이 동작의 + * 핵심이다. 그 판정만 여기서 본다 — 화면이 그것을 어떤 문구로 보이는지는 ViewModel 몫이다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DeleteMindRecordDraftsUseCaseTest { + @Test + fun `두 저장소에 걸친 대상을 각각의 저장소로 보낸다`() = + runTest { + val diaryRepository = FakeDiaryRepository() + val dailyQuestionRepository = FakeDailyQuestionRepository() + + val outcome = + DeleteMindRecordDraftsUseCase(diaryRepository, dailyQuestionRepository) + .delete( + targets = listOf(Target(Category.Diary, 1), Target(Category.DailyQuestion, 2)), + survivorsAfterDelete = { emptySet() }, + ) + + assertEquals(listOf(1L), diaryRepository.deletedIds) + assertEquals(listOf(2L), dailyQuestionRepository.deletedIds) + assertEquals(Outcome.Deleted(failures = emptyList(), remaining = emptyList()), outcome) + } + + /** + * **이 UseCase 의 존재 이유.** 지우려던 것이 이미 없어서 실패한 경우(404)는 사용자가 원한 + * 결과가 이뤄진 것이다. 그것까지 «다시 고르라» 고 하면 화면이 「목록은 비었는데 1개 선택」 이 된다. + * + * 다만 **계측에서는 사라지면 안 된다.** 화면에 안 보이는 실패일수록 콘솔이 유일한 흔적이라, + * 대조로 걸러 내면 「왜 안 지워졌나」를 나중에 물을 곳이 없어진다 (#964·#1693 리뷰). + * 그래서 화면이 보는 [Outcome.Deleted.remaining] 만 비고, [Outcome.failures] 에는 남는다. + */ + @Test + fun `재조회에서 사라진 실패는 화면에서 빠지되 계측에는 남는다`() = + runTest { + val gone = IOException("이미 없음") + val useCase = + DeleteMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(onDelete = { Result.failure(gone) }), + dailyQuestionRepository = FakeDailyQuestionRepository(), + ) + + val outcome = + useCase.delete( + targets = listOf(Target(Category.Diary, 1)), + // 재조회 결과에 없다 = 서버에서도 사라졌다. + survivorsAfterDelete = { emptySet() }, + ) + + assertEquals(emptyList(), (outcome as Outcome.Deleted).remaining) + assertEquals(listOf(Target(Category.Diary, 1)), outcome.failures.map { it.target }) + assertEquals(gone, outcome.failures.single().cause) + } + + /** 반대로 **아직 남아 있는** 실패는 올려야 한다 — 그래야 다시 선택해 줄 수 있다. */ + @Test + fun `실패하고 재조회에도 남아 있으면 실패로 올린다`() = + runTest { + val boom = IOException("서버 거절") + val target = Target(Category.Diary, 1) + val useCase = + DeleteMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(onDelete = { Result.failure(boom) }), + dailyQuestionRepository = FakeDailyQuestionRepository(), + ) + + val outcome = useCase.delete(targets = listOf(target), survivorsAfterDelete = { setOf(target) }) + + assertEquals(listOf(target), outcome.failures.map { it.target }) + assertEquals(boom, outcome.failures.single().cause) + assertTrue(outcome is Outcome.Deleted) + assertEquals(listOf(target), (outcome as Outcome.Deleted).remaining.map { it.target }) + } + + /** + * 두 저장소의 id 는 서로 독립이라 숫자가 겹친다. 종류를 안 들고 **숫자만으로** 대조하면 + * 「사라진 데일리질문 3」의 실패가 「남아 있는 일기 3」 때문에 살아남는다 — 사용자는 + * 자기가 지운 것을 「실패했으니 다시 선택하라」는 말로 돌려받는다. + * + * 그래서 실패한 쪽은 사라지고 **같은 숫자의 다른 종류만 남은** 상황으로 판정한다. + * 숫자만 보는 구현에서는 이 단언이 깨진다. + */ + @Test + fun `같은 숫자 id 라도 종류가 다르면 다른 항목이다`() = + runTest { + val useCase = + DeleteMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(), + dailyQuestionRepository = + FakeDailyQuestionRepository(onDelete = { Result.failure(IOException("이미 없음")) }), + ) + + val outcome = + useCase.delete( + targets = listOf(Target(Category.DailyQuestion, 3)), + // 실패한 데일리질문 3은 사라졌고, 지우지 않은 일기 3이 남아 있다. + survivorsAfterDelete = { setOf(Target(Category.Diary, 3)) }, + ) + + // 화면이 보는 것은 remaining 이다 — 계측용 failures 에는 남는 것이 정상이다 (#1693 리뷰). + assertEquals( + "사라진 데일리질문 3이 남아 있는 일기 3 때문에 실패로 살아남았다", + emptyList(), + (outcome as Outcome.Deleted).remaining.map { it.target }, + ) + assertEquals(listOf(Target(Category.DailyQuestion, 3)), outcome.failures.map { it.target }) + } + + /** + * 재조회가 실패하면 **무엇이 남았는지 알 수 없다.** 그때 「전부 지워졌다」로 닫으면 + * 서버에 남은 것을 없다고 말하게 된다. + */ + @Test + fun `재조회가 실패하면 판정하지 않고 Unknown 을 올린다`() = + runTest { + val boom = IOException("삭제 실패") + val useCase = + DeleteMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(onDelete = { Result.failure(boom) }), + dailyQuestionRepository = FakeDailyQuestionRepository(), + ) + + val outcome = + useCase.delete( + targets = listOf(Target(Category.Diary, 1)), + survivorsAfterDelete = { null }, + ) + + assertTrue("재조회 실패인데 $outcome 로 닫혔다", outcome is Outcome.Unknown) + // 대조를 못 해도 계측할 실패는 그대로 실어 올린다. + assertEquals(listOf(boom), outcome.failures.map { it.cause }) + } + + /** 지울 것이 없으면 저장소를 부르지 않는다 — 부르면 빈 재조회가 한 번 더 나간다. */ + @Test + fun `대상이 비면 아무 것도 지우지 않는다`() = + runTest { + val diaryRepository = FakeDiaryRepository() + var refreshed = false + + val outcome = + DeleteMindRecordDraftsUseCase(diaryRepository, FakeDailyQuestionRepository()) + .delete(targets = emptyList(), survivorsAfterDelete = { + refreshed = true + emptySet() + }) + + assertEquals(Outcome.Deleted(failures = emptyList(), remaining = emptyList()), outcome) + assertEquals(emptyList(), diaryRepository.deletedIds) + assertTrue("대상이 없는데 재조회가 나갔다", !refreshed) + } + + /** 순차 삭제면 100건이 100배 걸린다 — 실제로 겹치는지 본다. */ + @Test + fun `여러 대상을 병렬로 지운다`() = + runTest { + val diaryStarted = CompletableDeferred() + val dailyQuestionStarted = CompletableDeferred() + val useCase = + DeleteMindRecordDraftsUseCase( + diaryRepository = + FakeDiaryRepository(onDelete = { + diaryStarted.complete(Unit) + // 상대가 시작하지 않으면 여기서 멈춘다 — 순차 실행이면 교착이다. + dailyQuestionStarted.await() + Result.success(Unit) + }), + dailyQuestionRepository = + FakeDailyQuestionRepository(onDelete = { + dailyQuestionStarted.complete(Unit) + diaryStarted.await() + Result.success(Unit) + }), + ) + + val outcome = + async { + useCase.delete( + targets = listOf(Target(Category.Diary, 1), Target(Category.DailyQuestion, 2)), + survivorsAfterDelete = { emptySet() }, + ) + } + advanceUntilIdle() + + assertTrue("두 삭제가 겹치지 않았다", outcome.isCompleted) + assertEquals(Outcome.Deleted(failures = emptyList(), remaining = emptyList()), outcome.await()) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/GetMemorySpaceUseCaseTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/GetMemorySpaceUseCaseTest.kt new file mode 100644 index 0000000000..a650772892 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/GetMemorySpaceUseCaseTest.kt @@ -0,0 +1,194 @@ +package com.afternote.feature.mindrecord.presentation.usecase + +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.model.Diary +import com.afternote.feature.mindrecord.domain.model.DiaryList +import com.afternote.feature.mindrecord.domain.model.MindRecordType +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryRecordId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException +import java.time.YearMonth + +/** + * 추억 공간 집계 정책 (#1693). + * + * 종전에는 이 넷이 `MemorySpaceViewModel` 안에 있었다 — 비대칭 조회 범위, 부분 실패, 초안 + * 제외, 정렬·상한. ViewModel 을 통해서만 볼 수 있어서 「어느 달을 요청했는가」 같은 판정은 + * 벽시계에 기대야 했다. 기준 달을 인자로 받는 지금은 고정 날짜로 단언할 수 있다. + * + * 본문 이미지 추출이 `HtmlCompat` 을 타서 Robolectric 이 필요하다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class GetMemorySpaceUseCaseTest { + @Test + fun `일기는 최근 3개월만, 데일리질문은 전체 기간을 조회한다`() = + runTest { + val diaryRepository = FakeDiaryRepository() + val questionRepository = FakeDailyQuestionRepository() + + useCase(diaryRepository, questionRepository).invoke(currentMonth = YearMonth.of(2026, 9)) + + assertEquals( + listOf("2026-09", "2026-08", "2026-07"), + diaryRepository.listQueries.map { it.yearMonth }, + ) + // 일기 조회에는 draftOnly 를 싣지 않는다 — 그 달 전체를 받아 초안은 아래에서 거른다. + assertTrue(diaryRepository.listQueries.all { it.draftOnly == null }) + } + + @Test + fun `두 출처를 최신순 한 줄로 세워 상한까지만 남긴다`() = + runTest { + val records = + useCase( + diaryRepositoryOf(diary(1L, "2026-09-01"), diary(2L, "2026-09-05")), + FakeDailyQuestionRepository( + initialAnswers = listOf(question(3L, "2026-09-03"), question(4L, "2026-09-07")), + ), + ).invoke(currentMonth = YearMonth.of(2026, 9), limit = 3).getOrThrow() + + assertEquals( + listOf( + MemoryRecordId(MindRecordType.DAILY_QUESTION, 4L), + MemoryRecordId(MindRecordType.DIARY, 2L), + MemoryRecordId(MindRecordType.DAILY_QUESTION, 3L), + ), + records.map { it.id }, + ) + } + + /** + * 두 출처의 숫자 ID 공간이 겹친다. 종전에는 데일리질문을 음수로 접어 갈랐는데, 그 우회가 + * 사라진 뒤에도 같은 숫자가 서로 다른 기록으로 남는지 본다 — 여기서 접히면 카드 하나가 + * 사라지거나 탭이 엉뚱한 기록을 연다. + */ + @Test + fun `같은 숫자 ID 라도 종류가 다르면 다른 기록이다`() = + runTest { + val records = + useCase( + diaryRepositoryOf(diary(12L, "2026-09-01")), + FakeDailyQuestionRepository(initialAnswers = listOf(question(12L, "2026-09-02"))), + ).invoke(currentMonth = YearMonth.of(2026, 9)).getOrThrow() + + assertEquals( + listOf( + MemoryRecordId(MindRecordType.DAILY_QUESTION, 12L), + MemoryRecordId(MindRecordType.DIARY, 12L), + ), + records.map { it.id }, + ) + assertTrue("두 기록의 식별자가 같아졌다", records[0].id != records[1].id) + } + + @Test + fun `임시저장 일기는 전시하지 않는다`() = + runTest { + val records = + useCase( + diaryRepositoryOf(diary(1L, "2026-09-01"), diary(2L, "2026-09-02", isDraft = true)), + FakeDailyQuestionRepository(), + ).invoke(currentMonth = YearMonth.of(2026, 9)).getOrThrow() + + assertEquals(listOf(MemoryRecordId(MindRecordType.DIARY, 1L)), records.map { it.id }) + } + + @Test + fun `한쪽이 실패해도 카드가 채워지면 성공이다`() = + runTest { + val records = + useCase( + FakeDiaryRepository(onGetList = { _, _ -> Result.failure(IOException("일기 실패")) }), + FakeDailyQuestionRepository(initialAnswers = listOf(question(3L, "2026-09-03"))), + ).invoke(currentMonth = YearMonth.of(2026, 9)).getOrThrow() + + assertEquals(listOf(MemoryRecordId(MindRecordType.DAILY_QUESTION, 3L)), records.map { it.id }) + } + + /** + * 합친 결과가 비었는데 실패한 출처가 있으면 0건으로 확정하지 않는다 — 실패한 쪽에 기록이 + * 있었을 수 있어, 「아직 담긴 기록이 없어요」 로 오인하면 재시도 경로까지 사라진다. + */ + @Test + fun `결과가 비었는데 실패한 출처가 있으면 실패로 올린다`() = + runTest { + val result = + useCase( + FakeDiaryRepository(onGetList = { _, _ -> Result.failure(IOException("일기 실패")) }), + FakeDailyQuestionRepository(), + ).invoke(currentMonth = YearMonth.of(2026, 9)) + + assertTrue(result.isFailure) + assertEquals("일기 실패", result.exceptionOrNull()?.message) + } + + @Test + fun `모든 출처가 성공하고 기록이 없으면 빈 목록이다`() = + runTest { + val records = + useCase(FakeDiaryRepository(), FakeDailyQuestionRepository()) + .invoke(currentMonth = YearMonth.of(2026, 9)) + .getOrThrow() + + assertTrue(records.isEmpty()) + } + + private fun useCase( + diaryRepository: FakeDiaryRepository, + questionRepository: FakeDailyQuestionRepository, + ) = GetMemorySpaceUseCase(diaryRepository = diaryRepository, dailyQuestionRepository = questionRepository) + + /** + * 달마다 그 달의 일기만 돌려주는 저장소. 기본 fake 는 `yearMonth` 를 무시하고 같은 목록을 + * 그대로 주는데, 이 UseCase 는 세 달을 따로 조회하므로 같은 일기가 세 번 실려 실기와 + * 다른 결과가 된다. + */ + private fun diaryRepositoryOf(vararg diaries: Diary) = + FakeDiaryRepository( + onGetList = { yearMonth, _ -> + Result.success( + DiaryList( + diaries = diaries.filter { it.date.startsWith(yearMonth) }, + monthDiaryCount = diaries.size, + weeklyDominantMood = null, + ), + ) + }, + ) + + private fun diary( + id: Long, + date: String, + isDraft: Boolean = false, + ) = Diary( + diaryId = id, + title = "일기 $id", + content = "

본문

", + date = date, + createdAt = date, + todayMood = TodayMood.HAPPY, + isDraft = isDraft, + ) + + private fun question( + id: Long, + date: String, + ) = DailyQuestion( + dailyQuestionId = id, + title = "질문 $id", + content = "

답변

", + createdAt = date, + ) +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/LoadMindRecordDraftsUseCaseTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/LoadMindRecordDraftsUseCaseTest.kt new file mode 100644 index 0000000000..33868edb12 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/LoadMindRecordDraftsUseCaseTest.kt @@ -0,0 +1,214 @@ +package com.afternote.feature.mindrecord.presentation.usecase + +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.model.Diary +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.time.LocalDate +import java.time.YearMonth +import kotlin.coroutines.cancellation.CancellationException + +/** + * 임시저장 **조회 범위 정책** 가드 (#1693). + * + * 이 정책은 화면 둘(임시저장 목록·작성 툴바 카운트)이 공유하므로, 어느 한 화면의 테스트가 + * 아니라 정책 자체를 여기서 고정한다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class LoadMindRecordDraftsUseCaseTest { + /** + * 서버 계약이 갈려 범위가 비대칭이다 — 일기는 달 단위, 데일리질문은 전체 기간. + * 한쪽이 조용히 바뀌면 두 화면의 건수가 어긋나는 형태로만 드러나므로 여기서 못 박는다. + */ + @Test + fun `일기는 넘긴 달만, 데일리질문은 전체 기간을 조회한다`() = + runTest { + val diaryRepository = FakeDiaryRepository() + val dailyQuestionRepository = FakeDailyQuestionRepository() + + LoadMindRecordDraftsUseCase(diaryRepository, dailyQuestionRepository) + .load(currentMonth = YearMonth.of(2026, 3)) + + assertEquals(listOf("2026-03"), diaryRepository.listQueries.map { it.yearMonth }) + assertEquals(listOf(true), diaryRepository.listQueries.map { it.draftOnly }) + // 데일리질문은 date 를 싣지 않는다 — 실으면 그 날짜 것만 온다. + assertEquals(listOf(null), dailyQuestionRepository.listQueries.map { it.date }) + assertEquals(listOf(true), dailyQuestionRepository.listQueries.map { it.draftOnly }) + } + + /** + * **벽시계에 기대지 않는다.** 종전에는 `YearMonth.now()` 를 구현이 직접 불러, 기대값을 + * 만들려면 테스트도 같은 `now()` 를 불러야 했다 — 구현이 «지난 달» 을 조회하도록 잘못 + * 바뀌어도 기대값이 함께 틀려 통과한다. + */ + @Test + fun `기본값은 이번 달이지만 다른 달을 넘기면 그 달을 조회한다`() = + runTest { + val diaryRepository = FakeDiaryRepository() + val useCase = LoadMindRecordDraftsUseCase(diaryRepository, FakeDailyQuestionRepository()) + + useCase.load() + useCase.load(currentMonth = YearMonth.of(2019, 12)) + + assertEquals( + listOf(YearMonth.now().toString(), "2019-12"), + diaryRepository.listQueries.map { it.yearMonth }, + ) + } + + /** 빈 목록으로 흡수하면 「실패」와 「0건」이 같은 화면이 되어 복구 수단이 사라진다. */ + @Test + fun `일기 조회가 실패하면 전체가 실패한다`() = + runTest { + val boom = IOException("일기 조회 실패") + val useCase = + LoadMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(onGetList = { _, _ -> Result.failure(boom) }), + dailyQuestionRepository = FakeDailyQuestionRepository(initialAnswers = listOf(draftQuestion(1))), + ) + + val result = useCase.load(currentMonth = YearMonth.of(2026, 3)) + + assertTrue(result.isFailure) + // `assertSame` 을 쓸 수 없다 — 코루틴 stack-trace 복원이 `async` 경계에서 예외를 + // `(String)` 생성자로 복제하므로 인스턴스가 달라진다. 종류와 메시지로 본다. + assertEquals(boom.message, result.exceptionOrNull()?.message) + assertTrue("$boom 이 아닌 것이 올라왔다", result.exceptionOrNull() is IOException) + } + + /** 반대쪽도 같다 — 한쪽만 막아 두면 다른 쪽 실패가 0건으로 새어 나간다. */ + @Test + fun `데일리질문 조회가 실패하면 전체가 실패한다`() = + runTest { + val boom = IOException("데일리질문 조회 실패") + val useCase = + LoadMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(initialDiaries = listOf(draftDiary(1))), + dailyQuestionRepository = FakeDailyQuestionRepository(onGetList = { _, _ -> Result.failure(boom) }), + ) + + val result = useCase.load(currentMonth = YearMonth.of(2026, 3)) + + assertTrue(result.isFailure) + // `assertSame` 을 쓸 수 없다 — 코루틴 stack-trace 복원이 `async` 경계에서 예외를 + // `(String)` 생성자로 복제하므로 인스턴스가 달라진다. 종류와 메시지로 본다. + assertEquals(boom.message, result.exceptionOrNull()?.message) + assertTrue("$boom 이 아닌 것이 올라왔다", result.exceptionOrNull() is IOException) + } + + /** + * 취소를 실패로 바꾸면 **호출부가 「조회 실패」 화면을 그린다** — 화면을 떠나며 취소된 + * 것뿐인데 오류가 뜬다. `runCatchingCancellable` 이 그것을 막는지 본다. + */ + @Test + fun `취소는 실패 결과로 바뀌지 않는다`() = + runTest { + val blocked = CompletableDeferred() + val useCase = + LoadMindRecordDraftsUseCase( + diaryRepository = + FakeDiaryRepository(onGetList = { _, _ -> + blocked.await() + Result.success(emptyDiaryList()) + }), + dailyQuestionRepository = FakeDailyQuestionRepository(), + ) + var outcome: Result? = null + var cancelled = false + + val job = + launch { + try { + outcome = useCase.load(currentMonth = YearMonth.of(2026, 3)) + } catch (e: CancellationException) { + cancelled = true + throw e + } + } + advanceUntilIdle() + job.cancelAndJoin() + + assertTrue("취소가 CancellationException 으로 전파돼야 한다", cancelled) + assertEquals("취소가 Result 로 흡수됐다: $outcome", null, outcome) + } + + /** 두 조회가 순차면 느린 쪽이 합계 대기시간이 된다 — 병렬인지 실제로 겹쳐 본다. */ + @Test + fun `두 조회는 병렬로 나간다`() = + runTest { + val diaryStarted = CompletableDeferred() + val dailyQuestionStarted = CompletableDeferred() + val useCase = + LoadMindRecordDraftsUseCase( + diaryRepository = + FakeDiaryRepository(onGetList = { _, _ -> + diaryStarted.complete(Unit) + // 상대가 시작하지 않으면 여기서 영원히 멈춘다 — 순차 실행이면 교착이다. + dailyQuestionStarted.await() + Result.success(emptyDiaryList()) + }), + dailyQuestionRepository = + FakeDailyQuestionRepository(onGetList = { _, _ -> + dailyQuestionStarted.complete(Unit) + diaryStarted.await() + Result.success(emptyList()) + }), + ) + + val result = async { useCase.load(currentMonth = YearMonth.of(2026, 3)) } + advanceUntilIdle() + + assertTrue("두 조회가 겹치지 않았다", result.isCompleted) + assertFalse(result.await().isFailure) + } + + /** 툴바 카운트는 목록과 **같은 조회**를 써야 건수가 어긋나지 않는다 (#769). */ + @Test + fun `count 는 두 종류의 합이다`() = + runTest { + val useCase = + LoadMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(initialDiaries = listOf(draftDiary(1), draftDiary(2))), + dailyQuestionRepository = FakeDailyQuestionRepository(initialAnswers = listOf(draftQuestion(1))), + ) + + assertEquals(3, useCase.count(currentMonth = YearMonth.of(2026, 3)).getOrNull()) + } + + private fun emptyDiaryList() = + com.afternote.feature.mindrecord.domain.model + .DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null) + + private fun draftDiary(id: Long) = + Diary( + diaryId = id, + title = "일기 $id", + content = "본문", + date = LocalDate.of(2026, 3, 4).toString(), + createdAt = LocalDate.of(2026, 3, 4).toString(), + todayMood = TodayMood.entries.first(), + isDraft = true, + ) + + private fun draftQuestion(id: Long) = + DailyQuestion( + dailyQuestionId = id, + title = "질문 $id", + content = "답변", + createdAt = LocalDate.of(2026, 3, 4).toString(), + isDraft = true, + ) +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCaseTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCaseTest.kt new file mode 100644 index 0000000000..8d9438b9af --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCaseTest.kt @@ -0,0 +1,227 @@ +package com.afternote.feature.mindrecord.presentation.usecase + +import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis +import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.time.DayOfWeek +import java.time.LocalDate +import kotlin.coroutines.cancellation.CancellationException + +/** + * 주간 리포트 **조회·제한 폴링 정책** 가드 (#725 · #1693). + * + * 종전에는 이 규칙이 ViewModel 안에 있어 「몇 번·언제 조회하는가」를 화면을 띄우지 않고는 + * 볼 수 없었다. 폴링은 8초 간격이라 실시간으로 기다리면 한 번에 64초다 — `runTest` 의 + * 가상 시간으로 돌린다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ObserveWeeklyReportUseCaseTest { + private val thisMonday: LocalDate = LocalDate.of(2026, 3, 2) + private val today: LocalDate = thisMonday.plusDays(3) + + @Test + fun `첫 방출은 리포트와 프로필 이름을 함께 싣는다`() = + runTest { + val useCase = useCase(responses = listOf(Result.success(completed())), name = "채연") + + val first = useCase.observe(thisMonday, today).toList().first() + + assertEquals("채연", first.getOrNull()?.profileName) + assertEquals(completed(), first.getOrNull()?.report) + } + + /** 조회가 안 되는데 8초마다 다시 부를 이유가 없다. */ + @Test + fun `첫 조회가 실패하면 폴링하지 않고 끝난다`() = + runTest { + val repository = QueuedWeeklyReportRepository(listOf(Result.failure(IOException("조회 실패")))) + val useCase = ObserveWeeklyReportUseCase(repository, FakeUserRepository()) + + val emissions = useCase.observe(thisMonday, today).toList() + + assertEquals(1, emissions.size) + assertTrue(emissions.single().isFailure) + assertEquals(1, repository.requestedDates.size) + } + + /** + * 분석은 완료 신호를 주는 채널이 없어 재조회가 유일한 확인 수단이다 (#725). + * 완료된 순간 **바로 멈춰야** 한다 — 남은 시도를 마저 돌면 쓸데없는 조회가 나간다. + */ + @Test + fun `분석이 끝나면 그 즉시 폴링을 멈춘다`() = + runTest { + val repository = + QueuedWeeklyReportRepository( + listOf( + Result.success(pending()), + Result.success(pending()), + Result.success(completed()), + ), + ) + val useCase = ObserveWeeklyReportUseCase(repository, FakeUserRepository()) + + val emissions = useCase.observe(thisMonday, today).toList() + + // 첫 조회 + 폴링 2회 = 3 + assertEquals(3, repository.requestedDates.size) + assertEquals(3, emissions.size) + assertEquals(completed(), emissions.last().getOrNull()?.report) + } + + /** + * 무한 폴링을 하지 않는다 — 상한을 넘기면 화면 이탈·복귀나 재시도에 맡긴다. + * + * 기대값을 **숫자로 적는다.** `1 + POLL_ATTEMPTS` 로 쓰면 상수가 바뀔 때 기대값도 함께 + * 움직여 어떤 값이든 통과한다 — 상한을 지키는 테스트가 상한을 안 보게 된다. + * 8초 × 8회 = 64초가 「화면에 머무는 동안」의 상한이라는 판단이 여기 박혀 있어야, + * 그 값을 바꾸는 사람이 이 테스트에서 한 번 멈춘다. + */ + @Test + fun `분석이 끝나지 않아도 여덟 번에서 멈춘다`() = + runTest { + val repository = QueuedWeeklyReportRepository(alwaysPending = true) + val useCase = ObserveWeeklyReportUseCase(repository, FakeUserRepository()) + + useCase.observe(thisMonday, today).toList() + + // 요청 횟수와 흐른 시간이 곧 상한이다 — 상수를 따로 단언하면 프로덕션 visibility 를 + // 테스트 때문에 넓히게 된다 (#1688·#1693 리뷰). + assertEquals("첫 조회 1회 + 폴링 8회", 9, repository.requestedDates.size) + assertEquals("8초 × 8회 = 64초", 64_000L, testScheduler.currentTime) + } + + /** + * 폴링의 근거는 「저장 직후 비동기 분석」이라 **이번 주에만** 성립한다. 지난 주를 보는 + * 동안 8초마다 조회가 나갈 이유가 없다. + */ + @Test + fun `지난 주는 진행 중이어도 폴링하지 않는다`() = + runTest { + val repository = QueuedWeeklyReportRepository(alwaysPending = true) + val useCase = ObserveWeeklyReportUseCase(repository, FakeUserRepository()) + + val emissions = useCase.observe(thisMonday.minusWeeks(1), today).toList() + + assertEquals(1, repository.requestedDates.size) + assertEquals(1, emissions.size) + } + + /** + * `PENDING` 화면에는 재시도 수단이 없다(카드는 `FAILED` 전용). 한 번 실패했다고 남은 + * 시도를 버리면 **화면에 머무는 동안 복구할 길이 사라진다.** + */ + @Test + fun `폴링 중 한 번 실패해도 남은 시도를 계속한다`() = + runTest { + val repository = + QueuedWeeklyReportRepository( + listOf( + Result.success(pending()), + Result.failure(IOException("일시 실패")), + Result.success(completed()), + ), + ) + val useCase = ObserveWeeklyReportUseCase(repository, FakeUserRepository()) + + val emissions = useCase.observe(thisMonday, today).toList() + + assertEquals(3, repository.requestedDates.size) + // 실패한 시도는 방출하지 않는다 — 화면을 오류로 덮으면 진행 중 표시가 사라진다. + assertEquals(2, emissions.size) + assertEquals(completed(), emissions.last().getOrNull()?.report) + assertTrue("일시 실패가 화면까지 올라갔다", emissions.none { it.isFailure }) + } + + /** 사용자가 다른 주를 고르면 수집이 끊긴다. 그것을 「조회 실패」로 바꾸면 안 된다. */ + @Test + fun `취소는 실패 방출로 바뀌지 않는다`() = + runTest { + val blocked = CompletableDeferred() + val repository = + object : WeeklyReportRepository { + override suspend fun getWeeklyReport(date: String): Result { + blocked.await() + return Result.success(completed()) + } + } + val emissions = mutableListOf>() + var cancelled = false + + val job = + launch { + try { + ObserveWeeklyReportUseCase(repository, FakeUserRepository()) + .observe(thisMonday, today) + .toList(emissions) + } catch (e: CancellationException) { + cancelled = true + throw e + } + } + advanceUntilIdle() + job.cancelAndJoin() + + assertTrue("취소가 CancellationException 으로 전파돼야 한다", cancelled) + assertEquals("취소가 방출로 흡수됐다: $emissions", emptyList(), emissions) + } + + private fun useCase( + responses: List>, + name: String, + ): ObserveWeeklyReportUseCase = + ObserveWeeklyReportUseCase( + repository = QueuedWeeklyReportRepository(responses), + userRepository = userRepositoryNamed(name), + ) + + private fun userRepositoryNamed(name: String): MyProfileRepository = FakeUserRepository().apply { profile = profile.copy(name = name) } + + /** 큐를 순서대로 돌려주고, 다 쓰면 마지막 응답을 계속 준다. */ + private class QueuedWeeklyReportRepository( + private val responses: List> = emptyList(), + private val alwaysPending: Boolean = false, + ) : WeeklyReportRepository { + val requestedDates = mutableListOf() + + override suspend fun getWeeklyReport(date: String): Result { + requestedDates += date + if (alwaysPending) return Result.success(pendingReport()) + return responses[minOf(requestedDates.lastIndex, responses.lastIndex)] + } + + companion object { + fun pendingReport(): WeeklyReport = report(EmotionAnalysis(total = 2, succeeded = 0, pending = 2, failed = 0)) + } + } + + private fun pending(): WeeklyReport = QueuedWeeklyReportRepository.pendingReport() + + private fun completed(): WeeklyReport = report(EmotionAnalysis(total = 2, succeeded = 2, pending = 0, failed = 0)) + + private companion object { + fun report(analysis: EmotionAnalysis): WeeklyReport = + WeeklyReport( + dailyQuestionAmount = 0, + diaryAmount = 1, + summaryText = "요약", + week = emptyList(), + dailyQuestions = emptyList(), + emotions = emptyList(), + emotionAnalysis = analysis, + ) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlImageSrcTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlImageSrcTest.kt index 1b3a17d1cd..772c8d1c53 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlImageSrcTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlImageSrcTest.kt @@ -1,7 +1,9 @@ package com.afternote.feature.mindrecord.presentation.util +import com.mohamedrejeb.richeditor.model.RichTextState import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -68,29 +70,66 @@ class HtmlImageSrcTest { } @Test - fun `업로드 URL 은 스킴과 호스트를 떼 fileKey 가 된다`() { - // 서버가 본문 img src 에서 기대하는 형태 — 전체 URL 을 넣으면 호스트를 한 번 더 붙인다. - val url = "https://dpy1u4p7yz7bh.cloudfront.net/mindrecords/staging/13/a.png" + fun `본문의 업로드 URL 을 서버가 준 fileKey 로 바꾼다`() { + val url = "https://cdn.example.net/mindrecords/staging/13/a.png" + val html = """

앞

사진

뒤

""" - assertEquals("mindrecords/staging/13/a.png", url.toUploadedFileKey()) + val wire = html.toWireContent(mapOf(url to "mindrecords/staging/13/a.png")) + + assertEquals("""

앞

사진

뒤

""", wire) + } + + @Test + fun `CDN 이 경로 프리픽스를 붙여도 서버가 준 키가 그대로 나간다`() { + // **역산이 틀리던 자리다** (#1125). 스킴·호스트만 떼는 종전 규칙은 여기서 + // `bucket-7/mindrecords/staging/13/a.png` 를 만들어 조용히 틀린 키를 보냈다. + // 지금은 presigned 응답이 준 값을 그대로 쓰므로 프리픽스가 몇 겹이든 영향이 없다. + val url = "https://cdn.example.net/bucket-7/mindrecords/staging/13/a.png" + + val wire = """""".toWireContent(mapOf(url to "mindrecords/staging/13/a.png")) + + assertEquals("""""", wire) } @Test - fun `호스트가 바뀌어도 경로 규칙을 코드에 박지 않는다`() { - // 디렉터리 구조를 서버가 바꿔도 스킴·호스트만 떼는 규칙은 그대로 따라간다. - val url = "https://cdn.example.net/some/other/layout/b.png" + fun `이번에 업로드하지 않은 영구 URL 은 건드리지 않는다`() { + // 이미 저장된 본문의 영구 URL 은 서버가 그대로 통과시킨다 — 키로 바꾸면 이미 옮겨진 + // 파일을 다시 옮기려다 실패한다 (#549). + val permanent = "https://cdn.example.net/mindrecords/permanent/13/old.png" + val uploaded = "https://cdn.example.net/mindrecords/staging/13/new.png" + val html = """""" - assertEquals("some/other/layout/b.png", url.toUploadedFileKey()) + val wire = html.toWireContent(mapOf(uploaded to "mindrecords/staging/13/new.png")) + + assertEquals("""""", wire) + } + + @Test + fun `쿼리가 둘 이상인 URL 도 에디터 왕복 뒤에 치환된다`() { + // **리치 에디터가 직렬화하면서 `&` 를 `&` 로 바꾼다**(실측). 원문 URL 로 그대로 + // 찾으면 여기서 못 맞춰 전체 URL 이 서버로 나가고, #549 의 이중 호스트·403 이 재발한다 + // (#1125 리뷰). 그래서 이 가드는 **실제 왕복을 거친 HTML** 로 본다. + val url = "https://cdn.example.net/mindrecords/staging/13/a.png?x=1&y=2" + val serialized = RichTextState().apply { setHtml("""""") }.toHtml() + assertTrue("왕복 전제가 깨졌다: $serialized", "&" in serialized) + + val wire = serialized.toWireContent(mapOf(url to "mindrecords/staging/13/a.png")) + + assertTrue("전체 URL 이 그대로 나갔다: $wire", "cdn.example.net" !in wire) + assertTrue("fileKey 로 안 바뀌었다: $wire", """src="mindrecords/staging/13/a.png"""" in wire) } @Test - fun `스킴이 없는 값은 첫 경로 세그먼트를 잃지 않는다`() { - // substringAfter("://") 는 구분자가 없으면 원문을 그대로 돌려주고, 이어지는 - // substringAfter('/') 가 mindrecords 를 떼 버린다 — 조용히 틀린 키가 나간다. - assertEquals( - "mindrecords/staging/13/a.png", - "mindrecords/staging/13/a.png".toUploadedFileKey(), - ) + fun `링크 href 도 같은 규칙으로 치환된다`() { + // 첨부 파일은 `a href` 로 들어간다 — `img src` 와 같은 규칙이 적용되는 것을 실서버로 + // 확인했다(#549). 왕복 escape 도 똑같이 걸린다. + val url = "https://cdn.example.net/mindrecords/staging/13/b.m4a?v=1&t=2" + val serialized = RichTextState().apply { setHtml("""음성""") }.toHtml() + + val wire = serialized.toWireContent(mapOf(url to "mindrecords/staging/13/b.m4a")) + + assertTrue("전체 URL 이 그대로 나갔다: $wire", "cdn.example.net" !in wire) + assertTrue("fileKey 로 안 바뀌었다: $wire", """href="mindrecords/staging/13/b.m4a"""" in wire) } } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ChangeTrackerRefreshTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ChangeTrackerRefreshTest.kt index 0371a9a91f..d83cb57777 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ChangeTrackerRefreshTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ChangeTrackerRefreshTest.kt @@ -3,6 +3,7 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -45,7 +46,7 @@ class ChangeTrackerRefreshTest { runTest(dispatcher) { val changeTracker = MindRecordChangeTracker() val repository = FakeDailyQuestionRepository(changeTracker = changeTracker) - val viewModel = DailyQuestionListViewModel(repository, changeTracker) + val viewModel = DailyQuestionListViewModel(repository, changeTracker, RecordingErrorReporter()) // uiState 는 WhileSubscribed 라 구독자가 없으면 로드가 시작되지 않는다. backgroundScope.launch { viewModel.uiState.collect { } } advanceUntilIdle() @@ -64,7 +65,7 @@ class ChangeTrackerRefreshTest { // 같은 가드의 반대 방향 — 탭을 오가도 데이터가 그대로면 부르지 않는다 (#736). val changeTracker = MindRecordChangeTracker() val repository = FakeDailyQuestionRepository(changeTracker = changeTracker) - val viewModel = DailyQuestionListViewModel(repository, changeTracker) + val viewModel = DailyQuestionListViewModel(repository, changeTracker, RecordingErrorReporter()) // uiState 는 WhileSubscribed 라 구독자가 없으면 로드가 시작되지 않는다. backgroundScope.launch { viewModel.uiState.collect { } } advanceUntilIdle() diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTargetGuardTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTargetGuardTest.kt new file mode 100644 index 0000000000..2d692c597f --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTargetGuardTest.kt @@ -0,0 +1,165 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +/** + * 수정 진입의 **대상이 바뀌지 않는지** (#2028). + * + * `submit()` 은 「대상 레코드도 없고 오늘 질문도 없다」를 만나면 조회를 다시 걸어 사용자가 + * 재시도할 수 있게 한다 (#565). 그 복구가 **진입을 보지 않고 언제나 오늘 질문을 부르는** 것이 + * 결함이다 — 과거 답변 수정에서 프리필이 실패하면, 두 번째 저장이 원래 답변의 PATCH 가 아니라 + * **오늘 질문의 신규 POST** 로 나간다. 사용자는 고치던 답변이 아니라 다른 질문에 글을 남긴다. + * + * 이 파일이 보는 것은 **어디로 나갔는가**다 — 요청이 갔는지가 아니라 그 요청의 대상이다. + * 조회 재시도 자체는 유지한다: 복구는 없애는 것이 아니라 **원래 대상으로 되돌리는 것**이다. + * + * Robolectric 은 `SavedStateHandle.toRoute()` 가 `android.os.Bundle` 을 지나기 때문에만 필요하다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DailyQuestionEditTargetGuardTest { + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `프리필이 실패한 답변 수정은 오늘 질문으로 대상을 바꾸지 않는다`() { + val repository = failingPrefillRepository() + val viewModel = editViewModel(repository, answerId = EDIT_ID) + + viewModel.onAnswerChanged("

대체 본문

") + viewModel.submit() + viewModel.submit() + + assertTrue("오늘 질문을 불러 대상을 바꿨다", repository.getTodayCalls == 0) + assertTrue("못 읽은 원본 대신 새 답변이 생성됐다", repository.createdPayloads.isEmpty()) + assertTrue("못 읽은 원본을 덮는 PATCH 가 나갔다", repository.updatedPayloads.isEmpty()) + assertFalse("못 읽은 원본 위로 저장이 열렸다", viewModel.uiState.value.canSubmit) + } + + /** 복구는 없어지지 않는다 — 같은 대상을 다시 조회한다. */ + @Test + fun `프리필이 실패한 답변 수정의 재저장은 같은 대상을 다시 조회한다`() { + val repository = failingPrefillRepository() + val viewModel = editViewModel(repository, answerId = EDIT_ID) + val afterEntry = repository.listQueries.size + + viewModel.onAnswerChanged("

대체 본문

") + viewModel.submit() + + assertEquals("대상 재조회가 걸리지 않았다", afterEntry + 1, repository.listQueries.size) + } + + /** 재조회가 성공하면 원래 대상으로 PATCH 된다. */ + @Test + fun `재조회가 성공하면 원래 답변이 수정된다`() { + var failNext = true + val repository = + FakeDailyQuestionRepository().apply { + onGetList = { _, _ -> + if (failNext) { + failNext = false + Result.failure(IOException("프리필 조회 실패")) + } else { + Result.success(listOf(answer(EDIT_ID, "

원본 본문

"))) + } + } + } + val viewModel = editViewModel(repository, answerId = EDIT_ID) + + viewModel.onAnswerChanged("

고친 본문

") + // 첫 저장은 막히고 대상 재조회를 건다. + viewModel.submit() + // 재조회가 성공한 뒤의 저장이 원래 대상으로 나간다. + viewModel.submit() + + assertEquals(EDIT_ID, repository.updatedPayloads.single().first) + assertTrue("신규 답변이 함께 생성됐다", repository.createdPayloads.isEmpty()) + } + + /** 신규 작성의 오늘 질문 재시도는 그대로다. */ + @Test + fun `신규 작성은 오늘 질문 조회 실패 뒤 저장에서 다시 조회한다`() { + val repository = + FakeDailyQuestionRepository().apply { + onGetToday = { Result.failure(IOException("오늘 질문 조회 실패")) } + } + val viewModel = editViewModel(repository, answerId = null) + val afterEntry = repository.getTodayCalls + + viewModel.onAnswerChanged("

새 답변

") + viewModel.submit() + + assertEquals("신규 작성의 오늘 질문 재시도가 사라졌다", afterEntry + 1, repository.getTodayCalls) + } + + private fun failingPrefillRepository() = + FakeDailyQuestionRepository().apply { + onGetList = { _, _ -> Result.failure(IOException("프리필 조회 실패")) } + onGetToday = { error("수정 진입에서 오늘 질문을 부르면 안 된다") } + } + + private fun editViewModel( + repository: FakeDailyQuestionRepository, + answerId: Long?, + ): DailyQuestionWriteViewModel { + val handle = + SavedStateHandle( + if (answerId == null) emptyMap() else mapOf("answerId" to answerId, "isDraft" to false), + ) + return DailyQuestionWriteViewModel( + handle, + repository, + FakePhotoUploadRepository.strict(), + // 툴바 카운트 조회가 프리필 조회 기록에 섞이지 않게 다른 저장소를 넘긴다 (#769·#770). + LoadMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(), + dailyQuestionRepository = FakeDailyQuestionRepository(), + ), + RecordingErrorReporter(), + ) + } + + private fun answer( + id: Long, + content: String, + ) = DailyQuestion( + dailyQuestionId = id, + title = "오늘의 질문", + content = content, + createdAt = "2026-09-13T09:00:00", + isDraft = false, + ) + + private companion object { + const val EDIT_ID = 42L + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt index b128c9def9..39b45e5b89 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt @@ -12,6 +12,7 @@ import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -158,7 +159,7 @@ class DailyQuestionEditTest { // 툴바 카운트는 이 시나리오의 관심사가 아니다 — **다른** 저장소를 넘긴다. // 같은 fake 를 넘기면 카운트 조회의 draftOnly=true 가 프리필 조회 기록에 섞여 // «어느 목록을 봤는가» 단언이 무너진다 (#769·#770). - MindRecordDraftLoader( + LoadMindRecordDraftsUseCase( diaryRepository = EditTestEmptyDiaryRepository, dailyQuestionRepository = EditTestEmptyDailyQuestionRepository, ), diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt index 0843073bad..e27852387b 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt @@ -14,6 +14,7 @@ import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepositor import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -119,7 +120,7 @@ class DailyQuestionResumeDraftTest { photoUploadRepository = FakePhotoUploadRepository(uploadedUrl = "", uploadedKey = ""), // 툴바 카운트는 이 테스트의 관심사가 아니다 — 같은 저장소를 넘겨 0건으로 둔다 (#769). draftLoader = - MindRecordDraftLoader( + LoadMindRecordDraftsUseCase( diaryRepository = NoDiaryDraftsRepository, dailyQuestionRepository = repository, ), @@ -296,7 +297,7 @@ class DailyQuestionResumeDraftTest { photoUploadRepository = FakePhotoUploadRepository(uploadedUrl = "", uploadedKey = ""), // 툴바 카운트는 이 테스트의 관심사가 아니다 — 같은 저장소를 넘겨 0건으로 둔다 (#769). draftLoader = - MindRecordDraftLoader( + LoadMindRecordDraftsUseCase( diaryRepository = NoDiaryDraftsRepository, dailyQuestionRepository = repository, ), diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt index 017c69f977..4ef0ef6cd3 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt @@ -8,6 +8,7 @@ import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -60,7 +61,7 @@ class DailyQuestionWriteViewModelTest { SavedStateHandle(emptyMap()), repository, FakePhotoUploadRepository.strict(), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -86,7 +87,7 @@ class DailyQuestionWriteViewModelTest { SavedStateHandle(emptyMap()), repository, FakePhotoUploadRepository.strict(), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -117,7 +118,7 @@ class DailyQuestionWriteViewModelTest { uploadedUrl = "https://cdn/just-picked.jpg", uploadedKey = "mindrecords/1/just-picked.jpg", ), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -146,7 +147,7 @@ class DailyQuestionWriteViewModelTest { uploadedUrl = "https://cdn/picked.jpg", uploadedKey = "mindrecords/staging/13/picked.jpg", ), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -172,7 +173,7 @@ class DailyQuestionWriteViewModelTest { SavedStateHandle(emptyMap()), repository, FakePhotoUploadRepository.strict(), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -191,7 +192,7 @@ class DailyQuestionWriteViewModelTest { SavedStateHandle(emptyMap()), repository, FakePhotoUploadRepository.strict(), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -242,7 +243,7 @@ class DailyQuestionWriteViewModelTest { uploadedUrl = "https://cdn.example.net/mindrecords/staging/13/a.png", uploadedKey = "mindrecords/staging/13/a.png", ), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -271,7 +272,7 @@ class DailyQuestionWriteViewModelTest { SavedStateHandle(emptyMap()), repository, FakePhotoUploadRepository.strict(), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -283,8 +284,8 @@ class DailyQuestionWriteViewModelTest { } /** 툴바 카운트는 이 테스트의 관심사가 아니다 — 0건으로 고정한다 (#769). */ -internal fun noopDraftLoader() = - MindRecordDraftLoader( +internal fun noopLoadDraftsUseCase() = + LoadMindRecordDraftsUseCase( diaryRepository = FakeDiaryRepository.strict().apply { onGetList = { _, _ -> diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryDraftConditionTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryDraftConditionTest.kt index a66a12b495..0ba1ae0bc2 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryDraftConditionTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryDraftConditionTest.kt @@ -14,6 +14,13 @@ import org.junit.Test * 종전에는 `submit(isDraft = true)` 도 정식 등록과 같은 `canSubmit` 을 써서, 제목·본문· * 기분이 모두 있어야만 임시저장이 됐다 — **미완성 내용을 보존하는 것이 목적인 기능이 * 완성된 글에서만 동작**했다. 막힐 때 사유도 없었다. + * + * 그 뒤 한 번 더 조여야 했다. 서버가 `isDraft=true` 에도 셋을 전부 검증해(실측 400), + * 보내면 실패하는 조건을 «저장 가능» 으로 표시할 수 없었기 때문이다 (#1065). + * + * **지금은 서버가 풀었다** — `Afternote-BE#243` → PR #267(2026-08-30 머지)이 임시저장의 + * 필수 검증을 걷었다. 이 파일은 이제 그 완화된 조건을 잠근다: 제목·본문 중 하나면 되고 + * 기분은 선택이며, 정식 등록은 여전히 셋을 요구한다. */ class DiaryDraftConditionTest { private fun state( @@ -23,15 +30,21 @@ class DiaryDraftConditionTest { ) = DiaryWriteUiState(title = title, content = content, mood = mood) @Test - fun `제목만으로는 임시저장할 수 없다`() { - // 서버가 임시저장에도 제목·본문·기분을 모두 요구한다 — 실측 400 (#1065). - // 보내면 실패하는 조건을 «저장 가능» 으로 표시하면 버튼이 고장 난 것과 같다. - assertFalse(state(title = "쓰다 만 제목").canSaveDraft) + fun `제목만으로도 임시저장된다`() { + // 미완성 보존이 목적이다 — 서버가 임시저장의 필수 검증을 걷었다 (#1065). + assertTrue(state(title = "쓰다 만 제목").canSaveDraft) + } + + @Test + fun `본문만으로도 임시저장된다`() { + assertTrue(state(content = "

쓰다 만 본문

").canSaveDraft) } @Test - fun `본문만으로도 임시저장할 수 없다`() { - assertFalse(state(content = "

쓰다 만 본문

").canSaveDraft) + fun `기분을 안 골라도 임시저장된다`() { + // 「아직 안 골랐다」를 그대로 보낸다. 아무 값이나 채우면 고른 적 없는 기분이 + // 사용자 데이터가 되어 주간리포트 집계와 감정 분석에 들어간다. + assertNull(state(title = "제목", content = "

본문

").missingForDraft()) } @Test @@ -43,16 +56,16 @@ class DiaryDraftConditionTest { @Test fun `임시저장도 무엇이 빠졌는지 알려준다`() { - // 조용히 막으면 버튼이 죽은 것과 구분되지 않는다. - assertEquals(R.string.mindrecord_write_diary_missing_title, state().missingForDraft()) + // 조용히 막으면 버튼이 죽은 것과 구분되지 않는다. 남길 것이 하나도 없을 때만 막는다. assertEquals( - R.string.mindrecord_write_diary_missing_mood, - state(title = "제목", content = "

본문

").missingForDraft(), + R.string.mindrecord_write_diary_missing_draft_content, + state().missingForDraft(), ) } @Test fun `기분만 골라서는 임시저장할 내용이 없다`() { + // 제목도 본문도 없으면 이어쓰기 목록에서 무엇인지 알아볼 수 없는 빈 임시저장이 된다. assertFalse(state(mood = TodayMood.HAPPY).canSaveDraft) } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryDraftPartialSaveTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryDraftPartialSaveTest.kt new file mode 100644 index 0000000000..d7939c93c2 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryDraftPartialSaveTest.kt @@ -0,0 +1,130 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 미완성 일기가 실제로 저장되는지 (#1065). + * + * 임시저장의 목적이 미완성 보존인데 종전에는 제목·본문·기분이 모두 있어야 저장됐다. + * 서버가 `isDraft=true` 의 필수 검증을 걷어(`Afternote-BE#243` → PR #267) 이제 열린다. + * + * **화면 조건만 보지 않고 저장소에 닿은 payload 까지 본다** — 조건을 열어도 미선택 기분을 + * 어딘가에서 값으로 채우면 고른 적 없는 기분이 사용자 데이터가 된다. 그 폴백이 없는 것이 + * 이 이슈의 절반이다. + * + * 본문 HTML 판정이 `HtmlCompat` 을 타서 Robolectric 이 필요하다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DiaryDraftPartialSaveTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `제목만 적어도 임시저장이 서버까지 나간다`() = + runTest(dispatcher) { + val repository = FakeDiaryRepository() + val viewModel = viewModel(repository) + advanceUntilIdle() + + viewModel.onTitleChanged("쓰다 만 제목") + viewModel.submit(isDraft = true) + advanceUntilIdle() + + val payload = repository.createdPayloads.single() + assertEquals("쓰다 만 제목", payload.title) + assertTrue(payload.isDraft) + } + + @Test + fun `기분을 안 골랐으면 지어내지 않고 null 로 보낸다`() = + runTest(dispatcher) { + val repository = FakeDiaryRepository() + val viewModel = viewModel(repository) + advanceUntilIdle() + + viewModel.onTitleChanged("쓰다 만 제목") + viewModel.onContentChanged("

쓰다 만 본문

") + viewModel.submit(isDraft = true) + advanceUntilIdle() + + // 종전에는 SOSO 로 채웠다 — 이어쓰기로 열면 「그냥그래」가 이미 골라져 보이고 + // 주간리포트 집계와 감정 분석에도 그 값이 들어갔다. + assertNull(repository.createdPayloads.single().todayMood) + } + + @Test + fun `제목도 본문도 없으면 임시저장을 보내지 않는다`() = + runTest(dispatcher) { + val repository = FakeDiaryRepository() + val viewModel = viewModel(repository) + advanceUntilIdle() + + viewModel.submit(isDraft = true) + advanceUntilIdle() + + assertTrue("남길 것이 없는데 빈 임시저장이 나갔다", repository.createdPayloads.isEmpty()) + } + + @Test + fun `정식 등록은 기분 없이 나가지 않는다`() = + runTest(dispatcher) { + val repository = FakeDiaryRepository() + val viewModel = viewModel(repository) + advanceUntilIdle() + + viewModel.onTitleChanged("제목") + viewModel.onContentChanged("

본문

") + viewModel.submit(isDraft = false) + advanceUntilIdle() + + assertTrue("기분 없이 정식 등록이 나갔다", repository.createdPayloads.isEmpty()) + } + + private fun viewModel(repository: FakeDiaryRepository) = + DiaryWriteViewModel( + savedStateHandle = SavedStateHandle(mapOf("draftId" to null, "draftYearMonth" to null)), + repository = repository, + photoUploadRepository = + FakePhotoUploadRepository( + uploadedUrl = "https://cdn.test/image.jpg", + uploadedKey = "mindrecords/1/image.jpg", + ), + userRepository = + FakeUserRepository.strict().apply { + onReceiverListFlow = { flowOf(emptyList()) } + onGetReceivers = { emptyList() } + }, + draftLoader = LoadMindRecordDraftsUseCase(repository, FakeDailyQuestionRepository()), + errorReporter = RecordingErrorReporter(), + ) +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryEditPrefillGuardTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryEditPrefillGuardTest.kt new file mode 100644 index 0000000000..7a8b741a9d --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryEditPrefillGuardTest.kt @@ -0,0 +1,179 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.feature.mindrecord.domain.model.Diary +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +/** + * 수정 진입에서 **대상을 못 읽은 채 저장하는 것**을 막는다 (#2027). + * + * 수정 저장은 PATCH 다 — 보낸 값이 기존 기록을 덮는다. 프리필이 실패하면 화면은 빈 폼이므로, + * 그 상태의 저장은 **사용자가 한 번도 보지 못한 원본을 자기 입력으로 갈아치운다.** + * + * 종전 가드는 `isEditingDraft && draftLoadError != null` 이었고, `isEditingDraft` 는 + * `route.isDraft` 일 때만 섰다. **정식 기록 수정(`isDraft = false`)은 같은 PATCH 경로를 쓰면서 + * 가드 밖에 있었다** — 임시저장 이어쓰기만 막고 정식 수정은 그대로 통과했다. + * + * 그래서 이 파일은 두 진입을 **같은 단언으로** 본다. 한쪽만 고치면 다른 쪽이 다시 열리는 것이 + * 이 결함의 모양이라, 갈래를 나눠 쓰지 않는다. + * + * Robolectric 은 `SavedStateHandle.toRoute()` 가 `android.os.Bundle` 을 지나기 때문에만 필요하다 — + * 시간·디스패처는 [StandardTestDispatcher] 가 쥔다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DiaryEditPrefillGuardTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `정식 기록 수정은 프리필 실패 뒤 저장이 막힌다`() = + runTest(dispatcher) { + assertBlockedAfterPrefillFailure(isDraft = false) + } + + @Test + fun `임시저장 이어쓰기도 프리필 실패 뒤 저장이 막힌다`() = + runTest(dispatcher) { + assertBlockedAfterPrefillFailure(isDraft = true) + } + + /** 정상 수정 계약은 그대로다 — 프리필이 성공하면 저장이 열리고 PATCH 가 나간다. */ + @Test + fun `프리필이 성공한 정식 기록 수정은 종전처럼 저장된다`() = + runTest(dispatcher) { + val repository = + FakeDiaryRepository( + initialDiaries = + listOf( + Diary( + diaryId = EDIT_ID, + title = "원본 제목", + content = "

원본 본문

", + date = "2026-09-13", + createdAt = "2026-09-13T09:00:00", + todayMood = TodayMood.SOSO, + isDraft = false, + ), + ), + ) + val viewModel = viewModel(repository, isDraft = false) + advanceUntilIdle() + + viewModel.onTitleChanged("고친 제목") + viewModel.onContentChanged("

고친 본문

") + viewModel.onMoodSelected(TodayMood.HAPPY) + assertTrue("프리필이 성공했는데 저장이 막혔다", viewModel.uiState.value.canSubmit) + + viewModel.submit(isDraft = false) + advanceUntilIdle() + + assertTrue("정상 수정이 PATCH 를 내지 않았다", repository.updatedPayloads.isNotEmpty()) + } + + /** 신규 작성은 덮어쓸 원본이 없다 — 목록 조회가 실패해도 저장을 막지 않는다. */ + @Test + fun `신규 작성은 목록 조회 실패와 무관하게 저장된다`() = + runTest(dispatcher) { + val repository = FakeDiaryRepository() + repository.onGetList = { _, _ -> Result.failure(IOException("목록 조회 실패")) } + val viewModel = viewModel(repository, isDraft = false, recordId = null) + advanceUntilIdle() + + viewModel.onTitleChanged("새 제목") + viewModel.onContentChanged("

새 본문

") + viewModel.onMoodSelected(TodayMood.HAPPY) + + assertTrue("신규 작성이 막혔다", viewModel.uiState.value.canSubmit) + } + + private suspend fun TestScope.assertBlockedAfterPrefillFailure(isDraft: Boolean) { + val repository = FakeDiaryRepository() + repository.onGetList = { _, _ -> Result.failure(IOException("프리필 조회 실패")) } + val viewModel = viewModel(repository, isDraft = isDraft) + advanceUntilIdle() + + assertNotNull("프리필 실패가 상태에 남지 않았다", viewModel.uiState.value.draftLoadError) + + viewModel.onTitleChanged("대체 제목") + viewModel.onContentChanged("

대체 본문

") + viewModel.onMoodSelected(TodayMood.HAPPY) + + assertFalse("못 읽은 원본 위로 정식 등록이 열렸다", viewModel.uiState.value.canSubmit) + assertFalse("못 읽은 원본 위로 임시저장이 열렸다", viewModel.uiState.value.canSaveDraft) + + viewModel.submit(isDraft = false) + viewModel.submit(isDraft = true) + advanceUntilIdle() + + assertTrue("못 읽은 원본을 덮는 PATCH 가 나갔다", repository.updatedPayloads.isEmpty()) + } + + private fun viewModel( + repository: FakeDiaryRepository, + isDraft: Boolean, + recordId: Long? = EDIT_ID, + ) = DiaryWriteViewModel( + savedStateHandle = + SavedStateHandle( + mapOf( + "recordId" to recordId, + "yearMonth" to YEAR_MONTH, + "isDraft" to isDraft, + ), + ), + repository = repository, + photoUploadRepository = + FakePhotoUploadRepository( + uploadedUrl = "https://cdn.test/image.jpg", + uploadedKey = "mindrecords/1/image.jpg", + ), + userRepository = + FakeUserRepository.strict().apply { + onReceiverListFlow = { flowOf(emptyList()) } + onGetReceivers = { emptyList() } + }, + draftLoader = LoadMindRecordDraftsUseCase(repository, FakeDailyQuestionRepository()), + errorReporter = RecordingErrorReporter(), + ) + + private companion object { + const val EDIT_ID = 42L + const val YEAR_MONTH = "2026-09" + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryRecordDateWireTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryRecordDateWireTest.kt new file mode 100644 index 0000000000..68303399c5 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryRecordDateWireTest.kt @@ -0,0 +1,202 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.repository.PhotoUploadRepository +import com.afternote.core.domain.repository.UserReceiverRepository +import com.afternote.feature.mindrecord.domain.model.Diary +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException +import java.lang.reflect.Proxy +import java.time.LocalDate + +/** + * 사용자가 고른 기록일이 실제로 요청에 실리는지 (#1008). + * + * 종전에는 날짜 선택이 **표시 전용**이었다 — 서버 스키마에 `date` 가 없어 고른 값이 요청에 + * 실리지 않았고, #1121 이 「고를 수 있지만 반영되지 않는」 상태를 없애려 피커를 걷었다. + * 서버가 2026-08-29 부터 생성·수정 양쪽에서 `date` 를 받으므로(`Afternote-BE#244`, PR #262) + * 피커를 되돌렸고, 이 가드가 그 배선을 잠근다. + * + * 이 파일은 `DiaryWriteDateDisplayTest`(「날짜를 바꾸는 공개 창구가 없다」)를 대체한다 — + * 그 단언은 이제 정반대다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +// 수정 진입 시나리오가 `SavedStateHandle.toRoute()` 로 Bundle 을 탄다 — 순수 JVM 에서는 +// `BaseBundle.putLong` 이 not-mocked 로 죽는다. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DiaryRecordDateWireTest { + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `신규 작성은 고른 날짜를 그대로 싣는다`() = + runTest(dispatcher) { + val repository = FakeDiaryRepository() + val viewModel = diaryViewModel(repository, SavedStateHandle(emptyMap())) + advanceUntilIdle() + + val chosen = LocalDate.now().minusDays(3) + viewModel.onDateSelected(chosen) + fillRequiredFields(viewModel) + viewModel.submit() + advanceUntilIdle() + + assertEquals(chosen, repository.createdPayloads.single().date) + } + + @Test + fun `고르지 않으면 오늘이 실린다`() = + runTest(dispatcher) { + val repository = FakeDiaryRepository() + val viewModel = diaryViewModel(repository, SavedStateHandle(emptyMap())) + advanceUntilIdle() + + fillRequiredFields(viewModel) + viewModel.submit() + advanceUntilIdle() + + assertEquals(LocalDate.now(), repository.createdPayloads.single().date) + } + + @Test + fun `미래 날짜는 상태에 들어가지 않는다`() = + runTest(dispatcher) { + // 서버가 400(code 2101)으로 거절하는 값이다. 상태에 넣으면 사용자는 저장을 + // 눌러 봐야 실패를 안다 — 고르는 순간 사유와 함께 막는다. + val repository = FakeDiaryRepository() + val viewModel = diaryViewModel(repository, SavedStateHandle(emptyMap())) + advanceUntilIdle() + + val today = viewModel.uiState.value.date + viewModel.onDateSelected(LocalDate.now().plusDays(1)) + + assertEquals(today, viewModel.uiState.value.date) + assertEquals(false, viewModel.uiState.value.isDateChosen) + assertEquals(true, viewModel.uiState.value.dateError != null) + } + + @Test + fun `프리필이 날짜를 못 주면 수정은 기록일을 싣지 않는다`() = + runTest(dispatcher) { + // 여기서 «오늘» 을 실어 보내면 기존 기록일이 조용히 오늘로 옮겨진다. 서버는 + // 키를 생략하면 기존 값을 유지하므로 생략이 맞다. + // + // 「날짜를 못 준다」를 **조회 실패로 세우지 않는다.** 조회가 실패하면 화면은 못 읽은 + // 원본 위에 놓이고, 그 상태의 저장은 #2027 이 막는다 — 그러면 이 단언이 보려는 + // 「나간 요청에 date 키가 없다」에 도달하지 못한다. 조회는 성공하되 그 기록의 + // 날짜를 해석할 수 없는 경우로 세운다 (`Diary.toUi()` 가 null 을 돌려주는 자리). + val repository = + FakeDiaryRepository( + initialDiaries = listOf(existingDiary(date = "", createdAt = "")), + ) + val viewModel = diaryViewModel(repository, editRoute()) + advanceUntilIdle() + + fillRequiredFields(viewModel) + viewModel.submit() + advanceUntilIdle() + + assertNull( + repository.updatedPayloads + .single() + .second.date, + ) + } + + @Test + fun `프리필이 준 날짜는 수정 요청에 그대로 남는다`() = + runTest(dispatcher) { + val repository = FakeDiaryRepository(initialDiaries = listOf(existingDiary(date = "2026-07-11"))) + val viewModel = diaryViewModel(repository, editRoute()) + advanceUntilIdle() + + viewModel.submit() + advanceUntilIdle() + + assertEquals( + LocalDate.of(2026, 7, 11), + repository.updatedPayloads + .single() + .second.date, + ) + } + + private fun fillRequiredFields(viewModel: DiaryWriteViewModel) { + viewModel.onTitleChanged("제목") + viewModel.onContentChanged("

본문

") + viewModel.onMoodSelected(TodayMood.HAPPY) + } + + private fun editRoute() = + SavedStateHandle( + mapOf("recordId" to EXISTING_ID, "yearMonth" to "2026-07", "isDraft" to false), + ) + + private fun existingDiary( + date: String, + createdAt: String = "2026-07-11T09:00:00", + ) = Diary( + diaryId = EXISTING_ID, + title = "기존 제목", + content = "

기존 본문

", + date = date, + createdAt = createdAt, + todayMood = TodayMood.SOSO, + isDraft = false, + ) + + private fun diaryViewModel( + repository: FakeDiaryRepository, + savedStateHandle: SavedStateHandle, + ): DiaryWriteViewModel = + DiaryWriteViewModel( + savedStateHandle = savedStateHandle, + repository = repository, + // 이 시나리오는 업로드를 타지 않는다 — 부르면 그 자체가 결함이라 실패로 둔다. + photoUploadRepository = PhotoUploadRepository { _, _ -> Result.failure(IOException("unused")) }, + userRepository = emptyReceiverRepository(), + draftLoader = LoadMindRecordDraftsUseCase(repository, FakeDailyQuestionRepository()), + errorReporter = RecordingErrorReporter(), + ) + + /** UserRepository 는 표면이 넓다 — 이 시나리오가 타는 호출만 답한다. */ + private fun emptyReceiverRepository(): UserReceiverRepository = + Proxy.newProxyInstance( + UserReceiverRepository::class.java.classLoader, + arrayOf(UserReceiverRepository::class.java), + ) { _, method, _ -> + when (method.name) { + "getReceivers" -> emptyList() + "getReceiverListFlow" -> flowOf(emptyList()) + else -> error("Unexpected call: ${method.name}") + } + } as UserReceiverRepository + + private companion object { + const val EXISTING_ID = 4242L + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt index b6b3e9d8f0..ae36f414b8 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt @@ -10,6 +10,7 @@ import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking @@ -122,9 +123,32 @@ class DiaryWriteImageKeyTest { assertEquals(storedHtml, sent) } - private fun viewModel(onCreate: (DiaryCreatePayload) -> Unit = {}): DiaryWriteViewModel = + @Test + fun `프로세스 사망으로 되살아나도 방금 올린 이미지가 fileKey 로 나간다`() { + // 에디터 본문은 rememberSaveable(RichTextState.Saver) 라 죽었다 살아나도 HTML 이 + // 그대로 복원되고, 그 값이 다시 ViewModel 로 흘러든다. 대응표만 인메모리면 복원된 + // 본문에는 전체 URL 이 남았는데 바꿀 근거가 사라진 상태가 되어, 그대로 서버로 나가 + // 호스트가 한 번 더 붙고 403 이 된다 (#549 재발, #1125 리뷰). + val handle = SavedStateHandle(emptyMap()) + val previewUrl = runBlocking { viewModel(savedStateHandle = handle).uploadMedia("content://picked") } + + // 같은 SavedStateHandle 로 새 ViewModel — 프로세스 사망 뒤 복원과 같은 자리다. + var sent: String? = null + val revived = viewModel(onCreate = { sent = it.content }, savedStateHandle = handle) + revived.onTitleChanged("제목") + revived.onContentChanged("

본문

") + revived.onMoodSelected(TodayMood.HAPPY) + revived.submit() + + assertEquals("

본문

", sent) + } + + private fun viewModel( + onCreate: (DiaryCreatePayload) -> Unit = {}, + savedStateHandle: SavedStateHandle = SavedStateHandle(emptyMap()), + ): DiaryWriteViewModel = DiaryWriteViewModel( - savedStateHandle = SavedStateHandle(emptyMap()), + savedStateHandle = savedStateHandle, repository = RecordingDiaryRepository(onCreate), photoUploadRepository = FakePhotoUploadRepository( @@ -132,7 +156,7 @@ class DiaryWriteImageKeyTest { uploadedKey = UPLOADED_KEY, ), userRepository = noReceiverUserRepository(), - draftLoader = MindRecordDraftLoader(RecordingDiaryRepository {}, NoDailyQuestionRepository), + draftLoader = LoadMindRecordDraftsUseCase(RecordingDiaryRepository {}, NoDailyQuestionRepository), errorReporter = RecordingErrorReporter(), ) diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt index f51f92c62d..19cdd3711e 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt @@ -9,6 +9,9 @@ import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -88,7 +91,8 @@ class DraftListDeleteTest { Result.failure(IllegalStateException("404")) } } - val viewModel = viewModel(repository) + val reporter = RecordingErrorReporter() + val viewModel = viewModel(repository, reporter) val target = (viewModel.uiState.value as DraftListUiState.Success).items viewModel.delete(target) @@ -96,6 +100,41 @@ class DraftListDeleteTest { val state = viewModel.uiState.value as DraftListUiState.Success assertEquals(DraftDeleteOutcome.AllDeleted, state.deleteOutcome) assertTrue(state.items.isEmpty()) + // 화면에서 빠지는 실패일수록 콘솔이 유일한 흔적이다. 재조회 대조로 계측까지 걸러 내면 + // 「왜 안 지워졌나」를 나중에 물을 곳이 없어진다 (#964·#1693 리뷰). + assertEquals(listOf("draft_delete"), reporter.stages) + } + + /** + * 재조회 도중 스코프가 취소되는 창. 삭제는 이미 서버에 반영됐으므로 **그 실패는 남아야 + * 한다** — 반환값으로 받아 기록하면 이 경로에서 흔적이 통째로 사라진다 (#1693 리뷰). + */ + @Test + fun `재조회 도중 취소돼도 삭제 실패는 기록된다`() { + var listCalls = 0 + val repository = + FakeDailyQuestionRepository( + initialAnswers = listOf(dailyQuestion(id = 1L)), + onDelete = { Result.failure(IllegalStateException("서버 거절")) }, + ).apply { + // 첫 조회(초기 로드)는 통과시키고, 삭제 뒤 재조회에서 취소를 흉내 낸다 — + // 사용자가 화면을 벗어난 자리다. + onGetList = { _, _ -> + listCalls += 1 + if (listCalls == 1) { + Result.success(listOf(dailyQuestion(id = 1L))) + } else { + throw CancellationException("화면 이탈") + } + } + } + val reporter = RecordingErrorReporter() + val viewModel = viewModel(repository, reporter) + val target = (viewModel.uiState.value as DraftListUiState.Success).items + + runCatching { viewModel.delete(target) } + + assertEquals("재조회 취소로 삭제 실패의 흔적이 사라졌다", listOf("draft_delete"), reporter.stages) } @Test @@ -103,7 +142,8 @@ class DraftListDeleteTest { val repository = // 성공 삭제는 픽스처 기본 동작(저장소에서 제거 + success)이 그대로 맞다. FakeDailyQuestionRepository(initialAnswers = listOf(dailyQuestion(id = 1L))) - val viewModel = viewModel(repository) + val reporter = RecordingErrorReporter() + val viewModel = viewModel(repository, reporter) val target = (viewModel.uiState.value as DraftListUiState.Success).items viewModel.delete(target) @@ -111,6 +151,8 @@ class DraftListDeleteTest { val state = viewModel.uiState.value as DraftListUiState.Success assertEquals(DraftDeleteOutcome.AllDeleted, state.deleteOutcome) assertTrue(state.items.isEmpty()) + // 반대쪽 경계 — 성공에는 계측이 없어야 한다. 「전부 올린다」 로 되돌리면 여기가 먼저 깨진다. + assertEquals(emptyList(), reporter.stages) } private fun viewModel( @@ -118,15 +160,18 @@ class DraftListDeleteTest { errorReporter: RecordingErrorReporter = RecordingErrorReporter(), ): DraftListViewModel = DraftListViewModel( - // #769 가 목록 조회를 loader 로 뽑아낸 뒤 생성자가 3개로 늘었다. 같은 두 - // 저장소를 넘겨 종전과 같은 조회 경로를 그대로 태운다. - loader = - MindRecordDraftLoader( + // 조회와 삭제가 각각 UseCase 로 갈렸다 (#1693). 같은 두 저장소를 양쪽에 넘겨 + // 종전과 같은 경로를 그대로 태운다. + loadDrafts = + LoadMindRecordDraftsUseCase( + diaryRepository = emptyDiaryDrafts(), + dailyQuestionRepository = dailyQuestionRepository, + ), + deleteDrafts = + DeleteMindRecordDraftsUseCase( diaryRepository = emptyDiaryDrafts(), dailyQuestionRepository = dailyQuestionRepository, ), - diaryRepository = emptyDiaryDrafts(), - dailyQuestionRepository = dailyQuestionRepository, errorReporter = errorReporter, ) diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/LatePrefillOverwriteTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/LatePrefillOverwriteTest.kt new file mode 100644 index 0000000000..84143388e5 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/LatePrefillOverwriteTest.kt @@ -0,0 +1,220 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.model.Diary +import com.afternote.feature.mindrecord.domain.model.DiaryList +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 늦게 도착한 수정 프리필이 **작성 중 입력을 덮지 않는지** (#2031). + * + * 입력창은 프리필을 기다리는 동안에도 편집할 수 있다. 두 작성 화면의 프리필 성공 경로가 + * 사용자가 손댔는지 보지 않고 값을 실어, 응답이 늦게 오면 방금 친 글이 서버 값으로 되돌아갔다. + * + * 같은 VM 의 오늘 초안 이어쓰기(`resumeDraft`)에는 이미 그 보호가 있었다 — **대상 ID 로 들어온 + * 수정 프리필만 빠져 있었다.** 그래서 이 파일은 「사용자가 손댄 칸은 그대로」와 「손대지 않은 칸은 + * 원본으로 채워진다」를 함께 본다. 한쪽만 보면 덮어쓰기를 막다가 프리필 자체를 죽인다. + * + * Robolectric 은 `SavedStateHandle.toRoute()` 가 `android.os.Bundle` 을 지나기 때문에만 필요하다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class LatePrefillOverwriteTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `일기 - 로딩 중 입력한 값이 늦은 원본으로 되돌아가지 않는다`() = + runTest(dispatcher) { + val gate = CompletableDeferred() + val repository = + FakeDiaryRepository().apply { + onGetList = { _, _ -> + gate.await() + Result.success(DiaryList(listOf(serverDiary()), monthDiaryCount = 1, weeklyDominantMood = null)) + } + } + val viewModel = diaryViewModel(repository) + advanceUntilIdle() + + viewModel.onTitleChanged("내가 쓴 제목") + viewModel.onContentChanged("

내가 쓴 본문

") + viewModel.onMoodSelected(TodayMood.HAPPY) + + gate.complete(Unit) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals("내가 쓴 제목", state.title) + assertEquals("

내가 쓴 본문

", state.content) + assertEquals(TodayMood.HAPPY, state.mood) + assertTrue("프리필 도착이 기록되지 않아 저장이 막힌다", state.draftLoaded) + } + + @Test + fun `일기 - 손대지 않은 칸은 원본으로 채워진다`() = + runTest(dispatcher) { + val repository = + FakeDiaryRepository().apply { + onGetList = { _, _ -> + Result.success(DiaryList(listOf(serverDiary()), monthDiaryCount = 1, weeklyDominantMood = null)) + } + } + val viewModel = diaryViewModel(repository) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals("원본 제목", state.title) + assertEquals("

원본 본문

", state.content) + assertEquals(TodayMood.SOSO, state.mood) + } + + /** 한 칸만 손댄 경우 — 그 칸만 지키고 나머지는 원본으로 채운다. */ + @Test + fun `일기 - 제목만 입력했으면 본문과 기분은 원본으로 채워진다`() = + runTest(dispatcher) { + val gate = CompletableDeferred() + val repository = + FakeDiaryRepository().apply { + onGetList = { _, _ -> + gate.await() + Result.success(DiaryList(listOf(serverDiary()), monthDiaryCount = 1, weeklyDominantMood = null)) + } + } + val viewModel = diaryViewModel(repository) + advanceUntilIdle() + + viewModel.onTitleChanged("내가 쓴 제목") + gate.complete(Unit) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals("내가 쓴 제목", state.title) + assertEquals("

원본 본문

", state.content) + assertEquals(TodayMood.SOSO, state.mood) + } + + @Test + fun `데일리답변 - 로딩 중 입력한 본문이 늦은 원본으로 되돌아가지 않는다`() = + runTest(dispatcher) { + val gate = CompletableDeferred() + val repository = + FakeDailyQuestionRepository().apply { + onGetList = { _, _ -> + gate.await() + Result.success(listOf(serverAnswer())) + } + } + val viewModel = dailyQuestionViewModel(repository) + advanceUntilIdle() + + viewModel.onAnswerChanged("

내가 쓴 본문

") + + gate.complete(Unit) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals("

내가 쓴 본문

", state.answer) + // 질문 문구와 대상 ID 는 서버가 정하는 값이라 언제나 실려야 한다. + assertEquals("오늘의 질문", state.questionContent) + assertEquals(EDIT_ID, state.draftId) + assertTrue("프리필 도착이 기록되지 않아 저장이 막힌다", state.contentLoaded) + } + + @Test + fun `데일리답변 - 손대지 않았으면 원본 본문이 채워진다`() = + runTest(dispatcher) { + val repository = + FakeDailyQuestionRepository().apply { + onGetList = { _, _ -> Result.success(listOf(serverAnswer())) } + } + val viewModel = dailyQuestionViewModel(repository) + advanceUntilIdle() + + assertEquals("

원본 본문

", viewModel.uiState.value.answer) + } + + private fun serverDiary() = + Diary( + diaryId = EDIT_ID, + title = "원본 제목", + content = "

원본 본문

", + date = "2026-09-13", + createdAt = "2026-09-13T09:00:00", + todayMood = TodayMood.SOSO, + isDraft = false, + ) + + private fun serverAnswer() = + DailyQuestion( + dailyQuestionId = EDIT_ID, + title = "오늘의 질문", + content = "

원본 본문

", + createdAt = "2026-09-13T09:00:00", + isDraft = false, + ) + + private fun diaryViewModel(repository: FakeDiaryRepository) = + DiaryWriteViewModel( + savedStateHandle = + SavedStateHandle( + mapOf("recordId" to EDIT_ID, "yearMonth" to "2026-09", "isDraft" to false), + ), + repository = repository, + photoUploadRepository = FakePhotoUploadRepository.strict(), + userRepository = + FakeUserRepository.strict().apply { + onReceiverListFlow = { flowOf(emptyList()) } + onGetReceivers = { emptyList() } + }, + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), FakeDailyQuestionRepository()), + errorReporter = RecordingErrorReporter(), + ) + + private fun dailyQuestionViewModel(repository: FakeDailyQuestionRepository) = + DailyQuestionWriteViewModel( + SavedStateHandle(mapOf("answerId" to EDIT_ID, "isDraft" to false)), + repository, + FakePhotoUploadRepository.strict(), + LoadMindRecordDraftsUseCase(FakeDiaryRepository(), FakeDailyQuestionRepository()), + RecordingErrorReporter(), + ) + + private companion object { + const val EDIT_ID = 42L + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/LoadRaceVersionTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/LoadRaceVersionTest.kt index 29af801329..a6a4390ae4 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/LoadRaceVersionTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/LoadRaceVersionTest.kt @@ -3,6 +3,7 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -56,7 +57,7 @@ class LoadRaceVersionTest { } Result.success(emptyList()) } - val viewModel = DailyQuestionListViewModel(repository, changeTracker) + val viewModel = DailyQuestionListViewModel(repository, changeTracker, RecordingErrorReporter()) backgroundScope.launch { viewModel.uiState.collect { } } advanceUntilIdle() diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt index 6cc07cf1bf..121481194e 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt @@ -14,6 +14,7 @@ import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepositor import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -57,7 +58,12 @@ class MindRecordFailureRecoveryTest { fun `삭제 실패 안내는 다음 삭제가 성공하면 사라진다`() = runTest(dispatcher) { val repository = FlakyDeleteRepository() - val viewModel = DailyQuestionListViewModel(repository = repository, changeTracker = MindRecordChangeTracker()) + val viewModel = + DailyQuestionListViewModel( + repository = repository, + changeTracker = MindRecordChangeTracker(), + errorReporter = RecordingErrorReporter(), + ) // uiState 는 WhileSubscribed 라 구독자가 없으면 Loading 에 머문다. backgroundScope.launch { viewModel.uiState.collect { } } advanceUntilIdle() @@ -85,7 +91,12 @@ class MindRecordFailureRecoveryTest { // 리뷰가 스크린샷으로 실증한 경로 — 삭제 재시도가 아니라 **재조회 성공**이다. // 기내모드 해제 → 탭 전환 → 목록 정상 재로드인데도 배너가 남아 있었다. val repository = FlakyDeleteRepository() - val viewModel = DailyQuestionListViewModel(repository = repository, changeTracker = MindRecordChangeTracker()) + val viewModel = + DailyQuestionListViewModel( + repository = repository, + changeTracker = MindRecordChangeTracker(), + errorReporter = RecordingErrorReporter(), + ) backgroundScope.launch { viewModel.uiState.collect { } } advanceUntilIdle() @@ -119,7 +130,7 @@ class MindRecordFailureRecoveryTest { photoUploadRepository = FakePhotoUploadRepository(onUpload = { _, _ -> uploadGate.await() }), // 툴바 카운트는 이 테스트의 관심사가 아니다 — 빈 목록으로 고정한다 (#769). draftLoader = - MindRecordDraftLoader( + LoadMindRecordDraftsUseCase( diaryRepository = EmptyDiaryRepository, dailyQuestionRepository = repository, ), @@ -159,7 +170,12 @@ class MindRecordFailureRecoveryTest { // «본 버전» 까지 찍어 두면, 복귀할 때마다 재조회를 건너뛰어 **서버가 회복돼도 // 배너가 돌아오지 않는다**. 그 고정 상태를 본다 (#736 리뷰). val repository = FlakyTodayRepository() - val viewModel = DailyQuestionListViewModel(repository = repository, changeTracker = MindRecordChangeTracker()) + val viewModel = + DailyQuestionListViewModel( + repository = repository, + changeTracker = MindRecordChangeTracker(), + errorReporter = RecordingErrorReporter(), + ) backgroundScope.launch { viewModel.uiState.collect { } } advanceUntilIdle() diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordRequestCountTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordRequestCountTest.kt index b1b400cb0b..32c83c4470 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordRequestCountTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordRequestCountTest.kt @@ -1,6 +1,6 @@ package com.afternote.feature.mindrecord.presentation.viewmodel -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.MyProfileRepository import com.afternote.core.model.user.User import com.afternote.feature.mindrecord.domain.model.DailyQuestion import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload @@ -11,6 +11,8 @@ import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -112,7 +114,8 @@ class MindRecordRequestCountTest { fun `주간리포트도 데이터가 그대로면 복귀에 다시 부르지 않는다`() = runTest(dispatcher) { val repository = CountingWeeklyReportRepository() - val viewModel = WeeklyReportViewModel(repository, userRepository(), changeTracker) + val viewModel = + WeeklyReportViewModel(ObserveWeeklyReportUseCase(repository, userRepository()), changeTracker, RecordingErrorReporter()) backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } advanceUntilIdle() @@ -130,7 +133,8 @@ class MindRecordRequestCountTest { // 소진된 뒤 대기가 남으면 복귀 갱신이 유일한 복구 경로인데, «데이터가 그대로면 // 부르지 않는다» 가드가 그것까지 막으면 «분석 중» 이 앱 재시작까지 굳는다. val repository = CountingWeeklyReportRepository(pendingAnalysis = true) - val viewModel = WeeklyReportViewModel(repository, userRepository(), changeTracker) + val viewModel = + WeeklyReportViewModel(ObserveWeeklyReportUseCase(repository, userRepository()), changeTracker, RecordingErrorReporter()) backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } advanceUntilIdle() advanceTimeBy(10 * 60 * 1000L) @@ -155,7 +159,7 @@ class MindRecordRequestCountTest { // ── 테스트 도구 ─────────────────────────────────────────────────────────── private fun TestScope.startDailyQuestion(repository: DailyQuestionRepository): DailyQuestionListViewModel { - val viewModel = DailyQuestionListViewModel(repository, changeTracker) + val viewModel = DailyQuestionListViewModel(repository, changeTracker, RecordingErrorReporter()) backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } advanceUntilIdle() return viewModel @@ -216,15 +220,15 @@ class MindRecordRequestCountTest { } } - private fun userRepository(): UserRepository = + private fun userRepository(): MyProfileRepository = Proxy.newProxyInstance( - UserRepository::class.java.classLoader, - arrayOf(UserRepository::class.java), + MyProfileRepository::class.java.classLoader, + arrayOf(MyProfileRepository::class.java), ) { _, method, _ -> when (method.name) { "getReceiverListFlow" -> flowOf(emptyList()) "getMyProfile" -> User(name = "adamtia", email = "a@b.c", phone = null, profileImageUrl = null) else -> error("Unexpected user repository call: ${method.name}") } - } as UserRepository + } as MyProfileRepository } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RawExceptionTextNotShownTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RawExceptionTextNotShownTest.kt new file mode 100644 index 0000000000..a71d329acc --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RawExceptionTextNotShownTest.kt @@ -0,0 +1,119 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +/** + * 예외 원문이 화면 상태에 실리지 않는지 잠근다 (#1882). + * + * 이 저장소는 **서버 오류 원문을 화면에 내지 않는다** — 로그·텔레메트리로만 보낸다 + * (#1339 에서 수신자 흐름에 적용). 마음의 기록 두 자리는 그 선 밖에 있었다: + * `UiText.DynamicOrResource(value = e.message, …)` 는 `asString()` 이 + * `value ?: stringResource(fallback)` 이라 **`e.message` 가 있으면 fallback 을 아예 쓰지 + * 않는다.** 직렬화 예외 문구·영문 스택 용어가 그대로 사용자에게 갔다. + * + * 가정이 아니라 한 번 일어났던 일이다 — `EmotionAnalysisContractTest` 의 주석이 + * 「`MissingFieldException` 이 나 … `DynamicOrResource` 가 `e.message` 를 우선해 영문 예외 + * 원문까지 노출됐다」로 그 사고를 기록하고 있다. 그때는 DTO 필드를 옵셔널로 내려 그 예외만 + * 막았고, 노출 기전은 살아 있었다. + * + * ### 무엇을 단언하는가 + * + * **타입**을 본다 — 상태에 실린 `UiText` 가 `UiText.Resource` 여야 한다. 문구를 비교하면 + * 문자열이 바뀔 때마다 같이 고쳐야 하고, 정작 지키려는 것(원문이 실리는가)은 못 본다. + * `DynamicOrResource` 로 되돌리면 이 단언이 바로 빨개진다. + * + * 원문이 **유실되지도 않아야 한다** — 같은 실패가 `ErrorReporter` 에 단계 키와 함께 남는지 + * 함께 본다. 화면에서 걷어내고 계측에도 안 남기면 릴리즈에서 아무 단서가 없다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RawExceptionTextNotShownTest { + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `이어쓰기 조회 실패가 예외 문구 대신 안내 문자열을 싣는다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val repository = + FakeDailyQuestionRepository().apply { + onGetList = { _, _ -> Result.failure(IOException("Unresolved field 'daily-question' at offset 42")) } + } + val viewModel = + DailyQuestionWriteViewModel( + // 이어쓰기 진입 — 이 경로가 조회에 실패하는 자리다. + savedStateHandle = SavedStateHandle(mapOf("answerId" to 7L, "isDraft" to true)), + repository = repository, + photoUploadRepository = FakePhotoUploadRepository.strict(), + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository), + errorReporter = reporter, + ) + advanceUntilIdle() + + val shown = viewModel.uiState.value.questionLoadError + assertTrue( + "예외 원문이 화면 상태에 실렸다: $shown", + shown is UiText.Resource, + ) + assertEquals("원문은 계측으로 남아야 한다", listOf("daily_question_load"), reporter.stages) + } + + @Test + fun `주간 리포트 조회 실패가 예외 문구 대신 안내 문자열을 싣는다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val repository = + FakeWeeklyReportRepository().apply { + results.addLast(Result.failure(IOException("HTTP 500 Internal Server Error: gemini quota exceeded"))) + } + val viewModel = + WeeklyReportViewModel( + ObserveWeeklyReportUseCase(repository, FakeUserRepository()), + MindRecordChangeTracker(), + reporter, + ) + // uiState 가 WhileSubscribed 라 구독자가 없으면 Loading 에 머문다. + backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue("오류 상태여야 한다: $state", state is WeeklyReportUiState.Error) + state as WeeklyReportUiState.Error + assertTrue( + "예외 원문이 화면 상태에 실렸다: ${state.message}", + state.message is UiText.Resource, + ) + assertEquals("원문은 계측으로 남아야 한다", listOf("weekly_report_load"), reporter.stages) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReadFailureReportingTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReadFailureReportingTest.kt new file mode 100644 index 0000000000..6644267891 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReadFailureReportingTest.kt @@ -0,0 +1,287 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.GetMemorySpaceUseCase +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +/** + * 읽기 실패 계측의 **선별 기준** 가드 (#964). + * + * 이 모듈은 종전에 `Log.e` 도 `ErrorReporter` 도 0건이라, 실패가 UI 상태로만 흡수되고 + * 릴리즈에서 무슨 일이 있었는지 알 방법이 없었다. 쓰기 실패는 먼저 승격됐고 이 파일은 + * **읽기** 몫이다. + * + * ### 전부 올리지 않는다 + * + * Crashlytics 는 non-fatal 을 최근 8건만 보관하고 초과분을 버린다. 목록 조회 실패를 전부 + * 올리면 네트워크가 한 번 끊길 때 잡음이 한도를 채워 **실제 장애를 밀어낸다.** + * + * 그래서 선을 하나 긋는다 — **사용자가 오류 화면을 마주한 실패만 올린다.** 재진입 갱신 + * (`refreshOnReturn`)은 실패해도 보고 있던 목록을 그대로 두므로 올리지 않는다. 화면 이탈이 + * 잦은 목록에서 그쪽이 잡음의 대부분이다. + * + * 이 파일이 지키는 것은 그 선이다. 「기록되는가」뿐 아니라 **「기록되지 않는가」**를 함께 본다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +// 상세 ViewModel 이 `SavedStateHandle.toRoute()` 로 route 를 복원하는데 그 경로가 Android +// `Bundle` 을 탄다 — 순수 JVM 으로는 `putLong ... not mocked` 로 죽는다. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ReadFailureReportingTest { + private val dispatcher = StandardTestDispatcher() + + // ViewModel 은 `viewModelScope`(Main) 에서 돈다 — 바꿔 놓지 않으면 코루틴이 아예 + // 실행되지 않아 「기록되지 않았다」 단언이 **아무 것도 안 돌아서** 통과한다. + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `일기 목록이 오류 화면으로 바뀌면 기록한다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val viewModel = failingDiaryListViewModel(reporter) + + backgroundScope.launch { viewModel.uiState.collect { } } + advanceUntilIdle() + + assertEquals(listOf("record_list_load"), reporter.stages) + } + + /** + * **이 테스트가 선을 지킨다.** 재진입 갱신 실패는 화면을 갈아치우지 않으므로 사용자에게 + * 보이지도 않는다 — 그것까지 올리면 한도가 잡음으로 찬다. + * + * `refreshOnReturn` 은 `loadedVersion == changeTracker.version` 이면 즉시 반환한다. 트래커를 + * 새로 만들어 넘기면 첫 조회 뒤 두 값이 같아져 **재조회가 아예 안 나가고**, 심어 둔 실패가 + * 호출조차 되지 않은 채 「기록 없음」이 통과한다 — 계측을 «전부 올리기» 로 되돌려도 초록인 + * 공허한 테스트가 된다 (#964 리뷰). 그래서 쓰기를 한 번 알리고, **갱신이 실제로 나갔는지**를 + * 조회 횟수로 함께 못박는다. + */ + @Test + fun `재진입 갱신이 실패해도 목록이 남아 있으면 기록하지 않는다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val diaryRepository = FakeDiaryRepository() + val changeTracker = MindRecordChangeTracker() + val viewModel = + DiaryListViewModel( + repository = diaryRepository, + changeTracker = changeTracker, + errorReporter = reporter, + ) + backgroundScope.launch { viewModel.uiState.collect { } } + advanceUntilIdle() + // 첫 조회는 성공해 목록이 떠 있다. + assertEquals(emptyList(), reporter.stages) + assertEquals(1, diaryRepository.listQueries.size) + + diaryRepository.onGetList = { _, _ -> Result.failure(IOException("갱신 실패")) } + // 쓰기가 있었다고 알린다 — 없으면 버전 가드가 재조회를 건너뛴다. + changeTracker.notifyChanged() + viewModel.refreshOnReturn() + advanceUntilIdle() + + assertEquals("재조회가 나가지 않아 실패 자체가 없었다", 2, diaryRepository.listQueries.size) + assertTrue("갱신 실패가 화면을 갈아치웠다", viewModel.uiState.value is DiaryListUiState.Success) + assertEquals("보고 있던 목록이 유지되는 실패까지 올라갔다", emptyList(), reporter.stages) + } + + @Test + fun `데일리질문 목록이 오류 화면으로 바뀌면 기록한다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val viewModel = + DailyQuestionListViewModel( + repository = FakeDailyQuestionRepository(onGetList = { _, _ -> Result.failure(IOException("조회 실패")) }), + changeTracker = MindRecordChangeTracker(), + errorReporter = reporter, + ) + + backgroundScope.launch { viewModel.uiState.collect { } } + advanceUntilIdle() + + assertEquals(listOf("record_list_load"), reporter.stages) + } + + /** + * 작성 화면이 여는 조회다. 여기 실패하면 화면이 오류 문구만 남고 **쓸 수가 없다** — + * 목록 실패와 달리 「보고 있던 것」이 없다 (#964). + */ + @Test + fun `작성 화면이 오늘 질문을 못 받으면 기록한다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val repository = + FakeDailyQuestionRepository().apply { + onGetToday = { Result.failure(IOException("질문 조회 실패")) } + } + DailyQuestionWriteViewModel( + savedStateHandle = SavedStateHandle(emptyMap()), + repository = repository, + photoUploadRepository = FakePhotoUploadRepository.strict(), + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository), + errorReporter = reporter, + ) + advanceUntilIdle() + + assertEquals(listOf("daily_question_load"), reporter.stages) + } + + /** + * 추억 공간은 **부분 실패를 삼킨다** — 카드가 한 장이라도 차면 그대로 보여 준다. + * 그 선을 지키는 음성 단언이 없으면 「전부 올리기」로 되돌려도 초록이다 (#964 리뷰). + */ + @Test + fun `추억 공간은 한쪽만 실패하면 기록하지 않는다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + MemorySpaceViewModel( + getMemorySpace = + GetMemorySpaceUseCase( + diaryRepository = FakeDiaryRepository(onGetList = { _, _ -> Result.failure(IOException("일기 실패")) }), + dailyQuestionRepository = FakeDailyQuestionRepository(initialAnswers = listOf(answeredQuestion())), + ), + errorReporter = reporter, + ) + advanceUntilIdle() + + assertEquals("부분 실패까지 올라갔다", emptyList(), reporter.stages) + } + + /** 되돌릴 수 없는 동작이라 화면만 알리고 끝내면 나중에 물을 곳이 없다. */ + @Test + fun `기록 삭제가 실패하면 기록한다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val diaryRepository = FakeDiaryRepository(onDelete = { Result.failure(IOException("삭제 실패")) }) + val viewModel = + DiaryListViewModel( + repository = diaryRepository, + changeTracker = MindRecordChangeTracker(), + errorReporter = reporter, + ) + backgroundScope.launch { viewModel.uiState.collect { } } + advanceUntilIdle() + + viewModel.delete(1L) + advanceUntilIdle() + + assertEquals(listOf("record_delete"), reporter.stages) + } + + /** + * 데일리질문 쪽도 같은 계약이다. 종전에는 이 계측을 지워도 초록이었다 — + * `MindRecordFailureRecoveryTest` 가 같은 경로를 돌리지만 reporter 단언이 없다 (#964 리뷰). + */ + @Test + fun `데일리질문 삭제가 실패하면 기록한다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val viewModel = + DailyQuestionListViewModel( + repository = FakeDailyQuestionRepository(onDelete = { Result.failure(IOException("삭제 실패")) }), + changeTracker = MindRecordChangeTracker(), + errorReporter = reporter, + ) + backgroundScope.launch { viewModel.uiState.collect { } } + advanceUntilIdle() + + viewModel.delete(1L) + advanceUntilIdle() + + assertEquals(listOf("record_delete"), reporter.stages) + } + + /** 「내가 쓴 기록이 안 열린다」 — 재현 조건(그 계정의 그 기록)이 우리 손에 없다. */ + @Test + fun `기록 상세 조회가 실패하면 기록한다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + RecordDetailViewModel( + savedStateHandle = detailRoute(isDiary = true), + diaryRepository = FakeDiaryRepository(onGetList = { _, _ -> Result.failure(IOException("상세 실패")) }), + dailyQuestionRepository = FakeDailyQuestionRepository(), + errorReporter = reporter, + ) + advanceUntilIdle() + + assertEquals(listOf("record_detail_load"), reporter.stages) + } + + /** + * 추억 공간은 **부분 실패를 삼킨다** — 카드가 한 장이라도 차면 그대로 보여 준다. + * 그러니 여기 올라오는 것은 **합친 결과가 비었고 실패 출처가 하나라도 있을 때**다. + * 출처는 둘이 아니라 넷이다 — 일기는 최근 3개월을 달마다 따로 조회하고(`DIARY_MONTH_WINDOW`) + * 데일리질문이 하나다. 그래서 일기 0건(신규 사용자)에 질문 조회 하나만 실패해도 올라온다. + */ + @Test + fun `추억 공간이 통째로 실패하면 기록한다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + MemorySpaceViewModel( + getMemorySpace = + GetMemorySpaceUseCase( + diaryRepository = FakeDiaryRepository(onGetList = { _, _ -> Result.failure(IOException("일기 실패")) }), + dailyQuestionRepository = FakeDailyQuestionRepository(onGetList = { _, _ -> Result.failure(IOException("질문 실패")) }), + ), + errorReporter = reporter, + ) + advanceUntilIdle() + + assertEquals(listOf("memory_space_load"), reporter.stages) + } + + /** 추억 공간이 「한 장은 찼다」로 볼 수 있는 최소 재료. */ + private fun answeredQuestion() = + DailyQuestion( + dailyQuestionId = 1L, + title = "질문", + content = "답변", + createdAt = "2026-08-23T10:00:00", + isDraft = false, + ) + + /** 조회가 처음부터 실패하는 일기 목록 VM. 성공 경로는 각 테스트가 직접 만든다. */ + private fun failingDiaryListViewModel(reporter: RecordingErrorReporter): DiaryListViewModel = + DiaryListViewModel( + repository = FakeDiaryRepository(onGetList = { _, _ -> Result.failure(IOException("조회 실패")) }), + changeTracker = MindRecordChangeTracker(), + errorReporter = reporter, + ) + + private fun detailRoute(isDiary: Boolean): SavedStateHandle = + SavedStateHandle( + mapOf( + "recordId" to 1L, + "isDiary" to isDiary, + "yearMonth" to "2026-09", + ), + ) +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt index cbb4224f7a..ac93bd3feb 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt @@ -12,6 +12,7 @@ import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepositor import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -98,7 +99,7 @@ class ReceiverLoadFailureTest { userRepository { if (shouldFail) throw IOException("offline") else emptyList() }, - draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + draftLoader = LoadMindRecordDraftsUseCase(NoopDiaryRepository, NoopDailyQuestionRepository), errorReporter = RecordingErrorReporter(), ) advanceUntilIdle() @@ -138,7 +139,7 @@ class ReceiverLoadFailureTest { emptyList() } }, - draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + draftLoader = LoadMindRecordDraftsUseCase(NoopDiaryRepository, NoopDailyQuestionRepository), errorReporter = RecordingErrorReporter(), ) advanceUntilIdle() @@ -163,7 +164,7 @@ class ReceiverLoadFailureTest { repository = NoopDiaryRepository, photoUploadRepository = FakePhotoUploadRepository.strict(), userRepository = userRepository { failure?.let { throw it } ?: emptyList() }, - draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + draftLoader = LoadMindRecordDraftsUseCase(NoopDiaryRepository, NoopDailyQuestionRepository), errorReporter = RecordingErrorReporter(), ) diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailHeroImageTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailHeroImageTest.kt index d694d56223..1ed4314dc2 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailHeroImageTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailHeroImageTest.kt @@ -1,9 +1,23 @@ package com.afternote.feature.mindrecord.presentation.viewmodel +import androidx.lifecycle.SavedStateHandle +import com.afternote.feature.mindrecord.domain.model.Diary +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import com.afternote.feature.mindrecord.presentation.util.RecordContentBlock -import com.afternote.feature.mindrecord.presentation.util.toRecordContentBlocks +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -15,49 +29,91 @@ import org.robolectric.annotation.Config * 시안 4종은 첨부 이미지 유무로만 갈린다. 서버는 "대표 이미지" 를 따로 주지 않으므로 * 본문 HTML 에서 **첫 이미지**를 뽑아 헤더로 쓰고, 하나도 없으면 그라데이션 변형이 된다. * - * 본문 분할이 `HtmlCompat` 을 타서 Robolectric 이 필요하다. + * 뽑는 함수를 직접 부르지 않고 **ViewModel 이 내놓는 `heroImageUrl`** 로 본다 — 규칙이 + * 맞아도 상태에 실리지 않으면 헤더는 여전히 비어 있다 (#1674). 본문 분할이 `HtmlCompat` + * 을 타서 Robolectric 이 필요하다. */ +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) class RecordDetailHeroImageTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + @Test fun `본문 첫 이미지를 헤더로 쓴다`() { - val blocks = - ( + val state = + detailOf( "

앞 문단입니다.

" + "" + - "" - ).toRecordContentBlocks() + "", + ) - assertEquals("https://cdn.example.com/first.png", blocks.firstImageUrl()) + assertEquals("https://cdn.example.com/first.png", state.heroImageUrl) } @Test fun `이미지가 글 중간에 있어도 헤더로 올린다`() { // 헤더는 본문 순서와 무관하다 — 첫 문단 뒤에 붙은 사진도 시안의 "이미지 O" 변형이다. - val blocks = - "

앞 문단

뒤 문단

" - .toRecordContentBlocks() + val state = detailOf("

앞 문단

뒤 문단

") - assertEquals("https://cdn.example.com/mid.png", blocks.firstImageUrl()) + assertEquals("https://cdn.example.com/mid.png", state.heroImageUrl) } @Test fun `이미지가 없으면 헤더 이미지도 없다`() { - val blocks = "

글만 있는 본문입니다.

".toRecordContentBlocks() + val state = detailOf("

글만 있는 본문입니다.

") - assertNull(blocks.firstImageUrl()) - assertEquals(1, blocks.count { it is RecordContentBlock.Text }) + assertNull(state.heroImageUrl) + assertEquals(1, state.blocks.count { it is RecordContentBlock.Text }) } @Test fun `본문이 비어도 터지지 않는다`() { - assertNull("".toRecordContentBlocks().firstImageUrl()) + assertNull(detailOf("").heroImageUrl) } @Test fun `src 가 빈 img 는 헤더로 쓰지 않는다`() { // 빈 src 를 그대로 넘기면 헤더가 "이미지 O" 변형으로 그려진 뒤 영원히 비어 있다. - assertNull("

본문

".toRecordContentBlocks().firstImageUrl()) + assertNull(detailOf("

본문

").heroImageUrl) + } + + private fun detailOf(content: String): RecordDetailUiState.Success { + lateinit var state: RecordDetailUiState + runTest(dispatcher) { + val viewModel = + RecordDetailViewModel( + savedStateHandle = + SavedStateHandle( + mapOf("recordId" to RECORD_ID, "isDiary" to true, "yearMonth" to "2026-09"), + ), + diaryRepository = FakeDiaryRepository(initialDiaries = listOf(diary(content))), + dailyQuestionRepository = FakeDailyQuestionRepository(), + errorReporter = RecordingErrorReporter(), + ) + advanceUntilIdle() + state = viewModel.uiState.value + } + return state as RecordDetailUiState.Success + } + + private fun diary(content: String) = + Diary( + diaryId = RECORD_ID, + title = "오늘의 산책", + content = content, + date = "2026-09-03", + createdAt = "2026.09.03 목", + todayMood = TodayMood.HAPPY, + ) + + private companion object { + const val RECORD_ID = 1L } } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt index 469f4c605d..1ede87fd21 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt @@ -8,6 +8,8 @@ import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.model.WeeklyReportEmotion import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -221,7 +223,7 @@ class WeeklyReportEmotionAnalysisTest { return Result.success(report) } } - return WeeklyReportViewModel(repository, userRepository(), changeTracker) + return WeeklyReportViewModel(ObserveWeeklyReportUseCase(repository, userRepository()), changeTracker, RecordingErrorReporter()) } private fun userRepository(): FakeUserRepository = diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDaysTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDaysTest.kt index 7100c5ce37..5cffdc0228 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDaysTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDaysTest.kt @@ -4,6 +4,7 @@ import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.domain.model.WeeklyReportDay import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import java.time.LocalDate @@ -22,7 +23,8 @@ class WeeklyReportRecordedDaysTest { ) assertEquals(1, recordedDays) - assertEquals(LocalDate.of(2026, 7, 28), resolveDateInWeekOrNull(monday, 28)) + // index 가 아니라 day 로 복원됐는지는 날짜 키로 확인한다. + assertEquals(setOf(LocalDate.of(2026, 7, 28)), aggregateWeekRecordsByDate(monday, week).keys) } @Test @@ -59,9 +61,14 @@ class WeeklyReportRecordedDaysTest { // 2026-06-29(월) ~ 2026-07-05(일) — day=1 은 6월 1일이 아니라 7월 1일이다. val monday = LocalDate.of(2026, 6, 29) - assertEquals(LocalDate.of(2026, 6, 30), resolveDateInWeekOrNull(monday, 30)) - assertEquals(LocalDate.of(2026, 7, 1), resolveDateInWeekOrNull(monday, 1)) - assertEquals(LocalDate.of(2026, 7, 5), resolveDateInWeekOrNull(monday, 5)) + // day=1 이 6월 1일로 복원되면 이 키 집합이 갈린다. + assertEquals( + setOf(LocalDate.of(2026, 6, 30), LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 5)), + aggregateWeekRecordsByDate( + monday, + listOf(diaryDay(day = 30), diaryDay(day = 1), diaryDay(day = 5)), + ).keys, + ) val recordedDays = countRecordedDays( @@ -77,7 +84,8 @@ class WeeklyReportRecordedDaysTest { fun `주 범위 밖 일자는 집계에서 제외된다`() { val monday = LocalDate.of(2026, 7, 27) - assertNull(resolveDateInWeekOrNull(monday, 26)) + // 주 범위 밖 일자는 날짜로 복원되지 않아 집계 자체에 들어오지 않는다. + assertTrue(aggregateWeekRecordsByDate(monday, listOf(diaryDay(day = 26))).isEmpty()) val recordedDays = countRecordedDays( diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt index b6205f4b2f..de94a352ee 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt @@ -6,6 +6,8 @@ import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -176,8 +178,42 @@ class WeeklyReportRecoveryTest { emotionAnalysis = EmotionAnalysis(total = 0, succeeded = 0, pending = 0, failed = 0), ) - private fun TestScope.start(repository: WeeklyReportRepository): WeeklyReportViewModel { - val viewModel = WeeklyReportViewModel(repository, userRepository(), changeTracker) + @Test + fun `재진입 갱신은 그 사이 바뀐 프로필 이름을 반영한다`() = + runTest(dispatcher) { + // 종전에는 이미 Loaded 이고 같은 주면 `copy(report =)` 로만 갱신해, 방금 받아 온 + // 이름을 버렸다. 설정에서 이름을 바꾸고 돌아와도 주를 옮기기 전까지 옛 이름이 + // 남는다 — 폴링 방출은 첫 조회의 이름을 그대로 싣고 오므로 갈라 둘 이유가 없다 (#1693 리뷰). + var name = "옛이름" + val userRepository = + FakeUserRepository.strict().apply { + onReceiverListFlow = { flowOf(emptyList()) } + onGetMyProfile = { User(name = name, email = "a@b.c", phone = null, profileImageUrl = null) } + } + val repository = ScriptedRepository(mapOf(thisMonday to Result.success(report()))) + val viewModel = start(repository, userRepository) + + assertEquals("옛이름", (viewModel.uiState.value as WeeklyReportUiState.Success).userName) + + // 화면에 들어온 첫 ON_RESUME 은 init 조회가 덮으므로 건너뛴다 (#736 리뷰). + viewModel.refreshOnReturn() + advanceUntilIdle() + + // 설정에서 이름을 바꾸고 돌아왔다. + name = "새이름" + changeTracker.notifyChanged() + viewModel.refreshOnReturn() + advanceUntilIdle() + + assertEquals("새이름", (viewModel.uiState.value as WeeklyReportUiState.Success).userName) + } + + private fun TestScope.start( + repository: WeeklyReportRepository, + userRepository: FakeUserRepository = userRepository(), + ): WeeklyReportViewModel { + val viewModel = + WeeklyReportViewModel(ObserveWeeklyReportUseCase(repository, userRepository), changeTracker, RecordingErrorReporter()) backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } advanceUntilIdle() return viewModel diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt index a173c39b36..66faa96bfe 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt @@ -8,6 +8,8 @@ import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.presentation.model.MindRecordCategoryUi +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -125,7 +127,8 @@ class WeeklyReportWeekRangeTest { object : WeeklyReportRepository { override suspend fun getWeeklyReport(date: String): Result = Result.success(report) } - val viewModel = WeeklyReportViewModel(repository, userRepository(), changeTracker) + val viewModel = + WeeklyReportViewModel(ObserveWeeklyReportUseCase(repository, userRepository()), changeTracker, RecordingErrorReporter()) backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } advanceUntilIdle() return viewModel.uiState.value as WeeklyReportUiState.Success diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt index 148c9b8876..a13a0c4ce3 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt @@ -2,11 +2,12 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.lifecycle.SavedStateHandle import com.afternote.core.domain.repository.PhotoUploadRepository -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -96,7 +97,7 @@ class WriteFailureReportingTest { savedStateHandle = SavedStateHandle(emptyMap()), repository = repository, photoUploadRepository = failingUpload(), - draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository), errorReporter = reporter, ) advanceUntilIdle() @@ -133,7 +134,7 @@ class WriteFailureReportingTest { savedStateHandle = SavedStateHandle(emptyMap()), repository = repository, photoUploadRepository = failingUpload(), - draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository), errorReporter = reporter, ) advanceUntilIdle() @@ -155,22 +156,22 @@ class WriteFailureReportingTest { repository = repository, photoUploadRepository = failingUpload(), userRepository = emptyReceiverRepository(), - draftLoader = MindRecordDraftLoader(repository, FakeDailyQuestionRepository()), + draftLoader = LoadMindRecordDraftsUseCase(repository, FakeDailyQuestionRepository()), errorReporter = reporter, ) private fun failingUpload(): PhotoUploadRepository = PhotoUploadRepository { _, _ -> Result.failure(IOException("upload offline")) } /** UserRepository 는 표면이 넓다 — 이 시나리오가 타는 호출만 답한다. */ - private fun emptyReceiverRepository(): UserRepository = + private fun emptyReceiverRepository(): UserReceiverRepository = Proxy.newProxyInstance( - UserRepository::class.java.classLoader, - arrayOf(UserRepository::class.java), + UserReceiverRepository::class.java.classLoader, + arrayOf(UserReceiverRepository::class.java), ) { _, method, _ -> when (method.name) { "getReceivers" -> emptyList() "getReceiverListFlow" -> flowOf(emptyList()) else -> error("Unexpected call: ${method.name}") } - } as UserRepository + } as UserReceiverRepository } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteUploadLockTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteUploadLockTest.kt new file mode 100644 index 0000000000..c449296461 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteUploadLockTest.kt @@ -0,0 +1,198 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.model.UploadedFile +import com.afternote.core.domain.repository.PhotoUploadRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * 첨부 업로드 잠금이 **진행 중인 것을 빠짐없이 세는지** (#2029 · #2030). + * + * 잠금이 Boolean 하나였을 때 두 가지가 어긋났다. + * + * 1. **먼저 끝난 하나가 잠금을 통째로 풀었다** — 첨부 A·B 를 잇따라 고르고 A 만 끝나면 + * B 가 아직 올라가는 중인데 저장이 열렸다. 그 저장에는 B 가 빠진 본문이 실린다. + * 2. **취소에는 내려놓을 자리가 없었다** — 업로드 코루틴은 작성 화면의 `rememberCoroutineScope()` + * 가 소유하므로 구성 변경이면 코루틴만 끊기고 `Result` 는 오지 않는다. 잠금이 참으로 굳어, + * 이미 쓴 내용을 저장하려면 관계없는 첨부를 한 번 더 성공시켜야 했다. + * + * 두 작성 화면이 같은 코드 모양을 복제하고 있어 **둘을 같은 단언으로** 본다 — 한쪽만 고치면 + * 다른 쪽이 그대로 남는 것이 이 결함의 모양이다. + * + * 업로드를 붙잡아야 창이 열리므로 [CompletableDeferred] 로 응답 시점을 쥔다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WriteUploadLockTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `일기 - 앞선 첨부가 끝나도 뒤 첨부가 올라가는 중이면 저장이 닫혀 있다`() = + runTest(dispatcher) { + val uploader = GatedUploader() + val viewModel = diaryViewModel(uploader) + advanceUntilIdle() + viewModel.onTitleChanged("제목") + viewModel.onContentChanged("

본문

") + + val first = launch { viewModel.uploadMedia("content://a") } + val second = launch { viewModel.uploadMedia("content://b") } + advanceUntilIdle() + + uploader.complete("content://a") + advanceUntilIdle() + + assertTrue("뒤 첨부가 남았는데 업로드 표시가 사라졌다", viewModel.uiState.value.isUploadingImage) + assertFalse("뒤 첨부가 남았는데 저장이 열렸다", viewModel.uiState.value.canSaveDraft) + + uploader.complete("content://b") + advanceUntilIdle() + assertFalse("둘 다 끝났는데 업로드 표시가 남았다", viewModel.uiState.value.isUploadingImage) + first.cancelAndJoin() + second.cancelAndJoin() + } + + @Test + fun `데일리답변 - 앞선 첨부가 끝나도 뒤 첨부가 올라가는 중이면 저장이 닫혀 있다`() = + runTest(dispatcher) { + val uploader = GatedUploader() + val viewModel = dailyQuestionViewModel(uploader) + advanceUntilIdle() + viewModel.onAnswerChanged("

본문

") + + val first = launch { viewModel.uploadMedia("content://a") } + val second = launch { viewModel.uploadMedia("content://b") } + advanceUntilIdle() + + uploader.complete("content://a") + advanceUntilIdle() + + assertTrue("뒤 첨부가 남았는데 업로드 표시가 사라졌다", viewModel.uiState.value.isUploadingImage) + assertFalse("뒤 첨부가 남았는데 저장이 열렸다", viewModel.uiState.value.canSubmit) + + uploader.complete("content://b") + advanceUntilIdle() + assertFalse("둘 다 끝났는데 업로드 표시가 남았다", viewModel.uiState.value.isUploadingImage) + first.cancelAndJoin() + second.cancelAndJoin() + } + + @Test + fun `일기 - 화면이 사라져 첨부가 취소되면 저장 잠금이 풀린다`() = + runTest(dispatcher) { + val uploader = GatedUploader() + val viewModel = diaryViewModel(uploader) + advanceUntilIdle() + viewModel.onTitleChanged("제목") + viewModel.onContentChanged("

본문

") + + val screenScope = CoroutineScope(Job() + dispatcher) + screenScope.launch { viewModel.uploadMedia("content://a") } + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isUploadingImage) + + screenScope.cancel() + advanceUntilIdle() + + assertFalse("취소된 첨부의 잠금이 남았다", viewModel.uiState.value.isUploadingImage) + assertTrue("취소 뒤 저장이 열리지 않았다", viewModel.uiState.value.canSaveDraft) + } + + @Test + fun `일기 - 하나가 취소돼도 남은 첨부의 잠금은 유지된다`() = + runTest(dispatcher) { + val uploader = GatedUploader() + val viewModel = diaryViewModel(uploader) + advanceUntilIdle() + viewModel.onTitleChanged("제목") + viewModel.onContentChanged("

본문

") + + val cancelled = CoroutineScope(Job() + dispatcher) + cancelled.launch { viewModel.uploadMedia("content://a") } + val surviving = launch { viewModel.uploadMedia("content://b") } + advanceUntilIdle() + + cancelled.cancel() + advanceUntilIdle() + + assertTrue("남은 첨부가 있는데 잠금이 풀렸다", viewModel.uiState.value.isUploadingImage) + uploader.complete("content://b") + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isUploadingImage) + surviving.cancelAndJoin() + } + + /** 응답 시점을 테스트가 쥐는 업로더 — 두 첨부가 겹치는 창을 만든다. */ + private class GatedUploader : PhotoUploadRepository { + private val gates = mutableMapOf>() + + override suspend fun upload( + uriString: String, + directory: String, + ): Result { + gates.getOrPut(uriString) { CompletableDeferred() }.await() + return Result.success( + UploadedFile(fileUrl = "https://cdn.test/$uriString.jpg", fileKey = "mindrecords/$uriString.jpg"), + ) + } + + fun complete(uriString: String) { + gates.getOrPut(uriString) { CompletableDeferred() }.complete(Unit) + } + } + + private fun diaryViewModel(uploader: PhotoUploadRepository) = + DiaryWriteViewModel( + savedStateHandle = SavedStateHandle(emptyMap()), + repository = FakeDiaryRepository(), + photoUploadRepository = uploader, + userRepository = + FakeUserRepository.strict().apply { + onReceiverListFlow = { flowOf(emptyList()) } + onGetReceivers = { emptyList() } + }, + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), FakeDailyQuestionRepository()), + errorReporter = RecordingErrorReporter(), + ) + + private fun dailyQuestionViewModel(uploader: PhotoUploadRepository) = + DailyQuestionWriteViewModel( + SavedStateHandle(emptyMap()), + FakeDailyQuestionRepository(), + uploader, + LoadMindRecordDraftsUseCase(FakeDiaryRepository(), FakeDailyQuestionRepository()), + RecordingErrorReporter(), + ) +} diff --git a/feature/onboarding/presentation/build.gradle.kts b/feature/onboarding/presentation/build.gradle.kts index 54e05a97e4..43a4d22361 100644 --- a/feature/onboarding/presentation/build.gradle.kts +++ b/feature/onboarding/presentation/build.gradle.kts @@ -1,7 +1,7 @@ plugins { id("afternote.android.library.compose") id("afternote.android.hilt") - kotlin("plugin.serialization") + id("afternote.android.navigation") alias(libs.plugins.compose.screenshot) id("afternote.kover") } @@ -33,7 +33,6 @@ dependencies { implementation(projects.core.model) implementation(projects.core.ui) implementation(libs.androidx.hilt.navigation.compose) - implementation(libs.kotlinx.serialization.json) implementation(libs.kakao.sdk.auth) implementation(libs.kakao.sdk.user) implementation(libs.androidx.credentials) diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingEmailRule.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingEmailRule.kt new file mode 100644 index 0000000000..6abdaab540 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingEmailRule.kt @@ -0,0 +1,24 @@ +package com.afternote.feature.onboarding.presentation + +import android.util.Patterns + +/** + * 온보딩이 이메일 입력에 요구하는 형식 규칙. + * + * 회원가입 1단계·아이디 찾기·비밀번호 찾기 세 화면이 **같은 판정**을 쓴다. 종전에는 세 UiState 가 + * 각자 같은 한 줄을 들고 KDoc 으로 서로를 가리키는 것이 동기화의 전부였다 — 한쪽만 고쳐질 자리라 + * 여기 모은다 (#1851). 바로 옆의 [OnboardingPasswordRule] 과 같은 자리·같은 모양이다. + * + * [Patterns.EMAIL_ADDRESS] 는 컴파일된 정규식(`Pattern`) 상수라 `matcher(입력)` 으로 그 문자열 + * 전용 실행기를 만든 뒤 `matches()`(**전체 일치** — 부분 검색 `find()` 와 다름)로 판정하는 + * Java regex 2단계 API 를 쓴다. + * + * **정규식 사본으로 되돌리지 않는다.** `d5cc062d4`(#273)가 `SignUpViewModel` 의 AOSP 사본 + * `EMAIL_ADDRESS_REGEX` 를 지우고 이 상수로 옮긴 결정이 이미 있다 — 사본은 AOSP 원본과 조용히 + * 갈라지고, 직접 쓴 Android 정규식은 문자 클래스를 Unicode 로 판정해 서버 검증과 어긋날 여지를 + * 새로 만든다. 그래서 이 추출로도 소비처는 여전히 Robolectric 이 필요하다 — 이 함수의 목적은 + * 중복 제거이지 테스트 러너 경량화가 아니다. + */ +internal object OnboardingEmailRule { + fun isValid(email: String): Boolean = email.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(email).matches() +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingFailure.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingFailure.kt new file mode 100644 index 0000000000..8d4cb9c0e8 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingFailure.kt @@ -0,0 +1,47 @@ +package com.afternote.feature.onboarding.presentation + +import com.afternote.core.ui.UiText + +/** 실패 한 건의 사유. 표시 채널과 소비 방법은 화면 경계가 결정한다. */ +internal sealed interface OnboardingFailure { + data object CredentialsRejected : OnboardingFailure + + data object VerificationRejected : OnboardingFailure + + data object LoginNetworkUnavailable : OnboardingFailure + + data object SocialAccountRecoveryUnavailable : OnboardingFailure + + /** 서버 원문 대신 기존 작업별 로컬 안내 문구를 보존한 요청 실패. */ + data class RequestFailed( + val message: UiText, + ) : OnboardingFailure +} + +/** 화면에서만 사용하는 표시 결정. 한 실패가 두 채널로 동시에 전달될 수 없다. */ +internal sealed interface OnboardingFailureDisplay { + data object CredentialInline : OnboardingFailureDisplay + + data object VerificationInline : OnboardingFailureDisplay + + data object NetworkRetryPopup : OnboardingFailureDisplay + + data object SocialAccountPopup : OnboardingFailureDisplay + + data class Snackbar( + val message: UiText, + ) : OnboardingFailureDisplay +} + +internal fun OnboardingFailure?.toDisplay(): OnboardingFailureDisplay? = + when (this) { + OnboardingFailure.CredentialsRejected -> OnboardingFailureDisplay.CredentialInline + OnboardingFailure.VerificationRejected -> OnboardingFailureDisplay.VerificationInline + OnboardingFailure.LoginNetworkUnavailable -> OnboardingFailureDisplay.NetworkRetryPopup + OnboardingFailure.SocialAccountRecoveryUnavailable -> OnboardingFailureDisplay.SocialAccountPopup + is OnboardingFailure.RequestFailed -> OnboardingFailureDisplay.Snackbar(message) + null -> null + } + +internal val OnboardingFailureDisplay?.snackbarMessage: UiText? + get() = (this as? OnboardingFailureDisplay.Snackbar)?.message diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingFailureMessage.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingFailureMessage.kt index 1caae387fc..97eb682dae 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingFailureMessage.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingFailureMessage.kt @@ -42,6 +42,14 @@ private fun CoreAuthFailure.displayMessageResOrNull(): Int? = is CoreAuthFailure.SocialLoginRejected -> R.string.onboarding_login_social_rejected + is CoreAuthFailure.PasswordUnchanged -> R.string.onboarding_find_password_unchanged + + // 시안(`2383:16667`)은 이 사유를 차단 팝업으로 그린다. 그 표시는 화면이 하고, 여기 문구는 + // 팝업이 없는 자리(비밀번호 변경 제출)로 같은 사유가 흘러왔을 때의 스낵바 몫이다. + // 로그인 화면은 이 매핑을 타지 않는다 — 같은 사유라도 안내가 "비밀번호 찾기를 쓸 수 없다" 가 + // 아니라 "소셜로 로그인하라" 여서 `LoginViewModel` 이 자기 문구로 가른다. + is CoreAuthFailure.SocialSignUpAccount -> R.string.onboarding_find_password_social_blocked + // 셋 다 이 함수 밖에서 갈리므로 전용 문구를 갖지 않는다 — 자격 거절·인증번호 무효는 화면이 // 입력 필드 인라인으로, 인증 취소는 소비처가 무시한다. `else` 가 아니라 열거해 두는 건 // 사유가 새로 늘 때 컴파일러가 여기를 잡게 하려는 것이다. diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingPasswordRule.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingPasswordRule.kt new file mode 100644 index 0000000000..91bdeb4c7c --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingPasswordRule.kt @@ -0,0 +1,25 @@ +package com.afternote.feature.onboarding.presentation + +/** + * 온보딩이 새 비밀번호에 요구하는 조합 규칙. + * + * 회원가입 3단계와 비밀번호 찾기의 변경 화면이 **같은 규칙·같은 안내 문구** + * (`onboarding_signup_password_rule_combination`)를 쓴다 — 시안 `2383:16789` 의 안내 2줄이 + * 회원가입 시안과 글자까지 동일하다. 두 화면이 각자 정규식을 들면 한쪽만 고쳐질 자리라 여기 모은다. + * + * **서버 규칙과 같다 (#1628).** BE `PasswordValidation.REGEX` 를 그대로 옮겼다 — 영문·숫자·허용 + * 특수문자(`@ $ ! % * # ? &`)를 각각 하나 이상 포함하고, 전체 문자는 그 ASCII 허용 목록 안에서만 + * 8~15자여야 한다. 서버는 이 상수 하나를 `SignupRequest`·`PasswordChangeRequest`·`PasswordFindRequest` + * 세 곳에 `@Pattern` 으로 걸고 컨트롤러 진입 전에 거절하므로, 클라가 더 넓으면 사용자는 조건 충족 + * 표시를 다 받고 제출에서 400 을 맞는다. + * + * Android 정규식의 `\d` 는 Unicode 숫자까지 포함해 전각 `1`·아라비아-인도 `١` 을 통과시킨다. + * 서버 JVM 의 기본 판정과 갈리지 않도록 숫자를 `[0-9]` 로 명시한다. + */ +internal object OnboardingPasswordRule { + /** 8~15자, 영문·숫자·허용 특수문자 8종 각 1개 이상, 그 밖의 문자는 금지. */ + private val REGEX = + Regex("^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[@$!%*#?&])[A-Za-z0-9@$!%*#?&]{8,15}$") + + fun isSatisfied(password: String): Boolean = REGEX.matches(password) +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileContent.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileContent.kt new file mode 100644 index 0000000000..0dc2360398 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileContent.kt @@ -0,0 +1,157 @@ +package com.afternote.feature.onboarding.presentation + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.ProfileImagePicker +import com.afternote.core.ui.button.AfternoteButton +import com.afternote.core.ui.button.AfternoteButtonType +import com.afternote.core.ui.modifierextention.addFocusCleaner +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.onboarding.presentation.signup.SignUpIntent +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun OnboardingProfileContent( + state: SignUpUiState, + onIntent: (SignUpIntent) -> Unit, + snackbarHostState: SnackbarHostState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + // VM 은 Android Framework 의존 제거를 위해 String 으로 보관한다. Uri 변환은 UI 층 몫이다. + val displayImageUri = state.profileImageUri?.toUri() + val nameState = rememberTextFieldState(state.name) + + LaunchedEffect(nameState) { + snapshotFlow { nameState.text.toString() }.collect { onIntent(SignUpIntent.UpdateName(it)) } + } + + val photoPickerLauncher = + rememberLauncherForActivityResult( + contract = PickVisualMedia(), + onResult = { uri -> + handleProfileImagePickerResult(uri) { picked -> onIntent(SignUpIntent.PickProfileImage(picked.toString())) } + }, + ) + + Scaffold( + modifier = modifier, + topBar = { + DetailTopBar( + title = stringResource(R.string.onboarding_profile_top_bar_title), + onBackClick = { + focusManager.clearFocus() + onBackClick() + }, + ) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + containerColor = Color.Transparent, + ) { innerPadding -> + Column( + modifier = + Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .addFocusCleaner(focusManager) + .padding(horizontal = 20.dp), + ) { + Spacer(modifier = Modifier.height(39.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(56.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.onboarding_profile_headline), + modifier = Modifier.fillMaxWidth(), + style = AfternoteDesign.typography.h1, + color = AfternoteDesign.colors.black, + textAlign = TextAlign.Start, + ) + ProfileImagePicker( + onPickClick = { + photoPickerLauncher.launch( + PickVisualMediaRequest(PickVisualMedia.ImageOnly), + ) + }, + displayImageUri = displayImageUri?.toString(), + ) + + val isNameProvided = + nameState.text + .toString() + .trim() + .isNotEmpty() + + // 제출 중에는 버튼과 IME 두 경로 모두 잠근다. 한쪽만 막으면 다른 쪽으로 중복 제출된다. + val isCompleteEnabled = isNameProvided && !state.isLoading + + AfternoteTextField( + state = nameState, + placeholder = stringResource(R.string.onboarding_profile_name_placeholder), + imeAction = ImeAction.Done, + onImeAction = { + focusManager.clearFocus() + if (isCompleteEnabled) onIntent(SignUpIntent.SubmitSignUp) + }, + ) + + AfternoteButton( + text = stringResource(R.string.onboarding_profile_complete), + onClick = { + focusManager.clearFocus() + if (isCompleteEnabled) onIntent(SignUpIntent.SubmitSignUp) + }, + type = if (isCompleteEnabled) AfternoteButtonType.Default else AfternoteButtonType.Un, + isLoading = state.isLoading, + ) + } + } + } +} + +/** 포토 피커 취소 결과(null)는 선택 변경이 아니므로 기존 프로필 이미지를 그대로 둔다. */ +private fun handleProfileImagePickerResult( + uri: Uri?, + onProfileImagePick: (Uri) -> Unit, +) { + uri?.let(onProfileImagePick) +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileEntry.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileEntry.kt deleted file mode 100644 index 317d857201..0000000000 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileEntry.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.afternote.feature.onboarding.presentation - -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.core.net.toUri -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.afternote.core.ui.asString -import com.afternote.feature.onboarding.presentation.signup.SignUpUiState -import com.afternote.feature.onboarding.presentation.signup.SignUpViewModel -import kotlinx.coroutines.launch - -/** - * 프로필 설정 Entry. - * - * Graph-scoped [SignUpViewModel] 의 [SignUpUiState] 단발성 신호 (isSignedUp · - * errorMessage · isNameRequired) 를 LaunchedEffect 로 소비. 소비 후 VM 의 `onXxxConsumed()` - * 호출로 reset. - */ -@Composable -fun OnboardingProfileEntry( - viewModel: SignUpViewModel, - onOnboardingComplete: () -> Unit, - onBackClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } - val coroutineScope = rememberCoroutineScope() - - val nameRequiredMessage = stringResource(R.string.onboarding_signup_name_required) - - val showSnackbar: (String) -> Unit = { message -> - coroutineScope.launch { - snackbarHostState.showSnackbar( - message = message, - duration = SnackbarDuration.Short, - ) - } - } - - LaunchedEffect(uiState.isSignedUp) { - if (uiState.isSignedUp) { - onOnboardingComplete() - viewModel.onSignedUpConsumed() - } - } - LaunchedEffect(uiState.isNameRequired) { - if (uiState.isNameRequired) { - showSnackbar(nameRequiredMessage) - viewModel.onNameRequiredConsumed() - } - } - // VM 이 UiText 로 폴백까지 확정해 두므로 빈 문구가 도달하지 않는다. - val pendingErrorMessage = uiState.errorMessage?.asString() - LaunchedEffect(pendingErrorMessage) { - if (pendingErrorMessage != null) { - showSnackbar(pendingErrorMessage) - viewModel.onErrorConsumed() - } - } - - // VM 은 Android Framework 의존 제거를 위해 String 으로 보관. UI 레이어에서 Uri 와 변환. - OnboardingProfileScreen( - initialName = uiState.name, - displayImageUri = uiState.profileImageUri?.toUri(), - snackbarHostState = snackbarHostState, - onNameChange = viewModel::updateName, - onProfileImagePick = { uri -> viewModel.onProfileImagePicked(uri.toString()) }, - onCompleteClick = viewModel::submitSignUp, - onBackClick = onBackClick, - modifier = modifier, - isSubmitting = uiState.isLoading, - ) -} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileScreen.kt index 8700922e57..97b0b6b0d2 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileScreen.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileScreen.kt @@ -1,154 +1,44 @@ package com.afternote.feature.onboarding.presentation -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.AfternoteTextField -import com.afternote.core.ui.ProfileImagePicker -import com.afternote.core.ui.button.AfternoteButton -import com.afternote.core.ui.button.AfternoteButtonType -import com.afternote.core.ui.modifierextention.addFocusCleaner -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.topbar.DetailTopBar - -@OptIn(ExperimentalMaterial3Api::class) +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.mvi.ObserveFlag +import com.afternote.feature.onboarding.presentation.signup.SignUpIntent +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState +import com.afternote.feature.onboarding.presentation.signup.SignUpViewModel +import com.afternote.feature.onboarding.presentation.signup.rememberSignUpSnackbarHost + +/** + * 프로필 설정 — stateful 층. + * + * 그래프 스코프 [SignUpViewModel] 의 일회성 신호를 소비한다. 이름 미입력·실패 문구는 Step 화면과 + * 같은 관용구([rememberSignUpSnackbarHost])를 쓰고, 완료 신호([SignUpUiState.isSignedUp])만 + * 이 화면이 직접 소비한다. + */ @Composable -fun OnboardingProfileScreen( - initialName: String, - displayImageUri: Uri?, - snackbarHostState: SnackbarHostState, - onNameChange: (String) -> Unit, - onProfileImagePick: (Uri) -> Unit, +internal fun OnboardingProfileScreen( + viewModel: SignUpViewModel, + onOnboardingComplete: () -> Unit, onBackClick: () -> Unit, - onCompleteClick: () -> Unit, modifier: Modifier = Modifier, - isSubmitting: Boolean = false, ) { - val focusManager = LocalFocusManager.current - val nameState = rememberTextFieldState(initialName) - - LaunchedEffect(nameState) { - snapshotFlow { nameState.text.toString() }.collect(onNameChange) - } - - val photoPickerLauncher = - rememberLauncherForActivityResult( - contract = PickVisualMedia(), - onResult = { uri -> handleProfileImagePickerResult(uri, onProfileImagePick) }, - ) - - Scaffold( + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = rememberSignUpSnackbarHost(state, viewModel::onIntent) + + ObserveFlag( + raised = state.isSignedUp, + consumed = SignUpIntent.ConsumeSignedUp, + onIntent = viewModel::onIntent, + onRaised = onOnboardingComplete, + ) + + OnboardingProfileContent( + state = state, + onIntent = viewModel::onIntent, + snackbarHostState = snackbarHostState, + onBackClick = onBackClick, modifier = modifier, - topBar = { - DetailTopBar( - title = stringResource(R.string.onboarding_profile_top_bar_title), - onBackClick = { - focusManager.clearFocus() - onBackClick() - }, - ) - }, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - containerColor = Color.Transparent, - ) { innerPadding -> - Column( - modifier = - Modifier - .padding(innerPadding) - .consumeWindowInsets(innerPadding) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .imePadding() - .addFocusCleaner(focusManager) - .padding(horizontal = 20.dp), - ) { - Spacer(modifier = Modifier.height(39.dp)) - Column( - verticalArrangement = Arrangement.spacedBy(56.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.onboarding_profile_headline), - modifier = Modifier.fillMaxWidth(), - style = AfternoteDesign.typography.h1, - color = AfternoteDesign.colors.black, - textAlign = TextAlign.Start, - ) - ProfileImagePicker( - onPickClick = { - photoPickerLauncher.launch( - PickVisualMediaRequest(PickVisualMedia.ImageOnly), - ) - }, - displayImageUri = displayImageUri?.toString(), - ) - - val isNameProvided = - nameState.text - .toString() - .trim() - .isNotEmpty() - - // 제출 중에는 버튼과 IME 두 경로 모두 잠근다. 한쪽만 막으면 다른 쪽으로 중복 제출된다. - val isCompleteEnabled = isNameProvided && !isSubmitting - - AfternoteTextField( - state = nameState, - placeholder = stringResource(R.string.onboarding_profile_name_placeholder), - imeAction = ImeAction.Done, - onImeAction = { - focusManager.clearFocus() - if (isCompleteEnabled) onCompleteClick() - }, - ) - - AfternoteButton( - text = stringResource(R.string.onboarding_profile_complete), - onClick = { - focusManager.clearFocus() - if (isCompleteEnabled) onCompleteClick() - }, - type = if (isCompleteEnabled) AfternoteButtonType.Default else AfternoteButtonType.Un, - isLoading = isSubmitting, - ) - } - } - } -} - -/** 포토 피커 취소 결과(null)는 선택 변경이 아니므로 기존 프로필 이미지를 그대로 둔다. */ -internal fun handleProfileImagePickerResult( - uri: Uri?, - onProfileImagePick: (Uri) -> Unit, -) { - uri?.let(onProfileImagePick) + ) } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/PasswordRuleItem.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/PasswordRuleItem.kt new file mode 100644 index 0000000000..b5b4db57af --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/PasswordRuleItem.kt @@ -0,0 +1,56 @@ +package com.afternote.feature.onboarding.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteDesign + +/** + * 비밀번호 규칙 안내 한 줄 — 불릿 + 문구. + * + * 회원가입 3단계(`SignUpPasswordScreen`)와 비밀번호 찾기의 변경 화면이 같은 시안을 쓴다. + * + * @param isSatisfied 규칙 충족 여부에 따라 색이 갈리는 줄만 넘긴다. null(기본)이면 상태 없는 + * 안내 줄이라 항상 강조색이다 — "이전에 사용한 적 없는 비밀번호가 안전합니다." 처럼 클라가 + * 판정할 수 없는 문구가 여기 해당한다. + */ +@Composable +internal fun PasswordRuleItem( + text: String, + modifier: Modifier = Modifier, + isSatisfied: Boolean? = null, +) { + val color = + if (isSatisfied == false) { + AfternoteDesign.colors.gray5 + } else { + AfternoteDesign.colors.b1 + } + Row( + modifier = + modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = "•", + modifier = Modifier.clearAndSetSemantics {}, + style = AfternoteDesign.typography.captionLargeB, + color = color, + ) + Text( + text = text, + style = AfternoteDesign.typography.captionLargeB, + color = color, + ) + } +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdContent.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdContent.kt new file mode 100644 index 0000000000..53a2472ea6 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdContent.kt @@ -0,0 +1,155 @@ +package com.afternote.feature.onboarding.presentation.findaccount + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.TextFieldType +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.onboarding.presentation.OnboardingFailureDisplay +import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.toDisplay + +private val HeaderSpacing = 8.dp + +/** 상태를 그리는 렌더 계약. 별도 Screen 파일이 수명과 신호 소비를 담당하므로 모듈 내부에 공개한다. */ +@Composable +internal fun FindIdContent( + state: FindIdUiState, + onIntent: (FindIdIntent) -> Unit, + snackbarHostState: SnackbarHostState, + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val emailState = rememberTextFieldState(state.email) + val certificateCodeState = rememberTextFieldState(state.certificateCode) + + // "바뀔 때마다 UpdateEmail Intent 를 보낸다"를 수행하는 장치가 이 블록이다. TextFieldState 에는 콜백이 없고, + // LaunchedEffect 의 key(emailState) 는 객체 동일성 비교라 내용(.text) 변이에는 반응하지 않는다 + // (rememberTextFieldState 인스턴스는 화면 수명 내내 동일 → key 만으론 최초 1회 실행 후 침묵). + // snapshotFlow 가 스냅샷 읽기 추적으로 내용 변이를 리컴포지션 없이 구독해 값이 달라질 때만 흘리고, + // LaunchedEffect 는 그 수집 코루틴의 수명(화면 이탈 시 자동 해제)만 맡는다. (SignUpScreen 선례) + // key=emailState 는 재시작 트리거가 아니라 의존성 선언 — remember 덕에 지금은 인스턴스가 안 갈리지만, + // 갈리는 리팩터링이 오면 옛 구독을 취소하고 새 인스턴스로 갈아타게 하는 보험이다 (Unit 이면 유령 구독). + LaunchedEffect(emailState) { + snapshotFlow { emailState.text.toString() }.collect { onIntent(FindIdIntent.UpdateEmail(it)) } + } + LaunchedEffect(certificateCodeState) { + snapshotFlow { certificateCodeState.text.toString() }.collect { onIntent(FindIdIntent.UpdateCertificateCode(it)) } + } + + val requestCodeText = + when { + state.isSendingCode -> { + stringResource(R.string.onboarding_find_account_code_requesting) + } + + state.resendCooldownSeconds > 0 -> { + stringResource( + R.string.onboarding_find_account_code_resend_cooldown, + state.resendCooldownSeconds, + ) + } + + state.isVerificationSent -> { + stringResource(R.string.onboarding_find_account_code_resend) + } + + else -> { + stringResource(R.string.onboarding_find_account_code_request) + } + } + + FlowStepScaffold( + topBarTitle = stringResource(R.string.onboarding_find_id_title), + actionButtonText = stringResource(R.string.onboarding_find_account_next), + onBackClick = onBackClick, + onActionClick = onNextClick, + modifier = modifier, + isActionEnabled = state.isNextEnabled, + snackbarHostState = snackbarHostState, + ) { + Spacer(modifier = Modifier.height(HeaderSpacing)) + Column( + modifier = Modifier.imePadding(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.onboarding_find_account_verify_email_title), + style = AfternoteDesign.typography.h1, + color = AfternoteDesign.colors.gray9, + ) + Text( + text = stringResource(R.string.onboarding_find_account_verify_email_description), + // 시안 텍스트 스펙 = NanumBarunGothic Regular 12 / 행간 18 → captionLargeR (bodySmallB 는 Bold 14/20 로 불일치) + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray5, + ) + + AfternoteTextField( + state = emailState, + type = + TextFieldType.Variant7( + text = requestCodeText, + onClick = { onIntent(FindIdIntent.RequestVerificationCode) }, + enabled = state.isSendCodeEnabled, + ), + placeholder = stringResource(R.string.onboarding_find_account_email_placeholder), + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ) + + // 인증번호 필드의 "확인" 은 발송 이력이 있을 때만 노출 (시안 초기 상태엔 없음). + AfternoteTextField( + state = certificateCodeState, + type = + if (state.isVerificationSent) { + TextFieldType.Variant7( + text = stringResource(R.string.onboarding_find_account_code_confirm), + onClick = { onIntent(FindIdIntent.VerifyCode) }, + enabled = state.isVerifyEnabled, + ) + } else { + TextFieldType.Basic + }, + placeholder = stringResource(R.string.onboarding_find_account_code_placeholder), + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + onImeAction = { + if (state.isVerifyEnabled) onIntent(FindIdIntent.VerifyCode) + }, + ) + + // 인증번호 불일치는 인라인 에러로, 그 외 실패는 스낵바로 나뉜다 (시안 2431-14204). + if ((state.failure.toDisplay() == OnboardingFailureDisplay.VerificationInline)) { + Text( + text = stringResource(R.string.onboarding_find_account_code_mismatch), + style = AfternoteDesign.typography.captionLargeB, + color = AfternoteDesign.colors.error, + ) + } else if (state.isVerificationSent) { + Text( + text = stringResource(R.string.onboarding_find_account_code_sent), + style = AfternoteDesign.typography.captionLargeB, + color = AfternoteDesign.colors.b1, + ) + } + } + } +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdContract.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdContract.kt new file mode 100644 index 0000000000..3ac984fa1b --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdContract.kt @@ -0,0 +1,67 @@ +package com.afternote.feature.onboarding.presentation.findaccount + +import com.afternote.core.model.FoundAccount +import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +/** 아이디 찾기 화면이 [FindIdViewModel] 에 보내는 것 — 사용자가 하려는 것. */ +internal sealed interface FindIdIntent : MviIntent { + data class UpdateEmail( + val value: String, + ) : FindIdIntent + + data class UpdateCertificateCode( + val value: String, + ) : FindIdIntent + + data object RequestVerificationCode : FindIdIntent + + data object VerifyCode : FindIdIntent + + /** 스낵바로 표시한 [FindIdUiState.failure] 를 되돌린다. */ + data object ConsumeError : FindIdIntent +} + +/** 상태가 겪은 것. [FindIdViewModel] 만 만든다. */ +internal sealed interface FindIdReducerEvent : ReducerEvent { + data class EmailChanged( + val value: String, + ) : FindIdReducerEvent + + data class CertificateCodeChanged( + val value: String, + ) : FindIdReducerEvent + + data object CodeSendStarted : FindIdReducerEvent + + data object CodeSent : FindIdReducerEvent + + data class CodeSendFailed( + val message: UiText, + ) : FindIdReducerEvent + + data object CodeSendFinished : FindIdReducerEvent + + /** 쿨다운을 30초로 재장전한다. */ + data object CooldownReloaded : FindIdReducerEvent + + data object CooldownTicked : FindIdReducerEvent + + data object VerifyStarted : FindIdReducerEvent + + data class AccountFound( + val account: FoundAccount, + ) : FindIdReducerEvent + + /** 인증번호 무효 — 인라인 문구로 알린다. */ + data object VerificationRejected : FindIdReducerEvent + + data class VerifyFailed( + val message: UiText, + ) : FindIdReducerEvent + + data object VerifyFinished : FindIdReducerEvent + + data object ErrorConsumed : FindIdReducerEvent +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreen.kt index 85f46830c7..4bf80d13a9 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreen.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreen.kt @@ -1,169 +1,61 @@ package com.afternote.feature.onboarding.presentation.findaccount -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.AfternoteTextField -import com.afternote.core.ui.TextFieldType -import com.afternote.core.ui.scaffold.FlowStepScaffold -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.feature.onboarding.presentation.R - -private val HeaderSpacing = 8.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.asString +import com.afternote.core.ui.mvi.ObserveSignal +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay +import kotlinx.coroutines.launch /** - * 아이디 찾기 1단계 — 이메일 인증. + * 아이디 찾기 1단계 — 이메일 인증. stateful 층. + * + * ViewModel 은 `Route.Onboarding` 그래프 스코프 공유라 여기서 만들지 않고 받는다 + * (결과 화면·비밀번호 찾기가 같은 인스턴스를 이어받는다). * - * 인증번호 "확인" 이 곧 조회라(`auth/email/find` 가 인증번호를 함께 받는다) 확인에 성공해야 - * "다음" 이 열리고, 결과 화면은 VM 에 담긴 계정을 읽는다. + * 인증번호 무효는 시안상 인라인 문구라 [FindIdUiState.failure] 로 화면이 직접 그리고, + * 그 밖의 실패([FindIdUiState.failure])만 스낵바로 나른다. */ @Composable -fun FindIdScreen( - initialEmail: String, - initialCertificateCode: String, - isSendingCode: Boolean, - isVerificationSent: Boolean, - isSendCodeEnabled: Boolean, - isVerifyEnabled: Boolean, - isNextEnabled: Boolean, - resendCooldownSeconds: Int, - hasVerificationError: Boolean, - snackbarHostState: SnackbarHostState, - onEmailChange: (String) -> Unit, - onCertificateCodeChange: (String) -> Unit, - onRequestCode: () -> Unit, - onVerifyCode: () -> Unit, +internal fun FindIdScreen( + viewModel: FindIdViewModel, onNextClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - val emailState = rememberTextFieldState(initialEmail) - val certificateCodeState = rememberTextFieldState(initialCertificateCode) - - // "바뀔 때마다 onEmailChange 호출"을 수행하는 장치가 이 블록이다. TextFieldState 에는 콜백이 없고, - // LaunchedEffect 의 key(emailState) 는 객체 동일성 비교라 내용(.text) 변이에는 반응하지 않는다 - // (rememberTextFieldState 인스턴스는 화면 수명 내내 동일 → key 만으론 최초 1회 실행 후 침묵). - // snapshotFlow 가 스냅샷 읽기 추적으로 내용 변이를 리컴포지션 없이 구독해 값이 달라질 때만 흘리고, - // LaunchedEffect 는 그 수집 코루틴의 수명(화면 이탈 시 자동 해제)만 맡는다. (SignUpScreen 선례) - // key=emailState 는 재시작 트리거가 아니라 의존성 선언 — remember 덕에 지금은 인스턴스가 안 갈리지만, - // 갈리는 리팩터링이 오면 옛 구독을 취소하고 새 인스턴스로 갈아타게 하는 보험이다 (Unit 이면 유령 구독). - LaunchedEffect(emailState) { - snapshotFlow { emailState.text.toString() }.collect(onEmailChange) - } - LaunchedEffect(certificateCodeState) { - snapshotFlow { certificateCodeState.text.toString() }.collect(onCertificateCodeChange) - } - - val requestCodeText = - when { - isSendingCode -> { - stringResource(R.string.onboarding_find_account_code_requesting) - } - - resendCooldownSeconds > 0 -> { - stringResource( - R.string.onboarding_find_account_code_resend_cooldown, - resendCooldownSeconds, - ) - } - - isVerificationSent -> { - stringResource(R.string.onboarding_find_account_code_resend) - } - - else -> { - stringResource(R.string.onboarding_find_account_code_request) - } + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + ObserveSignal( + // VM 이 UiText 로 폴백까지 확정해 두므로 빈 문구가 도달하지 않는다. + signal = + state.failure + .toDisplay() + .snackbarMessage + ?.asString(), + consumed = FindIdIntent.ConsumeError, + onIntent = viewModel::onIntent, + ) { message -> + // 소비가 곧바로 신호를 되돌려 effect 를 재시작시키므로, 표출은 effect 밖 스코프에 맡긴다. + scope.launch { + snackbarHostState.showSnackbar(message = message, duration = SnackbarDuration.Short) } + } - FlowStepScaffold( - topBarTitle = stringResource(R.string.onboarding_find_id_title), - actionButtonText = stringResource(R.string.onboarding_find_account_next), + FindIdContent( + state = state, + onIntent = viewModel::onIntent, + snackbarHostState = snackbarHostState, + onNextClick = onNextClick, onBackClick = onBackClick, - onActionClick = onNextClick, modifier = modifier, - isActionEnabled = isNextEnabled, - snackbarHostState = snackbarHostState, - ) { - Spacer(modifier = Modifier.height(HeaderSpacing)) - Column( - modifier = Modifier.imePadding(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.onboarding_find_account_verify_email_title), - style = AfternoteDesign.typography.h1, - color = AfternoteDesign.colors.gray9, - ) - Text( - text = stringResource(R.string.onboarding_find_account_verify_email_description), - // 시안 텍스트 스펙 = NanumBarunGothic Regular 12 / 행간 18 → captionLargeR (bodySmallB 는 Bold 14/20 로 불일치) - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray5, - ) - - AfternoteTextField( - state = emailState, - type = - TextFieldType.Variant7( - text = requestCodeText, - onClick = onRequestCode, - enabled = isSendCodeEnabled, - ), - placeholder = stringResource(R.string.onboarding_find_account_email_placeholder), - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - ) - - // 인증번호 필드의 "확인" 은 발송 이력이 있을 때만 노출 (시안 초기 상태엔 없음). - AfternoteTextField( - state = certificateCodeState, - type = - if (isVerificationSent) { - TextFieldType.Variant7( - text = stringResource(R.string.onboarding_find_account_code_confirm), - onClick = onVerifyCode, - enabled = isVerifyEnabled, - ) - } else { - TextFieldType.Basic - }, - placeholder = stringResource(R.string.onboarding_find_account_code_placeholder), - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - onImeAction = { - if (isVerifyEnabled) onVerifyCode() - }, - ) - - // 인증번호 불일치는 인라인 에러로, 그 외 실패는 스낵바로 나뉜다 (시안 2431-14204). - if (hasVerificationError) { - Text( - text = stringResource(R.string.onboarding_find_account_code_mismatch), - style = AfternoteDesign.typography.captionLargeB, - color = AfternoteDesign.colors.error, - ) - } else if (isVerificationSent) { - Text( - text = stringResource(R.string.onboarding_find_account_code_sent), - style = AfternoteDesign.typography.captionLargeB, - color = AfternoteDesign.colors.b1, - ) - } - } - } + ) } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdUiState.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdUiState.kt index dd00345174..fc1fa1cee3 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdUiState.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdUiState.kt @@ -1,24 +1,23 @@ package com.afternote.feature.onboarding.presentation.findaccount -import android.util.Patterns import com.afternote.core.model.FoundAccount -import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.UiState +import com.afternote.feature.onboarding.presentation.OnboardingEmailRule +import com.afternote.feature.onboarding.presentation.OnboardingFailure /** * 아이디 찾기 화면 상태. * * 시안에 인증번호 만료 타이머가 없어 회원가입(`SignUpUiState.verificationRemainingSeconds`)과 달리 - * 만료 카운트다운을 두지 않는다. 만료 판정은 서버가 하고, 만료된 코드는 [verificationError] 로 표시된다. + * 만료 카운트다운을 두지 않는다. 만료 판정은 서버가 하고, 만료된 코드는 [failure] 로 표시된다. * * @property resendCooldownSeconds "재전송" 버튼 잠금의 남은 초 — 0 보다 크면 "재전송 (Ns)" 표시·비활성. * 발송 성공마다 30초로 재잠금되는 클라이언트 측 연타 방지이며, **인증번호 유효시간과 무관**하다 * (만료 판정은 서버 몫, 쿨다운 종료 ≠ 코드 만료). * @property foundAccount "확인" 성공 시 서버가 돌려준 계정. non-null 이면 "다음" 이 열린다. - * @property hasVerificationError 인증번호 불일치 — 시안상 인증번호 필드 아래 인라인 문구로 표시(스낵바 아님). - * 표시 문구는 화면의 고정 리소스라 서버 문구를 담지 않고 발생 여부만 든다. - * @property errorMessage 인증번호 불일치 외의 실패(네트워크 등) — 스낵바로 표시. + * @property failure 현재 실패의 사유. 화면 경계가 표시 채널을 결정한다. */ -data class FindIdUiState( +internal data class FindIdUiState( val email: String = "", val certificateCode: String = "", val isSendingCode: Boolean = false, @@ -26,16 +25,11 @@ data class FindIdUiState( val isVerifying: Boolean = false, val resendCooldownSeconds: Int = 0, val foundAccount: FoundAccount? = null, - val hasVerificationError: Boolean = false, - val errorMessage: UiText? = null, -) { - /** - * 이메일 형식 검사. [Patterns.EMAIL_ADDRESS] 는 컴파일된 정규식(`Pattern`) 상수라 - * `matcher(입력)` 으로 그 문자열 전용 실행기를 만든 뒤 `matches()`(**전체 일치** — 부분 검색 - * `find()` 와 다름) 로 판정하는 Java regex 2단계 API 를 쓴다. 회원가입과 동일 방식. - */ + val failure: OnboardingFailure? = null, +) : UiState { + /** 이메일 형식 검사 — 세 온보딩 화면이 [OnboardingEmailRule] 하나를 쓴다 (#1851). */ val isEmailFormatValid: Boolean - get() = email.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(email).matches() + get() = OnboardingEmailRule.isValid(email) /** 인증번호 발송/재발송 가능 여부. 쿨다운 중이거나 이메일 형식이 틀리면 막는다. */ val isSendCodeEnabled: Boolean diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModel.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModel.kt index 91aee797c4..356fa934e3 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModel.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModel.kt @@ -1,10 +1,11 @@ package com.afternote.feature.onboarding.presentation.findaccount -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.repository.account.AccountRepository +import com.afternote.core.ui.mvi.MviViewModel +import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.afternote.feature.onboarding.presentation.R import com.afternote.feature.onboarding.presentation.reporting.AuthFailureStage import com.afternote.feature.onboarding.presentation.reporting.recordAuthFailure @@ -12,10 +13,6 @@ import com.afternote.feature.onboarding.presentation.toDisplayMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException @@ -27,45 +24,130 @@ import kotlin.time.Duration.Companion.milliseconds * 회원가입 플로우가 이미 같은 방식이다). * * `TextFieldState` 는 Screen 이 소유하고 VM 은 String 만 들고 있는다. + * + * 전이는 [reduce] 한 곳이고 진입점은 [onIntent] 하나다 (#1802). 기준은 + * `docs/convention/mvi.md`. */ @HiltViewModel -class FindIdViewModel +internal class FindIdViewModel @Inject constructor( private val accountRepository: AccountRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { - private val _uiState = MutableStateFlow(FindIdUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - + ) : MviViewModel(FindIdUiState()) { private var cooldownJob: Job? = null - fun updateEmail(value: String) = - _uiState.update { - // 이메일이 바뀌면 앞서 받은 계정·에러는 더 이상 그 이메일의 것이 아니다. - it.copy(email = value, foundAccount = null, hasVerificationError = false) + /** + * 진행 중인 서버 호출 (#1864). 상태 플래그만으로는 연타를 막지 못한다 — + * `viewModelScope.launch` 가 비동기라 `isSendingCode`·`isVerifying` 이 상태에 반영되기 전에 + * 두 번째 호출이 같은 `false` 를 읽고 통과한다. 실기기의 `Dispatchers.Main` 이 `launch` 본문을 + * 다음 루프로 미루므로 빠른 두 탭이면 열리는 창이고, 버튼과 IME 제출이 각각 독립 경로라 + * 「버튼 → 키보드 Done」 순서로도 열린다 (`FindPasswordViewModel`·`SignUpViewModel` 과 같은 판단). + * + * 두 호출 다 인증번호를 소비하므로 중복 요청이 낭비로 끝나지 않는다 — 발송은 코드를 새로 + * 발급해 앞서 안내한 것을 조용히 무효로 만들고, 확인은 서버가 검증하며 코드를 지우므로 + * 두 번째가 1207 로 실패해 첫 요청이 성공했는데도 「인증번호가 일치하지 않습니다」 가 뜬다. + */ + private var sendCodeJob: Job? = null + private var verifyJob: Job? = null + + override fun onIntent(intent: FindIdIntent) { + when (intent) { + is FindIdIntent.UpdateEmail -> dispatch(FindIdReducerEvent.EmailChanged(intent.value)) + is FindIdIntent.UpdateCertificateCode -> dispatch(FindIdReducerEvent.CertificateCodeChanged(intent.value)) + FindIdIntent.RequestVerificationCode -> requestVerificationCode() + FindIdIntent.VerifyCode -> verifyCode() + FindIdIntent.ConsumeError -> dispatch(FindIdReducerEvent.ErrorConsumed) } + } - fun updateCertificateCode(value: String) = _uiState.update { it.copy(certificateCode = value, hasVerificationError = false) } + override fun reduce( + state: FindIdUiState, + event: FindIdReducerEvent, + ): FindIdUiState = + when (event) { + // 이메일이 바뀌면 앞서 받은 계정·에러는 더 이상 그 이메일의 것이 아니다. + is FindIdReducerEvent.EmailChanged -> { + state.copy(email = event.value, foundAccount = null, failure = state.failure.takeIf { state.email == event.value }) + } - fun requestVerificationCode() { - val state = _uiState.value - if (!state.isSendCodeEnabled) return - viewModelScope.launch { - _uiState.update { it.copy(isSendingCode = true) } - accountRepository - .sendFindCode(state.email) - .onSuccess { - _uiState.update { it.copy(isVerificationSent = true, hasVerificationError = false) } - startResendCooldown() - }.onFailure { error -> - // 취소는 장애가 아니다 — 기록·UI 소비 전에 되던져 전파를 보존한다(전수 정정은 #661). - if (error is CancellationException) throw error - errorReporter.recordAuthFailure(AuthFailureStage.FIND_ACCOUNT_CODE_SEND, error) - _uiState.update { it.copy(errorMessage = error.toDisplayMessage(R.string.onboarding_find_account_failed)) } - } - _uiState.update { it.copy(isSendingCode = false) } + is FindIdReducerEvent.CertificateCodeChanged -> { + state.copy(certificateCode = event.value, failure = state.failure.takeIf { state.certificateCode == event.value }) + } + + FindIdReducerEvent.CodeSendStarted -> { + state.copy(isSendingCode = true) + } + + FindIdReducerEvent.CodeSent -> { + state.copy(isVerificationSent = true, failure = null) + } + + is FindIdReducerEvent.CodeSendFailed -> { + state.copy(failure = OnboardingFailure.RequestFailed(event.message)) + } + + FindIdReducerEvent.CodeSendFinished -> { + state.copy(isSendingCode = false) + } + + FindIdReducerEvent.CooldownReloaded -> { + state.copy(resendCooldownSeconds = RESEND_COOLDOWN_SECONDS) + } + + FindIdReducerEvent.CooldownTicked -> { + state.copy(resendCooldownSeconds = state.resendCooldownSeconds - 1) + } + + FindIdReducerEvent.VerifyStarted -> { + state.copy(isVerifying = true, failure = null) + } + + is FindIdReducerEvent.AccountFound -> { + state.copy(foundAccount = event.account) + } + + FindIdReducerEvent.VerificationRejected -> { + state.copy(failure = OnboardingFailure.VerificationRejected) + } + + is FindIdReducerEvent.VerifyFailed -> { + state.copy(failure = OnboardingFailure.RequestFailed(event.message)) + } + + FindIdReducerEvent.VerifyFinished -> { + state.copy(isVerifying = false) + } + + FindIdReducerEvent.ErrorConsumed -> { + state.copy(failure = state.failure.takeUnless { it is OnboardingFailure.RequestFailed }) + } } + + private fun requestVerificationCode() { + if (sendCodeJob?.isActive == true) return + val state = currentState + if (!state.isSendCodeEnabled) return + sendCodeJob = + viewModelScope.launch { + dispatch(FindIdReducerEvent.CodeSendStarted) + accountRepository + .sendFindCode(state.email) + .onSuccess { + dispatch(FindIdReducerEvent.CodeSent) + startResendCooldown() + }.onFailure { error -> + // 취소는 장애가 아니다 — 기록·UI 소비 전에 되던져 전파를 보존한다(전수 정정은 #661). + if (error is CancellationException) throw error + errorReporter.recordAuthFailure(AuthFailureStage.FIND_ACCOUNT_CODE_SEND, error) + dispatch( + FindIdReducerEvent.CodeSendFailed( + error.toDisplayMessage(R.string.onboarding_find_account_failed), + ), + ) + } + dispatch(FindIdReducerEvent.CodeSendFinished) + } } /** @@ -77,37 +159,37 @@ class FindIdViewModel * 필드 아래 인라인 문구이고, 그 밖의 실패는 사용자가 입력으로 고칠 수 없어 스낵바로 보낸다. * 갈래를 나누지 않으면 네트워크 실패에도 "인증번호가 일치하지 않습니다" 가 뜬다. */ - fun verifyCode() { - val state = _uiState.value + private fun verifyCode() { + if (verifyJob?.isActive == true) return + val state = currentState if (!state.isVerifyEnabled) return - viewModelScope.launch { - _uiState.update { it.copy(isVerifying = true, hasVerificationError = false) } - accountRepository - .findAccount(state.email, state.certificateCode) - .onSuccess { account -> - _uiState.update { it.copy(foundAccount = account) } - }.onFailure { error -> - // 취소는 장애가 아니다 — 여기는 계측 대상이 아니지만 실패 UI 로 소비하는 것도 - // 막아야 해서 되던진다(전수 정정은 #661). - if (error is CancellationException) throw error - // 계측하지 않는다 — 인증번호 오타는 사용자의 정상적인 입력 실수다. - // 자세한 사유는 AuthFailureStage.FIND_ACCOUNT_CODE_SEND KDoc. - _uiState.update { + verifyJob = + viewModelScope.launch { + dispatch(FindIdReducerEvent.VerifyStarted) + accountRepository + .findAccount(state.email, state.certificateCode) + .onSuccess { account -> + dispatch(FindIdReducerEvent.AccountFound(account)) + }.onFailure { error -> + // 취소는 장애가 아니다 — 여기는 계측 대상이 아니지만 실패 UI 로 소비하는 것도 + // 막아야 해서 되던진다(전수 정정은 #661). + if (error is CancellationException) throw error + // 계측하지 않는다 — 인증번호 오타는 사용자의 정상적인 입력 실수다. + // 자세한 사유는 AuthFailureStage.FIND_ACCOUNT_CODE_SEND KDoc. if (error is CoreAuthFailure.EmailVerification) { - // 스낵바 신호를 함께 내린다 — 이번 실패는 인라인으로 알리므로, - // 아직 소비되지 않은 이전 실패 문구가 인라인과 겹쳐 뜨지 않게 한다. - it.copy(hasVerificationError = true, errorMessage = null) + dispatch(FindIdReducerEvent.VerificationRejected) } else { - it.copy(errorMessage = error.toDisplayMessage(R.string.onboarding_find_account_failed)) + dispatch( + FindIdReducerEvent.VerifyFailed( + error.toDisplayMessage(R.string.onboarding_find_account_failed), + ), + ) } } - } - _uiState.update { it.copy(isVerifying = false) } - } + dispatch(FindIdReducerEvent.VerifyFinished) + } } - fun onErrorConsumed() = _uiState.update { it.copy(errorMessage = null) } - private fun startResendCooldown() { // 중복 방지가 아니라 last-wins 재장전 — 이전 카운트다운을 폐기하고 30초를 새로 센다. // 호출부 가드(isSendCodeEnabled)상 보통 이전 잡은 끝나 있지만, "쿨다운 잡 최대 1개" 를 @@ -115,10 +197,10 @@ class FindIdViewModel cooldownJob?.cancel() cooldownJob = viewModelScope.launch { - _uiState.update { it.copy(resendCooldownSeconds = RESEND_COOLDOWN_SECONDS) } - while (_uiState.value.resendCooldownSeconds > 0) { + dispatch(FindIdReducerEvent.CooldownReloaded) + while (currentState.resendCooldownSeconds > 0) { delay(MILLIS_PER_SECOND.milliseconds) - _uiState.update { it.copy(resendCooldownSeconds = it.resendCooldownSeconds - 1) } + dispatch(FindIdReducerEvent.CooldownTicked) } } } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordCompleteScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordCompleteScreen.kt new file mode 100644 index 0000000000..d350b131db --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordCompleteScreen.kt @@ -0,0 +1,41 @@ +package com.afternote.feature.onboarding.presentation.findaccount + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.onboarding.presentation.R + +private val HeaderSpacing = 8.dp + +/** + * 비밀번호 찾기 3단계 — 변경 완료 안내 (시안 `2383:16854`). + * + * 하단 CTA 와 상단 뒤로가기가 같은 곳(로그인)으로 간다. 비밀번호는 이미 바뀐 뒤라 되돌아갈 + * 앞 단계가 없다 — 인증번호도 서버가 소비해 버려 이전 화면이 더는 유효하지 않다. + */ +@Composable +fun FindPasswordCompleteScreen( + onLoginClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FlowStepScaffold( + topBarTitle = stringResource(R.string.onboarding_find_password_title), + actionButtonText = stringResource(R.string.onboarding_find_password_complete_login), + onBackClick = onLoginClick, + onActionClick = onLoginClick, + modifier = modifier, + ) { + Spacer(modifier = Modifier.height(HeaderSpacing)) + Text( + text = stringResource(R.string.onboarding_find_password_complete_message), + style = AfternoteDesign.typography.bodyLargeR, + color = AfternoteDesign.colors.gray8, + ) + } +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordResetScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordResetScreen.kt new file mode 100644 index 0000000000..e8bb1f7fbc --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordResetScreen.kt @@ -0,0 +1,123 @@ +package com.afternote.feature.onboarding.presentation.findaccount + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.onboarding.presentation.PasswordRuleItem +import com.afternote.feature.onboarding.presentation.R + +private val HeaderSpacing = 8.dp + +/** + * 비밀번호 찾기 2단계 — 새 비밀번호 입력 (시안 `2383:16789`). + * + * 안내 2줄은 회원가입 3단계와 글자까지 같은 시안이라 문구·규칙·컴포넌트를 그대로 쓴다 + * ([PasswordRuleItem] · `onboarding_signup_password_rule_*`). + * + * "다음" 이 곧 최종 제출이다 — 이 화면의 제출이 인증번호 검증까지 겸한다 + * ([FindPasswordViewModel.submitNewPassword]). + */ +@Composable +fun FindPasswordResetScreen( + initialPassword: String, + initialPasswordConfirm: String, + isPasswordRuleSatisfied: Boolean, + isNextEnabled: Boolean, + snackbarHostState: SnackbarHostState, + onPasswordChange: (String) -> Unit, + onPasswordConfirmChange: (String) -> Unit, + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val passwordState = rememberTextFieldState(initialPassword) + val passwordConfirmState = rememberTextFieldState(initialPasswordConfirm) + + LaunchedEffect(passwordState) { + snapshotFlow { passwordState.text.toString() }.collect(onPasswordChange) + } + LaunchedEffect(passwordConfirmState) { + snapshotFlow { passwordConfirmState.text.toString() }.collect(onPasswordConfirmChange) + } + + FlowStepScaffold( + topBarTitle = stringResource(R.string.onboarding_find_password_title), + actionButtonText = stringResource(R.string.onboarding_find_account_next), + onBackClick = onBackClick, + onActionClick = onNextClick, + modifier = modifier, + isActionEnabled = isNextEnabled, + snackbarHostState = snackbarHostState, + ) { + Spacer(modifier = Modifier.height(HeaderSpacing)) + Column( + modifier = + Modifier + .fillMaxWidth() + .imePadding() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.onboarding_find_password_reset_title), + style = AfternoteDesign.typography.h1, + color = AfternoteDesign.colors.gray9, + ) + Text( + text = stringResource(R.string.onboarding_find_password_reset_description), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray5, + ) + + AfternoteTextField( + state = passwordState, + placeholder = stringResource(R.string.onboarding_signup_password_placeholder), + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ) + + AfternoteTextField( + state = passwordConfirmState, + placeholder = stringResource(R.string.onboarding_signup_password_confirm_placeholder), + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + onImeAction = { + focusManager.clearFocus() + if (isNextEnabled) onNextClick() + }, + ) + + Column { + PasswordRuleItem( + text = stringResource(R.string.onboarding_signup_password_rule_combination), + isSatisfied = isPasswordRuleSatisfied, + ) + Spacer(modifier = Modifier.height(4.dp)) + PasswordRuleItem( + text = stringResource(R.string.onboarding_signup_password_rule_reuse), + ) + } + } + } +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreen.kt new file mode 100644 index 0000000000..4a6166bdc6 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreen.kt @@ -0,0 +1,161 @@ +package com.afternote.feature.onboarding.presentation.findaccount + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.TextFieldType +import com.afternote.core.ui.popup.Popup +import com.afternote.core.ui.popup.PopupType +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.onboarding.presentation.R + +private val HeaderSpacing = 8.dp + +/** + * 비밀번호 찾기 1단계 — 이메일 인증 (시안 `2431:14299` · `2383:16680` · `2383:16667`). + * + * 아이디 찾기([FindIdScreen])와 달리 인증번호 필드에 인라인 "확인" 이 없다. 서버가 인증번호를 + * 검증하면서 삭제하므로 여기서 확인해 버리면 최종 제출(`auth/password/find`)에 쓸 코드가 남지 + * 않는다 — 시안도 그렇게 그려져 있다. "다음" 은 자릿수만 보고 열리고, 코드의 진위는 비밀번호 + * 변경 화면의 제출이 판정한다. + * + * @param showSocialAccountBlockedPopup 차단 팝업(시안 `2383:16667`)을 띄울지 — 소셜로 가입해 + * 로컬 비밀번호가 없는 계정이라 이 흐름을 쓸 수 없다는 안내다(서버 code 1702). + */ +@Composable +fun FindPasswordScreen( + initialEmail: String, + initialCertificateCode: String, + isSendingCode: Boolean, + isVerificationSent: Boolean, + isSendCodeEnabled: Boolean, + isNextEnabled: Boolean, + resendCooldownSeconds: Int, + showSocialAccountBlockedPopup: Boolean, + snackbarHostState: SnackbarHostState, + onEmailChange: (String) -> Unit, + onCertificateCodeChange: (String) -> Unit, + onRequestCode: () -> Unit, + onSocialAccountBlockedConfirm: () -> Unit, + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val emailState = rememberTextFieldState(initialEmail) + val certificateCodeState = rememberTextFieldState(initialCertificateCode) + + // TextFieldState 에는 콜백이 없고 LaunchedEffect 의 key 는 객체 동일성 비교라 내용 변이에 + // 반응하지 않는다. snapshotFlow 가 스냅샷 읽기 추적으로 내용 변이를 구독한다 (FindIdScreen 선례). + LaunchedEffect(emailState) { + snapshotFlow { emailState.text.toString() }.collect(onEmailChange) + } + LaunchedEffect(certificateCodeState) { + snapshotFlow { certificateCodeState.text.toString() }.collect(onCertificateCodeChange) + } + + val requestCodeText = + when { + isSendingCode -> { + stringResource(R.string.onboarding_find_account_code_requesting) + } + + resendCooldownSeconds > 0 -> { + stringResource( + R.string.onboarding_find_account_code_resend_cooldown, + resendCooldownSeconds, + ) + } + + isVerificationSent -> { + stringResource(R.string.onboarding_find_account_code_resend) + } + + else -> { + stringResource(R.string.onboarding_find_account_code_request) + } + } + + FlowStepScaffold( + topBarTitle = stringResource(R.string.onboarding_find_password_title), + actionButtonText = stringResource(R.string.onboarding_find_account_next), + onBackClick = onBackClick, + onActionClick = onNextClick, + modifier = modifier, + isActionEnabled = isNextEnabled, + snackbarHostState = snackbarHostState, + ) { + Spacer(modifier = Modifier.height(HeaderSpacing)) + Column( + modifier = Modifier.imePadding(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.onboarding_find_account_verify_email_title), + style = AfternoteDesign.typography.h1, + color = AfternoteDesign.colors.gray9, + ) + Text( + text = stringResource(R.string.onboarding_find_account_verify_email_description), + // 시안 텍스트 스펙 = NanumBarunGothic Regular 12 / 행간 18 → captionLargeR + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray5, + ) + + AfternoteTextField( + state = emailState, + type = + TextFieldType.Variant7( + text = requestCodeText, + onClick = onRequestCode, + enabled = isSendCodeEnabled, + ), + placeholder = stringResource(R.string.onboarding_find_account_email_placeholder), + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ) + + AfternoteTextField( + state = certificateCodeState, + placeholder = stringResource(R.string.onboarding_find_account_code_placeholder), + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + onImeAction = { + if (isNextEnabled) onNextClick() + }, + ) + + if (isVerificationSent) { + Text( + text = stringResource(R.string.onboarding_find_account_code_sent), + style = AfternoteDesign.typography.captionLargeB, + color = AfternoteDesign.colors.b1, + ) + } + } + } + + if (showSocialAccountBlockedPopup) { + // 확인 외의 선택지가 없는 안내다 — 바깥 탭·뒤로가기도 같은 소비로 닫는다. + Popup( + type = PopupType.Default, + message = stringResource(R.string.onboarding_find_password_social_blocked), + onConfirm = onSocialAccountBlockedConfirm, + onDismiss = onSocialAccountBlockedConfirm, + ) + } +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordUiState.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordUiState.kt new file mode 100644 index 0000000000..b3c60c4241 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordUiState.kt @@ -0,0 +1,67 @@ +package com.afternote.feature.onboarding.presentation.findaccount + +import com.afternote.feature.onboarding.presentation.OnboardingEmailRule +import com.afternote.feature.onboarding.presentation.OnboardingFailure +import com.afternote.feature.onboarding.presentation.OnboardingPasswordRule + +/** + * 비밀번호 찾기 흐름(이메일 인증 → 비밀번호 변경 → 완료)의 단일 상태. + * + * 아이디 찾기([FindIdUiState])와 두 곳이 다르다. + * + * 1. **인증번호를 미리 확인하지 않는다.** 서버가 인증번호를 검증하면서 삭제하므로 + * (BE `EmailService.verifyAndDeleteCode`) 여기서 한 번 쓰면 최종 제출에 쓸 코드가 없다. + * 시안(`2431:14299`)에 인증번호 필드의 인라인 "확인" 이 없는 것도 같은 이유다 — + * 검증은 `auth/password/find` 가 새 비밀번호와 함께 한 번에 한다. + * 2. **소셜 가입 계정을 인증번호 발송 단계에서 거른다.** BE 가 `auth/find/send/code` 에서 + * `password == null` 인 계정을 code 1702 로 거절하므로(`findActiveLocalUserForRecovery`), + * 코드 입력 전에 차단 팝업(시안 `2383:16667`)을 낼 수 있다. + * + * @property resendCooldownSeconds "재전송" 잠금의 남은 초 — 발송 성공마다 30초로 재잠금되는 + * 클라이언트 측 연타 방지이며 **인증번호 유효시간과 무관**하다(만료 판정은 서버 몫). + * @property failure 현재 실패의 사유. 화면 경계가 표시 채널을 결정한다. + * @property isPasswordChanged 재설정 성공 — 완료 화면으로 넘기는 단발성 신호. 소비 후 + * [FindPasswordViewModel.onPasswordResetConsumed] 로 흐름 상태 전체가 초기화된다. + */ +internal data class FindPasswordUiState( + val email: String = "", + val certificateCode: String = "", + val isSendingCode: Boolean = false, + val isVerificationSent: Boolean = false, + val resendCooldownSeconds: Int = 0, + val newPassword: String = "", + val newPasswordConfirm: String = "", + val isSubmitting: Boolean = false, + val isPasswordChanged: Boolean = false, + val failure: OnboardingFailure? = null, +) { + /** 이메일 형식 검사 — 세 온보딩 화면이 [OnboardingEmailRule] 하나를 쓴다 (#1851). */ + val isEmailFormatValid: Boolean + get() = OnboardingEmailRule.isValid(email) + + /** 인증번호 발송/재발송 가능 여부. 쿨다운 중이거나 이메일 형식이 틀리면 막는다. */ + val isSendCodeEnabled: Boolean + get() = !isSendingCode && resendCooldownSeconds == 0 && isEmailFormatValid + + /** + * 이메일 인증 화면의 "다음" 활성 여부. + * + * 인증번호를 서버에 물어보지 않은 채 다음 화면으로 넘긴다 — 자릿수만 본다. 코드가 틀렸는지는 + * 최종 제출이 알려주고, 그 실패는 스낵바(`onboarding_find_password_code_expired`)로 돌아온다. + */ + val isVerificationNextEnabled: Boolean + get() = isVerificationSent && isEmailFormatValid && certificateCode.length >= MIN_VERIFICATION_CODE_LENGTH + + /** 비밀번호 규칙 충족 여부. 안내 문구 색상 토글에도 쓴다 — 회원가입과 같은 규칙. */ + val isPasswordRuleSatisfied: Boolean + get() = OnboardingPasswordRule.isSatisfied(newPassword) + + /** 비밀번호 변경 화면의 "다음" 활성 여부 — 규칙 충족 + 확인 일치. 제출 중이면 연타를 막는다. */ + val isResetEnabled: Boolean + get() = !isSubmitting && isPasswordRuleSatisfied && newPassword == newPasswordConfirm + + companion object { + /** 서버가 요구하는 인증번호 자릿수 (BE `PasswordFindRequest` — `^[0-9]{6}$`). */ + const val MIN_VERIFICATION_CODE_LENGTH = 6 + } +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordViewModel.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordViewModel.kt new file mode 100644 index 0000000000..dd8bffc8a7 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordViewModel.kt @@ -0,0 +1,189 @@ +package com.afternote.feature.onboarding.presentation.findaccount + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.error.CoreAuthFailure +import com.afternote.core.domain.repository.account.AccountRepository +import com.afternote.core.ui.UiText +import com.afternote.feature.onboarding.presentation.OnboardingFailure +import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.reporting.AuthFailureStage +import com.afternote.feature.onboarding.presentation.reporting.recordAuthFailure +import com.afternote.feature.onboarding.presentation.toDisplayMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.milliseconds + +/** + * 비밀번호 찾기 플로우 ViewModel — 이메일 인증 · 비밀번호 변경 · 완료 세 화면이 + * `Route.Onboarding` 그래프 스코프로 공유한다. + * + * 공유가 필요한 이유는 [FindPasswordUiState.certificateCode] 다. 서버는 인증번호와 새 비밀번호를 + * **한 요청**(`auth/password/find`)으로 받으므로 인증 화면에서 받은 코드를 비밀번호 변경 화면까지 + * 들고 가야 한다. Route 인자로 넘기지 않는 것은 회원가입·아이디 찾기와 같은 관례이자, + * 인증번호가 백스택 엔트리 인자로 남지 않게 하려는 것이기도 하다. + * + * `TextFieldState` 는 Screen 이 소유하고 VM 은 String 만 들고 있는다. + */ +@HiltViewModel +internal class FindPasswordViewModel + @Inject + constructor( + private val accountRepository: AccountRepository, + private val errorReporter: ErrorReporter, + ) : ViewModel() { + private val _uiState = MutableStateFlow(FindPasswordUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var cooldownJob: Job? = null + + /** + * 진행 중인 서버 호출. 상태 플래그만으로는 연타를 막지 못한다 — `viewModelScope.launch` 가 + * 비동기라 `isSendingCode`·`isSubmitting` 이 반영되기 전에 두 번째 호출이 같은 `false` 를 + * 읽고 통과한다(`SignUpViewModel.signUpJob` 과 같은 판단). + * + * 두 흐름 다 인증번호를 소비하므로 중복 요청이 그냥 낭비로 끝나지 않는다 — 발송은 코드를 + * 새로 발급해 앞서 안내한 것을 무효로 만들고, 재설정은 두 번째가 1207 로 실패해 첫 요청이 + * 성공했는데도 "이메일 인증부터 다시" 안내가 뜬다. + */ + private var sendCodeJob: Job? = null + private var resetJob: Job? = null + + fun updateEmail(value: String) = + _uiState.update { + // 이메일이 바뀌면 앞선 발송 이력과 차단 판정은 더 이상 그 이메일의 것이 아니다. + it.copy(email = value, isVerificationSent = false, failure = it.failure.takeIf { _ -> it.email == value }) + } + + fun updateCertificateCode(value: String) = _uiState.update { it.copy(certificateCode = value) } + + fun updateNewPassword(value: String) = _uiState.update { it.copy(newPassword = value) } + + fun updateNewPasswordConfirm(value: String) = _uiState.update { it.copy(newPasswordConfirm = value) } + + /** + * 인증번호 발송. 소셜 가입 계정은 여기서 걸린다 — 서버가 `auth/find/send/code` 에서 + * 로컬 계정만 통과시키므로(code 1702), 코드 입력 전에 차단 팝업을 낼 수 있다. + */ + fun requestVerificationCode() { + if (sendCodeJob?.isActive == true) return + val state = _uiState.value + if (!state.isSendCodeEnabled) return + sendCodeJob = + viewModelScope.launch { + _uiState.update { it.copy(isSendingCode = true) } + accountRepository + .sendFindCode(state.email) + .onSuccess { + _uiState.update { it.copy(isVerificationSent = true) } + startResendCooldown() + }.onFailure { error -> + // 취소는 장애가 아니다 — 기록·UI 소비 전에 되던져 전파를 보존한다(전수 정정은 #661). + if (error is CancellationException) throw error + _uiState.update { current -> + if (error is CoreAuthFailure.SocialSignUpAccount) { + // 사용자 입력 오류가 아니라 계정 종류의 문제다 — 시안은 팝업으로 그린다. + // 계측하지 않는다: 서버가 정상적으로 가르는 분기지 장애가 아니다. + current.copy(failure = OnboardingFailure.SocialAccountRecoveryUnavailable) + } else { + errorReporter.recordAuthFailure(AuthFailureStage.FIND_ACCOUNT_CODE_SEND, error) + current.copy( + failure = + OnboardingFailure.RequestFailed( + error.toDisplayMessage(R.string.onboarding_find_account_failed), + ), + ) + } + } + } + _uiState.update { it.copy(isSendingCode = false) } + } + } + + /** + * 최종 제출 — 인증번호 검증과 비밀번호 반영이 이 한 번의 호출에서 함께 일어난다. + * + * 인증번호 무효([CoreAuthFailure.EmailVerification])만 전용 문구로 가른다. 이 시점의 + * 1207 은 "오타" 가 아니라 대개 만료라서, 폴백 문구로는 사용자가 무엇을 다시 해야 하는지 + * 알 수 없다 — 이메일 인증부터 다시 하라고 말해 준다. 시안에는 이 상태가 없어(변경 화면에 + * 인라인 에러 슬롯이 없다) 스낵바로 낸다. + */ + fun submitNewPassword() { + if (resetJob?.isActive == true) return + val state = _uiState.value + if (!state.isResetEnabled) return + resetJob = + viewModelScope.launch { + _uiState.update { it.copy(isSubmitting = true) } + accountRepository + .resetPassword( + email = state.email, + certificateCode = state.certificateCode, + newPassword = state.newPassword, + confirmPassword = state.newPasswordConfirm, + ).onSuccess { + _uiState.update { it.copy(isPasswordChanged = true) } + }.onFailure { error -> + if (error is CancellationException) throw error + errorReporter.recordAuthFailure(AuthFailureStage.FIND_PASSWORD_RESET, error) + _uiState.update { + it.copy(failure = OnboardingFailure.RequestFailed(error.toResetFailureMessage())) + } + } + _uiState.update { it.copy(isSubmitting = false) } + } + } + + fun onErrorConsumed() = + _uiState.update { + it.copy(failure = it.failure.takeUnless { failure -> failure is OnboardingFailure.RequestFailed }) + } + + /** + * 완료 화면으로 넘어간 뒤 흐름 상태를 통째로 버린다. + * + * 그래프 스코프 VM 이라 인스턴스가 흐름보다 오래 산다 — 초기화하지 않으면 (1) 재진입 시 + * [FindPasswordUiState.isPasswordChanged] 가 참인 채로 남아 완료 화면으로 곧장 튀고, + * (2) 평문 비밀번호가 메모리에 계속 남는다. + */ + fun onPasswordResetConsumed() { + cooldownJob?.cancel() + cooldownJob = null + _uiState.value = FindPasswordUiState() + } + + private fun startResendCooldown() { + // 중복 방지가 아니라 last-wins 재장전 — 이전 카운트다운을 폐기하고 30초를 새로 센다. + cooldownJob?.cancel() + cooldownJob = + viewModelScope.launch { + _uiState.update { it.copy(resendCooldownSeconds = RESEND_COOLDOWN_SECONDS) } + while (_uiState.value.resendCooldownSeconds > 0) { + delay(MILLIS_PER_SECOND.milliseconds) + _uiState.update { it.copy(resendCooldownSeconds = it.resendCooldownSeconds - 1) } + } + } + } + + private fun Throwable.toResetFailureMessage(): UiText = + if (this is CoreAuthFailure.EmailVerification) { + UiText.Resource(R.string.onboarding_find_password_code_expired) + } else { + toDisplayMessage(R.string.onboarding_find_password_failed) + } + + private companion object { + // 시안·서버 계약에 없는 클라 관례값 — 회원가입·아이디 찾기의 쿨다운과 동일하게 맞춤. + const val RESEND_COOLDOWN_SECONDS = 30 + const val MILLIS_PER_SECOND = 1000L + } + } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginContent.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginContent.kt new file mode 100644 index 0000000000..097c938d5d --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginContent.kt @@ -0,0 +1,329 @@ +package com.afternote.feature.onboarding.presentation.login + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.button.AfternoteButton +import com.afternote.core.ui.button.AfternoteButtonType +import com.afternote.core.ui.modifierextention.addFocusCleaner +import com.afternote.core.ui.popup.NetworkErrorPopup +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.onboarding.presentation.OnboardingFailureDisplay +import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.toDisplay + +/** + * 로그인 화면 — stateless 층. 프리뷰·screenshotTest·Robolectric 의 진입점이다. + * + * 소셜 로그인 콜백만 파라미터로 남는다. 토큰을 받아오는 일이 Activity·CredentialManager + * 의존이라 [LoginScreen] 이 하고, ViewModel 에는 문자열만 Intent 로 들어간다. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun LoginContent( + state: LoginUiState, + onIntent: (LoginIntent) -> Unit, + onSignUpClick: () -> Unit, + onFindAccountClick: () -> Unit, + onKakaoLoginClick: () -> Unit, + onGoogleLoginClick: () -> Unit, + onBackClick: () -> Unit, + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val emailState = rememberTextFieldState(state.email) + val passwordState = rememberTextFieldState(state.password) + + LaunchedEffect(emailState) { + snapshotFlow { emailState.text.toString() }.collect { onIntent(LoginIntent.UpdateEmail(it)) } + } + LaunchedEffect(passwordState) { + snapshotFlow { passwordState.text.toString() }.collect { onIntent(LoginIntent.UpdatePassword(it)) } + } + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = { + DetailTopBar( + title = stringResource(R.string.onboarding_login_top_bar_title), + onBackClick = { + focusManager.clearFocus() + onBackClick() + }, + ) + }, + bottomBar = { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 45.dp), + ) { + SocialLoginGroup( + onSignUpClick = onSignUpClick, + onFindAccountClick = onFindAccountClick, + onKakaoLoginClick = onKakaoLoginClick, + onGoogleLoginClick = onGoogleLoginClick, + ) + } + }, + containerColor = Color.Transparent, + ) { innerPadding -> + Column( + modifier = + Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .addFocusCleaner(focusManager) + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(39.dp)) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // 아이디 (이메일) 입력 필드 + AfternoteTextField( + state = emailState, + modifier = + Modifier.semantics { contentType = ContentType.Username }, + placeholder = stringResource(R.string.onboarding_login_email_label), + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ) + + // 비밀번호 필드와 인라인 안내(시안 3628:23437)를 6dp 로 묶는다 — 바깥 8dp 와 달라 + // 별도 Column. 문구 스타일은 아이디 찾기 인라인(FindIdScreen)과 동일 토큰. + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + AfternoteTextField( + state = passwordState, + modifier = + Modifier.semantics { contentType = ContentType.Password }, + placeholder = stringResource(R.string.onboarding_login_password_label), + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + onImeAction = { + if (!state.isLoading) { + focusManager.clearFocus() + onIntent(LoginIntent.SubmitEmailLogin) + } + }, + isError = (state.failure.toDisplay() == OnboardingFailureDisplay.CredentialInline), + ) + + if ((state.failure.toDisplay() == OnboardingFailureDisplay.CredentialInline)) { + Text( + text = stringResource(R.string.onboarding_login_invalid_credentials), + style = AfternoteDesign.typography.captionLargeB, + color = AfternoteDesign.colors.error, + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 로그인 버튼 + AfternoteButton( + text = stringResource(R.string.onboarding_login_button), + onClick = { + if (!state.isLoading) { + focusManager.clearFocus() + onIntent(LoginIntent.SubmitEmailLogin) + } + }, + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), + type = AfternoteButtonType.Default, + isLoading = state.isLoading, + ) + } + } + + if ((state.failure.toDisplay() == OnboardingFailureDisplay.NetworkRetryPopup)) { + NetworkErrorPopup( + onRetry = { onIntent(LoginIntent.RetryLogin) }, + onDismiss = { onIntent(LoginIntent.DismissNetworkError) }, + ) + } +} + +/** + * 로그인 화면 하단 소셜 로그인 그룹. + * + * `LoginScreen`에서만 쓰이므로 외부 파일로 분리하지 않고 `private`로 캡슐화한다. + */ +@Composable +private fun SocialLoginGroup( + onSignUpClick: () -> Unit, + onFindAccountClick: () -> Unit, + onKakaoLoginClick: () -> Unit, + onGoogleLoginClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // 간편 회원가입하기 버튼 + AfternoteButton( + text = stringResource(R.string.onboarding_login_signup_simple), + type = AfternoteButtonType.Plain, + onClick = { + focusManager.clearFocus() + onSignUpClick() + }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 카카오 로그인 버튼 — 배경·심볼만 브랜드 고정색, 프레임 규격은 구글·이메일 버튼과 동일. + SocialLoginButton( + text = stringResource(R.string.onboarding_login_kakao), + iconPainter = painterResource(R.drawable.onboarding_ic_kakao_symbol), + containerColor = KakaoContainerColor, + contentColor = KakaoContentColor, + onClick = { + focusManager.clearFocus() + onKakaoLoginClick() + }, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 구글 로그인 버튼 — 색은 회원가입 버튼(AfternoteButtonType.Plain)과 같은 gray2/gray9/gray3. + SocialLoginButton( + text = stringResource(R.string.onboarding_login_google), + iconPainter = painterResource(com.afternote.core.ui.R.drawable.core_ui_img_google_logo), + containerColor = AfternoteDesign.colors.gray2, + contentColor = AfternoteDesign.colors.gray9, + border = BorderStroke(1.dp, AfternoteDesign.colors.gray3), + onClick = { + focusManager.clearFocus() + onGoogleLoginClick() + }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // 아이디/비밀번호 찾기 + Text( + text = stringResource(R.string.onboarding_login_find_account), + style = + AfternoteDesign.typography.captionLargeR.copy( + color = AfternoteDesign.colors.gray6, + textDecoration = TextDecoration.Underline, + ), + modifier = + Modifier.clickable { + focusManager.clearFocus() + onFindAccountClick() + }, + ) + } +} + +/** 카카오 로그인 버튼 배경 — 카카오 브랜드 고정색이라 앱 팔레트 토큰으로 근사하지 않는다. */ +private val KakaoContainerColor = Color(0xFFFEE500) + +/** 카카오 로그인 버튼 라벨 색 — 심볼 벡터(onboarding_ic_kakao_symbol)와 같은 #212121·알파 90.2%. */ +private val KakaoContentColor = Color(0xE6212121) + +/** + * 소셜 로그인 버튼 공통 프레임 — 카카오·구글이 색과 아이콘만 다르고 같은 규격을 쓰도록 강제한다. + * + * 시안의 두 버튼은 출처가 달라(카카오 공식 에셋 이미지 vs 별도 컴포넌트) 셰이프·타이포·아이콘 + * 배치가 제각각이었고, 통일 판단은 FE 재량으로 확정된 건이다(#775). 규격은 이 화면의 다른 + * 버튼([AfternoteButton])과 동일한 6dp 셰이프·48dp 높이·captionLargeB 라벨로 수렴시킨다. + * 아이콘은 장식이라 contentDescription 없이 라벨 [text] 가 접근성 이름을 담당한다. + */ +@Composable +private fun SocialLoginButton( + text: String, + iconPainter: Painter, + containerColor: Color, + contentColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, + border: BorderStroke? = null, +) { + Surface( + onClick = onClick, + modifier = + modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(6.dp), + color = containerColor, + contentColor = contentColor, + border = border, + ) { + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Image( + painter = iconPainter, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + style = AfternoteDesign.typography.captionLargeB, + ) + } + } +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginContract.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginContract.kt new file mode 100644 index 0000000000..3812083275 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginContract.kt @@ -0,0 +1,87 @@ +package com.afternote.feature.onboarding.presentation.login + +import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.feature.onboarding.presentation.reporting.AuthProvider + +/** 로그인 화면이 [LoginViewModel] 에 보내는 것 — 사용자가 하려는 것. */ +internal sealed interface LoginIntent : MviIntent { + data class UpdateEmail( + val value: String, + ) : LoginIntent + + data class UpdatePassword( + val value: String, + ) : LoginIntent + + data object SubmitEmailLogin : LoginIntent + + /** + * 소셜 SDK 가 돌려준 토큰으로 로그인한다. 토큰을 받아오는 일은 Activity·CredentialManager + * 의존이라 화면이 하고, ViewModel 에는 문자열만 온다. + */ + data class SubmitKakaoLogin( + val oauthToken: String, + ) : LoginIntent + + data class SubmitGoogleLogin( + val idToken: String, + ) : LoginIntent + + /** + * 소셜 SDK 에서 토큰을 받아오지 못한 실패를 기록한다. + * + * 이 단계는 서버 호출 이전이라 로그인 경로를 타지 않아, 화면이 알려주지 않으면 콘솔에 아무 + * 흔적도 남지 않는다. 사용자 취소는 오류가 아니므로 화면이 걸러서 보낸다. + */ + data class ReportSocialTokenFailure( + val provider: AuthProvider, + val throwable: Throwable, + ) : LoginIntent + + /** 네트워크 실패 팝업의 "다시 시도하기". */ + data object RetryLogin : LoginIntent + + data object DismissNetworkError : LoginIntent + + data object ConsumeLoggedIn : LoginIntent + + data object ConsumeOnboardingStart : LoginIntent + + data object ConsumeError : LoginIntent +} + +/** 상태가 겪은 것. [LoginViewModel] 만 만든다. */ +internal sealed interface LoginReducerEvent : ReducerEvent { + data class EmailChanged( + val value: String, + ) : LoginReducerEvent + + data class PasswordChanged( + val value: String, + ) : LoginReducerEvent + + data object LoginStarted : LoginReducerEvent + + data object LoggedIn : LoginReducerEvent + + /** 소셜 신규 가입자 — 온보딩으로 보낸다. */ + data object OnboardingRequired : LoginReducerEvent + + data object CredentialRejected : LoginReducerEvent + + data object NetworkErrorRaised : LoginReducerEvent + + data class LoginFailed( + val message: UiText, + ) : LoginReducerEvent + + data object NetworkErrorDismissed : LoginReducerEvent + + data object LoggedInConsumed : LoginReducerEvent + + data object OnboardingStartConsumed : LoginReducerEvent + + data object ErrorConsumed : LoginReducerEvent +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginEntry.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginEntry.kt deleted file mode 100644 index 8bed7f7b29..0000000000 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginEntry.kt +++ /dev/null @@ -1,176 +0,0 @@ -package com.afternote.feature.onboarding.presentation.login - -import android.app.Activity -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.stringResource -import androidx.credentials.CredentialManager -import androidx.credentials.exceptions.NoCredentialException -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.afternote.core.domain.error.CoreAuthFailure -import com.afternote.core.ui.asString -import com.afternote.core.ui.findActivity -import com.afternote.feature.onboarding.presentation.BuildConfig -import com.afternote.feature.onboarding.presentation.R -import com.afternote.feature.onboarding.presentation.displayMessageResOrFallback -import com.afternote.feature.onboarding.presentation.login.social.requestGoogleIdToken -import com.afternote.feature.onboarding.presentation.login.social.requestKakaoAccessToken -import com.afternote.feature.onboarding.presentation.reporting.AuthProvider -import kotlinx.coroutines.launch - -/** - * 로그인 Entry. - * - * ViewModel 주입·이벤트 수집을 전담하고, Screen에 순수 상태만 전달합니다. - * 소셜 로그인 SDK(카카오) 및 Credential Manager(구글)는 Activity/Context에 - * 강하게 의존하므로 UI 레이어(Entry)에서 처리한 뒤 순수 토큰 문자열만 - * ViewModel로 전달하여 ViewModel과 Data 레이어의 플랫폼 독립성을 보장합니다. - */ -@Composable -fun LoginEntry( - onLoginSuccess: () -> Unit, - onNewUserOnboarding: () -> Unit, - onSignUpClick: () -> Unit, - onFindAccountClick: () -> Unit, - onBackClick: () -> Unit, - modifier: Modifier = Modifier, - viewModel: LoginViewModel = hiltViewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val focusManager = LocalFocusManager.current - val snackbarHostState = remember { SnackbarHostState() } - val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - val credentialManager = remember(context) { CredentialManager.create(context) } - - // 코루틴 콜백 안에서 여는 문구는 `LocalResources` 로 조회한다 — `LocalContext.current` 경유 - // `getString` 은 로케일·구성이 바뀌어도 옛 문자열을 물고, lint 가 막는다 - // (LocalContextGetResourceValueCall). 여기 `context` 는 Activity·Credential Manager 용이다. - val resources = LocalResources.current - - val googleNoCredentialsMessage = stringResource(R.string.onboarding_login_google_no_credentials) - val screenUnavailableMessage = stringResource(R.string.onboarding_login_screen_unavailable) - - val showErrorSnackbar: (String) -> Unit = { message -> - coroutineScope.launch { - snackbarHostState.showSnackbar( - message = message, - duration = SnackbarDuration.Short, - ) - } - } - - val withClearFocus: (() -> Unit) -> Unit = { action -> - focusManager.clearFocus() - action() - } - - LaunchedEffect(uiState.isLoggedIn) { - if (uiState.isLoggedIn) { - onLoginSuccess() - viewModel.onLoggedInConsumed() - } - } - LaunchedEffect(uiState.shouldStartOnboarding) { - if (uiState.shouldStartOnboarding) { - onNewUserOnboarding() - viewModel.onOnboardingStartConsumed() - } - } - val pendingErrorMessage = uiState.errorMessage?.asString() - LaunchedEffect(pendingErrorMessage) { - if (pendingErrorMessage != null) { - showErrorSnackbar(pendingErrorMessage) - viewModel.onErrorConsumed() - } - } - - LoginScreen( - initialEmail = uiState.email, - initialPassword = uiState.password, - onEmailChange = viewModel::updateEmail, - onPasswordChange = viewModel::updatePassword, - onLoginClick = { withClearFocus { viewModel.loginWithEmail() } }, - onSignUpClick = { withClearFocus { onSignUpClick() } }, - onFindAccountClick = { withClearFocus { onFindAccountClick() } }, - onKakaoLoginClick = { - withClearFocus { - val activity = context.findActivity() - if (activity != null) { - coroutineScope.launch { - requestKakaoAccessToken(activity) - .onSuccess { oauthToken -> - viewModel.loginWithKakao(oauthToken) - }.onFailure { exception -> - // 카카오 동의 화면·계정 로그인 창을 사용자가 닫은 경우(ClientErrorCause.Cancelled). - // 장애가 아니라 정상적인 이탈이므로 리포팅하지 않는다. - if (exception is CoreAuthFailure.UserCancelledAuth) return@onFailure - viewModel.onSocialTokenRequestFailed(AuthProvider.KAKAO, exception) - showErrorSnackbar( - resources.getString( - exception.displayMessageResOrFallback(R.string.onboarding_login_kakao_failed), - ), - ) - } - } - } else { - showErrorSnackbar(screenUnavailableMessage) - } - } - }, - onGoogleLoginClick = { - withClearFocus { - coroutineScope.launch { - requestGoogleIdToken( - context = context, - credentialManager = credentialManager, - serverClientId = BuildConfig.GOOGLE_WEB_CLIENT_ID, - ).onSuccess { idToken -> - viewModel.loginWithGoogle(idToken) - }.onFailure { exception -> - val message = - when (exception) { - // 계정 선택 시트를 사용자가 닫은 경우(GetCredentialCancellationException, - // 내부 타입 TYPE_USER_CANCELED). 정상적인 이탈이라 리포팅하지 않는다. - is CoreAuthFailure.UserCancelledAuth -> { - return@onFailure - } - - // 반면 이건 취소가 아니라 "쓸 계정이 없어 로그인 불가"라 리포팅 대상이다 — - // 배포본 소셜 로그인 불능을 잡아내려면 이쪽이 신호여야 한다. - is NoCredentialException -> { - googleNoCredentialsMessage - } - - else -> { - resources.getString( - exception.displayMessageResOrFallback(R.string.onboarding_login_google_failed), - ) - } - } - viewModel.onSocialTokenRequestFailed(AuthProvider.GOOGLE, exception) - showErrorSnackbar(message) - } - } - } - }, - onBackClick = { withClearFocus { onBackClick() } }, - snackbarHostState = snackbarHostState, - modifier = modifier, - isLoading = uiState.isLoading, - hasCredentialError = uiState.hasCredentialError, - showNetworkErrorPopup = uiState.showNetworkErrorPopup, - onRetryLogin = viewModel::retryLogin, - onNetworkErrorDismiss = viewModel::onNetworkErrorDismissed, - ) -} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginScreen.kt index 5c9d6ced09..a6cb6fa56a 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginScreen.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginScreen.kt @@ -1,329 +1,169 @@ package com.afternote.feature.onboarding.presentation.login -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost +import android.app.Activity +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.ContentType -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentType -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.AfternoteTextField -import com.afternote.core.ui.button.AfternoteButton -import com.afternote.core.ui.button.AfternoteButtonType -import com.afternote.core.ui.modifierextention.addFocusCleaner -import com.afternote.core.ui.popup.NetworkErrorPopup -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.topbar.DetailTopBar +import androidx.credentials.CredentialManager +import androidx.credentials.exceptions.NoCredentialException +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.domain.error.CoreAuthFailure +import com.afternote.core.ui.asString +import com.afternote.core.ui.findActivity +import com.afternote.core.ui.mvi.ObserveFlag +import com.afternote.core.ui.mvi.ObserveSignal +import com.afternote.feature.onboarding.presentation.BuildConfig import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.displayMessageResOrFallback +import com.afternote.feature.onboarding.presentation.login.social.requestGoogleIdToken +import com.afternote.feature.onboarding.presentation.login.social.requestKakaoAccessToken +import com.afternote.feature.onboarding.presentation.reporting.AuthProvider +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay +import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) +/** + * 로그인 화면 — stateful 층. + * + * ViewModel 주입과 일회성 신호 소비를 전담하고, [LoginContent] 에는 상태와 `onIntent` 만 넘긴다. + * 소셜 로그인 SDK(카카오)와 Credential Manager(구글)는 Activity·Context 에 강하게 의존하므로 + * 여기서 토큰을 받아낸 뒤 문자열만 Intent 로 보낸다 — ViewModel·data 계층의 플랫폼 독립성을 + * 그대로 유지한다. + */ @Composable -fun LoginScreen( - initialEmail: String, - initialPassword: String, - onEmailChange: (String) -> Unit, - onPasswordChange: (String) -> Unit, - onLoginClick: () -> Unit, +internal fun LoginScreen( + onLoginSuccess: () -> Unit, + onNewUserOnboarding: () -> Unit, onSignUpClick: () -> Unit, onFindAccountClick: () -> Unit, - onKakaoLoginClick: () -> Unit, - onGoogleLoginClick: () -> Unit, - onRetryLogin: () -> Unit, - onNetworkErrorDismiss: () -> Unit, onBackClick: () -> Unit, - snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, - isLoading: Boolean = false, - hasCredentialError: Boolean = false, - showNetworkErrorPopup: Boolean = false, + viewModel: LoginViewModel = hiltViewModel(), ) { - val focusManager = LocalFocusManager.current - val emailState = rememberTextFieldState(initialEmail) - val passwordState = rememberTextFieldState(initialPassword) - - LaunchedEffect(emailState) { - snapshotFlow { emailState.text.toString() }.collect(onEmailChange) - } - LaunchedEffect(passwordState) { - snapshotFlow { passwordState.text.toString() }.collect(onPasswordChange) - } - Scaffold( - modifier = modifier, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - topBar = { - DetailTopBar( - title = stringResource(R.string.onboarding_login_top_bar_title), - onBackClick = { - focusManager.clearFocus() - onBackClick() - }, + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val credentialManager = remember(context) { CredentialManager.create(context) } + + // 코루틴 콜백 안에서 여는 문구는 `LocalResources` 로 조회한다 — `LocalContext.current` 경유 + // `getString` 은 로케일·구성이 바뀌어도 옛 문자열을 물고, lint 가 막는다 + // (LocalContextGetResourceValueCall). 여기 `context` 는 Activity·Credential Manager 용이다. + val resources = LocalResources.current + + val googleNoCredentialsMessage = stringResource(R.string.onboarding_login_google_no_credentials) + val screenUnavailableMessage = stringResource(R.string.onboarding_login_screen_unavailable) + + val showErrorSnackbar: (String) -> Unit = { message -> + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = message, + duration = SnackbarDuration.Short, ) - }, - bottomBar = { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 45.dp), - ) { - SocialLoginGroup( - onSignUpClick = onSignUpClick, - onFindAccountClick = onFindAccountClick, - onKakaoLoginClick = onKakaoLoginClick, - onGoogleLoginClick = onGoogleLoginClick, - ) + } + } + + ObserveFlag( + raised = uiState.isLoggedIn, + consumed = LoginIntent.ConsumeLoggedIn, + onIntent = viewModel::onIntent, + onRaised = onLoginSuccess, + ) + ObserveFlag( + raised = uiState.shouldStartOnboarding, + consumed = LoginIntent.ConsumeOnboardingStart, + onIntent = viewModel::onIntent, + onRaised = onNewUserOnboarding, + ) + ObserveSignal( + signal = + uiState.failure + .toDisplay() + .snackbarMessage + ?.asString(), + consumed = LoginIntent.ConsumeError, + onIntent = viewModel::onIntent, + // showErrorSnackbar 가 별도 스코프에 launch 하므로 소비 직후의 재시작이 표출을 끊지 않는다. + onSignal = showErrorSnackbar, + ) + + LoginContent( + state = uiState, + onIntent = viewModel::onIntent, + onSignUpClick = onSignUpClick, + onFindAccountClick = onFindAccountClick, + onKakaoLoginClick = { + val activity = context.findActivity() + if (activity != null) { + coroutineScope.launch { + requestKakaoAccessToken(activity) + .onSuccess { oauthToken -> + viewModel.onIntent(LoginIntent.SubmitKakaoLogin(oauthToken)) + }.onFailure { exception -> + // 카카오 동의 화면·계정 로그인 창을 사용자가 닫은 경우(ClientErrorCause.Cancelled). + // 장애가 아니라 정상적인 이탈이므로 리포팅하지 않는다. + if (exception is CoreAuthFailure.UserCancelledAuth) return@onFailure + viewModel.onIntent( + LoginIntent.ReportSocialTokenFailure(AuthProvider.KAKAO, exception), + ) + showErrorSnackbar( + resources.getString( + exception.displayMessageResOrFallback(R.string.onboarding_login_kakao_failed), + ), + ) + } + } + } else { + showErrorSnackbar(screenUnavailableMessage) } }, - containerColor = Color.Transparent, - ) { innerPadding -> - Column( - modifier = - Modifier - .padding(innerPadding) - .consumeWindowInsets(innerPadding) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .imePadding() - .addFocusCleaner(focusManager) - .padding(horizontal = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.height(39.dp)) + onGoogleLoginClick = { + coroutineScope.launch { + requestGoogleIdToken( + context = context, + credentialManager = credentialManager, + serverClientId = BuildConfig.GOOGLE_WEB_CLIENT_ID, + ).onSuccess { idToken -> + viewModel.onIntent(LoginIntent.SubmitGoogleLogin(idToken)) + }.onFailure { exception -> + val message = + when (exception) { + // 계정 선택 시트를 사용자가 닫은 경우(GetCredentialCancellationException, + // 내부 타입 TYPE_USER_CANCELED). 정상적인 이탈이라 리포팅하지 않는다. + is CoreAuthFailure.UserCancelledAuth -> { + return@onFailure + } - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // 아이디 (이메일) 입력 필드 - AfternoteTextField( - state = emailState, - modifier = - Modifier.semantics { contentType = ContentType.Username }, - placeholder = stringResource(R.string.onboarding_login_email_label), - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - ) + // 반면 이건 취소가 아니라 "쓸 계정이 없어 로그인 불가"라 리포팅 대상이다 — + // 배포본 소셜 로그인 불능을 잡아내려면 이쪽이 신호여야 한다. + is NoCredentialException -> { + googleNoCredentialsMessage + } - // 비밀번호 필드와 인라인 안내(시안 3628:23437)를 6dp 로 묶는다 — 바깥 8dp 와 달라 - // 별도 Column. 문구 스타일은 아이디 찾기 인라인(FindIdScreen)과 동일 토큰. - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - AfternoteTextField( - state = passwordState, - modifier = - Modifier.semantics { contentType = ContentType.Password }, - placeholder = stringResource(R.string.onboarding_login_password_label), - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - onImeAction = { - if (!isLoading) { - focusManager.clearFocus() - onLoginClick() + else -> { + resources.getString( + exception.displayMessageResOrFallback(R.string.onboarding_login_google_failed), + ) } - }, - isError = hasCredentialError, + } + viewModel.onIntent( + LoginIntent.ReportSocialTokenFailure(AuthProvider.GOOGLE, exception), ) - - if (hasCredentialError) { - Text( - text = stringResource(R.string.onboarding_login_invalid_credentials), - style = AfternoteDesign.typography.captionLargeB, - color = AfternoteDesign.colors.error, - ) - } + showErrorSnackbar(message) } } - - Spacer(modifier = Modifier.height(24.dp)) - - // 로그인 버튼 - AfternoteButton( - text = stringResource(R.string.onboarding_login_button), - onClick = { - if (!isLoading) { - focusManager.clearFocus() - onLoginClick() - } - }, - modifier = - Modifier - .fillMaxWidth() - .height(48.dp), - type = AfternoteButtonType.Default, - isLoading = isLoading, - ) - } - } - - if (showNetworkErrorPopup) { - NetworkErrorPopup( - onRetry = onRetryLogin, - onDismiss = onNetworkErrorDismiss, - ) - } -} - -/** - * 로그인 화면 하단 소셜 로그인 그룹. - * - * `LoginScreen`에서만 쓰이므로 외부 파일로 분리하지 않고 `private`로 캡슐화한다. - */ -@Composable -private fun SocialLoginGroup( - onSignUpClick: () -> Unit, - onFindAccountClick: () -> Unit, - onKakaoLoginClick: () -> Unit, - onGoogleLoginClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val focusManager = LocalFocusManager.current - Column( - modifier = modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // 간편 회원가입하기 버튼 - AfternoteButton( - text = stringResource(R.string.onboarding_login_signup_simple), - type = AfternoteButtonType.Plain, - onClick = { - focusManager.clearFocus() - onSignUpClick() - }, - modifier = Modifier.fillMaxWidth(), - ) - - Spacer(modifier = Modifier.height(12.dp)) - - // 카카오 로그인 버튼 — 배경·심볼만 브랜드 고정색, 프레임 규격은 구글·이메일 버튼과 동일. - SocialLoginButton( - text = stringResource(R.string.onboarding_login_kakao), - iconPainter = painterResource(R.drawable.onboarding_ic_kakao_symbol), - containerColor = KakaoContainerColor, - contentColor = KakaoContentColor, - onClick = { - focusManager.clearFocus() - onKakaoLoginClick() - }, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - // 구글 로그인 버튼 — 색은 회원가입 버튼(AfternoteButtonType.Plain)과 같은 gray2/gray9/gray3. - SocialLoginButton( - text = stringResource(R.string.onboarding_login_google), - iconPainter = painterResource(com.afternote.core.ui.R.drawable.core_ui_img_google_logo), - containerColor = AfternoteDesign.colors.gray2, - contentColor = AfternoteDesign.colors.gray9, - border = BorderStroke(1.dp, AfternoteDesign.colors.gray3), - onClick = { - focusManager.clearFocus() - onGoogleLoginClick() - }, - ) - - Spacer(modifier = Modifier.height(24.dp)) - - // 아이디/비밀번호 찾기 - Text( - text = stringResource(R.string.onboarding_login_find_account), - style = - AfternoteDesign.typography.captionLargeR.copy( - color = AfternoteDesign.colors.gray6, - textDecoration = TextDecoration.Underline, - ), - modifier = - Modifier.clickable { - focusManager.clearFocus() - onFindAccountClick() - }, - ) - } -} - -/** 카카오 로그인 버튼 배경 — 카카오 브랜드 고정색이라 앱 팔레트 토큰으로 근사하지 않는다. */ -private val KakaoContainerColor = Color(0xFFFEE500) - -/** 카카오 로그인 버튼 라벨 색 — 심볼 벡터(onboarding_ic_kakao_symbol)와 같은 #212121·알파 90.2%. */ -private val KakaoContentColor = Color(0xE6212121) - -/** - * 소셜 로그인 버튼 공통 프레임 — 카카오·구글이 색과 아이콘만 다르고 같은 규격을 쓰도록 강제한다. - * - * 시안의 두 버튼은 출처가 달라(카카오 공식 에셋 이미지 vs 별도 컴포넌트) 셰이프·타이포·아이콘 - * 배치가 제각각이었고, 통일 판단은 FE 재량으로 확정된 건이다(#775). 규격은 이 화면의 다른 - * 버튼([AfternoteButton])과 동일한 6dp 셰이프·48dp 높이·captionLargeB 라벨로 수렴시킨다. - * 아이콘은 장식이라 contentDescription 없이 라벨 [text] 가 접근성 이름을 담당한다. - */ -@Composable -private fun SocialLoginButton( - text: String, - iconPainter: Painter, - containerColor: Color, - contentColor: Color, - onClick: () -> Unit, - modifier: Modifier = Modifier, - border: BorderStroke? = null, -) { - Surface( - onClick = onClick, - modifier = - modifier - .fillMaxWidth() - .height(48.dp), - shape = RoundedCornerShape(6.dp), - color = containerColor, - contentColor = contentColor, - border = border, - ) { - Row( - modifier = Modifier.fillMaxSize(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Image( - painter = iconPainter, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = text, - style = AfternoteDesign.typography.captionLargeB, - ) - } - } + }, + onBackClick = onBackClick, + snackbarHostState = snackbarHostState, + modifier = modifier, + ) } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginUiState.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginUiState.kt index 2cf8051992..9c13c564bc 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginUiState.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginUiState.kt @@ -1,17 +1,18 @@ package com.afternote.feature.onboarding.presentation.login -import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.UiState +import com.afternote.feature.onboarding.presentation.OnboardingFailure /** * 로그인 화면 단일 UI 상태. * - * 입력값(email/password) · 진행 플래그(isLoading) · 단발성 신호(isLoggedIn · shouldStartOnboarding · errorMessage) + * 입력값(email/password) · 진행 플래그(isLoading) · 단발성 신호(isLoggedIn · shouldStartOnboarding · failure) * 를 한 인스턴스로 묶어 reducer (`_uiState.update { copy(...) }`) 로 갱신한다. * * 단발성 신호는 UI 가 소비 후 [LoginViewModel.onLoggedInConsumed] / [LoginViewModel.onOnboardingStartConsumed] / * [LoginViewModel.onErrorConsumed] 호출로 reset. */ -data class LoginUiState( +internal data class LoginUiState( val email: String = "", val password: String = "", val isLoading: Boolean = false, @@ -19,17 +20,6 @@ data class LoginUiState( val isLoggedIn: Boolean = false, /** 소셜 신규 가입자 신호 — UI 가 온보딩(Welcome) nav 후 [LoginViewModel.onOnboardingStartConsumed] 로 reset. */ val shouldStartOnboarding: Boolean = false, - /** - * 자격 거절 인라인(시안 `3628:23437`). 소비형 신호가 아니라 상태다 — 입력이 바뀌면 그 자격의 - * 판정이 아니게 되므로 update 콜백이 해제한다. - */ - val hasCredentialError: Boolean = false, - /** 전송 계층 실패의 재시도 팝업(시안 `3628:23575`). retry·dismiss 로 해제. */ - val showNetworkErrorPopup: Boolean = false, - /** - * 위 두 갈래 밖의 실패(소셜 거절·미분류)는 스낵바 — 인라인을 걸 필드도, 재시도로 풀릴 보장도 - * 없다. 문구는 리소스 고정이라 서버 5xx 본문·역직렬화 원문이 화면에 닿지 않는다. - * UI 가 표시 후 [LoginViewModel.onErrorConsumed] 로 reset. - */ - val errorMessage: UiText? = null, -) + /** 실패 한 건의 사유. 입력 수정·재시도·화면 소비로 해제한다. */ + val failure: OnboardingFailure? = null, +) : UiState diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginViewModel.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginViewModel.kt index a7bcfd7cd5..02b4d71f0b 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginViewModel.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginViewModel.kt @@ -1,120 +1,187 @@ package com.afternote.feature.onboarding.presentation.login -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.usecase.auth.LoginType import com.afternote.core.domain.usecase.auth.LoginUseCase +import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.MviViewModel +import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.afternote.feature.onboarding.presentation.R import com.afternote.feature.onboarding.presentation.reporting.AuthFailureStage import com.afternote.feature.onboarding.presentation.reporting.AuthProvider import com.afternote.feature.onboarding.presentation.reporting.recordAuthFailure import com.afternote.feature.onboarding.presentation.toDisplayMessage import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException +/** + * 로그인 ViewModel. + * + * 전이는 [reduce] 한 곳이고 진입점은 [onIntent] 하나다 (#1802). 기준은 `docs/convention/mvi.md`. + */ @HiltViewModel -class LoginViewModel +internal class LoginViewModel @Inject constructor( private val loginUseCase: LoginUseCase, private val errorReporter: ErrorReporter, - ) : ViewModel() { - private val _uiState = MutableStateFlow(LoginUiState()) - val uiState = _uiState.asStateFlow() - + ) : MviViewModel(LoginUiState()) { /** 네트워크 실패 팝업의 "다시 시도하기" 가 재실행할 마지막 시도. */ private var lastAttempt: LoginType? = null - fun updateEmail(value: String) { - // 입력이 바뀌면 앞선 자격 거절은 더 이상 이 입력의 판정이 아니다. - _uiState.update { it.copy(email = value, hasCredentialError = false) } - } + override fun onIntent(intent: LoginIntent) { + when (intent) { + is LoginIntent.UpdateEmail -> { + dispatch(LoginReducerEvent.EmailChanged(intent.value)) + } - fun updatePassword(value: String) { - _uiState.update { it.copy(password = value, hasCredentialError = false) } - } + is LoginIntent.UpdatePassword -> { + dispatch(LoginReducerEvent.PasswordChanged(intent.value)) + } - fun loginWithEmail() { - val state = _uiState.value - login( - LoginType.Email( - email = state.email, - password = state.password, - ), - ) - } + LoginIntent.SubmitEmailLogin -> { + login( + LoginType.Email( + email = currentState.email, + password = currentState.password, + ), + ) + } - fun loginWithKakao(oauthToken: String) { - login(LoginType.Kakao(oauthToken)) - } + is LoginIntent.SubmitKakaoLogin -> { + login(LoginType.Kakao(intent.oauthToken)) + } - fun loginWithGoogle(idToken: String) { - login(LoginType.Google(idToken)) - } + is LoginIntent.SubmitGoogleLogin -> { + login(LoginType.Google(intent.idToken)) + } - /** - * 소셜 SDK 에서 토큰을 받아오지 못한 실패를 기록한다. - * - * 이 단계는 서버 호출 이전이라 [login] 경로를 타지 않아, UI 가 직접 알려주지 않으면 - * 콘솔에 아무 흔적도 남지 않는다. 사용자 취소는 오류가 아니므로 UI 가 걸러서 호출한다. - */ - fun onSocialTokenRequestFailed( - provider: AuthProvider, - throwable: Throwable, - ) { - errorReporter.recordAuthFailure( - stage = AuthFailureStage.SOCIAL_TOKEN_REQUEST, - throwable = throwable, - provider = provider, - ) - } + is LoginIntent.ReportSocialTokenFailure -> { + errorReporter.recordAuthFailure( + stage = AuthFailureStage.SOCIAL_TOKEN_REQUEST, + throwable = intent.throwable, + provider = intent.provider, + ) + } - /** UI 가 [LoginUiState.isLoggedIn] 소비 후 reset. */ - fun onLoggedInConsumed() { - _uiState.update { it.copy(isLoggedIn = false) } - } + LoginIntent.RetryLogin -> { + retryLogin() + } - /** UI 가 [LoginUiState.shouldStartOnboarding] 소비 후 reset. */ - fun onOnboardingStartConsumed() { - _uiState.update { it.copy(shouldStartOnboarding = false) } - } + LoginIntent.DismissNetworkError -> { + dispatch(LoginReducerEvent.NetworkErrorDismissed) + } - /** UI 가 [LoginUiState.errorMessage] 소비 (snackbar 표시) 후 reset. */ - fun onErrorConsumed() { - _uiState.update { it.copy(errorMessage = null) } - } + LoginIntent.ConsumeLoggedIn -> { + dispatch(LoginReducerEvent.LoggedInConsumed) + } - /** 네트워크 실패 팝업의 "다시 시도하기" — 마지막 시도를 같은 자격으로 재실행한다. */ - fun retryLogin() { - _uiState.update { it.copy(showNetworkErrorPopup = false) } - lastAttempt?.let(::login) + LoginIntent.ConsumeOnboardingStart -> { + dispatch(LoginReducerEvent.OnboardingStartConsumed) + } + + LoginIntent.ConsumeError -> { + dispatch(LoginReducerEvent.ErrorConsumed) + } + } } - fun onNetworkErrorDismissed() { - _uiState.update { it.copy(showNetworkErrorPopup = false) } + override fun reduce( + state: LoginUiState, + event: LoginReducerEvent, + ): LoginUiState = + when (event) { + // 입력이 바뀌면 앞선 자격 거절은 더 이상 이 입력의 판정이 아니다. + is LoginReducerEvent.EmailChanged -> { + state.copy( + email = event.value, + failure = + state.failure.takeIf { + state.email == + event.value + }, + ) + } + + is LoginReducerEvent.PasswordChanged -> { + state.copy( + password = event.value, + failure = + state.failure.takeIf { + state.password == + event.value + }, + ) + } + + LoginReducerEvent.LoginStarted -> { + state.copy(isLoading = true, failure = null) + } + + LoginReducerEvent.LoggedIn -> { + state.copy(isLoading = false, isLoggedIn = true) + } + + LoginReducerEvent.OnboardingRequired -> { + state.copy(isLoading = false, shouldStartOnboarding = true) + } + + LoginReducerEvent.CredentialRejected -> { + state.copy(isLoading = false, failure = OnboardingFailure.CredentialsRejected) + } + + LoginReducerEvent.NetworkErrorRaised -> { + state.copy(isLoading = false, failure = OnboardingFailure.LoginNetworkUnavailable) + } + + is LoginReducerEvent.LoginFailed -> { + state.copy(isLoading = false, failure = OnboardingFailure.RequestFailed(event.message)) + } + + LoginReducerEvent.NetworkErrorDismissed -> { + state.copy( + failure = + state.failure.takeUnless { + it == + OnboardingFailure.LoginNetworkUnavailable + }, + ) + } + + LoginReducerEvent.LoggedInConsumed -> { + state.copy(isLoggedIn = false) + } + + LoginReducerEvent.OnboardingStartConsumed -> { + state.copy(shouldStartOnboarding = false) + } + + LoginReducerEvent.ErrorConsumed -> { + state.copy(failure = state.failure.takeUnless { it is OnboardingFailure.RequestFailed }) + } + } + + /** 마지막 시도를 같은 자격으로 재실행한다. */ + private fun retryLogin() { + dispatch(LoginReducerEvent.NetworkErrorDismissed) + lastAttempt?.let(::login) } private fun login(loginType: LoginType) { - if (_uiState.value.isLoading) return + if (currentState.isLoading) return lastAttempt = loginType viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, hasCredentialError = false) } - val result = loginUseCase(loginType = loginType) - result + dispatch(LoginReducerEvent.LoginStarted) + loginUseCase(loginType = loginType) .onSuccess { isNewUser -> - _uiState.update { - if (isNewUser) { - it.copy(isLoading = false, shouldStartOnboarding = true) - } else { - it.copy(isLoading = false, isLoggedIn = true) - } + if (isNewUser) { + dispatch(LoginReducerEvent.OnboardingRequired) + } else { + dispatch(LoginReducerEvent.LoggedIn) } }.onFailure { exception -> // 화면 이탈로 스코프가 취소된 것을 장애로 기록하거나 실패 UI 로 소비하지 않는다. @@ -130,29 +197,51 @@ class LoginViewModel provider = loginType.authProvider, ) } - _uiState.update { - // 루트로 좁혀 `when` 을 exhaustive 하게 만든다 — 사유가 늘면 여기가 컴파일 에러로 - // 잡힌다. `else` 로 뭉개 두면 인라인·팝업으로 갈라야 할 새 사유가 조용히 문구 하나로 - // 흘러간다. 사유를 확인하지 못한 실패(null)는 계속 문구 매핑에 맡긴다. + // 루트로 좁혀 `when` 을 exhaustive 하게 만든다 — 사유가 늘면 여기가 컴파일 에러로 + // 잡힌다. `else` 로 뭉개 두면 인라인·팝업으로 갈라야 할 새 사유가 조용히 문구 하나로 + // 흘러간다. 사유를 확인하지 못한 실패(null)는 계속 문구 매핑에 맡긴다. + val event = when (exception as? CoreAuthFailure) { is CoreAuthFailure.InvalidLoginCredentials -> { - it.copy(isLoading = false, hasCredentialError = true) + LoginReducerEvent.CredentialRejected } is CoreAuthFailure.NetworkUnavailable -> { - it.copy(isLoading = false, showNetworkErrorPopup = true) + LoginReducerEvent.NetworkErrorRaised } + // 소셜로 가입해 로컬 비밀번호가 없는 계정(서버 1702 — BE `AuthService.login`). + // 자격 거절과 달리 입력을 고쳐서 될 일이 아니라 필드 인라인을 걸지 않는다. + // 문구를 여기서 직접 넣는 것은 사유별 매핑(`displayMessageResOrNull`)이 이 + // 타입에 비밀번호 찾기 문구를 물려 두었기 때문이다 — 화면마다 안내가 다르다. + is CoreAuthFailure.SocialSignUpAccount -> { + LoginReducerEvent.LoginFailed(UiText.Resource(R.string.onboarding_login_social_signup_account)) + } + + // 로그인 경로로 실제 오는 사유는 `SocialLoginRejected`(서버 1208·1209)와, 사유를 + // 확인하지 못한 실패(null)뿐이다 — `AuthRepositoryImpl.mapLoginFailure` 가 만드는 + // 나머지 셋(1201·1202 · 1702 · 네트워크)은 위에서 이미 갈렸다. + // + // 아래 넷은 여기까지 오지 않는다. `EmailAlreadyRegistered`·`EmailVerification`· + // `PasswordUnchanged` 는 계정 API 쪽 사유고(`AccountRepositoryImpl.mapAccountFailure`), + // BE 는 1206 을 비밀번호 변경·재설정(`AuthService.passwordChange`·`findPassword`) + // 에서만 던진다. `UserCancelledAuth` 는 소셜 SDK 를 부르는 `LoginScreen` 가 그 자리에서 + // 걸러낸다 — `LoginUseCase` 는 토큰을 받은 뒤 서버만 친다. `else` 로 뭉개지 않고 + // 열거해 두는 건 사유가 늘 때 컴파일러가 여기를 잡게 하려는 것이고, 도달하더라도 + // 문구 매핑이 받아 주므로 안내가 비지 않는다. is CoreAuthFailure.EmailAlreadyRegistered, is CoreAuthFailure.EmailVerification, is CoreAuthFailure.SocialLoginRejected, is CoreAuthFailure.UserCancelledAuth, + is CoreAuthFailure.PasswordUnchanged, null, -> { - it.copy(isLoading = false, errorMessage = exception.toDisplayMessage(R.string.onboarding_login_failed)) + LoginReducerEvent.LoginFailed( + exception.toDisplayMessage(R.string.onboarding_login_failed), + ) } } - } + dispatch(event) } } } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingExternalActions.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingExternalActions.kt new file mode 100644 index 0000000000..06c69bd439 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingExternalActions.kt @@ -0,0 +1,16 @@ +package com.afternote.feature.onboarding.presentation.navigation + +/** + * 온보딩 로컬 스택이 **스스로 갈 수 없는 곳**만 앱 셸에 남긴 이동. + * + * 그래프 안의 push/pop 은 [OnboardingNavHost] 가 로컬 백스택으로 직접 처리하고, 다른 소관 + * 그래프로 넘어가는 두 가지만 셸이 소유한다. 스택 바닥에서의 back 은 이동이 아니라 경계라 + * [com.afternote.core.ui.navigation.FeatureStackBoundary] 가 갖는다. + */ +public interface OnboardingExternalActions { + /** 로그인·회원가입 성공 → 온보딩을 통째로 비우고 홈 진입. */ + public fun replaceOnboardingWithHome() + + /** Welcome 의 "전달 받은 기록 확인하기" → 수신자 흐름(받은 기록함) 진입. */ + public fun navigateToReceivedRecords() +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingLocalNavActions.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingLocalNavActions.kt new file mode 100644 index 0000000000..0914b82850 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingLocalNavActions.kt @@ -0,0 +1,51 @@ +package com.afternote.feature.onboarding.presentation.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.afternote.core.ui.navigation.FeatureStackBoundary +import com.afternote.core.ui.navigation.popOrExit +import com.afternote.core.ui.navigation.pushSingleTop +import com.afternote.core.ui.navigation.replaceAllWith + +/** + * 온보딩 화면 콜백을 로컬 백스택 조작으로 잇는다. + * + * 컴포저블이 아니라 평범한 클래스다 — 백스택 «모양» 은 컴포지션 없이 그대로 잴 수 있어야 + * 회귀 기준(#1601)을 JVM 테스트로 고정할 수 있기 때문이다. + */ +internal class OnboardingLocalNavActions( + private val backStack: NavBackStack, + private val boundary: FeatureStackBoundary, + private val externalActions: OnboardingExternalActions, +) : OnboardingNavActions { + override fun replaceOnboardingWithHome(): Unit = externalActions.replaceOnboardingWithHome() + + override fun navigateToReceivedRecords(): Unit = externalActions.navigateToReceivedRecords() + + /** 소셜 신규 가입자 — Login(과 그 아래)을 비우고 Welcome 하나로 수렴한다. */ + override fun replaceLoginWithWelcome(): Unit = backStack.replaceAllWith(OnboardingRoute.WelcomeRoute) + + /** Login 을 SignUp 으로 교체 — 뒤로가기는 Login 이 아니라 그 이전(Welcome)으로. */ + override fun replaceLoginWithSignUp() { + backStack.remove(OnboardingRoute.LoginRoute) + backStack.add(OnboardingRoute.SignUpRoute) + } + + override fun navigateToSignUp(): Unit = backStack.pushSingleTop(OnboardingRoute.SignUpRoute) + + override fun navigateToLogin(): Unit = backStack.pushSingleTop(OnboardingRoute.LoginRoute) + + override fun navigateToFindId(): Unit = backStack.pushSingleTop(OnboardingRoute.FindIdRoute) + + override fun proceedToSignUpResidentNumber(): Unit = backStack.pushSingleTop(OnboardingRoute.SignUpResidentNumberRoute) + + override fun proceedToSignUpPassword(): Unit = backStack.pushSingleTop(OnboardingRoute.SignUpPasswordRoute) + + override fun proceedToTerms(): Unit = backStack.pushSingleTop(OnboardingRoute.TermsRoute) + + override fun proceedToProfile(): Unit = backStack.pushSingleTop(OnboardingRoute.ProfileRoute) + + override fun navigateToTermsDetail(): Unit = backStack.pushSingleTop(OnboardingRoute.TermsDetailRoute) + + override fun popBack(): Unit = backStack.popOrExit(boundary) +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingNavGraph.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingNavGraph.kt deleted file mode 100644 index d1a09fad55..0000000000 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingNavGraph.kt +++ /dev/null @@ -1,303 +0,0 @@ -package com.afternote.feature.onboarding.presentation.navigation - -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.res.stringResource -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.navigation -import com.afternote.core.ui.Route -import com.afternote.core.ui.asString -import com.afternote.feature.onboarding.presentation.OnboardingProfileEntry -import com.afternote.feature.onboarding.presentation.R -import com.afternote.feature.onboarding.presentation.WelcomeScreen -import com.afternote.feature.onboarding.presentation.findaccount.FindIdScreen -import com.afternote.feature.onboarding.presentation.findaccount.FindIdUiState -import com.afternote.feature.onboarding.presentation.findaccount.FindIdViewModel -import com.afternote.feature.onboarding.presentation.login.LoginEntry -import com.afternote.feature.onboarding.presentation.signup.SignUpPasswordScreen -import com.afternote.feature.onboarding.presentation.signup.SignUpResidentNumberScreen -import com.afternote.feature.onboarding.presentation.signup.SignUpScreen -import com.afternote.feature.onboarding.presentation.signup.SignUpUiState -import com.afternote.feature.onboarding.presentation.signup.SignUpViewModel -import com.afternote.feature.onboarding.presentation.terms.OnboardingTermsScreen -import com.afternote.feature.onboarding.presentation.terms.TermsDetailScreen - -/** - * 온보딩 피처의 네비게이션 그래프. - * - * 플로우: Welcome -> Login / SignUp(1~4단계) -> Profile -> 완료(Home 이동) - */ -fun NavGraphBuilder.onboardingNavGraph( - /** [Route.Onboarding] 그래프 엔트리 — SignUp/Profile 스코프 ViewModel 바인딩에 사용 */ - graphScopedParentEntry: () -> NavBackStackEntry, - actions: OnboardingNavActions, -) { - navigation(startDestination = OnboardingRoute.WelcomeRoute) { - // ── Welcome ── - composable { - WelcomeScreen( - // #381: "시작하기" 도 "로그인하기" 와 동일하게 로그인 화면으로 이동 (PM 요청). - // 신규 회원가입 진입은 로그인 화면 내 회원가입 링크(replaceLoginWithSignUp)로 유지. - onStartClick = actions::navigateToLogin, - onCheckRecordsClick = actions::navigateToReceivedRecords, - onLoginClick = actions::navigateToLogin, - ) - } - - // ── Login ── - composable { - LoginEntry( - onLoginSuccess = actions::replaceOnboardingWithHome, - onNewUserOnboarding = actions::replaceLoginWithWelcome, - onSignUpClick = actions::replaceLoginWithSignUp, - onFindAccountClick = actions::navigateToFindId, - onBackClick = actions::popBack, - ) - } - - // ── 아이디 찾기 (인증 → 결과) ── - composable { - val viewModel = graphScopedFindIdViewModel(graphScopedParentEntry) - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = rememberFindIdEventHost(viewModel, uiState) - - FindIdScreen( - initialEmail = uiState.email, - initialCertificateCode = uiState.certificateCode, - isSendingCode = uiState.isSendingCode, - isVerificationSent = uiState.isVerificationSent, - isSendCodeEnabled = uiState.isSendCodeEnabled, - isVerifyEnabled = uiState.isVerifyEnabled, - isNextEnabled = uiState.isNextEnabled, - resendCooldownSeconds = uiState.resendCooldownSeconds, - hasVerificationError = uiState.hasVerificationError, - snackbarHostState = snackbarHostState, - onEmailChange = viewModel::updateEmail, - onCertificateCodeChange = viewModel::updateCertificateCode, - onRequestCode = viewModel::requestVerificationCode, - onVerifyCode = viewModel::verifyCode, - // 결과 화면 연결은 아이디 찾기 존치 결정(#456 코멘트) 대기 — 확정 전까지 미배선. - onNextClick = {}, - onBackClick = actions::popBack, - ) - } - - // ── SignUp Step 1: 이메일 & 인증번호 ── - composable { - val signUpViewModel = graphScopedSignUpViewModel(graphScopedParentEntry) - val uiState by signUpViewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = - rememberSignUpEventHost( - viewModel = signUpViewModel, - onNavigateToResidentNumber = actions::proceedToSignUpResidentNumber, - ) - - SignUpScreen( - initialEmail = uiState.email, - initialVerificationCode = uiState.verificationCode, - isVerificationSent = uiState.isVerificationSent, - isSendingCode = uiState.isSendingCode, - isEmailFormatValid = uiState.isEmailFormatValid, - resendCooldownSeconds = uiState.resendCooldownSeconds, - hasVerificationError = uiState.hasVerificationError, - isNextEnabled = uiState.isStep1NextEnabled, - snackbarHostState = snackbarHostState, - onEmailChange = signUpViewModel::updateEmail, - onVerificationCodeChange = signUpViewModel::updateVerificationCode, - onRequestVerification = signUpViewModel::requestVerification, - onNextClick = signUpViewModel::verifyEmailAndProceed, - onBackClick = actions::popBack, - ) - } - - // ── SignUp Step 2: 주민등록번호 ── - composable { - val signUpViewModel = graphScopedSignUpViewModel(graphScopedParentEntry) - val uiState by signUpViewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = rememberSignUpEventHost(signUpViewModel) - - SignUpResidentNumberScreen( - initialFrontNumber = uiState.residentFrontNumber, - initialBackNumber = uiState.residentBackNumber, - isNextEnabled = uiState.isStep2NextEnabled, - snackbarHostState = snackbarHostState, - onFrontNumberChange = signUpViewModel::updateResidentFrontNumber, - onBackNumberChange = signUpViewModel::updateResidentBackNumber, - onNextClick = actions::proceedToSignUpPassword, - onBackClick = actions::popBack, - ) - } - - // ── SignUp Step 3: 비밀번호 설정 ── - composable { - val signUpViewModel = graphScopedSignUpViewModel(graphScopedParentEntry) - val uiState by signUpViewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = rememberSignUpEventHost(signUpViewModel) - - SignUpPasswordScreen( - initialPassword = uiState.signUpPassword, - initialPasswordConfirm = uiState.signUpPasswordConfirm, - isPasswordRuleSatisfied = uiState.isPasswordRuleSatisfied, - isNextEnabled = uiState.isStep3NextEnabled, - snackbarHostState = snackbarHostState, - onPasswordChange = signUpViewModel::updateSignUpPassword, - onPasswordConfirmChange = signUpViewModel::updateSignUpPasswordConfirm, - onNextClick = actions::proceedToTerms, - onBackClick = actions::popBack, - ) - } - - // ── SignUp Step 4: 약관 동의 ── - composable { - val signUpViewModel = graphScopedSignUpViewModel(graphScopedParentEntry) - val uiState by signUpViewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = rememberSignUpEventHost(signUpViewModel) - - OnboardingTermsScreen( - termsState = uiState.termsState, - isNextEnabled = uiState.isStep4NextEnabled, - snackbarHostState = snackbarHostState, - onTermsToggle = signUpViewModel::toggleTermsAgreed, - onPrivacyToggle = signUpViewModel::togglePrivacyAgreed, - onMarketingToggle = signUpViewModel::toggleMarketingAgreed, - onToggleAll = signUpViewModel::toggleAllTerms, - onViewTermsClick = { _ -> actions.navigateToTermsDetail() }, - onNextClick = actions::proceedToProfile, - onBackClick = actions::popBack, - ) - } - - // ── 약관 상세 ── - composable { - TermsDetailScreen( - title = "", - onBackClick = actions::popBack, - onNextClick = actions::popBack, - ) - } - - // ── Profile 설정 ── - composable { - val signUpViewModel = graphScopedSignUpViewModel(graphScopedParentEntry) - - OnboardingProfileEntry( - viewModel = signUpViewModel, - onOnboardingComplete = actions::replaceOnboardingWithHome, - onBackClick = actions::popBack, - ) - } - } -} - -/** - * `Route.Onboarding` 그래프 스코프에 묶인 [SignUpViewModel]을 가져옵니다. - * SignUp Step 1~4와 Profile 화면이 동일한 인스턴스를 공유합니다. - */ -@Composable -private fun graphScopedSignUpViewModel(graphScopedParentEntry: () -> NavBackStackEntry): SignUpViewModel { - val parentEntry = remember { graphScopedParentEntry() } - return hiltViewModel(parentEntry) -} - -/** - * `Route.Onboarding` 그래프 스코프에 묶인 [FindIdViewModel]을 가져옵니다. - * - * [NavBackStackEntry] 는 ViewModelStoreOwner 라서, [hiltViewModel] 에 부모 그래프 엔트리를 넘기면 - * VM 을 "만드는" 게 아니라 **그 엔트리의 store 에서 조회**한다 — 최초 호출만 생성이고, 같은 엔트리를 - * 넘기는 모든 화면이 동일 인스턴스를 받는다(인자를 생략하면 현재 화면 엔트리 스코프라 화면 pop 과 함께 - * 소멸). 수명은 그래프가 백스택에서 내려갈 때까지: 화면을 나갔다 재진입해도 상태가 남는 것은 - * [graphScopedSignUpViewModel] 과 동일 특성이다. 지금은 인증 화면 단독 소비지만 결과 화면(#474)· - * 비밀번호 찾기(#457)가 [FindIdUiState.foundAccount] 등을 이어받는 통로로 이 스코프를 쓴다. - * 람다를 [remember] 로 감싸는 건 getBackStackEntry 조회를 컴포지션당 1회로 고정하기 위함. - */ -@Composable -private fun graphScopedFindIdViewModel(graphScopedParentEntry: () -> NavBackStackEntry): FindIdViewModel { - val parentEntry = remember { graphScopedParentEntry() } - return hiltViewModel(parentEntry) -} - -/** - * 아이디 찾기 화면의 Snackbar 호스트 + 단발성 에러 신호 처리. - * - * 인증번호 불일치는 시안상 인라인 문구라 여기서 다루지 않고([FindIdUiState.hasVerificationError]), - * 그 외 실패([FindIdUiState.errorMessage])만 snackbar 로 노출한다. - */ -@Composable -private fun rememberFindIdEventHost( - viewModel: FindIdViewModel, - uiState: FindIdUiState, -): SnackbarHostState { - val snackbarHostState = remember { SnackbarHostState() } - // VM 이 UiText 로 폴백까지 확정해 두므로 빈 문구가 도달하지 않는다. - val pendingErrorMessage = uiState.errorMessage?.asString() - - LaunchedEffect(pendingErrorMessage) { - if (pendingErrorMessage != null) { - snackbarHostState.showSnackbar( - message = pendingErrorMessage, - duration = SnackbarDuration.Short, - ) - viewModel.onErrorConsumed() - } - } - return snackbarHostState -} - -/** - * SignUp Step 화면 공통의 Snackbar 호스트 + UI state 단발성 신호 처리. - * - * 각 Step composable 에서 호출해 [SignUpUiState.errorMessage] / [SignUpUiState.isNameRequired] - * 를 일관되게 snackbar 로 노출하고, Step 1 의 경우 [onNavigateToResidentNumber] 콜백으로 - * [SignUpUiState.shouldNavigateToResidentNumber] = true 시점에 네비게이트한다. - * - * sealed Event Channel 대신 UiState 의 nullable/boolean 신호 + [LaunchedEffect] + on*Consumed 패턴으로 통일 - * (Google 공식 가이드 — ViewModel events should always result in a UI state update). - */ -@Composable -private fun rememberSignUpEventHost( - viewModel: SignUpViewModel, - onNavigateToResidentNumber: (() -> Unit)? = null, -): SnackbarHostState { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } - - val nameRequiredMessage = stringResource(R.string.onboarding_signup_name_required) - - LaunchedEffect(uiState.shouldNavigateToResidentNumber) { - if (uiState.shouldNavigateToResidentNumber && onNavigateToResidentNumber != null) { - onNavigateToResidentNumber() - viewModel.onResidentNumberNavigatedConsumed() - } - } - - LaunchedEffect(uiState.isNameRequired) { - if (uiState.isNameRequired) { - snackbarHostState.showSnackbar( - message = nameRequiredMessage, - duration = SnackbarDuration.Short, - ) - viewModel.onNameRequiredConsumed() - } - } - - val pendingErrorMessage = uiState.errorMessage?.asString() - LaunchedEffect(pendingErrorMessage) { - if (pendingErrorMessage != null) { - snackbarHostState.showSnackbar( - message = pendingErrorMessage, - duration = SnackbarDuration.Short, - ) - viewModel.onErrorConsumed() - } - } - - return snackbarHostState -} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingNavHost.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingNavHost.kt new file mode 100644 index 0000000000..58ee5a7d90 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingNavHost.kt @@ -0,0 +1,133 @@ +package com.afternote.feature.onboarding.presentation.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import com.afternote.core.ui.navigation.FeatureNavDisplay +import com.afternote.core.ui.navigation.FeatureStackBoundary +import com.afternote.feature.onboarding.presentation.OnboardingProfileScreen +import com.afternote.feature.onboarding.presentation.WelcomeScreen +import com.afternote.feature.onboarding.presentation.findaccount.FindIdScreen +import com.afternote.feature.onboarding.presentation.findaccount.FindIdViewModel +import com.afternote.feature.onboarding.presentation.login.LoginScreen +import com.afternote.feature.onboarding.presentation.signup.SignUpPasswordScreen +import com.afternote.feature.onboarding.presentation.signup.SignUpResidentNumberScreen +import com.afternote.feature.onboarding.presentation.signup.SignUpScreen +import com.afternote.feature.onboarding.presentation.signup.SignUpViewModel +import com.afternote.feature.onboarding.presentation.terms.OnboardingTermsScreen +import com.afternote.feature.onboarding.presentation.terms.TermsDetailScreen + +/** + * 온보딩 피처가 소유하는 로컬 Navigation 3 스택. + * + * 흐름: Welcome → Login / SignUp(1~4단계) → Profile → 완료(Home 이동) + * + * ## 공유 ViewModel 수명 + * + * Nav2 에서 [SignUpViewModel]·[FindIdViewModel] 은 `Route.Onboarding` **그래프 엔트리**의 + * `ViewModelStore` 에 묶여 있었다(`getBackStackEntry()` + `hiltViewModel(parentEntry)`). + * Nav3 엔 그래프라는 중간 계층이 없으므로, 같은 수명을 **host 자신의 스코프**로 옮긴다 — 이 + * 컴포저블은 온보딩을 담은 상위 엔트리 안에서 실행되니 여기서 만든 ViewModel 은 그 엔트리가 + * 백스택에서 내려갈 때 정리된다. 즉 화면 사이 재진입에는 살아남고 온보딩을 벗어나면 사라지는, + * 이관 전과 같은 특성이다. + * + * 화면별 ViewModel 이 필요해지면 `entry { }` 안에서 [hiltViewModel] 을 부른다 — 그쪽은 + * entry 범위(= 그 화면이 pop 되면 정리)로 잡힌다. + */ +@Composable +public fun OnboardingNavHost( + boundary: FeatureStackBoundary, + externalActions: OnboardingExternalActions, + modifier: Modifier = Modifier, +) { + val backStack = rememberNavBackStack(OnboardingRoute.WelcomeRoute) + val actions = + remember(backStack, boundary, externalActions) { + OnboardingLocalNavActions(backStack, boundary, externalActions) + } + + // 온보딩 전체가 공유하는 ViewModel — 상세는 KDoc 참고. + val signUpViewModel: SignUpViewModel = hiltViewModel() + val findIdViewModel: FindIdViewModel = hiltViewModel() + + FeatureNavDisplay( + backStack = backStack, + boundary = boundary, + modifier = modifier, + entryProvider = + entryProvider { + entry { + WelcomeScreen( + // #381: "시작하기" 도 "로그인하기" 와 동일하게 로그인 화면으로 이동 (PM 요청). + // 신규 회원가입 진입은 로그인 화면 내 회원가입 링크(replaceLoginWithSignUp)로 유지. + onStartClick = actions::navigateToLogin, + onCheckRecordsClick = actions::navigateToReceivedRecords, + onLoginClick = actions::navigateToLogin, + ) + } + + entry { + LoginScreen( + onLoginSuccess = actions::replaceOnboardingWithHome, + onNewUserOnboarding = actions::replaceLoginWithWelcome, + onSignUpClick = actions::replaceLoginWithSignUp, + onFindAccountClick = actions::navigateToFindId, + onBackClick = actions::popBack, + ) + } + + // 현재 진입점이 없다. 로그인의 "아이디/비밀번호 찾기" 는 #457 로 비밀번호 찾기를 + // 향하게 바꿨다 — 이 화면의 종착지인 결과 화면(#474)이 not planned 로 닫혀 여기로 + // 보내면 "확인" 뒤에 갈 곳이 없기 때문이다. 화면·라우트 제거는 #943(카카오 단일화) + // 몫이라 여기서 지우지 않고, 그때까지 등록만 남긴다. + entry { + FindIdScreen(viewModel = findIdViewModel, onNextClick = {}, onBackClick = actions::popBack) + } + + entry { + SignUpScreen( + viewModel = signUpViewModel, + onNavigateToResidentNumber = actions::proceedToSignUpResidentNumber, + onBackClick = actions::popBack, + ) + } + + entry { + SignUpResidentNumberScreen( + viewModel = signUpViewModel, + onNextClick = actions::proceedToSignUpPassword, + onBackClick = actions::popBack, + ) + } + + entry { + SignUpPasswordScreen(viewModel = signUpViewModel, onNextClick = actions::proceedToTerms, onBackClick = actions::popBack) + } + + entry { + OnboardingTermsScreen(viewModel = signUpViewModel, onViewTermsClick = { _ -> + actions.navigateToTermsDetail() + }, onNextClick = actions::proceedToProfile, onBackClick = actions::popBack) + } + + entry { + TermsDetailScreen( + title = "", + onBackClick = actions::popBack, + onNextClick = actions::popBack, + ) + } + + entry { + OnboardingProfileScreen( + viewModel = signUpViewModel, + onOnboardingComplete = actions::replaceOnboardingWithHome, + onBackClick = actions::popBack, + ) + } + }, + ) +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingRoute.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingRoute.kt index caa7f80728..c0932e7a16 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingRoute.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingRoute.kt @@ -1,8 +1,15 @@ package com.afternote.feature.onboarding.presentation.navigation +import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable -sealed interface OnboardingRoute { +/** + * 온보딩 로컬 Navigation 3 스택의 키. + * + * [NavKey] 는 Nav3 백스택에 실릴 수 있다는 표식이고, `@Serializable` 은 프로세스 재생성 뒤 + * 스택을 복원하는 데 쓰인다 — 둘 다 있어야 `rememberNavBackStack` 이 이 키를 저장한다. + */ +sealed interface OnboardingRoute : NavKey { @Serializable data object WelcomeRoute : OnboardingRoute diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/reporting/AuthFailureReporting.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/reporting/AuthFailureReporting.kt index c7f12cafa1..15b5ab909e 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/reporting/AuthFailureReporting.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/reporting/AuthFailureReporting.kt @@ -32,6 +32,16 @@ enum class AuthFailureStage( */ FIND_ACCOUNT_CODE_SEND("find_account_code_send"), + /** + * 비밀번호 찾기 최종 제출(`auth/password/find`) — 인증번호 검증과 재설정을 겸한다. + * + * 인증번호 무효(1207)까지 함께 세는 것이 [FIND_ACCOUNT_CODE_SEND] KDoc 의 "확인은 계측하지 + * 않는다" 와 어긋나 보이지만, 여기서는 코드가 이미 자릿수를 채워 한 화면을 통과한 뒤라 + * 오타보다 만료·서버 문제일 확률이 높다. 재설정이 조용히 실패하면 사용자가 계정을 되찾지 + * 못하므로 실패 자체를 놓치지 않는 쪽을 택했다. + */ + FIND_PASSWORD_RESET("find_password_reset"), + /** 회원가입 최종 제출. */ SIGN_UP("sign_up"), diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpContent.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpContent.kt new file mode 100644 index 0000000000..ee6eb17ed4 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpContent.kt @@ -0,0 +1,130 @@ +package com.afternote.feature.onboarding.presentation.signup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.TextFieldType +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.onboarding.presentation.OnboardingFailureDisplay +import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.toDisplay + +/** 상태를 그리는 렌더 계약. 별도 Screen 파일이 수명과 신호 소비를 담당하므로 모듈 내부에 공개한다. */ +@Composable +internal fun SignUpContent( + state: SignUpUiState, + onIntent: (SignUpIntent) -> Unit, + snackbarHostState: SnackbarHostState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val emailState = rememberTextFieldState(state.email) + val verificationCodeState = rememberTextFieldState(state.verificationCode) + + LaunchedEffect(emailState) { + snapshotFlow { emailState.text.toString() }.collect { onIntent(SignUpIntent.UpdateEmail(it)) } + } + LaunchedEffect(verificationCodeState) { + snapshotFlow { verificationCodeState.text.toString() }.collect { onIntent(SignUpIntent.UpdateVerificationCode(it)) } + } + + val verificationButtonText = + when { + state.isSendingCode -> { + stringResource(R.string.onboarding_signup_verification_requesting) + } + + state.resendCooldownSeconds > 0 -> { + stringResource(R.string.onboarding_signup_verification_resend_cooldown, state.resendCooldownSeconds) + } + + state.isVerificationSent -> { + stringResource(R.string.onboarding_signup_verification_resend) + } + + else -> { + stringResource(R.string.onboarding_signup_verification_request) + } + } + val isVerificationButtonEnabled = + !state.isSendingCode && state.resendCooldownSeconds == 0 && state.isEmailFormatValid + + FlowStepScaffold( + topBarTitle = stringResource(R.string.onboarding_signup_title), + actionButtonText = stringResource(R.string.onboarding_signup_next), + onBackClick = onBackClick, + onActionClick = { onIntent(SignUpIntent.VerifyEmailAndProceed) }, + modifier = modifier, + isActionEnabled = state.isStep1NextEnabled, + currentStep = SignUpStep.EMAIL, + totalSteps = SIGN_UP_TOTAL_STEPS, + progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.EMAIL), + snackbarHostState = snackbarHostState, + content = { + Column( + modifier = + Modifier + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(top = 35.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // 이메일 입력 + 인증번호 받기 + AfternoteTextField( + type = + TextFieldType.Variant7( + text = verificationButtonText, + onClick = { onIntent(SignUpIntent.RequestVerification) }, + enabled = isVerificationButtonEnabled, + ), + state = emailState, + placeholder = stringResource(R.string.onboarding_signup_email_placeholder), + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ) + + // 인증번호 입력 + AfternoteTextField( + state = verificationCodeState, + placeholder = stringResource(R.string.onboarding_signup_verification_code_placeholder), + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + onImeAction = { + if (state.isStep1NextEnabled) onIntent(SignUpIntent.VerifyEmailAndProceed) + }, + ) + + // 인증번호 불일치는 인라인 에러로, 그 외 실패는 스낵바로 나뉜다 (시안 2431-14204). + if ((state.failure.toDisplay() == OnboardingFailureDisplay.VerificationInline)) { + Text( + text = stringResource(R.string.onboarding_signup_verification_mismatch), + style = AfternoteDesign.typography.captionLargeB, + color = AfternoteDesign.colors.error, + ) + } else if (state.isVerificationSent) { + Text( + text = stringResource(R.string.onboarding_signup_verification_sent), + style = AfternoteDesign.typography.captionLargeB, + color = AfternoteDesign.colors.b1, + ) + } + } + }, + ) +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpContract.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpContract.kt new file mode 100644 index 0000000000..b50587d336 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpContract.kt @@ -0,0 +1,197 @@ +package com.afternote.feature.onboarding.presentation.signup + +import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +/** + * 회원가입 플로우가 [SignUpViewModel] 에 보내는 것 — 사용자가 하려는 것. + * + * Step 1~4 와 Profile 이 같은 VM 인스턴스를 공유하므로 Intent 도 한 계약에 모인다. + */ +internal sealed interface SignUpIntent : MviIntent { + data class UpdateEmail( + val value: String, + ) : SignUpIntent + + data class UpdateVerificationCode( + val value: String, + ) : SignUpIntent + + data class UpdateResidentFrontNumber( + val value: String, + ) : SignUpIntent + + data class UpdateResidentBackNumber( + val value: String, + ) : SignUpIntent + + data class UpdateSignUpPassword( + val value: String, + ) : SignUpIntent + + data class UpdateSignUpPasswordConfirm( + val value: String, + ) : SignUpIntent + + data class UpdateName( + val value: String, + ) : SignUpIntent + + /** photo picker 선택 — 화면이 `Uri.toString()` 으로 바꿔 보낸다. 취소(null)는 화면 경계에서 걸러진다. */ + data class PickProfileImage( + val uri: String, + ) : SignUpIntent + + data class ToggleTermsAgreed( + val agreed: Boolean, + ) : SignUpIntent + + data class TogglePrivacyAgreed( + val agreed: Boolean, + ) : SignUpIntent + + data class ToggleMarketingAgreed( + val agreed: Boolean, + ) : SignUpIntent + + data class ToggleAllTerms( + val agreed: Boolean, + ) : SignUpIntent + + data object RequestVerification : SignUpIntent + + /** Step 1 "다음" — 이메일·인증번호를 서버에 검증한다. */ + data object VerifyEmailAndProceed : SignUpIntent + + data object SubmitSignUp : SignUpIntent + + data object ConsumeSignedUp : SignUpIntent + + data object ConsumeResidentNumberNavigation : SignUpIntent + + data object ConsumeNameRequired : SignUpIntent + + data object ConsumeError : SignUpIntent +} + +/** 상태가 겪은 것. [SignUpViewModel] 만 만든다. */ +internal sealed interface SignUpReducerEvent : ReducerEvent { + data class EmailChanged( + val value: String, + ) : SignUpReducerEvent + + data class VerificationCodeChanged( + val value: String, + ) : SignUpReducerEvent + + data class ResidentFrontNumberChanged( + val value: String, + ) : SignUpReducerEvent + + data class ResidentBackNumberChanged( + val value: String, + ) : SignUpReducerEvent + + data class SignUpPasswordChanged( + val value: String, + ) : SignUpReducerEvent + + data class SignUpPasswordConfirmChanged( + val value: String, + ) : SignUpReducerEvent + + data class NameChanged( + val value: String, + ) : SignUpReducerEvent + + data class ProfileImagePicked( + val uri: String, + ) : SignUpReducerEvent + + data class TermsAgreementChanged( + val agreed: Boolean, + ) : SignUpReducerEvent + + data class PrivacyAgreementChanged( + val agreed: Boolean, + ) : SignUpReducerEvent + + data class MarketingAgreementChanged( + val agreed: Boolean, + ) : SignUpReducerEvent + + data class AllAgreementsChanged( + val agreed: Boolean, + ) : SignUpReducerEvent + + data object CodeSendStarted : SignUpReducerEvent + + data object CodeSent : SignUpReducerEvent + + data class CodeSendFailed( + val message: UiText, + ) : SignUpReducerEvent + + data object CodeSendFinished : SignUpReducerEvent + + /** 쿨다운을 30초로 재장전한다. */ + data object CooldownReloaded : SignUpReducerEvent + + data object CooldownTicked : SignUpReducerEvent + + data object EmailVerifyStarted : SignUpReducerEvent + + /** + * 인증 결과 3종은 **자기가 검증한 입력을 함께 나른다** (#2025). + * + * 이메일 칸은 인증이 도는 동안에도 고칠 수 있다. 결과에 식별 정보가 없으면 리듀서가 + * 「지금 화면의 입력에 대한 답」과 「바꾸기 전 입력에 대한 답」을 가르지 못해, A 의 성공이 + * B 를 적힌 폼을 다음 단계로 밀어 버린다 — 서버가 검증한 적 없는 이메일로 가입이 이어진다. + * 실패도 같다: 지운 입력의 거절 문구가 새 입력 아래에 붙는다. + */ + data class EmailVerified( + val email: String, + val certificateCode: String, + ) : SignUpReducerEvent + + /** 인증번호 무효(서버 code 1207) — 인라인 문구로 알린다. */ + data class VerificationRejected( + val email: String, + val certificateCode: String, + ) : SignUpReducerEvent + + data class EmailVerifyFailed( + val email: String, + val certificateCode: String, + val message: UiText, + ) : SignUpReducerEvent + + data object EmailVerifyFinished : SignUpReducerEvent + + data object NameRequired : SignUpReducerEvent + + data object SubmitStarted : SignUpReducerEvent + + /** 만들어진 계정의 자격을 함께 나른다 — 재제출이 같은 계정인지 그 값으로 가른다 (#2026). */ + data class AccountCreated( + val email: String, + val password: String, + ) : SignUpReducerEvent + + data class SubmitFailed( + val message: UiText, + ) : SignUpReducerEvent + + data object SignedUp : SignUpReducerEvent + + data object SubmitFinished : SignUpReducerEvent + + data object SignedUpConsumed : SignUpReducerEvent + + data object ResidentNumberNavigationConsumed : SignUpReducerEvent + + data object NameRequiredConsumed : SignUpReducerEvent + + data object ErrorConsumed : SignUpReducerEvent +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordContent.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordContent.kt new file mode 100644 index 0000000000..747620c113 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordContent.kt @@ -0,0 +1,110 @@ +package com.afternote.feature.onboarding.presentation.signup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.feature.onboarding.presentation.PasswordRuleItem +import com.afternote.feature.onboarding.presentation.R + +/** 상태를 그리는 렌더 계약. 별도 Screen 파일이 수명과 신호 소비를 담당하므로 모듈 내부에 공개한다. */ +@Composable +internal fun SignUpPasswordContent( + state: SignUpUiState, + onIntent: (SignUpIntent) -> Unit, + snackbarHostState: SnackbarHostState, + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val passwordState = rememberTextFieldState(state.signUpPassword) + val passwordConfirmState = rememberTextFieldState(state.signUpPasswordConfirm) + + LaunchedEffect(passwordState) { + snapshotFlow { passwordState.text.toString() }.collect { onIntent(SignUpIntent.UpdateSignUpPassword(it)) } + } + LaunchedEffect(passwordConfirmState) { + snapshotFlow { passwordConfirmState.text.toString() }.collect { onIntent(SignUpIntent.UpdateSignUpPasswordConfirm(it)) } + } + + FlowStepScaffold( + topBarTitle = stringResource(R.string.onboarding_signup_title), + actionButtonText = stringResource(R.string.onboarding_signup_next), + onBackClick = onBackClick, + onActionClick = onNextClick, + modifier = modifier, + isActionEnabled = state.isStep3NextEnabled, + currentStep = SignUpStep.PASSWORD, + totalSteps = SIGN_UP_TOTAL_STEPS, + progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.PASSWORD), + snackbarHostState = snackbarHostState, + content = { + Column( + modifier = + Modifier + .fillMaxWidth() + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(top = 43.dp), + ) { + SignUpInputLabel( + text = stringResource(R.string.onboarding_signup_password_input_label), + ) + + Spacer(modifier = Modifier.height(17.dp)) + + // 비밀번호 입력 + AfternoteTextField( + state = passwordState, + placeholder = stringResource(R.string.onboarding_signup_password_placeholder), + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 비밀번호 확인 + AfternoteTextField( + state = passwordConfirmState, + placeholder = stringResource(R.string.onboarding_signup_password_confirm_placeholder), + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + onImeAction = { + focusManager.clearFocus() + if (state.isStep3NextEnabled) onNextClick() + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 안내 문구 + PasswordRuleItem( + text = stringResource(R.string.onboarding_signup_password_rule_combination), + isSatisfied = state.isPasswordRuleSatisfied, + ) + Spacer(modifier = Modifier.height(4.dp)) + PasswordRuleItem( + text = stringResource(R.string.onboarding_signup_password_rule_reuse), + ) + } + }, + ) +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreen.kt index 54621967dc..30a48943ce 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreen.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreen.kt @@ -1,153 +1,25 @@ package com.afternote.feature.onboarding.presentation.signup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.AfternoteTextField -import com.afternote.core.ui.scaffold.FlowStepScaffold -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.feature.onboarding.presentation.R +import androidx.lifecycle.compose.collectAsStateWithLifecycle @Composable -fun SignUpPasswordScreen( - initialPassword: String, - initialPasswordConfirm: String, - isPasswordRuleSatisfied: Boolean, - isNextEnabled: Boolean, - snackbarHostState: SnackbarHostState, - onPasswordChange: (String) -> Unit, - onPasswordConfirmChange: (String) -> Unit, +internal fun SignUpPasswordScreen( + viewModel: SignUpViewModel, onNextClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - val focusManager = LocalFocusManager.current - val passwordState = rememberTextFieldState(initialPassword) - val passwordConfirmState = rememberTextFieldState(initialPasswordConfirm) + val state by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(passwordState) { - snapshotFlow { passwordState.text.toString() }.collect(onPasswordChange) - } - LaunchedEffect(passwordConfirmState) { - snapshotFlow { passwordConfirmState.text.toString() }.collect(onPasswordConfirmChange) - } - - FlowStepScaffold( - topBarTitle = stringResource(R.string.onboarding_signup_title), - actionButtonText = stringResource(R.string.onboarding_signup_next), + SignUpPasswordContent( + state = state, + onIntent = viewModel::onIntent, + snackbarHostState = rememberSignUpSnackbarHost(state, viewModel::onIntent), + onNextClick = onNextClick, onBackClick = onBackClick, - onActionClick = onNextClick, modifier = modifier, - isActionEnabled = isNextEnabled, - currentStep = SignUpStep.PASSWORD, - totalSteps = SIGN_UP_TOTAL_STEPS, - progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.PASSWORD), - snackbarHostState = snackbarHostState, - content = { - Column( - modifier = - Modifier - .fillMaxWidth() - .imePadding() - .verticalScroll(rememberScrollState()) - .padding(top = 43.dp), - ) { - SignUpInputLabel( - text = stringResource(R.string.onboarding_signup_password_input_label), - ) - - Spacer(modifier = Modifier.height(17.dp)) - - // 비밀번호 입력 - AfternoteTextField( - state = passwordState, - placeholder = stringResource(R.string.onboarding_signup_password_placeholder), - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Next, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // 비밀번호 확인 - AfternoteTextField( - state = passwordConfirmState, - placeholder = stringResource(R.string.onboarding_signup_password_confirm_placeholder), - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - onImeAction = { - focusManager.clearFocus() - if (isNextEnabled) onNextClick() - }, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // 안내 문구 - PasswordRuleItem( - text = stringResource(R.string.onboarding_signup_password_rule_combination), - isSatisfied = isPasswordRuleSatisfied, - ) - Spacer(modifier = Modifier.height(4.dp)) - PasswordRuleItem( - text = stringResource(R.string.onboarding_signup_password_rule_reuse), - ) - } - }, ) } - -@Composable -private fun PasswordRuleItem( - text: String, - modifier: Modifier = Modifier, - isSatisfied: Boolean? = null, -) { - val color = - if (isSatisfied == false) { - AfternoteDesign.colors.gray5 - } else { - AfternoteDesign.colors.b1 - } - Row( - modifier = - modifier - .fillMaxWidth() - .semantics(mergeDescendants = true) {}, - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.Top, - ) { - Text( - text = "•", - modifier = Modifier.clearAndSetSemantics {}, - style = AfternoteDesign.typography.captionLargeB, - color = color, - ) - Text( - text = text, - style = AfternoteDesign.typography.captionLargeB, - color = color, - ) - } -} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberContent.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberContent.kt new file mode 100644 index 0000000000..d6c8dbd0f7 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberContent.kt @@ -0,0 +1,104 @@ +package com.afternote.feature.onboarding.presentation.signup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.TextFieldType +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.feature.onboarding.presentation.R +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filter + +/** 상태를 그리는 렌더 계약. 별도 Screen 파일이 수명과 신호 소비를 담당하므로 모듈 내부에 공개한다. */ +@Composable +internal fun SignUpResidentNumberContent( + state: SignUpUiState, + onIntent: (SignUpIntent) -> Unit, + snackbarHostState: SnackbarHostState, + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val frontNumberState = rememberTextFieldState(state.residentFrontNumber) + val backNumberState = rememberTextFieldState(state.residentBackNumber) + val frontFocusRequester = remember { FocusRequester() } + val backFocusRequester = remember { FocusRequester() } + + LaunchedEffect(frontNumberState) { + snapshotFlow { frontNumberState.text.toString() }.collect { onIntent(SignUpIntent.UpdateResidentFrontNumber(it)) } + } + LaunchedEffect(backNumberState) { + snapshotFlow { backNumberState.text.toString() }.collect { onIntent(SignUpIntent.UpdateResidentBackNumber(it)) } + } + + // 앞자리 6자리 입력 완료 시 뒷자리로 포커스 자동 이동 + LaunchedEffect(frontNumberState) { + snapshotFlow { frontNumberState.text } + .filter { it.length == SignUpUiState.RESIDENT_REGISTRATION_FRONT_DIGIT_COUNT } + .collectLatest { backFocusRequester.requestFocus() } + } + + // 화면 진입 시 앞자리 필드에 포커스 → 키보드 표시 + LaunchedEffect(Unit) { + frontFocusRequester.requestFocus() + } + + FlowStepScaffold( + topBarTitle = stringResource(R.string.onboarding_signup_title), + actionButtonText = stringResource(R.string.onboarding_signup_next), + onBackClick = onBackClick, + onActionClick = onNextClick, + modifier = modifier, + isActionEnabled = state.isStep2NextEnabled, + currentStep = SignUpStep.RESIDENT_NUMBER, + totalSteps = SIGN_UP_TOTAL_STEPS, + progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.RESIDENT_NUMBER), + snackbarHostState = snackbarHostState, + content = { + Column( + modifier = + Modifier + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(top = 43.dp), + ) { + SignUpInputLabel( + text = stringResource(R.string.onboarding_signup_resident_number_label), + ) + + Spacer(modifier = Modifier.height(18.dp)) + + AfternoteTextField( + state = frontNumberState, + focusRequester = frontFocusRequester, + type = + TextFieldType.Variant8( + backState = backNumberState, + placeholder = stringResource(R.string.onboarding_signup_resident_number_back_placeholder), + backFocusRequester = backFocusRequester, + frontFocusRequester = frontFocusRequester, + ), + placeholder = stringResource(R.string.onboarding_signup_resident_number_placeholder), + keyboardType = KeyboardType.Number, + onImeAction = { if (state.isStep2NextEnabled) onNextClick() }, + ) + } + }, + ) +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreen.kt index 54b39022d4..7c3459e971 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreen.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreen.kt @@ -1,106 +1,25 @@ package com.afternote.feature.onboarding.presentation.signup -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.AfternoteTextField -import com.afternote.core.ui.TextFieldType -import com.afternote.core.ui.scaffold.FlowStepScaffold -import com.afternote.feature.onboarding.presentation.R -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.filter +import androidx.lifecycle.compose.collectAsStateWithLifecycle @Composable -fun SignUpResidentNumberScreen( - initialFrontNumber: String, - initialBackNumber: String, - isNextEnabled: Boolean, - snackbarHostState: SnackbarHostState, - onFrontNumberChange: (String) -> Unit, - onBackNumberChange: (String) -> Unit, +internal fun SignUpResidentNumberScreen( + viewModel: SignUpViewModel, onNextClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - val frontNumberState = rememberTextFieldState(initialFrontNumber) - val backNumberState = rememberTextFieldState(initialBackNumber) - val frontFocusRequester = remember { FocusRequester() } - val backFocusRequester = remember { FocusRequester() } + val state by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(frontNumberState) { - snapshotFlow { frontNumberState.text.toString() }.collect(onFrontNumberChange) - } - LaunchedEffect(backNumberState) { - snapshotFlow { backNumberState.text.toString() }.collect(onBackNumberChange) - } - - // 앞자리 6자리 입력 완료 시 뒷자리로 포커스 자동 이동 - LaunchedEffect(frontNumberState) { - snapshotFlow { frontNumberState.text } - .filter { it.length == SignUpUiState.RESIDENT_REGISTRATION_FRONT_DIGIT_COUNT } - .collectLatest { backFocusRequester.requestFocus() } - } - - // 화면 진입 시 앞자리 필드에 포커스 → 키보드 표시 - LaunchedEffect(Unit) { - frontFocusRequester.requestFocus() - } - - FlowStepScaffold( - topBarTitle = stringResource(R.string.onboarding_signup_title), - actionButtonText = stringResource(R.string.onboarding_signup_next), + SignUpResidentNumberContent( + state = state, + onIntent = viewModel::onIntent, + snackbarHostState = rememberSignUpSnackbarHost(state, viewModel::onIntent), + onNextClick = onNextClick, onBackClick = onBackClick, - onActionClick = onNextClick, modifier = modifier, - isActionEnabled = isNextEnabled, - currentStep = SignUpStep.RESIDENT_NUMBER, - totalSteps = SIGN_UP_TOTAL_STEPS, - progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.RESIDENT_NUMBER), - snackbarHostState = snackbarHostState, - content = { - Column( - modifier = - Modifier - .imePadding() - .verticalScroll(rememberScrollState()) - .padding(top = 43.dp), - ) { - SignUpInputLabel( - text = stringResource(R.string.onboarding_signup_resident_number_label), - ) - - Spacer(modifier = Modifier.height(18.dp)) - - AfternoteTextField( - state = frontNumberState, - focusRequester = frontFocusRequester, - type = - TextFieldType.Variant8( - backState = backNumberState, - placeholder = stringResource(R.string.onboarding_signup_resident_number_back_placeholder), - backFocusRequester = backFocusRequester, - frontFocusRequester = frontFocusRequester, - ), - placeholder = stringResource(R.string.onboarding_signup_resident_number_placeholder), - keyboardType = KeyboardType.Number, - onImeAction = { if (isNextEnabled) onNextClick() }, - ) - } - }, ) } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpScreen.kt index da41b248fd..06671e872a 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpScreen.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpScreen.kt @@ -1,137 +1,33 @@ package com.afternote.feature.onboarding.presentation.signup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.AfternoteTextField -import com.afternote.core.ui.TextFieldType -import com.afternote.core.ui.scaffold.FlowStepScaffold -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.feature.onboarding.presentation.R +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.mvi.ObserveFlag @Composable -fun SignUpScreen( - initialEmail: String, - initialVerificationCode: String, - isVerificationSent: Boolean, - isSendingCode: Boolean, - isEmailFormatValid: Boolean, - resendCooldownSeconds: Int, - hasVerificationError: Boolean, - isNextEnabled: Boolean, - snackbarHostState: SnackbarHostState, - onEmailChange: (String) -> Unit, - onVerificationCodeChange: (String) -> Unit, - onRequestVerification: () -> Unit, - onNextClick: () -> Unit, +internal fun SignUpScreen( + viewModel: SignUpViewModel, + onNavigateToResidentNumber: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - val emailState = rememberTextFieldState(initialEmail) - val verificationCodeState = rememberTextFieldState(initialVerificationCode) - - LaunchedEffect(emailState) { - snapshotFlow { emailState.text.toString() }.collect(onEmailChange) - } - LaunchedEffect(verificationCodeState) { - snapshotFlow { verificationCodeState.text.toString() }.collect(onVerificationCodeChange) - } - - val verificationButtonText = - when { - isSendingCode -> { - stringResource(R.string.onboarding_signup_verification_requesting) - } - - resendCooldownSeconds > 0 -> { - stringResource(R.string.onboarding_signup_verification_resend_cooldown, resendCooldownSeconds) - } - - isVerificationSent -> { - stringResource(R.string.onboarding_signup_verification_resend) - } - - else -> { - stringResource(R.string.onboarding_signup_verification_request) - } - } - val isVerificationButtonEnabled = - !isSendingCode && resendCooldownSeconds == 0 && isEmailFormatValid + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = rememberSignUpSnackbarHost(state, viewModel::onIntent) + + ObserveFlag( + raised = state.shouldNavigateToResidentNumber, + consumed = SignUpIntent.ConsumeResidentNumberNavigation, + onIntent = viewModel::onIntent, + onRaised = onNavigateToResidentNumber, + ) - FlowStepScaffold( - topBarTitle = stringResource(R.string.onboarding_signup_title), - actionButtonText = stringResource(R.string.onboarding_signup_next), + SignUpContent( + state = state, + onIntent = viewModel::onIntent, + snackbarHostState = snackbarHostState, onBackClick = onBackClick, - onActionClick = onNextClick, modifier = modifier, - isActionEnabled = isNextEnabled, - currentStep = SignUpStep.EMAIL, - totalSteps = SIGN_UP_TOTAL_STEPS, - progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.EMAIL), - snackbarHostState = snackbarHostState, - content = { - Column( - modifier = - Modifier - .imePadding() - .verticalScroll(rememberScrollState()) - .padding(top = 35.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // 이메일 입력 + 인증번호 받기 - AfternoteTextField( - type = - TextFieldType.Variant7( - text = verificationButtonText, - onClick = onRequestVerification, - enabled = isVerificationButtonEnabled, - ), - state = emailState, - placeholder = stringResource(R.string.onboarding_signup_email_placeholder), - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - ) - - // 인증번호 입력 - AfternoteTextField( - state = verificationCodeState, - placeholder = stringResource(R.string.onboarding_signup_verification_code_placeholder), - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - onImeAction = { - if (isNextEnabled) onNextClick() - }, - ) - - // 인증번호 불일치는 인라인 에러로, 그 외 실패는 스낵바로 나뉜다 (시안 2431-14204). - if (hasVerificationError) { - Text( - text = stringResource(R.string.onboarding_signup_verification_mismatch), - style = AfternoteDesign.typography.captionLargeB, - color = AfternoteDesign.colors.error, - ) - } else if (isVerificationSent) { - Text( - text = stringResource(R.string.onboarding_signup_verification_sent), - style = AfternoteDesign.typography.captionLargeB, - color = AfternoteDesign.colors.b1, - ) - } - } - }, ) } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpSignalHost.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpSignalHost.kt new file mode 100644 index 0000000000..cdc2793f90 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpSignalHost.kt @@ -0,0 +1,62 @@ +package com.afternote.feature.onboarding.presentation.signup + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.res.stringResource +import com.afternote.core.ui.asString +import com.afternote.core.ui.mvi.ObserveFlag +import com.afternote.core.ui.mvi.ObserveSignal +import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay +import kotlinx.coroutines.launch + +/** + * SignUp Step 1~4 · Profile 이 공유하는 스낵바 호스트와 공통 신호 소비. + * + * Step 화면마다 같은 `LaunchedEffect` 를 다시 쓰면 소비 규약이 화면 수만큼 갈린다 — + * 신호를 읽고 `Intent.ConsumeXxx` 를 되쏘는 일은 [ObserveSignal]·[ObserveFlag] 하나로 모은다. + * + * 화면 고유 신호(Step 1 의 [SignUpUiState.shouldNavigateToResidentNumber], Profile 의 + * [SignUpUiState.isSignedUp])는 그 화면이 직접 소비한다. + */ +@Composable +internal fun rememberSignUpSnackbarHost( + state: SignUpUiState, + onIntent: (SignUpIntent) -> Unit, +): SnackbarHostState { + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val nameRequiredMessage = stringResource(R.string.onboarding_signup_name_required) + + ObserveFlag( + raised = state.isNameRequired, + consumed = SignUpIntent.ConsumeNameRequired, + onIntent = onIntent, + ) { + // 소비가 곧바로 신호를 되돌려 effect 를 재시작시키므로, 표출은 effect 밖 스코프에 맡긴다. + scope.launch { + snackbarHostState.showSnackbar(message = nameRequiredMessage, duration = SnackbarDuration.Short) + } + } + + ObserveSignal( + // VM 이 UiText 로 폴백까지 확정해 두므로 빈 문구가 도달하지 않는다. + signal = + state.failure + .toDisplay() + .snackbarMessage + ?.asString(), + consumed = SignUpIntent.ConsumeError, + onIntent = onIntent, + ) { message -> + scope.launch { + snackbarHostState.showSnackbar(message = message, duration = SnackbarDuration.Short) + } + } + + return snackbarHostState +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpUiState.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpUiState.kt index d3ada80e26..7aa6e03d54 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpUiState.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpUiState.kt @@ -1,7 +1,9 @@ package com.afternote.feature.onboarding.presentation.signup -import android.util.Patterns -import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.UiState +import com.afternote.feature.onboarding.presentation.OnboardingEmailRule +import com.afternote.feature.onboarding.presentation.OnboardingFailure +import com.afternote.feature.onboarding.presentation.OnboardingPasswordRule import com.afternote.feature.onboarding.presentation.terms.TermsState /** @@ -14,10 +16,11 @@ import com.afternote.feature.onboarding.presentation.terms.TermsState * 로 갱신. 단발성 navigation/error 신호는 UI 가 소비 후 [SignUpViewModel] 의 `onXxxConsumed()` * 콜백 호출로 reset. * - * 폼 룰 (const + regex) 은 본 data class 의 companion 으로 묶어 ViewModel + UI 양쪽에서 - * `SignUpUiState.RESIDENT_REGISTRATION_FRONT_DIGIT_COUNT` 식으로 참조. + * 폼 상수는 본 data class 의 companion 으로 묶어 ViewModel + UI 양쪽에서 + * `SignUpUiState.RESIDENT_REGISTRATION_FRONT_DIGIT_COUNT` 식으로 참조한다. 새 비밀번호 규칙은 + * 다른 온보딩 흐름에서도 재사용할 수 있도록 [OnboardingPasswordRule]에 둔다. */ -data class SignUpUiState( +internal data class SignUpUiState( /** Step 1 입력값 — 이메일. */ val email: String = "", /** Step 1 입력값 — 인증번호. */ @@ -50,34 +53,42 @@ data class SignUpUiState( /** 회원가입 + 자동 로그인 진행 중. */ val isLoading: Boolean = false, /** - * 회원가입 POST 가 이미 성공한 상태. 자동 로그인만 실패해 같은 화면에 남았을 때, - * 재제출이 가입을 다시 호출하지 않도록 가른다 — 다시 부르면 서버가 이메일 중복으로 - * 거절해 복구 자체가 막힌다. + * 회원가입 POST 가 성공한 **자격**. 자동 로그인만 실패해 같은 화면에 남았을 때, 재제출이 + * 가입을 다시 호출하지 않도록 가른다 — 다시 부르면 서버가 이메일 중복으로 거절해 복구 + * 자체가 막힌다 (#710). + * + * Boolean 이 아니라 자격을 드는 이유는, 부분 성공 뒤 뒤로 가 이메일·비밀번호를 고치면 + * **그 플래그가 새 입력에도 그대로 남기 때문이다** (#2026). 그러면 새 계정의 가입은 + * 건너뛰고 새 자격으로 로그인만 불러, 만들어진 적 없는 계정으로 복구가 돈다. */ - val isAccountCreated: Boolean = false, + val createdAccount: CreatedSignUpAccount? = null, /** 회원가입 + 자동 로그인 성공. UI 가 홈으로 navigate 후 reset. */ val isSignedUp: Boolean = false, /** Step 1 검증 통과 — 주민등록번호 단계로 이동. */ val shouldNavigateToResidentNumber: Boolean = false, /** 이름 미입력 — UI 가 명시적 메시지 표시. */ val isNameRequired: Boolean = false, + /** 실패 한 건의 사유. 화면이 인라인 또는 스낵바로 표시한다. */ + val failure: OnboardingFailure? = null, +) : UiState { + /** 지금 입력이 [createdAccount] 와 같은 자격인지 — 같을 때만 가입을 건너뛴다 (#2026). */ + internal val isAccountCreated: Boolean + get() = createdAccount == CreatedSignUpAccount(email = email, password = signUpPassword) + /** - * 인증번호 무효(서버 code 1207) — 시안(2431:14204)상 인증번호 필드 아래 인라인 문구로 - * 표시(스낵바 아님). 만료 판정은 서버가 한다. - * - * 표시 문구는 화면의 고정 리소스라 서버 문구를 담지 않고 발생 여부만 든다. - */ - val hasVerificationError: Boolean = false, - /** - * 인증번호 무효 외 실패(네트워크 등) — snackbar 로 노출할 문구. + * 인증 결과가 **지금 화면의 입력에 대한 답인지** (#2025). * - * 예외 `message` 원문을 담지 않는다 — 사유를 확인해 준 타입만 각자의 문구를 갖고 나머지는 - * 정적 리소스로 내려앉는다(`Throwable.toDisplayMessage`). + * 이메일 칸은 인증이 도는 동안에도 고칠 수 있다. 보낸 입력과 지금 입력이 다르면 그 결과는 + * 이미 사용자가 버린 시도의 것이고, 적용하면 서버가 검증한 적 없는 이메일로 다음 단계가 열린다. */ - val errorMessage: UiText? = null, -) { + internal fun matches( + email: String, + certificateCode: String, + ): Boolean = this.email == email && this.verificationCode == certificateCode + + /** 이메일 형식 검사 — 세 온보딩 화면이 [OnboardingEmailRule] 하나를 쓴다 (#1851). */ val isEmailFormatValid: Boolean - get() = email.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(email).matches() + get() = OnboardingEmailRule.isValid(email) /** Step 1 — 이메일·인증번호 입력 후 다음 단계 진행 가능 여부. */ val isStep1NextEnabled: Boolean @@ -94,7 +105,7 @@ data class SignUpUiState( /** 비밀번호 정규식 충족 여부. 안내 문구 색상 토글에도 사용. */ val isPasswordRuleSatisfied: Boolean - get() = PASSWORD_REGEX.matches(signUpPassword) + get() = OnboardingPasswordRule.isSatisfied(signUpPassword) /** Step 3 — 비밀번호 규칙 충족 + 확인 일치. */ val isStep3NextEnabled: Boolean @@ -112,9 +123,11 @@ data class SignUpUiState( const val RESIDENT_REGISTRATION_BACK_FIRST_DIGIT_COUNT = 1 private const val MIN_VERIFICATION_CODE_LENGTH = 6 - - /** 8~16자, 영문 대소문자 + 숫자 + 특수문자 각 1개 이상. */ - private val PASSWORD_REGEX = - Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{8,16}$") } } + +/** 회원가입 POST 가 성공한 자격 한 벌. 재제출이 같은 계정을 가리키는지 판정하는 단위다 (#2026). */ +internal data class CreatedSignUpAccount( + val email: String, + val password: String, +) diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModel.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModel.kt index 662746aae4..a747b1bb0a 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModel.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModel.kt @@ -1,25 +1,21 @@ package com.afternote.feature.onboarding.presentation.signup -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.repository.account.AccountRepository import com.afternote.core.domain.usecase.auth.LoginType import com.afternote.core.domain.usecase.auth.LoginUseCase +import com.afternote.core.ui.mvi.MviViewModel +import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.afternote.feature.onboarding.presentation.R import com.afternote.feature.onboarding.presentation.reporting.AuthFailureStage import com.afternote.feature.onboarding.presentation.reporting.AuthProvider import com.afternote.feature.onboarding.presentation.reporting.recordAuthFailure -import com.afternote.feature.onboarding.presentation.signup.SignUpViewModel.Companion.RESEND_COOLDOWN_SECONDS import com.afternote.feature.onboarding.presentation.toDisplayMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException @@ -30,29 +26,21 @@ import kotlin.time.Duration.Companion.milliseconds * * `Route.Onboarding` 그래프 스코프에 묶여 SignUp Step 1~4와 Profile 화면이 동일한 인스턴스를 공유. * - * **상태 관리**: 모든 폼 필드 · 플래그 · 약관 · navigation 신호를 [SignUpUiState] 의 단일 - * `MutableStateFlow` 로 통합. UI 는 `collectAsStateWithLifecycle` 로 한 번에 구독. + * **상태 관리**: 모든 폼 필드 · 플래그 · 약관 · navigation 신호를 [SignUpUiState] 하나로 통합하고, + * 전이는 [reduce] 한 곳에서만 일어난다. 화면은 [onIntent] 하나로 들어온다 (#1802). * * **TextFieldState 정책**: TextFieldState 는 각 Screen 이 `rememberTextFieldState` 로 소유하고, * ViewModel 은 평범한 `String` 으로 보관. Screen 이 LaunchedEffect + snapshotFlow 로 변경 사항을 - * VM 에 push, 다른 Screen 은 VM 의 [uiState] 에서 String 으로 read. + * Intent 로 push, 다른 Screen 은 [uiState] 에서 String 으로 read. */ @HiltViewModel -class SignUpViewModel +internal class SignUpViewModel @Inject constructor( private val accountRepository: AccountRepository, private val loginUseCase: LoginUseCase, private val errorReporter: ErrorReporter, - ) : ViewModel() { - companion object { - /** "재전송" 클릭 후 다음 요청까지 강제 대기 초. 서버 비용 · SMS 발송량 보호. */ - private const val RESEND_COOLDOWN_SECONDS = 30 - } - - private val _uiState = MutableStateFlow(SignUpUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - + ) : MviViewModel(SignUpUiState()) { private var cooldownJob: Job? = null /** @@ -61,80 +49,212 @@ class SignUpViewModel */ private var signUpJob: Job? = null - // ─── 입력 reducer ─── - // 이메일이 바뀌면 앞서 받은 인증 에러는 더 이상 그 이메일의 것이 아니다. - fun updateEmail(value: String) = _uiState.update { it.copy(email = value, hasVerificationError = false) } + override fun onIntent(intent: SignUpIntent) { + when (intent) { + is SignUpIntent.UpdateEmail -> dispatch(SignUpReducerEvent.EmailChanged(intent.value)) + is SignUpIntent.UpdateVerificationCode -> dispatch(SignUpReducerEvent.VerificationCodeChanged(intent.value)) + is SignUpIntent.UpdateResidentFrontNumber -> dispatch(SignUpReducerEvent.ResidentFrontNumberChanged(intent.value)) + is SignUpIntent.UpdateResidentBackNumber -> dispatch(SignUpReducerEvent.ResidentBackNumberChanged(intent.value)) + is SignUpIntent.UpdateSignUpPassword -> dispatch(SignUpReducerEvent.SignUpPasswordChanged(intent.value)) + is SignUpIntent.UpdateSignUpPasswordConfirm -> dispatch(SignUpReducerEvent.SignUpPasswordConfirmChanged(intent.value)) + is SignUpIntent.UpdateName -> dispatch(SignUpReducerEvent.NameChanged(intent.value)) + is SignUpIntent.PickProfileImage -> dispatch(SignUpReducerEvent.ProfileImagePicked(intent.uri)) + is SignUpIntent.ToggleTermsAgreed -> dispatch(SignUpReducerEvent.TermsAgreementChanged(intent.agreed)) + is SignUpIntent.TogglePrivacyAgreed -> dispatch(SignUpReducerEvent.PrivacyAgreementChanged(intent.agreed)) + is SignUpIntent.ToggleMarketingAgreed -> dispatch(SignUpReducerEvent.MarketingAgreementChanged(intent.agreed)) + is SignUpIntent.ToggleAllTerms -> dispatch(SignUpReducerEvent.AllAgreementsChanged(intent.agreed)) + SignUpIntent.RequestVerification -> requestVerification() + SignUpIntent.VerifyEmailAndProceed -> verifyEmailAndProceed() + SignUpIntent.SubmitSignUp -> submitSignUp() + SignUpIntent.ConsumeSignedUp -> dispatch(SignUpReducerEvent.SignedUpConsumed) + SignUpIntent.ConsumeResidentNumberNavigation -> dispatch(SignUpReducerEvent.ResidentNumberNavigationConsumed) + SignUpIntent.ConsumeNameRequired -> dispatch(SignUpReducerEvent.NameRequiredConsumed) + SignUpIntent.ConsumeError -> dispatch(SignUpReducerEvent.ErrorConsumed) + } + } - // photo picker 선택은 Entry 가 Uri.toString() 으로 변환해 push — 취소(null)는 Screen 경계에서 무시한다. - fun onProfileImagePicked(uri: String) = _uiState.update { it.copy(profileImageUri = uri) } + override fun reduce( + state: SignUpUiState, + event: SignUpReducerEvent, + ): SignUpUiState = + when (event) { + // 이메일이 바뀌면 앞서 받은 인증 에러는 더 이상 그 이메일의 것이 아니다. + is SignUpReducerEvent.EmailChanged -> { + state.copy(email = event.value, failure = state.failure.takeIf { state.email == event.value }) + } - fun updateVerificationCode(value: String) = _uiState.update { it.copy(verificationCode = value, hasVerificationError = false) } + is SignUpReducerEvent.VerificationCodeChanged -> { + state.copy(verificationCode = event.value, failure = state.failure.takeIf { state.verificationCode == event.value }) + } - fun updateResidentFrontNumber(value: String) = _uiState.update { it.copy(residentFrontNumber = value) } + is SignUpReducerEvent.ResidentFrontNumberChanged -> { + state.copy(residentFrontNumber = event.value) + } - fun updateResidentBackNumber(value: String) = _uiState.update { it.copy(residentBackNumber = value) } + is SignUpReducerEvent.ResidentBackNumberChanged -> { + state.copy(residentBackNumber = event.value) + } - fun updateSignUpPassword(value: String) = _uiState.update { it.copy(signUpPassword = value) } + is SignUpReducerEvent.SignUpPasswordChanged -> { + state.copy(signUpPassword = event.value) + } - fun updateSignUpPasswordConfirm(value: String) = _uiState.update { it.copy(signUpPasswordConfirm = value) } + is SignUpReducerEvent.SignUpPasswordConfirmChanged -> { + state.copy(signUpPasswordConfirm = event.value) + } - fun updateName(value: String) = _uiState.update { it.copy(name = value) } + is SignUpReducerEvent.NameChanged -> { + state.copy(name = event.value) + } - // ─── 단발성 신호 consume (UI 가 소비 후 호출) ─── - fun onSignedUpConsumed() = _uiState.update { it.copy(isSignedUp = false) } + is SignUpReducerEvent.ProfileImagePicked -> { + state.copy(profileImageUri = event.uri) + } - fun onResidentNumberNavigatedConsumed() = _uiState.update { it.copy(shouldNavigateToResidentNumber = false) } + is SignUpReducerEvent.TermsAgreementChanged -> { + state.copy(termsState = state.termsState.copy(isTermsAgreed = event.agreed)) + } - fun onNameRequiredConsumed() = _uiState.update { it.copy(isNameRequired = false) } + is SignUpReducerEvent.PrivacyAgreementChanged -> { + state.copy(termsState = state.termsState.copy(isPrivacyAgreed = event.agreed)) + } - fun onErrorConsumed() = _uiState.update { it.copy(errorMessage = null) } + is SignUpReducerEvent.MarketingAgreementChanged -> { + state.copy(termsState = state.termsState.copy(isMarketingAgreed = event.agreed)) + } - // ─── 약관 ─── - fun toggleTermsAgreed(agreed: Boolean) = - _uiState.update { - it.copy(termsState = it.termsState.copy(isTermsAgreed = agreed)) - } + is SignUpReducerEvent.AllAgreementsChanged -> { + state.copy( + termsState = + state.termsState.copy( + isTermsAgreed = event.agreed, + isPrivacyAgreed = event.agreed, + isMarketingAgreed = event.agreed, + ), + ) + } - fun togglePrivacyAgreed(agreed: Boolean) = - _uiState.update { - it.copy(termsState = it.termsState.copy(isPrivacyAgreed = agreed)) - } + SignUpReducerEvent.CodeSendStarted -> { + state.copy(isSendingCode = true) + } - fun toggleMarketingAgreed(agreed: Boolean) = - _uiState.update { - it.copy(termsState = it.termsState.copy(isMarketingAgreed = agreed)) - } + SignUpReducerEvent.CodeSent -> { + state.copy(isVerificationSent = true, failure = null) + } + + is SignUpReducerEvent.CodeSendFailed -> { + state.copy(failure = OnboardingFailure.RequestFailed(event.message)) + } + + SignUpReducerEvent.CodeSendFinished -> { + state.copy(isSendingCode = false) + } + + SignUpReducerEvent.CooldownReloaded -> { + state.copy(resendCooldownSeconds = RESEND_COOLDOWN_SECONDS) + } + + SignUpReducerEvent.CooldownTicked -> { + state.copy(resendCooldownSeconds = state.resendCooldownSeconds - 1) + } + + SignUpReducerEvent.EmailVerifyStarted -> { + state.copy(isVerifyingEmail = true, failure = null) + } + + // 셋 다 **자기가 검증한 입력이 아직 화면에 있을 때만** 적용한다 (#2025). + // 늦게 도착한 옛 요청의 답이 지금 폼을 다음 단계로 밀거나 새 입력에 오류를 + // 붙이면, 서버가 검증한 적 없는 이메일로 가입이 이어진다. + is SignUpReducerEvent.EmailVerified -> { + if (state.matches(event.email, event.certificateCode)) { + state.copy(shouldNavigateToResidentNumber = true) + } else { + state + } + } - fun toggleAllTerms(allAgreed: Boolean) = - _uiState.update { - it.copy( - termsState = - it.termsState.copy( - isTermsAgreed = allAgreed, - isPrivacyAgreed = allAgreed, - isMarketingAgreed = allAgreed, - ), - ) + is SignUpReducerEvent.VerificationRejected -> { + if (state.matches(event.email, event.certificateCode)) { + state.copy(failure = OnboardingFailure.VerificationRejected) + } else { + state + } + } + + is SignUpReducerEvent.EmailVerifyFailed -> { + if (state.matches(event.email, event.certificateCode)) { + state.copy(failure = OnboardingFailure.RequestFailed(event.message)) + } else { + state + } + } + + SignUpReducerEvent.EmailVerifyFinished -> { + state.copy(isVerifyingEmail = false) + } + + SignUpReducerEvent.NameRequired -> { + state.copy(isNameRequired = true) + } + + SignUpReducerEvent.SubmitStarted -> { + state.copy(isLoading = true) + } + + is SignUpReducerEvent.AccountCreated -> { + state.copy(createdAccount = CreatedSignUpAccount(email = event.email, password = event.password)) + } + + is SignUpReducerEvent.SubmitFailed -> { + state.copy(failure = OnboardingFailure.RequestFailed(event.message)) + } + + SignUpReducerEvent.SignedUp -> { + state.copy(isSignedUp = true) + } + + SignUpReducerEvent.SubmitFinished -> { + state.copy(isLoading = false) + } + + SignUpReducerEvent.SignedUpConsumed -> { + state.copy(isSignedUp = false) + } + + SignUpReducerEvent.ResidentNumberNavigationConsumed -> { + state.copy(shouldNavigateToResidentNumber = false) + } + + SignUpReducerEvent.NameRequiredConsumed -> { + state.copy(isNameRequired = false) + } + + SignUpReducerEvent.ErrorConsumed -> { + state.copy(failure = state.failure.takeUnless { it is OnboardingFailure.RequestFailed }) + } } - // ─── 액션 ─── - fun requestVerification() { - val state = _uiState.value + private fun requestVerification() { + val state = currentState if (state.isSendingCode || state.resendCooldownSeconds > 0) return viewModelScope.launch { - _uiState.update { it.copy(isSendingCode = true) } + dispatch(SignUpReducerEvent.CodeSendStarted) accountRepository .sendEmailCode(state.email) .onSuccess { - _uiState.update { it.copy(isVerificationSent = true, hasVerificationError = false) } + dispatch(SignUpReducerEvent.CodeSent) startResendCooldown() }.onFailure { error -> // 취소는 장애가 아니다 — 기록·UI 소비 전에 되던져 전파를 보존한다(전수 정정은 #661). if (error is CancellationException) throw error errorReporter.recordAuthFailure(AuthFailureStage.EMAIL_CODE_SEND, error) - _uiState.update { it.copy(errorMessage = error.toDisplayMessage(R.string.onboarding_signup_code_send_failed)) } + dispatch( + SignUpReducerEvent.CodeSendFailed( + error.toDisplayMessage(R.string.onboarding_signup_code_send_failed), + ), + ) } - _uiState.update { it.copy(isSendingCode = false) } + dispatch(SignUpReducerEvent.CodeSendFinished) } } @@ -143,10 +263,10 @@ class SignUpViewModel cooldownJob?.cancel() cooldownJob = viewModelScope.launch { - _uiState.update { it.copy(resendCooldownSeconds = RESEND_COOLDOWN_SECONDS) } - while (_uiState.value.resendCooldownSeconds > 0) { - delay(1000.milliseconds) - _uiState.update { it.copy(resendCooldownSeconds = it.resendCooldownSeconds - 1) } + dispatch(SignUpReducerEvent.CooldownReloaded) + while (currentState.resendCooldownSeconds > 0) { + delay(MILLIS_PER_SECOND.milliseconds) + dispatch(SignUpReducerEvent.CooldownTicked) } } } @@ -155,37 +275,40 @@ class SignUpViewModel * Step 1 "다음" 클릭 시점에 호출. * 이메일/인증번호를 서버에 검증해 성공 시 [SignUpUiState.shouldNavigateToResidentNumber] = true, * 인증번호 무효([CoreAuthFailure.EmailVerification] — 불일치/만료/미존재)는 - * [SignUpUiState.hasVerificationError] (인라인 문구), 그 외 실패는 - * [SignUpUiState.errorMessage] (스낵바) 로 set. 만료 판정은 서버가 한다. + * [SignUpUiState.failure]에 사유를 기록하고 화면이 표시 채널을 고른다. 만료 판정은 서버가 한다. */ - fun verifyEmailAndProceed() { - val state = _uiState.value + private fun verifyEmailAndProceed() { + val state = currentState if (state.isVerifyingEmail) return + // 보낸 입력을 결과에 그대로 실어 보낸다 — 리듀서가 「지금 화면의 답인지」를 그 값으로 가른다. + val verifiedEmail = state.email + val verifiedCode = state.verificationCode viewModelScope.launch { - _uiState.update { it.copy(isVerifyingEmail = true, hasVerificationError = false) } + dispatch(SignUpReducerEvent.EmailVerifyStarted) accountRepository .verifyEmail( - email = state.email, - certificateCode = state.verificationCode, + email = verifiedEmail, + certificateCode = verifiedCode, ).onSuccess { - _uiState.update { it.copy(shouldNavigateToResidentNumber = true) } + dispatch(SignUpReducerEvent.EmailVerified(verifiedEmail, verifiedCode)) }.onFailure { error -> // 취소는 장애가 아니다 — 기록·UI 소비 전에 되던져 전파를 보존한다(전수 정정은 #661). if (error is CancellationException) throw error if (error is CoreAuthFailure.EmailVerification) { - // 표시 문구는 화면의 고정 리소스 — 이 값은 인라인 표시 트리거 + 디버깅용 원문. // 인증번호 불일치·만료는 정상적인 사용자 입력 오류라 리포팅하지 않는다. - // 스낵바 신호를 함께 내린다 — 이번 실패는 인라인으로 알리므로, - // 아직 소비되지 않은 이전 실패 문구가 인라인과 겹쳐 뜨지 않게 한다. - _uiState.update { it.copy(hasVerificationError = true, errorMessage = null) } + dispatch(SignUpReducerEvent.VerificationRejected(verifiedEmail, verifiedCode)) } else { errorReporter.recordAuthFailure(AuthFailureStage.EMAIL_VERIFY, error) - _uiState.update { - it.copy(errorMessage = error.toDisplayMessage(R.string.onboarding_signup_email_verify_failed)) - } + dispatch( + SignUpReducerEvent.EmailVerifyFailed( + email = verifiedEmail, + certificateCode = verifiedCode, + message = error.toDisplayMessage(R.string.onboarding_signup_email_verify_failed), + ), + ) } } - _uiState.update { it.copy(isVerifyingEmail = false) } + dispatch(SignUpReducerEvent.EmailVerifyFinished) } } @@ -195,21 +318,22 @@ class SignUpViewModel * 회원가입 API 는 토큰을 내려주지 않으므로 같은 자격증명으로 자동 로그인. * * 가입과 자동 로그인은 따로 성공할 수 있어 재시도 지점이 다르다 - * ([SignUpUiState.isAccountCreated]). + * ([SignUpUiState.createdAccount]). 건너뛰기는 **그 자격 그대로 재제출할 때만** 적용한다 — + * 부분 성공 뒤 뒤로 가 이메일·비밀번호를 고치면 새 계정의 가입이 필요하다 (#2026). */ - fun submitSignUp() { + private fun submitSignUp() { if (signUpJob?.isActive == true) return - val state = _uiState.value + val state = currentState val trimmedName = state.name.trim() if (trimmedName.isEmpty()) { - _uiState.update { it.copy(isNameRequired = true) } + dispatch(SignUpReducerEvent.NameRequired) return } signUpJob = viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } + dispatch(SignUpReducerEvent.SubmitStarted) try { if (!state.isAccountCreated) { accountRepository @@ -219,19 +343,23 @@ class SignUpViewModel name = trimmedName, profileUrl = state.profileImageUri, ).onSuccess { - _uiState.update { it.copy(isAccountCreated = true) } + dispatch(SignUpReducerEvent.AccountCreated(email = state.email, password = state.signUpPassword)) }.onFailure { error -> // 취소는 장애가 아니다 — 기록·UI 소비 전에 되던져 전파를 보존한다(전수 정정은 #661). if (error is CancellationException) throw error errorReporter.recordAuthFailure(AuthFailureStage.SIGN_UP, error) - _uiState.update { it.copy(errorMessage = error.toDisplayMessage(R.string.onboarding_signup_failed)) } + dispatch( + SignUpReducerEvent.SubmitFailed( + error.toDisplayMessage(R.string.onboarding_signup_failed), + ), + ) return@launch } } loginUseCase(LoginType.Email(email = state.email, password = state.signUpPassword)) .onSuccess { - _uiState.update { it.copy(isSignedUp = true) } + dispatch(SignUpReducerEvent.SignedUp) }.onFailure { error -> // 취소는 장애가 아니다 — 기록·UI 소비 전에 되던져 전파를 보존한다(전수 정정은 #661). if (error is CancellationException) throw error @@ -240,16 +368,22 @@ class SignUpViewModel throwable = error, provider = AuthProvider.EMAIL, ) - _uiState.update { - it.copy( - errorMessage = error.toDisplayMessage(R.string.onboarding_signup_auto_login_failed), - ) - } + dispatch( + SignUpReducerEvent.SubmitFailed( + error.toDisplayMessage(R.string.onboarding_signup_auto_login_failed), + ), + ) } } finally { // 어느 갈래로 빠져나가든 버튼 잠금은 풀어야 한다. - _uiState.update { it.copy(isLoading = false) } + dispatch(SignUpReducerEvent.SubmitFinished) } } } + + private companion object { + /** "재전송" 클릭 후 다음 요청까지 강제 대기 초. 서버 비용 · SMS 발송량 보호. */ + const val RESEND_COOLDOWN_SECONDS = 30 + const val MILLIS_PER_SECOND = 1000L + } } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsContent.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsContent.kt new file mode 100644 index 0000000000..33f9358f63 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsContent.kt @@ -0,0 +1,217 @@ +package com.afternote.feature.onboarding.presentation.terms + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.button.AfternoteCircularCheckbox +import com.afternote.core.ui.button.CheckboxState +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.signup.SIGN_UP_TOTAL_STEPS +import com.afternote.feature.onboarding.presentation.signup.SignUpIntent +import com.afternote.feature.onboarding.presentation.signup.SignUpStep +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState +import com.afternote.core.common.R as CommonR + +/** 상태를 그리는 렌더 계약. 별도 Screen 파일이 수명과 신호 소비를 담당하므로 모듈 내부에 공개한다. */ +@Composable +internal fun OnboardingTermsContent( + state: SignUpUiState, + onIntent: (SignUpIntent) -> Unit, + snackbarHostState: SnackbarHostState, + onViewTermsClick: (TermsType) -> Unit, + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val termsState = state.termsState + FlowStepScaffold( + topBarTitle = stringResource(R.string.onboarding_signup_title), + actionButtonText = stringResource(R.string.onboarding_signup_next), + onBackClick = onBackClick, + onActionClick = onNextClick, + modifier = modifier, + isActionEnabled = state.isStep4NextEnabled, + currentStep = SignUpStep.TERMS, + totalSteps = SIGN_UP_TOTAL_STEPS, + progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.TERMS), + snackbarHostState = snackbarHostState, + content = { + Spacer(modifier = Modifier.height(32.dp)) + + // 로고 + Image( + painter = painterResource(CommonR.drawable.core_common_logo), + contentDescription = stringResource(R.string.onboarding_welcome_logo_description), + modifier = Modifier.height(55.dp), + contentScale = ContentScale.FillHeight, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // 환영 텍스트 + Text( + text = stringResource(R.string.onboarding_terms_welcome), + style = AfternoteDesign.typography.h1, + color = AfternoteDesign.colors.black, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = stringResource(R.string.onboarding_terms_description), + style = + AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + + Spacer(modifier = Modifier.weight(1f)) + + // --- 약관 동의 섹션 --- + + Column( + modifier = + Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + TermsRow( + title = stringResource(R.string.onboarding_terms_agree_all), + isChecked = termsState.isAllAgreed, + onToggle = { onIntent(SignUpIntent.ToggleAllTerms(!termsState.isAllAgreed)) }, + titleStyle = AfternoteDesign.typography.bodyBase, + ) + + HorizontalDivider( + thickness = 1.dp, + color = AfternoteDesign.colors.gray3, + ) + + // 서비스 이용 약관 (필수) + TermsRow( + title = stringResource(R.string.onboarding_terms_service), + isChecked = termsState.isTermsAgreed, + onToggle = { onIntent(SignUpIntent.ToggleTermsAgreed(!termsState.isTermsAgreed)) }, + titleStyle = AfternoteDesign.typography.bodySmallB, + ) { onViewTermsClick(TermsType.SERVICE) } + + // 개인정보 수집 및 이용 동의서 (필수) + TermsRow( + title = stringResource(R.string.onboarding_terms_privacy), + isChecked = termsState.isPrivacyAgreed, + onToggle = { onIntent(SignUpIntent.TogglePrivacyAgreed(!termsState.isPrivacyAgreed)) }, + titleStyle = AfternoteDesign.typography.bodySmallB, + ) { onViewTermsClick(TermsType.PRIVACY) } + + // 마케팅 수신 동의 (선택) + TermsRow( + title = stringResource(R.string.onboarding_terms_marketing), + isChecked = termsState.isMarketingAgreed, + onToggle = { onIntent(SignUpIntent.ToggleMarketingAgreed(!termsState.isMarketingAgreed)) }, + isOptional = true, + titleStyle = AfternoteDesign.typography.bodySmallB, + ) { onViewTermsClick(TermsType.MARKETING) } + } + Spacer(Modifier.height(71.dp)) + }, + ) +} + +@Composable +private fun TermsRow( + title: String, + isChecked: Boolean, + titleStyle: TextStyle, + onToggle: () -> Unit, + modifier: Modifier = Modifier, + isOptional: Boolean = false, + onViewDetailClick: (() -> Unit)? = null, +) { + // 최상위 Row는 레이아웃 배치만 담당 (이벤트 없음) + Row( + modifier = + modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // 1. 체크박스 + 텍스트 영역 (Toggleable) + Row( + modifier = + Modifier + // 「전체보기」가 폭을 먼저 가져가고, 제목은 남는 폭 안에서 줄바꿈한다. + // weight 가 없으면 제목이 순서상 앞이라 폭을 먼저 다 먹어, 「전체보기」가 + // 좁은 폭에 갇혀 세로로 깨진다. + // + // fill 은 기본값(true) 이다 — 남는 폭까지 토글 영역이 되어 행의 빈 곳을 + // 눌러도 체크된다. 리스트 행 체크박스의 통상 동작이다. + .weight(1f) + .toggleable( + value = isChecked, + role = Role.Checkbox, + onValueChange = { onToggle() }, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + AfternoteCircularCheckbox( + state = if (isChecked) CheckboxState.Default else CheckboxState.None, + size = 20.dp, + onClick = null, + ) + + Spacer(Modifier.width(8.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + Text( + text = title, + style = titleStyle, + color = AfternoteDesign.colors.gray9, + ) + + if (isOptional) { + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.onboarding_terms_marketing_description), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray4, + ) + } + } + } + + // 2. 전체보기 버튼 (독립된 Clickable — toggleable과 이벤트 분리) + if (onViewDetailClick != null) { + Text( + text = stringResource(R.string.onboarding_terms_view_detail), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + modifier = + Modifier + .padding(start = 8.dp) + .clickable(role = Role.Button, onClick = onViewDetailClick), + ) + } + } +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreen.kt index dee16cdb4a..e66d530b1f 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreen.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreen.kt @@ -1,48 +1,22 @@ package com.afternote.feature.onboarding.presentation.terms -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.selection.toggleable -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.button.AfternoteCircularCheckbox -import com.afternote.core.ui.button.CheckboxState -import com.afternote.core.ui.scaffold.FlowStepScaffold -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.feature.onboarding.presentation.R -import com.afternote.feature.onboarding.presentation.signup.SIGN_UP_TOTAL_STEPS -import com.afternote.feature.onboarding.presentation.signup.SignUpStep -import com.afternote.core.common.R as CommonR +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.feature.onboarding.presentation.signup.SignUpViewModel +import com.afternote.feature.onboarding.presentation.signup.rememberSignUpSnackbarHost -enum class TermsType { +internal enum class TermsType { SERVICE, PRIVACY, MARKETING, } @Immutable -data class TermsState( +internal data class TermsState( val isTermsAgreed: Boolean = false, val isPrivacyAgreed: Boolean = false, val isMarketingAgreed: Boolean = false, @@ -50,186 +24,28 @@ data class TermsState( val isAllAgreed: Boolean get() = isTermsAgreed && isPrivacyAgreed && isMarketingAgreed } +/** + * 약관 동의(Step 4) — stateful 층. + * + * 약관 상세·다음 단계 이동은 네비게이션이라 콜백으로 남는다. + */ @Composable -fun OnboardingTermsScreen( - termsState: TermsState, - isNextEnabled: Boolean, - snackbarHostState: SnackbarHostState, - onTermsToggle: (Boolean) -> Unit, - onPrivacyToggle: (Boolean) -> Unit, - onMarketingToggle: (Boolean) -> Unit, - onToggleAll: (Boolean) -> Unit, +internal fun OnboardingTermsScreen( + viewModel: SignUpViewModel, onViewTermsClick: (TermsType) -> Unit, onNextClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - FlowStepScaffold( - topBarTitle = stringResource(R.string.onboarding_signup_title), - actionButtonText = stringResource(R.string.onboarding_signup_next), + val state by viewModel.uiState.collectAsStateWithLifecycle() + + OnboardingTermsContent( + state = state, + onIntent = viewModel::onIntent, + snackbarHostState = rememberSignUpSnackbarHost(state, viewModel::onIntent), + onViewTermsClick = onViewTermsClick, + onNextClick = onNextClick, onBackClick = onBackClick, - onActionClick = onNextClick, modifier = modifier, - isActionEnabled = isNextEnabled, - currentStep = SignUpStep.TERMS, - totalSteps = SIGN_UP_TOTAL_STEPS, - progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.TERMS), - snackbarHostState = snackbarHostState, - content = { - Spacer(modifier = Modifier.height(32.dp)) - - // 로고 - Image( - painter = painterResource(CommonR.drawable.core_common_logo), - contentDescription = stringResource(R.string.onboarding_welcome_logo_description), - modifier = Modifier.height(55.dp), - contentScale = ContentScale.FillHeight, - ) - - Spacer(modifier = Modifier.height(24.dp)) - - // 환영 텍스트 - Text( - text = stringResource(R.string.onboarding_terms_welcome), - style = AfternoteDesign.typography.h1, - color = AfternoteDesign.colors.black, - ) - - Spacer(modifier = Modifier.height(32.dp)) - - Text( - text = stringResource(R.string.onboarding_terms_description), - style = - AfternoteDesign.typography.bodySmallB, - color = AfternoteDesign.colors.gray9, - ) - - Spacer(modifier = Modifier.weight(1f)) - - // --- 약관 동의 섹션 --- - - Column( - modifier = - Modifier - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - TermsRow( - title = stringResource(R.string.onboarding_terms_agree_all), - isChecked = termsState.isAllAgreed, - onToggle = { onToggleAll(!termsState.isAllAgreed) }, - titleStyle = AfternoteDesign.typography.bodyBase, - ) - - HorizontalDivider( - thickness = 1.dp, - color = AfternoteDesign.colors.gray3, - ) - - // 서비스 이용 약관 (필수) - TermsRow( - title = stringResource(R.string.onboarding_terms_service), - isChecked = termsState.isTermsAgreed, - onToggle = { onTermsToggle(!termsState.isTermsAgreed) }, - titleStyle = AfternoteDesign.typography.bodySmallB, - ) { onViewTermsClick(TermsType.SERVICE) } - - // 개인정보 수집 및 이용 동의서 (필수) - TermsRow( - title = stringResource(R.string.onboarding_terms_privacy), - isChecked = termsState.isPrivacyAgreed, - onToggle = { onPrivacyToggle(!termsState.isPrivacyAgreed) }, - titleStyle = AfternoteDesign.typography.bodySmallB, - ) { onViewTermsClick(TermsType.PRIVACY) } - - // 마케팅 수신 동의 (선택) - TermsRow( - title = stringResource(R.string.onboarding_terms_marketing), - isChecked = termsState.isMarketingAgreed, - onToggle = { onMarketingToggle(!termsState.isMarketingAgreed) }, - isOptional = true, - titleStyle = AfternoteDesign.typography.bodySmallB, - ) { onViewTermsClick(TermsType.MARKETING) } - } - Spacer(Modifier.height(71.dp)) - }, ) } - -@Composable -private fun TermsRow( - title: String, - isChecked: Boolean, - titleStyle: TextStyle, - onToggle: () -> Unit, - modifier: Modifier = Modifier, - isOptional: Boolean = false, - onViewDetailClick: (() -> Unit)? = null, -) { - // 최상위 Row는 레이아웃 배치만 담당 (이벤트 없음) - Row( - modifier = - modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - // 1. 체크박스 + 텍스트 영역 (Toggleable) - Row( - modifier = - Modifier - // 「전체보기」가 폭을 먼저 가져가고, 제목은 남는 폭 안에서 줄바꿈한다. - // weight 가 없으면 제목이 순서상 앞이라 폭을 먼저 다 먹어, 「전체보기」가 - // 좁은 폭에 갇혀 세로로 깨진다. - // - // fill 은 기본값(true) 이다 — 남는 폭까지 토글 영역이 되어 행의 빈 곳을 - // 눌러도 체크된다. 리스트 행 체크박스의 통상 동작이다. - .weight(1f) - .toggleable( - value = isChecked, - role = Role.Checkbox, - onValueChange = { onToggle() }, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - AfternoteCircularCheckbox( - state = if (isChecked) CheckboxState.Default else CheckboxState.None, - size = 20.dp, - onClick = null, - ) - - Spacer(Modifier.width(8.dp)) - - Column( - verticalArrangement = Arrangement.Center, - ) { - Text( - text = title, - style = titleStyle, - color = AfternoteDesign.colors.gray9, - ) - - if (isOptional) { - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource(R.string.onboarding_terms_marketing_description), - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray4, - ) - } - } - } - - // 2. 전체보기 버튼 (독립된 Clickable — toggleable과 이벤트 분리) - if (onViewDetailClick != null) { - Text( - text = stringResource(R.string.onboarding_terms_view_detail), - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray6, - modifier = - Modifier - .padding(start = 8.dp) - .clickable(role = Role.Button, onClick = onViewDetailClick), - ) - } - } -} diff --git a/feature/onboarding/presentation/src/main/res/values/strings.xml b/feature/onboarding/presentation/src/main/res/values/strings.xml index 7b7a6d656d..c7a946162b 100644 --- a/feature/onboarding/presentation/src/main/res/values/strings.xml +++ b/feature/onboarding/presentation/src/main/res/values/strings.xml @@ -21,6 +21,10 @@ 아이디 또는 비밀번호가 일치하지 않습니다. 소셜 로그인이 거절되었습니다. 다시 시도해 주세요. + + 소셜 로그인으로 가입한 계정입니다.\n소셜 로그인을 이용해 주세요. 네트워크 연결을 확인해주세요. 카카오 로그인에 실패했습니다. @@ -43,6 +47,19 @@ 다음 요청에 실패했습니다. + + 비밀번호 찾기 + 비밀번호 변경 + 비밀번호를 변경해 주세요. + 비밀번호가 변경되었습니다.\n변경된 비밀번호로 로그인해 주세요. + 로그인 + 소셜 로그인 계정은 비밀번호 찾기를\n사용 하실 수 없습니다. + + 인증번호가 만료되었거나 올바르지 않습니다.\n이메일 인증부터 다시 진행해 주세요. + + 지금 쓰고 있는 비밀번호입니다. 다른 비밀번호를 입력해 주세요. + 비밀번호를 변경하지 못했어요. 잠시 후 다시 시도해주세요. + 회원가입 진행도 4단계 중 %1$d단계 회원가입 @@ -61,7 +78,7 @@ 0 비밀번호 입력 비밀번호 확인 - 8 ~ 16자의 영문 대소문자, 숫자, 특수문자를 조합하여 설정해 주세요. + 8 ~ 15자의 영문, 숫자, 특수문자(@, $, !, %, *, #, ?, &)를 각각 1자 이상 사용해 주세요. 이전에 사용한 적 없는 비밀번호가 안전합니다. 회원가입 실패 인증번호를 보내지 못했어요. 잠시 후 다시 시도해주세요. diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTest.kt index 2765c43c8a..48ff7df77c 100644 --- a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTest.kt +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTest.kt @@ -5,10 +5,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState import com.android.tools.screenshot.PreviewTest /** - * [OnboardingProfileScreen] 의 시각 회귀 baseline — 초기 진입 상태 (이름 미입력 + 이미지 미선택). + * [OnboardingProfileContent] 의 시각 회귀 baseline — 초기 진입 상태 (이름 미입력 + 이미지 미선택). * * stateful 흐름 Screen 의 첫 baseline. `photoPickerLauncher` 는 Preview 환경에서 placeholder * 동작 (실제 picker 실행 없음) 이라 stable 렌더. @@ -19,14 +20,11 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun onboardingProfileScreenInitialScreenshot() { AfternoteTheme { - OnboardingProfileScreen( - initialName = "", - displayImageUri = null, + OnboardingProfileContent( + state = SignUpUiState(), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onNameChange = {}, - onProfileImagePick = {}, onBackClick = {}, - onCompleteClick = {}, ) } } @@ -42,15 +40,11 @@ internal fun onboardingProfileScreenInitialScreenshot() { @Composable internal fun onboardingProfileScreenSubmittingScreenshot() { AfternoteTheme { - OnboardingProfileScreen( - initialName = "애프터노트", - displayImageUri = null, + OnboardingProfileContent( + state = SignUpUiState(name = "애프터노트", isLoading = true), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onNameChange = {}, - onProfileImagePick = {}, onBackClick = {}, - onCompleteClick = {}, - isSubmitting = true, ) } } diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTest.kt index 3da40b2300..223cd76c04 100644 --- a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTest.kt +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTest.kt @@ -7,10 +7,11 @@ import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.onboarding.presentation.COMPACT_DEVICE_SPEC import com.afternote.feature.onboarding.presentation.LARGE_FONT_SCALE +import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.android.tools.screenshot.PreviewTest /** - * [FindIdScreen] 의 시각 회귀 baseline — 아이디 찾기 1단계 (이메일 인증). + * [FindIdContent] 의 시각 회귀 baseline — 아이디 찾기 1단계 (이메일 인증). * * 시안의 세 상태를 가드: * 1. 초기 진입 — 인증번호 필드에 "확인" 없음 @@ -24,21 +25,16 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun findIdScreenInitialScreenshot() { AfternoteTheme { - FindIdScreen( - initialEmail = "", - initialCertificateCode = "", - isSendingCode = false, - isVerificationSent = false, - isSendCodeEnabled = false, - isVerifyEnabled = false, - isNextEnabled = false, - resendCooldownSeconds = 0, - hasVerificationError = false, + FindIdContent( + state = + FindIdUiState( + email = "", + certificateCode = "", + isVerificationSent = false, + failure = null, + ), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onCertificateCodeChange = {}, - onRequestCode = {}, - onVerifyCode = {}, onNextClick = {}, onBackClick = {}, ) @@ -50,21 +46,16 @@ internal fun findIdScreenInitialScreenshot() { @Composable internal fun findIdScreenCodeSentScreenshot() { AfternoteTheme { - FindIdScreen( - initialEmail = "parkchae01@gmail.com", - initialCertificateCode = "", - isSendingCode = false, - isVerificationSent = true, - isSendCodeEnabled = true, - isVerifyEnabled = false, - isNextEnabled = false, - resendCooldownSeconds = 0, - hasVerificationError = false, + FindIdContent( + state = + FindIdUiState( + email = "parkchae01@gmail.com", + certificateCode = "", + isVerificationSent = true, + failure = null, + ), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onCertificateCodeChange = {}, - onRequestCode = {}, - onVerifyCode = {}, onNextClick = {}, onBackClick = {}, ) @@ -76,21 +67,16 @@ internal fun findIdScreenCodeSentScreenshot() { @Composable internal fun findIdScreenCodeMismatchScreenshot() { AfternoteTheme { - FindIdScreen( - initialEmail = "parkchae01@gmail.com", - initialCertificateCode = "123456", - isSendingCode = false, - isVerificationSent = true, - isSendCodeEnabled = true, - isVerifyEnabled = true, - isNextEnabled = false, - resendCooldownSeconds = 0, - hasVerificationError = true, + FindIdContent( + state = + FindIdUiState( + email = "parkchae01@gmail.com", + certificateCode = "123456", + isVerificationSent = true, + failure = OnboardingFailure.VerificationRejected, + ), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onCertificateCodeChange = {}, - onRequestCode = {}, - onVerifyCode = {}, onNextClick = {}, onBackClick = {}, ) @@ -107,21 +93,16 @@ internal fun findIdScreenCodeMismatchScreenshot() { @Composable internal fun findIdScreenCodeMismatchCompactScreenshot() { AfternoteTheme { - FindIdScreen( - initialEmail = "parkchae01@gmail.com", - initialCertificateCode = "123456", - isSendingCode = false, - isVerificationSent = true, - isSendCodeEnabled = true, - isVerifyEnabled = true, - isNextEnabled = false, - resendCooldownSeconds = 0, - hasVerificationError = true, + FindIdContent( + state = + FindIdUiState( + email = "parkchae01@gmail.com", + certificateCode = "123456", + isVerificationSent = true, + failure = OnboardingFailure.VerificationRejected, + ), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onCertificateCodeChange = {}, - onRequestCode = {}, - onVerifyCode = {}, onNextClick = {}, onBackClick = {}, ) @@ -138,21 +119,16 @@ internal fun findIdScreenCodeMismatchCompactScreenshot() { @Composable internal fun findIdScreenCodeMismatchLargeFontScreenshot() { AfternoteTheme { - FindIdScreen( - initialEmail = "parkchae01@gmail.com", - initialCertificateCode = "123456", - isSendingCode = false, - isVerificationSent = true, - isSendCodeEnabled = true, - isVerifyEnabled = true, - isNextEnabled = false, - resendCooldownSeconds = 0, - hasVerificationError = true, + FindIdContent( + state = + FindIdUiState( + email = "parkchae01@gmail.com", + certificateCode = "123456", + isVerificationSent = true, + failure = OnboardingFailure.VerificationRejected, + ), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onCertificateCodeChange = {}, - onRequestCode = {}, - onVerifyCode = {}, onNextClick = {}, onBackClick = {}, ) diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTest.kt new file mode 100644 index 0000000000..36b3eb912d --- /dev/null +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTest.kt @@ -0,0 +1,171 @@ +package com.afternote.feature.onboarding.presentation.findaccount + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.tooling.preview.Preview +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.onboarding.presentation.COMPACT_DEVICE_SPEC +import com.afternote.feature.onboarding.presentation.LARGE_FONT_SCALE +import com.android.tools.screenshot.PreviewTest + +/** + * 비밀번호 찾기 세 화면의 시각 회귀 baseline (#457). + * + * 시안 5장을 그대로 가드한다: + * 1. 이메일 인증 초기 (`2431:14299`) + * 2. 인증번호 전송됨 — 파란 안내 2줄 (`2383:16680`) + * 3. 비밀번호 변경 (`2383:16789`) + * 4. 변경 완료 (`2383:16854`) + * + * 소셜 차단 팝업(`2383:16667`)은 [androidx.compose.ui.window.Dialog] 라 별도 윈도우에 그려져 + * 프리뷰 캡처에 담기지 않는다 — 표시 조건은 `FindPasswordViewModelTest` 가 상태로 가드한다. + * + * 의도된 시각 변경 시 `./gradlew :feature:onboarding:presentation:updateScreenshotTest` 로 갱신. + */ +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun findPasswordScreenInitialScreenshot() { + AfternoteTheme { + FindPasswordScreen( + initialEmail = "", + initialCertificateCode = "", + isSendingCode = false, + isVerificationSent = false, + isSendCodeEnabled = false, + isNextEnabled = false, + resendCooldownSeconds = 0, + showSocialAccountBlockedPopup = false, + snackbarHostState = remember { SnackbarHostState() }, + onEmailChange = {}, + onCertificateCodeChange = {}, + onRequestCode = {}, + onSocialAccountBlockedConfirm = {}, + onNextClick = {}, + onBackClick = {}, + ) + } +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun findPasswordScreenCodeSentScreenshot() { + AfternoteTheme { + FindPasswordScreen( + initialEmail = "parkchae01@gmail.com", + initialCertificateCode = "", + isSendingCode = false, + isVerificationSent = true, + isSendCodeEnabled = true, + isNextEnabled = false, + resendCooldownSeconds = 0, + showSocialAccountBlockedPopup = false, + snackbarHostState = remember { SnackbarHostState() }, + onEmailChange = {}, + onCertificateCodeChange = {}, + onRequestCode = {}, + onSocialAccountBlockedConfirm = {}, + onNextClick = {}, + onBackClick = {}, + ) + } +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun findPasswordResetScreenInitialScreenshot() { + AfternoteTheme { + FindPasswordResetScreen( + initialPassword = "", + initialPasswordConfirm = "", + isPasswordRuleSatisfied = false, + isNextEnabled = false, + snackbarHostState = remember { SnackbarHostState() }, + onPasswordChange = {}, + onPasswordConfirmChange = {}, + onNextClick = {}, + onBackClick = {}, + ) + } +} + +/** + * 규칙 충족 변형 — 첫 줄 안내가 회색(gray5)에서 강조색(b1)으로 갈리는 것이 이 화면의 유일한 + * 상태 변화다. 색만 바뀌는 축이라 초기 baseline 으로는 잡히지 않는다. + */ +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun findPasswordResetScreenRuleSatisfiedScreenshot() { + AfternoteTheme { + FindPasswordResetScreen( + initialPassword = "NewPass1!", + initialPasswordConfirm = "NewPass1!", + isPasswordRuleSatisfied = true, + isNextEnabled = true, + snackbarHostState = remember { SnackbarHostState() }, + onPasswordChange = {}, + onPasswordConfirmChange = {}, + onNextClick = {}, + onBackClick = {}, + ) + } +} + +/** + * 좁은 화면(360×800dp @320dpi) 변형 — 스크롤이 붙어 있어도 안내 2줄이 접히면 시안과 어긋난다. + * + * 기준값은 [COMPACT_DEVICE_SPEC]. + */ +@PreviewTest +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun findPasswordResetScreenCompactScreenshot() { + AfternoteTheme { + FindPasswordResetScreen( + initialPassword = "NewPass1!", + initialPasswordConfirm = "NewPass1!", + isPasswordRuleSatisfied = true, + isNextEnabled = true, + snackbarHostState = remember { SnackbarHostState() }, + onPasswordChange = {}, + onPasswordConfirmChange = {}, + onNextClick = {}, + onBackClick = {}, + ) + } +} + +/** + * 글자 확대(×1.5) 변형 — 안내 2줄이 가장 먼저 넘치는 자리다. 기준값은 [LARGE_FONT_SCALE]. + */ +@PreviewTest +@Preview(showBackground = true, fontScale = LARGE_FONT_SCALE) +@Composable +internal fun findPasswordResetScreenLargeFontScreenshot() { + AfternoteTheme { + FindPasswordResetScreen( + initialPassword = "NewPass1!", + initialPasswordConfirm = "NewPass1!", + isPasswordRuleSatisfied = true, + isNextEnabled = true, + snackbarHostState = remember { SnackbarHostState() }, + onPasswordChange = {}, + onPasswordConfirmChange = {}, + onNextClick = {}, + onBackClick = {}, + ) + } +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun findPasswordCompleteScreenScreenshot() { + AfternoteTheme { + FindPasswordCompleteScreen(onLoginClick = {}) + } +} diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTest.kt index aae618391f..4744e399b1 100644 --- a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTest.kt +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTest.kt @@ -5,10 +5,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.android.tools.screenshot.PreviewTest /** - * [LoginScreen] 의 시각 회귀 baseline — 초기 진입 (빈 입력, 비로딩). + * [LoginContent] 의 시각 회귀 baseline — 초기 진입 (빈 입력, 비로딩). * * 의도된 시각 변경 시 `./gradlew :feature:onboarding:presentation:updateScreenshotTest` 로 갱신. */ @@ -17,21 +18,15 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun loginScreenInitialScreenshot() { AfternoteTheme { - LoginScreen( - initialEmail = "", - initialPassword = "", - onEmailChange = {}, - onPasswordChange = {}, - onLoginClick = {}, + LoginContent( + state = LoginUiState(), + onIntent = {}, onSignUpClick = {}, onFindAccountClick = {}, onKakaoLoginClick = {}, onGoogleLoginClick = {}, - onRetryLogin = {}, - onNetworkErrorDismiss = {}, onBackClick = {}, snackbarHostState = remember { SnackbarHostState() }, - isLoading = false, ) } } @@ -44,21 +39,20 @@ internal fun loginScreenInitialScreenshot() { @Composable internal fun loginScreenCredentialErrorScreenshot() { AfternoteTheme { - LoginScreen( - initialEmail = "user@example.com", - initialPassword = "wrong-password", - onEmailChange = {}, - onPasswordChange = {}, - onLoginClick = {}, + LoginContent( + state = + LoginUiState( + email = "user@example.com", + password = "wrong-password", + failure = OnboardingFailure.CredentialsRejected, + ), + onIntent = {}, onSignUpClick = {}, onFindAccountClick = {}, onKakaoLoginClick = {}, onGoogleLoginClick = {}, - onRetryLogin = {}, - onNetworkErrorDismiss = {}, onBackClick = {}, snackbarHostState = remember { SnackbarHostState() }, - hasCredentialError = true, ) } } diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreenScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreenScreenshotTest.kt index f2ede7cb31..5231c647e9 100644 --- a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreenScreenshotTest.kt +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreenScreenshotTest.kt @@ -8,7 +8,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.android.tools.screenshot.PreviewTest /** - * [SignUpPasswordScreen] 의 시각 회귀 baseline — 초기 진입 상태 (빈 입력 + 비밀번호 룰 미충족 + 다음 비활성). + * [SignUpPasswordContent] 의 시각 회귀 baseline — 초기 진입 상태 (빈 입력 + 비밀번호 룰 미충족 + 다음 비활성). * * 의도된 시각 변경 시 `./gradlew :feature:onboarding:presentation:updateScreenshotTest` 로 갱신. */ @@ -17,14 +17,10 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun signUpPasswordScreenInitialScreenshot() { AfternoteTheme { - SignUpPasswordScreen( - initialPassword = "", - initialPasswordConfirm = "", - isPasswordRuleSatisfied = false, - isNextEnabled = false, + SignUpPasswordContent( + state = SignUpUiState(), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onPasswordChange = {}, - onPasswordConfirmChange = {}, onNextClick = {}, onBackClick = {}, ) diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreenScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreenScreenshotTest.kt index f4513ff1ae..6962dea2b8 100644 --- a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreenScreenshotTest.kt +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreenScreenshotTest.kt @@ -8,7 +8,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.android.tools.screenshot.PreviewTest /** - * [SignUpResidentNumberScreen] 의 시각 회귀 baseline — 초기 진입 상태 (빈 입력 + 다음 비활성). + * [SignUpResidentNumberContent] 의 시각 회귀 baseline — 초기 진입 상태 (빈 입력 + 다음 비활성). * * 의도된 시각 변경 시 `./gradlew :feature:onboarding:presentation:updateScreenshotTest` 로 갱신. */ @@ -17,13 +17,10 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun signUpResidentNumberScreenInitialScreenshot() { AfternoteTheme { - SignUpResidentNumberScreen( - initialFrontNumber = "", - initialBackNumber = "", - isNextEnabled = false, + SignUpResidentNumberContent( + state = SignUpUiState(), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onFrontNumberChange = {}, - onBackNumberChange = {}, onNextClick = {}, onBackClick = {}, ) diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTest.kt index 2667125fce..1eb45e3b6a 100644 --- a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTest.kt +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTest.kt @@ -5,10 +5,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.android.tools.screenshot.PreviewTest /** - * [SignUpScreen] 의 시각 회귀 baseline — 회원가입 Step 1 (이메일 + 인증번호). + * [SignUpContent] 의 시각 회귀 baseline — 회원가입 Step 1 (이메일 + 인증번호). * * 세 케이스로 화면 흐름의 핵심 상태를 가드: * 1. 초기 진입 — 빈 입력 + "인증번호 받기" 비활성 @@ -22,20 +23,10 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun signUpScreenInitialScreenshot() { AfternoteTheme { - SignUpScreen( - initialEmail = "", - initialVerificationCode = "", - isVerificationSent = false, - isSendingCode = false, - isEmailFormatValid = false, - resendCooldownSeconds = 0, - hasVerificationError = false, - isNextEnabled = false, + SignUpContent( + state = SignUpUiState(), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onVerificationCodeChange = {}, - onRequestVerification = {}, - onNextClick = {}, onBackClick = {}, ) } @@ -46,20 +37,15 @@ internal fun signUpScreenInitialScreenshot() { @Composable internal fun signUpScreenVerificationInProgressScreenshot() { AfternoteTheme { - SignUpScreen( - initialEmail = "user@example.com", - initialVerificationCode = "", - isVerificationSent = true, - isSendingCode = false, - isEmailFormatValid = true, - resendCooldownSeconds = 20, - hasVerificationError = false, - isNextEnabled = false, + SignUpContent( + state = + SignUpUiState( + email = "user@example.com", + isVerificationSent = true, + resendCooldownSeconds = 20, + ), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onVerificationCodeChange = {}, - onRequestVerification = {}, - onNextClick = {}, onBackClick = {}, ) } @@ -70,20 +56,21 @@ internal fun signUpScreenVerificationInProgressScreenshot() { @Composable internal fun signUpScreenVerificationMismatchScreenshot() { AfternoteTheme { - SignUpScreen( - initialEmail = "user@example.com", - initialVerificationCode = "000000", - isVerificationSent = true, - isSendingCode = false, - isEmailFormatValid = true, - resendCooldownSeconds = 0, - hasVerificationError = true, - isNextEnabled = false, + SignUpContent( + state = + SignUpUiState( + email = "user@example.com", + verificationCode = "000000", + isVerificationSent = true, + // baseline 은 «다음» 이 비활성인 채로 잡혀 있다. isNextEnabled 가 파생값이 된 + // 지금 그 픽셀을 내는 상태는 검증이 아직 진행 중인 프레임이다 — 서버가 무효를 + // 알려 hasVerificationError 가 서고(VerificationRejected) 요청이 끝나 + // isVerifyingEmail 이 내려가기(EmailVerifyFinished) 전 사이다. + isVerifyingEmail = true, + failure = OnboardingFailure.VerificationRejected, + ), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onVerificationCodeChange = {}, - onRequestVerification = {}, - onNextClick = {}, onBackClick = {}, ) } diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTest.kt index 9bffe014b4..f62ebea55d 100644 --- a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTest.kt +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTest.kt @@ -6,10 +6,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.onboarding.presentation.COMPACT_DEVICE_SPEC +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState import com.android.tools.screenshot.PreviewTest /** - * [OnboardingTermsScreen] 의 시각 회귀 baseline — 초기 진입 (모든 약관 미동의, 다음 비활성). + * [OnboardingTermsContent] 의 시각 회귀 baseline — 초기 진입 (모든 약관 미동의, 다음 비활성). * * 의도된 시각 변경 시 `./gradlew :feature:onboarding:presentation:updateScreenshotTest` 로 갱신. */ @@ -18,14 +19,10 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun onboardingTermsScreenInitialScreenshot() { AfternoteTheme { - OnboardingTermsScreen( - termsState = TermsState(), - isNextEnabled = false, + OnboardingTermsContent( + state = SignUpUiState(), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onTermsToggle = {}, - onPrivacyToggle = {}, - onMarketingToggle = {}, - onToggleAll = {}, onViewTermsClick = {}, onNextClick = {}, onBackClick = {}, @@ -43,14 +40,10 @@ internal fun onboardingTermsScreenInitialScreenshot() { @Composable internal fun onboardingTermsScreenInitialCompactScreenshot() { AfternoteTheme { - OnboardingTermsScreen( - termsState = TermsState(), - isNextEnabled = false, + OnboardingTermsContent( + state = SignUpUiState(), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onTermsToggle = {}, - onPrivacyToggle = {}, - onMarketingToggle = {}, - onToggleAll = {}, onViewTermsClick = {}, onNextClick = {}, onBackClick = {}, diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTestKt/onboardingProfileScreenInitialScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTestKt/onboardingProfileScreenInitialScreenshot_748aa731_0.png index 7b887c1d02..ddaab5a86b 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTestKt/onboardingProfileScreenInitialScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTestKt/onboardingProfileScreenInitialScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTestKt/onboardingProfileScreenSubmittingScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTestKt/onboardingProfileScreenSubmittingScreenshot_748aa731_0.png index d189da63bc..6e2d9cf808 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTestKt/onboardingProfileScreenSubmittingScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTestKt/onboardingProfileScreenSubmittingScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/WelcomeScreenScreenshotTestKt/welcomeScreenCompactScreenshot_c2abd7e6_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/WelcomeScreenScreenshotTestKt/welcomeScreenCompactScreenshot_c2abd7e6_0.png index 467fff5556..91801e8702 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/WelcomeScreenScreenshotTestKt/welcomeScreenCompactScreenshot_c2abd7e6_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/WelcomeScreenScreenshotTestKt/welcomeScreenCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/WelcomeScreenScreenshotTestKt/welcomeScreenLargeFontScreenshot_39fcf3fa_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/WelcomeScreenScreenshotTestKt/welcomeScreenLargeFontScreenshot_39fcf3fa_0.png index e33c43b918..11f2c7ce0b 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/WelcomeScreenScreenshotTestKt/welcomeScreenLargeFontScreenshot_39fcf3fa_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/WelcomeScreenScreenshotTestKt/welcomeScreenLargeFontScreenshot_39fcf3fa_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/WelcomeScreenScreenshotTestKt/welcomeScreenScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/WelcomeScreenScreenshotTestKt/welcomeScreenScreenshot_748aa731_0.png index 49741e5c66..e1cb53dece 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/WelcomeScreenScreenshotTestKt/welcomeScreenScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/WelcomeScreenScreenshotTestKt/welcomeScreenScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeMismatchCompactScreenshot_c2abd7e6_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeMismatchCompactScreenshot_c2abd7e6_0.png index 5d9d921f98..ef052f611e 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeMismatchCompactScreenshot_c2abd7e6_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeMismatchCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeMismatchLargeFontScreenshot_39fcf3fa_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeMismatchLargeFontScreenshot_39fcf3fa_0.png index 07e6be43e3..eaf18fff1a 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeMismatchLargeFontScreenshot_39fcf3fa_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeMismatchLargeFontScreenshot_39fcf3fa_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeMismatchScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeMismatchScreenshot_748aa731_0.png index a49f7e7119..85cbfe55c5 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeMismatchScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeMismatchScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeSentScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeSentScreenshot_748aa731_0.png index 8f8ffbd201..983996d606 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeSentScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenCodeSentScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenInitialScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenInitialScreenshot_748aa731_0.png index a47248b7b5..7767b9e1a5 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenInitialScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTestKt/findIdScreenInitialScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordCompleteScreenScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordCompleteScreenScreenshot_748aa731_0.png new file mode 100644 index 0000000000..80d464b905 Binary files /dev/null and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordCompleteScreenScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordResetScreenCompactScreenshot_c2abd7e6_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordResetScreenCompactScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..3531759dd2 Binary files /dev/null and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordResetScreenCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordResetScreenInitialScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordResetScreenInitialScreenshot_748aa731_0.png new file mode 100644 index 0000000000..bc8c345d9b Binary files /dev/null and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordResetScreenInitialScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordResetScreenLargeFontScreenshot_39fcf3fa_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordResetScreenLargeFontScreenshot_39fcf3fa_0.png new file mode 100644 index 0000000000..5e05d28ba1 Binary files /dev/null and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordResetScreenLargeFontScreenshot_39fcf3fa_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordResetScreenRuleSatisfiedScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordResetScreenRuleSatisfiedScreenshot_748aa731_0.png new file mode 100644 index 0000000000..8047b4e4b5 Binary files /dev/null and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordResetScreenRuleSatisfiedScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordScreenCodeSentScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordScreenCodeSentScreenshot_748aa731_0.png new file mode 100644 index 0000000000..b8398d3e34 Binary files /dev/null and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordScreenCodeSentScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordScreenInitialScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordScreenInitialScreenshot_748aa731_0.png new file mode 100644 index 0000000000..e052f4f6ea Binary files /dev/null and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreenshotTestKt/findPasswordScreenInitialScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTestKt/loginScreenCredentialErrorScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTestKt/loginScreenCredentialErrorScreenshot_748aa731_0.png index ba1d0ae626..751fa809d5 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTestKt/loginScreenCredentialErrorScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTestKt/loginScreenCredentialErrorScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTestKt/loginScreenInitialScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTestKt/loginScreenInitialScreenshot_748aa731_0.png index dd7837d841..4f5398cec6 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTestKt/loginScreenInitialScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTestKt/loginScreenInitialScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreenScreenshotTestKt/signUpPasswordScreenInitialScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreenScreenshotTestKt/signUpPasswordScreenInitialScreenshot_748aa731_0.png index 95b4a95961..8a7a83e5ad 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreenScreenshotTestKt/signUpPasswordScreenInitialScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreenScreenshotTestKt/signUpPasswordScreenInitialScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreenScreenshotTestKt/signUpResidentNumberScreenInitialScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreenScreenshotTestKt/signUpResidentNumberScreenInitialScreenshot_748aa731_0.png index 945bd93ae9..5e36d170dd 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreenScreenshotTestKt/signUpResidentNumberScreenInitialScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreenScreenshotTestKt/signUpResidentNumberScreenInitialScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTestKt/signUpScreenInitialScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTestKt/signUpScreenInitialScreenshot_748aa731_0.png index e6d18e71dd..c7398beeee 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTestKt/signUpScreenInitialScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTestKt/signUpScreenInitialScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTestKt/signUpScreenVerificationInProgressScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTestKt/signUpScreenVerificationInProgressScreenshot_748aa731_0.png index 8f39f2a6a7..2f3ae385a6 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTestKt/signUpScreenVerificationInProgressScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTestKt/signUpScreenVerificationInProgressScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTestKt/signUpScreenVerificationMismatchScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTestKt/signUpScreenVerificationMismatchScreenshot_748aa731_0.png index c083506d93..aef67f4b5a 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTestKt/signUpScreenVerificationMismatchScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTestKt/signUpScreenVerificationMismatchScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTestKt/onboardingTermsScreenInitialCompactScreenshot_c2abd7e6_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTestKt/onboardingTermsScreenInitialCompactScreenshot_c2abd7e6_0.png index 34eb3d9ea0..58c583a808 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTestKt/onboardingTermsScreenInitialCompactScreenshot_c2abd7e6_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTestKt/onboardingTermsScreenInitialCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTestKt/onboardingTermsScreenInitialScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTestKt/onboardingTermsScreenInitialScreenshot_748aa731_0.png index 3234e293d8..3fabcf2602 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTestKt/onboardingTermsScreenInitialScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTestKt/onboardingTermsScreenInitialScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/terms/TermsDetailScreenScreenshotTestKt/termsDetailScreenScreenshot_748aa731_0.png b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/terms/TermsDetailScreenScreenshotTestKt/termsDetailScreenScreenshot_748aa731_0.png index 7b87efebfa..82692953ef 100644 Binary files a/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/terms/TermsDetailScreenScreenshotTestKt/termsDetailScreenScreenshot_748aa731_0.png and b/feature/onboarding/presentation/src/screenshotTestDebug/reference/com/afternote/feature/onboarding/presentation/terms/TermsDetailScreenScreenshotTestKt/termsDetailScreenScreenshot_748aa731_0.png differ diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingPasswordRuleTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingPasswordRuleTest.kt new file mode 100644 index 0000000000..c81db4376d --- /dev/null +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingPasswordRuleTest.kt @@ -0,0 +1,65 @@ +package com.afternote.feature.onboarding.presentation + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class OnboardingPasswordRuleTest { + @Test + fun `dev 서버를 통과한 비밀번호를 모두 허용한다`() { + val acceptedPasswords = + listOf( + PasswordCase("1차 #1", "Password1!"), + PasswordCase("1차 #2 - 15자", "Password12345!a"), + PasswordCase("1차 #7 - 소문자 영문", "password12345!a"), + PasswordCase("2차 기준", "Aa1!aaaa"), + PasswordCase("2차 15자", "Aa1!aaaaaaaaaaa"), + PasswordCase("2차 대문자만", "AA1!AAAA"), + PasswordCase("2차 소문자만", "aa1!aaaa"), + PasswordCase("허용 특수문자 @", "Aa1@aaaa"), + PasswordCase("허용 특수문자 $", "Aa1\$aaaa"), + PasswordCase("허용 특수문자 !", "Aa1!aaaa"), + PasswordCase("허용 특수문자 %", "Aa1%aaaa"), + PasswordCase("허용 특수문자 *", "Aa1*aaaa"), + PasswordCase("허용 특수문자 #", "Aa1#aaaa"), + PasswordCase("허용 특수문자 ?", "Aa1?aaaa"), + PasswordCase("허용 특수문자 &", "Aa1&aaaa"), + ) + + acceptedPasswords.forEach { case -> + assertTrue("${case.description}: ${case.password}", OnboardingPasswordRule.isSatisfied(case.password)) + } + } + + @Test + fun `dev 서버가 거절한 비밀번호와 Unicode 숫자를 모두 차단한다`() { + val rejectedPasswords = + listOf( + PasswordCase("1차 #3 - 16자", "Password12345!ab"), + PasswordCase("1차 #4 - 하이픈", "Password123-abc"), + PasswordCase("1차 #5 - 언더바", "Password123_abc"), + PasswordCase("1차 #6 - 물결", "Password123~abc"), + PasswordCase("1차 #8 - 한글", "Password1가나다"), + PasswordCase("1차 #9 - 공백", "Password1 abc"), + PasswordCase("2차 하이픈", "Aa1!aaa-"), + PasswordCase("2차 언더바", "Aa1!aaa_"), + PasswordCase("2차 물결", "Aa1!aaa~"), + PasswordCase("2차 마침표", "Aa1!aaa."), + PasswordCase("2차 한글", "Aa1!aaa가"), + PasswordCase("2차 중간 공백", "Aa1!a a a"), + PasswordCase("2차 7자", "Aa1!aaa"), + PasswordCase("2차 16자", "Aa1!aaaaaaaaaaaa"), + PasswordCase("전각 숫자", "Aa\uFF11!aaaa"), + PasswordCase("아라비아-인도 숫자", "Aa\u0661!aaaa"), + ) + + rejectedPasswords.forEach { case -> + assertFalse("${case.description}: ${case.password}", OnboardingPasswordRule.isSatisfied(case.password)) + } + } +} + +private data class PasswordCase( + val description: String, + val password: String, +) diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingProfilePickerResultTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingProfilePickerResultTest.kt index 7a538a85b9..f285f788b8 100644 --- a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingProfilePickerResultTest.kt +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingProfilePickerResultTest.kt @@ -1,18 +1,104 @@ package com.afternote.feature.onboarding.presentation +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.core.app.ActivityOptionsCompat +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.onboarding.presentation.signup.SignUpIntent +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState import org.junit.Assert.assertEquals +import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import com.afternote.core.ui.R as CoreUiR +/** + * 프로필 화면이 포토 피커 결과를 어떻게 다루는지 고정한다 (#1675). + * + * 결과 처리 helper 는 화면 파일 안에서만 쓰이는 `private` 구현이라 테스트가 직접 부르지 않는다. + * 대신 화면의 공개 계약 — 「프로필 수정 버튼」을 눌러 피커를 띄우고 그 결과가 돌아왔을 때 + * `PickProfileImage` 이 불리는가 — 으로 판정한다. 피커 자체는 실행하지 않고 + * [LocalActivityResultRegistryOwner] 를 결과를 즉시 돌려주는 레지스트리로 갈아 끼운다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) class OnboardingProfilePickerResultTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val pickedUris = mutableListOf() + + /** 피커를 띄우면 [pickerResult] 를 그대로 돌려주는 화면을 세운다. */ + private fun setProfileContent(pickerResult: Uri?) { + val registryOwner = + object : ActivityResultRegistryOwner { + override val activityResultRegistry = ImmediateResultRegistry(pickerResult) + } + composeRule.setContent { + AfternoteTheme { + CompositionLocalProvider(LocalActivityResultRegistryOwner provides registryOwner) { + OnboardingProfileContent( + state = SignUpUiState(name = "김노을"), + snackbarHostState = remember { SnackbarHostState() }, + onIntent = { intent -> + if (intent is SignUpIntent.PickProfileImage) pickedUris.add(Uri.parse(intent.uri)) + }, + onBackClick = {}, + ) + } + } + } + } + + private fun clickProfileImagePicker() { + val editDescription = + composeRule.activity.getString(CoreUiR.string.core_ui_content_description_profile_edit) + composeRule.onNodeWithContentDescription(editDescription).performClick() + } + @Test fun `갤러리 취소는 기존 프로필 이미지를 변경하지 않는다`() { - val previouslySelectedUri = "content://profile/selected" - var profileImageUri = previouslySelectedUri + setProfileContent(pickerResult = null) - handleProfileImagePickerResult(uri = null) { pickedUri -> - profileImageUri = pickedUri.toString() - } + clickProfileImagePicker() + + assertEquals(emptyList(), pickedUris) + } + + @Test + fun `갤러리에서 고른 이미지는 프로필 이미지로 올라간다`() { + val selectedUri = Uri.parse("content://profile/selected") + setProfileContent(pickerResult = selectedUri) + + clickProfileImagePicker() + + assertEquals(listOf(selectedUri), pickedUris) + } +} - assertEquals(previouslySelectedUri, profileImageUri) +/** `launch` 즉시 [result] 를 돌려주는 레지스트리. 실제 갤러리 Activity 를 띄우지 않는다. */ +private class ImmediateResultRegistry( + private val result: Uri?, +) : ActivityResultRegistry() { + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) { + @Suppress("UNCHECKED_CAST") + dispatchResult(requestCode, result as O) } } diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingTestDoubles.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingTestDoubles.kt new file mode 100644 index 0000000000..82b0eaa856 --- /dev/null +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingTestDoubles.kt @@ -0,0 +1,53 @@ +package com.afternote.feature.onboarding.presentation + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.repository.account.AccountRepository +import com.afternote.core.model.AccountRegistration +import com.afternote.core.model.FoundAccount + +/** 계측을 보지 않는 테스트용. */ +internal object NoopErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit +} + +/** + * 리듀서 테스트가 쓰는 저장소. **어느 경로도 부르지 않는다** — 순수 전이만 보는 층이라 + * 호출이 일어났다면 그것이 결함이고, 그 자리에서 드러나야 한다(core:data 의 Fake 들과 같은 규칙). + */ +internal object UnusedAccountRepository : AccountRepository { + override suspend fun sendEmailCode(email: String): Result = error("sendEmailCode 는 리듀서 경로에서 호출되면 안 됨") + + override suspend fun verifyEmail( + email: String, + certificateCode: String, + ): Result = error("verifyEmail 은 리듀서 경로에서 호출되면 안 됨") + + override suspend fun sendFindCode(email: String): Result = error("sendFindCode 는 리듀서 경로에서 호출되면 안 됨") + + override suspend fun findAccount( + email: String, + certificateCode: String, + ): Result = error("findAccount 는 리듀서 경로에서 호출되면 안 됨") + + override suspend fun signUp( + email: String, + password: String, + name: String, + profileUrl: String?, + ): Result = error("signUp 은 리듀서 경로에서 호출되면 안 됨") + + override suspend fun resetPassword( + email: String, + certificateCode: String, + newPassword: String, + confirmPassword: String, + ): Result = error("resetPassword 는 리듀서 경로에서 호출되면 안 됨") + + override suspend fun passwordChange( + currentPassword: String, + newPassword: String, + ): Result = error("passwordChange 는 리듀서 경로에서 호출되면 안 됨") +} diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdReducerTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdReducerTest.kt new file mode 100644 index 0000000000..c4e51fa97a --- /dev/null +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdReducerTest.kt @@ -0,0 +1,102 @@ +package com.afternote.feature.onboarding.presentation.findaccount + +import com.afternote.core.model.FoundAccount +import com.afternote.feature.onboarding.presentation.NoopErrorReporter +import com.afternote.feature.onboarding.presentation.OnboardingFailure +import com.afternote.feature.onboarding.presentation.UnusedAccountRepository +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 아이디 찾기의 **순수 전이** (#1802). + * + * 코루틴 하네스가 없다 — `Dispatchers.setMain` 도, `runTest` 도 쓰지 않는다. 여기서 보는 + * Intent 는 저장소를 부르지 않고 `dispatch` → `reduce` 만 지나기 때문이고, 그것이 MVI 로 + * 옮긴 이득이다. 비동기·부수효과는 ViewModel 테스트가 본다. + * + * [UnusedAccountRepository] 는 어느 경로가 불려도 `error` 로 드러내므로, 이 파일이 초록인 것 + * 자체가 「이 Intent 들은 저장소를 부르지 않는다」 의 증거다. + * + * Robolectric 은 [FindIdUiState.isEmailFormatValid] 가 읽는 `android.util.Patterns` 때문에만 + * 필요하다 — 순수 JVM 에서는 그 상수가 null 이다. 시간·디스패처는 여전히 개입하지 않는다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class FindIdReducerTest { + private fun viewModel() = FindIdViewModel(UnusedAccountRepository, NoopErrorReporter) + + @Test + fun `이메일이 바뀌면 앞서 받은 인증 에러가 함께 지워진다`() { + val viewModel = viewModel() + + viewModel.onIntent(FindIdIntent.UpdateEmail("user@example.com")) + viewModel.onIntent(FindIdIntent.UpdateCertificateCode("123456")) + + assertEquals("user@example.com", viewModel.uiState.value.email) + assertEquals("123456", viewModel.uiState.value.certificateCode) + + viewModel.onIntent(FindIdIntent.UpdateEmail("other@example.com")) + + val state = viewModel.uiState.value + assertNull(state.foundAccount) + assertFalse((state.failure == OnboardingFailure.VerificationRejected)) + } + + @Test + fun `ConsumeError 는 스낵바 신호만 지우고 입력은 남긴다`() { + val viewModel = viewModel() + viewModel.onIntent(FindIdIntent.UpdateEmail("user@example.com")) + + viewModel.onIntent(FindIdIntent.ConsumeError) + + val state = viewModel.uiState.value + assertNull(state.failure.toDisplay().snackbarMessage) + assertEquals("user@example.com", state.email) + } + + @Test + fun `이메일 형식이 아니면 인증번호 요청이 저장소로 가지 않는다`() { + val viewModel = viewModel() + viewModel.onIntent(FindIdIntent.UpdateEmail("not-an-email")) + + viewModel.onIntent(FindIdIntent.RequestVerificationCode) + + assertFalse(viewModel.uiState.value.isSendingCode) + } + + @Test + fun `확인이 열리지 않은 상태에서는 검증이 저장소로 가지 않는다`() { + val viewModel = viewModel() + viewModel.onIntent(FindIdIntent.UpdateEmail("user@example.com")) + viewModel.onIntent(FindIdIntent.UpdateCertificateCode("123")) + + viewModel.onIntent(FindIdIntent.VerifyCode) + + assertFalse(viewModel.uiState.value.isVerifying) + } + + @Test + fun `버튼 활성은 상태에서 파생된다`() { + val sent = + FindIdUiState( + email = "user@example.com", + certificateCode = "123456", + isVerificationSent = true, + ) + + assertTrue(sent.isSendCodeEnabled) + assertTrue(sent.isVerifyEnabled) + // "다음" 은 확인으로 계정을 받아야만 열린다. + assertFalse(sent.isNextEnabled) + assertTrue(sent.copy(foundAccount = FoundAccount(name = "사용자", email = "user@example.com")).isNextEnabled) + assertFalse(sent.copy(resendCooldownSeconds = 20).isSendCodeEnabled) + } +} diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModelTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModelTest.kt new file mode 100644 index 0000000000..f0f230c665 --- /dev/null +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModelTest.kt @@ -0,0 +1,158 @@ +package com.afternote.feature.onboarding.presentation.findaccount + +import com.afternote.core.domain.repository.account.AccountRepository +import com.afternote.core.model.AccountRegistration +import com.afternote.core.model.FoundAccount +import com.afternote.feature.onboarding.presentation.NoopErrorReporter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 아이디 찾기의 **연타 가드** (#1864). + * + * [FindIdReducerTest] 가 보는 순수 전이와 갈라 둔다 — 여기서 보는 것은 상태가 아니라 + * 「서버 호출이 몇 번 나갔는가」 다. + * + * 두 함수 다 상태 플래그(`isSendingCode`·`isVerifying`)만으로는 못 막는다. 가드가 + * `viewModelScope.launch` **밖**에서 상태를 읽는데 launch 본문은 다음 루프에 돌므로, 플래그가 + * 상태에 반영되기 전에 두 번째 호출이 같은 `false` 를 읽고 통과한다. + * + * **[StandardTestDispatcher] 여야 그 창이 재현된다.** `UnconfinedTestDispatcher` 는 launch 본문을 + * 즉시 돌려 플래그가 이미 반영돼 있으므로, 결함이 있는 코드에서도 이 테스트가 통과한다 — + * 비밀번호 찾기에서 실제로 그렇게 통과하고 있었다 (#457). + * + * Robolectric 은 [FindIdUiState.isEmailFormatValid] 가 읽는 `android.util.Patterns` 때문에만 필요하다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class FindIdViewModelTest { + @After + fun tearDown() { + Dispatchers.resetMain() + } + + /** 중복 발송은 코드를 새로 발급해 앞서 안내한 인증번호를 조용히 무효로 만든다. */ + @Test + fun `인증번호 발송 연타는 상태가 반영되기 전에도 요청을 한 번만 낸다`() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val repository = CountingAccountRepository() + val viewModel = viewModel(repository) + viewModel.onIntent(FindIdIntent.UpdateEmail(EMAIL)) + + viewModel.onIntent(FindIdIntent.RequestVerificationCode) + viewModel.onIntent(FindIdIntent.RequestVerificationCode) + advanceUntilIdle() + + assertEquals(1, repository.sendFindCodeCalls) + } + + /** + * `auth/email/find` 는 인증번호를 검증하며 **지운다**(BE `EmailService.verifyAndDeleteCode`). + * 두 번째 요청은 코드가 이미 없어 1207 로 실패하고, 첫 요청이 성공했는데도 사용자에게는 + * 「인증번호가 일치하지 않습니다」 가 뜬다. + */ + @Test + fun `인증번호 확인 연타는 상태가 반영되기 전에도 조회를 한 번만 부른다`() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val repository = CountingAccountRepository() + val viewModel = readyToVerify(repository) + + viewModel.onIntent(FindIdIntent.VerifyCode) + viewModel.onIntent(FindIdIntent.VerifyCode) + advanceUntilIdle() + + assertEquals(1, repository.findAccountCalls) + } + + /** 앞 요청이 끝난 뒤의 재확인까지 막지는 않는다 — 가드는 「진행 중」만 본다. */ + @Test + fun `앞 확인이 끝난 뒤의 재확인은 다시 조회한다`() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val repository = CountingAccountRepository() + val viewModel = readyToVerify(repository) + + viewModel.onIntent(FindIdIntent.VerifyCode) + advanceUntilIdle() + viewModel.onIntent(FindIdIntent.VerifyCode) + advanceUntilIdle() + + assertEquals(2, repository.findAccountCalls) + } + + private fun viewModel(repository: AccountRepository) = FindIdViewModel(repository, NoopErrorReporter) + + /** 발송을 끝내 `isVerifyEnabled` 를 연 상태 — 확인 연타가 통과할 수 있는 자리다. */ + private suspend fun TestScope.readyToVerify(repository: CountingAccountRepository): FindIdViewModel { + val viewModel = viewModel(repository) + viewModel.onIntent(FindIdIntent.UpdateEmail(EMAIL)) + viewModel.onIntent(FindIdIntent.RequestVerificationCode) + advanceUntilIdle() + viewModel.onIntent(FindIdIntent.UpdateCertificateCode(CODE)) + return viewModel + } + + private class CountingAccountRepository : AccountRepository { + var sendFindCodeCalls = 0 + var findAccountCalls = 0 + + override suspend fun sendFindCode(email: String): Result { + sendFindCodeCalls++ + return Result.success(Unit) + } + + override suspend fun findAccount( + email: String, + certificateCode: String, + ): Result { + findAccountCalls++ + return Result.success(FoundAccount(name = "박서연", email = email)) + } + + override suspend fun sendEmailCode(email: String): Result = error("이 테스트가 부르지 않는 경로") + + override suspend fun verifyEmail( + email: String, + certificateCode: String, + ): Result = error("이 테스트가 부르지 않는 경로") + + override suspend fun signUp( + email: String, + password: String, + name: String, + profileUrl: String?, + ): Result = error("이 테스트가 부르지 않는 경로") + + override suspend fun resetPassword( + email: String, + certificateCode: String, + newPassword: String, + confirmPassword: String, + ): Result = error("이 테스트가 부르지 않는 경로") + + override suspend fun passwordChange( + currentPassword: String, + newPassword: String, + ): Result = error("이 테스트가 부르지 않는 경로") + } + + private companion object { + const val EMAIL = "user@example.com" + const val CODE = "123456" + } +} diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordViewModelTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordViewModelTest.kt new file mode 100644 index 0000000000..98c2e62e31 --- /dev/null +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordViewModelTest.kt @@ -0,0 +1,391 @@ +package com.afternote.feature.onboarding.presentation.findaccount + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.error.CoreAuthFailure +import com.afternote.core.domain.repository.account.AccountRepository +import com.afternote.core.model.AccountRegistration +import com.afternote.core.model.FoundAccount +import com.afternote.core.ui.UiText +import com.afternote.feature.onboarding.presentation.OnboardingFailure +import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +/** + * 비밀번호 찾기 흐름의 상태 전이 회귀 가드 (#457). + * + * 가드하는 계약 네 가지. + * 1. 소셜 가입 계정(서버 code 1702)은 **스낵바가 아니라 차단 팝업** 신호로 갈린다 — 시안 `2383:16667`. + * 2. 인증번호를 서버에 미리 확인하지 않는다 — 확인하면 서버가 코드를 지워 최종 제출이 실패한다. + * 3. 최종 제출의 1207 은 전용 문구로 갈린다 — 폴백 문구로는 "무엇을 다시 해야 하는지" 를 못 말한다. + * 4. 완료 후 흐름 상태를 버린다 — 그래프 스코프 VM 이라 재진입 시 완료 화면으로 튀고, + * 평문 비밀번호가 계속 남는다. + * + * [RobolectricTestRunner] 를 쓰는 이유는 [FindPasswordUiState.isEmailFormatValid] 가 + * `android.util.Patterns` 를 읽기 때문이다(JVM 단독 실행에서는 stub 이 던진다). + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class FindPasswordViewModelTest { + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `소셜 가입 계정은 스낵바가 아니라 차단 팝업 신호로 갈린다`() { + val repository = FakeAccountRepository(onSendFindCode = { Result.failure(socialSignUpAccount()) }) + val reporter = RecordingErrorReporter() + val viewModel = viewModel(repository, reporter).apply { updateEmail(EMAIL) } + + viewModel.requestVerificationCode() + + val state = viewModel.uiState.value + assertTrue((state.failure == OnboardingFailure.SocialAccountRecoveryUnavailable)) + assertNull(state.failure.toDisplay().snackbarMessage) + assertFalse(state.isVerificationSent) + // 서버가 정상적으로 가르는 분기라 장애로 세지 않는다. + assertEquals(0, reporter.recordedCount) + } + + @Test + fun `발송 실패 중 소셜 계정이 아닌 사유는 스낵바로 가고 계측된다`() { + val repository = FakeAccountRepository(onSendFindCode = { Result.failure(CoreAuthFailure.NetworkUnavailable(IOException())) }) + val reporter = RecordingErrorReporter() + val viewModel = viewModel(repository, reporter).apply { updateEmail(EMAIL) } + + viewModel.requestVerificationCode() + + val state = viewModel.uiState.value + assertFalse((state.failure == OnboardingFailure.SocialAccountRecoveryUnavailable)) + assertEquals(UiText.Resource(R.string.onboarding_network_error), state.failure.toDisplay().snackbarMessage) + assertEquals(1, reporter.recordedCount) + } + + @Test + fun `이메일을 고치면 앞선 발송 이력과 차단 판정이 사라진다`() { + val repository = FakeAccountRepository(onSendFindCode = { Result.failure(socialSignUpAccount()) }) + val viewModel = viewModel(repository).apply { updateEmail(EMAIL) } + viewModel.requestVerificationCode() + + viewModel.updateEmail("other@example.com") + + val state = viewModel.uiState.value + assertFalse((state.failure == OnboardingFailure.SocialAccountRecoveryUnavailable)) + assertFalse(state.isVerificationSent) + } + + @Test + fun `인증 화면의 다음은 서버에 코드를 확인하지 않는다`() { + // findAccount(=auth/email/find)를 부르면 서버가 인증번호를 지워 최종 제출이 실패한다. + val repository = FakeAccountRepository(onSendFindCode = { Result.success(Unit) }) + val viewModel = + viewModel(repository).apply { + updateEmail(EMAIL) + requestVerificationCode() + updateCertificateCode(CODE) + } + + assertTrue(viewModel.uiState.value.isVerificationNextEnabled) + assertEquals(0, repository.findAccountCallCount) + } + + @Test + fun `발송 전에는 자릿수를 채워도 다음이 열리지 않는다`() { + val repository = FakeAccountRepository() + val viewModel = + viewModel(repository).apply { + updateEmail(EMAIL) + updateCertificateCode(CODE) + } + + assertFalse(viewModel.uiState.value.isVerificationNextEnabled) + } + + @Test + fun `비밀번호 확인이 다르면 제출하지 않는다`() { + val repository = FakeAccountRepository() + val viewModel = + readyToSubmit(repository).apply { + updateNewPasswordConfirm("Different1!") + } + + viewModel.submitNewPassword() + + assertEquals(0, repository.resetPasswordCallCount) + } + + @Test + fun `제출이 진행 중이면 재호출이 재설정을 다시 부르지 않는다`() = + runTest { + val inFlight = CompletableDeferred() + val repository = + FakeAccountRepository( + onResetPassword = { + inFlight.await() + Result.success(Unit) + }, + ) + val viewModel = readyToSubmit(repository) + + viewModel.submitNewPassword() + viewModel.submitNewPassword() + + assertEquals(1, repository.resetPasswordCallCount) + assertTrue(viewModel.uiState.value.isSubmitting) + + inFlight.complete(Unit) + + assertTrue(viewModel.uiState.value.isPasswordChanged) + assertFalse(viewModel.uiState.value.isSubmitting) + assertEquals(1, repository.resetPasswordCallCount) + } + + /** + * 바로 위 테스트와 **다른 창**을 본다 — 저쪽은 `isSubmitting` 이 상태에 반영된 뒤의 재호출이라 + * `isResetEnabled` 하나로 막히지만, 이쪽은 반영 전이다. + * + * 실기기의 `Dispatchers.Main` 은 `launch` 본문을 다음 루프에 돌리므로 빠른 두 탭이 모두 + * `isSubmitting = false` 를 읽고 통과할 수 있다. [StandardTestDispatcher] 가 그 창을 그대로 + * 재현한다 — 상태 플래그만으로는 여기서 못 막고 진행 중인 Job 을 봐야 한다. + * + * 이 흐름에서 요청이 두 번 나가면 낭비로 끝나지 않는다. 서버가 인증번호를 검증하며 지우므로 + * 두 번째가 1207 로 실패해, 첫 요청이 성공했는데도 "이메일 인증부터 다시" 안내가 뜬다. + */ + @Test + fun `제출 연타는 상태가 반영되기 전에도 재설정을 한 번만 부른다`() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val repository = FakeAccountRepository(onResetPassword = { Result.success(Unit) }) + val viewModel = readyToSubmit(repository) + + viewModel.submitNewPassword() + viewModel.submitNewPassword() + advanceUntilIdle() + + assertEquals(1, repository.resetPasswordCallCount) + } + + /** 발송도 같은 창을 갖는다 — 중복 발송은 앞서 안내한 인증번호를 무효로 만든다. */ + @Test + fun `인증번호 발송 연타는 상태가 반영되기 전에도 요청을 한 번만 낸다`() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + var sendCalls = 0 + val repository = + FakeAccountRepository( + onSendFindCode = { + sendCalls++ + Result.success(Unit) + }, + ) + val viewModel = viewModel(repository).apply { updateEmail(EMAIL) } + + viewModel.requestVerificationCode() + viewModel.requestVerificationCode() + advanceUntilIdle() + + assertEquals(1, sendCalls) + } + + @Test + fun `제출은 인증 화면에서 받은 인증번호를 그대로 싣는다`() { + val repository = FakeAccountRepository(onResetPassword = { Result.success(Unit) }) + val viewModel = readyToSubmit(repository) + + viewModel.submitNewPassword() + + assertEquals( + ResetPasswordCall(EMAIL, CODE, PASSWORD, PASSWORD), + repository.lastResetPasswordCall, + ) + } + + @Test + fun `제출 시점의 인증번호 무효는 전용 문구로 갈린다`() { + val repository = FakeAccountRepository(onResetPassword = { Result.failure(emailVerification()) }) + val viewModel = readyToSubmit(repository) + + viewModel.submitNewPassword() + + assertEquals( + UiText.Resource(R.string.onboarding_find_password_code_expired), + viewModel.uiState.value.failure + .toDisplay() + .snackbarMessage, + ) + assertFalse(viewModel.uiState.value.isPasswordChanged) + } + + @Test + fun `사유를 확인하지 못한 제출 실패는 재설정 폴백 문구로 내려앉는다`() { + val repository = FakeAccountRepository(onResetPassword = { Result.failure(IllegalStateException("boom")) }) + val viewModel = readyToSubmit(repository) + + viewModel.submitNewPassword() + + assertEquals( + UiText.Resource(R.string.onboarding_find_password_failed), + viewModel.uiState.value.failure + .toDisplay() + .snackbarMessage, + ) + } + + @Test + fun `완료 신호를 소비하면 인증번호와 비밀번호가 상태에서 사라진다`() { + val repository = FakeAccountRepository(onResetPassword = { Result.success(Unit) }) + val viewModel = readyToSubmit(repository) + viewModel.submitNewPassword() + + viewModel.onPasswordResetConsumed() + + assertEquals(FindPasswordUiState(), viewModel.uiState.value) + } + + @Test + fun `소셜 계정 거절은 이전 요청 실패를 대체하고 늦은 스낵바 소비를 견딘다`() { + var failure: Throwable = IllegalStateException("first request failed") + val repository = FakeAccountRepository(onSendFindCode = { Result.failure(failure) }) + val viewModel = viewModel(repository).apply { updateEmail(EMAIL) } + viewModel.requestVerificationCode() + assertTrue(viewModel.uiState.value.failure is OnboardingFailure.RequestFailed) + + failure = socialSignUpAccount() + viewModel.requestVerificationCode() + viewModel.onErrorConsumed() + + assertEquals(OnboardingFailure.SocialAccountRecoveryUnavailable, viewModel.uiState.value.failure) + assertNull( + viewModel.uiState.value.failure + .toDisplay() + .snackbarMessage, + ) + } + + private fun viewModel( + repository: AccountRepository, + errorReporter: ErrorReporter = RecordingErrorReporter(), + ) = FindPasswordViewModel(accountRepository = repository, errorReporter = errorReporter) + + /** 인증 화면을 통과해 비밀번호까지 채운 상태 — 제출 직전. */ + private fun readyToSubmit(repository: FakeAccountRepository) = + viewModel(repository).apply { + updateEmail(EMAIL) + updateCertificateCode(CODE) + updateNewPassword(PASSWORD) + updateNewPasswordConfirm(PASSWORD) + } + + private fun socialSignUpAccount() = CoreAuthFailure.SocialSignUpAccount(IllegalStateException("1702")) + + private fun emailVerification() = CoreAuthFailure.EmailVerification(IllegalStateException("1207")) + + private companion object { + const val EMAIL = "user@example.com" + const val CODE = "123456" + const val PASSWORD = "NewPass1!" + } +} + +private data class ResetPasswordCall( + val email: String, + val certificateCode: String, + val newPassword: String, + val confirmPassword: String, +) + +private class RecordingErrorReporter : ErrorReporter { + var recordedCount = 0 + private set + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + recordedCount++ + } +} + +/** 미지정 경로 호출은 error 로 드러낸다 (core:data 의 Fake 들과 같은 규칙). */ +private class FakeAccountRepository( + private val onSendFindCode: suspend () -> Result = { error("sendFindCode 는 이 시나리오에서 호출되면 안 됨") }, + private val onResetPassword: suspend () -> Result = { error("resetPassword 는 이 시나리오에서 호출되면 안 됨") }, +) : AccountRepository { + var findAccountCallCount = 0 + private set + var resetPasswordCallCount = 0 + private set + var lastResetPasswordCall: ResetPasswordCall? = null + private set + + override suspend fun sendFindCode(email: String): Result = onSendFindCode() + + override suspend fun resetPassword( + email: String, + certificateCode: String, + newPassword: String, + confirmPassword: String, + ): Result { + resetPasswordCallCount++ + lastResetPasswordCall = ResetPasswordCall(email, certificateCode, newPassword, confirmPassword) + return onResetPassword() + } + + /** + * 호출을 세고 실패로 닫는다 — 던지면 호출 사실이 예외로 먼저 터져 «몇 번 불렸나» 를 + * 단언할 수 없다. 이 흐름에서 이 API 가 불리면 서버가 인증번호를 지워 최종 제출이 깨진다. + */ + override suspend fun findAccount( + email: String, + certificateCode: String, + ): Result { + findAccountCallCount++ + return Result.failure(IllegalStateException("findAccount 는 비밀번호 찾기 흐름에서 호출되면 안 됨")) + } + + override suspend fun sendEmailCode(email: String): Result = error("sendEmailCode 는 이 시나리오에서 호출되면 안 됨") + + override suspend fun verifyEmail( + email: String, + certificateCode: String, + ): Result = error("verifyEmail 은 이 시나리오에서 호출되면 안 됨") + + override suspend fun signUp( + email: String, + password: String, + name: String, + profileUrl: String?, + ): Result = error("signUp 은 이 시나리오에서 호출되면 안 됨") + + override suspend fun passwordChange( + currentPassword: String, + newPassword: String, + ): Result = error("passwordChange 는 이 시나리오에서 호출되면 안 됨") +} diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginReducerTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginReducerTest.kt new file mode 100644 index 0000000000..bcf9416d93 --- /dev/null +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginReducerTest.kt @@ -0,0 +1,90 @@ +package com.afternote.feature.onboarding.presentation.login + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.domain.usecase.auth.LoginUseCase +import com.afternote.feature.onboarding.presentation.OnboardingFailure +import com.afternote.feature.onboarding.presentation.reporting.AuthProvider +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * 로그인의 **순수 전이** (#1802). + * + * 코루틴 하네스가 없다 — 여기서 보는 Intent 는 `loginUseCase` 를 부르지 않고 `dispatch` → + * `reduce` 만 지난다. 로그인 자체의 비동기 갈래는 [LoginViewModelTest] 가 본다. + */ +class LoginReducerTest { + private val recorded = mutableListOf>() + + private fun viewModel(): LoginViewModel = + LoginViewModel( + // strict 페이크라 로그인 경로가 실제로 불리면 그 자리에서 드러난다. + loginUseCase = LoginUseCase(FakeAuthRepository.strict()), + errorReporter = + object : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + recorded += attributes + } + }, + ) + + @Test + fun `입력이 바뀌면 앞선 자격 거절이 지워진다`() { + val viewModel = viewModel() + + viewModel.onIntent(LoginIntent.UpdateEmail("user@example.com")) + viewModel.onIntent(LoginIntent.UpdatePassword("pw")) + + val state = viewModel.uiState.value + assertEquals("user@example.com", state.email) + assertEquals("pw", state.password) + assertFalse((state.failure == OnboardingFailure.CredentialsRejected)) + } + + @Test + fun `소비 Intent 는 그 신호만 되돌린다`() { + val viewModel = viewModel() + viewModel.onIntent(LoginIntent.UpdateEmail("user@example.com")) + + viewModel.onIntent(LoginIntent.ConsumeLoggedIn) + viewModel.onIntent(LoginIntent.ConsumeOnboardingStart) + viewModel.onIntent(LoginIntent.ConsumeError) + + val state = viewModel.uiState.value + assertFalse(state.isLoggedIn) + assertFalse(state.shouldStartOnboarding) + assertNull(state.failure.toDisplay().snackbarMessage) + assertEquals("user@example.com", state.email) + } + + @Test + fun `네트워크 팝업 닫기는 재실행 없이 상태만 바꾼다`() { + val viewModel = viewModel() + + viewModel.onIntent(LoginIntent.DismissNetworkError) + + assertFalse((viewModel.uiState.value.failure == OnboardingFailure.LoginNetworkUnavailable)) + } + + @Test + fun `소셜 토큰 실패 보고는 계측만 남기고 상태를 바꾸지 않는다`() { + val viewModel = viewModel() + val before = viewModel.uiState.value + + viewModel.onIntent( + LoginIntent.ReportSocialTokenFailure(AuthProvider.GOOGLE, RuntimeException("SDK 실패")), + ) + + assertEquals(before, viewModel.uiState.value) + assertEquals("social_token_request", recorded.single()["auth_stage"]) + assertEquals("google", recorded.single()["auth_provider"]) + } +} diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelReportingTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelReportingTest.kt index b50bd529ff..218eb9e270 100644 --- a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelReportingTest.kt +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelReportingTest.kt @@ -4,7 +4,10 @@ import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.testing.FakeAuthRepository import com.afternote.core.domain.usecase.auth.LoginUseCase +import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.afternote.feature.onboarding.presentation.reporting.AuthProvider +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -70,7 +73,7 @@ class LoginViewModelReportingTest { val reporter = FakeErrorReporter() val viewModel = viewModelWith(RuntimeException("서버 거절"), reporter) - viewModel.loginWithKakao("oauth-token") + viewModel.onIntent(LoginIntent.SubmitKakaoLogin("oauth-token")) advanceUntilIdle() val attributes = reporter.written.single() @@ -84,7 +87,7 @@ class LoginViewModelReportingTest { val reporter = FakeErrorReporter() val viewModel = viewModelWith(RuntimeException("서버 거절"), reporter) - viewModel.loginWithEmail() + viewModel.onIntent(LoginIntent.SubmitEmailLogin) advanceUntilIdle() assertEquals("email", reporter.written.single()["auth_provider"]) @@ -96,7 +99,7 @@ class LoginViewModelReportingTest { val reporter = FakeErrorReporter() val viewModel = viewModelWith(RuntimeException("미사용"), reporter) - viewModel.onSocialTokenRequestFailed(AuthProvider.GOOGLE, RuntimeException("SDK 실패")) + viewModel.onIntent(LoginIntent.ReportSocialTokenFailure(AuthProvider.GOOGLE, RuntimeException("SDK 실패"))) val attributes = reporter.written.single() assertEquals("social_token_request", attributes["auth_stage"]) @@ -110,11 +113,16 @@ class LoginViewModelReportingTest { // 화면 이탈로 스코프가 취소되면 runCatching 이 이걸 Result.failure 로 만들어 넘긴다. val viewModel = viewModelWith(CancellationException("스코프 취소"), reporter) - viewModel.loginWithKakao("oauth-token") + viewModel.onIntent(LoginIntent.SubmitKakaoLogin("oauth-token")) advanceUntilIdle() assertTrue("취소는 리포팅 대상이 아니다", reporter.written.isEmpty()) - assertNull("취소를 실패 문구로 띄우면 안 된다", viewModel.uiState.value.errorMessage) + assertNull( + "취소를 실패 문구로 띄우면 안 된다", + viewModel.uiState.value.failure + .toDisplay() + .snackbarMessage, + ) } @Test @@ -123,7 +131,7 @@ class LoginViewModelReportingTest { val reporter = FakeErrorReporter() val viewModel = viewModelWith(CoreAuthFailure.InvalidLoginCredentials(RuntimeException("401")), reporter) - viewModel.loginWithEmail() + viewModel.onIntent(LoginIntent.SubmitEmailLogin) advanceUntilIdle() assertTrue(reporter.written.isEmpty()) diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelTest.kt index 24001c4201..2254b08e2b 100644 --- a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelTest.kt +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelTest.kt @@ -6,7 +6,10 @@ import com.afternote.core.domain.testing.FakeAuthRepository import com.afternote.core.domain.usecase.auth.LoginUseCase import com.afternote.core.model.Session import com.afternote.core.ui.UiText +import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -15,6 +18,7 @@ import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -25,10 +29,11 @@ import java.net.UnknownHostException * [LoginViewModel] 실패 안내 계약 회귀 가드 (#628). * * 계약 — 자격 거절([CoreAuthFailure.InvalidLoginCredentials])은 인라인 상태 - * ([LoginUiState.hasCredentialError], 입력 변경으로 해제), 전송 계층 실패 - * ([CoreAuthFailure.NetworkUnavailable])는 재시도 팝업([LoginUiState.showNetworkErrorPopup]), - * 소셜 거절([CoreAuthFailure.SocialLoginRejected])과 그 밖의 예외는 **원문을 쓰지 않고** - * 리소스 문구 스낵바로 고정한다. 실패 시 [LoginUiState.isLoading] 을 해제한다. + * ([OnboardingFailure.CredentialsRejected], 입력 변경으로 해제), 전송 계층 실패 + * ([CoreAuthFailure.NetworkUnavailable])는 재시도 팝업([OnboardingFailure.LoginNetworkUnavailable]), + * 소셜 거절([CoreAuthFailure.SocialLoginRejected])·소셜 가입 계정 + * ([CoreAuthFailure.SocialSignUpAccount])과 그 밖의 예외는 **원문을 쓰지 않고** 리소스 문구 + * 스낵바로 고정한다. 실패 시 [LoginUiState.isLoading] 을 해제한다. * * [LoginUseCase] 는 실물 사용 — Repository Result 가 VM 상태로 번역되는 경로 전체를 가드한다. */ @@ -65,9 +70,9 @@ class LoginViewModelTest { ) private fun LoginViewModel.attemptEmailLogin() { - updateEmail("user@example.com") - updatePassword("pw") - loginWithEmail() + onIntent(LoginIntent.UpdateEmail("user@example.com")) + onIntent(LoginIntent.UpdatePassword("pw")) + onIntent(LoginIntent.SubmitEmailLogin) } @Test @@ -80,8 +85,8 @@ class LoginViewModelTest { viewModel.attemptEmailLogin() val state = viewModel.uiState.value - assertTrue(state.showNetworkErrorPopup) - assertNull(state.errorMessage) + assertTrue((state.failure == OnboardingFailure.LoginNetworkUnavailable)) + assertNull(state.failure.toDisplay().snackbarMessage) assertFalse(state.isLoading) } @@ -95,7 +100,7 @@ class LoginViewModelTest { }) viewModel.attemptEmailLogin() - viewModel.retryLogin() + viewModel.onIntent(LoginIntent.RetryLogin) assertEquals(2, attempts) } @@ -110,9 +115,9 @@ class LoginViewModelTest { }) viewModel.attemptEmailLogin() - viewModel.onNetworkErrorDismissed() + viewModel.onIntent(LoginIntent.DismissNetworkError) - assertFalse(viewModel.uiState.value.showNetworkErrorPopup) + assertFalse((viewModel.uiState.value.failure == OnboardingFailure.LoginNetworkUnavailable)) assertEquals(1, attempts) } @@ -126,8 +131,8 @@ class LoginViewModelTest { viewModel.attemptEmailLogin() val state = viewModel.uiState.value - assertTrue(state.hasCredentialError) - assertNull(state.errorMessage) + assertTrue((state.failure == OnboardingFailure.CredentialsRejected)) + assertNull(state.failure.toDisplay().snackbarMessage) assertFalse(state.isLoading) } @@ -139,9 +144,9 @@ class LoginViewModelTest { }) viewModel.attemptEmailLogin() - viewModel.updatePassword("new-pw") + viewModel.onIntent(LoginIntent.UpdatePassword("new-pw")) - assertFalse(viewModel.uiState.value.hasCredentialError) + assertFalse((viewModel.uiState.value.failure == OnboardingFailure.CredentialsRejected)) } @Test @@ -153,7 +158,29 @@ class LoginViewModelTest { viewModel.attemptEmailLogin() - assertEquals(UiText.Resource(R.string.onboarding_login_social_rejected), viewModel.uiState.value.errorMessage) + assertEquals( + UiText.Resource(R.string.onboarding_login_social_rejected), + viewModel.uiState.value.failure + .toDisplay() + .snackbarMessage, + ) + } + + @Test + fun `소셜 가입 계정 - 자격 인라인이 아니라 로그인 화면 전용 안내로 표시`() { + val viewModel = + viewModel(onDefaultLogin = { + Result.failure(CoreAuthFailure.SocialSignUpAccount(Exception("1702"))) + }) + + viewModel.attemptEmailLogin() + + val state = viewModel.uiState.value + assertEquals(UiText.Resource(R.string.onboarding_login_social_signup_account), state.failure.toDisplay().snackbarMessage) + // 비밀번호 찾기 쪽 차단 문구를 돌려쓰지 않는다 — 로그인 화면에서는 틀린 안내다. + assertNotEquals(UiText.Resource(R.string.onboarding_find_password_social_blocked), state.failure.toDisplay().snackbarMessage) + assertFalse((state.failure == OnboardingFailure.CredentialsRejected)) + assertFalse(state.isLoading) } @Test @@ -164,7 +191,7 @@ class LoginViewModelTest { viewModel.attemptEmailLogin() val state = viewModel.uiState.value - assertEquals(UiText.Resource(R.string.onboarding_login_failed), state.errorMessage) + assertEquals(UiText.Resource(R.string.onboarding_login_failed), state.failure.toDisplay().snackbarMessage) assertFalse(state.isLoading) } @@ -174,7 +201,12 @@ class LoginViewModelTest { viewModel.attemptEmailLogin() - assertEquals(UiText.Resource(R.string.onboarding_login_failed), viewModel.uiState.value.errorMessage) + assertEquals( + UiText.Resource(R.string.onboarding_login_failed), + viewModel.uiState.value.failure + .toDisplay() + .snackbarMessage, + ) } @Test @@ -182,8 +214,50 @@ class LoginViewModelTest { val viewModel = viewModel(onDefaultLogin = { Result.failure(Exception("실패")) }) viewModel.attemptEmailLogin() - viewModel.onErrorConsumed() + viewModel.onIntent(LoginIntent.ConsumeError) + + assertNull( + viewModel.uiState.value.failure + .toDisplay() + .snackbarMessage, + ) + } + + @Test + fun `새 자격 거절은 미소비 스낵바를 대체하고 늦은 스낵바 소비로 지워지지 않는다`() { + var failure: Throwable = IllegalStateException("first request failed") + val viewModel = viewModel { Result.failure(failure) } + viewModel.attemptEmailLogin() + assertTrue(viewModel.uiState.value.failure is OnboardingFailure.RequestFailed) + + failure = CoreAuthFailure.InvalidLoginCredentials(Exception("invalid credentials")) + viewModel.onIntent(LoginIntent.SubmitEmailLogin) + viewModel.onIntent(LoginIntent.ConsumeError) + + assertEquals(OnboardingFailure.CredentialsRejected, viewModel.uiState.value.failure) + assertNull( + viewModel.uiState.value.failure + .toDisplay() + .snackbarMessage, + ) + } - assertNull(viewModel.uiState.value.errorMessage) + @Test + fun `화면의 같은 입력 재전달은 실패를 남기고 실제 입력 변경은 실패를 지운다`() { + val viewModel = viewModel { Result.failure(IllegalStateException("failed")) } + viewModel.attemptEmailLogin() + + viewModel.onIntent(LoginIntent.UpdateEmail("user@example.com")) + assertTrue(viewModel.uiState.value.failure is OnboardingFailure.RequestFailed) + viewModel.onIntent(LoginIntent.UpdateEmail("other@example.com")) + assertNull(viewModel.uiState.value.failure) + } + + @Test + fun `늦은 네트워크 팝업 닫기는 새 요청 실패를 지우지 않는다`() { + val viewModel = viewModel { Result.failure(IllegalStateException("failed")) } + viewModel.attemptEmailLogin() + viewModel.onIntent(LoginIntent.DismissNetworkError) + assertTrue(viewModel.uiState.value.failure is OnboardingFailure.RequestFailed) } } diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpPartialSuccessCredentialsTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpPartialSuccessCredentialsTest.kt new file mode 100644 index 0000000000..d1e4c55af3 --- /dev/null +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpPartialSuccessCredentialsTest.kt @@ -0,0 +1,181 @@ +package com.afternote.feature.onboarding.presentation.signup + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.repository.account.AccountRepository +import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.domain.usecase.auth.LoginUseCase +import com.afternote.core.model.AccountRegistration +import com.afternote.core.model.FoundAccount +import com.afternote.core.model.Session +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +/** + * 부분 성공 복구가 **실제로 만들어진 계정에만** 적용되는지 (#2026). + * + * 회원가입 POST 는 성공했는데 자동 로그인만 실패하면 화면은 그대로 남는다. #710 이 그 재제출에서 + * 가입을 다시 부르지 않도록 막았는데, 그 판정이 Boolean 하나였다 — **부분 성공 뒤 뒤로 가 + * 이메일·비밀번호를 고쳐도 그 플래그가 새 입력에 그대로 남는다.** 그러면 새 계정의 가입은 + * 건너뛰고 새 자격으로 로그인만 불러, 만들어진 적 없는 계정으로 복구가 돈다. + * + * 두 축을 함께 본다 — **자격을 바꾸면 새로 가입해야 하고, 바꾸지 않으면 #710 대로 건너뛰어야 한다.** + * 한쪽만 보면 중복 가입을 막다가 새 계정을 못 만들거나, 그 반대가 된다. + * + * Robolectric 은 [SignUpUiState] 의 이메일 형식 검사가 `android.util.Patterns` 를 읽기 때문에만 필요하다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SignUpPartialSuccessCredentialsTest { + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `부분 성공 뒤 이메일을 바꾸면 새 계정으로 다시 가입한다`() = + runTest { + val repository = RecordingAccountRepository() + val viewModel = viewModel(repository, loginFails = true) + viewModel.onIntent(SignUpIntent.SubmitSignUp) + + assertEquals(listOf(OLD_EMAIL), repository.signedUpEmails) + + viewModel.onIntent(SignUpIntent.UpdateEmail(NEW_EMAIL)) + viewModel.onIntent(SignUpIntent.SubmitSignUp) + + assertEquals( + "만들어진 적 없는 계정으로 복구가 돌았다", + listOf(OLD_EMAIL, NEW_EMAIL), + repository.signedUpEmails, + ) + } + + @Test + fun `부분 성공 뒤 비밀번호만 바꿔도 새 계정으로 다시 가입한다`() = + runTest { + val repository = RecordingAccountRepository() + val viewModel = viewModel(repository, loginFails = true) + viewModel.onIntent(SignUpIntent.SubmitSignUp) + + viewModel.onIntent(SignUpIntent.UpdateSignUpPassword("Changed1!")) + viewModel.onIntent(SignUpIntent.SubmitSignUp) + + assertEquals(listOf(OLD_EMAIL, OLD_EMAIL), repository.signedUpEmails) + assertEquals( + "생성 때 비밀번호와 재시도 비밀번호가 어긋난 채 가입을 건너뛰었다", + listOf(PASSWORD, "Changed1!"), + repository.signedUpPasswords, + ) + } + + /** #710 의 정상 복구 — 자격을 그대로 두고 재제출하면 가입을 다시 부르지 않는다. */ + @Test + fun `자격을 바꾸지 않은 재제출은 가입을 다시 부르지 않는다`() = + runTest { + val repository = RecordingAccountRepository() + val viewModel = viewModel(repository, loginFails = true) + viewModel.onIntent(SignUpIntent.SubmitSignUp) + viewModel.onIntent(SignUpIntent.SubmitSignUp) + + assertEquals(listOf(OLD_EMAIL), repository.signedUpEmails) + } + + private fun viewModel( + repository: AccountRepository, + loginFails: Boolean, + ): SignUpViewModel = + SignUpViewModel( + accountRepository = repository, + loginUseCase = + LoginUseCase( + FakeAuthRepository.strict().apply { + onDefaultLogin = { _, _ -> + if (loginFails) { + Result.failure(IOException("자동 로그인 실패")) + } else { + Result.success(Session.DefaultSession("access", "refresh")) + } + } + onSaveSession = { _, _ -> Result.success(Unit) } + }, + ), + errorReporter = NoopErrorReporter, + ).apply { + onIntent(SignUpIntent.UpdateEmail(OLD_EMAIL)) + onIntent(SignUpIntent.UpdateSignUpPassword(PASSWORD)) + onIntent(SignUpIntent.UpdateName("애프터노트")) + } + + private object NoopErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit + } + + private class RecordingAccountRepository : AccountRepository { + val signedUpEmails = mutableListOf() + val signedUpPasswords = mutableListOf() + + override suspend fun signUp( + email: String, + password: String, + name: String, + profileUrl: String?, + ): Result { + signedUpEmails += email + signedUpPasswords += password + return Result.success(AccountRegistration(userId = 1L, email = email)) + } + + override suspend fun sendEmailCode(email: String): Result = error("이 테스트가 부르지 않는 경로") + + override suspend fun verifyEmail( + email: String, + certificateCode: String, + ): Result = error("이 테스트가 부르지 않는 경로") + + override suspend fun sendFindCode(email: String): Result = error("이 테스트가 부르지 않는 경로") + + override suspend fun findAccount( + email: String, + certificateCode: String, + ): Result = error("이 테스트가 부르지 않는 경로") + + override suspend fun resetPassword( + email: String, + certificateCode: String, + newPassword: String, + confirmPassword: String, + ): Result = error("이 테스트가 부르지 않는 경로") + + override suspend fun passwordChange( + currentPassword: String, + newPassword: String, + ): Result = error("이 테스트가 부르지 않는 경로") + } + + private companion object { + const val OLD_EMAIL = "old@example.com" + const val NEW_EMAIL = "new@example.com" + const val PASSWORD = "Password1!" + } +} diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpReducerTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpReducerTest.kt new file mode 100644 index 0000000000..7c92e6a213 --- /dev/null +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpReducerTest.kt @@ -0,0 +1,135 @@ +package com.afternote.feature.onboarding.presentation.signup + +import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.domain.usecase.auth.LoginUseCase +import com.afternote.feature.onboarding.presentation.NoopErrorReporter +import com.afternote.feature.onboarding.presentation.OnboardingFailure +import com.afternote.feature.onboarding.presentation.UnusedAccountRepository +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 회원가입의 **순수 전이** (#1802). + * + * 코루틴 하네스가 없다 — 여기서 보는 Intent 는 저장소를 부르지 않고 `dispatch` → `reduce` 만 + * 지난다. 제출 잠금·부분 성공 복구 같은 비동기 갈래는 [SignUpViewModelSubmitTest] 가 본다. + * + * Robolectric 은 [SignUpUiState.isEmailFormatValid] 가 읽는 `android.util.Patterns` 때문에만 + * 필요하다 — 순수 JVM 에서는 그 상수가 null 이다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SignUpReducerTest { + private fun viewModel(): SignUpViewModel = + SignUpViewModel( + accountRepository = UnusedAccountRepository, + loginUseCase = LoginUseCase(FakeAuthRepository.strict()), + errorReporter = NoopErrorReporter, + ) + + @Test + fun `이메일과 인증번호가 바뀌면 인증 에러가 지워진다`() { + val viewModel = viewModel() + + viewModel.onIntent(SignUpIntent.UpdateEmail("user@example.com")) + viewModel.onIntent(SignUpIntent.UpdateVerificationCode("000000")) + + val state = viewModel.uiState.value + assertEquals("user@example.com", state.email) + assertEquals("000000", state.verificationCode) + assertFalse((state.failure == OnboardingFailure.VerificationRejected)) + } + + @Test + fun `전체 동의는 세 항목을 한 번에 뒤집는다`() { + val viewModel = viewModel() + + viewModel.onIntent(SignUpIntent.ToggleAllTerms(true)) + + val agreed = viewModel.uiState.value.termsState + assertTrue(agreed.isTermsAgreed) + assertTrue(agreed.isPrivacyAgreed) + assertTrue(agreed.isMarketingAgreed) + assertTrue(viewModel.uiState.value.isStep4NextEnabled) + + viewModel.onIntent(SignUpIntent.ToggleMarketingAgreed(false)) + + // 선택 항목만 내려가도 필수 두 개가 남아 "다음" 은 열려 있다. + assertFalse(viewModel.uiState.value.termsState.isMarketingAgreed) + assertTrue(viewModel.uiState.value.isStep4NextEnabled) + + viewModel.onIntent(SignUpIntent.TogglePrivacyAgreed(false)) + + assertFalse(viewModel.uiState.value.isStep4NextEnabled) + } + + @Test + fun `단계별 다음 활성은 입력에서 파생된다`() { + val viewModel = viewModel() + + viewModel.onIntent(SignUpIntent.UpdateEmail("user@example.com")) + viewModel.onIntent(SignUpIntent.UpdateVerificationCode("12345")) + assertFalse(viewModel.uiState.value.isStep1NextEnabled) + + viewModel.onIntent(SignUpIntent.UpdateVerificationCode("123456")) + assertTrue(viewModel.uiState.value.isStep1NextEnabled) + + viewModel.onIntent(SignUpIntent.UpdateResidentFrontNumber("900101")) + viewModel.onIntent(SignUpIntent.UpdateResidentBackNumber("1")) + assertTrue(viewModel.uiState.value.isStep2NextEnabled) + + viewModel.onIntent(SignUpIntent.UpdateSignUpPassword("Password1!")) + assertFalse(viewModel.uiState.value.isStep3NextEnabled) + + viewModel.onIntent(SignUpIntent.UpdateSignUpPasswordConfirm("Password1!")) + assertTrue(viewModel.uiState.value.isStep3NextEnabled) + } + + @Test + fun `이름이 비면 제출이 저장소로 가지 않고 안내 신호만 선다`() { + val viewModel = viewModel() + viewModel.onIntent(SignUpIntent.UpdateName(" ")) + + viewModel.onIntent(SignUpIntent.SubmitSignUp) + + assertTrue(viewModel.uiState.value.isNameRequired) + assertFalse(viewModel.uiState.value.isLoading) + + viewModel.onIntent(SignUpIntent.ConsumeNameRequired) + + assertFalse(viewModel.uiState.value.isNameRequired) + } + + @Test + fun `소비 Intent 는 그 신호만 되돌린다`() { + val viewModel = viewModel() + viewModel.onIntent(SignUpIntent.UpdateName("애프터노트")) + + viewModel.onIntent(SignUpIntent.ConsumeSignedUp) + viewModel.onIntent(SignUpIntent.ConsumeResidentNumberNavigation) + viewModel.onIntent(SignUpIntent.ConsumeError) + + val state = viewModel.uiState.value + assertFalse(state.isSignedUp) + assertFalse(state.shouldNavigateToResidentNumber) + assertNull(state.failure.toDisplay().snackbarMessage) + assertEquals("애프터노트", state.name) + } + + @Test + fun `프로필 이미지 선택은 문자열로 보관된다`() { + val viewModel = viewModel() + + viewModel.onIntent(SignUpIntent.PickProfileImage("content://media/picked")) + + assertEquals("content://media/picked", viewModel.uiState.value.profileImageUri) + } +} diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpStaleVerificationTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpStaleVerificationTest.kt new file mode 100644 index 0000000000..b79830a8ad --- /dev/null +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpStaleVerificationTest.kt @@ -0,0 +1,245 @@ +package com.afternote.feature.onboarding.presentation.signup + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.error.CoreAuthFailure +import com.afternote.core.domain.repository.account.AccountRepository +import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.domain.usecase.auth.LoginUseCase +import com.afternote.core.model.AccountRegistration +import com.afternote.core.model.FoundAccount +import com.afternote.feature.onboarding.presentation.OnboardingFailure +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +/** + * 인증 결과가 **자기가 검증한 입력에만 적용되는지** (#2025). + * + * 1단계의 이메일 칸은 인증이 도는 동안에도 고칠 수 있다(잠기는 것은 하단 제출 버튼뿐이다). + * 결과에 「무엇을 검증했는지」가 없으면 리듀서가 지금 화면의 답과 버린 시도의 답을 가르지 못해, + * A 의 성공이 B 를 적은 폼을 다음 단계로 밀어 버린다 — **서버가 검증한 적 없는 이메일로 가입이 + * 이어진다.** 실패도 같다: 지운 입력의 거절 문구가 새 입력 아래에 붙는다. + * + * 새 요청의 상태까지 지우지는 않는다는 것도 함께 본다 — 옛 결과가 왔다는 이유로 지금 폼을 + * 초기화하면 고친 쪽이 지워진다. + * + * Robolectric 은 [SignUpUiState] 의 이메일 형식 검사가 `android.util.Patterns` 를 읽기 때문에만 필요하다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SignUpStaleVerificationTest { + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `이메일을 바꾼 뒤 도착한 옛 인증 성공은 다음 단계로 보내지 않는다`() = + runTest { + val gate = CompletableDeferred() + val repository = + StubAccountRepository { + gate.await() + Result.success(Unit) + } + val viewModel = viewModel(repository) + + viewModel.onIntent(SignUpIntent.VerifyEmailAndProceed) + viewModel.onIntent(SignUpIntent.UpdateEmail(NEW_EMAIL)) + gate.complete(Unit) + + val state = viewModel.uiState.value + assertEquals(NEW_EMAIL, state.email) + assertFalse("검증한 적 없는 이메일로 다음 단계가 열렸다", state.shouldNavigateToResidentNumber) + assertEquals(OLD_EMAIL, repository.verifiedEmails.single()) + } + + @Test + fun `인증번호를 바꾼 뒤 도착한 옛 인증 성공도 다음 단계로 보내지 않는다`() = + runTest { + val gate = CompletableDeferred() + val repository = + StubAccountRepository { + gate.await() + Result.success(Unit) + } + val viewModel = viewModel(repository) + + viewModel.onIntent(SignUpIntent.VerifyEmailAndProceed) + viewModel.onIntent(SignUpIntent.UpdateVerificationCode("999999")) + gate.complete(Unit) + + assertFalse(viewModel.uiState.value.shouldNavigateToResidentNumber) + } + + @Test + fun `이메일을 바꾼 뒤 도착한 옛 인증 거절은 새 입력에 오류를 붙이지 않는다`() = + runTest { + val gate = CompletableDeferred() + val repository = + StubAccountRepository { + gate.await() + Result.failure(CoreAuthFailure.EmailVerification(IOException("1207"))) + } + val viewModel = viewModel(repository) + + viewModel.onIntent(SignUpIntent.VerifyEmailAndProceed) + viewModel.onIntent(SignUpIntent.UpdateEmail(NEW_EMAIL)) + gate.complete(Unit) + + assertNull("버린 시도의 거절이 새 입력에 붙었다", viewModel.uiState.value.failure) + } + + @Test + fun `이메일을 바꾼 뒤 도착한 옛 요청 실패도 새 입력에 오류를 붙이지 않는다`() = + runTest { + val gate = CompletableDeferred() + val repository = + StubAccountRepository { + gate.await() + Result.failure(IOException("offline")) + } + val viewModel = viewModel(repository) + + viewModel.onIntent(SignUpIntent.VerifyEmailAndProceed) + viewModel.onIntent(SignUpIntent.UpdateEmail(NEW_EMAIL)) + gate.complete(Unit) + + assertNull(viewModel.uiState.value.failure) + } + + /** 입력을 그대로 둔 정상 경로는 종전과 같다. */ + @Test + fun `입력을 바꾸지 않았으면 인증 성공이 다음 단계를 연다`() = + runTest { + val repository = StubAccountRepository { Result.success(Unit) } + val viewModel = viewModel(repository) + + viewModel.onIntent(SignUpIntent.VerifyEmailAndProceed) + + assertTrue(viewModel.uiState.value.shouldNavigateToResidentNumber) + } + + /** 정상 경로의 거절도 종전과 같이 인라인 문구로 남는다. */ + @Test + fun `입력을 바꾸지 않았으면 인증 거절이 그대로 표시된다`() = + runTest { + val repository = + StubAccountRepository { Result.failure(CoreAuthFailure.EmailVerification(IOException("1207"))) } + val viewModel = viewModel(repository) + + viewModel.onIntent(SignUpIntent.VerifyEmailAndProceed) + + assertEquals(OnboardingFailure.VerificationRejected, viewModel.uiState.value.failure) + } + + /** 옛 결과가 왔다는 이유로 진행 중 표시까지 굳어 있으면 새 요청을 걸 수 없다. */ + @Test + fun `옛 결과가 도착하면 진행 중 표시는 풀린다`() = + runTest { + val gate = CompletableDeferred() + val repository = + StubAccountRepository { + gate.await() + Result.success(Unit) + } + val viewModel = viewModel(repository) + + viewModel.onIntent(SignUpIntent.VerifyEmailAndProceed) + viewModel.onIntent(SignUpIntent.UpdateEmail(NEW_EMAIL)) + gate.complete(Unit) + + assertFalse("진행 중 표시가 굳어 새 인증을 걸 수 없다", viewModel.uiState.value.isVerifyingEmail) + } + + private fun viewModel(repository: AccountRepository): SignUpViewModel = + SignUpViewModel( + accountRepository = repository, + loginUseCase = + LoginUseCase( + FakeAuthRepository.strict().apply { + onSaveSession = { _, _ -> Result.success(Unit) } + }, + ), + errorReporter = NoopErrorReporter, + ).apply { + onIntent(SignUpIntent.UpdateEmail(OLD_EMAIL)) + onIntent(SignUpIntent.UpdateVerificationCode(CODE)) + } + + private object NoopErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit + } + + private class StubAccountRepository( + private val onVerifyEmail: suspend () -> Result, + ) : AccountRepository { + val verifiedEmails = mutableListOf() + + override suspend fun verifyEmail( + email: String, + certificateCode: String, + ): Result { + verifiedEmails += email + return onVerifyEmail() + } + + override suspend fun sendEmailCode(email: String): Result = error("이 테스트가 부르지 않는 경로") + + override suspend fun sendFindCode(email: String): Result = error("이 테스트가 부르지 않는 경로") + + override suspend fun findAccount( + email: String, + certificateCode: String, + ): Result = error("이 테스트가 부르지 않는 경로") + + override suspend fun signUp( + email: String, + password: String, + name: String, + profileUrl: String?, + ): Result = error("이 테스트가 부르지 않는 경로") + + override suspend fun resetPassword( + email: String, + certificateCode: String, + newPassword: String, + confirmPassword: String, + ): Result = error("이 테스트가 부르지 않는 경로") + + override suspend fun passwordChange( + currentPassword: String, + newPassword: String, + ): Result = error("이 테스트가 부르지 않는 경로") + } + + private companion object { + const val OLD_EMAIL = "old@example.com" + const val NEW_EMAIL = "new@example.com" + const val CODE = "123456" + } +} diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModelSubmitTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModelSubmitTest.kt index 1637c96f95..9cd5a5c54d 100644 --- a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModelSubmitTest.kt +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModelSubmitTest.kt @@ -61,9 +61,9 @@ class SignUpViewModelSubmitTest { ), errorReporter = NoopErrorReporter, ).apply { - updateEmail("user@example.com") - updateSignUpPassword("Password1!") - updateName("애프터노트") + onIntent(SignUpIntent.UpdateEmail("user@example.com")) + onIntent(SignUpIntent.UpdateSignUpPassword("Password1!")) + onIntent(SignUpIntent.UpdateName("애프터노트")) } @Test @@ -80,9 +80,9 @@ class SignUpViewModelSubmitTest { ) val viewModel = viewModel(repository) - viewModel.submitSignUp() - viewModel.submitSignUp() - viewModel.submitSignUp() + viewModel.onIntent(SignUpIntent.SubmitSignUp) + viewModel.onIntent(SignUpIntent.SubmitSignUp) + viewModel.onIntent(SignUpIntent.SubmitSignUp) assertEquals(1, repository.signUpCallCount) assertTrue(viewModel.uiState.value.isLoading) @@ -111,13 +111,13 @@ class SignUpViewModelSubmitTest { } } - viewModel.submitSignUp() + viewModel.onIntent(SignUpIntent.SubmitSignUp) assertEquals(1, repository.signUpCallCount) assertTrue(viewModel.uiState.value.isAccountCreated) assertFalse(viewModel.uiState.value.isSignedUp) - viewModel.submitSignUp() + viewModel.onIntent(SignUpIntent.SubmitSignUp) assertEquals(1, repository.signUpCallCount) assertEquals(2, loginAttempts) @@ -127,9 +127,9 @@ class SignUpViewModelSubmitTest { @Test fun `이름이 공백이면 요청 없이 안내만 세운다`() { val repository = FakeAccountRepository(onSignUp = { error("이름 미입력이면 호출되면 안 됨") }) - val viewModel = viewModel(repository).apply { updateName(" ") } + val viewModel = viewModel(repository).apply { onIntent(SignUpIntent.UpdateName(" ")) } - viewModel.submitSignUp() + viewModel.onIntent(SignUpIntent.SubmitSignUp) assertEquals(0, repository.signUpCallCount) assertTrue(viewModel.uiState.value.isNameRequired) @@ -168,6 +168,13 @@ private class FakeAccountRepository( certificateCode: String, ): Result = error("findAccount 는 이 시나리오에서 호출되면 안 됨") + override suspend fun resetPassword( + email: String, + certificateCode: String, + newPassword: String, + confirmPassword: String, + ): Result = error("resetPassword 는 이 시나리오에서 호출되면 안 됨") + override suspend fun passwordChange( currentPassword: String, newPassword: String, diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsRowWidthTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsRowWidthTest.kt index ec1a6ae0ea..4c5b649075 100644 --- a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsRowWidthTest.kt +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsRowWidthTest.kt @@ -16,6 +16,8 @@ import com.afternote.core.ui.testing.assertAccessibleClickTargets import com.afternote.core.ui.testing.scanEnabledClickTargets import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.signup.SignUpIntent +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Rule @@ -49,14 +51,12 @@ class OnboardingTermsRowWidthTest { ) { composeRule.setContent { AfternoteTheme { - OnboardingTermsScreen( - termsState = TermsState(), - isNextEnabled = false, + OnboardingTermsContent( + state = SignUpUiState(), + onIntent = { intent -> + if (intent is SignUpIntent.ToggleAllTerms) onToggleAll(intent.agreed) + }, snackbarHostState = remember { SnackbarHostState() }, - onTermsToggle = {}, - onPrivacyToggle = {}, - onMarketingToggle = {}, - onToggleAll = onToggleAll, onViewTermsClick = onViewTermsClick, onNextClick = {}, onBackClick = {}, diff --git a/feature/onboarding/presentation/src/test/kotlin/com/afternote/feature/onboarding/presentation/OnboardingContractTest.kt b/feature/onboarding/presentation/src/test/kotlin/com/afternote/feature/onboarding/presentation/OnboardingContractTest.kt index 553619655d..671d5d53d1 100644 --- a/feature/onboarding/presentation/src/test/kotlin/com/afternote/feature/onboarding/presentation/OnboardingContractTest.kt +++ b/feature/onboarding/presentation/src/test/kotlin/com/afternote/feature/onboarding/presentation/OnboardingContractTest.kt @@ -15,9 +15,12 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.onboarding.presentation.signup.SignUpScreen -import com.afternote.feature.onboarding.presentation.terms.OnboardingTermsScreen -import com.afternote.feature.onboarding.presentation.terms.TermsState +import com.afternote.feature.onboarding.presentation.findaccount.FindPasswordResetScreen +import com.afternote.feature.onboarding.presentation.findaccount.FindPasswordScreen +import com.afternote.feature.onboarding.presentation.signup.SignUpContent +import com.afternote.feature.onboarding.presentation.signup.SignUpIntent +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState +import com.afternote.feature.onboarding.presentation.terms.OnboardingTermsContent import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -35,25 +38,23 @@ class OnboardingContractTest { @Test fun signUpRequiredInputs_gateNextAndKeepEnteredValues() { - var email by mutableStateOf("") - var code by mutableStateOf("") + // 「다음」 활성은 이제 UiState 파생값이다 — 화면이 받는 것은 상태와 Intent 둘뿐이라, + // VM 이 하는 전이를 테스트가 대신한다. + var state by mutableStateOf(SignUpUiState(isVerificationSent = true)) var nextCalls = 0 composeRule.setContent { AfternoteTheme { - SignUpScreen( - initialEmail = email, - initialVerificationCode = code, - isVerificationSent = true, - isSendingCode = false, - isEmailFormatValid = email.contains('@'), - resendCooldownSeconds = 0, - hasVerificationError = false, - isNextEnabled = email.contains('@') && code.length == 6, + SignUpContent( + state = state, + onIntent = { intent -> + when (intent) { + is SignUpIntent.UpdateEmail -> state = state.copy(email = intent.value) + is SignUpIntent.UpdateVerificationCode -> state = state.copy(verificationCode = intent.value) + SignUpIntent.VerifyEmailAndProceed -> nextCalls += 1 + else -> Unit + } + }, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = { email = it }, - onVerificationCodeChange = { code = it }, - onRequestVerification = {}, - onNextClick = { nextCalls += 1 }, onBackClick = {}, ) } @@ -65,26 +66,51 @@ class OnboardingContractTest { composeRule.onNode(hasText("다음") and hasClickAction()).assertIsEnabled().performClick() assertEquals(1, nextCalls) - assertEquals("new@example.test", email) - assertEquals("123456", code) + assertEquals("new@example.test", state.email) + assertEquals("123456", state.verificationCode) } @Test fun requiredTerms_enableNext_withoutOptionalMarketing() { - var state by mutableStateOf(TermsState()) + var state by mutableStateOf(SignUpUiState()) var nextCalls = 0 composeRule.setContent { AfternoteTheme { - OnboardingTermsScreen( - termsState = state, - isNextEnabled = state.isTermsAgreed && state.isPrivacyAgreed, - snackbarHostState = remember { SnackbarHostState() }, - onTermsToggle = { state = state.copy(isTermsAgreed = it) }, - onPrivacyToggle = { state = state.copy(isPrivacyAgreed = it) }, - onMarketingToggle = { state = state.copy(isMarketingAgreed = it) }, - onToggleAll = { - state = TermsState(it, it, it) + OnboardingTermsContent( + state = state, + onIntent = { intent -> + val terms = state.termsState + state = + when (intent) { + is SignUpIntent.ToggleTermsAgreed -> { + state.copy(termsState = terms.copy(isTermsAgreed = intent.agreed)) + } + + is SignUpIntent.TogglePrivacyAgreed -> { + state.copy(termsState = terms.copy(isPrivacyAgreed = intent.agreed)) + } + + is SignUpIntent.ToggleMarketingAgreed -> { + state.copy(termsState = terms.copy(isMarketingAgreed = intent.agreed)) + } + + is SignUpIntent.ToggleAllTerms -> { + state.copy( + termsState = + terms.copy( + isTermsAgreed = intent.agreed, + isPrivacyAgreed = intent.agreed, + isMarketingAgreed = intent.agreed, + ), + ) + } + + else -> { + state + } + } }, + snackbarHostState = remember { SnackbarHostState() }, onViewTermsClick = {}, onNextClick = { nextCalls += 1 }, onBackClick = {}, @@ -101,29 +127,89 @@ class OnboardingContractTest { .assertIsEnabled() .performClick() - assertEquals(false, state.isMarketingAgreed) + assertEquals(false, state.termsState.isMarketingAgreed) assertEquals(1, nextCalls) } + @Test + fun findPasswordVerification_carriesCodeForwardWithoutServerCheck() { + var email by mutableStateOf("") + var code by mutableStateOf("") + var nextCalls = 0 + composeRule.setContent { + AfternoteTheme { + FindPasswordScreen( + initialEmail = email, + initialCertificateCode = code, + isSendingCode = false, + isVerificationSent = true, + isSendCodeEnabled = true, + isNextEnabled = email.contains('@') && code.length == 6, + resendCooldownSeconds = 0, + showSocialAccountBlockedPopup = false, + snackbarHostState = remember { SnackbarHostState() }, + onEmailChange = { email = it }, + onCertificateCodeChange = { code = it }, + onRequestCode = {}, + onSocialAccountBlockedConfirm = {}, + onNextClick = { nextCalls += 1 }, + onBackClick = {}, + ) + } + } + + composeRule.onNode(hasText("다음") and hasClickAction()).assertIsNotEnabled() + composeRule.onNodeWithText("이메일 주소").performTextInput("find@example.test") + composeRule.onNodeWithText("인증 번호").performTextInput("123456") + composeRule.onNode(hasText("다음") and hasClickAction()).assertIsEnabled().performClick() + + assertEquals(1, nextCalls) + assertEquals("123456", code) + // 인증번호 필드에 인라인 "확인" 을 두지 않는다 — 서버가 검증하며 코드를 지워 + // 최종 제출(auth/password/find)에 쓸 코드가 남지 않기 때문이다 (시안 2431:14299). + composeRule.onNode(hasText("확인") and hasClickAction()).assertDoesNotExist() + } + + @Test + fun findPasswordReset_gatesSubmitOnRuleAndConfirmationMatch() { + var password by mutableStateOf("") + var confirm by mutableStateOf("") + var submitCalls = 0 + composeRule.setContent { + AfternoteTheme { + FindPasswordResetScreen( + initialPassword = password, + initialPasswordConfirm = confirm, + isPasswordRuleSatisfied = OnboardingPasswordRule.isSatisfied(password), + isNextEnabled = OnboardingPasswordRule.isSatisfied(password) && password == confirm, + snackbarHostState = remember { SnackbarHostState() }, + onPasswordChange = { password = it }, + onPasswordConfirmChange = { confirm = it }, + onNextClick = { submitCalls += 1 }, + onBackClick = {}, + ) + } + } + + composeRule.onNodeWithText("비밀번호").performTextInput("NewPass1!") + // 확인란이 비어 있으면 규칙을 만족해도 제출이 열리지 않는다. + composeRule.onNode(hasText("다음") and hasClickAction()).assertIsNotEnabled() + + composeRule.onNodeWithText("비밀번호 확인").performTextInput("NewPass1!") + composeRule.onNode(hasText("다음") and hasClickAction()).assertIsEnabled().performClick() + + assertEquals(1, submitCalls) + } + @Test fun signUpTextFieldState_survivesSavedInstanceStateRestore() { val restorationTester = StateRestorationTester(composeRule) restorationTester.setContent { AfternoteTheme { - SignUpScreen( - initialEmail = "", - initialVerificationCode = "", - isVerificationSent = true, - isSendingCode = false, - isEmailFormatValid = true, - resendCooldownSeconds = 0, - hasVerificationError = false, - isNextEnabled = false, + SignUpContent( + state = SignUpUiState(isVerificationSent = true), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onVerificationCodeChange = {}, - onRequestVerification = {}, - onNextClick = {}, onBackClick = {}, ) } diff --git a/feature/onboarding/presentation/src/test/kotlin/com/afternote/feature/onboarding/presentation/navigation/OnboardingLocalNavActionsTest.kt b/feature/onboarding/presentation/src/test/kotlin/com/afternote/feature/onboarding/presentation/navigation/OnboardingLocalNavActionsTest.kt new file mode 100644 index 0000000000..05ecea5a1b --- /dev/null +++ b/feature/onboarding/presentation/src/test/kotlin/com/afternote/feature/onboarding/presentation/navigation/OnboardingLocalNavActionsTest.kt @@ -0,0 +1,117 @@ +package com.afternote.feature.onboarding.presentation.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.afternote.core.ui.navigation.FeatureStackBoundary +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 온보딩 로컬 백스택 경계 회귀 기준 — Nav2 시절 `AuthBoundaryBackStackTest` 가 보던 몫 (#1601 · #1698). + * + * 이관 전에는 `popUpTo` 옵션 조합의 «결과» 를 루트 NavController 에서 읽었다. 로컬 스택에선 + * 결과 상태를 직접 만들므로, 그 결과를 스택 모양으로 그대로 못박는다. 컴포지션이 필요 없어 + * Robolectric 없이 돈다. + */ +class OnboardingLocalNavActionsTest { + private val exits = mutableListOf() + private val backStack = NavBackStack(OnboardingRoute.WelcomeRoute) + private val external = RecordingExternalActions() + private val actions = + OnboardingLocalNavActions( + backStack = backStack, + boundary = FeatureStackBoundary { exits += Unit }, + externalActions = external, + ) + + private fun stack(): List = backStack.map { it::class.simpleName!! } + + @Test + fun `로그인 화면 교체 이동은 로그인을 백스택에 남기지 않는다`() { + actions.navigateToLogin() + + actions.replaceLoginWithSignUp() + + assertEquals(listOf("WelcomeRoute", "SignUpRoute"), stack()) + // 가입 화면에서 뒤로가기는 로그인이 아니라 Welcome 으로 간다. + actions.popBack() + assertEquals(listOf("WelcomeRoute"), stack()) + } + + @Test + fun `소셜 신규 가입 분기는 Welcome 을 새로 세우고 로그인을 남기지 않는다`() { + actions.navigateToLogin() + actions.navigateToFindId() + + actions.replaceLoginWithWelcome() + + assertEquals(listOf("WelcomeRoute"), stack()) + } + + @Test + fun `회원가입 4단계는 순서대로 쌓이고 뒤로가기로 한 칸씩 내려온다`() { + actions.navigateToLogin() + actions.replaceLoginWithSignUp() + actions.proceedToSignUpResidentNumber() + actions.proceedToSignUpPassword() + actions.proceedToTerms() + actions.proceedToProfile() + + assertEquals( + listOf( + "WelcomeRoute", + "SignUpRoute", + "SignUpResidentNumberRoute", + "SignUpPasswordRoute", + "TermsRoute", + "ProfileRoute", + ), + stack(), + ) + + actions.popBack() + assertEquals("TermsRoute", stack().last()) + } + + @Test + fun `같은 화면을 연달아 요청해도 두 번 쌓이지 않는다`() { + actions.navigateToLogin() + actions.navigateToLogin() + + assertEquals(listOf("WelcomeRoute", "LoginRoute"), stack()) + } + + @Test + fun `스택 바닥에서의 뒤로가기는 스택을 비우지 않고 셸에 넘긴다`() { + actions.popBack() + + assertEquals(listOf("WelcomeRoute"), stack()) + assertEquals(1, exits.size) + } + + @Test + fun `그래프 밖 이동은 스택을 건드리지 않고 셸로 나간다`() { + actions.navigateToLogin() + + actions.replaceOnboardingWithHome() + actions.navigateToReceivedRecords() + + assertTrue(external.replacedWithHome) + assertTrue(external.wentToReceivedRecords) + assertEquals(listOf("WelcomeRoute", "LoginRoute"), stack()) + } + + private class RecordingExternalActions : OnboardingExternalActions { + var replacedWithHome = false + var wentToReceivedRecords = false + + override fun replaceOnboardingWithHome() { + replacedWithHome = true + } + + override fun navigateToReceivedRecords() { + wentToReceivedRecords = true + } + } +} diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/error/ReceiverFailureTranslation.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/error/ReceiverFailureTranslation.kt index a29c12b916..aec59898db 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/error/ReceiverFailureTranslation.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/error/ReceiverFailureTranslation.kt @@ -25,6 +25,27 @@ internal fun Throwable.toReceiverFailure(): Throwable = else -> this } +/** + * 요청 결과의 실패를 [toReceiverFailure] 로 옮긴다 — **수신자 저장소가 모듈 밖으로 실패를 내보내는 + * 유일한 통로**다. + * + * 메서드마다 `try { ... } catch (e: ApiException)` 을 포개던 자리를 대신한다. 그 형태는 감싼 메서드만 + * 번역해서, 같은 모듈 안에서 어떤 endpoint 는 도메인 어휘로 나가고 어떤 endpoint 는 `ApiException` + * (status·BE code·서버 message)을 그대로 흘리는 상태를 만들었다. 호출부가 한 줄로 붙이는 형태여야 + * 새 endpoint 가 번역을 빠뜨리지 않는다. + * + * 번역 대상이 아닌 실패는 **같은 [Result] 인스턴스를 그대로** 돌려준다 — 매핑 실패 + * ([com.afternote.feature.receiver.data.mapper.ReceiverListMappingFailure] 등) 처럼 도메인 어휘가 없는 + * 실패를 새 [Result] 로 갈아 끼우지 않는다. + * + * 취소는 여기 오지 않는다 — 호출부의 `runCatchingCancellable` 이 [Result] 에 담지 않고 되던진다. + */ +internal fun Result.mapReceiverFailure(): Result { + val original = exceptionOrNull() ?: return this + val translated = original.toReceiverFailure() + return if (translated === original) this else Result.failure(translated) +} + /** * 서버 응답의 status·code·message 를 도메인 어휘로 번역한다. 판정 순서 — * @@ -40,7 +61,7 @@ internal fun Throwable.toReceiverFailure(): Throwable = * **BE `ErrorCode` 번호를 아는 것은 이 계층까지다.** 도메인 실패는 status·code·message 어느 것도 * 운반하지 않으며, presentation 은 번역된 타입과 사유만 소비한다. */ -internal fun ApiException.toReceiverServerFailure(): ReceiverFailure { +private fun ApiException.toReceiverServerFailure(): ReceiverFailure { val registeredReason = code.toReceiverRejectionReasonOrNull() return when { status !in CLIENT_ERROR_STATUS_RANGE -> { diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt index 037aaa29f7..cc8a95d8a7 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt @@ -13,8 +13,12 @@ private const val RECEIVER_LIST_DECODING_STAGE = "receiver_list_decoding" private const val RECEIVER_LIST_MAPPING_STAGE = "receiver_list_mapping" private const val REJECTED_ITEM_COUNT_KEY = "rejected_item_count" +// 아래 두 신호는 이 파일 밖에서 이름으로 참조되지 않는다 (#1832). 텔레메트리에 실리는 건 클래스 자체가 아니라 +// ErrorReporter 가 원문을 redact 하며 붙이는 `error_type` 속성이고, 소비처와 테스트는 그 값과 +// `receiver_stage` 로 단계를 가른다. + /** category 키가 누락됐거나 문자열이 아니어서 목록 디코딩 중 제외된 원소가 있음을 알리는 신호. */ -internal class ReceiverListDecodingFailure : RuntimeException() +private class ReceiverListDecodingFailure : RuntimeException() /** * 목록 응답에 도메인으로 변환할 수 없는 category 가 포함됐음을 알리는 non-fatal 신호. @@ -22,7 +26,7 @@ internal class ReceiverListDecodingFailure : RuntimeException() * 서버 원문을 예외에 담지 않는다. 응답 내 식별자·제목·category 가 텔레메트리로 * 흘러갈 수 있기 때문이다. */ -internal class ReceiverListMappingFailure : RuntimeException() +private class ReceiverListMappingFailure : RuntimeException() /** * 서버 category 를 해석할 수 없으면 항목 하나만 목록에서 제외한다. @@ -30,7 +34,7 @@ internal class ReceiverListMappingFailure : RuntimeException() * 와이어 계약에 맞는 미래 값은 받아들이되 도메인에는 임의의 종류를 채우지 않는다. 그래야 필터·아이콘· * 상세 라우팅이 다른 종류로 조용히 왜곡되지 않는다. category 누락·null은 목록 serializer가 먼저 제외한다. */ -fun ReceivedAfternoteDto.toDomainOrNull(): AfterNoteListItem? { +private fun ReceivedAfternoteDto.toDomainOrNull(): AfterNoteListItem? { val resolvedType = afternoteTypeFromServerCategory(category) ?: return null return AfterNoteListItem( id = id, @@ -40,7 +44,7 @@ fun ReceivedAfternoteDto.toDomainOrNull(): AfterNoteListItem? { ) } -fun List.toReceiverDomainList(errorReporter: ErrorReporter): List { +private fun List.toReceiverDomainList(errorReporter: ErrorReporter): List { var rejectedItemCount = 0 val items = mapNotNull { dto -> diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt index e526ac6fde..b7ba0241fa 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt @@ -2,7 +2,6 @@ package com.afternote.feature.receiver.data.repositoryimpl import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.core.network.model.ApiException import com.afternote.core.network.model.requireData import com.afternote.core.network.model.requireStatus import com.afternote.feature.receiver.data.dto.DeliveryVerificationRequestDto @@ -11,7 +10,7 @@ import com.afternote.feature.receiver.data.dto.ReceiverAuthPresignedUrlRequestDt import com.afternote.feature.receiver.data.dto.ReceiverAuthVerifyRequestDto import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyRequestDto import com.afternote.feature.receiver.data.dto.toDomain -import com.afternote.feature.receiver.data.error.toReceiverServerFailure +import com.afternote.feature.receiver.data.error.mapReceiverFailure import com.afternote.feature.receiver.data.service.ReceiverAuthApiService import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.ReceivedRecordBox @@ -26,11 +25,14 @@ import javax.inject.Singleton /** * `receiver-auth` 계열 endpoint 의 [ReceiverAuthRepository] 구현. * - * 에러 처리 구조 — 일부 메서드는 try/catch 가 [runCatchingCancellable] *안에* 포개져 있다: - * 안쪽 catch 가 [ApiException](인프라 타입)을 도메인 예외로 바꿔 던지면(exception translation), - * 그 새 예외는 자기를 만든 try 로 되돌아가지 않고 바깥 [runCatchingCancellable] 이 잡아 - * `Result.failure(도메인 예외)` 로 반환된다 — 호출자에게 예외가 throw 되어 나가는 일은 없다. - * 안쪽 catch 는 [ApiException] 만 받으므로 취소는 여기 걸리지 않고 바깥 래퍼가 그대로 되던진다. + * 에러 처리 구조 — 모든 메서드가 `runCatchingCancellable { ... }.mapReceiverFailure()` 한 형태다. + * [com.afternote.core.network.model.ApiException] 이 나르는 HTTP status·BE `ErrorCode` 번호·서버 문구는 + * [com.afternote.feature.receiver.data.error.mapReceiverFailure] 안에서만 해석되고, 호출자에게는 + * [com.afternote.feature.receiver.domain.error.ReceiverFailure] 의 도메인 어휘만 나간다. + * + * 메서드별 `try/catch` 대신 공통 꼬리를 쓰는 이유 — 번역을 붙인 endpoint 와 빠뜨린 endpoint 가 + * 섞이면 같은 저장소가 어떤 호출에서는 도메인 어휘를, 어떤 호출에서는 인프라 예외를 내보낸다. + * 소비처는 그 차이를 알 수 없어 «타입으로 갈리지 않으면 폴백» 규칙이 조용히 무너진다. */ @Singleton class ReceiverAuthRepositoryImpl @@ -41,37 +43,25 @@ class ReceiverAuthRepositoryImpl ) : ReceiverAuthRepository { override suspend fun verifyMasterKey(masterKey: String): Result = runCatchingCancellable { - try { - api.verifyMasterKey(ReceiverAuthVerifyRequestDto(masterKey)).requireData().toDomain() - } catch (e: ApiException) { - throw e.toReceiverServerFailure() - } - } + api.verifyMasterKey(ReceiverAuthVerifyRequestDto(masterKey)).requireData().toDomain() + }.mapReceiverFailure() override suspend fun sendEmailAuthCode(email: String): Result = runCatchingCancellable { - try { - api.sendEmailAuthCode(ReceiverAuthCodeEmailSendRequestDto(email)).requireStatus() - } catch (e: ApiException) { - throw e.toReceiverServerFailure() - } - } + api.sendEmailAuthCode(ReceiverAuthCodeEmailSendRequestDto(email)).requireStatus() + }.mapReceiverFailure() override suspend fun verifyEmailAuthCode( email: String, authCode: String, ): Result = runCatchingCancellable { - try { - api - .verifyEmailAuthCode( - ReceiverEmailAuthVerifyRequestDto(email = email, authCode = authCode), - ).requireData() - .toDomain() - } catch (e: ApiException) { - throw e.toReceiverServerFailure() - } - } + api + .verifyEmailAuthCode( + ReceiverEmailAuthVerifyRequestDto(email = email, authCode = authCode), + ).requireData() + .toDomain() + }.mapReceiverFailure() override suspend fun getPresignedUrl( extension: String, @@ -86,36 +76,32 @@ class ReceiverAuthRepositoryImpl ), ).requireData() .toDomain() - } + }.mapReceiverFailure() override suspend fun submitDeliveryVerification( deathCertificateUrl: String?, familyRelationCertificateUrl: String?, ): Result = runCatchingCancellable { - try { - api - .submitDeliveryVerification( - DeliveryVerificationRequestDto( - deathCertificateUrl = deathCertificateUrl, - familyRelationCertificateUrl = familyRelationCertificateUrl, - ), - ).requireData() - .toDomain(errorReporter) - } catch (e: ApiException) { - throw e.toReceiverServerFailure() - } - } + api + .submitDeliveryVerification( + DeliveryVerificationRequestDto( + deathCertificateUrl = deathCertificateUrl, + familyRelationCertificateUrl = familyRelationCertificateUrl, + ), + ).requireData() + .toDomain(errorReporter) + }.mapReceiverFailure() override suspend fun getDeliveryVerificationStatus(): Result = runCatchingCancellable { api.getDeliveryVerificationStatus().requireData().toDomain(errorReporter) - } + }.mapReceiverFailure() override suspend fun getSenderMessage(): Result = runCatchingCancellable { api.getSenderMessage().requireData().toDomain() - } + }.mapReceiverFailure() override suspend fun getReceivedRecordBoxes(): Result> = runCatchingCancellable { @@ -123,5 +109,5 @@ class ReceiverAuthRepositoryImpl .getReceivedRecordBoxes() .requireData() .toDomain(errorReporter) - } + }.mapReceiverFailure() } diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverDeliveryDocumentUploadRepositoryImpl.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverDeliveryDocumentUploadRepositoryImpl.kt index 15051c3617..513ee94c7c 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverDeliveryDocumentUploadRepositoryImpl.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverDeliveryDocumentUploadRepositoryImpl.kt @@ -2,6 +2,7 @@ package com.afternote.feature.receiver.data.repositoryimpl import com.afternote.core.common.di.IoDispatcher import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.feature.receiver.data.error.mapReceiverFailure import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository import com.afternote.feature.receiver.domain.repository.ReceiverDeliveryDocumentUploadRepository import kotlinx.coroutines.CoroutineDispatcher @@ -19,6 +20,11 @@ import javax.inject.Named * 인증·헤더 부착은 [ReceiverAuthRepository] 측 호출에서, S3 PUT 은 [okHttpClient] 로 분리되어 * 작성자 측 [com.afternote.feature.afternote.data.repositoryimpl.author.MemorialThumbnailUploadRepositoryImpl] * 와 동일한 패턴을 따른다. + * + * 실패도 이 모듈의 다른 저장소와 같은 규약으로 내보낸다 — presigned URL 발급 실패는 이미 번역돼 + * 오고, S3 PUT 이 전송 계층에서 끝난 경우는 [com.afternote.feature.receiver.data.error.mapReceiverFailure] + * 가 [com.afternote.feature.receiver.domain.error.ReceiverFailure.NetworkUnavailable] 로 옮긴다. + * 크기 불일치·비정상 응답 코드처럼 도메인 어휘가 없는 실패는 원본 그대로 나간다. */ class ReceiverDeliveryDocumentUploadRepositoryImpl @Inject @@ -62,7 +68,7 @@ class ReceiverDeliveryDocumentUploadRepositoryImpl } } presigned.fileUrl - } + }.mapReceiverFailure() private companion object { const val CONTENT_TYPE_FALLBACK = "application/octet-stream" diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverRepositoryImpl.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverRepositoryImpl.kt index ed0758210e..dc7ff95df6 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverRepositoryImpl.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverRepositoryImpl.kt @@ -6,11 +6,13 @@ import androidx.paging.PagingData import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.network.model.requireData +import com.afternote.feature.receiver.data.error.mapReceiverFailure import com.afternote.feature.receiver.data.local.ReceiverMasterKeyDataSource import com.afternote.feature.receiver.data.mapper.response.toDomain import com.afternote.feature.receiver.data.mapper.toDomainResult import com.afternote.feature.receiver.data.paging.ReceiverAfternotePagingSource import com.afternote.feature.receiver.data.service.ReceiverAfternoteApiService +import com.afternote.feature.receiver.domain.error.ReceiverFailure import com.afternote.feature.receiver.domain.model.AfterNoteListItem import com.afternote.feature.receiver.domain.model.AfterNotesListResult import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail @@ -27,8 +29,14 @@ private const val PAGE_SIZE = 50 /** * 인증 코드는 [ReceiverMasterKeyDataSource]에서 읽고·쓰고·지우며, REST 요청에는 ReceiverAuthInterceptor가 - * `X-Auth-Code` 헤더를 자동 부착한다. 아직 별도 기능 repository로 이관되지 않은 export 항목은 - * 폴백 값을 반환한다. + * `X-Auth-Code` 헤더를 자동 부착한다. 서버 계약이 없는 export 항목은 성공값을 만들지 않고 + * [ReceiverFailure.ExportNotSupported]로 닫는다. + * + * 서버를 부르는 메서드는 [ReceiverAuthRepositoryImpl] 과 같은 규약으로 + * [com.afternote.feature.receiver.data.error.mapReceiverFailure] 를 거친다 — 목록·상세도 페이징 경로 + * ([com.afternote.feature.receiver.data.paging.ReceiverAfternotePagingSource]) 와 같은 도메인 어휘로 + * 실패를 내보내야 화면이 «전달 조건 미충족»·«연결 없음» 을 같은 기준으로 가른다. + * [loadSenderMessage] 는 [ReceiverAuthRepository] 로 위임하므로 그쪽에서 이미 번역된 실패를 받는다. */ @Singleton class ReceiverRepositoryImpl @@ -59,7 +67,7 @@ class ReceiverRepositoryImpl .getReceiverAfternotes() .requireData() .toDomainResult(errorReporter) - } + }.mapReceiverFailure() override suspend fun getReceivedAfternoteDetail(afternoteId: Long): Result = runCatchingCancellable { @@ -67,11 +75,12 @@ class ReceiverRepositoryImpl .getReceiverAfternoteDetail(afternoteId = afternoteId) .requireData() .toDomain() - } + }.mapReceiverFailure() - override suspend fun downloadReceivedExport(): Result = Result.success(ReceivedExportBundle()) + override suspend fun downloadReceivedExport(): Result = Result.failure(ReceiverFailure.ExportNotSupported()) - override suspend fun saveReceivedExportToFile(bundle: ReceivedExportBundle): Result = Result.success(Unit) + override suspend fun saveReceivedExportToFile(bundle: ReceivedExportBundle): Result = + Result.failure(ReceiverFailure.ExportNotSupported()) // sender 메시지 조회는 receiver-auth 영역 → ReceiverAuthRepository 가 책임. // 본 Repository 는 위임만 — 같은 API 호출이 두 곳에 중복되지 않게 (Multiple levels of repositories). diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/error/ReceiverFailureTranslationTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/error/ReceiverFailureTranslationTest.kt index 5841b2879f..57b6786898 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/error/ReceiverFailureTranslationTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/error/ReceiverFailureTranslationTest.kt @@ -8,8 +8,15 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test - -/** HTTP·BE 오류 봉투를 수신자 도메인 어휘로 번역하는 경계의 회귀 가드(#1053). */ +import java.io.IOException + +/** + * HTTP·BE 오류 봉투를 수신자 도메인 어휘로 번역하는 경계의 회귀 가드(#1053). + * + * 서버 봉투 해석기를 이름으로 부르지 않고 공개 입구인 [toReceiverFailure] 로 들어간다 — 그 + * 해석기는 이 파일 밖에서 쓰이지 않으므로 `private` 다(#1678 가드). `ApiException` 을 넣으면 + * 입구가 곧장 그 해석기로 내려가므로 판정 범위는 그대로다. + */ class ReceiverFailureTranslationTest { @Test fun `표시 허용 5개 code 는 각각 도메인 사유로 번역한다`() { @@ -25,7 +32,7 @@ class ReceiverFailureTranslationTest { cases.forEach { (status, code, expectedReason) -> val original = apiException(status = status, code = code, serverMessage = "사용자 안내") - val failure = original.toReceiverServerFailure() + val failure = original.toReceiverFailure() assertTrue("code=$code: $failure", failure is ReceiverFailure.UserRejection) failure as ReceiverFailure.UserRejection @@ -38,7 +45,7 @@ class ReceiverFailureTranslationTest { fun `미등재 4xx 는 문구가 있으면 표시 사유 없는 사용자 거절이다`() { val original = apiException(status = 422, code = 4999, serverMessage = "개발자용 검증 문구") - val failure = original.toReceiverServerFailure() + val failure = original.toReceiverFailure() assertTrue(failure is ReceiverFailure.UserRejection) failure as ReceiverFailure.UserRejection @@ -61,7 +68,7 @@ class ReceiverFailureTranslationTest { listOf(null, " \n ").forEach { serverMessage -> val original = apiException(status = status, code = code, serverMessage = serverMessage) - val failure = original.toReceiverServerFailure() + val failure = original.toReceiverFailure() assertTrue( "code=$code serverMessage=$serverMessage: $failure", @@ -79,7 +86,7 @@ class ReceiverFailureTranslationTest { listOf(null, " \n ").forEach { serverMessage -> val original = apiException(status = 422, code = 4999, serverMessage = serverMessage) - val failure = original.toReceiverServerFailure() + val failure = original.toReceiverFailure() assertTrue("serverMessage=$serverMessage: $failure", failure is ReceiverFailure.UnexpectedServerFailure) assertSame(original, failure.cause) @@ -90,7 +97,7 @@ class ReceiverFailureTranslationTest { fun `등재 code 여도 5xx 봉투면 예상 밖 서버 실패다`() { val original = apiException(status = 500, code = 2008, serverMessage = "내부 처리 오류") - val failure = original.toReceiverServerFailure() + val failure = original.toReceiverFailure() assertTrue(failure is ReceiverFailure.UnexpectedServerFailure) assertSame(original, failure.cause) @@ -100,7 +107,7 @@ class ReceiverFailureTranslationTest { fun `전달 조건 미충족 2009 는 4xx 면 문구와 무관하게 전용 사유다`() { val original = apiException(status = 403, code = 2009, serverMessage = "아직 전달 조건이 충족되지 않았습니다.") - val failure = original.toReceiverServerFailure() + val failure = original.toReceiverFailure() assertTrue(failure is ReceiverFailure.DeliveryConditionNotMet) assertSame(original, failure.cause) @@ -110,7 +117,7 @@ class ReceiverFailureTranslationTest { fun `2009 여도 5xx 봉투면 예상 밖 서버 실패다`() { val original = apiException(status = 500, code = 2009, serverMessage = "내부 처리 오류") - val failure = original.toReceiverServerFailure() + val failure = original.toReceiverFailure() assertTrue(failure is ReceiverFailure.UnexpectedServerFailure) assertSame(original, failure.cause) @@ -121,13 +128,61 @@ class ReceiverFailureTranslationTest { listOf(null, " \n ").forEach { serverMessage -> val original = apiException(status = 403, code = 2009, serverMessage = serverMessage) - val failure = original.toReceiverServerFailure() + val failure = original.toReceiverFailure() assertTrue("serverMessage=$serverMessage: $failure", failure is ReceiverFailure.DeliveryConditionNotMet) assertSame(original, failure.cause) } } + @Test + fun `성공 결과는 값을 그대로 돌려준다`() { + val result = Result.success("ok").mapReceiverFailure() + + assertEquals("ok", result.getOrNull()) + } + + @Test + fun `서버 거절 실패는 도메인 어휘로 옮긴다`() { + val original = apiException(status = 404, code = 1901, serverMessage = "등록된 수신자 이메일이 아닙니다.") + + val failure = Result.failure(original).mapReceiverFailure().exceptionOrNull() + + assertTrue("$failure", failure is ReceiverFailure.UserRejection) + assertEquals(ReceiverRejectionReason.RECEIVER_EMAIL_NOT_FOUND, (failure as ReceiverFailure.UserRejection).reason) + assertSame(original, failure.cause) + } + + @Test + fun `전송 계층 실패는 연결 불가로 옮긴다`() { + val original = IOException("Unable to resolve host") + + val failure = Result.failure(original).mapReceiverFailure().exceptionOrNull() + + assertTrue("$failure", failure is ReceiverFailure.NetworkUnavailable) + assertSame(original, failure?.cause) + } + + /** 도메인 어휘가 없는 실패까지 감싸면 매핑 실패 같은 진단 신호가 소비처에서 사라진다. */ + @Test + fun `분류 대상이 아닌 실패는 원본 인스턴스 그대로 나간다`() { + val original = IllegalStateException("boom") + + val failure = Result.failure(original).mapReceiverFailure().exceptionOrNull() + + assertSame(original, failure) + } + + /** 위임으로 두 번 지나는 경로(문서 업로드 → presigned URL 발급) 가 실패를 겹겹이 감싸지 않는지. */ + @Test + fun `이미 번역된 실패는 다시 감싸지 않는다`() { + val original = ReceiverFailure.DeliveryConditionNotMet(apiException(status = 403, code = 2009, serverMessage = null)) + + val failure = Result.failure(original).mapReceiverFailure().exceptionOrNull() + + assertSame(original, failure) + } + private fun apiException( status: Int, code: Int, diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt index 83e20ea747..d691e1f4da 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt @@ -1,31 +1,32 @@ package com.afternote.feature.receiver.data.mapper -import com.afternote.core.common.reporting.ErrorReporter import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.receiver.data.dto.ReceivedAfternoteDto import com.afternote.feature.receiver.data.dto.ReceivedAfternoteListDto +import com.afternote.feature.receiver.data.reporting.RECEIVER_LIST_DECODING_STAGE +import com.afternote.feature.receiver.data.reporting.RECEIVER_LIST_MAPPING_STAGE import com.afternote.feature.receiver.data.reporting.RecordingErrorReporter +import com.afternote.feature.receiver.data.reporting.assertReceiverListFailureContract import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test /** - * [ReceivedAfternoteDto.toDomainOrNull] / [toReceiverDomainList] 회귀 가드. + * [ReceivedAfternoteListDto.toReceiverDomainList] / [ReceivedAfternoteListDto.toDomainResult] 회귀 가드. * 수신자 목록 DTO→[com.afternote.feature.receiver.domain.model.AfterNoteListItem] 매핑. * 서버 카테고리를 [AfternoteType] 으로 바꾸는 규칙과 createdAt null 가드를 검증한다. + * + * 항목 하나를 옮기는 변환과 제외 집계는 이 파일 안에 갇힌 `private` 헬퍼다 (#1832). 그래서 + * 목록을 받는 공개 owner 로만 넣고, 나온 목록과 텔레메트리 속성으로 같은 계약을 확인한다. */ class ReceiverAfternoteListItemDtoToDomainTest { @Test - fun `toDomainOrNull - 필드 매핑 + category 정규화 + 날짜 포맷`() { + fun `목록 매핑 - 필드 매핑 + category 정규화 + 날짜 포맷`() { val result = - ReceivedAfternoteDto( - id = 9L, - title = "사진첩", - category = "GALLERY", - createdAt = "2025-11-26T14:30:00", - ).toDomainOrNull() - - requireNotNull(result) + mapItems( + resp(id = 9L, title = "사진첩", category = "GALLERY", createdAt = "2025-11-26T14:30:00"), + ).single() assertEquals(9L, result.id) assertEquals("사진첩", result.serviceName) @@ -34,37 +35,37 @@ class ReceiverAfternoteListItemDtoToDomainTest { } @Test - fun `toDomainOrNull - 변환은 대소문자 무시 + MUSIC PLAYLIST는 MEMORIAL`() { - assertEquals(AfternoteType.SOCIAL_NETWORK, requireNotNull(resp(category = "social").toDomainOrNull()).type) - assertEquals(AfternoteType.MEMORIAL, requireNotNull(resp(category = "PLAYLIST").toDomainOrNull()).type) - assertEquals(AfternoteType.MEMORIAL, requireNotNull(resp(category = "music").toDomainOrNull()).type) + fun `목록 매핑 - 변환은 대소문자 무시 + MUSIC PLAYLIST는 MEMORIAL`() { + assertEquals(AfternoteType.SOCIAL_NETWORK, mapItems(resp(category = "social")).single().type) + assertEquals(AfternoteType.MEMORIAL, mapItems(resp(category = "PLAYLIST")).single().type) + assertEquals(AfternoteType.MEMORIAL, mapItems(resp(category = "music")).single().type) } @Test - fun `toDomainOrNull - 사업자는 서버가 아는 종류다 - BUSINESS 로 올라온다`() { - assertEquals(AfternoteType.BUSINESS, requireNotNull(resp(category = "BUSINESS").toDomainOrNull()).type) + fun `목록 매핑 - 사업자는 서버가 아는 종류다 - BUSINESS 로 올라온다`() { + assertEquals(AfternoteType.BUSINESS, mapItems(resp(category = "BUSINESS")).single().type) } @Test - fun `toDomainOrNull - 서버가 모르는 category 를 보내면 항목을 거절한다`() { - assertNull(resp(category = "ESTATE").toDomainOrNull()) + fun `목록 매핑 - 서버가 모르는 category 를 보내면 항목을 거절한다`() { + assertTrue(mapItems(resp(category = "ESTATE")).isEmpty()) } @Test - fun `toDomainOrNull - 아예 모르는 값도 항목을 거절하고 특정 종류로 메우지 않는다`() { - assertNull(resp(category = "WHAT_IS_THIS").toDomainOrNull()) + fun `목록 매핑 - 아예 모르는 값도 항목을 거절하고 특정 종류로 메우지 않는다`() { + assertTrue(mapItems(resp(category = "WHAT_IS_THIS")).isEmpty()) } @Test - fun `toDomainOrNull - createdAt null이면 lastUpdatedAt null`() { - assertNull(requireNotNull(resp(createdAt = null).toDomainOrNull()).lastUpdatedAt) + fun `목록 매핑 - createdAt null이면 lastUpdatedAt null`() { + assertNull(mapItems(resp(createdAt = null)).single().lastUpdatedAt) } @Test fun `toReceiverDomainList - 각 항목을 순서대로 매핑`() { val reporter = RecordingErrorReporter() - val list = listOf(resp(id = 1L), resp(id = 2L)).toReceiverDomainList(reporter) + val list = listDto(resp(id = 1L), resp(id = 2L)).toReceiverDomainList(reporter) assertEquals(listOf(1L, 2L), list.map { it.id }) assertEquals(0, reporter.failures.size) @@ -74,16 +75,14 @@ class ReceiverAfternoteListItemDtoToDomainTest { fun `toReceiverDomainList - 지원하지 않는 category 항목만 제외하고 유효 항목은 보존한다`() { val reporter = RecordingErrorReporter() val list = - listOf( + listDto( resp(id = 1L, category = "SOCIAL"), resp(id = 2L, category = "ESTATE"), resp(id = 3L, category = "BUSINESS"), ).toReceiverDomainList(reporter) assertEquals(listOf(1L, 3L), list.map { it.id }) - assertEquals(1, reporter.failures.size) - assertEquals("receiver_list_mapping", reporter.failures.single().attributes["receiver_stage"]) - assertEquals("1", reporter.failures.single().attributes["rejected_item_count"]) + reporter.assertReceiverListFailureContract(mapOf(RECEIVER_LIST_MAPPING_STAGE to "1")) } @Test @@ -118,17 +117,22 @@ class ReceiverAfternoteListItemDtoToDomainTest { assertEquals(7, result.totalCount) assertEquals(listOf(1L, 3L), result.items.map { it.id }) - val failuresByStage = reporter.failures.associateBy { it.attributes["receiver_stage"] } - assertEquals(setOf("receiver_list_decoding", "receiver_list_mapping"), failuresByStage.keys) + reporter.assertReceiverListFailureContract( + mapOf( + RECEIVER_LIST_DECODING_STAGE to "2", + RECEIVER_LIST_MAPPING_STAGE to "1", + ), + ) + } - val decodingFailure = requireNotNull(failuresByStage["receiver_list_decoding"]) - assertEquals("2", decodingFailure.attributes["rejected_item_count"]) - assertEquals(ReceiverListDecodingFailure::class.java.name, decodingFailure.attributes["error_type"]) + /** 제외가 없는 정상 경로만 쓰는 헬퍼 — 텔레메트리를 보는 케이스는 reporter 를 직접 들고 단언한다. */ + private fun mapItems(vararg dtos: ReceivedAfternoteDto) = listDto(*dtos).toReceiverDomainList(RecordingErrorReporter()) - val mappingFailure = requireNotNull(failuresByStage["receiver_list_mapping"]) - assertEquals("1", mappingFailure.attributes["rejected_item_count"]) - assertEquals(ReceiverListMappingFailure::class.java.name, mappingFailure.attributes["error_type"]) - } + private fun listDto(vararg dtos: ReceivedAfternoteDto) = + ReceivedAfternoteListDto( + afternotes = dtos.toList(), + totalCount = dtos.size, + ) private fun resp( id: Long = 1L, diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/paging/ReceiverAfternotePagingSourceTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/paging/ReceiverAfternotePagingSourceTest.kt index 42e09ef715..245733c1d9 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/paging/ReceiverAfternotePagingSourceTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/paging/ReceiverAfternotePagingSourceTest.kt @@ -6,9 +6,10 @@ import com.afternote.core.network.model.ApiException import com.afternote.core.network.model.BaseResponse import com.afternote.feature.receiver.data.dto.ReceivedAfternoteDto import com.afternote.feature.receiver.data.dto.ReceivedAfternoteListDto -import com.afternote.feature.receiver.data.mapper.ReceiverListDecodingFailure -import com.afternote.feature.receiver.data.mapper.ReceiverListMappingFailure +import com.afternote.feature.receiver.data.reporting.RECEIVER_LIST_DECODING_STAGE +import com.afternote.feature.receiver.data.reporting.RECEIVER_LIST_MAPPING_STAGE import com.afternote.feature.receiver.data.reporting.RecordingErrorReporter +import com.afternote.feature.receiver.data.reporting.assertReceiverListFailureContract import com.afternote.feature.receiver.data.service.ReceiverAfternoteApiService import com.afternote.feature.receiver.domain.error.ReceiverFailure import com.afternote.feature.receiver.domain.error.ReceiverRejectionReason @@ -154,16 +155,12 @@ class ReceiverAfternotePagingSourceTest { val page = result as PagingSource.LoadResult.Page assertEquals(listOf(5L, 8L), page.data.map { it.id }) - val failuresByStage = reporter.failures.associateBy { it.attributes["receiver_stage"] } - assertEquals(setOf("receiver_list_decoding", "receiver_list_mapping"), failuresByStage.keys) - - val decodingFailure = requireNotNull(failuresByStage["receiver_list_decoding"]) - assertEquals("2", decodingFailure.attributes["rejected_item_count"]) - assertEquals(ReceiverListDecodingFailure::class.java.name, decodingFailure.attributes["error_type"]) - - val mappingFailure = requireNotNull(failuresByStage["receiver_list_mapping"]) - assertEquals("1", mappingFailure.attributes["rejected_item_count"]) - assertEquals(ReceiverListMappingFailure::class.java.name, mappingFailure.attributes["error_type"]) + reporter.assertReceiverListFailureContract( + mapOf( + RECEIVER_LIST_DECODING_STAGE to "2", + RECEIVER_LIST_MAPPING_STAGE to "1", + ), + ) val reportedPayload = reporter.failures.joinToString { failure -> diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/reporting/ReceiverListFailureContract.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/reporting/ReceiverListFailureContract.kt new file mode 100644 index 0000000000..a87d4ceae7 --- /dev/null +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/reporting/ReceiverListFailureContract.kt @@ -0,0 +1,46 @@ +package com.afternote.feature.receiver.data.reporting + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue + +internal const val RECEIVER_LIST_DECODING_STAGE = "receiver_list_decoding" +internal const val RECEIVER_LIST_MAPPING_STAGE = "receiver_list_mapping" + +private const val RECEIVER_MAPPER_PACKAGE = "com.afternote.feature.receiver.data.mapper." +private const val RECEIVER_STAGE_KEY = "receiver_stage" +private const val REJECTED_ITEM_COUNT_KEY = "rejected_item_count" +private const val ERROR_TYPE_KEY = "error_type" + +/** + * 수신 목록 텔레메트리의 «분류 계약» 단언 (#1832). + * + * 디코딩·매핑 실패를 알리는 예외 타입은 mapper 파일 안에 갇힌 `private` 선언이라 테스트가 이름으로 + * 참조하지 않는다. 대신 [com.afternote.core.common.reporting.ErrorReporter] 가 기록에 붙이는 속성으로 + * 같은 계약을 검증한다 — 지켜야 할 것은 **두 단계가 각자 전용 타입으로 갈려 콘솔에서 구분된다**는 + * 사실이지 타입 이름이 아니다. `error_type` 은 원문을 버리는 redact 정책이 남기는 유일한 타입 정보다. + * + * @param expectedRejectedCounts 단계(`receiver_stage`) → 그 단계가 보고해야 할 제외 건수. + * 여기 없는 단계가 기록됐거나 빠졌으면 실패한다. + */ +internal fun RecordingErrorReporter.assertReceiverListFailureContract(expectedRejectedCounts: Map) { + val failuresByStage = failures.associateBy { it.attributes[RECEIVER_STAGE_KEY].orEmpty() } + assertEquals(expectedRejectedCounts.keys, failuresByStage.keys) + + val typesByStage = + expectedRejectedCounts.mapValues { (stage, expectedRejectedCount) -> + val failure = requireNotNull(failuresByStage[stage]) { "$stage 실패가 기록되지 않았다" } + assertEquals("$stage 제외 건수", expectedRejectedCount, failure.attributes[REJECTED_ITEM_COUNT_KEY]) + + val errorType = requireNotNull(failure.attributes[ERROR_TYPE_KEY]) { "$stage 에 error_type 이 없다" } + assertTrue( + "$stage 은 수신 목록 전용 실패 타입으로 기록돼야 한다: $errorType", + errorType.startsWith(RECEIVER_MAPPER_PACKAGE), + ) + errorType + } + + assertTrue( + "단계마다 다른 실패 타입으로 갈려야 콘솔에서 원인을 구분한다: $typesByStage", + typesByStage.values.toSet().size == typesByStage.size, + ) +} diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt index dac6bf851d..c04c7c75f3 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt @@ -93,8 +93,15 @@ class ReceiverAuthRepositoryImplEmailAuthTest { assertTrue(exception.cause is ApiException) } + /** + * 서버에 닿지 못한 실패도 도메인 어휘로 나간다 — 형제인 목록 경로 + * ([com.afternote.feature.receiver.data.paging.ReceiverAfternotePagingSource]) 와 같은 계약이고, + * [ReceiverFailure] 의 KDoc 이 «서버 응답과 네트워크 실패는 Data 계층이 이 계열로 번역한다» 로 + * 규정한 몫이다. 여기만 원본 [IOException] 을 흘리면 소비처의 «타입으로 안 갈린 것은 폴백» 규칙이 + * endpoint 마다 달라진다. + */ @Test - fun `sendEmailAuthCode - ApiException 아닌 인프라 예외는 원본 그대로 전파`() { + fun `sendEmailAuthCode - 전송 계층 실패는 연결 불가 도메인 실패로 번역`() { val original = IOException("timeout") val repository = ReceiverAuthRepositoryImpl( @@ -104,6 +111,23 @@ class ReceiverAuthRepositoryImplEmailAuthTest { val result = runBlocking { repository.sendEmailAuthCode("a@b.com") } + val exception = result.exceptionOrNull() + assertTrue("$exception", exception is ReceiverFailure.NetworkUnavailable) + assertEquals(original, exception?.cause) + } + + /** 도메인 어휘가 없는 실패까지 번역하면 원인 타입이 소비처에서 사라진다. */ + @Test + fun `sendEmailAuthCode - 분류 대상이 아닌 실패는 원본 그대로 전파`() { + val original = IllegalStateException("boom") + val repository = + ReceiverAuthRepositoryImpl( + api = FakeReceiverAuthApiService(onSendEmailAuthCode = { throw original }), + errorReporter = RecordingErrorReporter(), + ) + + val result = runBlocking { repository.sendEmailAuthCode("a@b.com") } + assertEquals(original, result.exceptionOrNull()) } diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplFailureTranslationTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplFailureTranslationTest.kt new file mode 100644 index 0000000000..ee67ada037 --- /dev/null +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplFailureTranslationTest.kt @@ -0,0 +1,150 @@ +package com.afternote.feature.receiver.data.repositoryimpl + +import com.afternote.core.network.model.ApiException +import com.afternote.core.network.model.BaseResponse +import com.afternote.feature.receiver.data.dto.DeliveryVerificationDto +import com.afternote.feature.receiver.data.dto.DeliveryVerificationRequestDto +import com.afternote.feature.receiver.data.dto.ReceivedRecordBoxListDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthCodeEmailSendRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthPresignedUrlDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthPresignedUrlRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthVerifyDto +import com.afternote.feature.receiver.data.dto.ReceiverAuthVerifyRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyDto +import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyRequestDto +import com.afternote.feature.receiver.data.dto.ReceiverMessageDto +import com.afternote.feature.receiver.data.reporting.RecordingErrorReporter +import com.afternote.feature.receiver.data.service.ReceiverAuthApiService +import com.afternote.feature.receiver.domain.error.ReceiverFailure +import com.afternote.feature.receiver.domain.error.ReceiverRejectionReason +import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * `receiver-auth` **모든** endpoint 가 실패를 도메인 어휘로 내보내는지의 회귀 가드(#1053). + * + * 형제 테스트인 [ReceiverAuthRepositoryImplEmailAuthTest] 가 이메일 인증 두 endpoint 의 사유 번역을 + * 지킨다면, 여기는 «번역이 붙지 않은 endpoint 가 남지 않는다» 를 지킨다. 메서드마다 `try/catch` 를 + * 포개던 시절에는 인증 계열 4개만 번역되고 상태 조회·기록함 목록·presigned URL 은 `ApiException` + * (HTTP status·BE `ErrorCode` 번호·서버 문구)을 도메인 밖으로 그대로 흘렸다. + * + * 새 endpoint 가 늘면 [receiverAuthCalls] 에 한 줄을 더한다 — 목록에서 빠진 메서드는 이 가드가 + * 잡지 못하므로, 저장소 인터페이스의 메서드 수와 이 목록의 크기를 함께 단언한다. + */ +class ReceiverAuthRepositoryImplFailureTranslationTest { + @Test + fun `모든 endpoint 가 서버 거절을 도메인 사유로 옮긴다`() { + val original = + ApiException( + status = 404, + code = 1901, + serverMessage = "등록된 수신자 이메일이 아닙니다.", + fallbackMessage = "등록된 수신자 이메일이 아닙니다.", + ) + val repository = repositoryFailingWith { throw original } + + receiverAuthCalls.forEach { (name, call) -> + val exception = runBlocking { repository.call() }.exceptionOrNull() + + assertTrue("$name: $exception", exception is ReceiverFailure.UserRejection) + exception as ReceiverFailure.UserRejection + assertEquals(name, ReceiverRejectionReason.RECEIVER_EMAIL_NOT_FOUND, exception.reason) + assertEquals(name, original, exception.cause) + } + } + + @Test + fun `모든 endpoint 가 전송 계층 실패를 연결 불가로 옮긴다`() { + val original = IOException("Unable to resolve host") + val repository = repositoryFailingWith { throw original } + + receiverAuthCalls.forEach { (name, call) -> + val exception = runBlocking { repository.call() }.exceptionOrNull() + + assertTrue("$name: $exception", exception is ReceiverFailure.NetworkUnavailable) + assertEquals(name, original, exception?.cause) + } + } + + /** + * 인프라 예외가 도메인 밖으로 새는지를 타입 이름으로 한 번 더 못 박는다 — 소비처(presentation)는 + * `core:network` 에 의존하지 않아 [ApiException] 을 받으면 사유를 가르지 못하고 폴백으로 수렴한다. + */ + @Test + fun `어떤 endpoint 도 ApiException 을 그대로 내보내지 않는다`() { + val serverFailure = ApiException(status = 500, code = 500, serverMessage = null, fallbackMessage = "서버 오류") + val repository = repositoryFailingWith { throw serverFailure } + + receiverAuthCalls.forEach { (name, call) -> + val exception = runBlocking { repository.call() }.exceptionOrNull() + + assertTrue("$name: $exception", exception is ReceiverFailure.UnexpectedServerFailure) + assertEquals(name, serverFailure, exception?.cause) + } + } + + /** + * 목록이 저장소 인터페이스보다 뒤처지면 새 endpoint 는 번역 없이 지나간다 — 위 세 테스트가 + * 전부 통과해도 그 사실이 드러나지 않으므로, 메서드 이름을 인터페이스와 맞춘다. + * + * JVM 이름에서 접미사를 떼는 이유 — 반환 타입이 인라인 클래스([Result]) 라 컴파일러가 + * `verifyMasterKey-gIAlu-s` 처럼 시그니처 해시를 붙인다. + */ + @Test + fun `가드 목록이 저장소 메서드 전량을 덮는다`() { + val declaredMethods = + ReceiverAuthRepository::class.java.declaredMethods + .map { it.name.substringBefore('-') } + .toSet() + + assertEquals(declaredMethods, receiverAuthCalls.map { it.first }.toSet()) + } + + private fun repositoryFailingWith(failure: suspend () -> Nothing): ReceiverAuthRepository = + ReceiverAuthRepositoryImpl( + api = AlwaysFailingReceiverAuthApiService(failure), + errorReporter = RecordingErrorReporter(), + ) +} + +/** + * 저장소가 서버를 부르는 메서드 전량. 인자 값은 어느 것이든 상관없다 — fake 가 호출 즉시 실패한다. + */ +private val receiverAuthCalls: List Result<*>>> = + listOf( + "verifyMasterKey" to { verifyMasterKey("master-key") }, + "sendEmailAuthCode" to { sendEmailAuthCode("a@b.com") }, + "verifyEmailAuthCode" to { verifyEmailAuthCode(email = "a@b.com", authCode = "123456") }, + "getPresignedUrl" to { getPresignedUrl(extension = "pdf", contentLength = 10L) }, + "submitDeliveryVerification" to { + submitDeliveryVerification(deathCertificateUrl = "https://example.com/a.pdf", familyRelationCertificateUrl = null) + }, + "getDeliveryVerificationStatus" to { getDeliveryVerificationStatus() }, + "getSenderMessage" to { getSenderMessage() }, + "getReceivedRecordBoxes" to { getReceivedRecordBoxes() }, + ) + +/** 모든 endpoint 가 같은 실패를 던지는 fake — «번역이 붙지 않은 메서드» 를 드러내는 것이 목적이다. */ +private class AlwaysFailingReceiverAuthApiService( + private val failure: suspend () -> Nothing, +) : ReceiverAuthApiService { + override suspend fun verifyMasterKey(body: ReceiverAuthVerifyRequestDto): BaseResponse = failure() + + override suspend fun sendEmailAuthCode(body: ReceiverAuthCodeEmailSendRequestDto): BaseResponse = failure() + + override suspend fun verifyEmailAuthCode(body: ReceiverEmailAuthVerifyRequestDto): BaseResponse = failure() + + override suspend fun getPresignedUrl(body: ReceiverAuthPresignedUrlRequestDto): BaseResponse = failure() + + override suspend fun submitDeliveryVerification(body: DeliveryVerificationRequestDto): BaseResponse = failure() + + override suspend fun getDeliveryVerificationStatus(): BaseResponse = failure() + + override suspend fun getSenderMessage(): BaseResponse = failure() + + override suspend fun getReceivedRecordBoxes(): BaseResponse = failure() +} diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverRepositoryImplExportTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverRepositoryImplExportTest.kt new file mode 100644 index 0000000000..2daabc6b95 --- /dev/null +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverRepositoryImplExportTest.kt @@ -0,0 +1,71 @@ +package com.afternote.feature.receiver.data.repositoryimpl + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.network.model.BaseResponse +import com.afternote.feature.receiver.data.dto.ReceivedAfternoteDetailDto +import com.afternote.feature.receiver.data.dto.ReceivedAfternoteListDto +import com.afternote.feature.receiver.data.local.ReceiverMasterKeyDataSource +import com.afternote.feature.receiver.data.service.ReceiverAfternoteApiService +import com.afternote.feature.receiver.domain.error.ReceiverFailure +import com.afternote.feature.receiver.domain.model.ReceivedExportBundle +import com.afternote.feature.receiver.domain.testing.FakeReceiverAuthRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReceiverRepositoryImplExportTest { + private val repository = + ReceiverRepositoryImpl( + masterKeyDataSource = ReceiverMasterKeyDataSource(InMemoryPreferencesDataStore()), + api = UnusedReceiverAfternoteApiService, + receiverAuthRepository = FakeReceiverAuthRepository.strict(), + errorReporter = UnusedErrorReporter, + ) + + @Test + fun `downloadReceivedExport - 구현 전에는 ExportNotSupported 실패로 닫힌다`() { + val failure = runBlocking { repository.downloadReceivedExport() }.exceptionOrNull() + + assertTrue(failure is ReceiverFailure.ExportNotSupported) + } + + @Test + fun `saveReceivedExportToFile - 구현 전에는 ExportNotSupported 실패로 닫힌다`() { + val failure = + runBlocking { + repository.saveReceivedExportToFile(ReceivedExportBundle(payloadJson = "not-written")) + }.exceptionOrNull() + + assertTrue(failure is ReceiverFailure.ExportNotSupported) + } +} + +private class InMemoryPreferencesDataStore : DataStore { + private val state = MutableStateFlow(emptyPreferences()) + + override val data: Flow get() = state + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + val transformed = transform(state.value) + state.value = transformed + return transformed + } +} + +private object UnusedReceiverAfternoteApiService : ReceiverAfternoteApiService { + override suspend fun getReceiverAfternotes(): BaseResponse = error("unused") + + override suspend fun getReceiverAfternoteDetail(afternoteId: Long): BaseResponse = error("unused") +} + +private object UnusedErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = error("unused") +} diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverRepositoryImplFailureTranslationTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverRepositoryImplFailureTranslationTest.kt new file mode 100644 index 0000000000..ebc3e0c367 --- /dev/null +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverRepositoryImplFailureTranslationTest.kt @@ -0,0 +1,118 @@ +package com.afternote.feature.receiver.data.repositoryimpl + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.network.model.ApiException +import com.afternote.core.network.model.BaseResponse +import com.afternote.feature.receiver.data.dto.ReceivedAfternoteDetailDto +import com.afternote.feature.receiver.data.dto.ReceivedAfternoteListDto +import com.afternote.feature.receiver.data.local.ReceiverMasterKeyDataSource +import com.afternote.feature.receiver.data.service.ReceiverAfternoteApiService +import com.afternote.feature.receiver.domain.error.ReceiverFailure +import com.afternote.feature.receiver.domain.testing.FakeReceiverAuthRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * 수신 목록·상세 조회가 실패를 도메인 어휘로 내보내는지의 회귀 가드(#1053). + * + * 같은 목록을 읽는 페이징 경로([com.afternote.feature.receiver.data.paging.ReceiverAfternotePagingSource]) + * 는 #611 에서 번역을 붙였지만 이 저장소의 단발 조회 두 개는 `ApiException`(HTTP status·BE + * `ErrorCode` 번호·서버 문구)을 도메인 밖으로 그대로 흘렸다. 같은 서버 사유가 어느 경로로 들어왔는지에 + * 따라 화면 처리가 갈리면(«전달 조건 미충족» 이 한쪽에서만 재시도 없는 안내가 된다) 소비처는 그 차이를 + * 알 방법이 없다. + * + * code·status 는 2026-07-30 실기기 logcat 캡처 — + * `<-- 403` `{"status":403,"code":2009,"message":"아직 전달 조건이 충족되지 않았습니다."}`. + */ +class ReceiverRepositoryImplFailureTranslationTest { + @Test + fun `목록 조회의 전달 조건 미충족 403 은 전용 도메인 타입으로 나온다`() { + val original = deliveryConditionNotMet() + val repository = repository(FailingReceiverAfternoteApiService { throw original }) + + val exception = runBlocking { repository.getReceivedAfterNotes() }.exceptionOrNull() + + assertTrue("$exception", exception is ReceiverFailure.DeliveryConditionNotMet) + assertEquals(original, exception?.cause) + } + + @Test + fun `목록 조회의 전송 계층 실패는 연결 불가로 나온다`() { + val original = IOException("Unable to resolve host") + val repository = repository(FailingReceiverAfternoteApiService { throw original }) + + val exception = runBlocking { repository.getReceivedAfterNotes() }.exceptionOrNull() + + assertTrue("$exception", exception is ReceiverFailure.NetworkUnavailable) + assertEquals(original, exception?.cause) + } + + @Test + fun `상세 조회의 전달 조건 미충족 403 은 전용 도메인 타입으로 나온다`() { + val original = deliveryConditionNotMet() + val repository = repository(FailingReceiverAfternoteApiService { throw original }) + + val exception = runBlocking { repository.getReceivedAfternoteDetail(afternoteId = 7L) }.exceptionOrNull() + + assertTrue("$exception", exception is ReceiverFailure.DeliveryConditionNotMet) + assertEquals(original, exception?.cause) + } + + /** 매핑 실패처럼 도메인 어휘가 없는 실패는 원인 타입 그대로 나가야 리포팅에서 구분된다. */ + @Test + fun `분류 대상이 아닌 실패는 원본 그대로 나온다`() { + val original = IllegalStateException("boom") + val repository = repository(FailingReceiverAfternoteApiService { throw original }) + + val exception = runBlocking { repository.getReceivedAfterNotes() }.exceptionOrNull() + + assertEquals(original, exception) + } + + private fun deliveryConditionNotMet(): ApiException = + ApiException( + status = 403, + code = 2009, + serverMessage = "아직 전달 조건이 충족되지 않았습니다.", + fallbackMessage = "아직 전달 조건이 충족되지 않았습니다.", + ) + + private fun repository(api: ReceiverAfternoteApiService): ReceiverRepositoryImpl = + ReceiverRepositoryImpl( + masterKeyDataSource = ReceiverMasterKeyDataSource(EmptyPreferencesDataStore()), + api = api, + receiverAuthRepository = FakeReceiverAuthRepository(), + errorReporter = SilentErrorReporter(), + ) +} + +/** 목록·상세 두 endpoint 가 같은 실패를 던지는 fake. */ +private class FailingReceiverAfternoteApiService( + private val failure: suspend () -> Nothing, +) : ReceiverAfternoteApiService { + override suspend fun getReceiverAfternotes(): BaseResponse = failure() + + override suspend fun getReceiverAfternoteDetail(afternoteId: Long): BaseResponse = failure() +} + +/** 인증 코드는 이 테스트의 관심사가 아니다 — 저장된 코드가 없는 상태만 흉내 낸다. */ +private class EmptyPreferencesDataStore : DataStore { + override val data: Flow = flowOf(emptyPreferences()) + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences = transform(emptyPreferences()) +} + +private class SilentErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit +} diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/DeliveryDocumentsMissingException.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/DeliveryDocumentsMissingException.kt new file mode 100644 index 0000000000..cf14c17c58 --- /dev/null +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/DeliveryDocumentsMissingException.kt @@ -0,0 +1,12 @@ +package com.afternote.feature.receiver.domain.error + +/** + * 열람 신청에 실을 증빙 서류가 하나도 없는 입력 (#380, #1701). + * + * 서버에 닿기 전에 호출 자체가 성립하지 않는다는 뜻이라 [ReceiverFailure] 계열(서버·전송 실패를 + * Data 계층이 번역한 결과)에 넣지 않았다. 그 계열은 원인 예외를 필수로 요구하는데, 이 실패에는 + * 원인이 될 예외가 없다. + * + * 화면 문구는 호출처 리소스가 갖는다 — 이 타입은 «어떤 실패인지» 만 나른다. + */ +class DeliveryDocumentsMissingException : Exception("delivery verification requires at least one document url") diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverFailure.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverFailure.kt index 0c0090f75c..bcae24aa84 100644 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverFailure.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverFailure.kt @@ -1,8 +1,9 @@ package com.afternote.feature.receiver.domain.error /** - * 수신자 흐름(본인 확인 이메일 인증·마스터 키 검증·전달 자격 심사)의 실패 중 **사유가 확인된 것**의 - * 도메인 루트. Retrofit·`BaseResponse` 같은 인프라 디테일은 Data 계층이 해석한 뒤 이 계열로 통일한다. + * 수신자 흐름(본인 확인 이메일 인증·마스터 키 검증·전달 자격 심사·기록 내보내기)의 알려진 실패를 + * 나타내는 도메인 루트. Retrofit·`BaseResponse` 같은 인프라 디테일은 Data 계층이 해석한 뒤 이 계열로 + * 통일한다. * * 소비처는 이 루트로 좁힌 뒤 `when` 으로 가른다 — 실패 유형이 늘면 컴파일러가 소비처를 잡아준다. * 서버 응답과 네트워크 실패는 Data 계층이 이 계열로 번역한다. 그 밖의 로컬 실패는 원본 그대로 @@ -12,14 +13,12 @@ package com.afternote.feature.receiver.domain.error * 갖는다. non-null 로 좁혀 하위 타입이 빠뜨릴 수 없게 했다: 비우면 `Exception` 이 * `cause.toString()`(`java.net.UnknownHostException: Unable to resolve host ...` 같은 영문 기술 * 원문)을 message 로 앉힌다. - * @param cause 이 실패를 만든 인프라 예외. non-null 인 건 이 계열이 전부 그런 예외를 옮겨 - * 만들어져 원인 없는 실패가 존재하지 않아서다. 버리면 원 호출 지점의 stack trace 가 끊겨 - * Crashlytics 에서 «어디서 난 실패인지» 를 잃는다. (`CoreAuthFailure` 루트가 nullable 인 건 - * 사용자 취소처럼 예외에서 유래하지 않는 갈래를 갖기 때문이고, 이 계열엔 그런 갈래가 없다.) + * @param cause 이 실패를 만든 인프라 예외. 서버·네트워크 실패는 원 호출 지점의 stack trace 가 끊기지 + * 않도록 반드시 보존한다. [ExportNotSupported]처럼 예외에서 유래하지 않은 기능 상태만 `null`이다. */ sealed class ReceiverFailure( message: String, - cause: Throwable, + cause: Throwable?, ) : Exception(message, cause) { /** * 서버가 예상하고 처리한 사용자 거절. @@ -77,6 +76,19 @@ sealed class ReceiverFailure( class DeliveryConditionNotMet( cause: Throwable, ) : ReceiverFailure("delivery condition not met", cause) + + /** + * 서버 export 계약이 아직 도착하지 않아 받은 기록 내보내기를 수행할 수 없다는 **기능 상태**. + * + * 사용자 잘못도 서버 장애도 아니라 «아직 만들지 않았다» 이므로, 인프라 예외에서 유래하지 않아 + * `cause` 가 없는 유일한 갈래다. 운영 장애로 기록하지 않는다 — 매 시도가 텔레메트리에 쌓이면 + * 실제 장애를 밀어낸다. + * + * 이 타입은 «성공을 만들어 내지 않기» 위해 존재한다. 스텁이 `Result.success` 를 돌려주던 동안 + * 사용자에게는 백업이 끝난 것처럼 보였다(#589). 서버 엔드포인트(Afternote-BE#90)가 도착하면 + * 이 갈래는 실제 구현으로 대체되어 사라진다. + */ + class ExportNotSupported : ReceiverFailure("received export is not supported", null) } /** 화면에 별도 안내가 필요한 수신자 거절 사유. 서버 code 와 문구는 Data 계층이 이 어휘로 번역한다. */ diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverAuthRepository.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverAuthRepository.kt index 43c3a7901d..0011e8c7e4 100644 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverAuthRepository.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverAuthRepository.kt @@ -16,6 +16,12 @@ import com.afternote.feature.receiver.domain.model.SenderMessageInfo * 인증 코드 저장은 `ReceiverRepository.saveMasterKey` 를 사용하며, * 저장된 코드는 `ReceiverAuthInterceptor` 가 `X-Auth-Code` 헤더로 자동 부착한다. * (둘 다 feature:afternote 쪽이라 이 모듈에서 KDoc 링크로 참조할 수 없다 — 의존 방향이 반대.) + * + * **실패 계약 — 메서드별 예외가 아니라 인터페이스 전체의 규약이다(#1053).** 서버가 응답하며 거절한 + * 실패와 서버에 닿지 못한 실패는 전부 [com.afternote.feature.receiver.domain.error.ReceiverFailure] 로 + * 온다. HTTP status·BE `ErrorCode` 번호·서버 문구는 Data 계층 밖으로 나오지 않으므로 소비처는 타입과 + * 사유만 본다. 매핑·검증 같은 로컬 실패는 도메인 어휘가 없어 원본 타입 그대로 오고, 그 몫이 소비처의 + * «루트가 아닌 Throwable» 폴백 갈래다. */ interface ReceiverAuthRepository { /** @@ -57,7 +63,8 @@ interface ReceiverAuthRepository { /** * 사망확인 서류 제출 — 두 URL 중 **하나 이상** 은 non-null 이어야 한다 (서버가 최소 1개 요구). - * 둘 다 null 인 호출은 서버가 거절하므로 호출부(ViewModel)가 사전 차단한다. + * 둘 다 null 인 호출은 서버가 거절하므로 + * [com.afternote.feature.receiver.domain.usecase.SubmitDeliveryVerificationUseCase] 가 사전 차단한다. */ suspend fun submitDeliveryVerification( deathCertificateUrl: String?, diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverRepository.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverRepository.kt index b1aaf38ddd..46f98663b3 100644 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverRepository.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverRepository.kt @@ -13,6 +13,10 @@ import kotlinx.coroutines.flow.Flow * * `X-Auth-Code` 헤더는 네트워크 계층의 ReceiverAuthInterceptor가 자동 부착하므로 * 호출자는 인증 코드를 메서드 인자로 들고 다닐 필요가 없습니다. + * + * **실패 계약은 [ReceiverAuthRepository] 와 같다(#1053)** — 서버를 부르는 조회는 단발이든 페이징이든 + * [com.afternote.feature.receiver.domain.error.ReceiverFailure] 로 실패를 돌려준다. 같은 서버 사유가 + * 경로에 따라 다른 타입으로 오면 화면이 «전달 조건 미충족»·«연결 없음» 을 한 기준으로 가를 수 없다. */ interface ReceiverRepository { /** 저장된 인증 코드 스트림(없거나 공백만 있으면 null 방출). */ @@ -34,8 +38,10 @@ interface ReceiverRepository { suspend fun getReceivedAfternoteDetail(afternoteId: Long): Result + /** 서버 export 계약 도입 전에는 [com.afternote.feature.receiver.domain.error.ReceiverFailure.ExportNotSupported]. */ suspend fun downloadReceivedExport(): Result + /** export 저장 구현 도입 전에는 [com.afternote.feature.receiver.domain.error.ReceiverFailure.ExportNotSupported]. */ suspend fun saveReceivedExportToFile(bundle: ReceivedExportBundle): Result suspend fun loadSenderMessage(): Result diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/usecase/SubmitDeliveryVerificationUseCase.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/usecase/SubmitDeliveryVerificationUseCase.kt new file mode 100644 index 0000000000..9c3d6bab90 --- /dev/null +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/usecase/SubmitDeliveryVerificationUseCase.kt @@ -0,0 +1,36 @@ +package com.afternote.feature.receiver.domain.usecase + +import com.afternote.feature.receiver.domain.error.DeliveryDocumentsMissingException +import com.afternote.feature.receiver.domain.model.DeliveryVerification +import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository +import javax.inject.Inject + +/** + * 열람 신청 서류 제출 (#215, #380, #1701). + * + * 불변식 — 사망확인서·가족관계증명서 URL 중 **하나 이상**이 있어야 제출이 성립한다. 이 규칙은 + * 서버 계약이지 화면 사정이 아니라서 ViewModel 이 아니라 여기가 소유한다. 둘 다 없으면 + * Repository 를 부르지 않고 [DeliveryDocumentsMissingException] 으로 닫는다 — 어차피 서버가 + * 거절할 요청이라 왕복시킬 이유가 없고, 화면은 «업로드부터 하라» 는 안내를 그 실패로 고른다. + * + * 빈 문자열을 «없음» 으로 정규화하지 않는다. URL 은 업로드 성공 응답에서만 채워지므로 빈 값이 + * 도달할 경로가 없고, 여기서 정규화하면 서버로 나가는 페이로드가 조용히 달라진다. + */ +class SubmitDeliveryVerificationUseCase + @Inject + constructor( + private val receiverAuthRepository: ReceiverAuthRepository, + ) { + suspend operator fun invoke( + deathCertificateUrl: String?, + familyRelationCertificateUrl: String?, + ): Result { + if (deathCertificateUrl == null && familyRelationCertificateUrl == null) { + return Result.failure(DeliveryDocumentsMissingException()) + } + return receiverAuthRepository.submitDeliveryVerification( + deathCertificateUrl = deathCertificateUrl, + familyRelationCertificateUrl = familyRelationCertificateUrl, + ) + } + } diff --git a/feature/receiver/domain/src/test/java/com/afternote/feature/receiver/domain/usecase/SubmitDeliveryVerificationUseCaseTest.kt b/feature/receiver/domain/src/test/java/com/afternote/feature/receiver/domain/usecase/SubmitDeliveryVerificationUseCaseTest.kt new file mode 100644 index 0000000000..21d12aa9ff --- /dev/null +++ b/feature/receiver/domain/src/test/java/com/afternote/feature/receiver/domain/usecase/SubmitDeliveryVerificationUseCaseTest.kt @@ -0,0 +1,103 @@ +package com.afternote.feature.receiver.domain.usecase + +import com.afternote.feature.receiver.domain.error.DeliveryDocumentsMissingException +import com.afternote.feature.receiver.domain.testing.FakeReceiverAuthRepository +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [SubmitDeliveryVerificationUseCase] 가 소유하는 제출 불변식 (#380, #1701). + * + * 계약 — 사망확인서·가족관계증명서 URL 이 둘 다 없으면 Repository 를 부르지 않고 + * [DeliveryDocumentsMissingException] 으로 닫는다. 한쪽이라도 있으면 두 값을 그대로 전달한다. + */ +class SubmitDeliveryVerificationUseCaseTest { + @Test + fun `두 URL이 모두 없으면 Repository를 부르지 않고 서류 누락으로 닫는다`() { + // strict fake 라 호출되면 unexpectedCall 로도 터진다 — 기록 단언과 이중으로 막는다. + val repository = FakeReceiverAuthRepository.strict() + + val result = + runBlocking { + SubmitDeliveryVerificationUseCase(repository)( + deathCertificateUrl = null, + familyRelationCertificateUrl = null, + ) + } + + assertTrue(repository.deliverySubmissions.isEmpty()) + assertTrue(result.exceptionOrNull() is DeliveryDocumentsMissingException) + } + + @Test + fun `사망확인서만 있으면 가족관계증명서 자리를 비운 채 전달한다`() { + val repository = FakeReceiverAuthRepository() + + val result = + runBlocking { + SubmitDeliveryVerificationUseCase(repository)( + deathCertificateUrl = "https://cdn.test/death.pdf", + familyRelationCertificateUrl = null, + ) + } + + assertEquals(listOf("https://cdn.test/death.pdf" to null), repository.deliverySubmissions) + assertEquals("https://cdn.test/death.pdf", result.getOrThrow().deathCertificateUrl) + } + + @Test + fun `가족관계증명서만 있어도 제출된다`() { + val repository = FakeReceiverAuthRepository() + + val result = + runBlocking { + SubmitDeliveryVerificationUseCase(repository)( + deathCertificateUrl = null, + familyRelationCertificateUrl = "https://cdn.test/family.pdf", + ) + } + + assertEquals(listOf(null to "https://cdn.test/family.pdf"), repository.deliverySubmissions) + assertEquals("https://cdn.test/family.pdf", result.getOrThrow().familyRelationCertificateUrl) + } + + @Test + fun `두 URL이 모두 있으면 둘 다 실려 나간다`() { + val repository = FakeReceiverAuthRepository() + + val result = + runBlocking { + SubmitDeliveryVerificationUseCase(repository)( + deathCertificateUrl = "https://cdn.test/death.pdf", + familyRelationCertificateUrl = "https://cdn.test/family.pdf", + ) + } + + assertEquals( + listOf("https://cdn.test/death.pdf" to "https://cdn.test/family.pdf"), + repository.deliverySubmissions, + ) + assertTrue(result.isSuccess) + } + + @Test + fun `Repository 실패는 그대로 흘려보낸다`() { + val failure = IllegalStateException("서버 거절") + val repository = + FakeReceiverAuthRepository.strict().apply { + onSubmitDeliveryVerification = { _, _ -> Result.failure(failure) } + } + + val result = + runBlocking { + SubmitDeliveryVerificationUseCase(repository)( + deathCertificateUrl = "https://cdn.test/death.pdf", + familyRelationCertificateUrl = null, + ) + } + + assertEquals(failure, result.exceptionOrNull()) + } +} diff --git a/feature/receiver/presentation/build.gradle.kts b/feature/receiver/presentation/build.gradle.kts index 5839bea901..1e571a5ee8 100644 --- a/feature/receiver/presentation/build.gradle.kts +++ b/feature/receiver/presentation/build.gradle.kts @@ -1,7 +1,7 @@ plugins { id("afternote.android.library.compose") id("afternote.android.hilt") - kotlin("plugin.serialization") + id("afternote.android.navigation") alias(libs.plugins.compose.screenshot) id("afternote.kover") } @@ -26,13 +26,11 @@ dependencies { implementation(projects.core.common) implementation(projects.core.ui) implementation(libs.androidx.activity.compose) - implementation(libs.android.navigation.compose) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.hilt.navigation) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.coil.compose) - implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.paging.runtime) implementation(libs.androidx.paging.compose) diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationFlowViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationFlowViewModel.kt index 7fe6935b74..7bbff3a950 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationFlowViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationFlowViewModel.kt @@ -1,41 +1,44 @@ package com.afternote.feature.receiver.presentation.deliveryverification -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.navigation.toRoute import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject /** * 열람 신청 흐름 — 본인 확인(2·3·4) + 마스터 키(5) + 서류 업로드(6·7·8) + 완료(9) — 전체에 걸친 - * flow-scoped ViewModel. [ReceiverRoute.DeliveryVerificationFlowRoute] (nested graph 진입점) 에 binding 되어 - * 자식 라우트들은 `hiltViewModel(parentBackStackEntry)` 로 동일 인스턴스를 공유한다. + * flow-scoped ViewModel. [ReceiverRoute.DeliveryVerificationFlowRoute] entry 범위에 묶여, 그 안의 + * 로컬 스택에 있는 단계 화면들이 동일 인스턴스를 공유한다. + * + * `senderId` 는 route 인자로 받는다. Nav3 entry 에는 Nav2 의 `savedStateHandle.toRoute()` 자동 + * 채움이 없으므로 assisted 주입으로 키를 직접 받는다 — 타입 안전하고, 테스트에서도 키를 그대로 + * 넘길 수 있다 (#959 실측 함정 3). * * 1차 도입(#220) 범위: * - `senderId` 단일 보유 (자식 라우트에서 nav arg 중복 박지 않음) - * - [IdentityVerificationRepository.isVerified] read-only 위임 — nested graph 시작 시점에 본인 확인 캐시 분기를 - * 자식이 직접 의존하지 않고 flow VM 통해 결정 + * - [IdentityVerificationRepository.isVerified] read-only 위임 — 흐름 스택의 첫 단계(Intro) entry 에서 본인 확인 + * 캐시 분기를 자식이 직접 의존하지 않고 flow VM 통해 결정 * * 후속(2차 PR 이후) 으로 옮길 책임: * - `masterKey`·`ReceiverIdentity` 등 누적 흐름 상태 보유 * - [com.afternote.feature.receiver.presentation.recordsbox.SenderRegistry.attachIdentity] 호출 시점 위임 * - [IdentityVerificationRepository] 자체의 흡수·싱글톤 제거 */ -@HiltViewModel +@HiltViewModel(assistedFactory = DeliveryVerificationFlowViewModel.Factory::class) class DeliveryVerificationFlowViewModel - @Inject + @AssistedInject constructor( - savedStateHandle: SavedStateHandle, + @Assisted route: ReceiverRoute.DeliveryVerificationFlowRoute, identityVerificationRepository: IdentityVerificationRepository, ) : ViewModel() { - val senderId: String = - savedStateHandle.toRoute().senderId + val senderId: String = route.senderId /** * 본인 확인 캐시 여부 — Intro 진입 시 즉시 MasterKey 로 jump 할지 판단용. @@ -54,4 +57,9 @@ class DeliveryVerificationFlowViewModel started = SharingStarted.WhileSubscribed(5_000), initialValue = false, ) + + @AssistedFactory + interface Factory { + fun create(route: ReceiverRoute.DeliveryVerificationFlowRoute): DeliveryVerificationFlowViewModel + } } diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreen.kt index 47440a1ff5..1cd7024726 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreen.kt @@ -44,6 +44,7 @@ import com.afternote.feature.receiver.presentation.deliveryverification.componen import com.afternote.feature.receiver.presentation.deliveryverification.component.RECEIVER_VERIFY_HEADER_SPACING import com.afternote.feature.receiver.presentation.deliveryverification.component.RECEIVER_VERIFY_TOTAL_STEPS import com.afternote.feature.receiver.presentation.deliveryverification.component.ReceiverVerifyStep +import com.afternote.feature.receiver.presentation.error.ReceiverErrorPopupHost import com.afternote.core.ui.R as CoreUiR /** @@ -112,8 +113,10 @@ fun DocumentUploadScreen( } // VM 은 리소스 또는 표시 가능한 동적 문구를 UiText 하나로 운반하므로 별도 우선순위 분기가 필요 없다. + // 서버 작업 실패는 이 채널이 아니라 아래 공통 오류 팝업으로 간다 (#446) — 여기 남는 것은 서버가 + // 준 거절 사유와 파일 읽기 실패 같은 로컬 안내다. val errorMessage = - uiState.error?.asString() + uiState.errorMessage?.asString() LaunchedEffect(errorMessage) { if (errorMessage != null) { snackbarHostState.showSnackbar(errorMessage) @@ -131,6 +134,12 @@ fun DocumentUploadScreen( modifier = modifier, ) + ReceiverErrorPopupHost( + popup = uiState.errorPopup, + onRetry = viewModel::retryFailedRequest, + onDismiss = viewModel::onErrorPopupDismissed, + ) + // 디자인 7 — 슬롯 클릭 시 떠오르는 미디어 소스 선택 시트. "이미지 추가" / "파일 추가" 둘 중 하나 선택. sheetSlot?.let { slot -> MediaSelectBottomSheet( diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt index a3fc7b7897..a0f7b727ea 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt @@ -2,6 +2,7 @@ package com.afternote.feature.receiver.presentation.deliveryverification import androidx.compose.runtime.Immutable import com.afternote.core.ui.UiText +import com.afternote.feature.receiver.presentation.error.ReceiverErrorPopup /** * 증빙 서류 업로드(6·7·8) UI 상태. @@ -21,7 +22,15 @@ data class DocumentUploadUiState( * * 표시 가능한 서버 message 와 클라이언트 fallback 을 [UiText] 하나로 운반하며, [isSubmitted] 와 같은 소비형 필드다. */ - val error: UiText? = null, + val errorMessage: UiText? = null, + /** + * 표시 중인 공통 오류 팝업(#446). [errorMessage] 와 배타적이다 — 서버 작업 실패는 팝업이 정본이고, + * 스낵바 자리에는 서버가 준 거절 사유와 로컬 검증 안내만 남는다. + * + * [errorMessage] 와 달리 «표시했으니 즉시 소비» 가 아니라 사용자가 버튼을 누를 때까지 떠 있는 상태다 — + * 모달이라 재시도·닫기 중 하나를 고르기 전에는 사라지지 않는다. + */ + val errorPopup: ReceiverErrorPopup? = null, /** 제출 성공 신호 — UI 가 LaunchedEffect 로 완료 화면 이동 후 [DocumentUploadViewModel.onSubmittedConsumed] 로 reset. */ val isSubmitted: Boolean = false, ) { diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadViewModel.kt index 49d09aec68..3581657ce8 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadViewModel.kt @@ -1,5 +1,6 @@ package com.afternote.feature.receiver.presentation.deliveryverification +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter @@ -7,9 +8,11 @@ import com.afternote.core.ui.UiText import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFailure import com.afternote.feature.afternote.presentation.reporting.shouldReportInReceiverFlow -import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository +import com.afternote.feature.receiver.domain.error.DeliveryDocumentsMissingException import com.afternote.feature.receiver.domain.repository.ReceiverDeliveryDocumentUploadRepository +import com.afternote.feature.receiver.domain.usecase.SubmitDeliveryVerificationUseCase import com.afternote.feature.receiver.presentation.R +import com.afternote.feature.receiver.presentation.error.toReceiverErrorPopupOrNull import com.afternote.feature.receiver.presentation.error.toReceiverErrorUiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -24,20 +27,31 @@ import javax.inject.Inject * * 슬롯별 파일 바이트는 UI 의 picker 콜백이 ContentResolver 로 추출해 [uploadDocument] 로 전달한다 — * 도메인 레이어가 Android Uri 에 의존하지 않도록 분리. 업로드 성공 시 슬롯에 fileUrl 이 채워지고, - * 두 슬롯 중 하나 이상 fileUrl 이 채워지면 "다음" 활성 → [submit] 으로 `submitDeliveryVerification` 호출 - * (사망진단서/가족관계증명서 중 하나만으로 신청 가능 — 이슈 #380). + * 두 슬롯 중 하나 이상 fileUrl 이 채워지면 "다음" 활성 → [submit] 이 [SubmitDeliveryVerificationUseCase] 호출. + * + * «서류가 최소 1장» 이라는 제출 불변식(#380)은 UseCase 가 소유한다 — 이 ViewModel 은 그 실패를 + * 화면 문구로 옮기기만 한다 (#1701). 업로드 진행 중 잠금(#711)과 중복 탭 차단은 화면 사정이라 + * 여기 남는다. */ @HiltViewModel class DocumentUploadViewModel @Inject constructor( private val uploadRepository: ReceiverDeliveryDocumentUploadRepository, - private val receiverAuthRepository: ReceiverAuthRepository, + private val submitDeliveryVerification: SubmitDeliveryVerificationUseCase, private val errorReporter: ErrorReporter, ) : ViewModel() { private val _uiState = MutableStateFlow(DocumentUploadUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** + * 팝업의 "다시 시도하기" 가 되돌릴 마지막 시도 (#446). 팝업이 사라질 때 함께 비운다 — + * 업로드 재시도는 사용자가 고른 파일 바이트를 붙들고 있어야 하므로, 안 비우면 화면이 살아 + * 있는 내내 서류 한 장이 힙에 남는다. 팝업 없이는 재시도 진입점 자체가 없으므로 팝업의 + * 수명이 곧 이 값의 수명이다. + */ + private var pendingRetry: (() -> Unit)? = null + fun uploadDocument( slot: DocumentSlot, bytes: ByteArray, @@ -64,8 +78,10 @@ class DocumentUploadViewModel }.onFailure { throwable -> errorReporter.recordAfternoteFailure(AfternoteFailureStage.DOCUMENT_UPLOAD, throwable) updateSlot(slot) { previous } - _uiState.update { - it.copy(error = UiText.Resource(R.string.receiver_verify_document_upload_failed)) + // 재시도는 같은 바이트를 다시 올리는 것이다 — 파일 선택부터 다시 시키면 + // 「다시 시도하기」 가 이름과 달리 처음부터 하기가 된다 (#446). + showFailure(throwable, R.string.receiver_verify_document_upload_failed, uploadPath = true) { + uploadDocument(slot, bytes, extension, displayName) } } } @@ -73,7 +89,7 @@ class DocumentUploadViewModel /** picker 가 돌려준 Uri 에서 바이트 추출이 실패한 경우 — 업로드 요청 전이므로 슬롯은 건드리지 않는다 (#740). */ fun onDocumentReadFailed() { - _uiState.update { it.copy(error = UiText.Resource(R.string.receiver_verify_document_read_failed)) } + _uiState.update { it.copy(errorMessage = UiText.Resource(R.string.receiver_verify_document_read_failed)) } } fun submit() { @@ -82,43 +98,84 @@ class DocumentUploadViewModel // 업로드 중에도 도달할 수 있고, 그대로 보내면 진행 중 파일이 신청에서 빠진다 (#711). if (state.deathCertificate.isUploading || state.familyRelationCertificate.isUploading) { _uiState.update { - it.copy(error = UiText.Resource(R.string.receiver_verify_document_upload_in_progress)) + it.copy(errorMessage = UiText.Resource(R.string.receiver_verify_document_upload_in_progress)) } return } - val deathUrl = state.deathCertificate.fileUrl - val famRelUrl = state.familyRelationCertificate.fileUrl - if ((deathUrl == null && famRelUrl == null) || state.isSubmitting) { + // 이미 보낸 신청이 응답을 기다리는 중이면 두 번째 탭은 버린다 — 화면 사정이라 UseCase 로 내리지 않는다. + if (state.isSubmitting) { _uiState.update { - it.copy(error = UiText.Resource(R.string.receiver_verify_documents_required)) + it.copy(errorMessage = UiText.Resource(R.string.receiver_verify_documents_required)) } return } _uiState.update { - it.copy(isSubmitting = true, error = null) + it.copy(isSubmitting = true, errorMessage = null) } viewModelScope.launch { - receiverAuthRepository - .submitDeliveryVerification(deathUrl, famRelUrl) - .onSuccess { - _uiState.update { it.copy(isSubmitting = false, isSubmitted = true) } - }.onFailure { throwable -> - // 서버가 사유 문구를 준 거절(이미 대기 중 등)은 예상된 경로라 리포팅하지 않는다. - if (throwable.shouldReportInReceiverFlow()) { - errorReporter.recordAfternoteFailure(AfternoteFailureStage.DELIVERY_SUBMIT, throwable) - } + submitDeliveryVerification( + deathCertificateUrl = state.deathCertificate.fileUrl, + familyRelationCertificateUrl = state.familyRelationCertificate.fileUrl, + ).onSuccess { + _uiState.update { it.copy(isSubmitting = false, isSubmitted = true) } + }.onFailure { throwable -> + // 서류가 한 장도 없어 요청이 나가지도 않은 경우 — 서버 실패가 아니므로 리포팅하지 않는다. + if (throwable is DeliveryDocumentsMissingException) { _uiState.update { it.copy( isSubmitting = false, - error = throwable.toReceiverErrorUiText(R.string.receiver_verify_submit_failed), + errorMessage = UiText.Resource(R.string.receiver_verify_documents_required), ) } + return@onFailure } + // 서버가 사유 문구를 준 거절(이미 대기 중 등)은 예상된 경로라 리포팅하지 않는다. + if (throwable.shouldReportInReceiverFlow()) { + errorReporter.recordAfternoteFailure(AfternoteFailureStage.DELIVERY_SUBMIT, throwable) + } + _uiState.update { it.copy(isSubmitting = false) } + showFailure(throwable, R.string.receiver_verify_submit_failed, retry = ::submit) + } + } + } + + /** 팝업의 "다시 시도하기" — 팝업을 닫고 실패한 그 요청을 그대로 다시 보낸다 (#446). */ + fun retryFailedRequest() { + val retry = pendingRetry + _uiState.update { it.copy(errorPopup = null) } + pendingRetry = null + retry?.invoke() + } + + /** 팝업의 닫기 — 재시도 없이 화면으로 돌아간다. 붙들고 있던 시도도 함께 버린다. */ + fun onErrorPopupDismissed() { + _uiState.update { it.copy(errorPopup = null) } + pendingRetry = null + } + + /** + * 실패를 팝업(서버 작업 실패)과 스낵바(서버가 준 거절 사유) 중 한쪽으로만 보낸다 — 둘 다 + * 세우면 모달 뒤에서 스낵바가 혼자 떴다 사라진다. + */ + private fun showFailure( + throwable: Throwable, + @StringRes fallbackRes: Int, + uploadPath: Boolean = false, + retry: () -> Unit, + ) { + val popup = throwable.toReceiverErrorPopupOrNull(uploadPath = uploadPath) + pendingRetry = if (popup == null) null else retry + _uiState.update { + if (popup == null) { + it.copy(errorMessage = throwable.toReceiverErrorUiText(fallbackRes)) + } else { + it.copy(errorPopup = popup) + } } } fun consumeError() { - _uiState.update { it.copy(error = null) } + _uiState.update { it.copy(errorMessage = null) } } fun onSubmittedConsumed() { diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreen.kt index 099552a723..779a768f16 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreen.kt @@ -30,6 +30,7 @@ import com.afternote.feature.receiver.presentation.R import com.afternote.feature.receiver.presentation.deliveryverification.component.RECEIVER_VERIFY_HEADER_SPACING import com.afternote.feature.receiver.presentation.deliveryverification.component.RECEIVER_VERIFY_TOTAL_STEPS import com.afternote.feature.receiver.presentation.deliveryverification.component.ReceiverVerifyStep +import com.afternote.feature.receiver.presentation.error.ReceiverErrorPopupHost /** * 본인 확인 이메일 인증(designs 3·4) — 이메일 + 인증번호 입력 화면 (이슈 #215). @@ -38,10 +39,11 @@ import com.afternote.feature.receiver.presentation.deliveryverification.componen * 강조 안내 텍스트가 나타난다. * * 인증번호 발송·검증은 실 API(`receiver-auth/email` 계열) — 서버 거절 안내 문구(이메일 미등록 등) 는 - * 스낵바로 노출된다 (#407). + * 스낵바로 노출된다 (#407). 서버·네트워크 실패는 스낵바가 아니라 공통 오류 팝업이 맡는다 (#446) — + * 사용자가 할 일이 재시도뿐이라 스스로 사라지는 안내로는 그 액션을 줄 자리가 없다. * - * `senderId` 는 [MasterKeyScreen] 과 같은 규약으로 parent backStackEntry 의 - * [DeliveryVerificationFlowViewModel] 에서 받아 검증 성공 시점의 발신자별 캐시 기록에 쓴다 (#597). + * `senderId` 는 [MasterKeyScreen] 과 같은 규약으로 흐름 호스트가 흐름 entry 범위의 + * [DeliveryVerificationFlowViewModel] 에서 꺼내 넘겨 주며, 검증 성공 시점의 발신자별 캐시 기록에 쓴다 (#597). */ @Composable fun IdentityVerificationEmailScreen( @@ -71,7 +73,7 @@ fun IdentityVerificationEmailScreen( } val errorMessage = - uiState.error?.asString() + uiState.errorMessage?.asString() LaunchedEffect(errorMessage) { if (errorMessage != null) { snackbarHostState.showSnackbar(errorMessage) @@ -89,6 +91,12 @@ fun IdentityVerificationEmailScreen( onVerifyAndProceed = { viewModel.verifyAndProceed(senderId) }, modifier = modifier, ) + + ReceiverErrorPopupHost( + popup = uiState.errorPopup, + onRetry = viewModel::retryFailedRequest, + onDismiss = viewModel::onErrorPopupDismissed, + ) } @Composable diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreen.kt index 44afaa137d..afee3d8e52 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreen.kt @@ -20,7 +20,8 @@ import com.afternote.feature.receiver.presentation.deliveryverification.componen * * 발신자 상세의 "열람 신청하기" 진입 시 해당 발신자의 본인 확인 캐시 * ([com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository.isVerified]) 가 - * false 인 경우만 노출된다 — 캐시 hit 시 NavGraph 가 마스터 키 단계로 바로 보낸다 (#597 발신자별 격리). + * false 인 경우만 노출된다 — 캐시 hit 시 흐름 호스트가 이 화면을 건너뛰고 마스터 키 단계로 바로 보낸다 + * (#597 발신자별 격리). * 다음 단계의 인증번호 발송·검증은 실 API(`receiver-auth/email` 계열) 호출 (#407). */ @Composable diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationUiState.kt index fce77388dc..086b5f3503 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationUiState.kt @@ -2,6 +2,7 @@ package com.afternote.feature.receiver.presentation.deliveryverification import androidx.compose.runtime.Immutable import com.afternote.core.ui.UiText +import com.afternote.feature.receiver.presentation.error.ReceiverErrorPopup @Immutable data class IdentityVerificationUiState( @@ -18,7 +19,15 @@ data class IdentityVerificationUiState( * * 표시 가능한 서버 message 와 클라이언트 fallback 을 [UiText] 하나로 운반한다. [isVerified] 와 같은 소비형 필드다. */ - val error: UiText? = null, + val errorMessage: UiText? = null, + /** + * 표시 중인 공통 오류 팝업(#446). [errorMessage] 와 배타적이다 — 서버 작업 실패는 팝업이 정본이고, + * 스낵바 자리에는 서버가 준 거절 사유(코드 불일치·미등록 이메일 등)만 남는다. + * + * [errorMessage] 와 달리 표시 즉시 소비하지 않는다 — 모달이라 사용자가 재시도·닫기 중 하나를 고를 + * 때까지 떠 있다. + */ + val errorPopup: ReceiverErrorPopup? = null, val isVerified: Boolean = false, ) { val canSubmit: Boolean diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt index 7b8c3e341c..2e656b05c2 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt @@ -1,5 +1,6 @@ package com.afternote.feature.receiver.presentation.deliveryverification +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter @@ -9,6 +10,7 @@ import com.afternote.feature.afternote.presentation.reporting.shouldReportInRece import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository import com.afternote.feature.receiver.presentation.R +import com.afternote.feature.receiver.presentation.error.toReceiverErrorPopupOrNull import com.afternote.feature.receiver.presentation.error.toReceiverErrorUiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -28,8 +30,8 @@ import javax.inject.Inject * UI 가 마스터 키(5) 단계로 이동. 이메일 인증은 신원 확인까지만 담당하며 마스터 키를 대신 획득하지 * 않는다 — 그랬다면 마스터 키 단계가 무력화된다 (#454). * - * `senderId` 는 [MasterKeyViewModel.submit] 과 같은 규약으로 자체 SavedStateHandle 이 아니라 parent - * backStackEntry 의 [DeliveryVerificationFlowViewModel] 에서 받아 [verifyAndProceed] 호출 시점에 + * `senderId` 는 [MasterKeyViewModel.submit] 과 같은 규약으로 자체 SavedStateHandle 이 아니라 흐름 + * entry 범위의 [DeliveryVerificationFlowViewModel] 에서 화면 인자로 받아 [verifyAndProceed] 호출 시점에 * 전달된다 — 인증 캐시가 발신자별 키에 기록되어 다른 발신자의 관문을 열지 않는다 (#597). * * 메모리 정책상 ViewModel 은 [androidx.compose.foundation.text.input.TextFieldState] 를 보유하지 않는다. @@ -49,24 +51,31 @@ class IdentityVerificationViewModel private val _uiState = MutableStateFlow(IdentityVerificationUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** + * 팝업의 "다시 시도하기" 가 되돌릴 마지막 시도 (#446). 인증번호 발송과 코드 검증 중 **어느 + * 쪽이 실패했는지** 를 담는다 — 하나로 뭉뚱그리면 코드 검증이 서버 오류로 실패했을 때 + * 재시도가 엉뚱하게 인증번호를 다시 보낸다. + */ + private var pendingRetry: (() -> Unit)? = null + fun onEmailChange(value: String) { _uiState.update { it.copy( email = value, isEmailFormatValid = EMAIL_REGEX.matches(value.trim()), - error = null, + errorMessage = null, ) } } fun onCodeChange(value: String) { - _uiState.update { it.copy(code = value, error = null) } + _uiState.update { it.copy(code = value, errorMessage = null) } } fun requestVerificationCode() { val state = _uiState.value if (!state.isEmailFormatValid || state.isSendingCode) return - _uiState.update { it.copy(isSendingCode = true, error = null) } + _uiState.update { it.copy(isSendingCode = true, errorMessage = null) } viewModelScope.launch { receiverAuthRepository .sendEmailAuthCode(state.email.trim()) @@ -84,12 +93,8 @@ class IdentityVerificationViewModel throwable, ) } - _uiState.update { - it.copy( - isSendingCode = false, - error = throwable.toReceiverErrorUiText(R.string.receiver_verify_code_send_failed), - ) - } + _uiState.update { it.copy(isSendingCode = false) } + showFailure(throwable, R.string.receiver_verify_code_send_failed, ::requestVerificationCode) } } } @@ -97,7 +102,7 @@ class IdentityVerificationViewModel fun verifyAndProceed(senderId: String) { val state = _uiState.value if (!state.canSubmit) return - _uiState.update { it.copy(isVerifying = true, error = null) } + _uiState.update { it.copy(isVerifying = true, errorMessage = null) } viewModelScope.launch { receiverAuthRepository .verifyEmailAuthCode(email = state.email.trim(), authCode = state.code.trim()) @@ -111,18 +116,50 @@ class IdentityVerificationViewModel throwable, ) } - _uiState.update { - it.copy( - isVerifying = false, - error = throwable.toReceiverErrorUiText(R.string.receiver_verify_code_verify_failed), - ) + _uiState.update { it.copy(isVerifying = false) } + showFailure(throwable, R.string.receiver_verify_code_verify_failed) { + verifyAndProceed(senderId) } } } } + /** 팝업의 "다시 시도하기" — 팝업을 닫고 실패한 그 요청을 그대로 다시 보낸다 (#446). */ + fun retryFailedRequest() { + val retry = pendingRetry + _uiState.update { it.copy(errorPopup = null) } + pendingRetry = null + retry?.invoke() + } + + /** 팝업의 닫기 — 재시도 없이 입력 화면으로 돌아간다. */ + fun onErrorPopupDismissed() { + _uiState.update { it.copy(errorPopup = null) } + pendingRetry = null + } + + /** + * 실패를 팝업(서버 작업 실패)과 스낵바(서버가 준 거절 사유) 중 한쪽으로만 보낸다 — 둘 다 + * 세우면 모달 뒤에서 스낵바가 혼자 떴다 사라진다. + */ + private fun showFailure( + throwable: Throwable, + @StringRes fallbackRes: Int, + retry: () -> Unit, + ) { + val popup = throwable.toReceiverErrorPopupOrNull() + pendingRetry = if (popup == null) null else retry + _uiState.update { + if (popup == null) { + it.copy(errorMessage = throwable.toReceiverErrorUiText(fallbackRes)) + } else { + it.copy(errorPopup = popup) + } + } + } + fun consumeError() { - _uiState.update { it.copy(error = null) } + _uiState.update { it.copy(errorMessage = null) } } fun onVerifiedConsumed() { diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreen.kt index 123a6aa7dd..8309fb8553 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreen.kt @@ -53,7 +53,7 @@ fun MasterKeyScreen( } val errorMessage = - uiState.error?.asString() + uiState.errorMessage?.asString() LaunchedEffect(errorMessage) { if (errorMessage != null) { snackbarHostState.showSnackbar(errorMessage) diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyUiState.kt index f683764f95..46bf5a3481 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyUiState.kt @@ -13,6 +13,6 @@ data class MasterKeyUiState( * * 표시 가능한 서버 message 와 클라이언트 fallback 을 [UiText] 하나로 운반한다. [isVerified] 와 같은 소비형 필드다. */ - val error: UiText? = null, + val errorMessage: UiText? = null, val isVerified: Boolean = false, ) diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModel.kt index b14127d4fb..aaf7c9351a 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModel.kt @@ -33,8 +33,8 @@ import javax.inject.Inject * 검증 성공 직후 본 ViewModel 인스턴스는 화면 pop 과 함께 사라지므로, 후속 화면은 * SenderRegistry 의 갱신된 SenderEntry 를 참조해 컨텍스트를 잇는다. * - * `senderId` 는 자체 SavedStateHandle 이 아니라 parent backStackEntry 의 - * [DeliveryVerificationFlowViewModel] 에서 받아 [submit] 호출 시점에 전달된다 — 자식 라우트에서 senderId 를 + * `senderId` 는 자체 SavedStateHandle 이 아니라 흐름 entry 범위의 + * [DeliveryVerificationFlowViewModel] 에서 화면 인자로 받아 [submit] 호출 시점에 전달된다 — 자식 라우트에서 senderId 를 * 중복 보유하지 않기 위함(#220). * * 입력 중인 텍스트는 UI 의 `TextFieldState` 가 보유 — submit() 호출 시점에만 값 전달. @@ -60,12 +60,12 @@ class MasterKeyViewModel if (trimmed.isEmpty() || _uiState.value.isSubmitting) return if (!MASTER_KEY_UUID_REGEX.matches(trimmed)) { _uiState.update { - it.copy(error = UiText.Resource(R.string.receiver_verify_master_key_invalid_format)) + it.copy(errorMessage = UiText.Resource(R.string.receiver_verify_master_key_invalid_format)) } return } - _uiState.update { it.copy(isSubmitting = true, error = null) } + _uiState.update { it.copy(isSubmitting = true, errorMessage = null) } viewModelScope.launch { receiverAuthRepository .verifyMasterKey(trimmed) @@ -80,7 +80,7 @@ class MasterKeyViewModel _uiState.update { it.copy( isSubmitting = false, - error = throwable.toReceiverErrorUiText(R.string.receiver_verify_error_unknown), + errorMessage = throwable.toReceiverErrorUiText(R.string.receiver_verify_error_unknown), ) } } @@ -88,7 +88,7 @@ class MasterKeyViewModel } fun consumeError() { - _uiState.update { it.copy(error = null) } + _uiState.update { it.copy(errorMessage = null) } } fun onVerifiedConsumed() { diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/error/ReceiverErrorDisplay.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/error/ReceiverErrorDisplay.kt index cf3321caf8..d3a33852df 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/error/ReceiverErrorDisplay.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/error/ReceiverErrorDisplay.kt @@ -29,6 +29,11 @@ private fun ReceiverFailure.displayUiTextOrNull(): UiText? = is ReceiverFailure.DeliveryConditionNotMet -> { null } + + // 「아직 만들지 않았다」에 전용 문구를 두지 않는다 — 호출처 폴백(내려받기 실패 안내)이 맞다. + is ReceiverFailure.ExportNotSupported -> { + null + } } /** diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/error/ReceiverErrorPopup.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/error/ReceiverErrorPopup.kt new file mode 100644 index 0000000000..f3246a0a0c --- /dev/null +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/error/ReceiverErrorPopup.kt @@ -0,0 +1,74 @@ +package com.afternote.feature.receiver.presentation.error + +import androidx.compose.runtime.Composable +import com.afternote.core.ui.popup.NetworkErrorPopup +import com.afternote.core.ui.popup.ServerErrorPopup +import com.afternote.core.ui.popup.UploadErrorPopup +import com.afternote.feature.receiver.domain.error.ReceiverFailure + +/** + * 수신자 화면이 띄울 공통 오류 팝업의 갈래 (#446). + * + * 화면마다 `showXxxPopup: Boolean` 을 여러 개 두지 않고 단일 nullable 필드로 운반한다 — 불리언을 + * 늘리면 «서버 오류와 업로드 실패가 동시에 true» 같은 표현 불가능한 상태가 타입에 남는다. + * 로그인 화면([com.afternote.feature.onboarding.presentation.login.LoginUiState])은 갈래가 하나뿐이라 + * 불리언으로 족했지만 여기는 셋이다. + */ +enum class ReceiverErrorPopup { + /** 서버에 닿지도 못한 실패 — 안내는 "연결을 확인하라". */ + NETWORK, + + /** 서버가 응답했지만 처리하지 못한 실패 — 안내는 "잠시 후 다시". */ + SERVER, + + /** 파일 업로드 경로의 실패 — 재시도 대상이 화면이 아니라 그 첨부 하나다. */ + UPLOAD, +} + +/** + * 실패를 팝업 갈래로 옮긴다. `null` 은 «팝업으로 안내하지 않는다» 는 뜻이고, 그 자리는 호출처가 + * 기존대로 [toReceiverErrorUiText] 로 문구(스낵바)를 만든다. + * + * 사용자 거절을 팝업에서 제외하는 이유 — 서버가 이미 사람이 읽을 사유를 줬고, 그 사유는 사용자가 + * 입력을 고쳐 스스로 푸는 것이다. "다시 시도하기" 버튼을 주면 같은 입력으로 같은 거절을 반복하게 + * 만든다. 시안 4종에 그 갈래가 없는 것도 같은 이유다. + * + * @param uploadPath 실패가 파일 업로드 경로에서 났는지. 같은 [ReceiverFailure.UnexpectedServerFailure] + * 라도 업로드 중이면 "업로드 실패" 가 사용자에게 더 정확하다. + */ +internal fun Throwable.toReceiverErrorPopupOrNull(uploadPath: Boolean = false): ReceiverErrorPopup? { + val serverSide = if (uploadPath) ReceiverErrorPopup.UPLOAD else ReceiverErrorPopup.SERVER + // 루트로 좁혀 `when` 을 exhaustive 하게 유지한다 — 수신자 실패 유형이 늘면 여기가 컴파일 + // 에러로 잡힌다. `else` 로 뭉개면 팝업으로 갈라야 할 새 유형이 조용히 스낵바로 흘러간다. + return when (this as? ReceiverFailure) { + is ReceiverFailure.NetworkUnavailable -> ReceiverErrorPopup.NETWORK + + is ReceiverFailure.UnexpectedServerFailure -> serverSide + + is ReceiverFailure.UserRejection -> null + + is ReceiverFailure.DeliveryConditionNotMet -> null + + // 미지원 기능은 재시도로 해결되지 않으므로 기존 실패 문구로 안내한다. + is ReceiverFailure.ExportNotSupported -> null + + // Data 계층이 번역하지 못한 실패(로컬 예외 등). 사유를 모르는 채 재시도 이상을 안내할 + // 근거가 없으므로 서버 쪽 갈래로 보낸다. + null -> serverSide + } +} + +/** [ReceiverErrorPopup] 갈래를 시안 4종의 팝업으로 그린다. `null` 이면 아무것도 그리지 않는다. */ +@Composable +internal fun ReceiverErrorPopupHost( + popup: ReceiverErrorPopup?, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + when (popup) { + ReceiverErrorPopup.NETWORK -> NetworkErrorPopup(onRetry = onRetry, onDismiss = onDismiss) + ReceiverErrorPopup.SERVER -> ServerErrorPopup(onRetry = onRetry, onDismiss = onDismiss) + ReceiverErrorPopup.UPLOAD -> UploadErrorPopup(onRetry = onRetry, onDismiss = onDismiss) + null -> Unit + } +} diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModel.kt deleted file mode 100644 index 87437f10d4..0000000000 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModel.kt +++ /dev/null @@ -1,260 +0,0 @@ -package com.afternote.feature.receiver.presentation.home - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.core.ui.icon.AfternoteSourceIcon -import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords -import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository -import com.afternote.feature.receiver.domain.model.AfterNoteListItem -import com.afternote.feature.receiver.domain.repository.ReceiverRepository -import com.afternote.feature.receiver.presentation.R -import com.afternote.feature.receiver.presentation.home.model.MindRecordSummary -import com.afternote.feature.receiver.presentation.home.model.ReceiverDownloadState -import com.afternote.feature.receiver.presentation.home.model.ReceiverHomeUiState -import com.afternote.feature.receiver.presentation.home.model.SenderMessage -import com.afternote.feature.receiver.presentation.reporting.ReceiverFailureStage -import com.afternote.feature.receiver.presentation.reporting.recordReceiverFailure -import com.afternote.feature.timeletter.domain.repository.ReceiverTimeLetterRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -/** - * 수신자 홈 화면 ViewModel. - * - * 한 마디·각 섹션 카운트·애프터노트 아이콘 목록을 한 번에 모아 단일 [ReceiverHomeUiState]로 노출하고, - * 모든 기록 내려받기 다이얼로그/요청 상태도 동일 객체에 합친다. - */ -@HiltViewModel -class ReceiverHomeViewModel - @Inject - constructor( - private val receiverRepository: ReceiverRepository, - private val mindRecordReceiverRepository: MindRecordReceiverRepository, - private val receiverTimeLetterRepository: ReceiverTimeLetterRepository, - private val errorReporter: ErrorReporter, - ) : ViewModel() { - private val _uiState = MutableStateFlow(ReceiverHomeUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() - - /** 진행 중인 홈 로드 — 첫 진입 이후의 ON_RESUME 이 실행 중인 로드와 겹치면 건너뛰기 위한 가드. */ - private var loadJob: Job? = null - - /** - * 다음 [refreshOnReturn] 이 첫 ON_RESUME(진입 자체)인지. 첫 resume 은 init 로드와 같은 - * 진입이므로 갱신하지 않는다 — Job 가드만으로는 init 로드가 빨리 끝난 뒤 도착한 첫 - * resume 이 순차 재조회를 건다. VM 필드인 이유는 - * [com.afternote.feature.receiver.presentation.detail.ReceivedAfternoteDetailViewModel] 과 - * 동일 — 프로세스 사망 후 복원에서도 init 로드와 수명이 일치한다. - */ - private var isFirstResume = true - - init { - loadHome() - } - - /** - * 다른 화면에서 홈으로 복귀했을 때의 자동 갱신 (#701). - * - * [ReceiverHomeEvent.Retry] 와 두 가지가 다르다 — 로딩을 방출하지 않고, 실패해도 보고 있던 - * 화면을 유지한다(부분 실패도 구멍 난 새 화면 대신 완결된 기존 화면을 남긴다). - * 첫 ON_RESUME(진입 자체)은 [isFirstResume] 로 스킵하고, 그 이후의 resume 이 실행 중인 - * 로드와 겹치면 진행 중인 Job 으로 건너뛴다. - */ - fun refreshOnReturn() { - if (isFirstResume) { - isFirstResume = false - return - } - if (loadJob?.isActive == true) return - loadHome(showsLoading = false, keepsStateOnFailure = true) - } - - fun onEvent(event: ReceiverHomeEvent) { - when (event) { - ReceiverHomeEvent.Retry -> loadHome() - ReceiverHomeEvent.RequestDownload -> updateDownload(ReceiverDownloadState.Confirming) - ReceiverHomeEvent.DismissDownload -> updateDownload(ReceiverDownloadState.Idle) - ReceiverHomeEvent.ConfirmDownload -> downloadAll() - ReceiverHomeEvent.ConsumeDownloadResult -> updateDownload(ReceiverDownloadState.Idle) - } - } - - private fun loadHome( - showsLoading: Boolean = true, - keepsStateOnFailure: Boolean = false, - ) { - loadJob?.cancel() - if (showsLoading) { - _uiState.value = ReceiverHomeUiState.Loading - } - loadJob = - viewModelScope.launch { - loadHomeInternal(keepsStateOnFailure) - } - } - - private suspend fun loadHomeInternal(keepsStateOnFailure: Boolean) { - coroutineScope { - val afternotes = async { receiverRepository.getReceivedAfterNotes() } - val mindRecords = async { mindRecordReceiverRepository.getAll() } - val timeLetters = - async { - runCatchingCancellable { - receiverTimeLetterRepository.getReceivedTimeLetters() - } - } - val message = async { receiverRepository.loadSenderMessage() } - val afternotesRes = afternotes.await() - val mindRecordsRes = mindRecords.await() - val timeLettersRes = timeLetters.await() - val messageRes = message.await() - - val failedSources = - buildList { - if (afternotesRes.isFailure) add("afternotes") - if (mindRecordsRes.isFailure) add("mind_records") - if (timeLettersRes.isFailure) add("time_letters") - if (messageRes.isFailure) add("sender_message") - } - val firstFailure = - afternotesRes.exceptionOrNull() - ?: mindRecordsRes.exceptionOrNull() - ?: timeLettersRes.exceptionOrNull() - ?: messageRes.exceptionOrNull() - - if (firstFailure != null) { - // 모든 호출이 실패한 경우만 Error. 일부 실패는 fallback 으로 진행. - if (failedSources.size == HOME_REQUEST_COUNT) { - // 화면을 유지하는 자동 갱신 실패도 기록한다 — 콘솔이 유일한 관측 지점이다. - errorReporter.recordReceiverFailure(ReceiverFailureStage.RECEIVER_HOME_LOAD, firstFailure) - _uiState.update { current -> - if (keepsStateOnFailure && current is ReceiverHomeUiState.Success) { - // 자동 갱신 실패: 잘 보고 있던 홈을 에러 화면으로 대체하지 않는다. - current - } else { - ReceiverHomeUiState.Error(firstFailure) - } - } - return@coroutineScope - } - // 일부 실패는 0·빈 값으로 덮여 화면에도 콘솔에도 흔적이 남지 않던 구간 — 여기서만 기록한다. - errorReporter.recordReceiverFailure( - stage = ReceiverFailureStage.RECEIVER_HOME_PARTIAL_LOAD, - throwable = firstFailure, - failedSources = failedSources, - ) - if (keepsStateOnFailure && _uiState.value is ReceiverHomeUiState.Success) { - // 자동 갱신의 부분 실패: 성공한 소스만 반영하면 실패한 섹션이 null 로 꺼져 - // 잘 보이던 카운트가 이유 없이 사라진다 — 완결된 기존 화면을 그대로 둔다. - return@coroutineScope - } - } - - val afternotesResult = afternotesRes.getOrNull() - val mindRecordsSummary = mindRecordsRes.getOrNull()?.toHomeSummary() - val timeLettersCount = timeLettersRes.getOrNull()?.totalCount - val senderMessageInfo = messageRes.getOrNull() - // senderName / senderMessage 둘 다 blank 가드 — sender 가 이름·메시지 미입력 케이스 대응. - // ".orEmpty()" 만으로는 공백(" ") 통과해 "故 님이 남기신 기록" / "님의 한 마디" UI 깨짐. - val senderName = senderMessageInfo?.senderName?.takeIf { it.isNotBlank() }.orEmpty() - val senderMessageBody = senderMessageInfo?.message?.takeIf { it.isNotBlank() } - - _uiState.update { current -> - ReceiverHomeUiState.Success( - senderName = senderName, - senderMessage = - senderMessageBody?.let { - // orEmpty(): null 이면 "" — createdAt 미제공(구버전 서버) 시 날짜 슬롯만 비워 보이게. - // senderMessageInfo 에 ?. 가 없어도 안전: senderMessageBody(= info?.message?…) 가 - // non-null 인 이 블록에선 안전 호출 체인의 대우로 info 도 non-null 이 보장되고, - // K2 가 이를 smart cast 한다 (Kotlin 2.0+ "Local variables and further scopes"). - // ?. 를 붙이면 "여기서 null 일 수 있다" 는 거짓 신호가 되어 생략. - SenderMessage(date = senderMessageInfo.createdAt.orEmpty(), body = it) - }, - mindRecord = mindRecordsSummary, - timeLetterTotalCount = timeLettersCount, - afternoteTotalCount = afternotesResult?.totalCount, - afternoteIcons = afternotesResult?.items.orEmpty().toAfternoteIcons(), - // 자동 갱신이 화면을 교체해도 진행 중인 내려받기 다이얼로그·진행 상태는 잃지 않는다. - download = (current as? ReceiverHomeUiState.Success)?.download ?: ReceiverDownloadState.Idle, - ) - } - } - } - - private fun downloadAll() { - updateDownload(ReceiverDownloadState.InProgress) - viewModelScope.launch { - receiverRepository - .downloadReceivedExport() - .onSuccess { bundle -> - receiverRepository - .saveReceivedExportToFile(bundle) - .onSuccess { updateDownload(ReceiverDownloadState.Done) } - .onFailure { e -> - errorReporter.recordReceiverFailure(ReceiverFailureStage.RECEIVED_EXPORT_SAVE, e) - updateDownload( - ReceiverDownloadState.Failed(R.string.receiver_home_download_all_save_failed), - ) - } - }.onFailure { e -> - errorReporter.recordReceiverFailure(ReceiverFailureStage.RECEIVED_EXPORT_DOWNLOAD, e) - updateDownload( - ReceiverDownloadState.Failed(R.string.receiver_home_download_all_failed), - ) - } - } - } - - private fun updateDownload(next: ReceiverDownloadState) { - _uiState.update { state -> - if (state is ReceiverHomeUiState.Success) state.copy(download = next) else state - } - } - } - -/** [ReceiverHomeViewModel] 이 홈 한 화면을 그리려고 병렬로 던지는 요청 수 — 전부 실패해야 Error 로 떨어진다. */ -private const val HOME_REQUEST_COUNT = 4 - -private const val MAX_AFTERNOTE_ICONS = 4 - -private fun ReceiverMindRecords.toHomeSummary(): MindRecordSummary = - MindRecordSummary( - dailyQuestionCount = dailyQuestions.size, - diaryCount = diaries.size, - ) - -private fun List.toAfternoteIcons(): List = - asSequence() - // `type` 은 non-null 이라 `mapNotNull` 이 걸러 낼 것이 없다 (#1332 · #1452). - // 남겨 두면 「종류를 모르는 항목이 섞여 들어온다」는 없는 사정을 읽게 한다. - .map { it.type } - .distinct() - .take(MAX_AFTERNOTE_ICONS) - .map { it.toSourceIcon() } - .toList() - -/** - * 애프터노트 종류를 홈이 그릴 아이콘으로 옮긴다 (#926). - * - * res id 를 상태에 담지 않는다 — 담으면 ViewModel 이 Android 리소스에 묶이고, 형제 - * feature 의 `R` 을 가로질러 참조하게 된다. 어떤 그림인지는 core:ui 가 안다. - */ -private fun AfternoteType.toSourceIcon(): AfternoteSourceIcon = - when (this) { - AfternoteType.SOCIAL_NETWORK -> AfternoteSourceIcon.SocialNetwork - AfternoteType.GALLERY_AND_FILES -> AfternoteSourceIcon.GalleryAndFiles - AfternoteType.MEMORIAL -> AfternoteSourceIcon.Memorial - AfternoteType.BUSINESS, AfternoteType.ESTATE -> AfternoteSourceIcon.Other - } diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/model/ReceiverHomeUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/model/ReceiverHomeUiState.kt deleted file mode 100644 index 003442b4bf..0000000000 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/home/model/ReceiverHomeUiState.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.afternote.feature.receiver.presentation.home.model - -import androidx.annotation.StringRes -import androidx.compose.runtime.Immutable -import com.afternote.core.ui.icon.AfternoteSourceIcon - -/** - * 수신자 홈 화면 UI 상태. - * - * 한 화면당 단일 UI State 객체로, 로드 상태 + 모든 기록 내려받기 다이얼로그 상태를 함께 보유한다. - */ -sealed interface ReceiverHomeUiState { - data object Loading : ReceiverHomeUiState - - @Immutable - data class Success( - val senderName: String, - val senderMessage: SenderMessage?, - /** null은 조회 실패, 0은 정상 조회된 빈 목록이다. */ - val mindRecord: MindRecordSummary?, - /** null은 조회 실패, 0은 정상 조회된 빈 목록이다. */ - val timeLetterTotalCount: Int?, - /** null은 조회 실패, 0은 정상 조회된 빈 목록이다. */ - val afternoteTotalCount: Int?, - val afternoteIcons: List, - val download: ReceiverDownloadState = ReceiverDownloadState.Idle, - ) : ReceiverHomeUiState - - data class Error( - val throwable: Throwable, - ) : ReceiverHomeUiState -} - -@Immutable -data class SenderMessage( - val date: String, - val body: String, -) - -@Immutable -data class MindRecordSummary( - val dailyQuestionCount: Int, - val diaryCount: Int, -) { - val totalCount: Int - get() = dailyQuestionCount + diaryCount -} - -@Immutable -sealed interface ReceiverDownloadState { - data object Idle : ReceiverDownloadState - - data object Confirming : ReceiverDownloadState - - data object InProgress : ReceiverDownloadState - - data object Done : ReceiverDownloadState - - data class Failed( - @param:StringRes val messageRes: Int, - ) : ReceiverDownloadState -} diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/DeliveryVerificationFlowHost.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/DeliveryVerificationFlowHost.kt new file mode 100644 index 0000000000..05cf58ee04 --- /dev/null +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/DeliveryVerificationFlowHost.kt @@ -0,0 +1,124 @@ +package com.afternote.feature.receiver.presentation.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import com.afternote.core.ui.navigation.FeatureNavDisplay +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.feature.receiver.presentation.deliveryverification.DeliveryVerificationCompleteScreen +import com.afternote.feature.receiver.presentation.deliveryverification.DeliveryVerificationFlowViewModel +import com.afternote.feature.receiver.presentation.deliveryverification.DocumentUploadScreen +import com.afternote.feature.receiver.presentation.deliveryverification.IdentityVerificationEmailScreen +import com.afternote.feature.receiver.presentation.deliveryverification.IdentityVerificationIntroScreen +import com.afternote.feature.receiver.presentation.deliveryverification.MasterKeyScreen +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute + +/** + * 열람 신청 흐름(본인 확인 → 마스터 키 → 서류 업로드 → 완료)의 **흐름 전용 로컬 스택**. + * + * Nav2 에서는 `navigation` 중첩 그래프가 5개 화면의 공용 + * `ViewModelStore` 를 갖고 있었다. Nav3 엔 중첩 그래프가 없으므로, 흐름 키를 바깥 스택의 한 + * entry 로 두고 **그 entry 안에서 다시 스택을 연다** — 흐름 ViewModel 은 이 entry 범위라 + * 단계 사이에는 공유되고 흐름을 벗어나면(= entry 가 pop 되면) 정리된다. 이관 전과 같은 수명이다. + * + * 단계 사이 «pop 하고 다음으로» 는 Nav2 의 `popUpTo(inclusive = true)` 자리로, 로컬 스택에선 + * 결과 스택을 직접 만든다 — 뒤로가기로 이미 지나온 인증·마스터 키 화면에 돌아가지 못하게 하는 + * 기존 동작을 그대로 지킨다. + * + * @param key 흐름 진입 키. `senderId` 를 나르며 흐름 ViewModel 에 assisted 로 주입된다. + * @param onExitFlow 흐름 스택 바닥에서의 back — 바깥 스택이 이 흐름 entry 를 내린다. + * @param onExitToReceivedRecords 완료 화면의 "받은 기록함으로" — 바깥 스택을 받은 기록함까지 되감는다. + */ +@Composable +internal fun DeliveryVerificationFlowHost( + key: ReceiverRoute.DeliveryVerificationFlowRoute, + onExitFlow: () -> Unit, + onExitToReceivedRecords: () -> Unit, +) { + val flowViewModel = + hiltViewModel( + creationCallback = { factory -> factory.create(key) }, + ) + val stepStack = rememberNavBackStack(ReceiverRoute.IdentityVerificationIntroRoute) + val navigationCallbacks = remember(onExitFlow) { FeatureNavigationCallbacks(onExitFlow) } + val actions = + remember(stepStack, navigationCallbacks, onExitToReceivedRecords) { + DeliveryVerificationFlowLocalNavActions(stepStack, navigationCallbacks, onExitToReceivedRecords) + } + + FeatureNavDisplay( + backStack = stepStack, + boundary = navigationCallbacks, + entryProvider = + entryProvider { + entry { + val isVerified by flowViewModel.isIdentityVerified.collectAsStateWithLifecycle() + // 이미 본인 확인을 마친 사용자는 안내 화면을 건너뛴다. + if (isVerified) { + LaunchedEffect(Unit) { + actions.proceedToMasterKey() + } + } else { + IdentityVerificationIntroScreen( + onBackClick = actions::popBack, + onStartClick = actions::navigateToIdentityVerificationEmail, + ) + } + } + + entry { + IdentityVerificationEmailScreen( + senderId = flowViewModel.senderId, + onBackClick = actions::popBack, + onVerified = actions::proceedToMasterKey, + ) + } + + entry { + MasterKeyScreen( + senderId = flowViewModel.senderId, + onBackClick = actions::popBack, + onVerified = actions::proceedToDocumentUpload, + ) + } + + entry { + DocumentUploadScreen( + onBackClick = actions::popBack, + onSubmitted = actions::proceedToDeliveryVerificationComplete, + ) + } + + entry { + DeliveryVerificationCompleteScreen( + onBackToRecords = actions::popToReceivedRecords, + ) + } + }, + ) +} + +/** 열람 신청 흐름 안에서만 의미가 있는 이동. 바깥 스택을 건드리는 둘은 콜백으로 위임한다. */ +internal interface DeliveryVerificationFlowNavActions { + fun popBack() + + /** 본인 확인 안내(2)의 "인증 시작하기" → 이메일 인증 화면(3·4). */ + fun navigateToIdentityVerificationEmail() + + /** Intro 의 캐시 hit jump 또는 이메일 인증 성공 → 마스터 키(5). 본인 확인 화면들은 남기지 않는다. */ + fun proceedToMasterKey() + + /** 마스터 키 검증 성공 → 증빙 서류 업로드(6·7·8). 마스터 키 화면은 남기지 않는다. */ + fun proceedToDocumentUpload() + + /** 서류 제출 성공 → 완료(9). 업로드 화면은 남기지 않는다. */ + fun proceedToDeliveryVerificationComplete() + + /** 완료(9)의 "받은 기록함으로 돌아가기". */ + fun popToReceivedRecords() +} diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverLocalNavActions.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverLocalNavActions.kt new file mode 100644 index 0000000000..bc7576e3b8 --- /dev/null +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverLocalNavActions.kt @@ -0,0 +1,66 @@ +package com.afternote.feature.receiver.presentation.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.core.ui.navigation.popOrExit +import com.afternote.core.ui.navigation.popUpTo +import com.afternote.core.ui.navigation.replaceAllWith +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute + +/** + * 수신자 화면 콜백을 로컬 백스택 조작으로 잇는다. + * + * 컴포저블이 아니라 평범한 클래스다 — 백스택 «모양» 을 컴포지션 없이 재려는 것이다(#1601). + */ +internal class ReceiverLocalNavActions( + private val backStack: NavBackStack, + private val navigationCallbacks: FeatureNavigationCallbacks, +) : ReceiverNavActions { + override fun popBack(): Unit = backStack.popOrExit(navigationCallbacks) + + override fun navigateToSenderRegistration() { + backStack.add(ReceiverRoute.SenderRegistrationRoute) + } + + override fun navigateToSenderDetail(senderId: String) { + backStack.add(ReceiverRoute.SenderDetailRoute(senderId = senderId)) + } + + override fun navigateToDeliveryVerificationFlow(senderId: String) { + backStack.add(ReceiverRoute.DeliveryVerificationFlowRoute(senderId = senderId)) + } + + override fun navigateToReceiverHome() { + backStack.add(ReceiverRoute.HomeRoute) + } + + /** 받은 기록함을 남기고 그 위(발신자 상세·열람 신청 흐름)를 모두 걷어낸다. */ + override fun popToReceivedRecords(): Unit = backStack.popUpTo(ReceiverRoute.ReceivedRecordsRoute) +} + +/** + * 열람 신청 흐름 안에서만 의미가 있는 이동. 바깥 스택을 건드리는 둘은 콜백으로 위임한다. + * + * 단계마다 «지나온 화면을 남기지 않는» 것이 이 흐름의 규칙이다 — 인증번호·마스터 키는 서버가 + * 이미 소비했으므로 뒤로가기로 되돌아가면 안 된다. + */ +internal class DeliveryVerificationFlowLocalNavActions( + private val stepStack: NavBackStack, + private val navigationCallbacks: FeatureNavigationCallbacks, + private val onExitToReceivedRecords: () -> Unit, +) : DeliveryVerificationFlowNavActions { + override fun popBack(): Unit = stepStack.popOrExit(navigationCallbacks) + + override fun navigateToIdentityVerificationEmail() { + stepStack.add(ReceiverRoute.IdentityVerificationEmailRoute) + } + + override fun proceedToMasterKey(): Unit = stepStack.replaceAllWith(ReceiverRoute.MasterKeyRoute) + + override fun proceedToDocumentUpload(): Unit = stepStack.replaceAllWith(ReceiverRoute.DocumentUploadRoute) + + override fun proceedToDeliveryVerificationComplete(): Unit = stepStack.replaceAllWith(ReceiverRoute.DeliveryVerificationCompleteRoute) + + override fun popToReceivedRecords(): Unit = onExitToReceivedRecords() +} diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavActions.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavActions.kt index 4270194339..18d7cbeb3d 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavActions.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavActions.kt @@ -1,11 +1,14 @@ package com.afternote.feature.receiver.presentation.navigation /** - * NavHost 루트에서 수신자 서브그래프로 넘기는 그래프 내부 네비게이션 명령. + * 수신자 로컬 스택 안에서만 의미가 있는 이동 명령. * - * 다른 top-level Route(MindRecord/TimeLetter/Setting 등)로의 이동은 수신자 홈 측에서 - * 별도 `ReceiverHomeActions`로 받는다 — 본 인터페이스는 [com.afternote.core.ui.Route.Receiver] - * 그래프 내부에서만 의미가 있는 이동만 다룬다. + * 구현은 스택을 가진 [ReceiverNavHost] 안에 있다 — 앱 셸은 더 이상 이 명령을 만들지 않는다. + * 열람 신청 흐름 5단계 사이의 이동은 흐름 전용 스택을 가진 `DeliveryVerificationFlowNavActions` + * 가 갖는다 (#1698). + * + * 다른 top-level Route(마음의 기록/타임레터/수신 애프터노트)로의 이동은 수신자 홈 측에서 + * 별도 `ReceiverHomeActions`로 받는다. * * 작명 컨벤션 (#239): `navigateTo` / `popBack` / `popTo` / * `replaceWith` / `proceedTo`. @@ -20,29 +23,14 @@ interface ReceiverNavActions { fun navigateToSenderDetail(senderId: String) /** - * 발신자 상세의 "열람 신청하기" → nested 열람 신청 흐름 그래프 + * 발신자 상세의 "열람 신청하기" → 열람 신청 흐름 entry * ([com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute.DeliveryVerificationFlowRoute]) - * 진입. 본인 확인 캐시 분기는 흐름 내부(IntroRoute 의 LaunchedEffect) 에서 자동 처리되므로 호출자는 - * senderId 만 전달. + * 진입. 단계 사이 이동은 그 entry 안의 흐름 전용 스택이 맡는다. 본인 확인 캐시 분기는 흐름 내부 + * (IntroRoute 의 LaunchedEffect) 에서 자동 처리되므로 호출자는 senderId 만 전달. */ fun navigateToDeliveryVerificationFlow(senderId: String) - /** 본인 확인 안내(2) 의 "인증 시작하기" → 이메일 인증 화면(3·4) 진입. */ - fun navigateToIdentityVerificationEmail() - - /** - * 본인 확인 흐름 (Intro 의 캐시 hit jump 또는 Email 인증 성공) → 마스터 키(5) 진입. - * Intro 까지의 본인 확인 화면들은 pop (사용자가 뒤로가기로 안내·인증을 다시 보지 않도록). - */ - fun proceedToMasterKey() - - /** 마스터 키 검증 성공 → 증빙 서류 업로드(6·7·8) 진입. 마스터 키 화면은 pop 한다. */ - fun proceedToDocumentUpload() - - /** 서류 업로드 + `submitDeliveryVerification` 성공 → 완료(9) 진입. 서류 화면은 pop 한다. */ - fun proceedToDeliveryVerificationComplete() - - /** 완료(9)의 "받은 기록함으로 돌아가기" → 받은 기록함까지 pop. */ + /** 열람 신청 완료(9)의 "받은 기록함으로 돌아가기" → 받은 기록함 위를 모두 걷어낸다. */ fun popToReceivedRecords() /** diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavGraph.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavGraph.kt deleted file mode 100644 index ffce8ab707..0000000000 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavGraph.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.afternote.feature.receiver.presentation.navigation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavGraphBuilder -import androidx.navigation.navigation -import androidx.navigation.toRoute -import com.afternote.core.ui.Route -import com.afternote.feature.receiver.presentation.deliveryverification.DeliveryVerificationCompleteScreen -import com.afternote.feature.receiver.presentation.deliveryverification.DeliveryVerificationFlowViewModel -import com.afternote.feature.receiver.presentation.deliveryverification.DocumentUploadScreen -import com.afternote.feature.receiver.presentation.deliveryverification.IdentityVerificationEmailScreen -import com.afternote.feature.receiver.presentation.deliveryverification.IdentityVerificationIntroScreen -import com.afternote.feature.receiver.presentation.deliveryverification.MasterKeyScreen -import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute -import com.afternote.feature.receiver.presentation.recordsbox.ReceivedRecordsScreen -import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistrationScreen -import com.afternote.feature.receiver.presentation.senderdetail.SenderDetailScreen - -/** - * 받은 기록함부터 수신 콘텐츠 상세까지의 수신자 화면을 연결한다. - * 열람 신청 단계는 중첩 그래프의 [DeliveryVerificationFlowViewModel]을 공유한다. - * - * @param homeContent 앱 모듈에서 제공하는 수신자 홈 화면 - * @param actions 수신자 그래프의 화면 이동 명령 - * @param deliveryFlowParentEntry 열람 신청 중첩 그래프의 back stack entry 제공자 - */ -fun NavGraphBuilder.receiverNavGraph( - homeContent: @Composable () -> Unit, - actions: ReceiverNavActions, - deliveryFlowParentEntry: () -> NavBackStackEntry, -) { - navigation(startDestination = ReceiverRoute.ReceivedRecordsRoute) { - receiverComposable { - ReceivedRecordsScreen( - onBackClick = actions::popBack, - onAddSenderClick = actions::navigateToSenderRegistration, - onSenderClick = { sender -> actions.navigateToSenderDetail(sender.id) }, - ) - } - - receiverComposable { - SenderRegistrationScreen( - onBackClick = actions::popBack, - onRegistered = actions::popBack, - ) - } - - receiverComposable { backStackEntry -> - val senderId = backStackEntry.toRoute().senderId - SenderDetailScreen( - onBackClick = actions::popBack, - onRequestVerification = { actions.navigateToDeliveryVerificationFlow(senderId) }, - onOpenReceiverHome = actions::navigateToReceiverHome, - ) - } - - // 열람 신청 단계가 발신자와 본인 확인 상태를 공유하는 중첩 그래프 - navigation( - startDestination = ReceiverRoute.IdentityVerificationIntroRoute, - ) { - receiverComposable { backStackEntry -> - val flowVm = backStackEntry.deliveryFlowViewModel(deliveryFlowParentEntry) - val isVerified by flowVm.isIdentityVerified.collectAsStateWithLifecycle() - // 이미 본인 확인을 마친 사용자는 안내 화면을 건너뛴다. - if (isVerified) { - LaunchedEffect(Unit) { - actions.proceedToMasterKey() - } - } else { - IdentityVerificationIntroScreen( - onBackClick = actions::popBack, - onStartClick = actions::navigateToIdentityVerificationEmail, - ) - } - } - - receiverComposable { backStackEntry -> - val flowVm = backStackEntry.deliveryFlowViewModel(deliveryFlowParentEntry) - IdentityVerificationEmailScreen( - senderId = flowVm.senderId, - onBackClick = actions::popBack, - onVerified = actions::proceedToMasterKey, - ) - } - - receiverComposable { backStackEntry -> - val flowVm = backStackEntry.deliveryFlowViewModel(deliveryFlowParentEntry) - MasterKeyScreen( - senderId = flowVm.senderId, - onBackClick = actions::popBack, - onVerified = actions::proceedToDocumentUpload, - ) - } - - receiverComposable { - DocumentUploadScreen( - onBackClick = actions::popBack, - onSubmitted = actions::proceedToDeliveryVerificationComplete, - ) - } - - receiverComposable { - DeliveryVerificationCompleteScreen( - onBackToRecords = actions::popToReceivedRecords, - ) - } - } - - receiverComposable { - homeContent() - } - } -} - -/** 열람 신청 단계가 동일한 그래프 범위의 ViewModel을 사용하도록 한다. */ -@Composable -private fun NavBackStackEntry.deliveryFlowViewModel(deliveryFlowParentEntry: () -> NavBackStackEntry): DeliveryVerificationFlowViewModel { - val parentEntry = remember(this) { deliveryFlowParentEntry() } - return hiltViewModel(parentEntry) -} diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavGraphTheme.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavGraphTheme.kt index cb610a07a4..4c8023d354 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavGraphTheme.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavGraphTheme.kt @@ -1,19 +1,16 @@ package com.afternote.feature.receiver.presentation.navigation import androidx.compose.runtime.Composable -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable import com.afternote.core.ui.theme.AfternoteTheme /** - * 수신자 그래프용 [composable] 래퍼. 작성자 측 `afternoteComposable` 와 동일하게 - * [AfternoteTheme] 라이트 모드를 강제 적용해 다크 모드 변형을 차단한다. + * 수신자 화면용 테마 래퍼. 작성자 측 `AfternoteLightTheme` 와 동일하게 [AfternoteTheme] + * 라이트 모드를 강제 적용해 다크 모드 변형을 차단한다. + * + * Nav2 시절엔 `receiverComposable` 이 `composable` 등록과 테마를 함께 감쌌지만, Nav3 의 + * `entry` 는 reified 확장으로 이미 제공되므로 테마만 남긴다. */ -internal inline fun NavGraphBuilder.receiverComposable(noinline content: @Composable (NavBackStackEntry) -> Unit) { - composable { backStackEntry -> - AfternoteTheme { - content(backStackEntry) - } - } +@Composable +internal fun ReceiverTheme(content: @Composable () -> Unit) { + AfternoteTheme(content = content) } diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavHost.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavHost.kt new file mode 100644 index 0000000000..09d9d2ad61 --- /dev/null +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverNavHost.kt @@ -0,0 +1,92 @@ +package com.afternote.feature.receiver.presentation.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import com.afternote.core.ui.navigation.FeatureNavDisplay +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute +import com.afternote.feature.receiver.presentation.recordsbox.ReceivedRecordsScreen +import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistrationScreen +import com.afternote.feature.receiver.presentation.senderdetail.SenderDetailScreen +import com.afternote.feature.receiver.presentation.senderdetail.SenderDetailViewModel + +/** + * 수신자 피처가 소유하는 로컬 Navigation 3 스택 — 받은 기록함부터 열람 신청 흐름까지. + * + * 열람 신청 5단계는 이 스택에 직접 쌓지 않고 [DeliveryVerificationFlowHost] 가 여는 흐름 전용 + * 스택으로 내린다. 흐름 ViewModel 의 수명(5화면 공유 → 흐름 이탈 시 정리)을 그 entry 범위로 + * 보존하기 위해서다. + * + * @param homeContent 앱 셸이 주는 수신자 홈 — 다른 피처의 수신 화면을 조합하므로 셸이 만든다. + * @param navigationCallbacks 스택 바닥에서의 back 경계. + */ +@Composable +public fun ReceiverNavHost( + homeContent: @Composable () -> Unit, + navigationCallbacks: FeatureNavigationCallbacks, + modifier: Modifier = Modifier, +) { + val backStack = rememberNavBackStack(ReceiverRoute.ReceivedRecordsRoute) + val actions = remember(backStack, navigationCallbacks) { ReceiverLocalNavActions(backStack, navigationCallbacks) } + + FeatureNavDisplay( + backStack = backStack, + boundary = navigationCallbacks, + modifier = modifier, + entryProvider = + entryProvider { + entry { + ReceiverTheme { + ReceivedRecordsScreen( + onBackClick = actions::popBack, + onAddSenderClick = actions::navigateToSenderRegistration, + onSenderClick = { sender -> actions.navigateToSenderDetail(sender.id) }, + ) + } + } + + entry { + ReceiverTheme { + SenderRegistrationScreen( + onBackClick = actions::popBack, + onRegistered = actions::popBack, + ) + } + } + + entry { key -> + ReceiverTheme { + SenderDetailScreen( + onBackClick = actions::popBack, + onRequestVerification = { actions.navigateToDeliveryVerificationFlow(key.senderId) }, + onOpenReceiverHome = actions::navigateToReceiverHome, + viewModel = + hiltViewModel( + creationCallback = { factory -> factory.create(key) }, + ), + ) + } + } + + entry { key -> + ReceiverTheme { + DeliveryVerificationFlowHost( + key = key, + onExitFlow = actions::popBack, + onExitToReceivedRecords = actions::popToReceivedRecords, + ) + } + } + + entry { + ReceiverTheme { + homeContent() + } + } + }, + ) +} diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/model/ReceiverRoute.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/model/ReceiverRoute.kt index 9cebead8d1..47b1e6e053 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/model/ReceiverRoute.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/model/ReceiverRoute.kt @@ -1,14 +1,18 @@ package com.afternote.feature.receiver.presentation.navigation.model +import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable /** - * 수신자 흐름 [com.afternote.core.ui.Route.Receiver] 그래프 내부 라우트. + * 수신자 흐름 [com.afternote.core.ui.Route.Receiver] 가 그리는 로컬 스택의 라우트. * - * 온보딩 Welcome 의 "전달 받은 기록 확인하기" 콜백이 [com.afternote.core.ui.Route.Receiver] - * (= startDestination [ReceivedRecordsRoute]) 로 진입한다. + * 온보딩 Welcome 의 "전달 받은 기록 확인하기" 콜백이 [com.afternote.core.ui.Route.Receiver] 로 + * 진입하고, 로컬 스택은 [ReceivedRecordsRoute] 에서 시작한다. + * + * [NavKey] 는 로컬 Navigation 3 스택에 실릴 수 있다는 표식이다 — `@Serializable` 과 함께 + * 있어야 프로세스 재생성 뒤 스택이 복원된다 (#1698). */ -sealed interface ReceiverRoute { +sealed interface ReceiverRoute : NavKey { /** 수신자 대시보드 — 발신자 한 마디 + 마음의 기록·타임레터·애프터노트 섹션 카드. */ @Serializable data object HomeRoute : ReceiverRoute @@ -35,7 +39,8 @@ sealed interface ReceiverRoute { * 신청 기록 없음(열람 불가) / PENDING(승인 대기) / APPROVED(열람 가능). * * `senderId` 는 [com.afternote.feature.receiver.presentation.recordsbox.SenderRegistry] - * 의 로컬 식별자 (typed-safe routes 규약: SavedStateHandle 키 `senderId` 와 일치). + * 의 로컬 식별자. 상세 ViewModel 에는 entry 가 이 키를 assisted 로 넘긴다. Nav3 entry 의 + * SavedStateHandle 에는 NavKey 필드가 실리지 않으므로 `toRoute` 로 읽을 수 없다 (#2168). */ @Serializable data class SenderDetailRoute( @@ -43,11 +48,13 @@ sealed interface ReceiverRoute { ) : ReceiverRoute /** - * 열람 신청 흐름 — 본인 확인(2·3·4) + 마스터 키(5) + 서류 업로드(6·7·8) + 완료(9) 의 *nested graph 진입점*. + * 열람 신청 흐름 — 본인 확인(2·3·4) + 마스터 키(5) + 서류 업로드(6·7·8) + 완료(9) 의 진입 키. + * 바깥 스택의 entry 하나로 올라가고, 그 안에서 + * [com.afternote.feature.receiver.presentation.navigation.DeliveryVerificationFlowHost] 가 단계 전용 스택을 연다. * - * 흐름 전체 동안 유지되는 `senderId` 는 본 라우트에만 보유 — 자식 라우트들은 parent backStackEntry 의 - * [com.afternote.feature.receiver.presentation.deliveryverification.DeliveryVerificationFlowViewModel] - * 에서 receive. 자식 라우트에서 senderId 를 nav arg 로 중복 박지 않는다. + * 흐름 전체 동안 유지되는 `senderId` 는 본 라우트에만 보유 — 흐름 VM + * ([com.afternote.feature.receiver.presentation.deliveryverification.DeliveryVerificationFlowViewModel]) + * 에 assisted 로 주입되고, 단계 화면은 호스트가 그 VM 에서 꺼내 넘긴다. 단계 라우트에 senderId 를 중복으로 싣지 않는다. * * 발신자 상세 "열람 신청하기" 진입점. */ @@ -89,7 +96,7 @@ sealed interface ReceiverRoute { /** * 열람 신청 3단계: 완료(9). `submitDeliveryVerification` 결과 표시. - * "받은 기록함으로 돌아가기" 버튼이 발신자 상세까지 pop 한다. + * "받은 기록함으로 돌아가기" 버튼이 받은 기록함까지 pop 한다(발신자 상세와 흐름 entry 도 걷힌다). */ @Serializable data object DeliveryVerificationCompleteRoute : ReceiverRoute diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/reporting/ReceiverFailureReporting.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/reporting/ReceiverFailureReporting.kt index ec3845af70..a55fd23a78 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/reporting/ReceiverFailureReporting.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/reporting/ReceiverFailureReporting.kt @@ -1,6 +1,7 @@ package com.afternote.feature.receiver.presentation.reporting import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.feature.receiver.domain.error.ReceiverFailure /** * 수신자 흐름에서 실패가 발생한 지점. @@ -38,6 +39,9 @@ enum class ReceiverFailureStage( /** * 수신자 흐름의 handled 실패를 공통 키 규격으로 기록한다. * + * [ReceiverFailure.ExportNotSupported]는 장애가 아닌 기능 상태이므로 기록하지 않는다. + * 화면 안내와 무관한 보고 정책은 이 창구에서 적용한다. + * * @param failedSources 한 화면이 여러 요청을 모아 그리는 탓에 일부만 깨진 경우, 어떤 항목이 * 비었는지 남긴다. 요청별로 따로 기록하면 한 번의 네트워크 단절이 보관 한도(최근 8건) 를 * 혼자 채워 버리므로, 한 건에 목록으로 묶는다. 비어 있으면 이 키는 붙지 않는다. @@ -47,6 +51,7 @@ fun ErrorReporter.recordReceiverFailure( throwable: Throwable, failedSources: List = emptyList(), ) { + if (throwable is ReceiverFailure.ExportNotSupported) return recordFailure( throwable = throwable, attributes = diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreen.kt index c2031d4b13..6ffd0bbc6a 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreen.kt @@ -1,6 +1,5 @@ package com.afternote.feature.receiver.presentation.senderdetail -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -11,8 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -23,15 +20,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.ProfileImage import com.afternote.core.ui.button.AfternoteButton import com.afternote.core.ui.button.AfternoteButtonType import com.afternote.core.ui.theme.AfternoteDesign @@ -56,8 +52,11 @@ fun SenderDetailScreen( onBackClick: () -> Unit, onRequestVerification: () -> Unit, onOpenReceiverHome: () -> Unit, + // 기본값을 두지 않는다. @AssistedInject VM 이라 인자 없는 hiltViewModel() 은 런타임에 + // "no creation callback was provided in CreationExtras" 로 죽는다. ReceiverNavHost 의 entry 가 + // 자기 key 로 만들어 넘긴다 (#2168). + viewModel: SenderDetailViewModel, modifier: Modifier = Modifier, - viewModel: SenderDetailViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val shouldOpenReceiverHome = (uiState as? SenderDetailUiState.Success)?.shouldOpenReceiverHome == true @@ -166,14 +165,7 @@ private fun SuccessBody( ) { Spacer(modifier = Modifier.height(39.dp)) - Image( - painter = painterResource(com.afternote.core.ui.R.drawable.core_ui_ic_profile_placeholder), - contentDescription = null, - modifier = - Modifier - .size(134.dp) - .clip(CircleShape), - ) + ProfileImage() Spacer(modifier = Modifier.height(12.5.dp)) diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModel.kt index 8481e90073..f95a17c522 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModel.kt @@ -1,9 +1,7 @@ package com.afternote.feature.receiver.presentation.senderdetail -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.navigation.toRoute import com.afternote.core.common.reporting.ErrorReporter import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFailure @@ -15,6 +13,9 @@ import com.afternote.feature.receiver.domain.repository.ReceiverRepository import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute import com.afternote.feature.receiver.presentation.recordsbox.SenderEntry import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistry +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -22,7 +23,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject /** * 발신자 상세(designs 11·12) ViewModel. @@ -32,19 +32,24 @@ import javax.inject.Inject * [ReceiverAuthRepository.getDeliveryVerificationStatus] 로 상태를 받아 정보 박스 데이터를 만든다. * * masterKey 가 없으면(마스터 키 미입력) 무조건 [SenderVerificationState.NotRequested] — API 호출 자체 생략. + * + * `senderId` 는 route 인자로 받는다. 이 화면은 Nav3 entry 안에 있고, entry 의 SavedStateHandle 에는 + * NavKey 필드가 실리지 않는다. `savedStateHandle.toRoute()` 로 읽으면 카드를 누르는 순간 + * `MissingFieldException` 으로 죽는다 (#2168). 그래서 + * [com.afternote.feature.receiver.presentation.deliveryverification.DeliveryVerificationFlowViewModel] 과 + * 같이 assisted 주입으로 키를 직접 받는다. */ -@HiltViewModel +@HiltViewModel(assistedFactory = SenderDetailViewModel.Factory::class) class SenderDetailViewModel - @Inject + @AssistedInject constructor( - savedStateHandle: SavedStateHandle, + @Assisted route: ReceiverRoute.SenderDetailRoute, private val senderRegistry: SenderRegistry, private val receiverRepository: ReceiverRepository, private val receiverAuthRepository: ReceiverAuthRepository, private val errorReporter: ErrorReporter, ) : ViewModel() { - private val senderId: String = - savedStateHandle.toRoute().senderId + private val senderId: String = route.senderId private val _uiState = MutableStateFlow(SenderDetailUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() @@ -56,7 +61,7 @@ class SenderDetailViewModel * 다음 [refreshOnReturn] 이 첫 ON_RESUME(진입 자체)인지. 첫 resume 은 init 로드와 같은 * 진입이므로 갱신하지 않는다 — Job 가드만으로는 init 로드가 빨리 끝난 뒤 도착한 첫 * resume 이 순차 재조회를 건다. VM 필드인 이유는 - * [com.afternote.feature.receiver.presentation.detail.ReceivedAfternoteDetailViewModel] 과 + * [com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel] 과 * 동일 — 프로세스 사망 후 복원에서도 init 로드와 수명이 일치한다. */ private var isFirstResume = true @@ -213,6 +218,11 @@ class SenderDetailViewModel }, ) } + + @AssistedFactory + interface Factory { + fun create(route: ReceiverRoute.SenderDetailRoute): SenderDetailViewModel + } } private fun DeliveryVerification.toSuccessState( diff --git a/feature/receiver/presentation/src/main/res/values/strings.xml b/feature/receiver/presentation/src/main/res/values/strings.xml index dcf7352c8a..d8eb2cb24e 100644 --- a/feature/receiver/presentation/src/main/res/values/strings.xml +++ b/feature/receiver/presentation/src/main/res/values/strings.xml @@ -1,32 +1,9 @@ - 故 %1$s님이 남기신 기록 - %1$s님의 한 마디 - TOTAL - 데일리 질문 - 일기 - +%1$d - 마음의 기록 - 고인의 일상적인 생각과 감정, 일기들입니다. - 마음의 기록 확인하러 가기 - 타임레터 - 고인이 특별한 날에\n수신자님에게 작성한 편지입니다. - 라이프 이벤트 레터 확인하러 가기 - 애프터노트 - 고인이 사후 정리하고자 하는 데이터입니다. - 애프터노트 확인하러 가기 - – - 모든 기록 내려받기 - 모든 기록을 내려받으시겠습니까? - 모든 기록 내려받기에 실패했습니다. - 파일 저장에 실패했습니다. - - 정보를 불러오지 못했습니다. - 다시 시도 - "%1$s개 " - 마음의 기록이 있습니다. - "%1$s개 " - 라이프 이벤트 레터가 있습니다. - %1$s개 - 의 애프터노트가 있습니다. diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenCompactScreenshot_c2abd7e6_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenCompactScreenshot_c2abd7e6_0.png index 3047830f5f..60e52f1b1e 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenCompactScreenshot_c2abd7e6_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenLargeFontScreenshot_39fcf3fa_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenLargeFontScreenshot_39fcf3fa_0.png index 8a59c0689d..784c2c3b5b 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenLargeFontScreenshot_39fcf3fa_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenLargeFontScreenshot_39fcf3fa_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenScreenshot_748aa731_0.png index 1db0caaf98..f9ada0c8f6 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationCompleteScreenScreenshotTestKt/deliveryVerificationCompleteScreenScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenDeathOnlyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenDeathOnlyScreenshot_748aa731_0.png index 961a9b85ec..ad3040108f 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenDeathOnlyScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenDeathOnlyScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenEmptyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenEmptyScreenshot_748aa731_0.png index 5fa22a0f83..9da3790ecf 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenEmptyScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenEmptyScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenFilledScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenFilledScreenshot_748aa731_0.png index 11b44806f3..9d59d66bd4 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenFilledScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreenScreenshotTestKt/documentUploadScreenFilledScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenEmptyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenEmptyScreenshot_748aa731_0.png index 864013d525..8983288873 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenEmptyScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenEmptyScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSendingScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSendingScreenshot_748aa731_0.png index e5acd82829..00f192c3b8 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSendingScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSendingScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentCompactScreenshot_c2abd7e6_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentCompactScreenshot_c2abd7e6_0.png index 66a42831f2..ca161722da 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentCompactScreenshot_c2abd7e6_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentLargeFontScreenshot_39fcf3fa_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentLargeFontScreenshot_39fcf3fa_0.png index b4c9e5cff2..8e8755c037 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentLargeFontScreenshot_39fcf3fa_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentLargeFontScreenshot_39fcf3fa_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentScreenshot_748aa731_0.png index 849d92b521..2ad4ada53a 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreenScreenshotTestKt/identityVerificationEmailScreenSentScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenCompactScreenshot_c2abd7e6_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenCompactScreenshot_c2abd7e6_0.png index 5a274a171d..a593205827 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenCompactScreenshot_c2abd7e6_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenLargeFontScreenshot_39fcf3fa_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenLargeFontScreenshot_39fcf3fa_0.png index e24a1bd6de..bd9848b5a2 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenLargeFontScreenshot_39fcf3fa_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenLargeFontScreenshot_39fcf3fa_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenScreenshot_748aa731_0.png index 4539129fc8..756ef8efca 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationIntroScreenScreenshotTestKt/identityVerificationIntroScreenScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenEmptyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenEmptyScreenshot_748aa731_0.png index a34e290732..9a5baa287b 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenEmptyScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenEmptyScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledCompactScreenshot_c2abd7e6_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledCompactScreenshot_c2abd7e6_0.png index 3909b85421..97a288e7ae 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledCompactScreenshot_c2abd7e6_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledLargeFontScreenshot_39fcf3fa_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledLargeFontScreenshot_39fcf3fa_0.png index 7e4ee9439c..6e099854f8 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledLargeFontScreenshot_39fcf3fa_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledLargeFontScreenshot_39fcf3fa_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledScreenshot_748aa731_0.png index c8601865ed..b4fa47123b 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreenScreenshotTestKt/masterKeyScreenFilledScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardEmptyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardEmptyScreenshot_748aa731_0.png index d85c096cf9..90450ab65b 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardEmptyScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardEmptyScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardFilledScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardFilledScreenshot_748aa731_0.png index 47c27bf919..c0c9825fdb 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardFilledScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/deliveryverification/component/DocumentSlotCardScreenshotTestKt/documentSlotCardFilledScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenPartialFailureScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenPartialFailureScreenshot_748aa731_0.png deleted file mode 100644 index 417f7ceaac..0000000000 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenPartialFailureScreenshot_748aa731_0.png and /dev/null differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenSuccessScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenSuccessScreenshot_748aa731_0.png deleted file mode 100644 index a307eaf42c..0000000000 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/ReceiverHomeScreenScreenshotTestKt/receiverHomeScreenSuccessScreenshot_748aa731_0.png and /dev/null differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/AfternoteSectionScreenshotTestKt/afternoteSectionScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/AfternoteSectionScreenshotTestKt/afternoteSectionScreenshot_748aa731_0.png deleted file mode 100644 index c22c17f648..0000000000 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/AfternoteSectionScreenshotTestKt/afternoteSectionScreenshot_748aa731_0.png and /dev/null differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/MindRecordSectionScreenshotTestKt/mindRecordSectionScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/MindRecordSectionScreenshotTestKt/mindRecordSectionScreenshot_748aa731_0.png deleted file mode 100644 index 19db884195..0000000000 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/MindRecordSectionScreenshotTestKt/mindRecordSectionScreenshot_748aa731_0.png and /dev/null differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCardScreenshotTestKt/mindRecordStatCardScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCardScreenshotTestKt/mindRecordStatCardScreenshot_748aa731_0.png deleted file mode 100644 index ff3b5625d3..0000000000 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/MindRecordStatCardScreenshotTestKt/mindRecordStatCardScreenshot_748aa731_0.png and /dev/null differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCardScreenshotTestKt/senderMessageHeroCardScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCardScreenshotTestKt/senderMessageHeroCardScreenshot_748aa731_0.png deleted file mode 100644 index c193a048cc..0000000000 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/SenderMessageHeroCardScreenshotTestKt/senderMessageHeroCardScreenshot_748aa731_0.png and /dev/null differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/TimeLetterSectionScreenshotTestKt/timeLetterSectionScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/TimeLetterSectionScreenshotTestKt/timeLetterSectionScreenshot_748aa731_0.png deleted file mode 100644 index 8497345e96..0000000000 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/home/component/TimeLetterSectionScreenshotTestKt/timeLetterSectionScreenshot_748aa731_0.png and /dev/null differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenEmptyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenEmptyScreenshot_748aa731_0.png index cdf56d6cb3..93b5c1734a 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenEmptyScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenEmptyScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenFilledScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenFilledScreenshot_748aa731_0.png index 825e31f819..9b09b8f71c 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenFilledScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsScreenScreenshotTestKt/receivedRecordsScreenFilledScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenEmptyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenEmptyScreenshot_748aa731_0.png index 297e0efc78..970d9c46ec 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenEmptyScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenEmptyScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledCompactScreenshot_c2abd7e6_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledCompactScreenshot_c2abd7e6_0.png index a199ff6f8f..907cd539a3 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledCompactScreenshot_c2abd7e6_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledLargeFontScreenshot_39fcf3fa_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledLargeFontScreenshot_39fcf3fa_0.png index 7ad675e453..ecd8743462 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledLargeFontScreenshot_39fcf3fa_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledLargeFontScreenshot_39fcf3fa_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledScreenshot_748aa731_0.png index 03b3ef9c80..57f52ea0e0 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationScreenScreenshotTestKt/senderRegistrationScreenFilledScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedCompactScreenshot_c2abd7e6_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedCompactScreenshot_c2abd7e6_0.png index e54733f104..a09d2d241e 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedCompactScreenshot_c2abd7e6_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedLargeFontScreenshot_39fcf3fa_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedLargeFontScreenshot_39fcf3fa_0.png index 7002e2b0e6..d20a43b3f8 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedLargeFontScreenshot_39fcf3fa_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedLargeFontScreenshot_39fcf3fa_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedScreenshot_748aa731_0.png index dd4b3849d9..783dea1eed 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenApprovedScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenNotRequestedScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenNotRequestedScreenshot_748aa731_0.png index e89e22a768..dbd281324c 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenNotRequestedScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenNotRequestedScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenPendingScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenPendingScreenshot_748aa731_0.png index bfc78a7849..9791b02eaa 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenPendingScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailScreenScreenshotTestKt/senderDetailScreenPendingScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/ReceiverFlowTouchTargetTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/ReceiverFlowTouchTargetTest.kt index c28f4795b5..34c53030c0 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/ReceiverFlowTouchTargetTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/ReceiverFlowTouchTargetTest.kt @@ -12,7 +12,6 @@ import com.afternote.core.ui.testing.scanEnabledClickTargets import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.receiver.presentation.deliveryverification.DocumentSlotState import com.afternote.feature.receiver.presentation.deliveryverification.component.DocumentSlotCard -import com.afternote.feature.receiver.presentation.home.component.SectionGoButton import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Rule @@ -24,9 +23,11 @@ import org.robolectric.annotation.Config /** * 수신자 흐름 진입 컴포넌트의 클릭 타깃 가드. * - * 섹션 이동 버튼(홈)과 서류 슬롯 카드(열람 신청)는 화면이 다르지만 규약이 같다 — 이름이 붙은 - * Button 역할이어야 하고, 최소 터치 크기를 밑돌지 않아야 하며, 한 카드의 촬영·첨부 두 진입점이 - * 서로 다른 콜백으로 갈려야 한다. + * 서류 슬롯 카드(열람 신청)는 이름이 붙은 Button 역할이어야 하고, 최소 터치 크기를 밑돌지 + * 않아야 하며, 한 카드의 촬영·첨부 두 진입점이 서로 다른 콜백으로 갈려야 한다. + * + * 같은 규약을 거는 섹션 이동 버튼은 수신자 홈과 함께 `feature:home` 으로 옮겼다 (#1462) — + * `ReceiverHomeTouchTargetTest` 에 있다. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) @@ -35,13 +36,11 @@ class ReceiverFlowTouchTargetTest { val composeRule = createComposeRule() @Test - fun `receiver navigation field and attachment actions invoke distinct callbacks`() { - var goClicks = 0 + fun `receiver document slot camera and attachment actions invoke distinct callbacks`() { var pickClicks = 0 composeRule.setContent { AfternoteTheme { Column { - SectionGoButton(text = "Go", onClick = { goClicks++ }) DocumentSlotCard( title = "Document", slot = DocumentSlotState(), @@ -53,20 +52,15 @@ class ReceiverFlowTouchTargetTest { composeRule.assertAccessibleClickTargets() val targets = composeRule.scanEnabledClickTargets() - val goTarget = targets.single { it.name == "Go" } val attachTarget = targets.single { it.name == "서류 촬영 또는 파일 첨부" } - assertEquals(Role.Button, goTarget.role) assertEquals(Role.Button, targets.single { it.name == "촬영 또는 파일 첨부" }.role) assertEquals(Role.Button, attachTarget.role) assertEquals(43f, attachTarget.layoutWidth.value, 0.1f) assertEquals(43f, attachTarget.layoutHeight.value, 0.1f) - assertFalse(goTarget.isSmallerThan(MinimumTouchTargetSize)) assertFalse(attachTarget.isSmallerThan(MinimumTouchTargetSize)) - composeRule.onNodeWithText("Go").performClick() composeRule.onNodeWithText("촬영 또는 파일 첨부").performClick() composeRule.onAllNodesWithContentDescription("서류 촬영 또는 파일 첨부")[0].performClick() - assertEquals(1, goClicks) assertEquals(2, pickClicks) } } diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationFailurePopupTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationFailurePopupTest.kt new file mode 100644 index 0000000000..76629a40e5 --- /dev/null +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationFailurePopupTest.kt @@ -0,0 +1,233 @@ +package com.afternote.feature.receiver.presentation.deliveryverification + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.ui.UiText +import com.afternote.feature.receiver.domain.error.ReceiverFailure +import com.afternote.feature.receiver.domain.error.ReceiverRejectionReason +import com.afternote.feature.receiver.domain.model.DeliveryVerification +import com.afternote.feature.receiver.domain.testing.FakeIdentityVerificationRepository +import com.afternote.feature.receiver.domain.testing.FakeReceiverAuthRepository +import com.afternote.feature.receiver.domain.testing.FakeReceiverDeliveryDocumentUploadRepository +import com.afternote.feature.receiver.domain.usecase.SubmitDeliveryVerificationUseCase +import com.afternote.feature.receiver.presentation.R +import com.afternote.feature.receiver.presentation.error.ReceiverErrorPopup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.io.IOException + +/** + * 수신자 열람 신청 흐름의 실패 안내가 «팝업(서버 작업 실패)» 과 «스낵바(서버가 준 거절 사유)» 로 + * 갈리는지 고정한다 (#446). + * + * 갈래 자체보다 **재시도가 실패한 그 요청을 되짚는지** 가 이 테스트들의 관심사다 — 「다시 시도하기」 + * 가 닫기와 같아지거나 엉뚱한 요청을 부르는 것이 팝업 도입에서 가장 흔한 회귀다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DeliveryVerificationFailurePopupTest { + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `서류 업로드 실패는 스낵바가 아니라 업로드 팝업으로 간다`() { + val upload = failingUploadRepository(IllegalStateException("S3 PUT 실패")) + val viewModel = documentUploadViewModel(upload) + + viewModel.uploadDeathCertificate() + + val state = viewModel.uiState.value + assertEquals(ReceiverErrorPopup.UPLOAD, state.errorPopup) + assertNull(state.errorMessage) + } + + @Test + fun `업로드 팝업의 재시도는 고른 파일을 그대로 다시 올린다`() { + val upload = failingUploadRepository(IllegalStateException("S3 PUT 실패")) + val viewModel = documentUploadViewModel(upload) + viewModel.uploadDeathCertificate() + + viewModel.retryFailedRequest() + + assertEquals(2, upload.uploadCalls.size) + assertEquals( + DOCUMENT_BYTES.toList(), + upload.uploadCalls + .last() + .bytes + .toList(), + ) + } + + @Test + fun `업로드 팝업을 닫으면 붙들고 있던 재시도도 함께 버려진다`() { + val upload = failingUploadRepository(IllegalStateException("S3 PUT 실패")) + val viewModel = documentUploadViewModel(upload) + viewModel.uploadDeathCertificate() + + viewModel.onErrorPopupDismissed() + viewModel.retryFailedRequest() + + assertNull(viewModel.uiState.value.errorPopup) + assertEquals(1, upload.uploadCalls.size) + } + + @Test + fun `서버가 준 거절 사유는 팝업이 아니라 문구로 남는다`() { + val auth = + submittingAuth { + Result.failure( + ReceiverFailure.UserRejection( + reason = ReceiverRejectionReason.VERIFICATION_ALREADY_SUBMITTED, + cause = CAUSE, + ), + ) + } + val viewModel = submittableViewModel(auth) + + viewModel.submit() + + val state = viewModel.uiState.value + assertNull(state.errorPopup) + assertEquals(UiText.Resource(R.string.receiver_error_verification_already_submitted), state.errorMessage) + } + + @Test + fun `서버에 닿지 못한 제출은 네트워크 팝업으로 간다`() { + val auth = submittingAuth { Result.failure(ReceiverFailure.NetworkUnavailable(CAUSE)) } + val viewModel = submittableViewModel(auth) + + viewModel.submit() + + assertEquals(ReceiverErrorPopup.NETWORK, viewModel.uiState.value.errorPopup) + } + + @Test + fun `서버 오류 팝업의 재시도는 같은 신청을 다시 보낸다`() { + val auth = submittingAuth { Result.failure(ReceiverFailure.UnexpectedServerFailure(CAUSE)) } + val viewModel = submittableViewModel(auth) + viewModel.submit() + assertEquals(ReceiverErrorPopup.SERVER, viewModel.uiState.value.errorPopup) + + viewModel.retryFailedRequest() + + assertEquals(listOf(UPLOADED_URL to null, UPLOADED_URL to null), auth.deliverySubmissions) + } + + /** 발송과 검증을 한 재시도로 뭉뚱그리면 검증 실패가 인증번호를 다시 보내는 회귀가 난다. */ + @Test + fun `인증번호 검증 실패의 재시도는 검증만 다시 부른다`() { + val auth = + FakeReceiverAuthRepository.strict().apply { + onSendEmailAuthCode = { Result.success(Unit) } + onVerifyEmailAuthCode = { _, _ -> + Result.failure(ReceiverFailure.UnexpectedServerFailure(CAUSE)) + } + } + val viewModel = IdentityVerificationViewModel(auth, FakeIdentityVerificationRepository(), NoopErrorReporter) + viewModel.onEmailChange(EMAIL) + viewModel.requestVerificationCode() + viewModel.onCodeChange("123456") + viewModel.verifyAndProceed(SENDER_ID) + assertEquals(ReceiverErrorPopup.SERVER, viewModel.uiState.value.errorPopup) + + viewModel.retryFailedRequest() + + assertEquals(1, auth.sentEmails.size) + assertEquals(2, auth.verifiedEmailCodes.size) + } + + @Test + fun `이메일 미등록 거절은 팝업 없이 문구로 남는다`() { + val auth = + FakeReceiverAuthRepository.strict().apply { + onSendEmailAuthCode = { + Result.failure( + ReceiverFailure.UserRejection( + reason = ReceiverRejectionReason.RECEIVER_EMAIL_NOT_FOUND, + cause = CAUSE, + ), + ) + } + } + val viewModel = IdentityVerificationViewModel(auth, FakeIdentityVerificationRepository(), NoopErrorReporter) + viewModel.onEmailChange(EMAIL) + + viewModel.requestVerificationCode() + + val state = viewModel.uiState.value + assertNull(state.errorPopup) + assertEquals(UiText.Resource(R.string.receiver_error_email_not_found), state.errorMessage) + } + + private fun documentUploadViewModel( + upload: FakeReceiverDeliveryDocumentUploadRepository, + auth: FakeReceiverAuthRepository = FakeReceiverAuthRepository.strict(), + ): DocumentUploadViewModel = + DocumentUploadViewModel( + uploadRepository = upload, + submitDeliveryVerification = SubmitDeliveryVerificationUseCase(auth), + errorReporter = NoopErrorReporter, + ) + + /** 사망진단서 한 장이 이미 올라간 상태 — 제출 실패 갈래를 보려면 여기부터 시작해야 한다. */ + private fun submittableViewModel(auth: FakeReceiverAuthRepository): DocumentUploadViewModel { + val upload = + FakeReceiverDeliveryDocumentUploadRepository.strict().apply { + onUpload = { _, _ -> Result.success(UPLOADED_URL) } + } + return documentUploadViewModel(upload, auth).apply { uploadDeathCertificate() } + } + + private fun DocumentUploadViewModel.uploadDeathCertificate() { + uploadDocument( + slot = DocumentSlot.DeathCertificate, + bytes = DOCUMENT_BYTES, + extension = "pdf", + displayName = "사망진단서.pdf", + ) + } + + private fun failingUploadRepository(failure: Throwable): FakeReceiverDeliveryDocumentUploadRepository = + FakeReceiverDeliveryDocumentUploadRepository.strict().apply { + onUpload = { _, _ -> Result.failure(failure) } + } + + private fun submittingAuth(onSubmit: suspend () -> Result): FakeReceiverAuthRepository = + FakeReceiverAuthRepository.strict().apply { + onSubmitDeliveryVerification = { _, _ -> onSubmit() } + } + + private object NoopErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit + } + + private companion object { + val DOCUMENT_BYTES = byteArrayOf(1, 2, 3) + const val UPLOADED_URL = "https://cdn.test/death.pdf" + const val EMAIL = "receiver@example.test" + const val SENDER_ID = "sender-1" + + /** + * 실패가 나르는 원인 예외 자리. 프로덕션에서는 `ApiException` 이 들어오지만 도메인 계약이 + * 요구하는 것은 `Throwable` 뿐이라 core:network 를 끌어오지 않는다. + */ + val CAUSE: Throwable = IOException("stub cause") + } +} diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationFlowViewModelTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationFlowViewModelTest.kt index e3a52c928f..555235e511 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationFlowViewModelTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DeliveryVerificationFlowViewModelTest.kt @@ -1,7 +1,7 @@ package com.afternote.feature.receiver.presentation.deliveryverification -import androidx.lifecycle.SavedStateHandle import com.afternote.feature.receiver.domain.testing.FakeIdentityVerificationRepository +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -14,21 +14,14 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config /** * 열람 신청 흐름의 Intro 스킵 관문이 발신자별로 격리되는지 본다 (#597). * * 이전에는 전역 boolean 캐시라 발신자 A 인증만으로 발신자 B 흐름의 Intro·이메일 단계까지 * 건너뛰었다 — 관문은 반드시 자신의 senderId 인증만 봐야 한다. - * - * Robolectric 인 이유: `SavedStateHandle.toRoute` 복원이 Bundle 기반이라 JVM 단독으로는 안 돈다. */ @OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [35]) class DeliveryVerificationFlowViewModelTest { // runTest 본문의 launch(collector)와 viewModelScope(stateIn 공유)가 같은 unconfined // 스케줄러를 타야 구독 → upstream 시작 → 값 반영이 단언 전에 동기로 끝난다. @@ -71,7 +64,7 @@ class DeliveryVerificationFlowViewModelTest { repository: FakeIdentityVerificationRepository, ): DeliveryVerificationFlowViewModel = DeliveryVerificationFlowViewModel( - savedStateHandle = SavedStateHandle(mapOf("senderId" to senderId)), + route = ReceiverRoute.DeliveryVerificationFlowRoute(senderId = senderId), identityVerificationRepository = repository, ) } diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadViewModelTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadViewModelTest.kt index b0e1c002da..8a92e25961 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadViewModelTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadViewModelTest.kt @@ -6,6 +6,7 @@ import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus import com.afternote.feature.receiver.domain.testing.FakeReceiverAuthRepository import com.afternote.feature.receiver.domain.testing.FakeReceiverDeliveryDocumentUploadRepository +import com.afternote.feature.receiver.domain.usecase.SubmitDeliveryVerificationUseCase import com.afternote.feature.receiver.presentation.R import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -54,7 +55,7 @@ class DocumentUploadViewModelTest { ): DocumentUploadViewModel = DocumentUploadViewModel( uploadRepository = uploads, - receiverAuthRepository = auth, + submitDeliveryVerification = SubmitDeliveryVerificationUseCase(auth), errorReporter = NoopErrorReporter, ) @@ -91,11 +92,25 @@ class DocumentUploadViewModelTest { viewModel.submit() val state = viewModel.uiState.value - assertEquals(UiText.Resource(R.string.receiver_verify_document_upload_in_progress), state.error) + assertEquals(UiText.Resource(R.string.receiver_verify_document_upload_in_progress), state.errorMessage) assertFalse(state.isSubmitting) assertTrue(auth.deliverySubmissions.isEmpty()) } + @Test + fun `첨부 하나도 없이 submit - 서류 누락 안내로 거절되고 신청 API 미호출 (#1701)`() { + val auth = FakeReceiverAuthRepository.strict() + val viewModel = viewModel(FakeReceiverDeliveryDocumentUploadRepository.strict(), auth) + + viewModel.submit() + + val state = viewModel.uiState.value + assertEquals(UiText.Resource(R.string.receiver_verify_documents_required), state.errorMessage) + assertFalse(state.isSubmitting) + assertFalse(state.isSubmitted) + assertTrue(auth.deliverySubmissions.isEmpty()) + } + @Test fun `업로드 실패로 끝난 슬롯 - 잠금이 풀려 기존 첨부만으로 제출 가능 (#380 정책 유지)`() { val (uploads, pendingUploads) = gatedUploadRepository() diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModelTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModelTest.kt index c0ac11196d..4677206c86 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModelTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModelTest.kt @@ -41,7 +41,7 @@ class MasterKeyViewModelTest { assertEquals( UiText.Resource(R.string.receiver_verify_master_key_invalid_format), - viewModel.uiState.value.error, + viewModel.uiState.value.errorMessage, ) assertFalse(viewModel.uiState.value.isSubmitting) } @@ -66,7 +66,7 @@ class MasterKeyViewModelTest { assertEquals("발신자", senderRegistry.findById(sender.id)?.realSenderName) assertTrue(viewModel.uiState.value.isVerified) assertFalse(viewModel.uiState.value.isSubmitting) - assertEquals(null, viewModel.uiState.value.error) + assertEquals(null, viewModel.uiState.value.errorMessage) } @Test @@ -83,7 +83,7 @@ class MasterKeyViewModelTest { assertEquals(listOf(normalized), authRepository.verifiedMasterKeys) assertEquals(listOf(normalized), receiverRepository.savedMasterKeys) assertTrue(viewModel.uiState.value.isVerified) - assertEquals(null, viewModel.uiState.value.error) + assertEquals(null, viewModel.uiState.value.errorMessage) } private fun viewModel( diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ReceiverVerificationTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ReceiverVerificationTest.kt index 5254cf700f..5d4d6cf727 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ReceiverVerificationTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ReceiverVerificationTest.kt @@ -15,6 +15,7 @@ import com.afternote.feature.receiver.domain.model.ReceiverEmailAuthResult import com.afternote.feature.receiver.domain.testing.FakeIdentityVerificationRepository import com.afternote.feature.receiver.domain.testing.FakeReceiverAuthRepository import com.afternote.feature.receiver.domain.testing.FakeReceiverDeliveryDocumentUploadRepository +import com.afternote.feature.receiver.domain.usecase.SubmitDeliveryVerificationUseCase import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -134,7 +135,8 @@ class ReceiverVerificationTest { ) } } - val viewModel = DocumentUploadViewModel(upload, auth, NoopErrorReporter) + val viewModel = + DocumentUploadViewModel(upload, SubmitDeliveryVerificationUseCase(auth), NoopErrorReporter) composeRule.setContent { AfternoteTheme {} } composeRule.runOnIdle { diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/error/ReceiverErrorPopupHostTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/error/ReceiverErrorPopupHostTest.kt new file mode 100644 index 0000000000..fe3b7d42f8 --- /dev/null +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/error/ReceiverErrorPopupHostTest.kt @@ -0,0 +1,70 @@ +package com.afternote.feature.receiver.presentation.error + +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.afternote.core.ui.theme.AfternoteTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import com.afternote.core.ui.R as CoreUiR + +/** + * [ReceiverErrorPopupHost] 의 갈래 → 팝업 대응 가드 (#446). + * + * 갈래를 잘못 이어도(예: 서버 오류에 업로드 실패 팝업) 컴파일은 통과하고 화면도 팝업 하나를 + * 정상적으로 띄운다 — 그 조용한 오배선을 잡는 것이 이 테스트다. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class ReceiverErrorPopupHostTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun networkPopup_showsNetworkCopy() { + assertPopupShows(ReceiverErrorPopup.NETWORK, CoreUiR.string.core_ui_network_error_title) + } + + @Test + fun serverPopup_showsServerCopy() { + assertPopupShows(ReceiverErrorPopup.SERVER, CoreUiR.string.core_ui_server_error_title) + } + + @Test + fun uploadPopup_showsUploadCopy() { + assertPopupShows(ReceiverErrorPopup.UPLOAD, CoreUiR.string.core_ui_upload_error_title) + } + + @Test + fun nullPopup_showsNothing() { + composeRule.setContent { + AfternoteTheme { + ReceiverErrorPopupHost(popup = null, onRetry = {}, onDismiss = {}) + } + } + + composeRule + .onNodeWithText(stringResourceValue(CoreUiR.string.core_ui_server_error_title)) + .assertDoesNotExist() + } + + private fun assertPopupShows( + popup: ReceiverErrorPopup, + titleRes: Int, + ) { + composeRule.setContent { + AfternoteTheme { + ReceiverErrorPopupHost(popup = popup, onRetry = {}, onDismiss = {}) + } + } + + composeRule.onNodeWithText(stringResourceValue(titleRes)).assertExists() + } + + private fun stringResourceValue(resourceId: Int): String = RuntimeEnvironment.getApplication().getString(resourceId) +} diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeDownloadSnackbarTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeDownloadSnackbarTest.kt deleted file mode 100644 index 6691581653..0000000000 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeDownloadSnackbarTest.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.afternote.feature.receiver.presentation.home - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.receiver.presentation.R -import com.afternote.feature.receiver.presentation.home.model.ReceiverDownloadState -import com.afternote.feature.receiver.presentation.home.model.ReceiverHomeUiState -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** 이 테스트의 관심 밖인 외부 라우팅을 채우는 no-op 묶음. */ -private val noopActions = - ReceiverHomeActions( - onNavigateToMindRecord = {}, - onNavigateToTimeLetter = {}, - onNavigateToAfternote = {}, - ) - -/** - * 수신자 홈 내려받기 실패 안내의 Snackbar 표출 가드 (#1391). - * - * 종전 Toast 는 Compose semantics 에 잡히지 않아 어떤 테스트로도 단언할 수 없었다 — 이 단언이 - * 가능해진 것 자체가 전환 목적의 일부다. 문구는 리소스 원문으로 대조한다 (모듈 테스트 관례, - * [com.afternote.feature.receiver.presentation.deliveryverification.ReceiverVerificationTest] 참조). - */ -@RunWith(RobolectricTestRunner::class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(sdk = [35]) -class ReceiverHomeDownloadSnackbarTest { - @get:Rule - val composeRule = createComposeRule() - - @Test - fun `내려받기 실패 상태는 스낵바로 표출된다`() { - setHomeContent(ReceiverDownloadState.Failed(R.string.receiver_home_download_all_failed)) - - composeRule.onNodeWithText("모든 기록 내려받기에 실패했습니다.").assertIsDisplayed() - } - - @Test - fun `파일 저장 실패 상태도 자기 문구로 표출된다`() { - setHomeContent(ReceiverDownloadState.Failed(R.string.receiver_home_download_all_save_failed)) - - composeRule.onNodeWithText("파일 저장에 실패했습니다.").assertIsDisplayed() - } - - private fun setHomeContent(download: ReceiverDownloadState) { - composeRule.setContent { - AfternoteTheme { - ReceiverHomeScreen( - uiState = - ReceiverHomeUiState.Success( - senderName = "박서연", - senderMessage = null, - mindRecord = null, - timeLetterTotalCount = null, - afternoteTotalCount = null, - afternoteIcons = emptyList(), - download = download, - ), - onEvent = { }, - actions = noopActions, - ) - } - } - } -} diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/navigation/DeliveryVerificationFlowLocalNavActionsTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/navigation/DeliveryVerificationFlowLocalNavActionsTest.kt new file mode 100644 index 0000000000..41dde96695 --- /dev/null +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/navigation/DeliveryVerificationFlowLocalNavActionsTest.kt @@ -0,0 +1,79 @@ +package com.afternote.feature.receiver.presentation.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 열람 신청 흐름의 단계 소거 회귀 기준 (#1601 · #1698). + * + * 인증번호·마스터 키는 서버가 이미 소비했으므로, 각 단계를 통과하면 지나온 화면이 스택에 + * 남으면 안 된다. Nav2 의 `popUpTo(inclusive = true)` 가 하던 일을 로컬 스택이 그대로 한다. + */ +class DeliveryVerificationFlowLocalNavActionsTest { + private var exits = 0 + private var exitsToRecords = 0 + private val stepStack = NavBackStack(ReceiverRoute.IdentityVerificationIntroRoute) + private val actions = + DeliveryVerificationFlowLocalNavActions( + stepStack = stepStack, + navigationCallbacks = FeatureNavigationCallbacks { exits += 1 }, + onExitToReceivedRecords = { exitsToRecords += 1 }, + ) + + private fun stack(): List = stepStack.map { it::class.simpleName!! } + + @Test + fun `마스터 키로 넘어가면 본인 확인 화면들이 남지 않는다`() { + actions.navigateToIdentityVerificationEmail() + assertEquals( + listOf("IdentityVerificationIntroRoute", "IdentityVerificationEmailRoute"), + stack(), + ) + + actions.proceedToMasterKey() + + assertEquals(listOf("MasterKeyRoute"), stack()) + } + + @Test + fun `본인 확인 캐시로 안내를 건너뛴 경로도 같은 모양이 된다`() { + actions.proceedToMasterKey() + + assertEquals(listOf("MasterKeyRoute"), stack()) + } + + @Test + fun `서류 업로드와 완료도 직전 단계를 남기지 않는다`() { + actions.proceedToMasterKey() + + actions.proceedToDocumentUpload() + assertEquals(listOf("DocumentUploadRoute"), stack()) + + actions.proceedToDeliveryVerificationComplete() + assertEquals(listOf("DeliveryVerificationCompleteRoute"), stack()) + } + + @Test + fun `단계 소거 뒤 뒤로가기는 흐름을 통째로 벗어난다`() { + actions.proceedToMasterKey() + + actions.popBack() + + // 흐름 스택엔 되돌아갈 단계가 남아 있지 않으므로 흐름 entry 자체가 내려간다. + assertEquals(listOf("MasterKeyRoute"), stack()) + assertEquals(1, exits) + } + + @Test + fun `완료 화면의 받은 기록함으로는 바깥 스택이 처리한다`() { + actions.proceedToDeliveryVerificationComplete() + + actions.popToReceivedRecords() + + assertEquals(1, exitsToRecords) + } +} diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverLocalNavActionsTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverLocalNavActionsTest.kt new file mode 100644 index 0000000000..ed177b7578 --- /dev/null +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/navigation/ReceiverLocalNavActionsTest.kt @@ -0,0 +1,64 @@ +package com.afternote.feature.receiver.presentation.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 수신자 로컬 백스택 경계 회귀 기준 — Nav2 시절 `AuthBoundaryBackStackTest` 가 보던 몫 (#1601 · #1698). + */ +class ReceiverLocalNavActionsTest { + private var exits = 0 + private val backStack = NavBackStack(ReceiverRoute.ReceivedRecordsRoute) + private val actions = + ReceiverLocalNavActions( + backStack = backStack, + navigationCallbacks = FeatureNavigationCallbacks { exits += 1 }, + ) + + private fun stack(): List = backStack.map { it::class.simpleName!! } + + @Test + fun `발신자 상세와 열람 신청 흐름은 받은 기록함 위에 차례로 쌓인다`() { + actions.navigateToSenderDetail(SENDER_ID) + actions.navigateToDeliveryVerificationFlow(SENDER_ID) + + assertEquals( + listOf("ReceivedRecordsRoute", "SenderDetailRoute", "DeliveryVerificationFlowRoute"), + stack(), + ) + } + + @Test + fun `흐름 진입 키가 senderId 를 나른다`() { + actions.navigateToDeliveryVerificationFlow(SENDER_ID) + + val flowKey = backStack.last() as ReceiverRoute.DeliveryVerificationFlowRoute + assertEquals(SENDER_ID, flowKey.senderId) + } + + @Test + fun `받은 기록함으로 돌아가기는 그 위를 모두 걷어낸다`() { + actions.navigateToSenderDetail(SENDER_ID) + actions.navigateToDeliveryVerificationFlow(SENDER_ID) + + actions.popToReceivedRecords() + + assertEquals(listOf("ReceivedRecordsRoute"), stack()) + } + + @Test + fun `스택 바닥에서의 뒤로가기는 스택을 비우지 않고 셸에 넘긴다`() { + actions.popBack() + + assertEquals(listOf("ReceivedRecordsRoute"), stack()) + assertEquals(1, exits) + } + + private companion object { + const val SENDER_ID = "sender-1601" + } +} diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/reporting/ReceiverFailureReportingTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/reporting/ReceiverFailureReportingTest.kt new file mode 100644 index 0000000000..cc660ddf0d --- /dev/null +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/reporting/ReceiverFailureReportingTest.kt @@ -0,0 +1,45 @@ +package com.afternote.feature.receiver.presentation.reporting + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.feature.receiver.domain.error.ReceiverFailure +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReceiverFailureReportingTest { + @Test + fun `내보내기 미지원은 내려받기와 파일 저장 모두 오류로 보고하지 않는다`() { + val reporter = RecordingErrorReporter() + + reporter.recordReceiverFailure(ReceiverFailureStage.RECEIVED_EXPORT_DOWNLOAD, ReceiverFailure.ExportNotSupported()) + reporter.recordReceiverFailure(ReceiverFailureStage.RECEIVED_EXPORT_SAVE, ReceiverFailure.ExportNotSupported()) + + assertTrue(reporter.attributes.isEmpty()) + } + + @Test + fun `일반 내보내기 실패는 각 단계와 오류 타입을 유지해 보고한다`() { + val reporter = RecordingErrorReporter() + val failure = IllegalStateException("export failed") + + reporter.recordReceiverFailure(ReceiverFailureStage.RECEIVED_EXPORT_DOWNLOAD, failure) + reporter.recordReceiverFailure(ReceiverFailureStage.RECEIVED_EXPORT_SAVE, failure) + + assertEquals( + listOf("received_export_download", "received_export_save"), + reporter.attributes.map { it["receiver_stage"] }, + ) + assertTrue(reporter.attributes.all { it["error_type"] == failure.javaClass.name }) + } +} + +private class RecordingErrorReporter : ErrorReporter { + val attributes = mutableListOf>() + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + this.attributes += attributes + } +} diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailApprovedAtTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailApprovedAtTest.kt index 45dcea11e9..3319079e70 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailApprovedAtTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailApprovedAtTest.kt @@ -1,6 +1,5 @@ package com.afternote.feature.receiver.presentation.senderdetail -import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus @@ -8,6 +7,7 @@ import com.afternote.feature.receiver.domain.model.ReceivedRecordBox import com.afternote.feature.receiver.domain.model.ReceiverIdentity import com.afternote.feature.receiver.domain.testing.FakeReceiverAuthRepository import com.afternote.feature.receiver.domain.testing.FakeReceiverRepository +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -183,7 +183,7 @@ class SenderDetailApprovedAtTest { fun viewModel(): SenderDetailViewModel = SenderDetailViewModel( - savedStateHandle = SavedStateHandle(mapOf("senderId" to senderId)), + route = ReceiverRoute.SenderDetailRoute(senderId = senderId), senderRegistry = registry, receiverRepository = receiver, receiverAuthRepository = auth, diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModelTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModelTest.kt index 1e343b97a8..b0787bff0a 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModelTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModelTest.kt @@ -1,12 +1,12 @@ package com.afternote.feature.receiver.presentation.senderdetail -import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus import com.afternote.feature.receiver.domain.model.ReceiverIdentity import com.afternote.feature.receiver.domain.testing.FakeReceiverAuthRepository import com.afternote.feature.receiver.domain.testing.FakeReceiverRepository +import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -150,7 +150,7 @@ private class Fixture { fun viewModel(): SenderDetailViewModel = SenderDetailViewModel( - savedStateHandle = SavedStateHandle(mapOf("senderId" to senderId)), + route = ReceiverRoute.SenderDetailRoute(senderId = senderId), senderRegistry = registry, receiverRepository = receiver, receiverAuthRepository = auth, diff --git a/feature/setting/data/build.gradle.kts b/feature/setting/data/build.gradle.kts index b63109866f..584c87c69b 100644 --- a/feature/setting/data/build.gradle.kts +++ b/feature/setting/data/build.gradle.kts @@ -9,5 +9,9 @@ android { dependencies { implementation(projects.feature.setting.domain) - implementation(projects.core.network) + // 탈퇴 실패 진단(ErrorReporter). core:domain·model·network 는 data 규약이 이미 붙인다. + implementation(projects.core.common) + + testImplementation(testFixtures(projects.core.domain)) + testImplementation(libs.coroutines.test) } diff --git a/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/PushSettingFailureMapper.kt b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/PushSettingFailureMapper.kt new file mode 100644 index 0000000000..454be1ffd0 --- /dev/null +++ b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/PushSettingFailureMapper.kt @@ -0,0 +1,14 @@ +package com.afternote.feature.setting.data + +import com.afternote.core.domain.error.PushSettingFailure +import com.afternote.core.network.model.ApiException +import java.io.IOException + +internal inline fun mapPushSettingFailure(block: () -> T): T = + try { + block() + } catch (failure: ApiException) { + throw PushSettingFailure.ServerUnavailable(failure) + } catch (failure: IOException) { + throw PushSettingFailure.NetworkUnavailable(failure) + } diff --git a/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/SettingAccountRepositoryImpl.kt b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/SettingAccountRepositoryImpl.kt new file mode 100644 index 0000000000..3da9cf8706 --- /dev/null +++ b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/SettingAccountRepositoryImpl.kt @@ -0,0 +1,69 @@ +package com.afternote.feature.setting.data + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.repository.auth.AuthRepository +import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.core.network.dto.SocialAccountLinkRequestDto +import com.afternote.core.network.model.requireData +import com.afternote.core.network.model.requireStatus +import com.afternote.core.network.service.UserApiService +import com.afternote.feature.setting.domain.SettingAccountRepository +import javax.inject.Inject + +internal class SettingAccountRepositoryImpl + @Inject + constructor( + private val userApiService: UserApiService, + private val authRepository: AuthRepository, + private val errorReporter: ErrorReporter, + ) : SettingAccountRepository { + /** + * 탈퇴 성공 후 로컬 세션도 정리한다 (#586) — 서버가 계정을 지워도 토큰이 남으면 재시작 시 + * 죽은 토큰으로 홈이 뜨고 인증 요청이 연달아 401 로 실패한다(2026-07-28 에뮬 실측). + * + * 정리를 서버 호출 **뒤**에 두는 건 `AuthRepositoryImpl.logout()` 과 같은 이유 — DELETE 요청도 + * `AuthInterceptor` 를 지나므로 그 시점엔 토큰이 살아 있어야 한다. + * + * `clearSession()` 의 실패는 삼킨다. 서버 계정은 이미 지워졌으므로 여기서 예외를 올리면 + * 화면이 "탈퇴 실패" 로 표시돼 사용자가 재시도하고, 그 재시도는 없는 계정에 대해 실패한다. + * 대신 [ErrorReporter] 에 비식별 단계만 붙여 남긴다 — 조용히 넘기면 로컬 토큰이 남은 채로 + * 이 버그가 재발해도 탐지되지 않는다. 예외 원문 제거는 리포터 공통 정책이 담당한다. + */ + override suspend fun deleteAccount() { + userApiService + .deleteAccount() + .requireStatus() + authRepository.clearSession().onFailure { + errorReporter.recordFailure( + throwable = it, + attributes = mapOf(KEY_ACCOUNT_STAGE to ACCOUNT_STAGE_DELETE_SESSION_CLEANUP), + ) + } + } + + override suspend fun getConnectedAccounts(): UserConnectedAccount = + userApiService + .getConnectedAccounts() + .requireData() + .toDomain() + + override suspend fun linkConnectedAccount( + provider: String, + accessToken: String, + ): UserConnectedAccount = + userApiService + .linkConnectedAccount( + provider = provider, + request = SocialAccountLinkRequestDto(accessToken = accessToken), + ).requireData() + .toDomain() + + override suspend fun unlinkConnectedAccount(provider: String): UserConnectedAccount = + userApiService + .unlinkConnectedAccount(provider) + .requireData() + .toDomain() + } + +private const val KEY_ACCOUNT_STAGE = "account_stage" +private const val ACCOUNT_STAGE_DELETE_SESSION_CLEANUP = "delete_session_cleanup" diff --git a/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/SettingNotificationRepositoryImpl.kt b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/SettingNotificationRepositoryImpl.kt new file mode 100644 index 0000000000..0f81e41434 --- /dev/null +++ b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/SettingNotificationRepositoryImpl.kt @@ -0,0 +1,60 @@ +package com.afternote.feature.setting.data + +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.core.network.dto.UserUpdateMarketingConsentRequestDto +import com.afternote.core.network.dto.UserUpdatePushSettingRequestDto +import com.afternote.core.network.model.requireData +import com.afternote.core.network.service.UserApiService +import com.afternote.feature.setting.domain.SettingNotificationRepository +import javax.inject.Inject + +internal class SettingNotificationRepositoryImpl + @Inject + constructor( + private val userApiService: UserApiService, + ) : SettingNotificationRepository { + override suspend fun getMyPushSettings(): UserPushSetting = + userApiService + .getMyPushSettings() + .requireData() + .toDomain() + + override suspend fun updateMyPushSettings( + timeLetter: Boolean?, + mindRecord: Boolean?, + afterNote: Boolean?, + ): UserPushSetting = + mapPushSettingFailure { + userApiService + .updateMyPushSettings( + UserUpdatePushSettingRequestDto( + timeLetter = timeLetter, + mindRecord = mindRecord, + afterNote = afterNote, + ), + ).requireData() + .toDomain() + } + + override suspend fun getMyMarketingConsents(): UserMarketingConsent = + userApiService + .getMyMarketingConsents() + .requireData() + .toDomain() + + override suspend fun updateMyMarketingConsents( + sms: Boolean?, + email: Boolean?, + push: Boolean?, + ): UserMarketingConsent = + userApiService + .updateMyMarketingConsents( + UserUpdateMarketingConsentRequestDto( + sms = sms, + email = email, + push = push, + ), + ).requireData() + .toDomain() + } diff --git a/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/SettingUserMapper.kt b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/SettingUserMapper.kt new file mode 100644 index 0000000000..0b4718f8f8 --- /dev/null +++ b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/SettingUserMapper.kt @@ -0,0 +1,36 @@ +package com.afternote.feature.setting.data + +import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.core.network.dto.UserConnectedAccountDto +import com.afternote.core.network.dto.UserMarketingConsentDto +import com.afternote.core.network.dto.UserPushSettingDto + +internal fun UserPushSettingDto.toDomain(): UserPushSetting = + UserPushSetting( + timeLetter = timeLetter, + mindRecord = mindRecord, + afterNote = afterNote, + ) + +internal fun UserMarketingConsentDto.toDomain(): UserMarketingConsent = + UserMarketingConsent( + sms = sms, + email = email, + push = push, + ) + +internal fun UserConnectedAccountDto.toDomain(): UserConnectedAccount = + UserConnectedAccount( + local = local, + google = google, + naver = naver, + kakao = kakao, + apple = apple, + localEmail = localEmail, + googleEmail = googleEmail, + naverEmail = naverEmail, + kakaoEmail = kakaoEmail, + appleEmail = appleEmail, + ) diff --git a/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/di/SettingUserRepositoryModule.kt b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/di/SettingUserRepositoryModule.kt new file mode 100644 index 0000000000..e208e420f1 --- /dev/null +++ b/feature/setting/data/src/main/kotlin/com/afternote/feature/setting/data/di/SettingUserRepositoryModule.kt @@ -0,0 +1,25 @@ +package com.afternote.feature.setting.data.di + +import com.afternote.feature.setting.data.SettingAccountRepositoryImpl +import com.afternote.feature.setting.data.SettingNotificationRepositoryImpl +import com.afternote.feature.setting.domain.SettingAccountRepository +import com.afternote.feature.setting.domain.SettingNotificationRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * 설정 계정·알림 저장소 바인딩 (#1429). + * + * 두 구현 모두 상태가 없어 unscoped 다 — 매 주입마다 새로 만들어도 서버가 정본이다. + */ +@Module +@InstallIn(SingletonComponent::class) +internal abstract class SettingUserRepositoryModule { + @Binds + internal abstract fun bindSettingAccountRepository(impl: SettingAccountRepositoryImpl): SettingAccountRepository + + @Binds + internal abstract fun bindSettingNotificationRepository(impl: SettingNotificationRepositoryImpl): SettingNotificationRepository +} diff --git a/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/PushSettingFailureMapperTest.kt b/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/PushSettingFailureMapperTest.kt new file mode 100644 index 0000000000..bbf8a3b203 --- /dev/null +++ b/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/PushSettingFailureMapperTest.kt @@ -0,0 +1,52 @@ +package com.afternote.feature.setting.data + +import com.afternote.core.domain.error.PushSettingFailure +import com.afternote.core.network.model.ApiException +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.IOException + +class PushSettingFailureMapperTest { + @Test + fun `전송 실패는 네트워크 도메인 실패로 변환한다`() { + val cause = IOException("offline") + + val failure = + assertThrows(PushSettingFailure.NetworkUnavailable::class.java) { + mapPushSettingFailure { throw cause } + } + + assertSame(cause, failure.cause) + } + + @Test + fun `API 응답 실패는 서버 도메인 실패로 변환한다`() { + val cause = + ApiException( + status = 503, + code = 1503, + serverMessage = null, + fallbackMessage = "server unavailable", + ) + + val failure = + assertThrows(PushSettingFailure.ServerUnavailable::class.java) { + mapPushSettingFailure { throw cause } + } + + assertSame(cause, failure.cause) + } + + @Test + fun `분류하지 않는 실패는 원본을 유지한다`() { + val cause = IllegalStateException("unexpected") + + val failure = + assertThrows(IllegalStateException::class.java) { + mapPushSettingFailure { throw cause } + } + + assertSame(cause, failure) + } +} diff --git a/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/SettingAccountRepositoryImplTest.kt b/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/SettingAccountRepositoryImplTest.kt new file mode 100644 index 0000000000..992a105eb6 --- /dev/null +++ b/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/SettingAccountRepositoryImplTest.kt @@ -0,0 +1,185 @@ +package com.afternote.feature.setting.data + +import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.core.network.dto.SocialAccountLinkRequestDto +import com.afternote.core.network.dto.UserConnectedAccountDto +import com.afternote.core.network.model.ApiException +import com.afternote.core.network.model.BaseResponse +import com.afternote.core.network.service.UserApiService +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class SettingAccountRepositoryImplTest { + private val calls = mutableListOf() + private val errorReporter = RecordingErrorReporter() + + private fun repository( + deleteAccountResponse: BaseResponse = success(), + clearSessionResult: Result = Result.success(Unit), + userApiService: UserApiService = + SettingUserApiServiceFake( + onDeleteAccount = { + calls += "deleteAccount" + deleteAccountResponse + }, + ), + ) = SettingAccountRepositoryImpl( + userApiService = userApiService, + authRepository = + FakeAuthRepository.strict(loggedIn = true).apply { + onClearSession = { + calls += "clearSession" + clearSessionResult + } + }, + errorReporter = errorReporter, + ) + + @Test + fun `deleteAccount - 탈퇴 성공 시 로컬 세션을 정리한다`() { + val repository = repository() + + runBlocking { repository.deleteAccount() } + + assertEquals(listOf("deleteAccount", "clearSession"), calls) + assertEquals(0, errorReporter.writtenFailures.size) + } + + @Test + fun `deleteAccount - 서버 탈퇴 실패면 세션을 유지한다`() { + val repository = repository(deleteAccountResponse = BaseResponse(status = 500, code = 500)) + + assertThrows(ApiException::class.java) { + runBlocking { repository.deleteAccount() } + } + + assertEquals(listOf("deleteAccount"), calls) + assertEquals(0, errorReporter.writtenFailures.size) + } + + /** + * 서버 계정은 이미 지워진 뒤라 정리 실패를 예외로 올리면 화면이 "탈퇴 실패" 로 표시되고, + * 사용자의 재시도는 없는 계정에 대해 다시 실패한다. 삼키는 것이 계약이다. + */ + @Test + fun `deleteAccount - 세션 정리가 실패해도 탈퇴는 성공으로 끝난다`() { + val failure = IllegalStateException("datastore 쓰기 실패") + val repository = repository(clearSessionResult = Result.failure(failure)) + + runBlocking { repository.deleteAccount() } + + assertEquals(listOf("deleteAccount", "clearSession"), calls) + val (reported, attributes) = errorReporter.writtenFailures.single() + assertEquals(IllegalStateException::class.java.name, reported.message) + assertEquals( + mapOf( + "account_stage" to "delete_session_cleanup", + "error_type" to IllegalStateException::class.java.name, + ), + attributes, + ) + } + + /** 정리가 DELETE 앞에 오면 요청이 토큰 없이 나가므로, 순서 자체가 계약이다. */ + @Test + fun `deleteAccount - 세션 정리는 서버 호출 뒤에 온다`() { + val repository = repository() + + runBlocking { repository.deleteAccount() } + + assertEquals(0, calls.indexOf("deleteAccount")) + assertEquals(1, calls.indexOf("clearSession")) + } + + /** 연결 계정 응답은 DTO 필드를 한 칸도 섞지 않고 도메인으로 옮긴다. */ + @Test + fun `getConnectedAccounts - 서버 응답을 그대로 도메인으로 옮긴다`() { + val repository = + repository( + userApiService = + SettingUserApiServiceFake( + onGetConnectedAccounts = { dataResponse(CONNECTED_ACCOUNT_DTO) }, + ), + ) + + val accounts = runBlocking { repository.getConnectedAccounts() } + + assertEquals(EXPECTED_CONNECTED_ACCOUNT, accounts) + } + + /** provider 는 경로, accessToken 은 본문 — 자리가 바뀌면 서버가 연결 대상을 잘못 잡는다. */ + @Test + fun `linkConnectedAccount - provider 와 accessToken 을 각자 자리로 보낸다`() { + val requests = mutableListOf>() + val repository = + repository( + userApiService = + SettingUserApiServiceFake( + onLinkConnectedAccount = { provider, request -> + requests += provider to request + dataResponse(CONNECTED_ACCOUNT_DTO) + }, + ), + ) + + val accounts = runBlocking { repository.linkConnectedAccount(provider = "google", accessToken = "google-token") } + + assertEquals(listOf("google" to SocialAccountLinkRequestDto(accessToken = "google-token")), requests) + assertEquals(EXPECTED_CONNECTED_ACCOUNT, accounts) + } + + @Test + fun `unlinkConnectedAccount - 해제 뒤 서버가 준 최신 연결 상태를 돌려준다`() { + val providers = mutableListOf() + val repository = + repository( + userApiService = + SettingUserApiServiceFake( + onUnlinkConnectedAccount = { provider -> + providers += provider + dataResponse(CONNECTED_ACCOUNT_DTO.copy(google = false, googleEmail = null)) + }, + ), + ) + + val accounts = runBlocking { repository.unlinkConnectedAccount("google") } + + assertEquals(listOf("google"), providers) + assertEquals(EXPECTED_CONNECTED_ACCOUNT.copy(google = false, googleEmail = null), accounts) + } + + private companion object { + val CONNECTED_ACCOUNT_DTO = + UserConnectedAccountDto( + local = true, + google = true, + naver = false, + kakao = false, + apple = false, + localEmail = "local@afternote.local", + googleEmail = "google@afternote.local", + naverEmail = null, + kakaoEmail = null, + appleEmail = null, + ) + + val EXPECTED_CONNECTED_ACCOUNT = + UserConnectedAccount( + local = true, + google = true, + naver = false, + kakao = false, + apple = false, + localEmail = "local@afternote.local", + googleEmail = "google@afternote.local", + naverEmail = null, + kakaoEmail = null, + appleEmail = null, + ) + } +} + +private fun success() = BaseResponse(status = 200, code = 200) diff --git a/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/SettingNotificationRepositoryImplTest.kt b/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/SettingNotificationRepositoryImplTest.kt new file mode 100644 index 0000000000..debfe886c3 --- /dev/null +++ b/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/SettingNotificationRepositoryImplTest.kt @@ -0,0 +1,142 @@ +package com.afternote.feature.setting.data + +import com.afternote.core.domain.error.PushSettingFailure +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.core.network.dto.UserMarketingConsentDto +import com.afternote.core.network.dto.UserPushSettingDto +import com.afternote.core.network.dto.UserUpdateMarketingConsentRequestDto +import com.afternote.core.network.dto.UserUpdatePushSettingRequestDto +import com.afternote.core.network.model.ApiException +import com.afternote.core.network.model.BaseResponse +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Test +import java.net.UnknownHostException + +class SettingNotificationRepositoryImplTest { + @Test + fun `getMyPushSettings - 서버 응답을 그대로 도메인으로 옮긴다`() { + val repository = + SettingNotificationRepositoryImpl( + SettingUserApiServiceFake( + onGetMyPushSettings = { + dataResponse(UserPushSettingDto(timeLetter = true, mindRecord = false, afterNote = true)) + }, + ), + ) + + val setting = runBlocking { repository.getMyPushSettings() } + + assertEquals(UserPushSetting(timeLetter = true, mindRecord = false, afterNote = true), setting) + } + + /** 건드리지 않는 항목은 `null` 로 남겨야 서버가 나머지 토글을 덮어쓰지 않는다. */ + @Test + fun `updateMyPushSettings - 지정한 항목만 담아 보낸다`() { + val requests = mutableListOf() + val repository = + SettingNotificationRepositoryImpl( + SettingUserApiServiceFake( + onUpdateMyPushSettings = { request -> + requests += request + dataResponse(UserPushSettingDto(timeLetter = false, mindRecord = true, afterNote = true)) + }, + ), + ) + + val updated = + runBlocking { + repository.updateMyPushSettings(timeLetter = false, mindRecord = null, afterNote = null) + } + + assertEquals( + listOf(UserUpdatePushSettingRequestDto(timeLetter = false, mindRecord = null, afterNote = null)), + requests, + ) + assertEquals(UserPushSetting(timeLetter = false, mindRecord = true, afterNote = true), updated) + } + + /** + * 화면은 네트워크와 서버 오류 안내를 나눠 띄운다 — 분류가 이 구현에서 빠지면 두 경우가 같은 + * 문구로 합쳐지고 사용자는 재시도 여부를 판단할 근거를 잃는다. + */ + @Test + fun `updateMyPushSettings - 전송 실패는 네트워크 도메인 실패로 분류한다`() { + val cause = UnknownHostException("offline") + val repository = + SettingNotificationRepositoryImpl( + SettingUserApiServiceFake(onUpdateMyPushSettings = { throw cause }), + ) + + val failure = + assertThrows(PushSettingFailure.NetworkUnavailable::class.java) { + runBlocking { repository.updateMyPushSettings(timeLetter = null, mindRecord = false, afterNote = null) } + } + + assertSame(cause, failure.cause) + } + + @Test + fun `updateMyPushSettings - 봉투 실패는 서버 도메인 실패로 분류한다`() { + val repository = + SettingNotificationRepositoryImpl( + SettingUserApiServiceFake( + onUpdateMyPushSettings = { BaseResponse(status = 503, code = 1503) }, + ), + ) + + val failure = + assertThrows(PushSettingFailure.ServerUnavailable::class.java) { + runBlocking { repository.updateMyPushSettings(timeLetter = null, mindRecord = null, afterNote = false) } + } + + assertEquals(ApiException::class.java, failure.cause?.javaClass) + } + + /** 마케팅 동의는 푸시 토글과 달리 실패를 분류하지 않는다 — 원본 예외가 그대로 화면까지 간다. */ + @Test + fun `updateMyMarketingConsents - 실패를 분류하지 않고 원본을 그대로 올린다`() { + val cause = UnknownHostException("offline") + val repository = + SettingNotificationRepositoryImpl( + SettingUserApiServiceFake(onUpdateMyMarketingConsents = { throw cause }), + ) + + val thrown = + assertThrows(UnknownHostException::class.java) { + runBlocking { repository.updateMyMarketingConsents(sms = false, email = null, push = null) } + } + + assertSame(cause, thrown) + } + + @Test + fun `마케팅 동의 조회와 수정은 지정한 항목만 담아 보내고 서버 응답을 옮긴다`() { + val requests = mutableListOf() + val repository = + SettingNotificationRepositoryImpl( + SettingUserApiServiceFake( + onGetMyMarketingConsents = { + dataResponse(UserMarketingConsentDto(sms = true, email = true, push = false)) + }, + onUpdateMyMarketingConsents = { request -> + requests += request + dataResponse(UserMarketingConsentDto(sms = false, email = true, push = false)) + }, + ), + ) + + val loaded = runBlocking { repository.getMyMarketingConsents() } + val updated = runBlocking { repository.updateMyMarketingConsents(sms = false, email = null, push = null) } + + assertEquals(UserMarketingConsent(sms = true, email = true, push = false), loaded) + assertEquals( + listOf(UserUpdateMarketingConsentRequestDto(sms = false, email = null, push = null)), + requests, + ) + assertEquals(UserMarketingConsent(sms = false, email = true, push = false), updated) + } +} diff --git a/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/SettingUserApiServiceFake.kt b/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/SettingUserApiServiceFake.kt new file mode 100644 index 0000000000..d0508e9fc5 --- /dev/null +++ b/feature/setting/data/src/test/kotlin/com/afternote/feature/setting/data/SettingUserApiServiceFake.kt @@ -0,0 +1,115 @@ +package com.afternote.feature.setting.data + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.network.dto.DeletePushTokenRequestDto +import com.afternote.core.network.dto.PushTokenDto +import com.afternote.core.network.dto.ReceiverDetailDto +import com.afternote.core.network.dto.ReceiverListDto +import com.afternote.core.network.dto.RegisterPushTokenRequestDto +import com.afternote.core.network.dto.SocialAccountLinkRequestDto +import com.afternote.core.network.dto.UserConnectedAccountDto +import com.afternote.core.network.dto.UserCreateReceiverDto +import com.afternote.core.network.dto.UserCreateReceiverRequestDto +import com.afternote.core.network.dto.UserDto +import com.afternote.core.network.dto.UserMarketingConsentDto +import com.afternote.core.network.dto.UserPatchReceiverDto +import com.afternote.core.network.dto.UserPatchReceiverRequestDto +import com.afternote.core.network.dto.UserPushSettingDto +import com.afternote.core.network.dto.UserUpdateMarketingConsentRequestDto +import com.afternote.core.network.dto.UserUpdateProfileRequestDto +import com.afternote.core.network.dto.UserUpdatePushSettingRequestDto +import com.afternote.core.network.dto.UserUpdateReceiverMessageRequestDto +import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionDto +import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionUpdateRequestDto +import com.afternote.core.network.model.BaseResponse +import com.afternote.core.network.service.UserApiService + +/** + * 설정 저장소가 실제로 부르는 엔드포인트만 열어 둔 [UserApiService] fake. + * + * 나머지는 `TODO` 로 닫아 둔다 — 이 모듈 구현이 수신자·프로필 엔드포인트를 부르기 시작하면 + * 조용히 통과하지 않고 그 자리에서 터진다. + */ +internal class SettingUserApiServiceFake( + private val onDeleteAccount: suspend () -> BaseResponse = { TODO("이 테스트 미사용") }, + private val onGetConnectedAccounts: suspend () -> BaseResponse = { TODO("이 테스트 미사용") }, + private val onLinkConnectedAccount: suspend (String, SocialAccountLinkRequestDto) -> BaseResponse = + { _, _ -> + TODO("이 테스트 미사용") + }, + private val onUnlinkConnectedAccount: suspend (String) -> BaseResponse = { TODO("이 테스트 미사용") }, + private val onGetMyPushSettings: suspend () -> BaseResponse = { TODO("이 테스트 미사용") }, + private val onUpdateMyPushSettings: suspend (UserUpdatePushSettingRequestDto) -> BaseResponse = { + TODO("이 테스트 미사용") + }, + private val onGetMyMarketingConsents: suspend () -> BaseResponse = { TODO("이 테스트 미사용") }, + private val onUpdateMyMarketingConsents: suspend (UserUpdateMarketingConsentRequestDto) -> BaseResponse = { + TODO("이 테스트 미사용") + }, +) : UserApiService { + override suspend fun deleteAccount(): BaseResponse = onDeleteAccount() + + override suspend fun getConnectedAccounts(): BaseResponse = onGetConnectedAccounts() + + override suspend fun linkConnectedAccount( + provider: String, + request: SocialAccountLinkRequestDto, + ): BaseResponse = onLinkConnectedAccount(provider, request) + + override suspend fun unlinkConnectedAccount(provider: String): BaseResponse = + onUnlinkConnectedAccount(provider) + + override suspend fun getMyPushSettings(): BaseResponse = onGetMyPushSettings() + + override suspend fun updateMyPushSettings(request: UserUpdatePushSettingRequestDto): BaseResponse = + onUpdateMyPushSettings(request) + + override suspend fun getMyMarketingConsents(): BaseResponse = onGetMyMarketingConsents() + + override suspend fun updateMyMarketingConsents(request: UserUpdateMarketingConsentRequestDto): BaseResponse = + onUpdateMyMarketingConsents(request) + + override suspend fun getReceivers(): BaseResponse> = TODO("이 테스트 미사용") + + override suspend fun createReceiver(request: UserCreateReceiverRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getReceiverDetail(receiverId: Long): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiver( + receiverId: Long, + request: UserPatchReceiverRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiverMessage( + receiverId: Long, + request: UserUpdateReceiverMessageRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getMyProfile(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyProfile(request: UserUpdateProfileRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun registerPushToken(request: RegisterPushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun deletePushToken(request: DeletePushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getReceiverDeliveryConditions(receiverId: Long): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiverDeliveryConditions( + receiverId: Long, + request: ReceiverDeliveryConditionUpdateRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") +} + +internal class RecordingErrorReporter : ErrorReporter { + val writtenFailures = mutableListOf>>() + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + writtenFailures += throwable to attributes + } +} + +internal fun dataResponse(data: T) = BaseResponse(status = 200, code = 200, data = data) diff --git a/feature/setting/domain/build.gradle.kts b/feature/setting/domain/build.gradle.kts index 06aee4006b..39b64eebda 100644 --- a/feature/setting/domain/build.gradle.kts +++ b/feature/setting/domain/build.gradle.kts @@ -1,19 +1,11 @@ plugins { - id("java-library") - alias(libs.plugins.jetbrains.kotlin.jvm) + id("afternote.jvm.domain") id("afternote.kover") - // 아래 Java 11 고정 때문에 afternote.jvm.library(17) 를 탈 수 없어 ktlint 만 빠져 있었다 (#1419). - id("afternote.jvm.lint") } -java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 -} -kotlin { - // Java 11 고정 때문에 공통 JVM 규약을 쓰지 못하는 기존 예외다. strict 전환 전까지 동일한 - // explicit API warning inventory 를 유지한다. - explicitApiWarning() - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 - } + +dependencies { + // 계정·알림 계약이 노출하는 도메인 모델(UserConnectedAccount·UserPushSetting·UserMarketingConsent). + implementation(projects.core.model) + + testFixturesImplementation(projects.core.model) } diff --git a/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/Notice.kt b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/Notice.kt index b54f1c430d..eb64d64a0d 100644 --- a/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/Notice.kt +++ b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/Notice.kt @@ -2,7 +2,8 @@ package com.afternote.feature.setting.domain import java.time.LocalDate -data class Notice( +/** 공지 목록 화면(`:feature:setting:presentation`)이 소비하는 모듈 밖 계약이라 `public` 이다. */ +public data class Notice( val date: LocalDate, val title: String, val content: String, diff --git a/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/SettingAccountRepository.kt b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/SettingAccountRepository.kt new file mode 100644 index 0000000000..ce5ebe3974 --- /dev/null +++ b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/SettingAccountRepository.kt @@ -0,0 +1,27 @@ +package com.afternote.feature.setting.domain + +import com.afternote.core.model.user.UserConnectedAccount + +/** + * 설정의 계정 계약 (#1429). + * + * 탈퇴와 소셜 계정 연결은 설정 밖에 소비자가 없어 `core:domain` 의 사용자 합본이 아니라 이 모듈이 + * 소유한다. 탈퇴는 서버 삭제가 성공한 뒤 로컬 세션 정리까지를 한 호출로 끝낸다 — 호출처가 토큰 + * 정리를 따로 신경 쓰지 않는다. + */ +public interface SettingAccountRepository { + // 회원 탈퇴 + public suspend fun deleteAccount() + + // 연결된 계정 조회 + public suspend fun getConnectedAccounts(): UserConnectedAccount + + // 소셜 계정 연결 + public suspend fun linkConnectedAccount( + provider: String, + accessToken: String, + ): UserConnectedAccount + + // 소셜 계정 연결 해제 + public suspend fun unlinkConnectedAccount(provider: String): UserConnectedAccount +} diff --git a/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/SettingNotificationRepository.kt b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/SettingNotificationRepository.kt new file mode 100644 index 0000000000..a94d508226 --- /dev/null +++ b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/SettingNotificationRepository.kt @@ -0,0 +1,36 @@ +package com.afternote.feature.setting.domain + +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting + +/** + * 설정의 알림 계약 (#1429). + * + * 푸시 토글과 마케팅 수신 동의는 설정 밖에 소비자가 없어 `core:domain` 의 사용자 합본이 아니라 이 + * 모듈이 소유한다. `null` 인자는 그 항목을 건드리지 않는다는 뜻이고, 서버가 돌려준 전체 설정이 + * 반환값이다. + * + * [updateMyPushSettings] 의 실패는 구현이 `PushSettingFailure` 로 분류해 올린다 — 화면이 네트워크와 + * 서버 오류 안내를 나눠 보여줄 수 있어야 하기 때문이다. + */ +public interface SettingNotificationRepository { + // 푸시 알림 설정 조회 + public suspend fun getMyPushSettings(): UserPushSetting + + // 푸시 알림 설정 수정 + public suspend fun updateMyPushSettings( + timeLetter: Boolean?, + mindRecord: Boolean?, + afterNote: Boolean?, + ): UserPushSetting + + // 마케팅 수신 동의 조회 (문자·이메일·푸시) + public suspend fun getMyMarketingConsents(): UserMarketingConsent + + // 마케팅 수신 동의 수정 + public suspend fun updateMyMarketingConsents( + sms: Boolean?, + email: Boolean?, + push: Boolean?, + ): UserMarketingConsent +} diff --git a/feature/setting/domain/src/testFixtures/kotlin/com/afternote/feature/setting/domain/testing/FakeSettingAccountRepository.kt b/feature/setting/domain/src/testFixtures/kotlin/com/afternote/feature/setting/domain/testing/FakeSettingAccountRepository.kt new file mode 100644 index 0000000000..1b5d5bae83 --- /dev/null +++ b/feature/setting/domain/src/testFixtures/kotlin/com/afternote/feature/setting/domain/testing/FakeSettingAccountRepository.kt @@ -0,0 +1,91 @@ +package com.afternote.feature.setting.domain.testing + +import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.feature.setting.domain.SettingAccountRepository +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger + +/** + * [SettingAccountRepository] fake 정본 (#1429). + * + * 연결 계정 상태만 메모리에 담는다 — 프로필·수신자·알림 상태는 갖지 않는다. 호출은 모두 기록하고, + * 실패 응답이나 경합 게이트처럼 저장소 상태만으로 표현할 수 없는 시나리오는 `onX` 로 갈아끼운다. + * + * 호출 기록 타입 [ConnectedAccountLinkCall] 은 이 fake 가 소유한다 — 합본 `FakeUserRepository` 가 + * 좁은 사실을 들고 있으면 이관이 끝난 뒤에도 소비자가 합본을 계속 import 한다. + */ +class FakeSettingAccountRepository( + @Volatile var connectedAccounts: UserConnectedAccount = DEFAULT_CONNECTED_ACCOUNTS, + var onDeleteAccount: (suspend () -> Unit)? = null, + var onGetConnectedAccounts: (suspend () -> UserConnectedAccount)? = null, + var onLinkConnectedAccount: (suspend (String, String) -> UserConnectedAccount)? = null, + var onUnlinkConnectedAccount: (suspend (String) -> UserConnectedAccount)? = null, +) : SettingAccountRepository { + private val deleteAccountCounter = AtomicInteger() + private val getConnectedAccountsCounter = AtomicInteger() + + val connectedLinkCalls = CopyOnWriteArrayList() + val connectedUnlinkCalls = CopyOnWriteArrayList() + + val deleteAccountCalls: Int get() = deleteAccountCounter.get() + val getConnectedAccountsCalls: Int get() = getConnectedAccountsCounter.get() + + override suspend fun deleteAccount() { + deleteAccountCounter.incrementAndGet() + onDeleteAccount?.invoke() + } + + override suspend fun getConnectedAccounts(): UserConnectedAccount { + getConnectedAccountsCounter.incrementAndGet() + onGetConnectedAccounts?.let { return it() } + return connectedAccounts + } + + override suspend fun linkConnectedAccount( + provider: String, + accessToken: String, + ): UserConnectedAccount { + connectedLinkCalls += ConnectedAccountLinkCall(provider, accessToken) + onLinkConnectedAccount?.let { return it(provider, accessToken) } + connectedAccounts = connectedAccounts.withProvider(provider, connected = true) + return connectedAccounts + } + + override suspend fun unlinkConnectedAccount(provider: String): UserConnectedAccount { + connectedUnlinkCalls += provider + onUnlinkConnectedAccount?.let { return it(provider) } + connectedAccounts = connectedAccounts.withProvider(provider, connected = false) + return connectedAccounts + } + + data class ConnectedAccountLinkCall( + val provider: String, + val accessToken: String, + ) + + companion object { + internal val DEFAULT_CONNECTED_ACCOUNTS = + UserConnectedAccount(true, false, false, false, false, "test@afternote.local", null, null, null, null) + + fun strict(): FakeSettingAccountRepository = + FakeSettingAccountRepository( + onDeleteAccount = { unexpectedCall("SettingAccountRepository.deleteAccount") }, + onGetConnectedAccounts = { unexpectedCall("SettingAccountRepository.getConnectedAccounts") }, + onLinkConnectedAccount = { _, _ -> unexpectedCall("SettingAccountRepository.linkConnectedAccount") }, + onUnlinkConnectedAccount = { unexpectedCall("SettingAccountRepository.unlinkConnectedAccount") }, + ) + } +} + +private fun UserConnectedAccount.withProvider( + provider: String, + connected: Boolean, +): UserConnectedAccount = + when (provider.lowercase()) { + "google" -> copy(google = connected, googleEmail = googleEmail.takeIf { connected }) + "naver" -> copy(naver = connected, naverEmail = naverEmail.takeIf { connected }) + "kakao" -> copy(kakao = connected, kakaoEmail = kakaoEmail.takeIf { connected }) + "apple" -> copy(apple = connected, appleEmail = appleEmail.takeIf { connected }) + "local" -> copy(local = connected, localEmail = localEmail.takeIf { connected }) + else -> error("지원하지 않는 연결 계정 provider: $provider") + } diff --git a/feature/setting/domain/src/testFixtures/kotlin/com/afternote/feature/setting/domain/testing/FakeSettingNotificationRepository.kt b/feature/setting/domain/src/testFixtures/kotlin/com/afternote/feature/setting/domain/testing/FakeSettingNotificationRepository.kt new file mode 100644 index 0000000000..afcd0642bc --- /dev/null +++ b/feature/setting/domain/src/testFixtures/kotlin/com/afternote/feature/setting/domain/testing/FakeSettingNotificationRepository.kt @@ -0,0 +1,110 @@ +package com.afternote.feature.setting.domain.testing + +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.feature.setting.domain.SettingNotificationRepository +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger + +/** + * [SettingNotificationRepository] fake 정본 (#1429). + * + * 푸시 토글과 마케팅 동의 상태만 메모리에 담는다. 기본 동작은 `null` 이 아닌 인자만 덮어써 서버의 + * 부분 갱신 의미를 그대로 흉내 내고, 실패 분류나 매달린 응답은 `onX` 로 갈아끼운다. + * + * 호출 기록 타입 [PushUpdateCall]·[MarketingConsentUpdateCall] 은 이 fake 가 소유한다. + */ +class FakeSettingNotificationRepository( + @Volatile var pushSetting: UserPushSetting = DEFAULT_PUSH_SETTING, + @Volatile var marketingConsent: UserMarketingConsent = DEFAULT_MARKETING_CONSENT, + var onGetMyPushSettings: (suspend () -> UserPushSetting)? = null, + var onUpdateMyPushSettings: (suspend (Boolean?, Boolean?, Boolean?) -> UserPushSetting)? = null, + var onGetMyMarketingConsents: (suspend () -> UserMarketingConsent)? = null, + var onUpdateMyMarketingConsents: (suspend (Boolean?, Boolean?, Boolean?) -> UserMarketingConsent)? = null, +) : SettingNotificationRepository { + private val getPushSettingsCounter = AtomicInteger() + private val getMarketingConsentsCounter = AtomicInteger() + + val pushUpdateCalls = CopyOnWriteArrayList() + val marketingConsentUpdateCalls = CopyOnWriteArrayList() + + val getMyPushSettingsCalls: Int get() = getPushSettingsCounter.get() + val getMyMarketingConsentsCalls: Int get() = getMarketingConsentsCounter.get() + val pushSettingUpdates: List> + get() = pushUpdateCalls.map { Triple(it.timeLetter, it.mindRecord, it.afterNote) } + val marketingConsentUpdates: List> + get() = marketingConsentUpdateCalls.map { Triple(it.sms, it.email, it.push) } + + override suspend fun getMyPushSettings(): UserPushSetting { + getPushSettingsCounter.incrementAndGet() + onGetMyPushSettings?.let { return it() } + return pushSetting + } + + override suspend fun updateMyPushSettings( + timeLetter: Boolean?, + mindRecord: Boolean?, + afterNote: Boolean?, + ): UserPushSetting { + pushUpdateCalls += PushUpdateCall(timeLetter, mindRecord, afterNote) + onUpdateMyPushSettings?.let { return it(timeLetter, mindRecord, afterNote) } + pushSetting = + UserPushSetting( + timeLetter = timeLetter ?: pushSetting.timeLetter, + mindRecord = mindRecord ?: pushSetting.mindRecord, + afterNote = afterNote ?: pushSetting.afterNote, + ) + return pushSetting + } + + override suspend fun getMyMarketingConsents(): UserMarketingConsent { + getMarketingConsentsCounter.incrementAndGet() + onGetMyMarketingConsents?.let { return it() } + return marketingConsent + } + + override suspend fun updateMyMarketingConsents( + sms: Boolean?, + email: Boolean?, + push: Boolean?, + ): UserMarketingConsent { + marketingConsentUpdateCalls += MarketingConsentUpdateCall(sms, email, push) + onUpdateMyMarketingConsents?.let { return it(sms, email, push) } + marketingConsent = + UserMarketingConsent( + sms = sms ?: marketingConsent.sms, + email = email ?: marketingConsent.email, + push = push ?: marketingConsent.push, + ) + return marketingConsent + } + + data class PushUpdateCall( + val timeLetter: Boolean?, + val mindRecord: Boolean?, + val afterNote: Boolean?, + ) + + data class MarketingConsentUpdateCall( + val sms: Boolean?, + val email: Boolean?, + val push: Boolean?, + ) + + companion object { + internal val DEFAULT_PUSH_SETTING = UserPushSetting(true, true, true) + internal val DEFAULT_MARKETING_CONSENT = UserMarketingConsent(true, true, false) + + fun strict(): FakeSettingNotificationRepository = + FakeSettingNotificationRepository( + onGetMyPushSettings = { unexpectedCall("SettingNotificationRepository.getMyPushSettings") }, + onUpdateMyPushSettings = { _, _, _ -> + unexpectedCall("SettingNotificationRepository.updateMyPushSettings") + }, + onGetMyMarketingConsents = { unexpectedCall("SettingNotificationRepository.getMyMarketingConsents") }, + onUpdateMyMarketingConsents = { _, _, _ -> + unexpectedCall("SettingNotificationRepository.updateMyMarketingConsents") + }, + ) + } +} diff --git a/feature/setting/domain/src/testFixtures/kotlin/com/afternote/feature/setting/domain/testing/UnexpectedCall.kt b/feature/setting/domain/src/testFixtures/kotlin/com/afternote/feature/setting/domain/testing/UnexpectedCall.kt new file mode 100644 index 0000000000..f678a0278e --- /dev/null +++ b/feature/setting/domain/src/testFixtures/kotlin/com/afternote/feature/setting/domain/testing/UnexpectedCall.kt @@ -0,0 +1,4 @@ +package com.afternote.feature.setting.domain.testing + +/** strict fake에서 시나리오가 열지 않은 계약 호출을 즉시 실패시킨다. */ +fun unexpectedCall(method: String): Nothing = error("$method 는 이 시나리오에서 호출되면 안 됨") diff --git a/feature/setting/presentation/build.gradle.kts b/feature/setting/presentation/build.gradle.kts index b0e7153824..4eb2c8004c 100644 --- a/feature/setting/presentation/build.gradle.kts +++ b/feature/setting/presentation/build.gradle.kts @@ -10,6 +10,7 @@ val googleWebClientId = socialLoginKey("GOOGLE_WEB_CLIENT_ID") android { namespace = "com.afternote.feature.setting.presentation" + resourcePrefix = "setting_" experimentalProperties["android.experimental.enableScreenshotTest"] = true testOptions.unitTests.isIncludeAndroidResources = true @@ -41,6 +42,7 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(testFixtures(projects.core.domain)) + testImplementation(testFixtures(projects.feature.setting.domain)) testImplementation(projects.core.network) testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContent.kt new file mode 100644 index 0000000000..1a51323a4d --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContent.kt @@ -0,0 +1,73 @@ +package com.afternote.feature.setting.presentation.account + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.setting.presentation.R +import com.afternote.feature.setting.presentation.account.component.SocialAccountRow + +/** + * 연결된 계정 화면의 상태 없는 본문. + * + * ViewModel·소셜 인증 연동은 [ConnectedAccountsScreen] 이 들고, 이 함수는 넘겨받은 상태만 그린다. + */ +@Composable +internal fun ConnectedAccountsContent( + uiState: ConnectedAccountsUiState, + snackbarHostState: SnackbarHostState, + onBack: () -> Unit, + onToggle: (provider: String, enabled: Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + containerColor = Color.Transparent, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + DetailTopBar( + title = "연결된 계정", + onBackClick = onBack, + ) + }, + ) { paddingValues -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Text( + text = stringResource(R.string.setting_sns_login_section_title), + style = AfternoteDesign.typography.bodyLargeR, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.setting_sns_login_section_desc), + style = AfternoteDesign.typography.bodySmallR, + ) + Spacer(modifier = Modifier.height(24.dp)) + + uiState.accounts.forEach { account -> + SocialAccountRow( + account = account, + onToggle = { enabled -> onToggle(account.provider, enabled) }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ConnectedAccountsScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt similarity index 56% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ConnectedAccountsScreen.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt index 2be78a8fd5..6e6d6295f4 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ConnectedAccountsScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt @@ -1,41 +1,25 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.account import android.app.Activity -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.credentials.CredentialManager import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.ui.findActivity -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.BuildConfig import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.component.SocialAccountRow -import com.afternote.feature.setting.presentation.social.KakaoAuthResult -import com.afternote.feature.setting.presentation.social.requestGoogleIdToken -import com.afternote.feature.setting.presentation.social.requestKakaoAccessToken -import com.afternote.feature.setting.presentation.social.toKakaoAuthResult -import com.afternote.feature.setting.presentation.viewmodel.ConnectedAccountsEvent -import com.afternote.feature.setting.presentation.viewmodel.ConnectedAccountsViewModel +import com.afternote.feature.setting.presentation.account.social.KakaoAuthResult +import com.afternote.feature.setting.presentation.account.social.requestGoogleIdToken +import com.afternote.feature.setting.presentation.account.social.requestKakaoAccessToken +import com.afternote.feature.setting.presentation.account.social.toKakaoAuthResult @Composable fun ConnectedAccountsScreen( @@ -47,8 +31,8 @@ fun ConnectedAccountsScreen( val context = LocalContext.current val credentialManager = remember(context) { CredentialManager.create(context) } val snackbarHostState = remember { SnackbarHostState() } - val kakaoAccountLinkFailedMessage = stringResource(R.string.kakao_account_link_failed) - val googleAccountLinkFailedMessage = stringResource(R.string.google_account_link_failed) + val kakaoAccountLinkFailedMessage = stringResource(R.string.setting_kakao_account_link_failed) + val googleAccountLinkFailedMessage = stringResource(R.string.setting_google_account_link_failed) LaunchedEffect(Unit) { viewModel.events.collect { event -> @@ -100,48 +84,11 @@ fun ConnectedAccountsScreen( } } - Scaffold( + ConnectedAccountsContent( + uiState = uiState, + snackbarHostState = snackbarHostState, + onBack = onBack, + onToggle = viewModel::onToggle, modifier = modifier, - containerColor = Color.Transparent, - snackbarHost = { SnackbarHost(snackbarHostState) }, - topBar = { - DetailTopBar( - title = "연결된 계정", - onBackClick = onBack, - ) - }, - ) { paddingValues -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 20.dp, vertical = 16.dp), - ) { - Text( - text = stringResource(R.string.sns_login_section_title), - style = AfternoteDesign.typography.bodyLargeR, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.sns_login_section_desc), - style = AfternoteDesign.typography.bodySmallR, - ) - Spacer(modifier = Modifier.height(24.dp)) - - uiState.accounts.forEach { account -> - SocialAccountRow( - account = account, - onToggle = { enabled -> viewModel.onToggle(account.provider, enabled) }, - ) - Spacer(modifier = Modifier.height(8.dp)) - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun ConnectedAccountScreenPrev() { - ConnectedAccountsScreen(onBack = {}) + ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ConnectedAccountsUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt similarity index 86% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ConnectedAccountsUiState.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt index 56afd456bd..7fb4ecbf53 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ConnectedAccountsUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.account data class ConnectedAccountsUiState( val isLoading: Boolean = false, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ConnectedAccountsViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt similarity index 78% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ConnectedAccountsViewModel.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt index d2fcca828b..9bb3992114 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ConnectedAccountsViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt @@ -1,9 +1,9 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.account import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.afternote.core.domain.repository.UserRepository import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.feature.setting.domain.SettingAccountRepository import com.afternote.feature.setting.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel @@ -18,7 +18,7 @@ import javax.inject.Inject class ConnectedAccountsViewModel @Inject constructor( - private val userRepository: UserRepository, + private val accountRepository: SettingAccountRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(ConnectedAccountsUiState(isLoading = true)) val uiState = _uiState.asStateFlow() @@ -32,7 +32,7 @@ class ConnectedAccountsViewModel private fun loadConnectedAccounts() { viewModelScope.launch { - runCatching { userRepository.getConnectedAccounts() } + runCatching { accountRepository.getConnectedAccounts() } .onSuccess { accounts -> _uiState.update { it.copy(isLoading = false, accounts = accounts.toStateList()) } }.onFailure { @@ -61,7 +61,7 @@ class ConnectedAccountsViewModel accessToken: String, ) { viewModelScope.launch { - runCatching { userRepository.linkConnectedAccount(provider, accessToken) } + runCatching { accountRepository.linkConnectedAccount(provider, accessToken) } .onSuccess { accounts -> _uiState.update { it.copy(accounts = accounts.toStateList()) } } .onFailure { _uiState.update { it.copy(errorMessage = "계정 연결에 실패했습니다.") } } } @@ -69,7 +69,7 @@ class ConnectedAccountsViewModel private fun unlink(provider: String) { viewModelScope.launch { - runCatching { userRepository.unlinkConnectedAccount(provider) } + runCatching { accountRepository.unlinkConnectedAccount(provider) } .onSuccess { accounts -> _uiState.update { it.copy(accounts = accounts.toStateList()) } } .onFailure { _uiState.update { it.copy(errorMessage = "계정 연결 해제에 실패했습니다.") } } } @@ -79,32 +79,32 @@ class ConnectedAccountsViewModel listOf( SocialAccountState( provider = "naver", - iconRes = R.drawable.ic_naver_logo, - labelRes = R.string.login_with_naver, + iconRes = R.drawable.setting_ic_naver_logo, + labelRes = R.string.setting_login_with_naver, isConnected = naver, isLinkable = false, email = naverEmail, ), SocialAccountState( provider = "google", - iconRes = R.drawable.ic_google_logo, - labelRes = R.string.login_with_google, + iconRes = R.drawable.setting_ic_google_logo, + labelRes = R.string.setting_login_with_google, isConnected = google, isLinkable = true, email = googleEmail, ), SocialAccountState( provider = "kakao", - iconRes = R.drawable.ic_kakao_logo, - labelRes = R.string.login_with_kakao, + iconRes = R.drawable.setting_ic_kakao_logo, + labelRes = R.string.setting_login_with_kakao, isConnected = kakao, isLinkable = true, email = kakaoEmail, ), SocialAccountState( provider = "apple", - iconRes = R.drawable.ic_apple_logo, - labelRes = R.string.login_with_apple, + iconRes = R.drawable.setting_ic_apple_logo, + labelRes = R.string.setting_login_with_apple, isConnected = apple, isLinkable = false, email = appleEmail, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/SocialAccountState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/SocialAccountState.kt similarity index 83% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/SocialAccountState.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/SocialAccountState.kt index 3f31314e95..9e8ce5c6d7 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/SocialAccountState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/SocialAccountState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.account import androidx.annotation.DrawableRes import androidx.annotation.StringRes diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SocialAccountRow.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/component/SocialAccountRow.kt similarity index 83% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SocialAccountRow.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/component/SocialAccountRow.kt index a48b87e643..b78a718d53 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SocialAccountRow.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/component/SocialAccountRow.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.account.component import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column @@ -17,11 +17,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.viewmodel.SocialAccountState +import com.afternote.feature.setting.presentation.account.SocialAccountState @Composable fun SocialAccountRow( @@ -51,7 +50,7 @@ fun SocialAccountRow( ) Spacer(modifier = Modifier.padding(top = 10.dp)) Text( - text = account.email ?: stringResource(R.string.status_not_connected), + text = account.email ?: stringResource(R.string.setting_status_not_connected), style = AfternoteDesign.typography.captionLargeR, color = if (account.isConnected) { @@ -82,19 +81,3 @@ fun SocialAccountRow( ) } } - -@Preview(showBackground = true) -@Composable -private fun RowPrev() { - SocialAccountRow( - account = - SocialAccountState( - provider = "google", - iconRes = R.drawable.ic_google_logo, - labelRes = R.string.login_with_google, - isConnected = true, - email = "example@gmail.com", - ), - onToggle = {}, - ) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/GoogleLinkHelper.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/social/GoogleLinkHelper.kt similarity index 97% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/GoogleLinkHelper.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/social/GoogleLinkHelper.kt index 915a42c411..71ee2aeaa2 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/GoogleLinkHelper.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/social/GoogleLinkHelper.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.social +package com.afternote.feature.setting.presentation.account.social import android.content.Context import androidx.credentials.CredentialManager diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/KakaoAuthResult.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/social/KakaoAuthResult.kt similarity index 90% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/KakaoAuthResult.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/social/KakaoAuthResult.kt index 0300e3ae5a..bb2453e0f7 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/KakaoAuthResult.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/social/KakaoAuthResult.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.social +package com.afternote.feature.setting.presentation.account.social import com.afternote.core.domain.error.CoreAuthFailure diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/KakaoLinkHelper.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/social/KakaoLinkHelper.kt similarity index 97% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/KakaoLinkHelper.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/social/KakaoLinkHelper.kt index b348f527f6..7a43358266 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/social/KakaoLinkHelper.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/social/KakaoLinkHelper.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.social +package com.afternote.feature.setting.presentation.account.social import android.app.Activity import com.afternote.core.domain.error.CoreAuthFailure diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupContent.kt new file mode 100644 index 0000000000..3e3a1c969e --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupContent.kt @@ -0,0 +1,67 @@ +package com.afternote.feature.setting.presentation.applock + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.setting.presentation.shared.component.InsertPasswordContent + +/** + * 앱 잠금 비밀번호 설정 화면의 상태 없는 본문. + * + * ViewModel·부수효과는 [AppLockSetupScreen] 이 들고, 이 함수는 넘겨받은 상태만 그린다. + */ +@Composable +internal fun AppLockSetupContent( + step: PinSetupStep, + passwordLength: Int, + onDigitClick: (String) -> Unit, + onDeleteClick: () -> Unit, + onConfirmClick: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val titleText = + when (step) { + PinSetupStep.ENTER_NEW -> "비밀번호를 입력해 주세요." + PinSetupStep.CONFIRM_NEW -> "비밀번호를 다시 한 번 입력해 주세요." + PinSetupStep.ENTER_CURRENT -> "변경할 비밀번호를 입력해 주세요." + } + + Scaffold( + topBar = { + DetailTopBar( + title = "앱 잠금 비밀번호 설정", + onBackClick = onBack, + ) + }, + modifier = modifier, + containerColor = Color.Transparent, + ) { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(40.dp)) + + InsertPasswordContent( + titleText = titleText, + passwordLength = passwordLength, + onDigitClick = onDigitClick, + onDeleteClick = onDeleteClick, + onConfirmClick = onConfirmClick, + ) + } + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupScreen.kt new file mode 100644 index 0000000000..0dc39dabf1 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupScreen.kt @@ -0,0 +1,38 @@ +package com.afternote.feature.setting.presentation.applock + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun AppLockSetupScreen( + step: PinSetupStep, + onPinComplete: (pin: String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: AppLockSetupViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val currentOnPinComplete by rememberUpdatedState(onPinComplete) + + LaunchedEffect(uiState.isComplete) { + if (uiState.isComplete) { + currentOnPinComplete(uiState.pin) + viewModel.resetPin() + } + } + + AppLockSetupContent( + step = step, + passwordLength = uiState.pin.length, + onDigitClick = viewModel::onDigitInput, + onDeleteClick = viewModel::onDelete, + onConfirmClick = { currentOnPinComplete(uiState.pin) }, + onBack = onBack, + modifier = modifier, + ) +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/AppLockSetupUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupUiState.kt similarity index 61% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/AppLockSetupUiState.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupUiState.kt index d0a4b9dfa6..99c2815557 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/AppLockSetupUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.applock data class AppLockSetupUiState( val pin: String = "", diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/AppLockSetupViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupViewModel.kt similarity index 94% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/AppLockSetupViewModel.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupViewModel.kt index d14a9e16b7..c7866b7f13 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/AppLockSetupViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupViewModel.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.applock import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/PinSetupStep.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/PinSetupStep.kt similarity index 80% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/PinSetupStep.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/PinSetupStep.kt index 8f8135df44..6bd979a35c 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/PinSetupStep.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/applock/PinSetupStep.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.applock enum class PinSetupStep { ENTER_NEW, // "비밀번호를 입력해 주세요." diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/DatePickerBottomSheet.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/DatePickerBottomSheet.kt deleted file mode 100644 index 17f6071272..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/DatePickerBottomSheet.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.afternote.feature.setting.presentation.component - -import androidx.compose.runtime.Composable -import com.afternote.core.ui.calendar.BottomSheetCalendar -import java.time.LocalDate - -@Composable -fun DatePickerBottomSheet( - onDismiss: () -> Unit, - onDateSelect: (LocalDate) -> Unit, - initialDate: LocalDate? = null, -) { - BottomSheetCalendar( - onDismiss = onDismiss, - onDateSelect = onDateSelect, - title = "전달 날짜", - initialDate = initialDate ?: LocalDate.now(), - ) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroup.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroup.kt deleted file mode 100644 index 0c9ef796c3..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroup.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.afternote.feature.setting.presentation.component - -import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy -import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -@Composable -fun RadioGroup( - items: List, - selectedIndex: Int, - onSelectIndex: (Int) -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier, - verticalArrangement = spacedBy(12.dp), - ) { - items.forEachIndexed { index, item -> - RadioGroupCard( - item = item, - selected = index == selectedIndex, - onClick = { onSelectIndex(index) }, - ) - } - } -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt deleted file mode 100644 index 6400865cf9..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.afternote.feature.setting.presentation.component - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.border -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.button.CustomRadioButton -import com.afternote.core.ui.theme.AfternoteDesign - -@Composable -fun RadioGroupCard( - item: RadioGroupItem, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val borderColor by animateColorAsState( - targetValue = if (selected) AfternoteDesign.colors.gray9 else AfternoteDesign.colors.gray3, - animationSpec = tween(durationMillis = 150), - label = "RadioGroupCardBorderColor", - ) - - Row( - modifier = - modifier - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .border( - width = 1.dp, - color = borderColor, - shape = RoundedCornerShape(6.dp), - ).selectable( - selected = selected, - onClick = onClick, - role = Role.RadioButton, - indication = ripple(), - interactionSource = remember { MutableInteractionSource() }, - ).padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CustomRadioButton( - selected = selected, - // onClick null → Row에서 처리 - ) - Spacer(modifier = Modifier.padding(start = 16.dp)) - Column(verticalArrangement = spacedBy(4.dp)) { - Text( - text = item.title, - color = - if (selected) { - AfternoteDesign.colors.gray9 - } else { - AfternoteDesign.colors.gray8 - }, - style = - if (selected) { - AfternoteDesign.typography.primaryButton - } else { - AfternoteDesign.typography.bodyBase - }, - ) - Text( - text = item.description, - color = AfternoteDesign.colors.gray6, - style = AfternoteDesign.typography.bodySmallR, - ) - } - } -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SettingSourceItem.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SettingSourceItem.kt deleted file mode 100644 index 103a90c79a..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SettingSourceItem.kt +++ /dev/null @@ -1,124 +0,0 @@ -package com.afternote.feature.setting.presentation.component - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.icon.RightArrowIcon -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.setting.presentation.R - -data class SettingSourceItemData( - val name: String, - val date: String, - val iconBgColor: Color, -) - -@Composable -fun SettingSourceItem( - item: SettingSourceItemData, - icon: Painter, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Card( - onClick = onClick, - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = AfternoteDesign.colors.white), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 80.dp) - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background(item.iconBgColor), - contentAlignment = Alignment.Center, - ) { - Image( - painter = icon, - contentDescription = null, - modifier = Modifier.size(28.dp), - ) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = item.name, - style = AfternoteDesign.typography.textField.copy(fontWeight = FontWeight.Medium), - ) - Text( - text = item.date, - style = - AfternoteDesign.typography.footnoteCaption.copy( - color = AfternoteDesign.colors.gray5, - ), - ) - } - - RightArrowIcon( - modifier = Modifier.size(24.dp), - tint = AfternoteDesign.colors.gray9, - ) - } - } -} - -@Preview(showBackground = true) -@Composable -private fun SettingSourceItemPreview() { - AfternoteTheme { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - SettingSourceItem( - item = - SettingSourceItemData( - name = "갤러리", - date = "최종 작성일 2025.11.26.", - iconBgColor = Color(0xFFF5F5F5), - ), - icon = painterResource(R.drawable.ic_default_profile), - onClick = {}, - ) - } - } -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/DeliveryConditionScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt similarity index 74% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/DeliveryConditionScreen.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt index 7c636c2ec9..50dc3c6696 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/DeliveryConditionScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.delivery import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -25,27 +25,21 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.model.delivery.DeliveryConditionType import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.component.RadioGroup -import com.afternote.feature.setting.presentation.component.RadioGroupItem -import com.afternote.feature.setting.presentation.viewmodel.DeliveryConditionError -import com.afternote.feature.setting.presentation.viewmodel.DeliveryConditionUiState -import com.afternote.feature.setting.presentation.viewmodel.DeliveryConditionViewModel +import com.afternote.feature.setting.presentation.delivery.component.RadioGroup +import com.afternote.feature.setting.presentation.delivery.component.RadioGroupItem @Composable -fun DeliveryConditionScreen( +internal fun DeliveryConditionScreen( onBack: () -> Unit, onSaveSuccess: () -> Unit, onLastGreetingEditClick: () -> Unit, - viewModel: DeliveryConditionViewModel = hiltViewModel(), + viewModel: DeliveryConditionViewModel, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val currentOnSaveSuccess by rememberUpdatedState(onSaveSuccess) @@ -74,25 +68,25 @@ private fun DeliveryConditionContent( val processingMethodItems = listOf( RadioGroupItem( - title = stringResource(R.string.processing_method_inactive_title), - description = stringResource(R.string.processing_method_inactive_description), + title = stringResource(R.string.setting_processing_method_inactive_title), + description = stringResource(R.string.setting_processing_method_inactive_description), ), RadioGroupItem( - title = stringResource(R.string.processing_method_recipient_request_title), - description = stringResource(R.string.processing_method_recipient_request_description), + title = stringResource(R.string.setting_processing_method_recipient_request_title), + description = stringResource(R.string.setting_processing_method_recipient_request_description), ), ) Scaffold( topBar = { DetailTopBar( - title = stringResource(R.string.settings_recipient_after_delivery), + title = stringResource(R.string.setting_recipient_after_delivery), onBackClick = onBack, actions = { val isSaveEnabled = uiState.isInitialized && !uiState.isLoading && !uiState.isSaving TextButton(onClick = onSave, enabled = isSaveEnabled) { Text( - text = stringResource(R.string.delivery_condition_save), + text = stringResource(R.string.setting_delivery_condition_save), style = AfternoteDesign.typography.bodySmallB, color = if (isSaveEnabled) { @@ -117,8 +111,8 @@ private fun DeliveryConditionContent( ) { val errorMessageRes = when (uiState.error) { - DeliveryConditionError.LOAD_FAILED -> R.string.delivery_condition_load_error - DeliveryConditionError.SAVE_FAILED -> R.string.delivery_condition_save_error + DeliveryConditionError.LOAD_FAILED -> R.string.setting_delivery_condition_load_error + DeliveryConditionError.SAVE_FAILED -> R.string.setting_delivery_condition_save_error null -> null } if (errorMessageRes != null) { @@ -129,7 +123,7 @@ private fun DeliveryConditionContent( ) Spacer(Modifier.height(16.dp)) } - SectionLabel(text = stringResource(R.string.processing_method_section_title)) + SectionLabel(text = stringResource(R.string.setting_processing_method_section_title)) Spacer(Modifier.height(28.dp)) RadioGroup( items = processingMethodItems, @@ -146,13 +140,13 @@ private fun DeliveryConditionContent( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.last_greeting_edit_section_title), + text = stringResource(R.string.setting_last_greeting_edit_section_title), style = AfternoteDesign.typography.bodyBase, color = AfternoteDesign.colors.gray9, ) Spacer(Modifier.weight(1f)) Image( - painter = painterResource(R.drawable.ic_right_arrow), + painter = painterResource(R.drawable.setting_ic_right_arrow), contentDescription = null, modifier = Modifier.size(24.dp), ) @@ -164,12 +158,12 @@ private fun DeliveryConditionContent( ) Spacer(Modifier.height(24.dp)) Text( - text = stringResource(R.string.after_delivery_notice_condition), + text = stringResource(R.string.setting_after_delivery_notice_condition), style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray9, ) Text( - text = stringResource(R.string.after_delivery_notice_safety), + text = stringResource(R.string.setting_after_delivery_notice_safety), style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray9, ) @@ -189,17 +183,3 @@ private fun SectionLabel( color = AfternoteDesign.colors.gray9, ) } - -@Preview(showBackground = true) -@Composable -private fun DeliveryConditionContentPreview() { - AfternoteTheme { - DeliveryConditionContent( - uiState = DeliveryConditionUiState(), - onBack = {}, - onConditionTypeSelect = {}, - onLastGreetingEditClick = {}, - onSave = {}, - ) - } -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/DeliveryConditionUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt similarity index 92% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/DeliveryConditionUiState.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt index 4d3b5e37e7..723942ec0e 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/DeliveryConditionUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.delivery import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.DeliveryConditionType diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt similarity index 82% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt index 13ec93769f..2be5e9e7c7 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt @@ -1,17 +1,18 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.delivery -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.navigation.toRoute import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.model.delivery.ConditionState import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.DeliveryConditionType import com.afternote.core.model.delivery.DeliveryContentType import com.afternote.core.model.delivery.InactivityPeriod import com.afternote.feature.setting.presentation.navigation.SettingRoute +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -20,16 +21,19 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject -@HiltViewModel -class DeliveryConditionViewModel - @Inject +/** + * 대상 수신자 [SettingRoute.AfterDeliveryRoute] 는 assisted 로 받는다 — Nav3 entry 에는 Nav2 의 + * `savedStateHandle.toRoute()` 자동 채움이 없다 (#1695). + */ +@HiltViewModel(assistedFactory = DeliveryConditionViewModel.Factory::class) +internal class DeliveryConditionViewModel + @AssistedInject constructor( - savedStateHandle: SavedStateHandle, - private val userRepository: UserRepository, + @Assisted route: SettingRoute.AfterDeliveryRoute, + private val receiverRepository: UserReceiverRepository, ) : ViewModel() { - private val receiverId = savedStateHandle.toRoute().receiverId + private val receiverId = route.receiverId private val _uiState = MutableStateFlow(DeliveryConditionUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -44,7 +48,7 @@ class DeliveryConditionViewModel private fun loadDeliveryConditions() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - runCatchingCancellable { userRepository.getReceiverDeliveryConditions(receiverId) } + runCatchingCancellable { receiverRepository.getReceiverDeliveryConditions(receiverId) } .onSuccess { response -> val representative = response.conditions.firstOrNull { @@ -109,7 +113,7 @@ class DeliveryConditionViewModel viewModelScope.launch { _uiState.update { it.copy(isSaving = true) } runCatchingCancellable { - userRepository.updateReceiverDeliveryConditions(receiverId, updatedConditions) + receiverRepository.updateReceiverDeliveryConditions(receiverId, updatedConditions) }.onSuccess { response -> _uiState.update { it.copy(isSaving = false, conditions = response.conditions) } _saveSuccess.send(Unit) @@ -129,4 +133,9 @@ class DeliveryConditionViewModel gracePeriodStartedAt = null, fulfilledAt = null, ) + + @AssistedFactory + interface Factory { + fun create(route: SettingRoute.AfterDeliveryRoute): DeliveryConditionViewModel + } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/component/RadioGroup.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/component/RadioGroup.kt new file mode 100644 index 0000000000..ad3c42c70c --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/component/RadioGroup.kt @@ -0,0 +1,31 @@ +package com.afternote.feature.setting.presentation.delivery.component + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.button.AfternoteRadioGroup + +@Composable +internal fun RadioGroup( + items: List, + selectedIndex: Int, + onSelectIndex: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val indexedItems = items.withIndex().toList() + + AfternoteRadioGroup( + options = indexedItems, + selectedValue = indexedItems.getOrNull(selectedIndex), + onSelect = { onSelectIndex(it.index) }, + modifier = modifier, + itemContentPadding = PaddingValues(16.dp), + itemDecoration = { _, selected -> radioGroupCardDecoration(selected) }, + ) { indexedItem, selected -> + RadioGroupCardContent( + item = indexedItem.value, + selected = selected, + ) + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/component/RadioGroupCard.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/component/RadioGroupCard.kt new file mode 100644 index 0000000000..cd2184dd53 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/component/RadioGroupCard.kt @@ -0,0 +1,63 @@ +package com.afternote.feature.setting.presentation.delivery.component + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteDesign + +@Composable +internal fun radioGroupCardDecoration(selected: Boolean): Modifier { + val borderColor by animateColorAsState( + targetValue = if (selected) AfternoteDesign.colors.gray9 else AfternoteDesign.colors.gray3, + animationSpec = tween(durationMillis = 150), + label = "RadioGroupCardBorderColor", + ) + + return Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .border( + width = 1.dp, + color = borderColor, + shape = RoundedCornerShape(6.dp), + ) +} + +@Composable +internal fun RadioGroupCardContent( + item: RadioGroupItem, + selected: Boolean, +) { + Column(verticalArrangement = spacedBy(4.dp)) { + Text( + text = item.title, + color = + if (selected) { + AfternoteDesign.colors.gray9 + } else { + AfternoteDesign.colors.gray8 + }, + style = + if (selected) { + AfternoteDesign.typography.primaryButton + } else { + AfternoteDesign.typography.bodyBase + }, + ) + Text( + text = item.description, + color = AfternoteDesign.colors.gray6, + style = AfternoteDesign.typography.bodySmallR, + ) + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupItem.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/component/RadioGroupItem.kt similarity index 53% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupItem.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/component/RadioGroupItem.kt index 5515d9abd7..56e21921e4 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupItem.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/component/RadioGroupItem.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.delivery.component data class RadioGroupItem( val title: String, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/SettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt similarity index 62% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/SettingScreen.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt index 54a29eed20..0474ef8b65 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/SettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.home import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -7,18 +7,20 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState 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.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -28,15 +30,14 @@ import com.afternote.core.ui.popup.Popup import com.afternote.core.ui.popup.PopupType import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.component.SettingMenuItem -import com.afternote.feature.setting.presentation.component.SettingProfile -import com.afternote.feature.setting.presentation.component.SettingSection -import com.afternote.feature.setting.presentation.viewmodel.SettingUiState -import com.afternote.feature.setting.presentation.viewmodel.SettingViewModel +import com.afternote.feature.setting.presentation.home.component.SettingProfile +import com.afternote.feature.setting.presentation.home.component.SettingSection +import com.afternote.feature.setting.presentation.shared.component.SettingMenuItem +import kotlinx.coroutines.launch // 설정-메인 @Composable -fun SettingScreen( +internal fun SettingScreen( onBackClick: () -> Unit, onLogoutSuccess: () -> Unit, onProfileEditClick: () -> Unit, @@ -45,19 +46,18 @@ fun SettingScreen( onNotificationClick: () -> Unit, onRecipientListClick: () -> Unit, onRecipientRegisterClick: () -> Unit, - onAfterDeliveryClick: () -> Unit, + onDeliveryConditionsClick: () -> Unit, onPasskeyClick: () -> Unit, onAppLockClick: () -> Unit, - onFaqClick: () -> Unit, - onInquiryClick: () -> Unit, onNoticeClick: () -> Unit, - onTermsClick: () -> Unit, - onPrivacyClick: () -> Unit, - onServiceInfoClick: () -> Unit, onWithdrawGuideClick: () -> Unit, modifier: Modifier = Modifier, viewModel: SettingViewModel = hiltViewModel(), ) { + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val unavailableMessage = stringResource(R.string.setting_menu_unavailable) + val acknowledgeLabel = stringResource(R.string.setting_menu_acknowledge) val uiState by viewModel.uiState.collectAsStateWithLifecycle() val logoutCompleted by viewModel.logoutCompleted.collectAsStateWithLifecycle() val currentOnLogoutSuccess by rememberUpdatedState(onLogoutSuccess) @@ -77,9 +77,10 @@ fun SettingScreen( } Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { DetailTopBar( - title = stringResource(R.string.settings_title), + title = stringResource(R.string.setting_title), onBackClick = onBackClick, ) }, @@ -89,21 +90,22 @@ fun SettingScreen( SettingScreenContent( uiState = uiState, onLogoutClick = viewModel::logout, + onUnavailableClick = { + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar(unavailableMessage, actionLabel = acknowledgeLabel) + } + }, onProfileEditClick = onProfileEditClick, onPasswordChangeClick = onPasswordChangeClick, onLinkedAccountClick = onLinkedAccountClick, onNotificationClick = onNotificationClick, onRecipientListClick = onRecipientListClick, onRecipientRegisterClick = onRecipientRegisterClick, - onAfterDeliveryClick = onAfterDeliveryClick, + onDeliveryConditionsClick = onDeliveryConditionsClick, onPasskeyClick = onPasskeyClick, onAppLockClick = onAppLockClick, - onFaqClick = onFaqClick, - onInquiryClick = onInquiryClick, onNoticeClick = onNoticeClick, - onTermsClick = onTermsClick, - onPrivacyClick = onPrivacyClick, - onServiceInfoClick = onServiceInfoClick, onWithdrawGuideClick = onWithdrawGuideClick, modifier = Modifier.padding(innerPadding), ) @@ -114,21 +116,17 @@ fun SettingScreen( private fun SettingScreenContent( uiState: SettingUiState, onLogoutClick: () -> Unit, + onUnavailableClick: () -> Unit, onProfileEditClick: () -> Unit, onPasswordChangeClick: () -> Unit, onLinkedAccountClick: () -> Unit, onNotificationClick: () -> Unit, onRecipientListClick: () -> Unit, onRecipientRegisterClick: () -> Unit, - onAfterDeliveryClick: () -> Unit, + onDeliveryConditionsClick: () -> Unit, onPasskeyClick: () -> Unit, onAppLockClick: () -> Unit, - onFaqClick: () -> Unit, - onInquiryClick: () -> Unit, onNoticeClick: () -> Unit, - onTermsClick: () -> Unit, - onPrivacyClick: () -> Unit, - onServiceInfoClick: () -> Unit, onWithdrawGuideClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -137,9 +135,9 @@ private fun SettingScreenContent( if (showLogoutDialog) { Popup( type = PopupType.Variant2, - message = stringResource(R.string.settings_logout_dialog_message), - confirmText = stringResource(R.string.settings_logout_dialog_confirm), - dismissText = stringResource(R.string.settings_logout_dialog_cancel), + message = stringResource(R.string.setting_logout_dialog_message), + confirmText = stringResource(R.string.setting_logout_dialog_confirm), + dismissText = stringResource(R.string.setting_logout_dialog_cancel), onConfirm = { showLogoutDialog = false onLogoutClick() @@ -168,89 +166,90 @@ private fun SettingScreenContent( SettingProfile( name = state.name, email = state.email, + onInquiryClick = onUnavailableClick, onNoticeClick = onNoticeClick, onRecipientListClick = onRecipientListClick, ) - SettingSection(title = stringResource(R.string.settings_section_account)) { + SettingSection(title = stringResource(R.string.setting_section_account)) { SettingMenuItem( - label = stringResource(R.string.settings_account_profile_edit), + label = stringResource(R.string.setting_account_profile_edit), onClick = onProfileEditClick, ) SettingMenuItem( - label = stringResource(R.string.settings_account_password_change), + label = stringResource(R.string.setting_account_password_change), onClick = onPasswordChangeClick, ) SettingMenuItem( - label = stringResource(R.string.settings_account_linked_account), + label = stringResource(R.string.setting_account_linked_account), onClick = onLinkedAccountClick, ) SettingMenuItem( - label = stringResource(R.string.settings_account_notification), + label = stringResource(R.string.setting_account_notification), onClick = onNotificationClick, ) } - SettingSection(title = stringResource(R.string.settings_section_recipient)) { + SettingSection(title = stringResource(R.string.setting_section_recipient)) { SettingMenuItem( - label = stringResource(R.string.settings_recipient_list), + label = stringResource(R.string.setting_recipient_list), onClick = onRecipientListClick, ) SettingMenuItem( - label = stringResource(R.string.settings_recipient_register), + label = stringResource(R.string.setting_recipient_register), onClick = onRecipientRegisterClick, ) SettingMenuItem( - label = stringResource(R.string.settings_recipient_after_delivery), - onClick = onAfterDeliveryClick, + label = stringResource(R.string.setting_recipient_after_delivery), + onClick = onDeliveryConditionsClick, ) } - SettingSection(title = stringResource(R.string.settings_section_security)) { + SettingSection(title = stringResource(R.string.setting_section_security)) { SettingMenuItem( - label = stringResource(R.string.settings_security_passkey), + label = stringResource(R.string.setting_security_passkey), onClick = onPasskeyClick, ) SettingMenuItem( - label = stringResource(R.string.settings_security_app_lock), + label = stringResource(R.string.setting_security_app_lock), onClick = onAppLockClick, ) } - SettingSection(title = stringResource(R.string.settings_section_support)) { + SettingSection(title = stringResource(R.string.setting_section_support)) { SettingMenuItem( - label = stringResource(R.string.settings_support_faq), - onClick = onFaqClick, + label = stringResource(R.string.setting_support_faq), + onClick = onUnavailableClick, ) SettingMenuItem( - label = stringResource(R.string.settings_support_inquiry), - onClick = onInquiryClick, + label = stringResource(R.string.setting_support_inquiry), + onClick = onUnavailableClick, ) SettingMenuItem( - label = stringResource(R.string.settings_support_notice), + label = stringResource(R.string.setting_support_notice), onClick = onNoticeClick, ) SettingMenuItem( - label = stringResource(R.string.settings_support_terms), - onClick = onTermsClick, + label = stringResource(R.string.setting_support_terms), + onClick = onUnavailableClick, ) SettingMenuItem( - label = stringResource(R.string.settings_support_privacy), - onClick = onPrivacyClick, + label = stringResource(R.string.setting_support_privacy), + onClick = onUnavailableClick, ) SettingMenuItem( - label = stringResource(R.string.settings_support_service_info), - onClick = onServiceInfoClick, + label = stringResource(R.string.setting_support_service_info), + onClick = onUnavailableClick, ) } SettingMenuItem( - label = stringResource(R.string.settings_logout), + label = stringResource(R.string.setting_logout), onClick = { showLogoutDialog = true }, modifier = Modifier.padding(top = 8.dp), ) SettingMenuItem( - label = stringResource(R.string.settings_account_withdraw), + label = stringResource(R.string.setting_account_withdraw), onClick = onWithdrawGuideClick, ) } @@ -262,40 +261,10 @@ private fun SettingScreenContent( horizontalAlignment = Alignment.CenterHorizontally, ) { SettingMenuItem( - label = stringResource(R.string.settings_logout), + label = stringResource(R.string.setting_logout), onClick = { showLogoutDialog = true }, ) } } } } - -@Preview(showBackground = true) -@Composable -private fun SettingScreenPrev() { - Scaffold( - topBar = { DetailTopBar(title = "설정") }, - ) { innerPadding -> - SettingScreenContent( - uiState = SettingUiState.Success(name = "박서연", email = "afternote@email.com"), - onLogoutClick = {}, - onProfileEditClick = {}, - onPasswordChangeClick = {}, - onLinkedAccountClick = {}, - onNotificationClick = {}, - onRecipientListClick = {}, - onRecipientRegisterClick = {}, - onAfterDeliveryClick = {}, - onPasskeyClick = {}, - onAppLockClick = {}, - onFaqClick = {}, - onInquiryClick = {}, - onNoticeClick = {}, - onTermsClick = {}, - onPrivacyClick = {}, - onServiceInfoClick = {}, - onWithdrawGuideClick = {}, - modifier = Modifier.padding(innerPadding), - ) - } -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/SettingViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt similarity index 88% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/SettingViewModel.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt index 6f02de7830..d051696f53 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/SettingViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt @@ -1,9 +1,10 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.MyProfileRepository import com.afternote.core.domain.repository.auth.AuthRepository +import com.afternote.feature.setting.domain.SettingAccountRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow @@ -45,7 +46,8 @@ class SettingViewModel @Inject constructor( private val authRepository: AuthRepository, - private val userRepository: UserRepository, + private val myProfileRepository: MyProfileRepository, + private val accountRepository: SettingAccountRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingUiState.Loading) val uiState = _uiState.asStateFlow() @@ -61,7 +63,7 @@ class SettingViewModel private fun loadProfile() { viewModelScope.launch { - runCatching { userRepository.getMyProfile() } + runCatching { myProfileRepository.getMyProfile() } .onSuccess { profile -> _uiState.value = SettingUiState.Success( @@ -91,7 +93,7 @@ class SettingViewModel viewModelScope.launch { try { - userRepository.deleteAccount() + accountRepository.deleteAccount() _withdrawUiState.value = WithdrawUiState.Success } catch (exception: CancellationException) { throw exception diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/WithdrawConfirmScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmContent.kt similarity index 67% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/WithdrawConfirmScreen.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmContent.kt index 49771d46b7..0f86baac05 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/WithdrawConfirmScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmContent.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.home import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.rememberTextFieldState @@ -23,61 +24,32 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.AfternoteTextField import com.afternote.core.ui.button.AfternoteButton import com.afternote.core.ui.button.AfternoteButtonType -import com.afternote.core.ui.popup.Popup -import com.afternote.core.ui.popup.PopupType import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.viewmodel.SettingUiState -import com.afternote.feature.setting.presentation.viewmodel.SettingViewModel -import com.afternote.feature.setting.presentation.viewmodel.WithdrawUiState private const val WITHDRAW_CONFIRM_TEXT = "탈퇴하겠습니다" +/** + * 회원 탈퇴 확인 화면의 상태 없는 본문. + * + * ViewModel·탈퇴 결과 팝업은 [WithdrawConfirmScreen] 이 들고, 이 함수는 넘겨받은 상태만 그린다. + * 확인 문구 입력은 화면 지역 상태라 이 함수 안에 남고, 문구가 일치할 때만 [onWithdrawClick] 을 부른다. + */ @Composable -fun WithdrawConfirmScreen( - uiState: SettingUiState, +internal fun WithdrawConfirmContent( + userName: String, + userEmail: String, onBackClick: () -> Unit, - onWithdrawSuccess: () -> Unit, - viewModel: SettingViewModel, + onWithdrawClick: () -> Unit, + isLoading: Boolean, modifier: Modifier = Modifier, ) { - val userName = (uiState as? SettingUiState.Success)?.name.orEmpty() - val userEmail = (uiState as? SettingUiState.Success)?.email.orEmpty() val textState = rememberTextFieldState() var showError by remember { mutableStateOf(false) } - val withdrawUiState by viewModel.withdrawUiState.collectAsStateWithLifecycle() - - when (withdrawUiState) { - WithdrawUiState.Success -> { - Popup( - type = PopupType.Default, - message = stringResource(R.string.withdraw_complete_message), - confirmText = stringResource(R.string.withdraw_complete_button), - onConfirm = onWithdrawSuccess, - onDismiss = onWithdrawSuccess, - ) - } - - WithdrawUiState.Error -> { - Popup( - type = PopupType.Variant2, - message = stringResource(R.string.withdraw_failed_message), - confirmText = stringResource(R.string.withdraw_retry_button), - dismissText = stringResource(R.string.withdraw_close_button), - onConfirm = viewModel::deleteAccount, - onDismiss = viewModel::dismissWithdrawError, - ) - } - - WithdrawUiState.Idle, - WithdrawUiState.Loading, - -> {} - } Scaffold( topBar = { @@ -98,7 +70,7 @@ fun WithdrawConfirmScreen( ) { Spacer(Modifier.height(20.dp)) Text( - text = stringResource(R.string.withdraw_confirm_top_message), + text = stringResource(R.string.setting_withdraw_confirm_top_message), style = AfternoteDesign.typography.bodyBase, color = AfternoteDesign.colors.gray9, ) @@ -107,13 +79,13 @@ fun WithdrawConfirmScreen( Spacer(Modifier.height(24.dp)) AfternoteTextField( state = textState, - placeholder = stringResource(R.string.withdraw_confirm_placeholder), + placeholder = stringResource(R.string.setting_withdraw_confirm_placeholder), modifier = Modifier.fillMaxWidth(), ) if (showError) { Spacer(Modifier.height(6.dp)) Text( - text = stringResource(R.string.withdraw_confirm_error), + text = stringResource(R.string.setting_withdraw_confirm_error), style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.error, ) @@ -124,12 +96,12 @@ fun WithdrawConfirmScreen( onWithdrawClick = { if (textState.text.toString() == WITHDRAW_CONFIRM_TEXT) { showError = false - viewModel.deleteAccount() + onWithdrawClick() } else { showError = true } }, - isLoading = withdrawUiState == WithdrawUiState.Loading, + isLoading = isLoading, ) Spacer(Modifier.height(24.dp)) } @@ -145,14 +117,14 @@ private fun WithdrawAccountSection( modifier = Modifier .fillMaxWidth() - .height(82.dp) + .heightIn(min = 82.dp) .clip(RoundedCornerShape(12.dp)) .background(color = AfternoteDesign.colors.gray2) .padding(start = 16.dp), ) { Spacer(modifier = Modifier.height(12.dp)) Text( - text = stringResource(R.string.withdraw_account_section), + text = stringResource(R.string.setting_withdraw_account_section), style = AfternoteDesign.typography.textField, color = AfternoteDesign.colors.gray9, ) @@ -184,13 +156,13 @@ private fun WithdrawConfirmBottomButtons( verticalArrangement = Arrangement.spacedBy(8.dp), ) { AfternoteButton( - text = stringResource(R.string.withdraw_confirm_prev_button), + text = stringResource(R.string.setting_withdraw_confirm_prev_button), onClick = onBackClick, type = AfternoteButtonType.Plain, modifier = Modifier.fillMaxWidth(), ) AfternoteButton( - text = stringResource(R.string.withdraw_confirm_button), + text = stringResource(R.string.setting_withdraw_confirm_button), onClick = onWithdrawClick, type = AfternoteButtonType.Default, isLoading = isLoading, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt new file mode 100644 index 0000000000..73560e2fb2 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt @@ -0,0 +1,59 @@ +package com.afternote.feature.setting.presentation.home + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.popup.Popup +import com.afternote.core.ui.popup.PopupType +import com.afternote.feature.setting.presentation.R + +@Composable +fun WithdrawConfirmScreen( + uiState: SettingUiState, + onBackClick: () -> Unit, + onWithdrawSuccess: () -> Unit, + viewModel: SettingViewModel, + modifier: Modifier = Modifier, +) { + val userName = (uiState as? SettingUiState.Success)?.name.orEmpty() + val userEmail = (uiState as? SettingUiState.Success)?.email.orEmpty() + val withdrawUiState by viewModel.withdrawUiState.collectAsStateWithLifecycle() + + when (withdrawUiState) { + WithdrawUiState.Success -> { + Popup( + type = PopupType.Default, + message = stringResource(R.string.setting_withdraw_complete_message), + confirmText = stringResource(R.string.setting_withdraw_complete_button), + onConfirm = onWithdrawSuccess, + onDismiss = onWithdrawSuccess, + ) + } + + WithdrawUiState.Error -> { + Popup( + type = PopupType.Variant2, + message = stringResource(R.string.setting_withdraw_failed_message), + confirmText = stringResource(R.string.setting_withdraw_retry_button), + dismissText = stringResource(R.string.setting_withdraw_close_button), + onConfirm = viewModel::deleteAccount, + onDismiss = viewModel::dismissWithdrawError, + ) + } + + WithdrawUiState.Idle, + WithdrawUiState.Loading, + -> {} + } + + WithdrawConfirmContent( + userName = userName, + userEmail = userEmail, + onBackClick = onBackClick, + onWithdrawClick = viewModel::deleteAccount, + isLoading = withdrawUiState == WithdrawUiState.Loading, + modifier = modifier, + ) +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/WithdrawGuideScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawGuideScreen.kt similarity index 83% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/WithdrawGuideScreen.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawGuideScreen.kt index 6b5eb4b122..3916385035 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/WithdrawGuideScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawGuideScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.home import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -24,7 +24,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.button.AfternoteButton import com.afternote.core.ui.button.AfternoteButtonType @@ -33,7 +32,6 @@ import com.afternote.core.ui.button.CheckboxState import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.viewmodel.SettingUiState // 설정 - 회원 탈퇴 안내 @Composable @@ -92,7 +90,7 @@ fun WithdrawGuideScreen( @Composable private fun TopMessageSection() { Text( - text = stringResource(R.string.withdraw_top_message), + text = stringResource(R.string.setting_withdraw_top_message), style = AfternoteDesign.typography.bodyBase, color = AfternoteDesign.colors.gray9, ) @@ -114,7 +112,7 @@ private fun AccountSection( ) { Spacer(modifier = Modifier.height(12.dp)) Text( - text = stringResource(R.string.withdraw_account_section), + text = stringResource(R.string.setting_withdraw_account_section), style = AfternoteDesign.typography.textField, color = AfternoteDesign.colors.gray9, ) @@ -142,28 +140,28 @@ private fun DeleteItemsSection() { HorizontalDivider(color = AfternoteDesign.colors.gray3) Spacer(modifier = Modifier.padding(top = 10.dp)) Text( - text = stringResource(R.string.withdraw_delete_section), + text = stringResource(R.string.setting_withdraw_delete_section), style = AfternoteDesign.typography.textField, color = AfternoteDesign.colors.gray9, ) Spacer(Modifier.height(8.dp)) Text( - text = stringResource(R.string.withdraw_delete_description), + text = stringResource(R.string.setting_withdraw_delete_description), style = AfternoteDesign.typography.bodySmallR, color = AfternoteDesign.colors.gray8, ) Spacer(Modifier.height(8.dp)) listOf( - R.string.withdraw_delete_item_1, - R.string.withdraw_delete_item_2, - R.string.withdraw_delete_item_3, - R.string.withdraw_delete_item_4, + R.string.setting_withdraw_delete_item_1, + R.string.setting_withdraw_delete_item_2, + R.string.setting_withdraw_delete_item_3, + R.string.setting_withdraw_delete_item_4, ).forEach { resId -> BulletItem(text = stringResource(resId)) } Spacer(Modifier.height(8.dp)) Text( - text = stringResource(R.string.withdraw_delete_warning), + text = stringResource(R.string.setting_withdraw_delete_warning), style = AfternoteDesign.typography.bodySmallR, color = AfternoteDesign.colors.gray8, ) @@ -176,16 +174,16 @@ private fun CheckListSection() { HorizontalDivider(color = AfternoteDesign.colors.gray3) Spacer(modifier = Modifier.padding(top = 10.dp)) Text( - text = stringResource(R.string.withdraw_check_section), + text = stringResource(R.string.setting_withdraw_check_section), style = AfternoteDesign.typography.textField, color = AfternoteDesign.colors.gray9, ) Spacer(Modifier.height(8.dp)) listOf( - R.string.withdraw_check_item_1, - R.string.withdraw_check_item_2, - R.string.withdraw_check_item_3, - R.string.withdraw_check_item_4, + R.string.setting_withdraw_check_item_1, + R.string.setting_withdraw_check_item_2, + R.string.setting_withdraw_check_item_3, + R.string.setting_withdraw_check_item_4, ).forEach { resId -> BulletItem(text = stringResource(resId)) } @@ -198,13 +196,13 @@ private fun PrivacySection() { HorizontalDivider(color = AfternoteDesign.colors.gray3) Spacer(modifier = Modifier.padding(top = 10.dp)) Text( - text = stringResource(R.string.withdraw_privacy_section), + text = stringResource(R.string.setting_withdraw_privacy_section), style = AfternoteDesign.typography.textField, color = AfternoteDesign.colors.gray9, ) Spacer(Modifier.height(8.dp)) Text( - text = stringResource(R.string.withdraw_privacy_description), + text = stringResource(R.string.setting_withdraw_privacy_description), style = AfternoteDesign.typography.bodySmallR, color = AfternoteDesign.colors.gray8, ) @@ -227,7 +225,7 @@ private fun AgreeCheckbox( ) Spacer(modifier = Modifier.padding(12.dp)) Text( - text = stringResource(R.string.withdraw_agree_checkbox), + text = stringResource(R.string.setting_withdraw_agree_checkbox), style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray9, ) @@ -245,13 +243,13 @@ private fun BottomButtons( verticalArrangement = Arrangement.spacedBy(8.dp), ) { AfternoteButton( - text = stringResource(R.string.withdraw_cancel_button), + text = stringResource(R.string.setting_withdraw_cancel_button), onClick = onCancelClick, type = AfternoteButtonType.Default, modifier = Modifier.fillMaxWidth(), ) AfternoteButton( - text = stringResource(R.string.withdraw_confirm_button), + text = stringResource(R.string.setting_withdraw_confirm_button), onClick = onConfirmClick, type = if (agreed) AfternoteButtonType.Default else AfternoteButtonType.Un, modifier = Modifier.fillMaxWidth(), @@ -280,14 +278,3 @@ private fun BulletItem(text: String) { ) } } - -@Preview(showBackground = true) -@Composable -private fun WithdrawGuideScreenPrev() { - WithdrawGuideScreen( - uiState = SettingUiState.Success(name = "홍길동", email = "hong@example.com"), - onBackClick = {}, - onCancelClick = {}, - onConfirmClick = {}, - ) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SettingProfile.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/component/SettingProfile.kt similarity index 74% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SettingProfile.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/component/SettingProfile.kt index df4cf8c436..1dc7c14288 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SettingProfile.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/component/SettingProfile.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.home.component import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -16,15 +16,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.setting.presentation.R @Composable -fun SettingProfile( +internal fun SettingProfile( name: String, email: String, + onInquiryClick: () -> Unit, onNoticeClick: () -> Unit, onRecipientListClick: () -> Unit, modifier: Modifier = Modifier, @@ -44,7 +44,7 @@ fun SettingProfile( verticalAlignment = Alignment.CenterVertically, ) { Image( - painterResource(R.drawable.ic_default_profile), + painterResource(R.drawable.setting_ic_default_profile), contentDescription = "기본 이미지", modifier = Modifier.size(60.dp), ) @@ -64,7 +64,7 @@ fun SettingProfile( Spacer(modifier = Modifier.weight(1f)) Image( - painterResource(R.drawable.ic_right_arrow), + painterResource(R.drawable.setting_ic_right_arrow), contentDescription = "화살표", modifier = Modifier.size(24.dp), ) @@ -77,9 +77,9 @@ fun SettingProfile( ) { val items = listOf( - stringResource(R.string.settings_support_inquiry) to null, - stringResource(R.string.settings_support_notice) to onNoticeClick, - stringResource(R.string.settings_recipient_list) to onRecipientListClick, + stringResource(R.string.setting_support_inquiry) to onInquiryClick, + stringResource(R.string.setting_support_notice) to onNoticeClick, + stringResource(R.string.setting_recipient_list) to onRecipientListClick, ) items.forEach { (label, onClick) -> @@ -87,17 +87,11 @@ fun SettingProfile( modifier = Modifier .weight(1f) - .then( - if (onClick != null) { - Modifier.clickable(onClick = onClick) - } else { - Modifier - }, - ), + .clickable(onClick = onClick), horizontalAlignment = Alignment.CenterHorizontally, ) { Image( - painterResource(R.drawable.ic_list), + painterResource(R.drawable.setting_ic_list), contentDescription = label, modifier = Modifier.size(40.dp), ) @@ -111,14 +105,3 @@ fun SettingProfile( } } } - -@Preview(showBackground = true) -@Composable -private fun SettingProfilePrev() { - SettingProfile( - name = "박서연", - email = "afternote@email.com", - onNoticeClick = {}, - onRecipientListClick = {}, - ) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SettingSection.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/component/SettingSection.kt similarity index 92% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SettingSection.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/component/SettingSection.kt index b89186598d..0121b64dc7 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SettingSection.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/component/SettingSection.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.home.component import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingExternalActions.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingExternalActions.kt new file mode 100644 index 0000000000..2f8f80c54c --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingExternalActions.kt @@ -0,0 +1,13 @@ +package com.afternote.feature.setting.presentation.navigation + +/** + * 설정 로컬 스택이 **스스로 갈 수 없는 곳**만 앱 셸에 남긴 이동. + * + * 둘 다 인증 상태가 바뀐 뒤라 루트 스택을 통째로 비워야 하는데, 다른 피처의 백스택은 로컬 스택이 + * 건드리지 않는다 — 셸이 온보딩으로 갈아 끼운다. + */ +public interface SettingExternalActions { + public fun onLogoutSuccess() + + public fun onWithdrawSuccess() +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingLocalNavActions.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingLocalNavActions.kt new file mode 100644 index 0000000000..c06870a8d7 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingLocalNavActions.kt @@ -0,0 +1,91 @@ +package com.afternote.feature.setting.presentation.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.core.ui.navigation.popOrExit + +/** + * 설정 화면 콜백을 로컬 백스택 조작으로 잇는다. + * + * 컴포저블이 아니라 평범한 클래스다 — 백스택 «모양» 을 컴포지션 없이 재려는 것이다(#1601). + */ +internal class SettingLocalNavActions( + private val backStack: NavBackStack, + private val navigationCallbacks: FeatureNavigationCallbacks, + private val externalActions: SettingExternalActions, +) : SettingNavActions { + override fun popBack(): Unit = backStack.popOrExit(navigationCallbacks) + + override fun onLogoutSuccess(): Unit = externalActions.onLogoutSuccess() + + override fun onWithdrawSuccess(): Unit = externalActions.onWithdrawSuccess() + + override fun onWithdrawGuideClick() { + backStack.add(SettingRoute.WithdrawGuideRoute) + } + + override fun onWithdrawConfirmClick() { + backStack.add(SettingRoute.WithdrawConfirmRoute) + } + + override fun onProfileEditClick() { + backStack.add(SettingRoute.ProfileEditRoute) + } + + override fun onPasswordChangeClick() { + backStack.add(SettingRoute.PasswordChangeRoute) + } + + override fun onLinkedAccountClick() { + backStack.add(SettingRoute.LinkedAccountRoute) + } + + override fun onNotificationClick() { + backStack.add(SettingRoute.NotificationRoute) + } + + override fun onPushNotificationClick() { + backStack.add(SettingRoute.PushNotificationRoute) + } + + override fun onRecipientListClick() { + backStack.add(SettingRoute.RecipientListRoute()) + } + + override fun onDeliveryConditionsClick() { + backStack.add(SettingRoute.RecipientListRoute(selectForDeliveryConditions = true)) + } + + override fun onRecipientRegisterClick() { + backStack.add(SettingRoute.RecipientRegisterRoute) + } + + override fun onRecipientEditClick(receiverId: Long) { + backStack.add(SettingRoute.RecipientEditRoute(receiverId)) + } + + override fun onDeliveryConditionsRecipientSelected(receiverId: Long) { + backStack.add(SettingRoute.AfterDeliveryRoute(receiverId)) + } + + override fun onPasskeyClick() { + backStack.add(SettingRoute.PasskeyRoute) + } + + override fun onPasskeyRegisterClick() { + backStack.add(SettingRoute.PasskeyMakingRoute) + } + + override fun onPasswordAuthClick() { + backStack.add(SettingRoute.PasskeyPasswordRoute) + } + + override fun onAppLockClick() { + backStack.add(SettingRoute.AppLockSetupRoute) + } + + override fun onNoticeClick() { + backStack.add(SettingRoute.NoticeRoute) + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt index 44bef11258..c36d150267 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt @@ -1,67 +1,50 @@ package com.afternote.feature.setting.presentation.navigation -interface SettingNavActions { - fun onSettingBack() +/** + * 설정 화면 콜백이 부르는 이동 명령. + * + * 화면마다 있던 `onBack` 은 전부 [popBack] 하나다 — 로컬 스택에서는 어느 화면이든 + * back 이 «한 칸 내리기» 라 이름을 나눌 이유가 없다. 인증 상태를 바꾸는 둘([onLogoutSuccess]· + * [onWithdrawSuccess])은 루트 스택을 비워야 해서 [SettingExternalActions] 로 나간다. + */ +internal interface SettingNavActions { + fun popBack() fun onLogoutSuccess() - fun onNavigateToWithdrawGuide() - - fun onNavigateToWithdrawConfirm() - - fun onWithdrawGuideBack() - - fun onWithdrawConfirmBack() - fun onWithdrawSuccess() - fun onNavigateToProfileEdit() - - fun onProfileEditBack() - - fun onNavigateToLinkedAccount() - - fun onLinkedAccountBack() - - fun onNavigateToNotification() - - fun onNotificationBack() - - fun onNavigateToRecipientList() - - fun onNavigateToRecipientListForDeliveryConditions() - - fun onRecipientListBack() + fun onWithdrawGuideClick() - fun onNavigateToRecipientRegister() + fun onWithdrawConfirmClick() - fun onRecipientRegisterBack() + fun onProfileEditClick() - fun onNavigateToRecipientEdit(receiverId: Long) + fun onPasswordChangeClick() - fun onRecipientEditBack() + fun onLinkedAccountClick() - fun onNavigateToAfterDelivery(receiverId: Long) + fun onNotificationClick() - fun onAfterDeliveryBack() + fun onPushNotificationClick() - fun onNavigateToPasskey() + fun onRecipientListClick() - fun onPasskeyBack() + fun onDeliveryConditionsClick() - fun onNavigateToPasskeyMaking() + fun onRecipientRegisterClick() - fun onPasskeyMakingBack() + fun onRecipientEditClick(receiverId: Long) - fun onNavigateToPasskeyPassword() + fun onDeliveryConditionsRecipientSelected(receiverId: Long) - fun onPasskeyPasswordBack() + fun onPasskeyClick() - fun onNavigateToAppLock() + fun onPasskeyRegisterClick() - fun onAppLockBack() + fun onPasswordAuthClick() - fun onNavigateToNotice() + fun onAppLockClick() - fun onNoticeBack() + fun onNoticeClick() } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavGraph.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavGraph.kt deleted file mode 100644 index c47d60b995..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavGraph.kt +++ /dev/null @@ -1,191 +0,0 @@ -package com.afternote.feature.setting.presentation.navigation - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.navigation -import androidx.navigation.toRoute -import com.afternote.core.ui.Route -import com.afternote.feature.setting.presentation.component.PinSetupStep -import com.afternote.feature.setting.presentation.screen.AppLockSetupScreen -import com.afternote.feature.setting.presentation.screen.ConnectedAccountsScreen -import com.afternote.feature.setting.presentation.screen.DeliveryConditionScreen -import com.afternote.feature.setting.presentation.screen.NoticeListScreen -import com.afternote.feature.setting.presentation.screen.PassKeyListScreen -import com.afternote.feature.setting.presentation.screen.PassKeyMakingScreen -import com.afternote.feature.setting.presentation.screen.PassKeyPasswordScreen -import com.afternote.feature.setting.presentation.screen.PassKeyScreen -import com.afternote.feature.setting.presentation.screen.ProfileEditScreen -import com.afternote.feature.setting.presentation.screen.PushNotificationScreen -import com.afternote.feature.setting.presentation.screen.ReceiverEditScreen -import com.afternote.feature.setting.presentation.screen.ReceiverListScreen -import com.afternote.feature.setting.presentation.screen.ReceiverRegisterScreen -import com.afternote.feature.setting.presentation.screen.SettingScreen -import com.afternote.feature.setting.presentation.screen.WithdrawConfirmScreen -import com.afternote.feature.setting.presentation.screen.WithdrawGuideScreen -import com.afternote.feature.setting.presentation.viewmodel.PassKeyViewModel -import com.afternote.feature.setting.presentation.viewmodel.ReceiverListViewModel -import com.afternote.feature.setting.presentation.viewmodel.SettingViewModel - -fun NavGraphBuilder.settingNavGraph( - graphScopedParentEntry: () -> NavBackStackEntry, - actions: SettingNavActions, -) { - navigation(startDestination = SettingRoute.SettingHomeRoute) { - composable { - SettingScreen( - onBackClick = actions::onSettingBack, - onLogoutSuccess = actions::onLogoutSuccess, - onProfileEditClick = actions::onNavigateToProfileEdit, - onPasswordChangeClick = {}, - onLinkedAccountClick = actions::onNavigateToLinkedAccount, - onNotificationClick = actions::onNavigateToNotification, - onRecipientListClick = actions::onNavigateToRecipientList, - onRecipientRegisterClick = actions::onNavigateToRecipientRegister, - onAfterDeliveryClick = { - actions.onNavigateToRecipientListForDeliveryConditions() - }, - onPasskeyClick = actions::onNavigateToPasskey, - onAppLockClick = actions::onNavigateToAppLock, - onFaqClick = {}, - onInquiryClick = {}, - onNoticeClick = actions::onNavigateToNotice, - onTermsClick = {}, - onPrivacyClick = {}, - onServiceInfoClick = {}, - onWithdrawGuideClick = actions::onNavigateToWithdrawGuide, - ) - } - - composable { - val parentEntry = remember(it) { graphScopedParentEntry() } - val viewModel: SettingViewModel = hiltViewModel(parentEntry) - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - WithdrawGuideScreen( - uiState = uiState, - onBackClick = actions::onWithdrawGuideBack, - onCancelClick = actions::onWithdrawGuideBack, - onConfirmClick = actions::onNavigateToWithdrawConfirm, - ) - } - - composable { - val parentEntry = remember(it) { graphScopedParentEntry() } - val viewModel: SettingViewModel = hiltViewModel(parentEntry) - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - WithdrawConfirmScreen( - uiState = uiState, - onBackClick = actions::onWithdrawConfirmBack, - onWithdrawSuccess = actions::onWithdrawSuccess, - viewModel = viewModel, - ) - } - - composable { - ProfileEditScreen( - onBackClick = actions::onProfileEditBack, - onWithdrawGuideClick = actions::onNavigateToWithdrawGuide, - ) - } - - composable { - ConnectedAccountsScreen( - onBack = actions::onLinkedAccountBack, - ) - } - - composable { - PushNotificationScreen( - onBack = actions::onNotificationBack, - ) - } - - composable { - val route = it.toRoute() - val viewModel: ReceiverListViewModel = hiltViewModel() - val receivers by viewModel.receivers.collectAsStateWithLifecycle() - ReceiverListScreen( - receivers = receivers, - onBackClick = actions::onRecipientListBack, - onConfirmClick = { receiver -> - if (route.selectForDeliveryConditions) { - actions.onNavigateToAfterDelivery(receiver.receiverId) - } else { - actions.onRecipientListBack() - } - }, - ) - } - - composable { - ReceiverRegisterScreen( - onBackClick = actions::onRecipientRegisterBack, - onRegisterSuccess = actions::onRecipientRegisterBack, - ) - } - - composable { - ReceiverEditScreen( - onBackClick = actions::onRecipientEditBack, - onEditSuccess = actions::onRecipientEditBack, - ) - } - - composable { - val route = it.toRoute() - DeliveryConditionScreen( - onBack = actions::onAfterDeliveryBack, - onSaveSuccess = actions::onAfterDeliveryBack, - onLastGreetingEditClick = { - actions.onNavigateToRecipientEdit(route.receiverId) - }, - ) - } - - composable { - val viewModel: PassKeyViewModel = hiltViewModel() - val isPasskeyRegistered by viewModel.isPasskeyRegistered.collectAsStateWithLifecycle() - if (isPasskeyRegistered == true) { - PassKeyListScreen(onBackClick = actions::onPasskeyBack) - } else if (isPasskeyRegistered == false) { - PassKeyScreen( - onBackClick = actions::onPasskeyBack, - onRegisterClick = actions::onNavigateToPasskeyMaking, - ) - } - } - - composable { - PassKeyMakingScreen( - onBackClick = actions::onPasskeyMakingBack, - onPasswordAuthClick = actions::onNavigateToPasskeyPassword, - ) - } - - composable { - PassKeyPasswordScreen( - onPinComplete = { actions.onPasskeyPasswordBack() }, - onBack = actions::onPasskeyPasswordBack, - ) - } - - composable { - AppLockSetupScreen( - step = PinSetupStep.ENTER_NEW, - onPinComplete = { actions.onAppLockBack() }, - onBack = actions::onAppLockBack, - ) - } - - composable { - NoticeListScreen( - notices = emptyList(), - onBackClick = actions::onNoticeBack, - ) - } - } -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt new file mode 100644 index 0000000000..f30810c265 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt @@ -0,0 +1,259 @@ +package com.afternote.feature.setting.presentation.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import com.afternote.core.ui.navigation.FeatureNavDisplay +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.feature.setting.presentation.account.ConnectedAccountsScreen +import com.afternote.feature.setting.presentation.applock.AppLockSetupScreen +import com.afternote.feature.setting.presentation.applock.PinSetupStep +import com.afternote.feature.setting.presentation.delivery.DeliveryConditionScreen +import com.afternote.feature.setting.presentation.delivery.DeliveryConditionViewModel +import com.afternote.feature.setting.presentation.home.SettingScreen +import com.afternote.feature.setting.presentation.home.SettingViewModel +import com.afternote.feature.setting.presentation.home.WithdrawConfirmScreen +import com.afternote.feature.setting.presentation.home.WithdrawGuideScreen +import com.afternote.feature.setting.presentation.notice.NoticeListScreen +import com.afternote.feature.setting.presentation.notification.NotificationSettingScreen +import com.afternote.feature.setting.presentation.notification.PushNotificationScreen +import com.afternote.feature.setting.presentation.passkey.PassKeyListScreen +import com.afternote.feature.setting.presentation.passkey.PassKeyMakingScreen +import com.afternote.feature.setting.presentation.passkey.PassKeyPasswordScreen +import com.afternote.feature.setting.presentation.passkey.PassKeyScreen +import com.afternote.feature.setting.presentation.passkey.PassKeyViewModel +import com.afternote.feature.setting.presentation.password.PasswordChangeScreen +import com.afternote.feature.setting.presentation.profile.ProfileEditScreen +import com.afternote.feature.setting.presentation.receiver.ReceiverEditScreen +import com.afternote.feature.setting.presentation.receiver.ReceiverEditViewModel +import com.afternote.feature.setting.presentation.receiver.ReceiverListIntent +import com.afternote.feature.setting.presentation.receiver.ReceiverListRouteContent +import com.afternote.feature.setting.presentation.receiver.ReceiverListViewModel +import com.afternote.feature.setting.presentation.receiver.ReceiverRegisterScreen + +/** + * 설정 피처가 소유하는 로컬 Navigation 3 스택 (#1695). + * + * ## 루트와의 계약 (#1702 가 소비한다) + * + * 루트가 주는 것은 둘이다. [navigationCallbacks] 는 스택 바닥에서의 back 을 받아 이 host 를 루트 + * 백스택에서 내리고, [externalActions] 는 인증 상태가 바뀐 뒤(로그아웃·탈퇴) 루트 스택을 갈아 + * 끼운다. 설정 안에서는 바텀바가 보이지 않으므로 깊이 신호는 쓰지 않는다. + * + * host 가 갖는 것은 스택과 그 시작점, 그리고 탈퇴 두 화면이 공유하는 [SettingViewModel] 의 수명이다. + * 루트가 `NavDisplay` 로 바뀌어도 이 경계는 그대로고 콜백의 구현만 갈린다. + * + * ## 시작점 + * + * 홈의 «수신인 지정 미완료» 칩은 설정 홈을 거치지 않고 등록 화면으로 곧장 들어온다 + * ([startWithRecipientRegistration]). 등록 화면을 스택 바닥에 두면 등록 성공의 pop 이 곧 host 이탈이라, + * 이관 전처럼 부른 곳(홈)으로 돌아간다. 설정 홈·목록에서 들어온 등록은 그 위에 쌓이므로 거기로 돌아간다. + * + * ## 탈퇴 안내·확인이 공유하는 ViewModel + * + * Nav2 에서는 `Route.Setting` 그래프 엔트리에 묶여 있었다. Nav3 엔 그래프 계층이 없으므로 이 + * 컴포저블을 담은 상위 entry 의 스토어(`hostOwner`)에 올린다 — host 가 루트 백스택에서 내려갈 때 + * 정리되므로 이관 전과 같은 수명이다. host 몸통에서 미리 만들지 않는 것은 `init` 이 프로필을 + * 불러오기 때문이다. 탈퇴 화면에 들어갈 때 처음 만들어지는 것도 이관 전과 같다. 설정 홈은 제 entry + * 범위의 인스턴스를 따로 쓴다. + * + * ## route 인자 + * + * Nav3 entry 는 `SavedStateHandle` 에 route 인자를 채워 주지 않는다. 인자를 받는 ViewModel 은 키를 + * assisted 로 받고, entry 가 `creationCallback` 으로 키를 넘긴다. + */ +@Composable +public fun SettingNavHost( + navigationCallbacks: FeatureNavigationCallbacks, + externalActions: SettingExternalActions, + modifier: Modifier = Modifier, + startWithRecipientRegistration: Boolean = false, +) { + val backStack = + rememberNavBackStack( + if (startWithRecipientRegistration) SettingRoute.RecipientRegisterRoute else SettingRoute.SettingHomeRoute, + ) + val actions = + remember(backStack, navigationCallbacks, externalActions) { + SettingLocalNavActions(backStack, navigationCallbacks, externalActions) + } + val hostOwner = checkNotNull(LocalViewModelStoreOwner.current) { "설정 host 를 담은 entry 의 ViewModelStoreOwner 가 없다" } + + FeatureNavDisplay( + backStack = backStack, + boundary = navigationCallbacks, + modifier = modifier, + entryProvider = + entryProvider { + entry { + SettingScreen( + onBackClick = actions::popBack, + onLogoutSuccess = actions::onLogoutSuccess, + onProfileEditClick = actions::onProfileEditClick, + onPasswordChangeClick = actions::onPasswordChangeClick, + onLinkedAccountClick = actions::onLinkedAccountClick, + onNotificationClick = actions::onNotificationClick, + onRecipientListClick = actions::onRecipientListClick, + onRecipientRegisterClick = actions::onRecipientRegisterClick, + onDeliveryConditionsClick = actions::onDeliveryConditionsClick, + onPasskeyClick = actions::onPasskeyClick, + onAppLockClick = actions::onAppLockClick, + onNoticeClick = actions::onNoticeClick, + onWithdrawGuideClick = actions::onWithdrawGuideClick, + ) + } + + entry { + val viewModel: SettingViewModel = hiltViewModel(hostOwner) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + WithdrawGuideScreen( + uiState = uiState, + onBackClick = actions::popBack, + onCancelClick = actions::popBack, + onConfirmClick = actions::onWithdrawConfirmClick, + ) + } + + entry { + val viewModel: SettingViewModel = hiltViewModel(hostOwner) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + WithdrawConfirmScreen( + uiState = uiState, + onBackClick = actions::popBack, + onWithdrawSuccess = actions::onWithdrawSuccess, + viewModel = viewModel, + ) + } + + entry { + ProfileEditScreen( + onBackClick = actions::popBack, + onWithdrawGuideClick = actions::onWithdrawGuideClick, + ) + } + + entry { + PasswordChangeScreen( + onBackClick = actions::popBack, + onChanged = actions::popBack, + ) + } + + entry { + ConnectedAccountsScreen( + onBack = actions::popBack, + ) + } + + entry { + NotificationSettingScreen( + onBack = actions::popBack, + onPushNotificationClick = actions::onPushNotificationClick, + ) + } + + entry { + PushNotificationScreen( + onBack = actions::popBack, + ) + } + + entry { key -> + val viewModel: ReceiverListViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LifecycleStartEffect(viewModel) { + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + onStopOrDispose { viewModel.onIntent(ReceiverListIntent.ObservationStopped) } + } + ReceiverListRouteContent( + uiState = uiState, + selectForDeliveryConditions = key.selectForDeliveryConditions, + onBackClick = actions::popBack, + onRetryClick = { viewModel.onIntent(ReceiverListIntent.Retry) }, + onConfirmClick = { receiver -> actions.onDeliveryConditionsRecipientSelected(receiver.receiverId) }, + onReceiverClick = actions::onRecipientEditClick, + onRegisterClick = actions::onRecipientRegisterClick, + ) + } + + entry { + ReceiverRegisterScreen( + onBackClick = actions::popBack, + onRegisterSuccess = actions::popBack, + ) + } + + entry { key -> + ReceiverEditScreen( + onBackClick = actions::popBack, + onEditSuccess = actions::popBack, + viewModel = + hiltViewModel( + creationCallback = { factory -> factory.create(key) }, + ), + ) + } + + entry { key -> + DeliveryConditionScreen( + onBack = actions::popBack, + onSaveSuccess = actions::popBack, + onLastGreetingEditClick = { actions.onRecipientEditClick(key.receiverId) }, + viewModel = + hiltViewModel( + creationCallback = { factory -> factory.create(key) }, + ), + ) + } + + entry { + val viewModel: PassKeyViewModel = hiltViewModel() + val isPasskeyRegistered by viewModel.isPasskeyRegistered.collectAsStateWithLifecycle() + if (isPasskeyRegistered == true) { + PassKeyListScreen(onBackClick = actions::popBack) + } else if (isPasskeyRegistered == false) { + PassKeyScreen( + onBackClick = actions::popBack, + onRegisterClick = actions::onPasskeyRegisterClick, + ) + } + } + + entry { + PassKeyMakingScreen( + onBackClick = actions::popBack, + onPasswordAuthClick = actions::onPasswordAuthClick, + ) + } + + entry { + PassKeyPasswordScreen( + onPinComplete = { actions.popBack() }, + onBack = actions::popBack, + ) + } + + entry { + AppLockSetupScreen( + step = PinSetupStep.ENTER_NEW, + onPinComplete = { actions.popBack() }, + onBack = actions::popBack, + ) + } + + entry { + NoticeListScreen( + notices = emptyList(), + onBackClick = actions::popBack, + ) + } + }, + ) +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt index 898cb15077..0715412fee 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt @@ -1,8 +1,15 @@ package com.afternote.feature.setting.presentation.navigation +import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable -sealed interface SettingRoute { +/** + * 설정 로컬 스택([SettingNavHost]) 안의 화면 키. + * + * [NavKey] 는 로컬 Navigation 3 스택에 실릴 수 있다는 표식이다 — `@Serializable` 과 함께 + * 있어야 프로세스 재생성 뒤 스택이 복원된다 (#1695). + */ +internal sealed interface SettingRoute : NavKey { @Serializable data object SettingHomeRoute : SettingRoute @@ -15,17 +22,27 @@ sealed interface SettingRoute { @Serializable data object ProfileEditRoute : SettingRoute + @Serializable + data object PasswordChangeRoute : SettingRoute + @Serializable data object LinkedAccountRoute : SettingRoute @Serializable data object NotificationRoute : SettingRoute + /** + * 수신자 목록. [selectForDeliveryConditions] 가 참이면 관리 목록이 아니라 사후 전달 조건을 + * 정할 수신자를 고르는 선택 목록이다 — 진입 경로 결정(#1998)과 무관하게 현행 분기를 그대로 나른다. + */ @Serializable data class RecipientListRoute( val selectForDeliveryConditions: Boolean = false, ) : SettingRoute + @Serializable + data object PushNotificationRoute : SettingRoute + @Serializable data object RecipientRegisterRoute : SettingRoute diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/NoticeListScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notice/NoticeListScreen.kt similarity index 62% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/NoticeListScreen.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notice/NoticeListScreen.kt index b9ca353ffe..431c7cfbf1 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/NoticeListScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notice/NoticeListScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.notice import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -11,14 +11,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.domain.Notice import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.component.NoticeListItem -import java.time.LocalDate +import com.afternote.feature.setting.presentation.notice.component.NoticeListItem // 설정 - 공지사항 @Composable @@ -30,7 +28,7 @@ fun NoticeListScreen( Scaffold( topBar = { DetailTopBar( - title = stringResource(R.string.settings_support_notice), + title = stringResource(R.string.setting_support_notice), onBackClick = onBackClick, ) }, @@ -67,39 +65,9 @@ private fun NoticeEmptyState(modifier: Modifier = Modifier) { .padding(top = 24.dp), ) { Text( - text = stringResource(R.string.settings_notice_empty), + text = stringResource(R.string.setting_notice_empty), style = AfternoteDesign.typography.bodyLargeR, color = AfternoteDesign.colors.gray8, ) } } - -@Preview(showBackground = true) -@Composable -private fun NoticeListScreenPrev() { - NoticeListScreen( - onBackClick = {}, - notices = - listOf( - Notice( - date = LocalDate.of(2022, 11, 20), - title = "서비스 점검 안내", - content = "서버 점검으로 인해 서비스가 일시 중단됩니다.", - ), - Notice( - date = LocalDate.of(2023, 3, 5), - title = "개인정보 처리방침 변경 안내", - content = "개인정보 처리방침이 변경되었습니다.", - ), - ), - ) -} - -@Preview(showBackground = true) -@Composable -private fun NoticeListScreenEmptyPrev() { - NoticeListScreen( - onBackClick = {}, - notices = emptyList(), - ) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/NoticeListItem.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notice/component/NoticeListItem.kt similarity index 78% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/NoticeListItem.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notice/component/NoticeListItem.kt index 27dae07eb2..04cdf96731 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/NoticeListItem.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notice/component/NoticeListItem.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.notice.component import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement @@ -15,12 +15,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.setting.domain.Notice import com.afternote.feature.setting.presentation.R -import java.time.LocalDate import java.time.format.DateTimeFormatter private val noticeDateFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") @@ -52,22 +50,9 @@ fun NoticeListItem( } Spacer(modifier = Modifier.weight(1f)) Image( - painterResource(R.drawable.ic_right_arrow), + painterResource(R.drawable.setting_ic_right_arrow), contentDescription = "화살표", colorFilter = ColorFilter.tint(AfternoteDesign.colors.gray9), ) } } - -@Preview(showBackground = true) -@Composable -private fun NoticeListItemPrev() { - NoticeListItem( - notice = - Notice( - date = LocalDate.of(2022, 11, 20), - title = "서비스 점검 안내", - content = "서버 점검으로 인해 서비스가 일시 중단됩니다.", - ), - ) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationPermissionAction.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationPermissionAction.kt new file mode 100644 index 0000000000..0ef4de8a85 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationPermissionAction.kt @@ -0,0 +1,16 @@ +package com.afternote.feature.setting.presentation.notification + +internal enum class NotificationPermissionAction { + RequestPermission, + OpenSettings, +} + +internal fun notificationPermissionAction( + sdkInt: Int, + permissionGranted: Boolean, +): NotificationPermissionAction = + if (sdkInt >= 33 && !permissionGranted) { + NotificationPermissionAction.RequestPermission + } else { + NotificationPermissionAction.OpenSettings + } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt new file mode 100644 index 0000000000..b5a271b264 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt @@ -0,0 +1,170 @@ +package com.afternote.feature.setting.presentation.notification + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.afternote.core.ui.findActivity +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.setting.presentation.R +import com.afternote.feature.setting.presentation.notification.component.DeviceAlarmOffSection +import com.afternote.feature.setting.presentation.shared.component.SettingMenuItem + +@Composable +fun NotificationSettingScreen( + onBack: () -> Unit, + onPushNotificationClick: () -> Unit, + viewModel: PushNotificationViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val activity = remember(context) { context.findActivity() } + val snackbarHostState = remember { SnackbarHostState() } + val lifecycleOwner = LocalLifecycleOwner.current + val marketingConsentSaveFailedMessage = stringResource(R.string.setting_marketing_consent_save_failed) + val openNotificationSettings = { + val intent = + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + context.startActivity(intent) + } + val notificationPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + viewModel.refreshDeviceAlarmStatus() + if ( + !granted && + activity != null && + !ActivityCompat.shouldShowRequestPermissionRationale( + activity, + Manifest.permission.POST_NOTIFICATIONS, + ) + ) { + openNotificationSettings() + } + } + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.refreshDeviceAlarmStatus() + } + + LaunchedEffect(viewModel, lifecycleOwner, marketingConsentSaveFailedMessage) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.events.collect { event -> + when (event) { + PushNotificationEvent.MarketingConsentSaveFailed -> { + snackbarHostState.showSnackbar(marketingConsentSaveFailedMessage) + } + } + } + } + } + + Scaffold( + topBar = { + DetailTopBar( + title = stringResource(R.string.setting_push_notification_title), + onBackClick = onBack, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = Color.Transparent, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + val permissionGranted = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + when (notificationPermissionAction(Build.VERSION.SDK_INT, permissionGranted)) { + NotificationPermissionAction.RequestPermission -> { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + NotificationPermissionAction.OpenSettings -> { + openNotificationSettings() + } + } + }.padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.setting_device_alarm_setting), + style = AfternoteDesign.typography.bodyLargeR, + ) + Spacer(Modifier.width(12.dp)) + Text( + text = + if (uiState.isDeviceAlarmOn) { + stringResource(R.string.setting_device_alarm_on) + } else { + stringResource(R.string.setting_device_alarm_off) + }, + style = AfternoteDesign.typography.captionLargeR, + ) + } + + Spacer(Modifier.height(8.dp)) + if (uiState.isDeviceAlarmOn) { + SettingMenuItem( + label = stringResource(R.string.setting_push_notification_title), + onClick = onPushNotificationClick, + ) + } else { + DeviceAlarmOffSection( + uiState = uiState, + onSmsCheck = viewModel::onSmsChecked, + onEmailCheck = viewModel::onEmailChecked, + onPushCheck = viewModel::onPushChecked, + ) + } + } + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContent.kt new file mode 100644 index 0000000000..231273c3c7 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContent.kt @@ -0,0 +1,66 @@ +package com.afternote.feature.setting.presentation.notification + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.setting.presentation.R +import com.afternote.feature.setting.presentation.notification.component.PushToggleSection + +/** + * 푸시 알림 설정 화면의 상태 없는 본문. + * + * 기기 알림 on/off·SMS/이메일/푸시 채널 동의는 [NotificationSettingScreen] 이 들고 있다 (#560) — + * 이 화면은 기기 알림이 켜진 뒤에만 진입하는 카테고리별(뉴스레터·마음의 기록·애프터노트) 토글만 그린다. + * ViewModel 은 [PushNotificationScreen] 이 들고, 이 함수는 넘겨받은 상태만 그린다. + */ +@Composable +internal fun PushNotificationContent( + uiState: PushNotificationUiState, + onBack: () -> Unit, + onNewsletterToggle: (Boolean) -> Unit, + onMindRecordToggle: (Boolean) -> Unit, + onAfternoteToggle: (Boolean) -> Unit, +) { + Scaffold( + topBar = { + DetailTopBar( + title = stringResource(R.string.setting_push_notification_title), + onBackClick = onBack, + ) + }, + containerColor = Color.Transparent, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + PushToggleSection( + uiState = uiState, + onNewsletterToggle = onNewsletterToggle, + onMindRecordToggle = onMindRecordToggle, + onAfternoteToggle = onAfternoteToggle, + ) + + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.setting_push_notification_device_guide), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray5, + ) + } + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt new file mode 100644 index 0000000000..2dcb24789a --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt @@ -0,0 +1,47 @@ +package com.afternote.feature.setting.presentation.notification + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.popup.NetworkErrorPopup +import com.afternote.core.ui.popup.ServerErrorPopup +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.setting.presentation.R +import com.afternote.feature.setting.presentation.notification.component.DeviceAlarmOffSection +import com.afternote.feature.setting.presentation.notification.component.PushToggleSection + +@Composable +fun PushNotificationScreen( + onBack: () -> Unit, + viewModel: PushNotificationViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + PushNotificationContent( + uiState = uiState, + onBack = onBack, + onNewsletterToggle = viewModel::onNewsletterToggle, + onMindRecordToggle = viewModel::onMindRecordToggle, + onAfternoteToggle = viewModel::onAfternoteToggle, + ) + + when (uiState.saveFailure) { + PushNotificationSaveFailure.NETWORK -> { + NetworkErrorPopup( + onRetry = viewModel::onSaveFailureRetry, + onDismiss = viewModel::onSaveFailureDismiss, + ) + } + + PushNotificationSaveFailure.SERVER -> { + ServerErrorPopup( + onRetry = viewModel::onSaveFailureRetry, + onDismiss = viewModel::onSaveFailureDismiss, + ) + } + + null -> {} + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/PushNotificationUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt similarity index 51% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/PushNotificationUiState.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt index 7d91d35bc4..81ee23a7c7 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/PushNotificationUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.notification data class PushNotificationUiState( val isLoading: Boolean = false, @@ -11,4 +11,17 @@ data class PushNotificationUiState( val isNewsletterOn: Boolean = false, val isMindRecordOn: Boolean = false, val isAfternoteOn: Boolean = false, + val isNewsletterUpdating: Boolean = false, + val isMindRecordUpdating: Boolean = false, + val isAfternoteUpdating: Boolean = false, + val saveFailure: PushNotificationSaveFailure? = null, ) + +enum class PushNotificationSaveFailure { + NETWORK, + SERVER, +} + +sealed interface PushNotificationEvent { + data object MarketingConsentSaveFailed : PushNotificationEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt new file mode 100644 index 0000000000..4e928b830b --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt @@ -0,0 +1,251 @@ +package com.afternote.feature.setting.presentation.notification + +import android.content.Context +import android.util.Log +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.error.PushSettingFailure +import com.afternote.feature.setting.domain.SettingNotificationRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import jakarta.inject.Inject +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@HiltViewModel +class PushNotificationViewModel + @Inject + constructor( + @ApplicationContext private val context: Context, + private val notificationRepository: SettingNotificationRepository, + private val errorReporter: ErrorReporter, + ) : ViewModel() { + private val _uiState = MutableStateFlow(PushNotificationUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // 화면이 없는 동안의 안내는 다음 진입에 재생하지 않는다. + private val _events = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val events = _events.asSharedFlow() + + // 슬롯 하나 — 서로 다른 토글이 연달아 실패해도 재시도 대상은 마지막 실패만 남긴다. + // 이미 실패한 토글은 위에서 이전 값으로 롤백되어 화면에 "안 켜짐"으로 보이므로, + // 조용히 사라지는 것은 재시도 "대상"뿐이다. 여러 실패를 동시에 재시도하는 요구가 + // 없어 의도적으로 단순화했다 (#558 리뷰 합의). + private var failedUpdate: PushSettingUpdate? = null + + init { + refreshDeviceAlarmStatus() + loadPushSettings() + loadMarketingConsents() + } + + fun refreshDeviceAlarmStatus() { + val deviceAlarmOn = NotificationManagerCompat.from(context).areNotificationsEnabled() + Log.d(TAG, "refreshDeviceAlarmStatus: deviceAlarmOn=$deviceAlarmOn") + _uiState.update { it.copy(isDeviceAlarmOn = deviceAlarmOn) } + } + + private fun loadPushSettings() { + viewModelScope.launch { + Log.d(TAG, "loadPushSettings: start") + _uiState.update { it.copy(isLoading = true) } + runCatchingCancellable { notificationRepository.getMyPushSettings() } + .onSuccess { setting -> + Log.d(TAG, "loadPushSettings: success=$setting") + _uiState.update { + it.copy( + isLoading = false, + isNewsletterOn = setting.timeLetter, + isMindRecordOn = setting.mindRecord, + isAfternoteOn = setting.afterNote, + ) + } + }.onFailure { e -> + Log.e(TAG, "loadPushSettings: failed", e) + _uiState.update { it.copy(isLoading = false) } + } + } + } + + private fun loadMarketingConsents() { + viewModelScope.launch { + Log.d(TAG, "loadMarketingConsents: start") + runCatching { notificationRepository.getMyMarketingConsents() } + .onSuccess { consent -> + Log.d(TAG, "loadMarketingConsents: success=$consent") + _uiState.update { + it.copy( + isSmsChecked = consent.sms, + isEmailChecked = consent.email, + isPushChecked = consent.push, + ) + } + }.onFailure { e -> + Log.e(TAG, "loadMarketingConsents: failed", e) + } + } + } + + fun onSmsChecked(checked: Boolean) { + _uiState.update { it.copy(isSmsChecked = checked) } + viewModelScope.launch { + runCatchingCancellable { notificationRepository.updateMyMarketingConsents(sms = checked, email = null, push = null) } + .onSuccess { Log.d(TAG, "onSmsChecked: success, checked=$checked") } + .onFailure { e -> + errorReporter.recordFailure(e, mapOf(KEY_STAGE to STAGE_SMS_CONSENT)) + _uiState.update { it.copy(isSmsChecked = !checked) } + _events.tryEmit(PushNotificationEvent.MarketingConsentSaveFailed) + } + } + } + + fun onEmailChecked(checked: Boolean) { + _uiState.update { it.copy(isEmailChecked = checked) } + viewModelScope.launch { + runCatchingCancellable { notificationRepository.updateMyMarketingConsents(sms = null, email = checked, push = null) } + .onSuccess { Log.d(TAG, "onEmailChecked: success, checked=$checked") } + .onFailure { e -> + errorReporter.recordFailure(e, mapOf(KEY_STAGE to STAGE_EMAIL_CONSENT)) + _uiState.update { it.copy(isEmailChecked = !checked) } + _events.tryEmit(PushNotificationEvent.MarketingConsentSaveFailed) + } + } + } + + fun onPushChecked(checked: Boolean) { + _uiState.update { it.copy(isPushChecked = checked) } + viewModelScope.launch { + runCatchingCancellable { notificationRepository.updateMyMarketingConsents(sms = null, email = null, push = checked) } + .onSuccess { Log.d(TAG, "onPushChecked: success, checked=$checked") } + .onFailure { e -> + errorReporter.recordFailure(e, mapOf(KEY_STAGE to STAGE_PUSH_CONSENT)) + _uiState.update { it.copy(isPushChecked = !checked) } + _events.tryEmit(PushNotificationEvent.MarketingConsentSaveFailed) + } + } + } + + fun onNewsletterToggle(on: Boolean) { + updatePushSetting(PushSettingUpdate(PushSetting.NEWSLETTER, on)) + } + + fun onMindRecordToggle(on: Boolean) { + updatePushSetting(PushSettingUpdate(PushSetting.MIND_RECORD, on)) + } + + fun onAfternoteToggle(on: Boolean) { + updatePushSetting(PushSettingUpdate(PushSetting.AFTERNOTE, on)) + } + + fun onSaveFailureDismiss() { + failedUpdate = null + _uiState.update { it.copy(saveFailure = null) } + } + + fun onSaveFailureRetry() { + val update = failedUpdate ?: return + failedUpdate = null + _uiState.update { it.copy(saveFailure = null) } + updatePushSetting(update) + } + + private fun updatePushSetting(update: PushSettingUpdate) { + if (_uiState.value.isUpdating(update.setting)) return + val previousValue = _uiState.value.valueOf(update.setting) + _uiState.update { + it.withValue(update.setting, update.on).withUpdating(update.setting, updating = true) + } + viewModelScope.launch { + runCatchingCancellable { + notificationRepository.updateMyPushSettings( + timeLetter = update.on.takeIf { update.setting == PushSetting.NEWSLETTER }, + mindRecord = update.on.takeIf { update.setting == PushSetting.MIND_RECORD }, + afterNote = update.on.takeIf { update.setting == PushSetting.AFTERNOTE }, + ) + }.onSuccess { + _uiState.update { it.withUpdating(update.setting, updating = false) } + }.onFailure { failure -> + failedUpdate = update + _uiState.update { + it + .withValue(update.setting, previousValue) + .withUpdating(update.setting, updating = false) + .copy(saveFailure = failure.toSaveFailure()) + } + } + } + } + + companion object { + private const val TAG = "PushNotificationVM" + private const val KEY_STAGE = "stage" + private const val STAGE_SMS_CONSENT = "sms_consent_update" + private const val STAGE_EMAIL_CONSENT = "email_consent_update" + private const val STAGE_PUSH_CONSENT = "push_consent_update" + } + } + +private enum class PushSetting { + NEWSLETTER, + MIND_RECORD, + AFTERNOTE, +} + +private data class PushSettingUpdate( + val setting: PushSetting, + val on: Boolean, +) + +private fun PushNotificationUiState.valueOf(setting: PushSetting): Boolean = + when (setting) { + PushSetting.NEWSLETTER -> isNewsletterOn + PushSetting.MIND_RECORD -> isMindRecordOn + PushSetting.AFTERNOTE -> isAfternoteOn + } + +private fun PushNotificationUiState.withValue( + setting: PushSetting, + on: Boolean, +): PushNotificationUiState = + when (setting) { + PushSetting.NEWSLETTER -> copy(isNewsletterOn = on) + PushSetting.MIND_RECORD -> copy(isMindRecordOn = on) + PushSetting.AFTERNOTE -> copy(isAfternoteOn = on) + } + +private fun PushNotificationUiState.isUpdating(setting: PushSetting): Boolean = + when (setting) { + PushSetting.NEWSLETTER -> isNewsletterUpdating + PushSetting.MIND_RECORD -> isMindRecordUpdating + PushSetting.AFTERNOTE -> isAfternoteUpdating + } + +private fun PushNotificationUiState.withUpdating( + setting: PushSetting, + updating: Boolean, +): PushNotificationUiState = + when (setting) { + PushSetting.NEWSLETTER -> copy(isNewsletterUpdating = updating) + PushSetting.MIND_RECORD -> copy(isMindRecordUpdating = updating) + PushSetting.AFTERNOTE -> copy(isAfternoteUpdating = updating) + } + +private fun Throwable.toSaveFailure(): PushNotificationSaveFailure = + when (this) { + is PushSettingFailure.NetworkUnavailable -> PushNotificationSaveFailure.NETWORK + else -> PushNotificationSaveFailure.SERVER + } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/DeviceAlarmOffSection.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt similarity index 68% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/DeviceAlarmOffSection.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt index e5de47f6d7..d908c8d8e0 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/DeviceAlarmOffSection.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.notification.component import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,11 +10,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.viewmodel.PushNotificationUiState +import com.afternote.feature.setting.presentation.notification.PushNotificationUiState @Composable fun DeviceAlarmOffSection( @@ -28,23 +27,23 @@ fun DeviceAlarmOffSection( Divider(thickness = 0.8.dp, color = AfternoteDesign.colors.gray3) Spacer(Modifier.height(18.dp)) Text( - text = stringResource(R.string.marketing_alarm_setting), + text = stringResource(R.string.setting_marketing_alarm_setting), style = AfternoteDesign.typography.bodyBase, ) Spacer(Modifier.height(38.dp)) LabeledCheckboxRow( - label = stringResource(R.string.sms), + label = stringResource(R.string.setting_sms), checked = uiState.isSmsChecked, onCheckedChange = onSmsCheck, ) LabeledCheckboxRow( - label = stringResource(R.string.email), + label = stringResource(R.string.setting_email), checked = uiState.isEmailChecked, onCheckedChange = onEmailCheck, ) LabeledCheckboxRow( - label = stringResource(R.string.push_alarm), + label = stringResource(R.string.setting_push_alarm), checked = uiState.isPushChecked, onCheckedChange = onPushCheck, ) @@ -54,9 +53,9 @@ fun DeviceAlarmOffSection( // 안내 문구 bullet list val guides = listOf( - stringResource(R.string.marketing_guide_1), - stringResource(R.string.marketing_guide_2), - stringResource(R.string.marketing_guide_3), + stringResource(R.string.setting_marketing_guide_1), + stringResource(R.string.setting_marketing_guide_2), + stringResource(R.string.setting_marketing_guide_3), ) guides.forEach { guide -> Row(modifier = Modifier.padding(vertical = 14.dp)) { @@ -69,14 +68,3 @@ fun DeviceAlarmOffSection( } } } - -@Preview(showBackground = true) -@Composable -private fun DeviceAlarmOffSectionPrev() { - DeviceAlarmOffSection( - uiState = PushNotificationUiState(), - onSmsCheck = {}, - onEmailCheck = {}, - onPushCheck = {}, - ) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/LabelCheckoutboxRow.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/LabeledCheckboxRow.kt similarity index 95% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/LabelCheckoutboxRow.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/LabeledCheckboxRow.kt index 1caf7bad28..223366d7e7 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/LabelCheckoutboxRow.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/LabeledCheckboxRow.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.notification.component import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/LabeledSwitchRow.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/LabeledSwitchRow.kt similarity index 86% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/LabeledSwitchRow.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/LabeledSwitchRow.kt index 625f378fdc..c22203f102 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/LabeledSwitchRow.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/LabeledSwitchRow.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.notification.component import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -9,7 +9,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign @@ -19,6 +18,7 @@ fun LabeledSwitchRow( checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Row( modifier = @@ -35,6 +35,7 @@ fun LabeledSwitchRow( Switch( checked = checked, onCheckedChange = onCheckedChange, + enabled = enabled, thumbContent = {}, colors = SwitchDefaults.colors( @@ -48,9 +49,3 @@ fun LabeledSwitchRow( ) } } - -@Preview(showBackground = true) -@Composable -private fun LabeledSwitchRowPrev() { - LabeledSwitchRow(label = "애프터노트", true, {}) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/PushToggleSection.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt similarity index 58% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/PushToggleSection.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt index 02a13dafc9..aca5f1757f 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/PushToggleSection.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt @@ -1,9 +1,9 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.notification.component import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.viewmodel.PushNotificationUiState +import com.afternote.feature.setting.presentation.notification.PushNotificationUiState @Composable fun PushToggleSection( @@ -13,18 +13,21 @@ fun PushToggleSection( onAfternoteToggle: (Boolean) -> Unit, ) { LabeledSwitchRow( - label = stringResource(R.string.timeletter), + label = stringResource(R.string.setting_timeletter), checked = uiState.isNewsletterOn, onCheckedChange = onNewsletterToggle, + enabled = !uiState.isNewsletterUpdating, ) LabeledSwitchRow( - label = stringResource(R.string.mind_record), + label = stringResource(R.string.setting_mind_record), checked = uiState.isMindRecordOn, onCheckedChange = onMindRecordToggle, + enabled = !uiState.isMindRecordUpdating, ) LabeledSwitchRow( - label = stringResource(R.string.afternote), + label = stringResource(R.string.setting_afternote), checked = uiState.isAfternoteOn, onCheckedChange = onAfternoteToggle, + enabled = !uiState.isAfternoteUpdating, ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyListScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListScreen.kt similarity index 75% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyListScreen.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListScreen.kt index 2d5c51dae9..f7128d8896 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyListScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyListScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.passkey import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -11,12 +11,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.component.PasskeyListItem +import com.afternote.feature.setting.presentation.passkey.component.PasskeyListItem @Composable fun PassKeyListScreen( @@ -28,7 +27,7 @@ fun PassKeyListScreen( containerColor = Color.Transparent, topBar = { DetailTopBar( - title = stringResource(id = R.string.passkey_management_title), + title = stringResource(id = R.string.setting_passkey_management_title), onBackClick = onBackClick, ) }, @@ -43,17 +42,17 @@ fun PassKeyListScreen( item { Spacer(modifier = Modifier.height(16.dp)) Text( - text = stringResource(id = R.string.passkey_section_title), + text = stringResource(id = R.string.setting_passkey_section_title), style = AfternoteDesign.typography.bodyLargeB, ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(id = R.string.passkey_description), + text = stringResource(id = R.string.setting_passkey_description), style = AfternoteDesign.typography.bodySmallR, ) Spacer(modifier = Modifier.height(24.dp)) Text( - text = stringResource(id = R.string.passkey_list_header), + text = stringResource(id = R.string.setting_passkey_list_header), style = AfternoteDesign.typography.bodyLargeB, ) Spacer(modifier = Modifier.height(12.dp)) @@ -64,9 +63,3 @@ fun PassKeyListScreen( } } } - -@Preview(showBackground = true) -@Composable -private fun PassKeyListScreenPrev() { - PassKeyListScreen(onBackClick = {}) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingContent.kt new file mode 100644 index 0000000000..7493fdc231 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingContent.kt @@ -0,0 +1,91 @@ +package com.afternote.feature.setting.presentation.passkey + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.button.AfternoteButton +import com.afternote.core.ui.button.AfternoteButtonType +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.setting.presentation.R + +/** + * 패스키 생성 화면의 상태 없는 본문. + * + * 생체 인증 실행·완료 팝업은 [PassKeyMakingScreen] 이 들고, 이 함수는 넘겨받은 상태만 그린다. + */ +@Composable +internal fun PassKeyMakingContent( + onBackClick: () -> Unit, + onBiometricAuthClick: () -> Unit, + onPasswordAuthClick: () -> Unit, + isBiometricAvailable: Boolean, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + containerColor = Color.Transparent, + topBar = { + DetailTopBar( + title = stringResource(id = R.string.setting_passkey_management_title), + onBackClick = onBackClick, + ) + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(137.dp)) + Text( + text = stringResource(R.string.setting_passkey_fingerprint_guide), + style = AfternoteDesign.typography.bodyLargeR, + modifier = Modifier.padding(innerPadding), + ) + Spacer(modifier = Modifier.height(40.dp)) + Image( + painterResource(R.drawable.setting_ic_fingerprint), + "지문", + ) + } + Column( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 63.dp), + ) { + AfternoteButton( + text = "지문 인증하기", + onClick = onBiometricAuthClick, + type = if (isBiometricAvailable) AfternoteButtonType.Default else AfternoteButtonType.Un, + ) + Spacer(modifier = Modifier.height(8.dp)) + AfternoteButton( + text = "비밀번호로 인증하기", + onClick = onPasswordAuthClick, + type = AfternoteButtonType.Active, + ) + } + } + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyMakingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreen.kt similarity index 50% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyMakingScreen.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreen.kt index a890c7ef68..89aea0918a 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyMakingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreen.kt @@ -1,47 +1,31 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.passkey +import android.util.Log import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.afternote.core.ui.button.AfternoteButton -import com.afternote.core.ui.button.AfternoteButtonType +import com.afternote.core.common.biometric.confirmWithCryptoOperation +import com.afternote.core.common.biometric.createBiometricCryptoObject +import com.afternote.core.common.biometric.isBiometricCryptoSupported import com.afternote.core.ui.findActivity import com.afternote.core.ui.popup.Popup import com.afternote.core.ui.popup.PopupType -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.topbar.DetailTopBar -import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.viewmodel.PassKeyViewModel import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume +private const val LOG_TAG = "PassKeyMaking" + private sealed interface BiometricResult { data object Success : BiometricResult @@ -93,82 +77,37 @@ fun PassKeyMakingScreen( ) } - Scaffold( - modifier = modifier, - containerColor = Color.Transparent, - topBar = { - DetailTopBar( - title = stringResource(id = R.string.passkey_management_title), - onBackClick = onBackClick, - ) - }, - ) { innerPadding -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = 20.dp), - ) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.height(137.dp)) - Text( - text = stringResource(R.string.passkey_fingerprint_guide), - style = AfternoteDesign.typography.bodyLargeR, - modifier = Modifier.padding(innerPadding), - ) - Spacer(modifier = Modifier.height(40.dp)) - Image( - painterResource(R.drawable.ic_fingerprint), - "지문", - ) - } - Column( - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 63.dp), - ) { - AfternoteButton( - text = "지문 인증하기", - onClick = { - if (isAuthenticating || activity == null) return@AfternoteButton - isAuthenticating = true - coroutineScope.launch { - try { - val result = authenticate(activity) - when (result) { - BiometricResult.Success -> { - viewModel.savePasskeyRegistered() - showCompletionDialog = true - } - - BiometricResult.Canceled -> { - Unit - } - - is BiometricResult.Error -> { - errorMessage = result.message - } - } - } finally { - isAuthenticating = false + PassKeyMakingContent( + onBackClick = onBackClick, + onBiometricAuthClick = { + if (!isAuthenticating && activity != null) { + isAuthenticating = true + coroutineScope.launch { + try { + when (val result = authenticate(activity)) { + BiometricResult.Success -> { + viewModel.savePasskeyRegistered() + showCompletionDialog = true + } + + BiometricResult.Canceled -> { + Unit + } + + is BiometricResult.Error -> { + errorMessage = result.message } } - }, - type = if (isBiometricAvailable) AfternoteButtonType.Default else AfternoteButtonType.Un, - ) - Spacer(modifier = Modifier.height(8.dp)) - AfternoteButton( - text = "비밀번호로 인증하기", - onClick = onPasswordAuthClick, - type = AfternoteButtonType.Active, - ) + } finally { + isAuthenticating = false + } + } } - } - } + }, + onPasswordAuthClick = onPasswordAuthClick, + isBiometricAvailable = isBiometricAvailable, + modifier = modifier, + ) } private suspend fun authenticate(activity: FragmentActivity): BiometricResult = @@ -185,13 +124,26 @@ private suspend fun authenticate(activity: FragmentActivity): BiometricResult = .setAllowedAuthenticators(authenticators) .build() + // API 26~29 에서는 DEVICE_CREDENTIAL 허용자와 CryptoObject 를 함께 쓸 수 없다. + // 이때는 관문 없이 인증하고, 성공 확정은 cipher 부재로 통과시킨다 (#1166). + val cryptoObject = if (isBiometricCryptoSupported) createBiometricCryptoObject() else null + val biometricPrompt = BiometricPrompt( activity, ContextCompat.getMainExecutor(activity), object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - if (continuation.isActive) continuation.resume(BiometricResult.Success) + if (!continuation.isActive) return + // 성공은 콜백의 도달이 아니라 사용자 인증에 묶인 키로 암호 연산이 + // 성사되는가로 확정한다 — 콜백만 가로챈 경우 연산이 실패한다 (#1166). + val confirmation = confirmWithCryptoOperation(result.cryptoObject?.cipher) + if (confirmation.isSuccess) { + continuation.resume(BiometricResult.Success) + } else { + Log.w(LOG_TAG, "인증 후 암호 연산 실패", confirmation.exceptionOrNull()) + continuation.resume(BiometricResult.Error("지문 확인에 실패했어요. 다시 시도해 주세요.")) + } } override fun onAuthenticationError( @@ -214,11 +166,9 @@ private suspend fun authenticate(activity: FragmentActivity): BiometricResult = ) continuation.invokeOnCancellation { biometricPrompt.cancelAuthentication() } - biometricPrompt.authenticate(promptInfo) + if (cryptoObject != null) { + biometricPrompt.authenticate(promptInfo, cryptoObject) + } else { + biometricPrompt.authenticate(promptInfo) + } } - -@Preview(showBackground = true) -@Composable -private fun PassKeyMakingScreenPrev() { - PassKeyMakingScreen(onBackClick = {}, onPasswordAuthClick = {}) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordContent.kt new file mode 100644 index 0000000000..fbe5770a42 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordContent.kt @@ -0,0 +1,60 @@ +package com.afternote.feature.setting.presentation.passkey + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.setting.presentation.R +import com.afternote.feature.setting.presentation.shared.component.InsertPasswordContent + +/** + * 패스키 비밀번호 인증 화면의 상태 없는 본문. + * + * ViewModel·완료 처리는 [PassKeyPasswordScreen] 이 들고, 이 함수는 넘겨받은 상태만 그린다. + */ +@Composable +internal fun PassKeyPasswordContent( + passwordLength: Int, + onDigitClick: (String) -> Unit, + onDeleteClick: () -> Unit, + onConfirmClick: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + topBar = { + DetailTopBar( + title = stringResource(id = R.string.setting_passkey_management_title), + onBackClick = onBack, + ) + }, + modifier = modifier, + containerColor = Color.Transparent, + ) { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(40.dp)) + InsertPasswordContent( + titleText = "비밀번호를 입력해 주세요.", + passwordLength = passwordLength, + onDigitClick = onDigitClick, + onDeleteClick = onDeleteClick, + onConfirmClick = onConfirmClick, + ) + } + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreen.kt new file mode 100644 index 0000000000..5993c0ad28 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreen.kt @@ -0,0 +1,39 @@ +package com.afternote.feature.setting.presentation.passkey + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel + +@Composable +fun PassKeyPasswordScreen( + onPinComplete: (pin: String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: AppLockSetupViewModel = hiltViewModel(), + passKeyViewModel: PassKeyViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val currentOnPinComplete by rememberUpdatedState(onPinComplete) + + LaunchedEffect(uiState.isComplete) { + if (uiState.isComplete) { + passKeyViewModel.savePasskeyRegistered() + currentOnPinComplete(uiState.pin) + viewModel.resetPin() + } + } + + PassKeyPasswordContent( + passwordLength = uiState.pin.length, + onDigitClick = viewModel::onDigitInput, + onDeleteClick = viewModel::onDelete, + onConfirmClick = { currentOnPinComplete(uiState.pin) }, + onBack = onBack, + modifier = modifier, + ) +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyScreen.kt similarity index 82% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyScreen.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyScreen.kt index f7df5cab09..79d70ff9e3 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.passkey import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box @@ -18,7 +18,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.button.AfternoteButton import com.afternote.core.ui.button.AfternoteButtonType @@ -37,7 +36,7 @@ fun PassKeyScreen( containerColor = Color.Transparent, topBar = { DetailTopBar( - title = stringResource(id = R.string.passkey_management_title), + title = stringResource(id = R.string.setting_passkey_management_title), onBackClick = onBackClick, ) }, @@ -51,13 +50,13 @@ fun PassKeyScreen( ) { Column { Text( - text = stringResource(id = R.string.passkey_management_title), + text = stringResource(id = R.string.setting_passkey_management_title), style = AfternoteDesign.typography.bodyLargeB, ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(id = R.string.passkey_management_description), + text = stringResource(id = R.string.setting_passkey_management_description), style = AfternoteDesign.typography.bodySmallR, ) Spacer(modifier = Modifier.height(84.dp)) @@ -70,7 +69,7 @@ fun PassKeyScreen( Alignment.Center, ) { Image( - painter = painterResource(R.drawable.ic_passkey_main), + painter = painterResource(R.drawable.setting_ic_passkey_main), contentDescription = "패스키 메인 로고", contentScale = ContentScale.FillWidth, modifier = @@ -85,16 +84,10 @@ fun PassKeyScreen( Modifier .align(Alignment.BottomCenter) .padding(bottom = 63.dp), - text = stringResource(id = R.string.passkey_register), + text = stringResource(id = R.string.setting_passkey_register), onClick = onRegisterClick, type = AfternoteButtonType.Default, ) } } } - -@Preview(showBackground = true) -@Composable -private fun PassKeyScreenPrev() { - PassKeyScreen(onBackClick = {}, onRegisterClick = {}) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/PassKeyViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyViewModel.kt similarity index 93% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/PassKeyViewModel.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyViewModel.kt index 5feb660352..345fcb0e5c 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/PassKeyViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyViewModel.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.passkey import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/PasskeyListItem.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/component/PasskeyListItem.kt similarity index 61% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/PasskeyListItem.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/component/PasskeyListItem.kt index bf6469498b..c8fbde5ded 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/PasskeyListItem.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/component/PasskeyListItem.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.passkey.component import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box @@ -7,13 +7,14 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.setting.presentation.R @Composable @@ -25,7 +26,7 @@ fun PasskeyListItem(modifier: Modifier = Modifier) { .padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically, ) { - Image(painterResource(R.drawable.ic_apple_login), contentDescription = "패스키기본") + Image(painterResource(R.drawable.setting_ic_apple_login), contentDescription = "패스키기본") Spacer(modifier = Modifier.weight(1f)) Column { Text("이름") @@ -33,14 +34,16 @@ fun PasskeyListItem(modifier: Modifier = Modifier) { } Spacer(modifier = Modifier.weight(1f)) Box { - Image(painterResource(R.drawable.ic_vector1), contentDescription = "왼쪽 막대기") - Image(painterResource(R.drawable.ic_vector2), contentDescription = "오른쪽 막대기") + Icon( + painter = painterResource(R.drawable.setting_ic_vector1), + contentDescription = "왼쪽 막대기", + tint = AfternoteDesign.colors.gray5, + ) + Icon( + painter = painterResource(R.drawable.setting_ic_vector2), + contentDescription = "오른쪽 막대기", + tint = AfternoteDesign.colors.gray5, + ) } } } - -@Preview(showBackground = true) -@Composable -private fun PasskeyListItemPrev() { - PasskeyListItem() -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt new file mode 100644 index 0000000000..24b699a0ec --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt @@ -0,0 +1,139 @@ +package com.afternote.feature.setting.presentation.password + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.asString +import com.afternote.core.ui.mvi.ObserveSignal +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.setting.presentation.R + +/** + * 비밀번호 변경 화면의 상태 없는 본문. + * + * 시안 대조는 하지 않았다 — 이 화면 전용 시안을 열 수단이 없었다. 배치는 같은 저장소의 새 비밀번호 + * 입력 선례(회원가입 3단계·비밀번호 찾기)를 따랐고, 문구와 간격은 시안이 확인되면 그때 맞춘다. + */ +@Composable +internal fun PasswordChangeContent( + uiState: PasswordChangeUiState, + onIntent: (PasswordChangeIntent) -> Unit, + onBackClick: () -> Unit, + onChanged: () -> Unit, + modifier: Modifier = Modifier, +) { + val currentPasswordState = rememberTextFieldState() + val newPasswordState = rememberTextFieldState() + // LaunchedEffect 는 키가 그대로면 최초 람다를 계속 들고 있다. 재구성으로 새로 온 핸들러를 쓰도록 + // 최신 값을 읽는다 — 키에 핸들러를 넣으면 매 재구성마다 수집이 끊겼다 다시 붙는다. + val currentOnIntent by rememberUpdatedState(onIntent) + + LaunchedEffect(currentPasswordState) { + snapshotFlow { currentPasswordState.text.toString() }.collect { value -> + currentOnIntent(PasswordChangeIntent.UpdateCurrentPassword(value)) + } + } + LaunchedEffect(newPasswordState) { + snapshotFlow { newPasswordState.text.toString() }.collect { value -> + currentOnIntent(PasswordChangeIntent.UpdateNewPassword(value)) + } + } + + ObserveSignal( + signal = uiState.changed, + consumed = PasswordChangeIntent.ConsumeChanged, + onIntent = onIntent, + ) { + onChanged() + } + + FlowStepScaffold( + topBarTitle = stringResource(R.string.setting_account_password_change), + actionButtonText = stringResource(R.string.setting_password_change_submit), + onBackClick = onBackClick, + onActionClick = { onIntent(PasswordChangeIntent.Submit) }, + modifier = modifier, + isActionEnabled = uiState.isSubmitEnabled, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Spacer(modifier = Modifier.height(35.dp)) + + PasswordFieldLabel(text = stringResource(R.string.setting_password_change_current_label)) + AfternoteTextField( + state = currentPasswordState, + placeholder = stringResource(R.string.setting_password_change_current_placeholder), + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + PasswordFieldLabel(text = stringResource(R.string.setting_password_change_new_label)) + AfternoteTextField( + state = newPasswordState, + placeholder = stringResource(R.string.setting_password_change_new_placeholder), + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + onImeAction = { onIntent(PasswordChangeIntent.Submit) }, + ) + + Text( + text = stringResource(R.string.setting_password_change_rule), + modifier = Modifier.fillMaxWidth(), + style = AfternoteDesign.typography.captionLargeB, + color = + if (uiState.isNewPasswordRuleSatisfied) { + AfternoteDesign.colors.b1 + } else { + AfternoteDesign.colors.gray5 + }, + ) + + uiState.errorMessage?.let { message -> + Text( + text = message.asString(), + modifier = Modifier.fillMaxWidth(), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.error, + ) + } + } + } +} + +@Composable +private fun PasswordFieldLabel( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = modifier.fillMaxWidth(), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeFailureMessage.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeFailureMessage.kt new file mode 100644 index 0000000000..c57d74dab5 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeFailureMessage.kt @@ -0,0 +1,25 @@ +package com.afternote.feature.setting.presentation.password + +import com.afternote.core.domain.error.CoreAuthFailure +import com.afternote.core.ui.UiText +import com.afternote.feature.setting.presentation.R + +/** + * 비밀번호 변경 실패를 표시 문구로 옮긴다 — **타입만** 보고 고르고 서버 `message` 는 쓰지 않는다 + * (BE#92 — 서버 문구가 사용자 노출용이라는 규정이 없다). 같은 규칙의 선례가 + * [com.afternote.feature.setting.presentation.receiver.toReceiverFailureMessage] 다. + * + * 사유가 확인된 것만 전용 문구를 갖고 나머지는 폴백으로 내려앉는다. 폴백으로 내려앉는 것 중 가장 + * 흔한 것은 **현재 비밀번호 불일치**(BE `PASSWORD_MISMATCH`, code 1202)다 — 이 코드는 + * `mapAccountFailure` 가 아직 번역하지 않아 원본 예외로 도착한다. 이 화면은 현재의 실패 계약을 + * 그대로 소비하며, 1202에 대한 전용 번역은 추가하지 않는다. + */ +internal fun Throwable.toPasswordChangeMessage(): UiText = + UiText.Resource( + when (this) { + is CoreAuthFailure.NetworkUnavailable -> R.string.setting_password_change_network_error + is CoreAuthFailure.PasswordUnchanged -> R.string.setting_password_change_unchanged + is CoreAuthFailure.SocialSignUpAccount -> R.string.setting_password_change_social_blocked + else -> R.string.setting_password_change_failed + }, + ) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeScreen.kt new file mode 100644 index 0000000000..741684ea9d --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeScreen.kt @@ -0,0 +1,30 @@ +package com.afternote.feature.setting.presentation.password + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +/** + * 설정 > 계정 > 비밀번호 변경 (#564). + * + * ViewModel 은 여기서 들고, 그리는 일은 [PasswordChangeContent] 가 한다. + */ +@Composable +internal fun PasswordChangeScreen( + onBackClick: () -> Unit, + onChanged: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PasswordChangeViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + PasswordChangeContent( + uiState = uiState, + onIntent = viewModel::onIntent, + onBackClick = onBackClick, + onChanged = onChanged, + modifier = modifier, + ) +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt new file mode 100644 index 0000000000..5810bed96c --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt @@ -0,0 +1,81 @@ +package com.afternote.feature.setting.presentation.password + +import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.core.ui.mvi.UiState + +/** + * 설정 > 비밀번호 변경 화면 상태. + * + * 입력 칸이 둘인 것은 서버 계약 그대로다 — BE `PasswordChangeRequest` 는 `currentPassword`· + * `newPassword` 두 필드뿐이다. 「새 비밀번호 확인」 칸은 비밀번호 **찾기**(`auth/password/find`)의 + * `confirmPassword` 계약에만 있고 변경에는 없어 두지 않는다. + */ +internal data class PasswordChangeUiState( + val currentPassword: String = "", + val newPassword: String = "", + val isSubmitting: Boolean = false, + /** + * 입력 아래에 그대로 남는 실패 안내. 다음 입력·다음 제출이 지운다. + * + * 일회성 신호가 아니다 — 어느 칸을 고쳐야 하는지 읽는 동안 남아 있어야 한다. + */ + val errorMessage: UiText? = null, + /** 변경 완료 신호. 화면이 한 번 소비하고 [PasswordChangeIntent.ConsumeChanged] 로 되돌린다(#228). */ + val changed: PasswordChanged? = null, +) : UiState { + val isNewPasswordRuleSatisfied: Boolean get() = SettingPasswordRule.isSatisfied(newPassword) + + /** + * 제출 가능 여부. + * + * 현재 비밀번호는 비어 있지만 않으면 된다 — 규칙 검사를 걸면 옛 규칙으로 만든 비밀번호를 쓰는 + * 계정이 변경 자체를 못 한다([SettingPasswordRule]). 새 비밀번호가 현재와 같은지는 서버만 + * 판정할 수 있다(BE `NEWPASSWORD_MATCH`) — 클라는 현재 비밀번호의 평문을 비교 근거로 삼지 않는다. + */ + val isSubmitEnabled: Boolean + get() = !isSubmitting && currentPassword.isNotEmpty() && isNewPasswordRuleSatisfied +} + +/** + * [PasswordChangeUiState.changed] 가 나르는 완료 신호. + * + * `Boolean` 대신 전용 타입인 것은 소비 관용구 `ObserveSignal` 이 non-null 값을 키로 쓰기 때문이다 — + * `false` 도 값이라 플래그로 두면 화면 진입 즉시 한 번 소비된다. + */ +internal data object PasswordChanged + +internal sealed interface PasswordChangeIntent : MviIntent { + data class UpdateCurrentPassword( + val value: String, + ) : PasswordChangeIntent + + data class UpdateNewPassword( + val value: String, + ) : PasswordChangeIntent + + data object Submit : PasswordChangeIntent + + data object ConsumeChanged : PasswordChangeIntent +} + +internal sealed interface PasswordChangeReducerEvent : ReducerEvent { + data class CurrentPasswordChanged( + val value: String, + ) : PasswordChangeReducerEvent + + data class NewPasswordChanged( + val value: String, + ) : PasswordChangeReducerEvent + + data object SubmitStarted : PasswordChangeReducerEvent + + data object Succeeded : PasswordChangeReducerEvent + + data class Failed( + val message: UiText, + ) : PasswordChangeReducerEvent + + data object ChangedConsumed : PasswordChangeReducerEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModel.kt new file mode 100644 index 0000000000..aa6d6c88be --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModel.kt @@ -0,0 +1,96 @@ +package com.afternote.feature.setting.presentation.password + +import androidx.lifecycle.viewModelScope +import com.afternote.core.domain.repository.account.AccountRepository +import com.afternote.core.ui.mvi.MviViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * 설정 > 비밀번호 변경. + * + * 서버·네트워크·데이터·도메인 계약은 이미 있고(`AccountRepository.passwordChange` → + * `POST auth/password/change`) 여기가 그 첫 소비처다. + */ +@HiltViewModel +internal class PasswordChangeViewModel + @Inject + constructor( + private val accountRepository: AccountRepository, + ) : MviViewModel( + PasswordChangeUiState(), + ) { + override fun onIntent(intent: PasswordChangeIntent) { + when (intent) { + is PasswordChangeIntent.UpdateCurrentPassword -> { + dispatch(PasswordChangeReducerEvent.CurrentPasswordChanged(intent.value)) + } + + is PasswordChangeIntent.UpdateNewPassword -> { + dispatch(PasswordChangeReducerEvent.NewPasswordChanged(intent.value)) + } + + PasswordChangeIntent.Submit -> { + submit() + } + + PasswordChangeIntent.ConsumeChanged -> { + dispatch(PasswordChangeReducerEvent.ChangedConsumed) + } + } + } + + override fun reduce( + state: PasswordChangeUiState, + event: PasswordChangeReducerEvent, + ): PasswordChangeUiState = + when (event) { + // 입력이 바뀌면 직전 실패 안내는 지운다 — 고친 값에 대고 옛 사유를 읽히지 않는다. + is PasswordChangeReducerEvent.CurrentPasswordChanged -> { + state.copy(currentPassword = event.value, errorMessage = null) + } + + is PasswordChangeReducerEvent.NewPasswordChanged -> { + state.copy(newPassword = event.value, errorMessage = null) + } + + PasswordChangeReducerEvent.SubmitStarted -> { + state.copy(isSubmitting = true, errorMessage = null) + } + + PasswordChangeReducerEvent.Succeeded -> { + state.copy(isSubmitting = false, errorMessage = null, changed = PasswordChanged) + } + + is PasswordChangeReducerEvent.Failed -> { + state.copy(isSubmitting = false, errorMessage = event.message) + } + + PasswordChangeReducerEvent.ChangedConsumed -> { + state.copy(changed = null) + } + } + + /** + * 버튼이 비활성이어도 다시 막는다 — IME 의 완료 액션처럼 버튼을 거치지 않는 제출 경로가 + * 같은 진입점으로 들어온다. + */ + private fun submit() { + val state = currentState + if (!state.isSubmitEnabled) return + + dispatch(PasswordChangeReducerEvent.SubmitStarted) + viewModelScope.launch { + accountRepository + .passwordChange( + currentPassword = state.currentPassword, + newPassword = state.newPassword, + ).onSuccess { + dispatch(PasswordChangeReducerEvent.Succeeded) + }.onFailure { error -> + dispatch(PasswordChangeReducerEvent.Failed(error.toPasswordChangeMessage())) + } + } + } + } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/SettingPasswordRule.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/SettingPasswordRule.kt new file mode 100644 index 0000000000..9603577ca6 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/SettingPasswordRule.kt @@ -0,0 +1,30 @@ +package com.afternote.feature.setting.presentation.password + +/** + * 설정의 비밀번호 변경이 **새 비밀번호** 에 요구하는 조합 규칙. + * + * **서버 규칙과 같다.** BE `domain/auth/dto/PasswordValidation.REGEX` 를 그대로 옮겼다(2026-09-13 + * Afternote-BE main 실코드 확인) — 영문·숫자·허용 특수문자(`@ $ ! % * # ? &`)를 각각 하나 이상 + * 포함하고, 전체 문자는 그 ASCII 허용 목록 안에서만 8~15자여야 한다. 서버는 이 상수 하나를 + * `SignupRequest`·`PasswordChangeRequest`·`PasswordFindRequest` 세 곳에 `@Pattern` 으로 걸고 + * 컨트롤러 진입 전에 거절하므로, 클라가 더 넓으면 사용자는 조건 충족 표시를 다 받고 제출에서 400 을 맞는다. + * + * Android 정규식의 `\d` 는 Unicode 숫자까지 포함해 전각 `1`·아라비아-인도 `١` 을 통과시킨다. + * 서버 JVM 의 기본 판정과 갈리지 않도록 숫자를 `[0-9]` 로 명시한다(#1628). + * + * ### 온보딩의 `OnboardingPasswordRule` 과 사본이 둘이다 + * #1644 는 「규칙 사본은 한 곳」을 목표로 두었고 그때 설정의 비밀번호 변경은 화면·호출부가 없어 + * 대상이 아니었다. 지금 그 소비처가 생겼지만 온보딩 쪽 객체는 `feature:onboarding:presentation` + * 의 `internal` 이라 모듈 밖에서 쓸 수 없고, 공용 자리로 옮기는 일은 남의 모듈 프로덕션을 고치는 + * 일이라 이 변경의 범위 밖이다. 값을 고칠 땐 **BE 실코드를 정본으로** 두 사본을 함께 고친다. + * + * 현재 비밀번호에는 이 규칙을 걸지 않는다 — 옛 규칙으로 만든 비밀번호가 그대로 살아 있을 수 있어 + * 클라가 막으면 변경 자체가 불가능해진다(로그인에 규칙을 걸지 않는 것과 같은 판단). + */ +internal object SettingPasswordRule { + /** 8~15자, 영문·숫자·허용 특수문자 8종 각 1개 이상, 그 밖의 문자는 금지. */ + private val REGEX = + Regex("^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[@$!%*#?&])[A-Za-z0-9@$!%*#?&]{8,15}$") + + fun isSatisfied(password: String): Boolean = REGEX.matches(password) +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ProfileEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt similarity index 79% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ProfileEditScreen.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt index 778d02c4c5..5bb9aff051 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ProfileEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt @@ -1,6 +1,5 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.profile -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -9,8 +8,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.input.InputTransformation @@ -27,7 +24,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -37,9 +33,7 @@ import com.afternote.core.ui.button.AfternoteButtonType import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.viewmodel.ProfileEditEvent -import com.afternote.feature.setting.presentation.viewmodel.ProfileEditUiState -import com.afternote.feature.setting.presentation.viewmodel.ProfileEditViewModel +import com.afternote.feature.setting.presentation.shared.component.ProfilePhotoWithAddBadge @Composable fun ProfileEditScreen( @@ -129,26 +123,7 @@ private fun ProfileEditForm( .padding(top = 50.dp), contentAlignment = Alignment.Center, ) { - Box(modifier = Modifier.size(134.dp)) { - Image( - painter = painterResource(R.drawable.ic_default_profile), - contentDescription = "기본", - modifier = Modifier.fillMaxSize(), - ) - Box( - modifier = - Modifier - .align(Alignment.BottomEnd) - .size(48.dp), - contentAlignment = Alignment.Center, - ) { - Image( - painter = painterResource(R.drawable.ic_plus), - contentDescription = "추가", - modifier = Modifier.requiredSize(72.dp), - ) - } - } + ProfilePhotoWithAddBadge() } } item { @@ -239,23 +214,3 @@ private fun ProfileEditForm( } } } - -@Preview(showBackground = true) -@Composable -private fun ProfileEditScreenPrev() { - Scaffold( - topBar = { DetailTopBar(title = "프로필 설정") }, - ) { innerPadding -> - ProfileEditForm( - state = - ProfileEditUiState.Success( - name = "박서연", - phone = "01012345678", - email = "afternote@email.com", - ), - onUpdateClick = { _, _ -> }, - onWithdrawGuideClick = {}, - modifier = Modifier.padding(innerPadding), - ) - } -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ProfileEditUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt similarity index 87% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ProfileEditUiState.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt index 1ef215ca2a..bc4fbf71d1 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ProfileEditUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.profile sealed interface ProfileEditUiState { data object Loading : ProfileEditUiState diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ProfileEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt similarity index 87% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ProfileEditViewModel.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt index 3782b73928..6fabdd2988 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ProfileEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt @@ -1,8 +1,8 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.MyProfileRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -16,7 +16,7 @@ import javax.inject.Inject class ProfileEditViewModel @Inject constructor( - private val userRepository: UserRepository, + private val myProfileRepository: MyProfileRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(ProfileEditUiState.Loading) val uiState = _uiState.asStateFlow() @@ -30,7 +30,7 @@ class ProfileEditViewModel private fun loadProfile() { viewModelScope.launch { - runCatching { userRepository.getMyProfile() } + runCatching { myProfileRepository.getMyProfile() } .onSuccess { user -> _uiState.value = ProfileEditUiState.Success( @@ -52,7 +52,7 @@ class ProfileEditViewModel _uiState.update { current.copy(isUpdating = true) } viewModelScope.launch { runCatching { - userRepository.updateMyProfile( + myProfileRepository.updateMyProfile( name = name.takeIf { it.isNotBlank() }, phone = phone.takeIf { it.isNotBlank() }, profileImageUrl = null, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContent.kt similarity index 69% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverEditScreen.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContent.kt index edd8e7e507..12791f9ea6 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContent.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.receiver import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -7,37 +7,24 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.asString import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar -import com.afternote.feature.setting.presentation.viewmodel.ReceiverEditEvent -import com.afternote.feature.setting.presentation.viewmodel.ReceiverEditViewModel +/** + * 수신자 수정 화면의 상태 없는 본문. + * + * ViewModel·수정 성공 이벤트는 [ReceiverEditScreen] 이 들고, 이 함수는 넘겨받은 상태만 그린다. + */ @Composable -fun ReceiverEditScreen( +internal fun ReceiverEditContent( + uiState: ReceiverEditUiState, onBackClick: () -> Unit, - onEditSuccess: () -> Unit, + onRegister: (name: String, relation: String, phone: String, email: String, message: String) -> Unit, modifier: Modifier = Modifier, - viewModel: ReceiverEditViewModel = hiltViewModel(), ) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val currentOnEditSuccess by rememberUpdatedState(onEditSuccess) - - LaunchedEffect(Unit) { - viewModel.events.collect { event -> - when (event) { - ReceiverEditEvent.EditSuccess -> currentOnEditSuccess() - } - } - } - val receiver = uiState.receiver if (receiver != null) { ReceiverRegisterContent( @@ -47,7 +34,7 @@ fun ReceiverEditScreen( isLoading = uiState.isSaving, errorMessage = uiState.errorMessage, onBackClick = onBackClick, - onRegister = viewModel::update, + onRegister = onRegister, modifier = modifier, initialName = receiver.name, initialRelation = receiver.relation, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt new file mode 100644 index 0000000000..96280cd46d --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt @@ -0,0 +1,34 @@ +package com.afternote.feature.setting.presentation.receiver + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +internal fun ReceiverEditScreen( + onBackClick: () -> Unit, + onEditSuccess: () -> Unit, + viewModel: ReceiverEditViewModel, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val currentOnEditSuccess by rememberUpdatedState(onEditSuccess) + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + ReceiverEditEvent.EditSuccess -> currentOnEditSuccess() + } + } + } + + ReceiverEditContent( + uiState = uiState, + onBackClick = onBackClick, + onRegister = viewModel::update, + modifier = modifier, + ) +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditUiState.kt similarity index 85% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditUiState.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditUiState.kt index 4775d9f9c7..376bb44a08 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.receiver import com.afternote.core.model.user.ReceiverDetail import com.afternote.core.ui.UiText diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt similarity index 71% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditViewModel.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt index 2d1b0dbf41..7c190f0cbb 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt @@ -1,14 +1,15 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.receiver -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.navigation.toRoute import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.ui.UiText import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.navigation.SettingRoute +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -16,16 +17,19 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject -@HiltViewModel -class ReceiverEditViewModel - @Inject +/** + * 수정 대상 [SettingRoute.RecipientEditRoute] 는 assisted 로 받는다 — Nav3 entry 에는 Nav2 의 + * `savedStateHandle.toRoute()` 자동 채움이 없다 (#1695). + */ +@HiltViewModel(assistedFactory = ReceiverEditViewModel.Factory::class) +internal class ReceiverEditViewModel + @AssistedInject constructor( - savedStateHandle: SavedStateHandle, - private val userRepository: UserRepository, + @Assisted route: SettingRoute.RecipientEditRoute, + private val receiverRepository: UserReceiverRepository, ) : ViewModel() { - private val receiverId = savedStateHandle.toRoute().receiverId + private val receiverId = route.receiverId private val _uiState = MutableStateFlow(ReceiverEditUiState()) val uiState = _uiState.asStateFlow() @@ -39,12 +43,12 @@ class ReceiverEditViewModel private fun loadReceiver() { viewModelScope.launch { - runCatchingCancellable { userRepository.getReceiverDetail(receiverId) } + runCatchingCancellable { receiverRepository.getReceiverDetail(receiverId) } .onSuccess { receiver -> _uiState.update { it.copy(isLoading = false, receiver = receiver) } }.onFailure { _uiState.update { - it.copy(isLoading = false, errorMessage = UiText.Resource(R.string.receiver_load_failed)) + it.copy(isLoading = false, errorMessage = UiText.Resource(R.string.setting_receiver_load_failed)) } } } @@ -59,16 +63,16 @@ class ReceiverEditViewModel ) { if (_uiState.value.isSaving) return if (!email.isValidReceiverEmail()) { - _uiState.update { it.copy(errorMessage = UiText.Resource(R.string.receiver_email_invalid)) } + _uiState.update { it.copy(errorMessage = UiText.Resource(R.string.setting_receiver_email_invalid)) } return } val phoneValidation = phone.validateReceiverPhone(isRequired = true) if (phoneValidation != ReceiverPhoneValidation.VALID) { val messageRes = if (phoneValidation == ReceiverPhoneValidation.REQUIRED) { - R.string.receiver_phone_required + R.string.setting_receiver_phone_required } else { - R.string.receiver_phone_invalid + R.string.setting_receiver_phone_invalid } _uiState.update { it.copy(errorMessage = UiText.Resource(messageRes)) } return @@ -78,7 +82,7 @@ class ReceiverEditViewModel viewModelScope.launch { val receiverUpdateResult = runCatchingCancellable { - userRepository.updateReceiver( + receiverRepository.updateReceiver( receiverId = receiverId, name = name, phone = phone.normalizeReceiverPhone(), @@ -90,14 +94,14 @@ class ReceiverEditViewModel _uiState.update { it.copy( isSaving = false, - errorMessage = error.toReceiverFailureMessage(R.string.receiver_edit_failed), + errorMessage = error.toReceiverFailureMessage(R.string.setting_receiver_edit_failed), ) } return@launch } runCatchingCancellable { - userRepository.updateReceiverMessage( + receiverRepository.updateReceiverMessage( receiverId = receiverId, message = message, ) @@ -108,10 +112,15 @@ class ReceiverEditViewModel _uiState.update { it.copy( isSaving = false, - errorMessage = UiText.Resource(R.string.receiver_message_update_partial_failed), + errorMessage = UiText.Resource(R.string.setting_receiver_message_update_partial_failed), ) } } } } + + @AssistedFactory + interface Factory { + fun create(route: SettingRoute.RecipientEditRoute): ReceiverEditViewModel + } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverFailureMessage.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverFailureMessage.kt new file mode 100644 index 0000000000..9c7e7d4ce1 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverFailureMessage.kt @@ -0,0 +1,23 @@ +package com.afternote.feature.setting.presentation.receiver + +import androidx.annotation.StringRes +import com.afternote.core.domain.error.ReceiverRequestRejectedException +import com.afternote.core.ui.UiText +import com.afternote.feature.setting.presentation.R + +/** + * [ReceiverRequestRejectedException] 은 **타입만** 보고 문구를 고른다. 서버 원문은 + * 사용자 화면에 노출하지 않으므로(BE#92 — 서버 `message` 는 사용자 노출용이라는 규정이 없다), 표시 + * 문구는 여기 로컬 리소스가 갖는다 — feature/receiver 의 ReceiverFailureTranslation.kt 이 확정한 + * 선례(#1339)와 같은 규칙이다. + * + * 예외의 `message`도 고정 진단 문구이며 사용자 표시 문구로 사용하지 않는다. + */ +internal fun Throwable.toReceiverFailureMessage( + @StringRes fallbackResId: Int, +): UiText = + if (this is ReceiverRequestRejectedException) { + UiText.Resource(R.string.setting_receiver_request_rejected) + } else { + UiText.Resource(fallbackResId) + } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListContract.kt new file mode 100644 index 0000000000..5ae8615f29 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListContract.kt @@ -0,0 +1,36 @@ +package com.afternote.feature.setting.presentation.receiver + +import com.afternote.core.domain.model.ReceiverListState +import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.core.ui.mvi.UiState + +internal data class ReceiverListUiState( + val receivers: List = emptyList(), + val loadState: ReceiverListLoadState = ReceiverListLoadState.Loading, + val sessionId: String? = null, +) : UiState + +internal sealed interface ReceiverListIntent : MviIntent { + data object ObservationStarted : ReceiverListIntent + + data object ObservationStopped : ReceiverListIntent + + data object Retry : ReceiverListIntent +} + +internal sealed interface ReceiverListReducerEvent : ReducerEvent { + data object RetryStarted : ReceiverListReducerEvent + + data class RepositoryStateChanged( + val state: ReceiverListState, + ) : ReceiverListReducerEvent +} + +internal enum class ReceiverListLoadState { + Loading, + Ready, + Failure, + RefreshFailure, +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListRouteContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListRouteContent.kt new file mode 100644 index 0000000000..a63d949d9a --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListRouteContent.kt @@ -0,0 +1,62 @@ +package com.afternote.feature.setting.presentation.receiver + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.ui.loading.ListRefreshErrorBanner +import com.afternote.core.ui.loading.LoadingBody +import com.afternote.core.ui.receiver.ReceiverListLoadFailure + +/** + * 설정 수신자 목록 라우트의 조회 상태 분기 (#1281). 관리·선택 두 진입이 같은 규칙을 쓴다. + * + * 보여 줄 행이 없을 때만 목록 자리를 로딩이나 실패 안내로 바꾼다. 행이 있으면 조회 중에도, 다시 불러오기가 + * 실패해도 행과 검색·선택을 그대로 둔다. 0건 안내와 등록 버튼은 조회에 성공해 실제로 0건일 때만 나온다. + * + * 선택 진입은 공용 `ReceiverSelectScreen` 의 `listReplacement` 로만 넘긴다. 목록을 남긴 채 얹는 갱신 실패 + * 배너는 그 슬롯으로 표현할 수 없어 관리 진입에만 있다. + */ +@Composable +internal fun ReceiverListRouteContent( + uiState: ReceiverListUiState, + selectForDeliveryConditions: Boolean, + onBackClick: () -> Unit, + onRetryClick: () -> Unit, + onConfirmClick: (ReceiverListItem) -> Unit, + onReceiverClick: (Long) -> Unit, + onRegisterClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val listReplacement: (@Composable () -> Unit)? = + when { + uiState.receivers.isNotEmpty() -> null + uiState.loadState == ReceiverListLoadState.Loading -> ({ LoadingBody() }) + uiState.loadState == ReceiverListLoadState.Failure -> ({ ReceiverListLoadFailure(onRetryClick = onRetryClick) }) + else -> null + } + + if (selectForDeliveryConditions) { + ReceiverListScreen( + receivers = uiState.receivers, + onBackClick = onBackClick, + onConfirmClick = onConfirmClick, + modifier = modifier, + listReplacement = listReplacement, + ) + } else { + ReceiverManageScreen( + receivers = uiState.receivers, + onBackClick = onBackClick, + onReceiverClick = onReceiverClick, + onRegisterClick = onRegisterClick, + modifier = modifier, + listReplacement = listReplacement, + listHeader = + if (uiState.loadState == ReceiverListLoadState.RefreshFailure) { + { ListRefreshErrorBanner(onRetry = onRetryClick) } + } else { + null + }, + ) + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverListScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListScreen.kt similarity index 61% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverListScreen.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListScreen.kt index ea6717d6ab..19595dbb79 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverListScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListScreen.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.receiver import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -6,29 +6,34 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.model.setting.ReceiverListItem import com.afternote.core.ui.receiver.ReceiverSelectItem import com.afternote.core.ui.receiver.ReceiverSelectScreen /** - * 설정의 수신자 목록(선택) 화면 — 공용 [ReceiverSelectScreen] 소비 (#791). + * 설정의 "사후 전달 조건" 수신자 선택 화면 — 공용 [ReceiverSelectScreen] 소비 (#791). * * 검색·초성 인덱스·단일 선택·완료 UI 는 공용 화면이 그리고, 여기서는 - * 설정 모델 매핑과 화면 내 선택 상태만 소유한다. 공개 시그니처는 추출 전과 같다. + * 설정 모델 매핑과 화면 내 선택 상태만 소유한다. 추출 전 인자는 그대로 받는다. + * 조회 중·실패 표시는 호출부가 [listReplacement] 로 넘기고(#1281) 공용 화면의 같은 슬롯에 그대로 전달한다. + * + * 평범한 "수신자 목록" 열람·수정 진입은 #631 로 관리 화면([ReceiverManageScreen])이 맡고, + * 이 화면은 사후 전달 조건 진입(`RecipientListRoute.selectForDeliveryConditions == true`) + * 시의 선택 플로우에만 쓰인다. */ @Composable -fun ReceiverListScreen( +internal fun ReceiverListScreen( receivers: List, onBackClick: () -> Unit, onConfirmClick: (ReceiverListItem) -> Unit, modifier: Modifier = Modifier, + listReplacement: (@Composable () -> Unit)? = null, ) { var selectedId by remember { mutableStateOf(null) } ReceiverSelectScreen( - // 유일하게 공용 기본값("수신자 선택")을 벗어나는 소비자다 — #631 이 이 화면을 - // 관리 화면으로 바꾸면 선택 컴포넌트 소비 자체가 사라진다. + // 유일하게 공용 기본값("수신자 선택")을 벗어나는 소비자다 — 사후 전달 조건 + // 진입 맥락에서도 화면 제목은 "수신자 목록"을 유지한다. title = "수신자 목록", receivers = remember(receivers) { @@ -43,20 +48,6 @@ fun ReceiverListScreen( receivers.find { it.receiverId == receiverId }?.let(onConfirmClick) }, modifier = modifier, - ) -} - -@Preview(showBackground = true) -@Composable -private fun ReceiverListScreenPrev() { - ReceiverListScreen( - receivers = - listOf( - ReceiverListItem(receiverId = 1L, name = "박경민", relation = "친구"), - ReceiverListItem(receiverId = 2L, name = "김철수", relation = "가족"), - ReceiverListItem(receiverId = 3L, name = "이영희", relation = "연인"), - ), - onBackClick = {}, - onConfirmClick = { _ -> }, + listReplacement = listReplacement, ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListViewModel.kt new file mode 100644 index 0000000000..7bc44417ca --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListViewModel.kt @@ -0,0 +1,117 @@ +package com.afternote.feature.setting.presentation.receiver + +import androidx.lifecycle.viewModelScope +import com.afternote.core.domain.model.ReceiverListState +import com.afternote.core.domain.repository.UserReceiverRepository +import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.model.user.Receiver +import com.afternote.core.ui.mvi.MviViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class ReceiverListViewModel + @Inject + constructor( + private val receiverRepository: UserReceiverRepository, + ) : MviViewModel(ReceiverListUiState()) { + private var observation: Job? = null + private var pendingStop: Job? = null + + override fun onIntent(intent: ReceiverListIntent) { + when (intent) { + ReceiverListIntent.ObservationStarted -> startObservation() + ReceiverListIntent.ObservationStopped -> stopObservation() + ReceiverListIntent.Retry -> retry() + } + } + + override fun reduce( + state: ReceiverListUiState, + event: ReceiverListReducerEvent, + ): ReceiverListUiState = + when (event) { + ReceiverListReducerEvent.RetryStarted -> state.copy(loadState = ReceiverListLoadState.Loading) + is ReceiverListReducerEvent.RepositoryStateChanged -> state.withRepositoryState(event.state) + } + + private fun retry() { + if (observation?.isActive != true) return + when (currentState.loadState) { + ReceiverListLoadState.Failure, ReceiverListLoadState.RefreshFailure -> { + dispatch(ReceiverListReducerEvent.RetryStarted) + receiverRepository.refreshReceiverList() + } + + ReceiverListLoadState.Loading, ReceiverListLoadState.Ready -> { + Unit + } + } + } + + private fun startObservation() { + pendingStop?.cancel() + pendingStop = null + if (observation?.isActive == true) return + observation = + viewModelScope.launch { + receiverRepository.receiverListStateFlow.collect { state -> + dispatch(ReceiverListReducerEvent.RepositoryStateChanged(state)) + } + } + } + + private fun stopObservation() { + if (pendingStop?.isActive == true) return + pendingStop = + viewModelScope.launch { + // 기존 WhileSubscribed(5_000)의 재구독 유예와 마지막 목록을 유지한다. + delay(OBSERVATION_STOP_TIMEOUT_MILLIS) + observation?.cancel() + observation = null + } + } + + private companion object { + const val OBSERVATION_STOP_TIMEOUT_MILLIS = 5_000L + } + } + +private fun ReceiverListUiState.withRepositoryState(state: ReceiverListState): ReceiverListUiState = + when (state) { + ReceiverListState.SignedOut -> { + ReceiverListUiState() + } + + is ReceiverListState.Loading -> { + copy( + receivers = state.previousReceivers?.toItems() ?: receivers.takeIf { sessionId == state.sessionId }.orEmpty(), + loadState = ReceiverListLoadState.Loading, + sessionId = state.sessionId, + ) + } + + is ReceiverListState.Success -> { + copy( + receivers = state.receivers.toItems(), + loadState = ReceiverListLoadState.Ready, + ) + } + + is ReceiverListState.Failure -> { + val remaining = + when { + state.discardPrevious -> emptyList() + else -> state.previousReceivers?.toItems() ?: receivers + } + copy( + receivers = remaining, + loadState = if (remaining.isEmpty()) ReceiverListLoadState.Failure else ReceiverListLoadState.RefreshFailure, + ) + } + } + +private fun List.toItems(): List = map { ReceiverListItem(it.receiverId, it.name, it.relation) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverManageScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverManageScreen.kt new file mode 100644 index 0000000000..1de6fa03df --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverManageScreen.kt @@ -0,0 +1,173 @@ +package com.afternote.feature.setting.presentation.receiver + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.ui.ProfileImage +import com.afternote.core.ui.button.AfternoteButton +import com.afternote.core.ui.receiver.ReceiverProfileRow +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.setting.presentation.R + +/** + * 설정 > 수신자 관리 목록 화면 (#631). + * + * 이전에는 공용 선택 UI([com.afternote.core.ui.receiver.ReceiverSelectScreen])를 그대로 + * 소비해 체크박스·완료 버튼이 있는 "선택 화면"으로 동작했다. 여기서는 목록 열람·수정 진입만 + * 다루는 관리 화면으로, 행 탭이 곧바로 수신자 수정 화면(#595)으로 이동한다. + * + * 수신자가 0건이면 목록 대신 [ReceiverManageEmpty] 안내를 그린다 (#556). 이 화면엔 검색창이 + * 없어서 검색 결과 0건 축은 여기 해당하지 않는다. 그 축은 검색어를 쥔 core:ui 안에서 닫는다. + * + * 0건이 조회 성공의 결과인지는 이 화면이 모른다. 조회 중·실패일 때는 호출부가 [listReplacement] 를 넘겨 + * 0건 안내 대신 그리게 한다 (#1281). + * + * @param listReplacement 목록 영역을 통째로 대체할 상태 화면. null 이면 목록이나 0건 안내를 그린다. + * @param listHeader 목록 위에 얹을 안내. 행은 그대로 둔다. + */ +@Composable +internal fun ReceiverManageScreen( + receivers: List, + onBackClick: () -> Unit, + onReceiverClick: (Long) -> Unit, + onRegisterClick: () -> Unit, + modifier: Modifier = Modifier, + listReplacement: (@Composable () -> Unit)? = null, + listHeader: (@Composable () -> Unit)? = null, +) { + Scaffold( + modifier = modifier, + containerColor = Color.Transparent, + topBar = { + DetailTopBar( + title = stringResource(R.string.setting_recipient_list), + onBackClick = onBackClick, + ) + }, + ) { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + listHeader?.invoke() + when { + listReplacement != null -> { + listReplacement() + } + + receivers.isEmpty() -> { + ReceiverManageEmpty( + onRegisterClick = onRegisterClick, + modifier = Modifier.fillMaxSize(), + ) + } + + else -> { + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + ) { + items(receivers, key = { it.receiverId }) { receiver -> + ReceiverManageRow( + receiver = receiver, + onClick = { onReceiverClick(receiver.receiverId) }, + ) + } + } + } + } + } + } +} + +/** + * 수신자 0건 빈 상태 (#556). 시안 3628:23840 이 안내 문구와 [수신자 등록하기] 를 함께 둔다. + * + * 문구·일러스트 구성은 같은 성격의 확정 시안(4163:20979)을 그대로 구현한 애프터노트 + * `SelectReceiverEmpty` 를 준거로 한다 (제목 → 8dp → 설명 → 56dp → 일러스트). + * 등록 CTA 는 Scaffold bottomBar 가 아니라 본문 안에 둔다. 목록 상태 슬롯(#1281)이 목록 영역을 + * 대체하는 동안 CTA 도 함께 사라지게 하려는 것이다. 조회 중·실패에 등록 버튼만 남으면 수신자가 없다고 + * 말하는 셈이 된다. + */ +@Composable +private fun ReceiverManageEmpty( + onRegisterClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .padding(horizontal = 20.dp) + .padding(top = 40.dp, bottom = 16.dp), + ) { + Text( + text = stringResource(R.string.setting_recipient_empty), + style = AfternoteDesign.typography.h1, + color = AfternoteDesign.colors.black, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.setting_recipient_empty_description), + // 애프터노트 빈 상태와 같은 근거다. 시안의 H3 는 weight 400 인데 레포 `h3` 토큰은 Bold 다. + style = AfternoteDesign.typography.h3.copy(fontWeight = FontWeight.Normal), + color = AfternoteDesign.colors.gray6, + ) + Spacer(modifier = Modifier.height(56.dp)) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + ProfileImage() + } + Spacer(modifier = Modifier.weight(1f)) + AfternoteButton( + text = stringResource(R.string.setting_recipient_empty_register), + onClick = onRegisterClick, + ) + } +} + +@Composable +private fun ReceiverManageRow( + receiver: ReceiverListItem, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ReceiverProfileRow( + name = receiver.name, + relation = receiver.relation, + onClick = onClick, + modifier = modifier, + trailing = { + Image( + painterResource(R.drawable.setting_ic_right_arrow), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + }, + ) +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidator.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverPhoneValidator.kt similarity index 94% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidator.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverPhoneValidator.kt index d61fb5f36f..f1da794fb0 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidator.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverPhoneValidator.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.receiver private val KOREAN_MOBILE_PHONE_REGEX = Regex("^01(?:0\\d{8}|[16789]\\d{7,8})$") private val HYPHENATED_PHONE_REGEX = Regex("^\\d{3}-\\d{3,4}-\\d{4}$") diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterScreen.kt similarity index 84% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterScreen.kt index 5b1b5b0b8f..d140f22595 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterScreen.kt @@ -1,6 +1,5 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.receiver -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.input.rememberTextFieldState @@ -37,7 +35,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -47,14 +44,9 @@ import com.afternote.core.ui.PhoneNumberVisualTransformation import com.afternote.core.ui.UiText import com.afternote.core.ui.asString import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.viewmodel.ReceiverPhoneValidation -import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterEvent -import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterViewModel -import com.afternote.feature.setting.presentation.viewmodel.isValidReceiverEmail -import com.afternote.feature.setting.presentation.viewmodel.validateReceiverPhone +import com.afternote.feature.setting.presentation.shared.component.ProfilePhotoWithAddBadge import com.afternote.core.ui.R as CoreR private const val CUSTOM_RELATION_OPTION = "직접 추가하기" @@ -181,26 +173,7 @@ internal fun ReceiverRegisterContent( horizontalAlignment = Alignment.CenterHorizontally, ) { item { - Box(modifier = Modifier.size(134.dp)) { - Image( - painter = painterResource(R.drawable.ic_default_profile), - contentDescription = "기본", - modifier = Modifier.fillMaxSize(), - ) - Box( - modifier = - Modifier - .align(Alignment.BottomEnd) - .size(48.dp), - contentAlignment = Alignment.Center, - ) { - Image( - painter = painterResource(R.drawable.ic_plus), - contentDescription = "추가", - modifier = Modifier.requiredSize(72.dp), - ) - } - } + ProfilePhotoWithAddBadge() } item { Spacer(modifier = Modifier.height(56.dp)) @@ -226,9 +199,9 @@ internal fun ReceiverRegisterContent( text = stringResource( if (phoneValidation == ReceiverPhoneValidation.REQUIRED) { - R.string.receiver_phone_required + R.string.setting_receiver_phone_required } else { - R.string.receiver_phone_invalid + R.string.setting_receiver_phone_invalid }, ), modifier = Modifier.fillMaxWidth(), @@ -305,7 +278,7 @@ internal fun ReceiverRegisterContent( Spacer(modifier = Modifier.height(8.dp)) AfternoteTextField( state = customRelationState, - placeholder = stringResource(R.string.receiver_custom_relation_placeholder), + placeholder = stringResource(R.string.setting_receiver_custom_relation_placeholder), ) } } @@ -322,9 +295,9 @@ internal fun ReceiverRegisterContent( text = stringResource( if (email.isBlank()) { - R.string.receiver_email_required + R.string.setting_receiver_email_required } else { - R.string.receiver_email_invalid + R.string.setting_receiver_email_invalid }, ), modifier = Modifier.fillMaxWidth(), @@ -336,12 +309,12 @@ internal fun ReceiverRegisterContent( item { Spacer(modifier = Modifier.height(24.dp)) Text( - text = stringResource(R.string.receiver_last_greeting_label), + text = stringResource(R.string.setting_receiver_last_greeting_label), modifier = Modifier.fillMaxWidth(), ) AfternoteTextField( state = messageState, - placeholder = stringResource(R.string.receiver_last_greeting_placeholder), + placeholder = stringResource(R.string.setting_receiver_last_greeting_placeholder), ) } if (errorMessage != null) { @@ -358,19 +331,3 @@ internal fun ReceiverRegisterContent( } } } - -@Preview(showBackground = true) -@Composable -private fun ReceiverRegisterContentPreview() { - AfternoteTheme { - ReceiverRegisterContent( - title = "수신자 등록", - actionText = "등록", - isPhoneRequired = true, - isLoading = false, - errorMessage = null, - onBackClick = {}, - onRegister = { _, _, _, _, _ -> }, - ) - } -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterUiState.kt similarity index 80% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterUiState.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterUiState.kt index e2816fd7a3..2c502ea57f 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterUiState.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.receiver import com.afternote.core.ui.UiText diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterViewModel.kt similarity index 78% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterViewModel.kt index e32238d1d4..0fed507c84 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterViewModel.kt @@ -1,9 +1,9 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.receiver import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.ui.UiText import com.afternote.feature.setting.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel @@ -19,7 +19,7 @@ import javax.inject.Inject class ReceiverRegisterViewModel @Inject constructor( - private val userRepository: UserRepository, + private val receiverRepository: UserReceiverRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(ReceiverRegisterUiState()) val uiState = _uiState.asStateFlow() @@ -31,12 +31,12 @@ class ReceiverRegisterViewModel name: String, relation: String, phone: String?, - email: String?, + email: String, message: String?, ) { - if (!email.orEmpty().isValidReceiverEmail()) { + if (!email.isValidReceiverEmail()) { val messageRes = - if (email.isNullOrBlank()) R.string.receiver_email_required else R.string.receiver_email_invalid + if (email.isBlank()) R.string.setting_receiver_email_required else R.string.setting_receiver_email_invalid _uiState.update { it.copy(errorMessage = UiText.Resource(messageRes)) } return } @@ -44,19 +44,19 @@ class ReceiverRegisterViewModel if (phoneValidation != ReceiverPhoneValidation.VALID) { val messageRes = if (phoneValidation == ReceiverPhoneValidation.REQUIRED) { - R.string.receiver_phone_required + R.string.setting_receiver_phone_required } else { - R.string.receiver_phone_invalid + R.string.setting_receiver_phone_invalid } _uiState.update { it.copy(errorMessage = UiText.Resource(messageRes)) } return } - val normalizedEmail = email.orEmpty().trim() + val normalizedEmail = email.trim() _uiState.update { it.copy(isLoading = true, errorMessage = null) } viewModelScope.launch { runCatchingCancellable { - userRepository.createReceiver( + receiverRepository.createReceiver( name = name, relation = relation, phone = phone?.takeIf { it.isNotBlank() }?.normalizeReceiverPhone(), @@ -66,7 +66,7 @@ class ReceiverRegisterViewModel }.onSuccess { _events.send(ReceiverRegisterEvent.RegisterSuccess) }.onFailure { error -> - val errorMessage = error.toReceiverFailureMessage(R.string.receiver_register_failed) + val errorMessage = error.toReceiverFailureMessage(R.string.setting_receiver_register_failed) _uiState.update { it.copy(isLoading = false, errorMessage = errorMessage) } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/AppLockSetupScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/AppLockSetupScreen.kt deleted file mode 100644 index fb6faeaf00..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/AppLockSetupScreen.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.afternote.feature.setting.presentation.screen - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.afternote.core.ui.topbar.DetailTopBar -import com.afternote.feature.setting.presentation.component.InsertPasswordContent -import com.afternote.feature.setting.presentation.component.PinSetupStep -import com.afternote.feature.setting.presentation.viewmodel.AppLockSetupViewModel - -@Composable -fun AppLockSetupScreen( - step: PinSetupStep, - onPinComplete: (pin: String) -> Unit, - onBack: () -> Unit, - modifier: Modifier = Modifier, - viewModel: AppLockSetupViewModel = hiltViewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val currentOnPinComplete by rememberUpdatedState(onPinComplete) - - LaunchedEffect(uiState.isComplete) { - if (uiState.isComplete) { - currentOnPinComplete(uiState.pin) - viewModel.resetPin() - } - } - - AppLockSetupContent( - step = step, - passwordLength = uiState.pin.length, - onDigitClick = viewModel::onDigitInput, - onDeleteClick = viewModel::onDelete, - onConfirmClick = { currentOnPinComplete(uiState.pin) }, - onBack = onBack, - modifier = modifier, - ) -} - -@Composable -private fun AppLockSetupContent( - step: PinSetupStep, - passwordLength: Int, - onDigitClick: (String) -> Unit, - onDeleteClick: () -> Unit, - onConfirmClick: () -> Unit, - onBack: () -> Unit, - modifier: Modifier = Modifier, -) { - val titleText = - when (step) { - PinSetupStep.ENTER_NEW -> "비밀번호를 입력해 주세요." - PinSetupStep.CONFIRM_NEW -> "비밀번호를 다시 한 번 입력해 주세요." - PinSetupStep.ENTER_CURRENT -> "변경할 비밀번호를 입력해 주세요." - } - - Scaffold( - topBar = { - DetailTopBar( - title = "앱 잠금 비밀번호 설정", - onBackClick = onBack, - ) - }, - modifier = modifier, - containerColor = Color.Transparent, - ) { innerPadding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(innerPadding), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.height(40.dp)) - - InsertPasswordContent( - titleText = titleText, - passwordLength = passwordLength, - onDigitClick = onDigitClick, - onDeleteClick = onDeleteClick, - onConfirmClick = onConfirmClick, - ) - } - } -} - -@Preview(name = "비밀번호 입력", showBackground = true) -@Composable -private fun PreviewEnterNew() { - AppLockSetupContent( - step = PinSetupStep.ENTER_NEW, - passwordLength = 0, - onDigitClick = {}, - onDeleteClick = {}, - onConfirmClick = {}, - onBack = {}, - ) -} - -@Preview(name = "비밀번호 재입력", showBackground = true) -@Composable -private fun PreviewConfirmNew() { - AppLockSetupContent( - step = PinSetupStep.CONFIRM_NEW, - passwordLength = 3, - onDigitClick = {}, - onDeleteClick = {}, - onConfirmClick = {}, - onBack = {}, - ) -} - -@Preview(name = "변경할 비밀번호 입력", showBackground = true) -@Composable -private fun PreviewEnterCurrent() { - AppLockSetupContent( - step = PinSetupStep.ENTER_CURRENT, - passwordLength = 0, - onDigitClick = {}, - onDeleteClick = {}, - onConfirmClick = {}, - onBack = {}, - ) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyPasswordScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyPasswordScreen.kt deleted file mode 100644 index 1990505a62..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyPasswordScreen.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.afternote.feature.setting.presentation.screen - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.afternote.core.ui.topbar.DetailTopBar -import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.component.InsertPasswordContent -import com.afternote.feature.setting.presentation.viewmodel.AppLockSetupViewModel -import com.afternote.feature.setting.presentation.viewmodel.PassKeyViewModel - -@Composable -fun PassKeyPasswordScreen( - onPinComplete: (pin: String) -> Unit, - onBack: () -> Unit, - modifier: Modifier = Modifier, - viewModel: AppLockSetupViewModel = hiltViewModel(), - passKeyViewModel: PassKeyViewModel = hiltViewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val currentOnPinComplete by rememberUpdatedState(onPinComplete) - - LaunchedEffect(uiState.isComplete) { - if (uiState.isComplete) { - passKeyViewModel.savePasskeyRegistered() - currentOnPinComplete(uiState.pin) - viewModel.resetPin() - } - } - - Scaffold( - topBar = { - DetailTopBar( - title = stringResource(id = R.string.passkey_management_title), - onBackClick = onBack, - ) - }, - modifier = modifier, - containerColor = Color.Transparent, - ) { innerPadding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(innerPadding), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.height(40.dp)) - InsertPasswordContent( - titleText = "비밀번호를 입력해 주세요.", - passwordLength = uiState.pin.length, - onDigitClick = viewModel::onDigitInput, - onDeleteClick = viewModel::onDelete, - onConfirmClick = { currentOnPinComplete(uiState.pin) }, - ) - } - } -} - -@Preview(showBackground = true) -@Composable -private fun PassKeyPasswordScreenPreview() { - PassKeyPasswordScreen( - onPinComplete = {}, - onBack = {}, - ) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PushNotificationScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PushNotificationScreen.kt deleted file mode 100644 index a62a8c5c94..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/PushNotificationScreen.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.afternote.feature.setting.presentation.screen - -import android.content.Intent -import android.provider.Settings -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.topbar.DetailTopBar -import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.component.DeviceAlarmOffSection -import com.afternote.feature.setting.presentation.component.PushToggleSection -import com.afternote.feature.setting.presentation.viewmodel.PushNotificationUiState -import com.afternote.feature.setting.presentation.viewmodel.PushNotificationViewModel - -@Composable -fun PushNotificationScreen( - onBack: () -> Unit, - viewModel: PushNotificationViewModel = hiltViewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - - PushNotificationContent( - uiState = uiState, - onBack = onBack, - onDeviceAlarmClick = { - val intent = - Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - } - context.startActivity(intent) - }, - onNewsletterToggle = viewModel::onNewsletterToggle, - onMindRecordToggle = viewModel::onMindRecordToggle, - onAfternoteToggle = viewModel::onAfternoteToggle, - onSmsCheck = viewModel::onSmsChecked, - onEmailCheck = viewModel::onEmailChecked, - onPushCheck = viewModel::onPushChecked, - ) -} - -@Composable -private fun PushNotificationContent( - uiState: PushNotificationUiState, - onBack: () -> Unit, - onDeviceAlarmClick: () -> Unit, - onNewsletterToggle: (Boolean) -> Unit, - onMindRecordToggle: (Boolean) -> Unit, - onAfternoteToggle: (Boolean) -> Unit, - onSmsCheck: (Boolean) -> Unit, - onEmailCheck: (Boolean) -> Unit, - onPushCheck: (Boolean) -> Unit, -) { - Scaffold( - topBar = { - DetailTopBar( - title = stringResource(R.string.push_notification_title), - onBackClick = onBack, - ) - }, - containerColor = Color.Transparent, - ) { padding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 20.dp, vertical = 16.dp), - ) { - // 기기 알림 설정 행 — 항상 표시, 탭하면 Android 시스템 알림 설정으로 이동 - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { onDeviceAlarmClick() } - .padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.device_alarm_setting), - style = AfternoteDesign.typography.bodyLargeR, - ) - Spacer(Modifier.width(12.dp)) - Text( - text = - if (uiState.isDeviceAlarmOn) { - stringResource(R.string.device_alarm_on) - } else { - stringResource(R.string.device_alarm_off) - }, - style = AfternoteDesign.typography.captionLargeR, - ) - } - - Spacer(Modifier.height(8.dp)) - - if (uiState.isDeviceAlarmOn) { - PushToggleSection( - uiState = uiState, - onNewsletterToggle = onNewsletterToggle, - onMindRecordToggle = onMindRecordToggle, - onAfternoteToggle = onAfternoteToggle, - ) - } else { - DeviceAlarmOffSection( - uiState = uiState, - onSmsCheck = onSmsCheck, - onEmailCheck = onEmailCheck, - onPushCheck = onPushCheck, - ) - } - - Spacer(Modifier.height(24.dp)) - Text( - text = stringResource(R.string.push_notification_device_guide), - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray5, - ) - } - } -} - -@Preview(name = "기기 알림 켜짐", showBackground = true) -@Composable -private fun PreviewAlarmOn() { - PushNotificationContent( - uiState = PushNotificationUiState(isDeviceAlarmOn = true), - onBack = {}, - onDeviceAlarmClick = {}, - onNewsletterToggle = {}, - onMindRecordToggle = {}, - onAfternoteToggle = {}, - onSmsCheck = {}, - onEmailCheck = {}, - onPushCheck = {}, - ) -} - -@Preview(name = "기기 알림 꺼짐", showBackground = true) -@Composable -private fun PreviewAlarmOff() { - PushNotificationContent( - uiState = PushNotificationUiState(isDeviceAlarmOn = false), - onBack = {}, - onDeviceAlarmClick = {}, - onNewsletterToggle = {}, - onMindRecordToggle = {}, - onAfternoteToggle = {}, - onSmsCheck = {}, - onEmailCheck = {}, - onPushCheck = {}, - ) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/RecipientSetScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/RecipientSetScreen.kt deleted file mode 100644 index a4c4f38fc5..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/RecipientSetScreen.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.afternote.feature.setting.presentation.screen - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.AfternoteTextField -import com.afternote.core.ui.button.AfternoteButton -import com.afternote.core.ui.button.AfternoteButtonType -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.topbar.DetailTopBar -import com.afternote.feature.setting.presentation.R - -@Composable -fun RecipientSetScreen(modifier: Modifier = Modifier) { - val phoneState = rememberTextFieldState() - val emailState = rememberTextFieldState() - var isEditing by remember { mutableStateOf(false) } - - Scaffold( - modifier = modifier, - containerColor = Color.Transparent, - topBar = { - DetailTopBar("수신인 설정") - }, - ) { innerPadding -> - LazyColumn( - modifier = - Modifier - .fillMaxWidth() - .padding(innerPadding) - .padding(horizontal = 20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - item { - Spacer(Modifier.height(8.dp)) - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth()) { - Image( - painterResource(R.drawable.ic_default_profile), - contentDescription = "프로필", - modifier = Modifier.size(134.dp), - ) - } - } - item { - Column( - modifier = Modifier.padding(horizontal = 20.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text("이름", style = AfternoteDesign.typography.bodySmallB) - Text("관계", style = AfternoteDesign.typography.bodySmallB) - } - } - item { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("연락처", style = AfternoteDesign.typography.bodySmallB) - if (isEditing) { - AfternoteTextField( - state = phoneState, - placeholder = "연락처를 입력하세요", - ) - } else { - ReadOnlyField( - value = phoneState.text.toString(), - placeholder = "연락처를 입력하세요", - ) - } - } - } - item { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("이메일", style = AfternoteDesign.typography.bodySmallB) - if (isEditing) { - AfternoteTextField( - state = emailState, - placeholder = "afternote@email.com", - ) - } else { - ReadOnlyField( - value = emailState.text.toString(), - placeholder = "afternote@email.com", - ) - } - } - } - item { - Text("전달할 내용") - } - item { - Spacer(Modifier.height(8.dp)) - if (isEditing) { - AfternoteButton( - text = "저장하기", - onClick = { isEditing = false }, - type = AfternoteButtonType.Default, - ) - } else { - AfternoteButton( - text = "수정하기", - onClick = { isEditing = true }, - type = AfternoteButtonType.Default, - ) - } - Spacer(Modifier.height(16.dp)) - } - } - } -} - -@Composable -private fun ReadOnlyField( - value: String, - placeholder: String, - modifier: Modifier = Modifier, -) { - val shape = RoundedCornerShape(8.dp) - Box( - modifier = - modifier - .fillMaxWidth() - .background(AfternoteDesign.colors.white, shape) - .border(1.dp, AfternoteDesign.colors.gray2, shape) - .padding(horizontal = 24.dp, vertical = 13.dp), - contentAlignment = Alignment.CenterStart, - ) { - Text( - text = value.ifEmpty { placeholder }, - style = AfternoteDesign.typography.textField, - color = if (value.isNotEmpty()) AfternoteDesign.colors.gray9 else AfternoteDesign.colors.gray4, - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun RecipientSetScreenPrev() { - RecipientSetScreen() -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/InsertPasswordContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/InsertPasswordContent.kt similarity index 78% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/InsertPasswordContent.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/InsertPasswordContent.kt index 27c6c71f0a..a15e97fecb 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/InsertPasswordContent.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/InsertPasswordContent.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.shared.component import android.R.attr.password import androidx.compose.foundation.Image @@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.setting.presentation.R @@ -34,7 +33,7 @@ fun InsertPasswordContent( horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(modifier = Modifier.height(40.dp)) - Image(painterResource(R.drawable.ic_lock), contentDescription = null) + Image(painterResource(R.drawable.setting_ic_lock), contentDescription = null) Spacer(modifier = Modifier.height(24.dp)) Text( text = titleText, @@ -51,15 +50,3 @@ fun InsertPasswordContent( Spacer(modifier = Modifier.height(40.dp)) } } - -@Preview(showBackground = true) -@Composable -private fun Prev() { - InsertPasswordContent( - titleText = "비밀번호 입력해주세요", - passwordLength = 4, - onDigitClick = {}, - onDeleteClick = {}, - onConfirmClick = {}, - ) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/NumberKeypad.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/NumberKeypad.kt similarity index 96% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/NumberKeypad.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/NumberKeypad.kt index 96dff3550d..22059cf5d5 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/NumberKeypad.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/NumberKeypad.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.shared.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -15,7 +15,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.afternote.core.ui.R import com.afternote.core.ui.theme.AfternoteDesign @@ -77,7 +76,7 @@ fun NumberKeypad( else -> { Text( text = key, - style = AfternoteDesign.typography.h2.copy(fontSize = 30.sp), + style = AfternoteDesign.typography.h2, ) } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/PasswordDots.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/PasswordDots.kt similarity index 95% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/PasswordDots.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/PasswordDots.kt index 38b4100966..75cef05dd9 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/PasswordDots.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/PasswordDots.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.shared.component import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/ProfilePhotoWithAddBadge.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/ProfilePhotoWithAddBadge.kt new file mode 100644 index 0000000000..6636c642be --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/ProfilePhotoWithAddBadge.kt @@ -0,0 +1,49 @@ +package com.afternote.feature.setting.presentation.shared.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.afternote.feature.setting.presentation.R + +/** + * 프로필 사진 자리 + 우하단 「추가」 배지. 표시 전용이며 아직 picker 가 붙어 있지 않다. + * + * 프로필 이미지 크기 134dp 의 단일 출처다(#1782). setting 의 프로필 편집·수신인 등록 두 화면이 + * 같은 블록을 복제하고 있어 여기로 모았다. + * + * core:ui 의 [ProfileImagePicker][com.afternote.core.ui.ProfileImagePicker] 로 흡수하지 않은 + * 이유는 자산과 배지 기하가 달라서다 — 이쪽은 `ic_default_profile`(60dp 뷰포트) 과 + * 48dp 칸 안에 72dp 로 넘쳐 그려지는 `ic_plus` 를 쓰고, core 쪽은 + * `core_ui_ic_profile_placeholder` 와 48dp `PlusBadgeButton` 을 쓴다. 바꾸면 픽셀이 움직인다. + * 수렴은 시안 대조가 선행돼야 하므로 #2003 으로 분리했다. + */ +@Composable +fun ProfilePhotoWithAddBadge(modifier: Modifier = Modifier) { + Box(modifier = modifier.size(134.dp)) { + Image( + painter = painterResource(R.drawable.setting_ic_default_profile), + contentDescription = "기본", + modifier = Modifier.fillMaxSize(), + ) + Box( + modifier = + Modifier + .align(Alignment.BottomEnd) + .size(48.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(R.drawable.setting_ic_plus), + contentDescription = "추가", + modifier = Modifier.requiredSize(72.dp), + ) + } + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SettingMenuItem.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/SettingMenuItem.kt similarity index 81% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SettingMenuItem.kt rename to feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/SettingMenuItem.kt index f9ef93e5f5..219d38778f 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/SettingMenuItem.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/SettingMenuItem.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.component +package com.afternote.feature.setting.presentation.shared.component import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -13,7 +13,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.setting.presentation.R @@ -36,15 +35,9 @@ fun SettingMenuItem( Text(label, style = AfternoteDesign.typography.bodyBase, color = AfternoteDesign.colors.gray9) Spacer(modifier = Modifier.weight(1f)) Image( - painterResource(R.drawable.ic_right_arrow), + painterResource(R.drawable.setting_ic_right_arrow), contentDescription = null, modifier = Modifier.size(24.dp), ) } } - -@Preview(showBackground = true) -@Composable -private fun SettingMenuItemPrev() { - SettingMenuItem(label = "프로필 수정", onClick = {}) -} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/InsertPasswordUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/InsertPasswordUiState.kt deleted file mode 100644 index d40df33487..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/InsertPasswordUiState.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.afternote.feature.setting.presentation.viewmodel - -data class InsertPasswordUiState( - val password: String = "", -) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel.kt deleted file mode 100644 index b0d42f57d5..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/InsertPasswordViewModel.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.afternote.feature.setting.presentation.viewmodel - -import androidx.lifecycle.ViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import jakarta.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update - -@HiltViewModel -class InsertPasswordViewModel - @Inject - constructor() : ViewModel() { - private val _uiState = MutableStateFlow(InsertPasswordUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - fun onDigitInput(digit: String) { - _uiState.update { state -> - if (state.password.length < 4) { - state.copy(password = state.password + digit) - } else { - state - } - } - } - - fun onDelete() { - _uiState.update { state -> - state.copy(password = state.password.dropLast(1)) - } - } - } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/PushNotificationViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/PushNotificationViewModel.kt deleted file mode 100644 index e9d344f6bc..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/PushNotificationViewModel.kt +++ /dev/null @@ -1,102 +0,0 @@ -package com.afternote.feature.setting.presentation.viewmodel - -import android.content.Context -import android.util.Log -import androidx.core.app.NotificationManagerCompat -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.afternote.core.domain.repository.UserRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import jakarta.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -@HiltViewModel -class PushNotificationViewModel - @Inject - constructor( - @ApplicationContext private val context: Context, - private val userRepository: UserRepository, - ) : ViewModel() { - private val _uiState = MutableStateFlow(PushNotificationUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - val deviceAlarmOn = NotificationManagerCompat.from(context).areNotificationsEnabled() - Log.d(TAG, "init: deviceAlarmOn=$deviceAlarmOn") - _uiState.update { it.copy(isDeviceAlarmOn = deviceAlarmOn) } - loadPushSettings() - } - - private fun loadPushSettings() { - viewModelScope.launch { - Log.d(TAG, "loadPushSettings: start") - _uiState.update { it.copy(isLoading = true) } - runCatching { userRepository.getMyPushSettings() } - .onSuccess { setting -> - Log.d(TAG, "loadPushSettings: success=$setting") - _uiState.update { - it.copy( - isLoading = false, - isNewsletterOn = setting.timeLetter, - isMindRecordOn = setting.mindRecord, - isAfternoteOn = setting.afterNote, - ) - } - }.onFailure { e -> - Log.e(TAG, "loadPushSettings: failed", e) - _uiState.update { it.copy(isLoading = false) } - } - } - } - - fun onSmsChecked(checked: Boolean) = _uiState.update { it.copy(isSmsChecked = checked) } - - fun onEmailChecked(checked: Boolean) = _uiState.update { it.copy(isEmailChecked = checked) } - - fun onPushChecked(checked: Boolean) = _uiState.update { it.copy(isPushChecked = checked) } - - fun onNewsletterToggle(on: Boolean) { - _uiState.update { it.copy(isNewsletterOn = on) } - viewModelScope.launch { - runCatching { userRepository.updateMyPushSettings(timeLetter = on, mindRecord = null, afterNote = null) } - .onSuccess { Log.d(TAG, "onNewsletterToggle: success, on=$on") } - .onFailure { e -> - Log.e(TAG, "onNewsletterToggle: failed, on=$on", e) - _uiState.update { it.copy(isNewsletterOn = !on) } - } - } - } - - fun onMindRecordToggle(on: Boolean) { - _uiState.update { it.copy(isMindRecordOn = on) } - viewModelScope.launch { - runCatching { userRepository.updateMyPushSettings(timeLetter = null, mindRecord = on, afterNote = null) } - .onSuccess { Log.d(TAG, "onMindRecordToggle: success, on=$on") } - .onFailure { e -> - Log.e(TAG, "onMindRecordToggle: failed, on=$on", e) - _uiState.update { it.copy(isMindRecordOn = !on) } - } - } - } - - fun onAfternoteToggle(on: Boolean) { - _uiState.update { it.copy(isAfternoteOn = on) } - viewModelScope.launch { - runCatching { userRepository.updateMyPushSettings(timeLetter = null, mindRecord = null, afterNote = on) } - .onSuccess { Log.d(TAG, "onAfternoteToggle: success, on=$on") } - .onFailure { e -> - Log.e(TAG, "onAfternoteToggle: failed, on=$on", e) - _uiState.update { it.copy(isAfternoteOn = !on) } - } - } - } - - companion object { - private const val TAG = "PushNotificationVM" - } - } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverFailureMessage.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverFailureMessage.kt deleted file mode 100644 index f4538178f3..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverFailureMessage.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.afternote.feature.setting.presentation.viewmodel - -import androidx.annotation.StringRes -import com.afternote.core.domain.error.ReceiverRequestRejectedException -import com.afternote.core.ui.UiText -import com.afternote.feature.setting.presentation.R - -/** - * [ReceiverRequestRejectedException.userMessage] 는 서버 원문이라 사용자 화면에 직접 노출하지 - * 않는다(리포팅·로그 용도로만 예외에 남긴다) — feature/receiver 의 ReceiverFailureTranslation.kt - * 이 확정한 선례(#1339)와 같은 규칙이다. - */ -internal fun Throwable.toReceiverFailureMessage( - @StringRes fallbackResId: Int, -): UiText = - if (this is ReceiverRequestRejectedException) { - UiText.Resource(R.string.receiver_request_rejected) - } else { - UiText.Resource(fallbackResId) - } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModel.kt deleted file mode 100644 index 7a0735f1e5..0000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModel.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.afternote.feature.setting.presentation.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.afternote.core.domain.repository.UserRepository -import com.afternote.core.model.setting.ReceiverListItem -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject - -@HiltViewModel -class ReceiverListViewModel - @Inject - constructor( - private val userRepository: UserRepository, - ) : ViewModel() { - val receivers: StateFlow> = - userRepository.receiverListFlow - .map { receivers -> receivers.map { ReceiverListItem(it.receiverId, it.name, it.relation) } } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList(), - ) - } diff --git a/feature/setting/presentation/src/main/res/drawable/ic_more.xml b/feature/setting/presentation/src/main/res/drawable/ic_more.xml deleted file mode 100644 index 9a65dd8357..0000000000 --- a/feature/setting/presentation/src/main/res/drawable/ic_more.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - diff --git a/feature/setting/presentation/src/main/res/drawable/ic_apple_login.xml b/feature/setting/presentation/src/main/res/drawable/setting_ic_apple_login.xml similarity index 100% rename from feature/setting/presentation/src/main/res/drawable/ic_apple_login.xml rename to feature/setting/presentation/src/main/res/drawable/setting_ic_apple_login.xml diff --git a/feature/setting/presentation/src/main/res/drawable/ic_apple_logo.xml b/feature/setting/presentation/src/main/res/drawable/setting_ic_apple_logo.xml similarity index 100% rename from feature/setting/presentation/src/main/res/drawable/ic_apple_logo.xml rename to feature/setting/presentation/src/main/res/drawable/setting_ic_apple_logo.xml diff --git a/feature/setting/presentation/src/main/res/drawable/ic_default_profile.xml b/feature/setting/presentation/src/main/res/drawable/setting_ic_default_profile.xml similarity index 100% rename from feature/setting/presentation/src/main/res/drawable/ic_default_profile.xml rename to feature/setting/presentation/src/main/res/drawable/setting_ic_default_profile.xml diff --git a/feature/setting/presentation/src/main/res/drawable/ic_fingerprint.xml b/feature/setting/presentation/src/main/res/drawable/setting_ic_fingerprint.xml similarity index 100% rename from feature/setting/presentation/src/main/res/drawable/ic_fingerprint.xml rename to feature/setting/presentation/src/main/res/drawable/setting_ic_fingerprint.xml diff --git a/feature/setting/presentation/src/main/res/drawable/ic_google_logo.xml b/feature/setting/presentation/src/main/res/drawable/setting_ic_google_logo.xml similarity index 100% rename from feature/setting/presentation/src/main/res/drawable/ic_google_logo.xml rename to feature/setting/presentation/src/main/res/drawable/setting_ic_google_logo.xml diff --git a/feature/setting/presentation/src/main/res/drawable/ic_kakao_logo.xml b/feature/setting/presentation/src/main/res/drawable/setting_ic_kakao_logo.xml similarity index 100% rename from feature/setting/presentation/src/main/res/drawable/ic_kakao_logo.xml rename to feature/setting/presentation/src/main/res/drawable/setting_ic_kakao_logo.xml diff --git a/feature/setting/presentation/src/main/res/drawable/ic_list.xml b/feature/setting/presentation/src/main/res/drawable/setting_ic_list.xml similarity index 100% rename from feature/setting/presentation/src/main/res/drawable/ic_list.xml rename to feature/setting/presentation/src/main/res/drawable/setting_ic_list.xml diff --git a/feature/setting/presentation/src/main/res/drawable/ic_lock.xml b/feature/setting/presentation/src/main/res/drawable/setting_ic_lock.xml similarity index 100% rename from feature/setting/presentation/src/main/res/drawable/ic_lock.xml rename to feature/setting/presentation/src/main/res/drawable/setting_ic_lock.xml diff --git a/feature/setting/presentation/src/main/res/drawable/ic_naver_logo.xml b/feature/setting/presentation/src/main/res/drawable/setting_ic_naver_logo.xml similarity index 100% rename from feature/setting/presentation/src/main/res/drawable/ic_naver_logo.xml rename to feature/setting/presentation/src/main/res/drawable/setting_ic_naver_logo.xml diff --git a/feature/setting/presentation/src/main/res/drawable/ic_passkey_main.xml b/feature/setting/presentation/src/main/res/drawable/setting_ic_passkey_main.xml similarity index 100% rename from feature/setting/presentation/src/main/res/drawable/ic_passkey_main.xml rename to feature/setting/presentation/src/main/res/drawable/setting_ic_passkey_main.xml diff --git a/feature/setting/presentation/src/main/res/drawable/ic_plus.xml b/feature/setting/presentation/src/main/res/drawable/setting_ic_plus.xml similarity index 100% rename from feature/setting/presentation/src/main/res/drawable/ic_plus.xml rename to feature/setting/presentation/src/main/res/drawable/setting_ic_plus.xml diff --git a/feature/setting/presentation/src/main/res/drawable/ic_right_arrow.xml b/feature/setting/presentation/src/main/res/drawable/setting_ic_right_arrow.xml similarity index 100% rename from feature/setting/presentation/src/main/res/drawable/ic_right_arrow.xml rename to feature/setting/presentation/src/main/res/drawable/setting_ic_right_arrow.xml diff --git a/feature/setting/presentation/src/main/res/drawable/ic_vector1.xml b/feature/setting/presentation/src/main/res/drawable/setting_ic_vector1.xml similarity index 100% rename from feature/setting/presentation/src/main/res/drawable/ic_vector1.xml rename to feature/setting/presentation/src/main/res/drawable/setting_ic_vector1.xml diff --git a/feature/setting/presentation/src/main/res/drawable/ic_vector2.xml b/feature/setting/presentation/src/main/res/drawable/setting_ic_vector2.xml similarity index 100% rename from feature/setting/presentation/src/main/res/drawable/ic_vector2.xml rename to feature/setting/presentation/src/main/res/drawable/setting_ic_vector2.xml diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index 5bef489d95..1bcff3c581 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -1,168 +1,184 @@ - 설정 + 설정 - 계정 - 수신자 관리 - 보안 - 고객지원 + 계정 + 수신자 관리 + 보안 + 고객지원 - 프로필 수정 - 비밀번호 변경 - 연결된 계정 - 알림 설정 + 프로필 수정 + 비밀번호 변경 + 연결된 계정 + 알림 설정 - 수신자 목록 - 수신자 등록 - 사후 전달 조건 + 수신자 목록 + 수신자 등록 + 사후 전달 조건 + 등록된 수신자가 없습니다. + 수신자를 등록하고 쉽게 관리해 보세요. + 수신자 등록하기 - 패스키 관리 - 앱 잠금 설정 + 패스키 관리 + 앱 잠금 설정 - FAQ - 고객센터 - 공지사항 - 등록된 공지사항이 없어요. - 이용약관 - 개인정보 취급방침 - 애프터노트 서비스 안내 + FAQ + 고객센터 + 공지사항 + 등록된 공지사항이 없어요. + 이용약관 + 개인정보 취급방침 + 애프터노트 서비스 안내 - 로그아웃 - 애프터노트를 로그아웃하시겠습니까? - 아니요 - 예 + 로그아웃 + 애프터노트를 로그아웃하시겠습니까? + 아니요 + 예 - 애프터노트를 정말 탈퇴하시겠습니까?\n서비스 탈퇴 전 아래 내용을 꼭 확인해 주세요. + 애프터노트를 정말 탈퇴하시겠습니까?\n서비스 탈퇴 전 아래 내용을 꼭 확인해 주세요. - 탈퇴할 계정 + 탈퇴할 계정 - 탈퇴 시 삭제되는 내용 - 다음 정보는 탈퇴와 동시에 영구적으로 삭제됩니다. - 작성한 모든 기록 및 메세지 - 예약된 데이터 전달 설정 - 업로드한 파일 및 첨부 데이터 - 계정 정보 및 프로필 정보 - 삭제된 데이터는 어떠한 경우에도 복구할 수 없습니다. + 탈퇴 시 삭제되는 내용 + 다음 정보는 탈퇴와 동시에 영구적으로 삭제됩니다. + 작성한 모든 기록 및 메세지 + 예약된 데이터 전달 설정 + 업로드한 파일 및 첨부 데이터 + 계정 정보 및 프로필 정보 + 삭제된 데이터는 어떠한 경우에도 복구할 수 없습니다. - 탈퇴 전 확인 사항 - 전달이 예정된 메세지는 모두 취소됩니다. - 삭제된 기록은 다시 확인할 수 없습니다. - 탈퇴일로부터 30일 이후, 해당 계정의 이메일 주소로 새로 가입할 수 있습니다. - 단 기존 정보는 파기되어 복구할 수 없습니다. + 탈퇴 전 확인 사항 + 전달이 예정된 메세지는 모두 취소됩니다. + 삭제된 기록은 다시 확인할 수 없습니다. + 탈퇴일로부터 30일 이후, 해당 계정의 이메일 주소로 새로 가입할 수 있습니다. + 단 기존 정보는 파기되어 복구할 수 없습니다. - 개인정보 처리 안내 - 회원 탈퇴 시 개인정보는 즉시 삭제됩니다.\n단, 관련 법령에 따라 일정 기간 보관이 필요한 경우 해당 정보는 법적 기준에 따라 보관 후 삭제됩니다. + 개인정보 처리 안내 + 회원 탈퇴 시 개인정보는 즉시 삭제됩니다.\n단, 관련 법령에 따라 일정 기간 보관이 필요한 경우 해당 정보는 법적 기준에 따라 보관 후 삭제됩니다. - 탈퇴 주의사항을 모두 확인했으며, 이에 동의합니다. + 탈퇴 주의사항을 모두 확인했으며, 이에 동의합니다. - 취소하기 - 탈퇴하기 + 취소하기 + 탈퇴하기 - 안전한 탈퇴 진행을 위해 아래 문장을 입력해 주세요. - 탈퇴하겠습니다 - 문장이 일치하지 않습니다. 재입력해 주세요. - 이전으로 - 회원 탈퇴가 완료되었습니다.\n애프터노트를 이용해 주셔서 감사합니다. - 확인하기 - 회원 탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요. - 재시도 - 닫기 + 안전한 탈퇴 진행을 위해 아래 문장을 입력해 주세요. + 탈퇴하겠습니다 + 문장이 일치하지 않습니다. 재입력해 주세요. + 이전으로 + 회원 탈퇴가 완료되었습니다.\n애프터노트를 이용해 주셔서 감사합니다. + 확인하기 + 회원 탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요. + 재시도 + 닫기 - 회원 탈퇴 + 회원 탈퇴 - 카카오 계정 연결에 실패했습니다. - Google 계정 연결에 실패했습니다. - 연결된 계정 - SNS 계정 연결 - 자주 사용하는 소셜 계정을 연결하고,\n빠르게 로그인 할 수 있습니다. + 카카오 계정 연결에 실패했습니다. + Google 계정 연결에 실패했습니다. + 연결된 계정 + SNS 계정 연결 + 자주 사용하는 소셜 계정을 연결하고,\n빠르게 로그인 할 수 있습니다. - 네이버 계정으로 로그인 - 구글 계정으로 로그인 - 카카오톡 계정으로 로그인 - 애플 계정으로 로그인 + 네이버 계정으로 로그인 + 구글 계정으로 로그인 + 카카오톡 계정으로 로그인 + 애플 계정으로 로그인 - 연결되지 않음 + 연결되지 않음 - 푸시 알림 설정 - 기기 알림 설정 - 꺼짐 - 켜짐 - 전체 알림 수신 설정은 기기 알림 설정에서 가능합니다. - 마케팅 및 광고 알림 설정 - 문자 메시지 - E-mail - 푸시 알림 - 서비스의 중요 안내사항 및 수신인 관련 정보는 위 수신 여부와 관계없이 발송됩니다. - 문자, 이메일, 푸시 메세지 수신을 선택하면 광고성 정보 수신에 자동 동의 처리됩니다. - 앱 푸시 알림은 애프터노트 앱 로그인 › 설정 › 푸시 알림 설정에서 알림을 끌 수 있습니다. - - - 타임레터 - 마음의 기록 - 애프터노트 - 전체 알림 수신 설정은 기기 알림 설정에서 가능합니다. + 푸시 알림 설정 + 기기 알림 설정 + 꺼짐 + 켜짐 + 전체 알림 수신 설정은 기기 알림 설정에서 가능합니다. + 마케팅 및 광고 알림 설정 + 문자 메시지 + E-mail + 푸시 알림 + 서비스의 중요 안내사항 및 수신인 관련 정보는 위 수신 여부와 관계없이 발송됩니다. + 문자, 이메일, 푸시 메세지 수신을 선택하면 광고성 정보 수신에 자동 동의 처리됩니다. + 저장에 실패했어요. 다시 시도해 주세요. + 앱 푸시 알림은 애프터노트 앱 로그인 › 설정 › 푸시 알림 설정에서 알림을 끌 수 있습니다. + + + 타임레터 + 마음의 기록 + 애프터노트 + 전체 알림 수신 설정은 기기 알림 설정에서 가능합니다. - 패스키 관리 - 패스키를 만들어 비밀번호 대신 지문 및 얼굴 인식으로\n 쉽고 안전하게 로그인 할 수 있습니다.\n패스키는 본인 소유의 기기에서만 생성해주세요. - 패스키 등록 + 패스키 관리 + 패스키를 만들어 비밀번호 대신 지문 및 얼굴 인식으로\n 쉽고 안전하게 로그인 할 수 있습니다.\n패스키는 본인 소유의 기기에서만 생성해주세요. + 패스키 등록 - 패스키로 사용할 지문을 인식해 주세요. - 지문 인증하기 - 비밀번호로 인증하기 - 비밀번호를 입력해주세요 + 패스키로 사용할 지문을 인식해 주세요. + 지문 인증하기 + 비밀번호로 인증하기 + 비밀번호를 입력해주세요 - 패스키 관리 - 패스키를 만들어 비밀번호 대신 지문 및 얼굴 인식으로\n쉽고 안전하게 로그인할 수 있습니다.\n패스키는 본인 소유의 기기에서만 생성해주세요. + 패스키 관리 + 패스키를 만들어 비밀번호 대신 지문 및 얼굴 인식으로\n쉽고 안전하게 로그인할 수 있습니다.\n패스키는 본인 소유의 기기에서만 생성해주세요. - 패스키 목록 - + 패스키 목록 - 정보 처리 방법 + 정보 처리 방법 - 일정 기간 앱 미사용 시 - 1년 이상의 기간 동안 앱 사용이 없을 경우 전달 조건이 자동으로 충족됩니다. + 일정 기간 앱 미사용 시 + 1년 이상의 기간 동안 앱 사용이 없을 경우 전달 조건이 자동으로 충족됩니다. - 수신자 요청 이후 - 수신자의 요청이 접수된 후, 확인 절차를 거쳐 전달이 진행됩니다. + 수신자 요청 이후 + 수신자의 요청이 접수된 후, 확인 절차를 거쳐 전달이 진행됩니다. - 마지막 인사말 수정하기 + 마지막 인사말 수정하기 - • 해당 정보는 지정된 조건이 충족 시에만 전달됩니다. - • 예기치 않은 상황에서도 설정된 방식에 따라 안전하게 전달됩니다. - - 저장 - 전달 조건을 불러오지 못했습니다. - 전달 조건을 저장하지 못했습니다. - 마지막 인사말 - 마지막 인사말을 입력해주세요 - 관계를 직접 입력해주세요 - 연락처를 입력해주세요. - 올바른 연락처를 입력해주세요. - 올바른 이메일 주소를 입력해주세요. - 이메일 주소를 입력해주세요. - 수신자 등록에 실패했습니다. 잠시 후 다시 시도해주세요. - 수신자 수정에 실패했습니다. 잠시 후 다시 시도해주세요. - 입력하신 내용을 확인해 주세요. - 기본 정보는 수정됐지만 마지막 인사말 수정에 실패했습니다. - 수신자 정보를 불러오지 못했습니다. + • 해당 정보는 지정된 조건이 충족 시에만 전달됩니다. + • 예기치 않은 상황에서도 설정된 방식에 따라 안전하게 전달됩니다. + + 저장 + 전달 조건을 불러오지 못했습니다. + 전달 조건을 저장하지 못했습니다. + 마지막 인사말 + 마지막 인사말을 입력해주세요 + 관계를 직접 입력해주세요 + 연락처를 입력해주세요. + 올바른 연락처를 입력해주세요. + 올바른 이메일 주소를 입력해주세요. + 이메일 주소를 입력해주세요. + 수신자 등록에 실패했습니다. 잠시 후 다시 시도해주세요. + 수신자 수정에 실패했습니다. 잠시 후 다시 시도해주세요. + 입력하신 내용을 확인해 주세요. + 기본 정보는 수정됐지만 마지막 인사말 수정에 실패했습니다. + 수신자 정보를 불러오지 못했습니다. + 현재 이 메뉴는 이용할 수 없습니다. + 확인 + + 현재 비밀번호 + 현재 비밀번호를 입력해주세요 + 새 비밀번호 + 새 비밀번호를 입력해주세요 + 8 ~ 15자의 영문, 숫자, 특수문자(@, $, !, %, *, #, ?, &)를 각각 1자 이상 사용해 주세요. + 변경하기 + 지금 쓰고 있는 비밀번호입니다. 다른 비밀번호를 입력해 주세요. + 소셜 로그인으로 가입한 계정은 비밀번호를 변경할 수 없습니다. + 네트워크 연결을 확인해주세요. + 비밀번호를 변경하지 못했습니다. 입력하신 내용을 확인해 주세요. diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/ConnectedAccountsScreenScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTest.kt similarity index 73% rename from feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/ConnectedAccountsScreenScreenshotTest.kt rename to feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTest.kt index 6e845ac4db..680fa0fba8 100644 --- a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/ConnectedAccountsScreenScreenshotTest.kt +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.account import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable @@ -7,8 +7,6 @@ import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.setting.presentation.COMPACT_DEVICE_SPEC import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.viewmodel.ConnectedAccountsUiState -import com.afternote.feature.setting.presentation.viewmodel.SocialAccountState import com.android.tools.screenshot.PreviewTest @PreviewTest @@ -43,28 +41,28 @@ private val connectedAccountsPreviewState = listOf( SocialAccountState( provider = "naver", - iconRes = R.drawable.ic_naver_logo, - labelRes = R.string.login_with_naver, + iconRes = R.drawable.setting_ic_naver_logo, + labelRes = R.string.setting_login_with_naver, isConnected = false, isLinkable = false, ), SocialAccountState( provider = "google", - iconRes = R.drawable.ic_google_logo, - labelRes = R.string.login_with_google, + iconRes = R.drawable.setting_ic_google_logo, + labelRes = R.string.setting_login_with_google, isConnected = true, email = "example@gmail.com", ), SocialAccountState( provider = "kakao", - iconRes = R.drawable.ic_kakao_logo, - labelRes = R.string.login_with_kakao, + iconRes = R.drawable.setting_ic_kakao_logo, + labelRes = R.string.setting_login_with_kakao, isConnected = false, ), SocialAccountState( provider = "apple", - iconRes = R.drawable.ic_apple_logo, - labelRes = R.string.login_with_apple, + iconRes = R.drawable.setting_ic_apple_logo, + labelRes = R.string.setting_login_with_apple, isConnected = false, isLinkable = false, ), diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/AppLockSetupScreenScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTest.kt similarity index 91% rename from feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/AppLockSetupScreenScreenshotTest.kt rename to feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTest.kt index d92347a67d..43f0e50004 100644 --- a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/AppLockSetupScreenScreenshotTest.kt +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTest.kt @@ -1,10 +1,9 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.applock import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.setting.presentation.COMPACT_DEVICE_SPEC -import com.afternote.feature.setting.presentation.component.PinSetupStep import com.android.tools.screenshot.PreviewTest @PreviewTest diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/WithdrawConfirmScreenScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreenScreenshotTest.kt similarity index 66% rename from feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/WithdrawConfirmScreenScreenshotTest.kt rename to feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreenScreenshotTest.kt index 6a0aee81c9..c596fa3b02 100644 --- a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/WithdrawConfirmScreenScreenshotTest.kt +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.home import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview @@ -20,12 +20,19 @@ internal fun withdrawConfirmScreenCompactScreenshot() { WithdrawConfirmScreenScreenshotContent() } +@PreviewTest +@Preview(showBackground = true, fontScale = 1.5f) +@Composable +internal fun withdrawConfirmScreenLargeFontScreenshot() { + WithdrawConfirmScreenScreenshotContent(userEmail = "accessibility.regression@afternote.kr") +} + @Composable -private fun WithdrawConfirmScreenScreenshotContent() { +private fun WithdrawConfirmScreenScreenshotContent(userEmail: String = "example@afternote.kr") { AfternoteTheme { WithdrawConfirmContent( userName = "홍길동", - userEmail = "example@afternote.kr", + userEmail = userEmail, onBackClick = {}, onWithdrawClick = {}, isLoading = false, diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTest.kt new file mode 100644 index 0000000000..d3168b6c7b --- /dev/null +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTest.kt @@ -0,0 +1,34 @@ +package com.afternote.feature.setting.presentation.notification + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.COMPACT_DEVICE_SPEC +import com.android.tools.screenshot.PreviewTest + +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun pushNotificationScreenshot() { + PushNotificationScreenshotContent() +} + +@PreviewTest +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun pushNotificationCompactScreenshot() { + PushNotificationScreenshotContent() +} + +@Composable +private fun PushNotificationScreenshotContent() { + AfternoteTheme { + PushNotificationContent( + uiState = PushNotificationUiState(isAfternoteOn = true), + onBack = {}, + onNewsletterToggle = {}, + onMindRecordToggle = {}, + onAfternoteToggle = {}, + ) + } +} diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyMakingScreenScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreenScreenshotTest.kt similarity index 93% rename from feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyMakingScreenScreenshotTest.kt rename to feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreenScreenshotTest.kt index 20bd91f093..3296b1ee22 100644 --- a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyMakingScreenScreenshotTest.kt +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.passkey import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyPasswordScreenScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreenScreenshotTest.kt similarity index 93% rename from feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyPasswordScreenScreenshotTest.kt rename to feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreenScreenshotTest.kt index b24a04c4f3..6bcef91f08 100644 --- a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyPasswordScreenScreenshotTest.kt +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.passkey import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyScreenScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyScreenScreenshotTest.kt similarity index 92% rename from feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyScreenScreenshotTest.kt rename to feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyScreenScreenshotTest.kt index 2102594c00..20e4d67fb5 100644 --- a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/PassKeyScreenScreenshotTest.kt +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyScreenScreenshotTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.passkey import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverEditScreenScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreenScreenshotTest.kt similarity index 88% rename from feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverEditScreenScreenshotTest.kt rename to feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreenScreenshotTest.kt index 1ec4a5046c..499ff2fb5a 100644 --- a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverEditScreenScreenshotTest.kt +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreenScreenshotTest.kt @@ -1,11 +1,10 @@ -package com.afternote.feature.setting.presentation.screen +package com.afternote.feature.setting.presentation.receiver import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.model.user.ReceiverDetail import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.setting.presentation.COMPACT_DEVICE_SPEC -import com.afternote.feature.setting.presentation.viewmodel.ReceiverEditUiState import com.android.tools.screenshot.PreviewTest @PreviewTest @@ -44,5 +43,4 @@ private val receiverPreview = timeLetterCount = 2, afterNoteCount = 1, message = "오랫동안 기억해 줘", - authCode = "AFTER123", ) diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTest.kt new file mode 100644 index 0000000000..bfdbd74779 --- /dev/null +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTest.kt @@ -0,0 +1,64 @@ +package com.afternote.feature.setting.presentation.receiver + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.ui.theme.AfternoteTheme +import com.android.tools.screenshot.PreviewTest + +/** 설정 수신자 목록의 조회 상태별 렌더 (#1281). 실제 0건 안내는 기존 관리 화면 그대로라 여기 두지 않는다. */ +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun receiverListInitialLoadingScreenshot() { + ReceiverListStateScreenshotContent(ReceiverListUiState(emptyList(), ReceiverListLoadState.Loading)) +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun receiverListLoadFailureScreenshot() { + ReceiverListStateScreenshotContent(ReceiverListUiState(emptyList(), ReceiverListLoadState.Failure)) +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun receiverListRefreshFailureScreenshot() { + ReceiverListStateScreenshotContent(ReceiverListUiState(previewReceivers, ReceiverListLoadState.RefreshFailure)) +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun receiverListSelectLoadFailureScreenshot() { + ReceiverListStateScreenshotContent( + uiState = ReceiverListUiState(emptyList(), ReceiverListLoadState.Failure), + selectForDeliveryConditions = true, + ) +} + +@Composable +private fun ReceiverListStateScreenshotContent( + uiState: ReceiverListUiState, + selectForDeliveryConditions: Boolean = false, +) { + AfternoteTheme { + ReceiverListRouteContent( + uiState = uiState, + selectForDeliveryConditions = selectForDeliveryConditions, + onBackClick = {}, + onRetryClick = {}, + onConfirmClick = {}, + onReceiverClick = {}, + onRegisterClick = {}, + ) + } +} + +private val previewReceivers = + listOf( + ReceiverListItem(receiverId = 1L, name = "김지은", relation = "딸"), + ReceiverListItem(receiverId = 2L, name = "박경민", relation = "친구"), + ReceiverListItem(receiverId = 3L, name = "이서준", relation = "아들"), + ) diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/PushNotificationScreenScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/PushNotificationScreenScreenshotTest.kt deleted file mode 100644 index e3afd8c6bc..0000000000 --- a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/PushNotificationScreenScreenshotTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.afternote.feature.setting.presentation.screen - -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.setting.presentation.COMPACT_DEVICE_SPEC -import com.afternote.feature.setting.presentation.viewmodel.PushNotificationUiState -import com.android.tools.screenshot.PreviewTest - -@PreviewTest -@Preview(showBackground = true) -@Composable -internal fun pushNotificationAlarmOnScreenshot() { - PushNotificationScreenshotContent(isDeviceAlarmOn = true) -} - -@PreviewTest -@Preview(showBackground = true) -@Composable -internal fun pushNotificationAlarmOffScreenshot() { - PushNotificationScreenshotContent(isDeviceAlarmOn = false) -} - -@PreviewTest -@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) -@Composable -internal fun pushNotificationAlarmOffCompactScreenshot() { - PushNotificationScreenshotContent(isDeviceAlarmOn = false) -} - -@Composable -private fun PushNotificationScreenshotContent(isDeviceAlarmOn: Boolean) { - AfternoteTheme { - PushNotificationContent( - uiState = PushNotificationUiState(isDeviceAlarmOn = isDeviceAlarmOn), - onBack = {}, - onDeviceAlarmClick = {}, - onNewsletterToggle = {}, - onMindRecordToggle = {}, - onAfternoteToggle = {}, - onSmsCheck = {}, - onEmailCheck = {}, - onPushCheck = {}, - ) - } -} diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsScreenCompactScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsScreenCompactScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..a1476a8552 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsScreenCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsScreenScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsScreenScreenshot_748aa731_0.png new file mode 100644 index 0000000000..70eeb351c0 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsScreenScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTestKt/appLockSetupConfirmNewCompactScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTestKt/appLockSetupConfirmNewCompactScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..df593b966b Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTestKt/appLockSetupConfirmNewCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTestKt/appLockSetupConfirmNewScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTestKt/appLockSetupConfirmNewScreenshot_748aa731_0.png new file mode 100644 index 0000000000..0c09633559 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTestKt/appLockSetupConfirmNewScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTestKt/appLockSetupEnterCurrentScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTestKt/appLockSetupEnterCurrentScreenshot_748aa731_0.png new file mode 100644 index 0000000000..74e34bd75a Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTestKt/appLockSetupEnterCurrentScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTestKt/appLockSetupEnterNewScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTestKt/appLockSetupEnterNewScreenshot_748aa731_0.png new file mode 100644 index 0000000000..3dde78fb20 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/applock/AppLockSetupScreenScreenshotTestKt/appLockSetupEnterNewScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreenScreenshotTestKt/withdrawConfirmScreenCompactScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreenScreenshotTestKt/withdrawConfirmScreenCompactScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..be4627f653 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreenScreenshotTestKt/withdrawConfirmScreenCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreenScreenshotTestKt/withdrawConfirmScreenLargeFontScreenshot_39fcf3fa_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreenScreenshotTestKt/withdrawConfirmScreenLargeFontScreenshot_39fcf3fa_0.png new file mode 100644 index 0000000000..be171748bc Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreenScreenshotTestKt/withdrawConfirmScreenLargeFontScreenshot_39fcf3fa_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreenScreenshotTestKt/withdrawConfirmScreenScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreenScreenshotTestKt/withdrawConfirmScreenScreenshot_748aa731_0.png new file mode 100644 index 0000000000..03e5839cad Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreenScreenshotTestKt/withdrawConfirmScreenScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationAlarmOffCompactScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationAlarmOffCompactScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..e41627eca2 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationAlarmOffCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationAlarmOffScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationAlarmOffScreenshot_748aa731_0.png new file mode 100644 index 0000000000..75353c875e Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationAlarmOffScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationAlarmOnScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationAlarmOnScreenshot_748aa731_0.png new file mode 100644 index 0000000000..975721aa49 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationAlarmOnScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationCompactScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationCompactScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..1eb8b370a5 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationScreenshot_748aa731_0.png new file mode 100644 index 0000000000..6dcdb9887e Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreenScreenshotTestKt/passKeyMakingScreenCompactScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreenScreenshotTestKt/passKeyMakingScreenCompactScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..fa2756fb1d Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreenScreenshotTestKt/passKeyMakingScreenCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreenScreenshotTestKt/passKeyMakingScreenScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreenScreenshotTestKt/passKeyMakingScreenScreenshot_748aa731_0.png new file mode 100644 index 0000000000..7d6fbfc47a Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreenScreenshotTestKt/passKeyMakingScreenScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreenScreenshotTestKt/passKeyPasswordScreenCompactScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreenScreenshotTestKt/passKeyPasswordScreenCompactScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..fa1b0a2691 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreenScreenshotTestKt/passKeyPasswordScreenCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreenScreenshotTestKt/passKeyPasswordScreenScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreenScreenshotTestKt/passKeyPasswordScreenScreenshot_748aa731_0.png new file mode 100644 index 0000000000..0a65d2c7c4 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyPasswordScreenScreenshotTestKt/passKeyPasswordScreenScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyScreenScreenshotTestKt/passKeyScreenCompactScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyScreenScreenshotTestKt/passKeyScreenCompactScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..baa6cec5f4 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyScreenScreenshotTestKt/passKeyScreenCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyScreenScreenshotTestKt/passKeyScreenScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyScreenScreenshotTestKt/passKeyScreenScreenshot_748aa731_0.png new file mode 100644 index 0000000000..bcabda7f1a Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/passkey/PassKeyScreenScreenshotTestKt/passKeyScreenScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreenScreenshotTestKt/receiverEditScreenCompactScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreenScreenshotTestKt/receiverEditScreenCompactScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..4d2e28a0e6 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreenScreenshotTestKt/receiverEditScreenCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreenScreenshotTestKt/receiverEditScreenScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreenScreenshotTestKt/receiverEditScreenScreenshot_748aa731_0.png new file mode 100644 index 0000000000..e726e4440f Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreenScreenshotTestKt/receiverEditScreenScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListInitialLoadingScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListInitialLoadingScreenshot_748aa731_0.png new file mode 100644 index 0000000000..6e79a360b7 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListInitialLoadingScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListLoadFailureScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListLoadFailureScreenshot_748aa731_0.png new file mode 100644 index 0000000000..8edf26d127 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListLoadFailureScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListRefreshFailureScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListRefreshFailureScreenshot_748aa731_0.png new file mode 100644 index 0000000000..938147ff0b Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListRefreshFailureScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListSelectLoadFailureScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListSelectLoadFailureScreenshot_748aa731_0.png new file mode 100644 index 0000000000..d2ba70a2bb Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListSelectLoadFailureScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverListRouteContentTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverListRouteContentTest.kt new file mode 100644 index 0000000000..b58c7b34c7 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverListRouteContentTest.kt @@ -0,0 +1,186 @@ +package com.afternote.feature.setting.presentation + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.receiver.ReceiverListLoadState +import com.afternote.feature.setting.presentation.receiver.ReceiverListRouteContent +import com.afternote.feature.setting.presentation.receiver.ReceiverListUiState +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * 설정 수신자 목록 라우트가 조회 상태를 관리·선택 두 화면에 옮기는 규칙 (#1281). + * + * 조회 중·실패를 성공한 0건으로 그리면 등록된 수신자가 없다는 거짓 안내와 등록 버튼이 뜬다. 그 두 줄이 + * 실제 0건일 때만 나오는지, 행이 있을 때는 조회 중·갱신 실패에도 행과 선택이 남는지를 본다. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class ReceiverListRouteContentTest { + @get:Rule + val composeRule = createComposeRule() + + private var retryCalls = 0 + private var confirmed: ReceiverListItem? = null + private var clickedReceiverId: Long? = null + + @Test + fun manage_firstLoading_showsProgressWithoutEmptyGuideOrRegisterButton() { + setContent(ReceiverListUiState(emptyList(), ReceiverListLoadState.Loading), selectForDeliveryConditions = false) + + composeRule.onAllNodes(progressMatcher).assertCountEquals(1) + composeRule.onNodeWithText(EMPTY_GUIDE).assertDoesNotExist() + composeRule.onNodeWithText(REGISTER).assertDoesNotExist() + } + + @Test + fun manage_failureWithoutRows_showsFailureAndRetryInsteadOfEmptyGuide() { + setContent(ReceiverListUiState(emptyList(), ReceiverListLoadState.Failure), selectForDeliveryConditions = false) + + composeRule.onNodeWithText(LOAD_FAILED).assertIsDisplayed() + composeRule.onNodeWithText(EMPTY_GUIDE).assertDoesNotExist() + composeRule.onNodeWithText(REGISTER).assertDoesNotExist() + composeRule.onNodeWithText(RETRY).performClick() + + assertEquals(1, retryCalls) + } + + @Test + fun manage_readyWithZeroReceivers_keepsEmptyGuideAndRegisterButton() { + setContent(ReceiverListUiState(emptyList(), ReceiverListLoadState.Ready), selectForDeliveryConditions = false) + + composeRule.onNodeWithText(EMPTY_GUIDE).assertIsDisplayed() + composeRule.onNodeWithText(REGISTER).assertIsDisplayed() + composeRule.onNodeWithText(LOAD_FAILED).assertDoesNotExist() + } + + @Test + fun manage_loadingWithRows_keepsRowsWithoutProgressOrBanner() { + setContent(ReceiverListUiState(receivers, ReceiverListLoadState.Loading), selectForDeliveryConditions = false) + + composeRule.onNodeWithText("박친구").assertIsDisplayed() + composeRule.onAllNodes(progressMatcher).assertCountEquals(0) + composeRule.onNodeWithText(REFRESH_FAILED).assertDoesNotExist() + } + + @Test + fun manage_refreshFailure_keepsRowsAndShowsBannerRetry() { + setContent(ReceiverListUiState(receivers, ReceiverListLoadState.RefreshFailure), selectForDeliveryConditions = false) + + composeRule.onNodeWithText(REFRESH_FAILED).assertIsDisplayed() + composeRule.onNodeWithText(LOAD_FAILED).assertDoesNotExist() + composeRule.onNodeWithText(RETRY).performClick() + composeRule.onNodeWithText("박친구").performClick() + + assertEquals(1, retryCalls) + assertEquals(11L, clickedReceiverId) + } + + @Test + fun select_firstLoading_replacesListWithProgress() { + setContent(ReceiverListUiState(emptyList(), ReceiverListLoadState.Loading), selectForDeliveryConditions = true) + + composeRule.onAllNodes(progressMatcher).assertCountEquals(1) + composeRule.onAllNodes(checkboxMatcher).assertCountEquals(0) + } + + @Test + fun select_failureWithoutRows_showsFailureAndRetry() { + setContent(ReceiverListUiState(emptyList(), ReceiverListLoadState.Failure), selectForDeliveryConditions = true) + + composeRule.onNodeWithText(LOAD_FAILED).assertIsDisplayed() + composeRule.onNodeWithText(RETRY).performClick() + + assertEquals(1, retryCalls) + } + + /** 검색·선택을 마친 뒤 다시 불러오기가 돌고 실패해도 같은 화면에서 그 선택으로 완료할 수 있어야 한다. */ + @Test + fun select_refreshLoadingThenFailure_keepsSearchAndSelection() { + var uiState by mutableStateOf(ReceiverListUiState(receivers, ReceiverListLoadState.Ready)) + composeRule.setContent { + AfternoteTheme { + ReceiverListRouteContent( + uiState = uiState, + selectForDeliveryConditions = true, + onBackClick = {}, + onRetryClick = { retryCalls += 1 }, + onConfirmClick = { confirmed = it }, + onReceiverClick = {}, + onRegisterClick = {}, + ) + } + } + composeRule.onNodeWithText("이름으로 검색하기").performTextInput("박") + composeRule.onAllNodes(checkboxMatcher).run { + assertCountEquals(1) + get(0).performClick() + } + + uiState = ReceiverListUiState(receivers, ReceiverListLoadState.Loading) + composeRule.waitForIdle() + uiState = ReceiverListUiState(receivers, ReceiverListLoadState.RefreshFailure) + composeRule.waitForIdle() + + composeRule.onNodeWithText("박친구").assertIsDisplayed() + composeRule.onNodeWithText("김수신").assertDoesNotExist() + composeRule.onNodeWithText("수신자 선택 완료하기").performClick() + assertEquals(receivers[1], confirmed) + } + + private fun setContent( + uiState: ReceiverListUiState, + selectForDeliveryConditions: Boolean, + ) { + composeRule.setContent { + AfternoteTheme { + ReceiverListRouteContent( + uiState = uiState, + selectForDeliveryConditions = selectForDeliveryConditions, + onBackClick = {}, + onRetryClick = { retryCalls += 1 }, + onConfirmClick = { confirmed = it }, + onReceiverClick = { clickedReceiverId = it }, + onRegisterClick = {}, + ) + } + } + } + + private val receivers = + listOf( + ReceiverListItem(receiverId = 7L, name = "김수신", relation = "가족"), + ReceiverListItem(receiverId = 11L, name = "박친구", relation = "친구"), + ) + + private companion object { + const val EMPTY_GUIDE = "등록된 수신자가 없습니다." + const val REGISTER = "수신자 등록하기" + const val LOAD_FAILED = "수신자 목록을 불러오지 못했습니다." + const val REFRESH_FAILED = "목록을 새로 불러오지 못했습니다. 지금 보이는 내용이 최신이 아닐 수 있어요." + const val RETRY = "다시 시도" + + val checkboxMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox) + val progressMatcher = + SemanticsMatcher.expectValue(SemanticsProperties.ProgressBarRangeInfo, ProgressBarRangeInfo.Indeterminate) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverManageScreenTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverManageScreenTest.kt new file mode 100644 index 0000000000..daf5705fa6 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverManageScreenTest.kt @@ -0,0 +1,114 @@ +package com.afternote.feature.setting.presentation + +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.receiver.ReceiverManageScreen +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** #631 — 설정의 수신자 목록은 선택 화면이 아니라 관리 화면으로 동작해야 한다. */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class ReceiverManageScreenTest { + @get:Rule + val composeRule = createComposeRule() + + private val receivers = + listOf( + ReceiverListItem(receiverId = 7L, name = "김수신", relation = "가족"), + ReceiverListItem(receiverId = 11L, name = "박친구", relation = "친구"), + ) + + @Test + fun tappingRow_navigatesWithTappedReceiverId() { + var clickedId: Long? = null + setReceiverContent(onReceiverClick = { clickedId = it }) + + composeRule.onNodeWithText("박친구").performClick() + + assertEquals(11L, clickedId) + } + + @Test + fun screen_hasNoSelectionUi() { + setReceiverContent() + + composeRule.onAllNodes(checkboxMatcher).assertCountEquals(0) + composeRule.onNodeWithText("수신자 선택 완료하기").assertDoesNotExist() + } + + @Test + fun backClick_invokesOnBackClick() { + var backCalls = 0 + setReceiverContent(onBackClick = { backCalls += 1 }) + + composeRule.onNodeWithContentDescription("뒤로가기").performClick() + + assertEquals(1, backCalls) + } + + /** #556 — 0건에서 빈 화면이 아니라 안내 문구가 떠야 한다. */ + @Test + fun emptyReceivers_showsEmptyGuide() { + setReceiverContent(receivers = emptyList()) + + composeRule.onNodeWithText("등록된 수신자가 없습니다.").assertIsDisplayed() + composeRule.onNodeWithText("수신자를 등록하고 쉽게 관리해 보세요.").assertIsDisplayed() + } + + /** #556 — 노션 2차 QA 6번이 짚은 등록 진입점 부재 축. 안내에서 바로 등록으로 갈 수 있어야 한다. */ + @Test + fun emptyReceivers_registerButtonInvokesOnRegisterClick() { + var registerCalls = 0 + setReceiverContent(receivers = emptyList(), onRegisterClick = { registerCalls += 1 }) + + composeRule.onNodeWithText("수신자 등록하기").performClick() + + assertEquals(1, registerCalls) + } + + @Test + fun nonEmptyReceivers_hasNoEmptyGuide() { + setReceiverContent() + + composeRule.onNodeWithText("등록된 수신자가 없습니다.").assertDoesNotExist() + composeRule.onNodeWithText("수신자 등록하기").assertDoesNotExist() + } + + private fun setReceiverContent( + receivers: List = this.receivers, + onBackClick: () -> Unit = {}, + onReceiverClick: (Long) -> Unit = {}, + onRegisterClick: () -> Unit = {}, + ) { + composeRule.setContent { + AfternoteTheme { + ReceiverManageScreen( + receivers = receivers, + onBackClick = onBackClick, + onReceiverClick = onReceiverClick, + onRegisterClick = onRegisterClick, + ) + } + } + } + + private companion object { + val checkboxMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverSelectionBoundaryTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverSelectionBoundaryTest.kt index f738498262..5162df2d59 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverSelectionBoundaryTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverSelectionBoundaryTest.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput import com.afternote.core.model.setting.ReceiverListItem import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.setting.presentation.screen.ReceiverListScreen +import com.afternote.feature.setting.presentation.receiver.ReceiverListScreen import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Rule diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverSelectionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverSelectionTest.kt index d002c1fa53..4bd3d5c8c7 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverSelectionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverSelectionTest.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import com.afternote.core.model.setting.ReceiverListItem import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.setting.presentation.screen.ReceiverListScreen +import com.afternote.feature.setting.presentation.receiver.ReceiverListScreen import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt index ce03d0030b..cb449670ea 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt @@ -6,12 +6,11 @@ import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput -import androidx.lifecycle.SavedStateHandle import com.afternote.core.domain.testing.FakeAuthRepository -import com.afternote.core.domain.testing.FakeUserRepository -import com.afternote.core.domain.testing.FakeUserRepository.ConnectedAccountLinkCall -import com.afternote.core.domain.testing.FakeUserRepository.DeliveryUpdateCall -import com.afternote.core.domain.testing.FakeUserRepository.ProfileUpdateCall +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakeMyProfileRepository.ProfileUpdateCall +import com.afternote.core.domain.testing.FakeUserReceiverRepository +import com.afternote.core.domain.testing.FakeUserReceiverRepository.DeliveryUpdateCall import com.afternote.core.model.delivery.ConditionState import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.DeliveryConditionType @@ -23,24 +22,27 @@ import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount import com.afternote.core.ui.UiText import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.setting.presentation.component.PinSetupStep -import com.afternote.feature.setting.presentation.screen.AppLockSetupScreen -import com.afternote.feature.setting.presentation.screen.PassKeyListScreen -import com.afternote.feature.setting.presentation.screen.PassKeyScreen -import com.afternote.feature.setting.presentation.screen.ProfileEditScreen -import com.afternote.feature.setting.presentation.screen.WithdrawConfirmScreen -import com.afternote.feature.setting.presentation.viewmodel.AppLockSetupViewModel -import com.afternote.feature.setting.presentation.viewmodel.ConnectedAccountsEvent -import com.afternote.feature.setting.presentation.viewmodel.ConnectedAccountsViewModel -import com.afternote.feature.setting.presentation.viewmodel.DeliveryConditionError -import com.afternote.feature.setting.presentation.viewmodel.DeliveryConditionViewModel -import com.afternote.feature.setting.presentation.viewmodel.ProfileEditEvent -import com.afternote.feature.setting.presentation.viewmodel.ProfileEditUiState -import com.afternote.feature.setting.presentation.viewmodel.ProfileEditViewModel -import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterViewModel -import com.afternote.feature.setting.presentation.viewmodel.SettingUiState -import com.afternote.feature.setting.presentation.viewmodel.SettingViewModel -import com.afternote.feature.setting.presentation.viewmodel.WithdrawUiState +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository.ConnectedAccountLinkCall +import com.afternote.feature.setting.presentation.account.ConnectedAccountsEvent +import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel +import com.afternote.feature.setting.presentation.applock.AppLockSetupScreen +import com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel +import com.afternote.feature.setting.presentation.applock.PinSetupStep +import com.afternote.feature.setting.presentation.delivery.DeliveryConditionError +import com.afternote.feature.setting.presentation.delivery.DeliveryConditionViewModel +import com.afternote.feature.setting.presentation.home.SettingUiState +import com.afternote.feature.setting.presentation.home.SettingViewModel +import com.afternote.feature.setting.presentation.home.WithdrawConfirmScreen +import com.afternote.feature.setting.presentation.home.WithdrawUiState +import com.afternote.feature.setting.presentation.navigation.SettingRoute +import com.afternote.feature.setting.presentation.passkey.PassKeyListScreen +import com.afternote.feature.setting.presentation.passkey.PassKeyScreen +import com.afternote.feature.setting.presentation.profile.ProfileEditEvent +import com.afternote.feature.setting.presentation.profile.ProfileEditScreen +import com.afternote.feature.setting.presentation.profile.ProfileEditUiState +import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel +import com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -66,7 +68,7 @@ class SettingAccountSecurityTest { @Test fun profileLoadValidationAndUpdateFailure_preserveExactContract() { val loadFailureRepository = - settingContractUserRepository().apply { + settingContractProfileRepository().apply { onGetMyProfile = { throw IllegalStateException("profile unavailable") } } val loadFailureViewModel = ProfileEditViewModel(loadFailureRepository) @@ -88,7 +90,7 @@ class SettingAccountSecurityTest { composeRule.runOnIdle { loadFailureViewModel.updateProfile("새 이름", "01012345678") } assertTrue(loadFailureRepository.profileUpdateCalls.isEmpty()) - val updateFailureRepository = settingContractUserRepository() + val updateFailureRepository = settingContractProfileRepository() val updateFailureViewModel = ProfileEditViewModel(updateFailureRepository) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { updateFailureViewModel.uiState.value is ProfileEditUiState.Success @@ -110,7 +112,7 @@ class SettingAccountSecurityTest { @Test fun connectedAccountLinkAndUnlink_preservePreCallAndFailureBoundaries() { - val linkRepository = settingContractUserRepository() + val linkRepository = settingContractAccountRepository() val linkViewModel = ConnectedAccountsViewModel(linkRepository) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { !linkViewModel.uiState.value.isLoading @@ -134,7 +136,7 @@ class SettingAccountSecurityTest { ) val unlinkRepository = - settingContractUserRepository().apply { + settingContractAccountRepository().apply { onGetConnectedAccounts = { connectedAccounts(google = true) } onUnlinkConnectedAccount = { throw IllegalStateException("server error") } } @@ -155,7 +157,7 @@ class SettingAccountSecurityTest { @Test fun receiverRegister_blankRequiredEmail_isRejectedBeforeRepositoryCall() { - val repository = settingContractUserRepository() + val repository = settingContractReceiverRepository() val viewModel = ReceiverRegisterViewModel(repository) composeRule.runOnIdle { @@ -168,7 +170,7 @@ class SettingAccountSecurityTest { ) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.uiState.value.errorMessage == UiText.Resource(SettingR.string.receiver_email_required) + viewModel.uiState.value.errorMessage == UiText.Resource(SettingR.string.setting_receiver_email_required) } assertTrue(repository.receiverCreateCalls.isEmpty()) @@ -191,7 +193,7 @@ class SettingAccountSecurityTest { ), ) val repository = - settingContractUserRepository().apply { + settingContractReceiverRepository().apply { onGetReceiverDeliveryConditions = { ReceiverDeliveryConditions( receiverId = RECEIVER_ID, @@ -202,8 +204,8 @@ class SettingAccountSecurityTest { } val viewModel = DeliveryConditionViewModel( - savedStateHandle = SavedStateHandle(mapOf("receiverId" to RECEIVER_ID)), - userRepository = repository, + route = SettingRoute.AfterDeliveryRoute(RECEIVER_ID), + receiverRepository = repository, ) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isInitialized @@ -297,11 +299,11 @@ class SettingAccountSecurityTest { @Test fun withdrawFailure_requiresFinalConfirmationAndKeepsSession() { val authRepository = settingContractAuthRepository() - val userRepository = - settingContractUserRepository().apply { + val accountRepository = + settingContractAccountRepository().apply { onDeleteAccount = { throw IllegalStateException("delete rejected") } } - val viewModel = SettingViewModel(authRepository, userRepository) + val viewModel = SettingViewModel(authRepository, settingContractProfileRepository(), accountRepository) var successCalls = 0 composeRule.setContent { AfternoteTheme { @@ -316,12 +318,12 @@ class SettingAccountSecurityTest { composeRule.onNodeWithText("탈퇴하기").performClick() composeRule.onNodeWithText("문장이 일치하지 않습니다. 재입력해 주세요.").assertIsDisplayed() - assertEquals(0, userRepository.deleteAccountCalls) + assertEquals(0, accountRepository.deleteAccountCalls) composeRule.onNodeWithText("탈퇴하겠습니다").performTextInput("탈퇴하겠습니다") composeRule.onNodeWithText("탈퇴하기").performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - userRepository.deleteAccountCalls == 1 + accountRepository.deleteAccountCalls == 1 } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { @@ -388,19 +390,27 @@ private fun deliveryCondition( fulfilledAt = null, ) -private fun settingContractUserRepository(): FakeUserRepository = - FakeUserRepository.strict().apply { - onReceiverListFlow = { receiverState } +private fun settingContractProfileRepository(): FakeMyProfileRepository = + FakeMyProfileRepository.strict().apply { onGetMyProfile = { DEFAULT_USER } onUpdateMyProfile = { _, _, _ -> DEFAULT_USER } - onGetConnectedAccounts = { connectedAccounts() } - onLinkConnectedAccount = { _, _ -> connectedAccounts(google = true) } - onUnlinkConnectedAccount = { connectedAccounts() } + } + +private fun settingContractReceiverRepository(): FakeUserReceiverRepository = + FakeUserReceiverRepository.strict().apply { + onReceiverListFlow = { receiverState } onCreateReceiver = { _, _, _, _, _ -> ReceiverCreated(1L, "AUTH-1") } onGetReceiverDeliveryConditions = { receiverId -> ReceiverDeliveryConditions(receiverId, emptyList()) } onUpdateReceiverDeliveryConditions = { receiverId, conditions -> ReceiverDeliveryConditions(receiverId, conditions) } + } + +private fun settingContractAccountRepository(): FakeSettingAccountRepository = + FakeSettingAccountRepository.strict().apply { + onGetConnectedAccounts = { connectedAccounts() } + onLinkConnectedAccount = { _, _ -> connectedAccounts(google = true) } + onUnlinkConnectedAccount = { connectedAccounts() } onDeleteAccount = {} } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index 3f04e31abb..52472d7104 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -1,19 +1,24 @@ package com.afternote.feature.setting.presentation import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasScrollToIndexAction import androidx.compose.ui.test.hasScrollToNodeAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput -import androidx.lifecycle.SavedStateHandle import androidx.test.core.app.ApplicationProvider import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.data.repoimpl.UserRepositoryImpl import com.afternote.core.domain.testing.FakeAuthRepository -import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakeUserReceiverRepository import com.afternote.core.model.delivery.ConditionState import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.DeliveryConditionType @@ -25,45 +30,29 @@ import com.afternote.core.model.user.ReceiverCreated import com.afternote.core.model.user.ReceiverDetail import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.core.model.user.UserMarketingConsent import com.afternote.core.model.user.UserPushSetting -import com.afternote.core.network.dto.DeletePushTokenRequestDto -import com.afternote.core.network.dto.PushTokenDto -import com.afternote.core.network.dto.ReceiverDetailDto -import com.afternote.core.network.dto.ReceiverListDto -import com.afternote.core.network.dto.RegisterPushTokenRequestDto -import com.afternote.core.network.dto.SocialAccountLinkRequestDto -import com.afternote.core.network.dto.UserConnectedAccountDto -import com.afternote.core.network.dto.UserCreateReceiverDto -import com.afternote.core.network.dto.UserCreateReceiverRequestDto -import com.afternote.core.network.dto.UserDto -import com.afternote.core.network.dto.UserPatchReceiverDto -import com.afternote.core.network.dto.UserPatchReceiverRequestDto -import com.afternote.core.network.dto.UserPushSettingDto -import com.afternote.core.network.dto.UserUpdateProfileRequestDto -import com.afternote.core.network.dto.UserUpdatePushSettingRequestDto -import com.afternote.core.network.dto.UserUpdateReceiverMessageRequestDto -import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionDto -import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionUpdateRequestDto -import com.afternote.core.network.model.BaseResponse -import com.afternote.core.network.service.UserApiService import com.afternote.core.ui.UiText import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.setting.presentation.screen.ReceiverEditScreen -import com.afternote.feature.setting.presentation.screen.ReceiverRegisterScreen -import com.afternote.feature.setting.presentation.viewmodel.ConnectedAccountsViewModel -import com.afternote.feature.setting.presentation.viewmodel.DeliveryConditionError -import com.afternote.feature.setting.presentation.viewmodel.DeliveryConditionViewModel -import com.afternote.feature.setting.presentation.viewmodel.ProfileEditEvent -import com.afternote.feature.setting.presentation.viewmodel.ProfileEditUiState -import com.afternote.feature.setting.presentation.viewmodel.ProfileEditViewModel -import com.afternote.feature.setting.presentation.viewmodel.PushNotificationViewModel -import com.afternote.feature.setting.presentation.viewmodel.ReceiverEditEvent -import com.afternote.feature.setting.presentation.viewmodel.ReceiverEditViewModel -import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterEvent -import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterViewModel -import com.afternote.feature.setting.presentation.viewmodel.SettingUiState -import com.afternote.feature.setting.presentation.viewmodel.SettingViewModel -import com.afternote.feature.setting.presentation.viewmodel.WithdrawUiState +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel +import com.afternote.feature.setting.presentation.delivery.DeliveryConditionError +import com.afternote.feature.setting.presentation.delivery.DeliveryConditionViewModel +import com.afternote.feature.setting.presentation.home.SettingUiState +import com.afternote.feature.setting.presentation.home.SettingViewModel +import com.afternote.feature.setting.presentation.home.WithdrawUiState +import com.afternote.feature.setting.presentation.navigation.SettingRoute +import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel +import com.afternote.feature.setting.presentation.profile.ProfileEditEvent +import com.afternote.feature.setting.presentation.profile.ProfileEditUiState +import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel +import com.afternote.feature.setting.presentation.receiver.ReceiverEditEvent +import com.afternote.feature.setting.presentation.receiver.ReceiverEditScreen +import com.afternote.feature.setting.presentation.receiver.ReceiverEditViewModel +import com.afternote.feature.setting.presentation.receiver.ReceiverRegisterEvent +import com.afternote.feature.setting.presentation.receiver.ReceiverRegisterScreen +import com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -80,13 +69,13 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode import java.util.ArrayDeque -import com.afternote.core.domain.testing.FakeUserRepository.ConnectedAccountLinkCall as CompletionConnectedLinkCall -import com.afternote.core.domain.testing.FakeUserRepository.DeliveryUpdateCall as CompletionDeliveryUpdateCall -import com.afternote.core.domain.testing.FakeUserRepository.ProfileUpdateCall as CompletionProfileUpdateCall -import com.afternote.core.domain.testing.FakeUserRepository.PushUpdateCall as CompletionPushUpdateCall -import com.afternote.core.domain.testing.FakeUserRepository.ReceiverCreateCall as CompletionReceiverRegistrationCall -import com.afternote.core.domain.testing.FakeUserRepository.ReceiverMessageCall as CompletionReceiverMessageCall -import com.afternote.core.domain.testing.FakeUserRepository.ReceiverUpdateCall as CompletionReceiverEditCall +import com.afternote.core.domain.testing.FakeMyProfileRepository.ProfileUpdateCall as CompletionProfileUpdateCall +import com.afternote.core.domain.testing.FakeUserReceiverRepository.DeliveryUpdateCall as CompletionDeliveryUpdateCall +import com.afternote.core.domain.testing.FakeUserReceiverRepository.ReceiverCreateCall as CompletionReceiverRegistrationCall +import com.afternote.core.domain.testing.FakeUserReceiverRepository.ReceiverMessageCall as CompletionReceiverMessageCall +import com.afternote.core.domain.testing.FakeUserReceiverRepository.ReceiverUpdateCall as CompletionReceiverEditCall +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository.ConnectedAccountLinkCall as CompletionConnectedLinkCall +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository.PushUpdateCall as CompletionPushUpdateCall import com.afternote.feature.setting.presentation.R as SettingR @RunWith(RobolectricTestRunner::class) @@ -100,7 +89,7 @@ class SettingCompletionTest { fun profileUpdate_success_emitsExactPayloadAndEventAfterPendingRequest() { setHarnessContent() val scenario = CompletionUserScenario() - val repository = scenario.repository + val repository = scenario.profileRepository val updateGate = scenario.enqueueProfileUpdate() val viewModel = ProfileEditViewModel(repository) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { @@ -131,14 +120,15 @@ class SettingCompletionTest { fun pushUpdates_keepOptimisticSuccessAndRollbackFailureWithExactPatches() { setHarnessContent() val scenario = CompletionUserScenario() - val repository = scenario.repository + val repository = scenario.notificationRepository val newsletterGate = scenario.enqueuePushUpdate() val mindRecordGate = scenario.enqueuePushUpdate() val afterNoteGate = scenario.enqueuePushUpdate() val viewModel = PushNotificationViewModel( context = ApplicationProvider.getApplicationContext(), - userRepository = repository, + notificationRepository = repository, + errorReporter = NoOpErrorReporter, ) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { !viewModel.uiState.value.isLoading @@ -184,6 +174,7 @@ class SettingCompletionTest { assertTrue(viewModel.uiState.value.isMindRecordOn) assertFalse(viewModel.uiState.value.isNewsletterOn) + assertTrue(viewModel.uiState.value.saveFailure != null) composeRule.runOnIdle { viewModel.onAfternoteToggle(false) @@ -219,7 +210,7 @@ class SettingCompletionTest { fun connectedAccounts_linkThenUnlink_appliesExactPayloadAndServerState() { setHarnessContent() val scenario = CompletionUserScenario() - val repository = scenario.repository + val repository = scenario.accountRepository val linkGate = scenario.enqueueConnectedLink() val unlinkGate = scenario.enqueueConnectedUnlink() val viewModel = ConnectedAccountsViewModel(repository) @@ -285,7 +276,7 @@ class SettingCompletionTest { fun receiverRegistration_failureThenRetry_succeedsWithSameNormalizedPayload() { setHarnessContent() val scenario = CompletionUserScenario() - val repository = scenario.repository + val repository = scenario.receiverRepository val firstGate = scenario.enqueueReceiverCreate() val retryGate = scenario.enqueueReceiverCreate() val viewModel = ReceiverRegisterViewModel(repository) @@ -314,7 +305,7 @@ class SettingCompletionTest { firstGate.complete(Result.failure(IllegalStateException("temporary failure"))) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.uiState.value.errorMessage == UiText.Resource(SettingR.string.receiver_register_failed) + viewModel.uiState.value.errorMessage == UiText.Resource(SettingR.string.setting_receiver_register_failed) } assertFalse(viewModel.uiState.value.isLoading) @@ -340,11 +331,46 @@ class SettingCompletionTest { assertEquals(ReceiverRegisterEvent.RegisterSuccess, awaitEvent(viewModel.events)) } + @Test + fun receiverRegistration_requiresValidEmailBeforeEnablingRegister() { + val viewModel = ReceiverRegisterViewModel(FakeUserReceiverRepository.strict()) + composeRule.setContent { + AfternoteTheme { + ReceiverRegisterScreen( + onBackClick = {}, + onRegisterSuccess = {}, + viewModel = viewModel, + ) + } + } + val registerButton = hasText("등록") and hasClickAction() + + composeRule.onNode(registerButton).assertIsNotEnabled() + composeRule.onNodeWithText("이름을 입력하세요").performTextInput("김수신") + composeRule.onNodeWithText("연락처를 지정해주세요").performTextInput("01012345678") + composeRule + .onNode(hasText("관계를 선택하세요") and hasClickAction()) + .performClick() + composeRule.onNodeWithText("어머니").performClick() + // 이메일 필드(6행 중 5번째, index 4)는 테스트 뷰포트 밖이라 LazyColumn이 아직 구성하지 않는다 — + // performScrollTo()는 이미 구성된 노드만 찾을 수 있어 인덱스로 직접 스크롤한다. + composeRule.onNode(hasScrollToIndexAction()).performScrollToIndex(4) + composeRule + .onNodeWithText("afternote@email.com") + .performTextInput("invalid-email") + composeRule.onNode(registerButton).assertIsNotEnabled() + + composeRule.onNodeWithText("invalid-email").performTextClearance() + composeRule.onNodeWithText("afternote@email.com").performTextInput("receiver@afternote.com") + + composeRule.onNode(registerButton).assertIsEnabled() + } + @Test fun receiverEdit_serialFailurePartialFailureThenRetry_sendsExactPayloadOncePerAttempt() { setHarnessContent() val scenario = CompletionUserScenario() - val repository = scenario.repository + val repository = scenario.receiverRepository val firstBasicGate = scenario.enqueueReceiverUpdate() val retryBasicGate = scenario.enqueueReceiverUpdate() val retryMessageGate = scenario.enqueueReceiverMessageUpdate() @@ -352,8 +378,8 @@ class SettingCompletionTest { val finalMessageGate = scenario.enqueueReceiverMessageUpdate() val viewModel = ReceiverEditViewModel( - savedStateHandle = SavedStateHandle(mapOf("receiverId" to RECEIVER_ID)), - userRepository = repository, + route = SettingRoute.RecipientEditRoute(RECEIVER_ID), + receiverRepository = repository, ) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.receiver == COMPLETION_DEFAULT_RECEIVER_DETAIL @@ -394,7 +420,7 @@ class SettingCompletionTest { firstBasicGate.complete(Result.failure(IllegalStateException("basic update failed"))) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.uiState.value.errorMessage == UiText.Resource(SettingR.string.receiver_edit_failed) + viewModel.uiState.value.errorMessage == UiText.Resource(SettingR.string.setting_receiver_edit_failed) } assertFalse(viewModel.uiState.value.isSaving) assertTrue(repository.receiverMessageCalls.isEmpty()) @@ -410,7 +436,7 @@ class SettingCompletionTest { retryMessageGate.complete(Result.failure(IllegalStateException("message update failed"))) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.errorMessage == - UiText.Resource(SettingR.string.receiver_message_update_partial_failed) + UiText.Resource(SettingR.string.setting_receiver_message_update_partial_failed) } assertEquals(listOf(expectedMessageCall), repository.receiverMessageCalls) @@ -442,11 +468,11 @@ class SettingCompletionTest { CompletionUserScenario().apply { receiverDetail = COMPLETION_DEFAULT_RECEIVER_DETAIL.copy(phone = null) } - val repository = scenario.repository + val repository = scenario.receiverRepository val viewModel = ReceiverEditViewModel( - savedStateHandle = SavedStateHandle(mapOf("receiverId" to RECEIVER_ID)), - userRepository = repository, + route = SettingRoute.RecipientEditRoute(RECEIVER_ID), + receiverRepository = repository, ) composeRule.setContent { @@ -469,7 +495,7 @@ class SettingCompletionTest { @Test fun receiverRegister_blankPhoneWiring_disablesSubmitAndShowsRequiredMessage() { - val repository = FakeUserRepository(receivers = emptyList()) + val repository = FakeUserReceiverRepository(receivers = emptyList()) val viewModel = ReceiverRegisterViewModel(repository) composeRule.setContent { @@ -495,7 +521,7 @@ class SettingCompletionTest { fun deliveryCondition_failureThenRetry_succeedsWithExactReceiverPatch() { setHarnessContent() val scenario = CompletionUserScenario() - val repository = scenario.repository + val repository = scenario.receiverRepository val initialConditions = completionDefaultDeliveryConditions() scenario.deliveryConditions = ReceiverDeliveryConditions(receiverId = RECEIVER_ID, conditions = initialConditions) @@ -503,8 +529,8 @@ class SettingCompletionTest { val retryGate = scenario.enqueueDeliveryUpdate() val viewModel = DeliveryConditionViewModel( - savedStateHandle = SavedStateHandle(mapOf("receiverId" to RECEIVER_ID)), - userRepository = repository, + route = SettingRoute.AfterDeliveryRoute(RECEIVER_ID), + receiverRepository = repository, ) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isInitialized @@ -566,53 +592,56 @@ class SettingCompletionTest { } @Test - fun withdrawal_failureThenRetry_cleansSessionExactlyOnceAfterServerSuccess() { + fun withdrawal_failureThenRetry_reportsErrorThenSuccess() { setHarnessContent() - val calls = CompletionCallLedger() - val userApi = CompletionGatedWithdrawalUserApi(calls) - val firstGate = userApi.enqueueDelete() - val retryGate = userApi.enqueueDelete() - val authRepository = completionStatefulWithdrawalAuthRepository(calls) - val repository = UserRepositoryImpl(userApi, authRepository, NoopErrorReporter) - val viewModel = SettingViewModel(authRepository, repository) + val firstGate = CompletableDeferred() + val retryGate = CompletableDeferred() + val deleteGates = ArrayDeque(listOf(firstGate, retryGate)) + val authRepository = + FakeAuthRepository + .strict(loggedIn = true, accessToken = "access", refreshToken = "refresh") + .apply { + onIsLoggedIn = { loggedInState } + onGetAccessToken = null + onGetRefreshToken = null + } + // 탈퇴 성공 뒤 세션을 정확히 한 번, 서버 호출 뒤에 정리하는 것은 저장소 구현의 계약이라 + // :feature:setting:data `SettingAccountRepositoryImplTest` 의 deleteAccount 계열이 고정한다. + // 여기서는 화면 상태 전이만 본다. + val profileRepository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { COMPLETION_DEFAULT_USER } + } + val repository = + FakeSettingAccountRepository.strict().apply { + onDeleteAccount = { deleteGates.removeFirst().await() } + } + val viewModel = SettingViewModel(authRepository, profileRepository, repository) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value is SettingUiState.Success } composeRule.runOnIdle { viewModel.deleteAccount() } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - userApi.deleteCalls == 1 + repository.deleteAccountCalls == 1 } - assertEquals(0, authRepository.clearSessionCalls) + assertEquals(WithdrawUiState.Loading, viewModel.withdrawUiState.value) - firstGate.complete(BaseResponse(status = 503, code = 503, message = "retry")) - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - userApi.completedDeleteCalls == 1 - } + firstGate.completeExceptionally(IllegalStateException("서버가 503 으로 거절했다")) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.withdrawUiState.value == WithdrawUiState.Error } - assertEquals(0, authRepository.clearSessionCalls) - assertTrue(runBlocking { authRepository.isLoggedIn.first() }) composeRule.runOnIdle { viewModel.deleteAccount() } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - userApi.deleteCalls == 2 + repository.deleteAccountCalls == 2 } - assertEquals(0, authRepository.clearSessionCalls) - retryGate.complete(BaseResponse(status = 200, code = 200)) + retryGate.complete(Unit) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.withdrawUiState.value == WithdrawUiState.Success } - - assertEquals(2, userApi.completedDeleteCalls) - assertEquals(1, authRepository.clearSessionCalls) - assertFalse(runBlocking { authRepository.isLoggedIn.first() }) - assertEquals( - listOf("deleteAccount#1", "deleteAccount#2", "clearSession"), - calls.snapshot(), - ) + assertEquals(2, repository.deleteAccountCalls) } private fun setHarnessContent() { @@ -630,13 +659,6 @@ class SettingCompletionTest { } } -private object NoopErrorReporter : ErrorReporter { - override fun writeFailure( - throwable: Throwable, - attributes: Map, - ) = Unit -} - private val COMPLETION_DEFAULT_USER = User( name = "테스트 사용자", @@ -652,6 +674,13 @@ private val COMPLETION_DEFAULT_PUSH_SETTING = afterNote = true, ) +private val COMPLETION_DEFAULT_MARKETING_CONSENT = + UserMarketingConsent( + sms = true, + email = true, + push = false, + ) + private val COMPLETION_DEFAULT_RECEIVER_DETAIL = ReceiverDetail( receiverId = 77L, @@ -663,7 +692,6 @@ private val COMPLETION_DEFAULT_RECEIVER_DETAIL = timeLetterCount = 2, afterNoteCount = 3, message = "기존 마지막 인사말", - authCode = "AUTH-77", ) private fun completionConnectedAccounts( @@ -687,7 +715,6 @@ private fun completionUpdatedReceiver(call: CompletionReceiverEditCall) = receiverId = call.receiverId, name = call.name, relation = call.relation, - authCode = "AUTH-77", ) private fun completionDefaultDeliveryConditions() = @@ -731,22 +758,17 @@ private class CompletionUserScenario { val completedPushUpdates: Int get() = synchronized(this) { pushUpdateCompletions } - val repository = - FakeUserRepository.strict().apply { - onReceiverListFlow = { flowOf(emptyList()) } + val profileRepository = + FakeMyProfileRepository.strict().apply { onGetMyProfile = { COMPLETION_DEFAULT_USER } onUpdateMyProfile = { _, _, _ -> takeGate(profileUpdateGates, "updateMyProfile").await().getOrThrow() } - onGetMyPushSettings = { COMPLETION_DEFAULT_PUSH_SETTING } - onUpdateMyPushSettings = { _, _, _ -> - val gate = takeGate(pushUpdateGates, "updateMyPushSettings") - try { - gate.await().getOrThrow() - } finally { - synchronized(this@CompletionUserScenario) { pushUpdateCompletions += 1 } - } - } + } + + val receiverRepository = + FakeUserReceiverRepository.strict().apply { + onReceiverListFlow = { flowOf(emptyList()) } onCreateReceiver = { _, _, _, _, _ -> takeGate(receiverCreateGates, "createReceiver").await().getOrThrow() } @@ -767,6 +789,10 @@ private class CompletionUserScenario { onUpdateReceiverMessage = { _, _ -> takeGate(receiverMessageGates, "updateReceiverMessage").await().getOrThrow() } + } + + val accountRepository = + FakeSettingAccountRepository.strict().apply { onGetConnectedAccounts = { completionConnectedAccounts() } onLinkConnectedAccount = { _, _ -> takeGate(connectedLinkGates, "linkConnectedAccount").await().getOrThrow() @@ -776,6 +802,27 @@ private class CompletionUserScenario { } } + val notificationRepository = + FakeSettingNotificationRepository.strict().apply { + onGetMyPushSettings = { COMPLETION_DEFAULT_PUSH_SETTING } + onUpdateMyPushSettings = { _, _, _ -> + val gate = takeGate(pushUpdateGates, "updateMyPushSettings") + try { + gate.await().getOrThrow() + } finally { + synchronized(this@CompletionUserScenario) { pushUpdateCompletions += 1 } + } + } + onGetMyMarketingConsents = { COMPLETION_DEFAULT_MARKETING_CONSENT } + onUpdateMyMarketingConsents = { sms, email, push -> + COMPLETION_DEFAULT_MARKETING_CONSENT.copy( + sms = sms ?: COMPLETION_DEFAULT_MARKETING_CONSENT.sms, + email = email ?: COMPLETION_DEFAULT_MARKETING_CONSENT.email, + push = push ?: COMPLETION_DEFAULT_MARKETING_CONSENT.push, + ) + } + } + fun enqueueProfileUpdate(): CompletableDeferred> = CompletableDeferred>().also { gate -> synchronized(this) { profileUpdateGates.addLast(gate) } @@ -822,128 +869,9 @@ private class CompletionUserScenario { ): T = synchronized(this) { gates.pollFirst() ?: error("$method gate was not prepared") } } -private class CompletionCallLedger { - private val calls = mutableListOf() - - fun record(call: String) { - synchronized(this) { calls += call } - } - - fun snapshot(): List = synchronized(this) { calls.toList() } -} - -private class CompletionGatedWithdrawalUserApi( - private val calls: CompletionCallLedger, -) : UserApiService { - private val deleteGates = ArrayDeque>>() - private var startedDeletes = 0 - private var completedDeletes = 0 - - val deleteCalls: Int - get() = synchronized(this) { startedDeletes } - - val completedDeleteCalls: Int - get() = synchronized(this) { completedDeletes } - - fun enqueueDelete(): CompletableDeferred> = - CompletableDeferred>().also { gate -> - synchronized(this) { deleteGates.addLast(gate) } - } - - override suspend fun registerPushToken(request: RegisterPushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") - - override suspend fun deletePushToken(request: DeletePushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") - - override suspend fun getMyProfile(): BaseResponse = - BaseResponse( - status = 200, - code = 200, - data = - UserDto( - name = COMPLETION_DEFAULT_USER.name, - email = COMPLETION_DEFAULT_USER.email, - phone = COMPLETION_DEFAULT_USER.phone, - profileImageUrl = COMPLETION_DEFAULT_USER.profileImageUrl, - ), - ) - - override suspend fun deleteAccount(): BaseResponse { - val callNumber: Int - val gate: CompletableDeferred> - synchronized(this) { - startedDeletes += 1 - callNumber = startedDeletes - gate = deleteGates.pollFirst() ?: error("deleteAccount gate was not prepared") - } - calls.record("deleteAccount#$callNumber") - return try { - gate.await() - } finally { - synchronized(this) { completedDeletes += 1 } - } - } - - override suspend fun getReceivers(): BaseResponse> = completionUnexpected("getReceivers") - - override suspend fun createReceiver(request: UserCreateReceiverRequestDto): BaseResponse = - completionUnexpected("createReceiver") - - override suspend fun getReceiverDetail(receiverId: Long): BaseResponse = completionUnexpected("getReceiverDetail") - - override suspend fun updateReceiver( - receiverId: Long, - request: UserPatchReceiverRequestDto, - ): BaseResponse = completionUnexpected("updateReceiver") - - override suspend fun updateReceiverMessage( - receiverId: Long, - request: UserUpdateReceiverMessageRequestDto, - ): BaseResponse = completionUnexpected("updateReceiverMessage") - - override suspend fun updateMyProfile(request: UserUpdateProfileRequestDto): BaseResponse = - completionUnexpected("updateMyProfile") - - override suspend fun getMyPushSettings(): BaseResponse = completionUnexpected("getMyPushSettings") - - override suspend fun updateMyPushSettings(request: UserUpdatePushSettingRequestDto): BaseResponse = - completionUnexpected("updateMyPushSettings") - - override suspend fun getConnectedAccounts(): BaseResponse = completionUnexpected("getConnectedAccounts") - - override suspend fun linkConnectedAccount( - provider: String, - request: SocialAccountLinkRequestDto, - ): BaseResponse = completionUnexpected("linkConnectedAccount") - - override suspend fun unlinkConnectedAccount(provider: String): BaseResponse = - completionUnexpected("unlinkConnectedAccount") - - override suspend fun getReceiverDeliveryConditions(receiverId: Long): BaseResponse = - completionUnexpected("getReceiverDeliveryConditions") - - override suspend fun updateReceiverDeliveryConditions( - receiverId: Long, - request: ReceiverDeliveryConditionUpdateRequestDto, - ): BaseResponse = completionUnexpected("updateReceiverDeliveryConditions") +internal object NoOpErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit } - -private fun completionStatefulWithdrawalAuthRepository(calls: CompletionCallLedger): FakeAuthRepository = - FakeAuthRepository - .strict( - loggedIn = true, - accessToken = "access", - refreshToken = "refresh", - ).apply { - onIsLoggedIn = { loggedInState } - onGetAccessToken = null - onGetRefreshToken = null - onClearSession = { - calls.record("clearSession") - accessToken = null - refreshToken = null - loggedIn = false - Result.success(Unit) - } - } - -private fun completionUnexpected(method: String): T = error("$method must not be called by this test") diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt index 3ca55e2464..0f68bccef2 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt @@ -1,18 +1,22 @@ package com.afternote.feature.setting.presentation import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.test.core.app.ApplicationProvider import com.afternote.core.domain.testing.FakeAuthRepository -import com.afternote.core.domain.testing.FakeUserRepository -import com.afternote.core.model.user.Receiver +import com.afternote.core.domain.testing.FakeMyProfileRepository import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.setting.presentation.screen.SettingScreen -import com.afternote.feature.setting.presentation.viewmodel.PushNotificationViewModel -import com.afternote.feature.setting.presentation.viewmodel.SettingViewModel +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.home.SettingScreen +import com.afternote.feature.setting.presentation.home.SettingViewModel +import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -31,8 +35,7 @@ class SettingFlowTest { @Test fun profileAndSecurityEntries_emitExpectedNavigation() { val auth = settingFlowAuthRepository(loggedIn = true) - val user = settingFlowUserRepository() - val viewModel = SettingViewModel(auth, user) + val viewModel = settingViewModel(auth) var destination: String? = null setSettingContent( @@ -52,8 +55,7 @@ class SettingFlowTest { @Test fun logout_cancelThenConfirm_callsRepositoryExactlyOnce() { val auth = settingFlowAuthRepository(loggedIn = true) - val user = settingFlowUserRepository() - val viewModel = SettingViewModel(auth, user) + val viewModel = settingViewModel(auth) var navigationCalls = 0 setSettingContent( @@ -76,29 +78,30 @@ class SettingFlowTest { @Test fun destructiveDelete_isNotCalledUntilViewModelCommand() { - val user = settingFlowUserRepository() - val viewModel = SettingViewModel(settingFlowAuthRepository(loggedIn = true), user) + val account = settingFlowAccountRepository() + val viewModel = settingViewModel(settingFlowAuthRepository(loggedIn = true), account = account) composeRule.setContent { AfternoteTheme {} } - assertEquals(0, user.deleteAccountCalls) + assertEquals(0, account.deleteAccountCalls) composeRule.runOnIdle { viewModel.deleteAccount() } - composeRule.waitUntil(timeoutMillis = 5_000) { user.deleteAccountCalls == 1 } + composeRule.waitUntil(timeoutMillis = 5_000) { account.deleteAccountCalls == 1 } - assertEquals(1, user.deleteAccountCalls) + assertEquals(1, account.deleteAccountCalls) } @Test fun pushToggle_failure_rollsBackAndSendsExactPatchOnce() { - val user = settingFlowUserRepository() + val notification = settingFlowNotificationRepository() val pushSettingUpdateResults = ArrayDeque>() pushSettingUpdateResults.addLast(Result.failure(IllegalStateException("offline"))) - user.onUpdateMyPushSettings = { _, _, _ -> + notification.onUpdateMyPushSettings = { _, _, _ -> requireNotNull(pushSettingUpdateResults.removeFirstOrNull()) { "push setting 응답이 준비되지 않음" }.getOrThrow() } val viewModel = PushNotificationViewModel( context = ApplicationProvider.getApplicationContext(), - userRepository = user, + notificationRepository = notification, + errorReporter = NoOpErrorReporter, ) composeRule.setContent { AfternoteTheme {} } composeRule.waitUntil(timeoutMillis = 5_000) { !viewModel.uiState.value.isLoading } @@ -108,14 +111,103 @@ class SettingFlowTest { viewModel.uiState.value.isNewsletterOn } - assertEquals(listOf(Triple(false, null, null)), user.pushSettingUpdates) + assertEquals(listOf(Triple(false, null, null)), notification.pushSettingUpdates) } + /** + * 비밀번호 변경은 목적지가 생겼다 (#564) — 이용 불가 안내가 아니라 실제 배선을 탄다. + * + * 아래 [unavailableMenus_showFeedbackAndKeepSettingsUsable] 의 목록에서 이 항목을 뺀 것과 한 쌍이다. + */ + @Test + fun passwordChangeMenu_opensDestinationInsteadOfUnavailableFeedback() { + val viewModel = settingViewModel(settingFlowAuthRepository(loggedIn = true)) + var passwordChangeOpened = false + setSettingContent(viewModel = viewModel, onPasswordChange = { passwordChangeOpened = true }) + val resources = ApplicationProvider.getApplicationContext().resources + + composeRule + .onNode( + hasText(resources.getString(R.string.setting_account_password_change)) and hasClickAction(), + ).performScrollTo() + .performClick() + + assertEquals(true, passwordChangeOpened) + composeRule.onNodeWithText("현재 이 메뉴는 이용할 수 없습니다.").assertDoesNotExist() + } + + @Test + fun profileShortcuts_showSupportFeedbackAndKeepExistingNavigation() { + val viewModel = settingViewModel(settingFlowAuthRepository(loggedIn = true)) + val destinations = mutableListOf() + setSettingContent( + viewModel = viewModel, + onNotice = { destinations += "notice" }, + onRecipientList = { destinations += "recipient-list" }, + ) + val resources = ApplicationProvider.getApplicationContext().resources + + composeRule + .onNode( + hasContentDescription(resources.getString(R.string.setting_support_inquiry)) and hasClickAction(), + ).performClick() + composeRule.onNodeWithText("현재 이 메뉴는 이용할 수 없습니다.").assertIsDisplayed() + assertEquals(emptyList(), destinations) + composeRule.onNodeWithText("확인").performClick() + composeRule.onNodeWithText("현재 이 메뉴는 이용할 수 없습니다.").assertDoesNotExist() + + composeRule + .onNode( + hasContentDescription(resources.getString(R.string.setting_support_notice)) and hasClickAction(), + ).performClick() + composeRule + .onNode( + hasContentDescription(resources.getString(R.string.setting_recipient_list)) and hasClickAction(), + ).performClick() + assertEquals(listOf("notice", "recipient-list"), destinations) + } + + @Test + fun unavailableMenus_showFeedbackAndKeepSettingsUsable() { + val viewModel = settingViewModel(settingFlowAuthRepository(loggedIn = true)) + var profileOpened = false + setSettingContent(viewModel = viewModel, onProfileEdit = { profileOpened = true }) + val resources = ApplicationProvider.getApplicationContext().resources + val menuIds = + listOf( + R.string.setting_support_faq, + R.string.setting_support_inquiry, + R.string.setting_support_terms, + R.string.setting_support_privacy, + R.string.setting_support_service_info, + ) + menuIds.forEach { menuId -> + val label = resources.getString(menuId) + composeRule + .onNode( + hasText(label) and hasClickAction() and !hasContentDescription(label), + ).performScrollTo() + .performClick() + composeRule.onNodeWithText("현재 이 메뉴는 이용할 수 없습니다.").assertIsDisplayed() + composeRule.onNodeWithText("확인").performClick() + } + composeRule.onNodeWithText("프로필 수정").performScrollTo().performClick() + assertEquals(true, profileOpened) + } + + private fun settingViewModel( + auth: FakeAuthRepository, + account: FakeSettingAccountRepository = settingFlowAccountRepository(), + ): SettingViewModel = SettingViewModel(auth, settingFlowProfileRepository(), account) + private fun setSettingContent( viewModel: SettingViewModel, onLogoutSuccess: () -> Unit = {}, onProfileEdit: () -> Unit = {}, + onPasswordChange: () -> Unit = {}, onAppLock: () -> Unit = {}, + onNotice: () -> Unit = {}, + onRecipientList: () -> Unit = {}, ) { composeRule.setContent { AfternoteTheme { @@ -123,20 +215,15 @@ class SettingFlowTest { onBackClick = {}, onLogoutSuccess = onLogoutSuccess, onProfileEditClick = onProfileEdit, - onPasswordChangeClick = {}, + onPasswordChangeClick = onPasswordChange, onLinkedAccountClick = {}, onNotificationClick = {}, - onRecipientListClick = {}, + onRecipientListClick = onRecipientList, onRecipientRegisterClick = {}, - onAfterDeliveryClick = {}, + onDeliveryConditionsClick = {}, onPasskeyClick = {}, onAppLockClick = onAppLock, - onFaqClick = {}, - onInquiryClick = {}, - onNoticeClick = {}, - onTermsClick = {}, - onPrivacyClick = {}, - onServiceInfoClick = {}, + onNoticeClick = onNotice, onWithdrawGuideClick = {}, viewModel = viewModel, ) @@ -157,16 +244,22 @@ private fun settingFlowAuthRepository(loggedIn: Boolean): FakeAuthRepository = onLogout = null } -private fun settingFlowUserRepository(): FakeUserRepository = - FakeUserRepository.strict().apply { - receiverState.value = listOf(Receiver(7L, "김수신", "가족", "fake-auth-7")) - onReceiverListFlow = null - onGetReceivers = null - onCreateReceiver = null +private fun settingFlowProfileRepository(): FakeMyProfileRepository = + FakeMyProfileRepository.strict().apply { onGetMyProfile = null onUpdateMyProfile = null + } + +private fun settingFlowAccountRepository(): FakeSettingAccountRepository = + FakeSettingAccountRepository.strict().apply { onDeleteAccount = null + onGetConnectedAccounts = null + } + +private fun settingFlowNotificationRepository(): FakeSettingNotificationRepository = + FakeSettingNotificationRepository.strict().apply { onGetMyPushSettings = null onUpdateMyPushSettings = null - onGetConnectedAccounts = null + onGetMyMarketingConsents = null + onUpdateMyMarketingConsents = null } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/social/KakaoAuthResultTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/social/KakaoAuthResultTest.kt similarity index 93% rename from feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/social/KakaoAuthResultTest.kt rename to feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/social/KakaoAuthResultTest.kt index 0fb804e309..6bd0ec2b1f 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/social/KakaoAuthResultTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/social/KakaoAuthResultTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.social +package com.afternote.feature.setting.presentation.account.social import com.afternote.core.domain.error.CoreAuthFailure import org.junit.Assert.assertEquals diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/delivery/component/RadioGroupTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/delivery/component/RadioGroupTest.kt new file mode 100644 index 0000000000..b2d9162ba6 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/delivery/component/RadioGroupTest.kt @@ -0,0 +1,167 @@ +package com.afternote.feature.setting.presentation.delivery.component + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RadioGroupTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `기존 index 계약으로 한 항목만 선택하고 선택 index를 전달한다`() { + val selectedIndices = mutableListOf() + composeRule.setContent { + var selectedIndex by remember { mutableIntStateOf(0) } + + AfternoteTheme { + RadioGroup( + items = ITEMS, + selectedIndex = selectedIndex, + onSelectIndex = { + selectedIndices += it + selectedIndex = it + }, + ) + } + } + + composeRule.onAllNodes(radioRoleMatcher).apply { + assertCountEquals(2) + get(0).assertIsSelected() + get(1).assertIsNotSelected() + get(1).performClick() + get(0).assertIsNotSelected() + get(1).assertIsSelected() + get(0).performClick() + get(0).assertIsSelected() + get(1).assertIsNotSelected() + } + composeRule.runOnIdle { + assertEquals(listOf(1, 0), selectedIndices) + } + + composeRule.onAllNodes(groupMatcher).assertCountEquals(1) + composeRule.onAllNodes(radioRoleMatcher, useUnmergedTree = true).apply { + assertCountEquals(2) + repeat(2) { index -> + val bounds = get(index).getUnclippedBoundsInRoot() + assertTrue(bounds.right - bounds.left >= 48.dp) + assertTrue(bounds.bottom - bounds.top >= 48.dp) + } + } + } + + @Test + fun `표시 내용이 같아도 index로 각 항목을 구분한다`() { + val selectedIndices = mutableListOf() + composeRule.setContent { + var selectedIndex by remember { mutableIntStateOf(0) } + AfternoteTheme { + RadioGroup( + items = listOf(ITEMS.first(), ITEMS.first()), + selectedIndex = selectedIndex, + onSelectIndex = { + selectedIndices += it + selectedIndex = it + }, + ) + } + } + + composeRule.onAllNodes(radioRoleMatcher).apply { + assertCountEquals(2) + get(0).assertIsSelected() + get(1).assertIsNotSelected() + get(1).performClick() + get(0).assertIsNotSelected() + get(1).assertIsSelected() + } + composeRule.runOnIdle { + assertEquals(listOf(1), selectedIndices) + } + } + + @Test + fun `범위 밖 index는 미선택으로 표시하고 선택한 index를 전달한다`() { + val selectedIndex = mutableIntStateOf(-1) + val selectedIndices = mutableListOf() + composeRule.setContent { + AfternoteTheme { + RadioGroup( + items = ITEMS, + selectedIndex = selectedIndex.intValue, + onSelectIndex = { + selectedIndices += it + selectedIndex.intValue = it + }, + ) + } + } + + composeRule.onAllNodes(selectedMatcher).assertCountEquals(0) + composeRule.runOnIdle { selectedIndex.intValue = ITEMS.size } + composeRule.onAllNodes(selectedMatcher).assertCountEquals(0) + composeRule.onAllNodes(radioRoleMatcher)[1].performClick() + composeRule.onAllNodes(radioRoleMatcher)[1].assertIsSelected() + composeRule.runOnIdle { + assertEquals(listOf(1), selectedIndices) + } + } + + @Test + fun `항목이 비면 선택 행을 제거하고 선택 callback을 호출하지 않는다`() { + val items = mutableStateOf(ITEMS) + val selectedIndices = mutableListOf() + composeRule.setContent { + AfternoteTheme { + RadioGroup( + items = items.value, + selectedIndex = 0, + onSelectIndex = { selectedIndices += it }, + ) + } + } + + composeRule.onAllNodes(radioRoleMatcher).assertCountEquals(2) + composeRule.runOnIdle { items.value = emptyList() } + composeRule.onAllNodes(radioRoleMatcher).assertCountEquals(0) + composeRule.onAllNodes(groupMatcher).assertCountEquals(1) + composeRule.runOnIdle { + assertTrue(selectedIndices.isEmpty()) + } + } + + private companion object { + val ITEMS = + listOf( + RadioGroupItem(title = "첫 번째", description = "첫 번째 설명"), + RadioGroupItem(title = "두 번째", description = "두 번째 설명"), + ) + val radioRoleMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.RadioButton) + val selectedMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Selected, true) + val groupMatcher = SemanticsMatcher.keyIsDefined(SemanticsProperties.SelectableGroup) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/SettingViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModelTest.kt similarity index 85% rename from feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/SettingViewModelTest.kt rename to feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModelTest.kt index e1c0ab660e..f1582f606a 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/SettingViewModelTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModelTest.kt @@ -1,8 +1,9 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.home import com.afternote.core.domain.testing.FakeAuthRepository -import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.domain.testing.FakeMyProfileRepository import com.afternote.core.model.user.User +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -70,9 +71,12 @@ class SettingViewModelTest { private fun viewModel(onDeleteAccount: () -> Unit): SettingViewModel = SettingViewModel( authRepository = FakeAuthRepository.strict(), - userRepository = - FakeUserRepository.strict().apply { + myProfileRepository = + FakeMyProfileRepository.strict().apply { onGetMyProfile = { User("name", "user@example.com", null, null) } + }, + accountRepository = + FakeSettingAccountRepository.strict().apply { this.onDeleteAccount = { onDeleteAccount() } }, ) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/navigation/SettingLocalNavActionsTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/navigation/SettingLocalNavActionsTest.kt new file mode 100644 index 0000000000..a8ac0bef14 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/navigation/SettingLocalNavActionsTest.kt @@ -0,0 +1,180 @@ +package com.afternote.feature.setting.presentation.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 설정 로컬 백스택 경계 회귀 기준 (#1695). + * + * Nav2 시절 `AuthBoundaryBackStackTest` 와 `SettingImplementedCoverageAndroidTest` 가 루트 + * 백스택으로 보던 설정 안쪽 모양을 여기서 본다 — 어느 화면에서든 back 은 한 칸이고, 바닥에서는 + * 스택을 비우지 않고 셸로 나가며, 인증 상태를 바꾸는 둘은 스택을 건드리지 않는다. + * + * 키 직렬화 왕복은 `SavedState` 가 Bundle 이라 Robolectric 이 필요해 [SettingRouteSerializationTest] 에 따로 둔다. + */ +class SettingLocalNavActionsTest { + private var exits = 0 + private val external = RecordingExternalActions() + + private fun actionsOn(vararg keys: SettingRoute): Pair, SettingLocalNavActions> { + val backStack = NavBackStack(*keys) + val actions = + SettingLocalNavActions( + backStack = backStack, + navigationCallbacks = FeatureNavigationCallbacks { exits += 1 }, + externalActions = external, + ) + return backStack to actions + } + + private fun NavBackStack.names(): List = map { it::class.simpleName!! } + + @Test + fun `설정 홈에서 시작하고 바닥의 back 은 스택을 비우지 않고 셸로 나간다`() { + val (backStack, actions) = actionsOn(SettingRoute.SettingHomeRoute) + + actions.popBack() + + assertEquals(listOf("SettingHomeRoute"), backStack.names()) + assertEquals(1, exits) + } + + @Test + fun `홈 칩으로 들어온 등록은 바닥이라 성공의 pop 이 곧 host 이탈이다`() { + val (backStack, actions) = actionsOn(SettingRoute.RecipientRegisterRoute) + + // ReceiverRegisterScreen 의 onRegisterSuccess 는 popBack 이다. + actions.popBack() + + assertEquals(listOf("RecipientRegisterRoute"), backStack.names()) + assertEquals("부른 곳(홈)으로 돌아간다", 1, exits) + } + + @Test + fun `설정 홈과 목록에서 들어온 등록은 그 위에 쌓여 성공 뒤 거기로 돌아간다`() { + val (backStack, actions) = actionsOn(SettingRoute.SettingHomeRoute) + + actions.onRecipientRegisterClick() + actions.popBack() + assertEquals(listOf("SettingHomeRoute"), backStack.names()) + + actions.onRecipientListClick() + actions.onRecipientRegisterClick() + actions.popBack() + assertEquals(listOf("SettingHomeRoute", "RecipientListRoute"), backStack.names()) + assertEquals(SettingRoute.RecipientListRoute(selectForDeliveryConditions = false), backStack.last()) + assertEquals(0, exits) + } + + @Test + fun `사후 전달 조건은 선택 목록 → 조건 → 마지막 인사말 수정으로 receiverId 를 나르고 back 은 한 칸씩이다`() { + val (backStack, actions) = actionsOn(SettingRoute.SettingHomeRoute) + + actions.onDeliveryConditionsClick() + actions.onDeliveryConditionsRecipientSelected(37L) + actions.onRecipientEditClick(37L) + + assertEquals( + listOf( + SettingRoute.SettingHomeRoute, + SettingRoute.RecipientListRoute(selectForDeliveryConditions = true), + SettingRoute.AfterDeliveryRoute(37L), + SettingRoute.RecipientEditRoute(37L), + ), + backStack.toList(), + ) + + // 수정 저장 성공(onEditSuccess)·조건 저장 성공(onSaveSuccess)은 둘 다 한 칸 pop 이다. + actions.popBack() + assertEquals(SettingRoute.AfterDeliveryRoute(37L), backStack.last()) + actions.popBack() + assertEquals(SettingRoute.RecipientListRoute(selectForDeliveryConditions = true), backStack.last()) + actions.popBack() + assertEquals(listOf("SettingHomeRoute"), backStack.names()) + assertEquals(0, exits) + } + + @Test + fun `패스키·앱 잠금·알림은 한 칸씩 쌓이고 PIN 완료는 한 칸 pop 이다`() { + val (backStack, actions) = actionsOn(SettingRoute.SettingHomeRoute) + + actions.onPasskeyClick() + actions.onPasskeyRegisterClick() + actions.onPasswordAuthClick() + assertEquals(listOf("SettingHomeRoute", "PasskeyRoute", "PasskeyMakingRoute", "PasskeyPasswordRoute"), backStack.names()) + // PassKeyPasswordScreen 의 onPinComplete 는 popBack 이다. + actions.popBack() + assertEquals("PasskeyMakingRoute", backStack.names().last()) + repeat(2) { actions.popBack() } + + actions.onAppLockClick() + actions.popBack() + assertEquals(listOf("SettingHomeRoute"), backStack.names()) + + actions.onNotificationClick() + actions.onPushNotificationClick() + actions.popBack() + assertEquals(listOf("SettingHomeRoute", "NotificationRoute"), backStack.names()) + assertEquals(0, exits) + } + + @Test + fun `프로필 수정과 설정 홈 양쪽에서 탈퇴 안내로 간다`() { + val (backStack, actions) = actionsOn(SettingRoute.SettingHomeRoute) + + actions.onProfileEditClick() + actions.onWithdrawGuideClick() + assertEquals(listOf("SettingHomeRoute", "ProfileEditRoute", "WithdrawGuideRoute"), backStack.names()) + repeat(2) { actions.popBack() } + + actions.onWithdrawGuideClick() + actions.onWithdrawConfirmClick() + assertEquals(listOf("SettingHomeRoute", "WithdrawGuideRoute", "WithdrawConfirmRoute"), backStack.names()) + } + + @Test + fun `로그아웃과 탈퇴 완료는 스택을 건드리지 않고 셸에만 알린다`() { + val (backStack, actions) = actionsOn(SettingRoute.SettingHomeRoute) + actions.onWithdrawGuideClick() + actions.onWithdrawConfirmClick() + val before = backStack.toList() + + actions.onWithdrawSuccess() + actions.onLogoutSuccess() + + assertEquals(1, external.withdrawals) + assertEquals(1, external.logouts) + assertEquals(before, backStack.toList()) + assertEquals(0, exits) + } + + @Test + fun `클릭 하나마다 목적지 하나가 쌓인다`() { + val (backStack, actions) = actionsOn(SettingRoute.SettingHomeRoute) + + actions.onPasswordChangeClick() + actions.onLinkedAccountClick() + actions.onNoticeClick() + + assertEquals( + listOf("SettingHomeRoute", "PasswordChangeRoute", "LinkedAccountRoute", "NoticeRoute"), + backStack.names(), + ) + } + + private class RecordingExternalActions : SettingExternalActions { + var logouts = 0 + var withdrawals = 0 + + override fun onLogoutSuccess() { + logouts += 1 + } + + override fun onWithdrawSuccess() { + withdrawals += 1 + } + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRouteSerializationTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRouteSerializationTest.kt new file mode 100644 index 0000000000..da79fd041a --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRouteSerializationTest.kt @@ -0,0 +1,52 @@ +package com.afternote.feature.setting.presentation.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.serialization.NavBackStackSerializer +import androidx.navigation3.runtime.serialization.NavKeySerializer +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 프로세스 재생성은 `rememberNavBackStack` 이 키를 직렬화해 되살린다 — 설정 키 전부와 인자가 왕복해야 한다 (#1695). + * + * `SavedState` 가 Bundle 이라 Robolectric 이 필요하다. back 체인은 순수 JVM 인 [SettingLocalNavActionsTest] 가 본다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SettingRouteSerializationTest { + @Test + fun `모든 키와 route 인자가 직렬화를 왕복한다`() { + val routes = + listOf( + SettingRoute.SettingHomeRoute, + SettingRoute.WithdrawGuideRoute, + SettingRoute.WithdrawConfirmRoute, + SettingRoute.ProfileEditRoute, + SettingRoute.PasswordChangeRoute, + SettingRoute.LinkedAccountRoute, + SettingRoute.NotificationRoute, + SettingRoute.PushNotificationRoute, + SettingRoute.RecipientListRoute(selectForDeliveryConditions = true), + SettingRoute.RecipientRegisterRoute, + SettingRoute.RecipientEditRoute(37L), + SettingRoute.AfterDeliveryRoute(91L), + SettingRoute.PasskeyRoute, + SettingRoute.PasskeyMakingRoute, + SettingRoute.PasskeyPasswordRoute, + SettingRoute.AppLockSetupRoute, + SettingRoute.NoticeRoute, + ) + val backStack = NavBackStack(*routes.toTypedArray()) + val serializer = NavBackStackSerializer(NavKeySerializer()) + + val restored = decodeFromSavedState(serializer, encodeToSavedState(serializer, backStack)) + + assertEquals(routes, restored.toList()) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt new file mode 100644 index 0000000000..6d88a442f7 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt @@ -0,0 +1,154 @@ +package com.afternote.feature.setting.presentation.notification + +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class MarketingConsentFailureTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `각 동의 철회 실패는 값을 복원하고 안내와 진단을 남긴다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() + val repository = + repository().apply { + onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } + } + val viewModel = viewModel(repository, reporter) + val events = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + advanceUntilIdle() + runCurrent() + + viewModel.onSmsChecked(false) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isSmsChecked) + viewModel.onEmailChecked(false) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isEmailChecked) + viewModel.onPushChecked(false) + advanceUntilIdle() + runCurrent() + assertTrue(viewModel.uiState.value.isPushChecked) + + assertEquals(List(3) { PushNotificationEvent.MarketingConsentSaveFailed }, events) + assertEquals(listOf("sms_consent_update", "email_consent_update", "push_consent_update"), reporter.stages) + assertEquals( + listOf(Triple(false, null, null), Triple(null, false, null), Triple(null, null, false)), + repository.marketingConsentUpdates, + ) + } + + @Test + fun `각 동의 저장 취소는 롤백이나 실패 안내로 처리하지 않는다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() + val repository = + repository().apply { + onUpdateMyMarketingConsents = { _, _, _ -> throw CancellationException("screen left") } + } + val viewModel = viewModel(repository, reporter) + val events = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + advanceUntilIdle() + runCurrent() + + viewModel.onSmsChecked(false) + viewModel.onEmailChecked(false) + viewModel.onPushChecked(false) + advanceUntilIdle() + runCurrent() + + assertEquals( + listOf(false, false, false), + viewModel.uiState.value.let { listOf(it.isSmsChecked, it.isEmailChecked, it.isPushChecked) }, + ) + assertTrue(events.isEmpty()) + assertTrue(reporter.stages.isEmpty()) + } + + @Test + fun `구독이 끝난 동안의 실패 안내는 다음 구독에 재생하지 않는다`() = + runTest(dispatcher) { + val viewModel = + viewModel( + repository().apply { + onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } + }, + RecordingReporter(), + ) + val events = mutableListOf() + val firstCollector = backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + advanceUntilIdle() + runCurrent() + firstCollector.cancel() + runCurrent() + + viewModel.onSmsChecked(false) + advanceUntilIdle() + backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + runCurrent() + assertTrue(events.isEmpty()) + + viewModel.onEmailChecked(false) + advanceUntilIdle() + runCurrent() + assertEquals(listOf(PushNotificationEvent.MarketingConsentSaveFailed), events) + } + + private fun repository() = + FakeSettingNotificationRepository.strict().apply { + onGetMyPushSettings = { UserPushSetting(false, false, false) } + onGetMyMarketingConsents = { UserMarketingConsent(true, true, true) } + } + + private fun viewModel( + repository: FakeSettingNotificationRepository, + reporter: ErrorReporter, + ) = PushNotificationViewModel(ApplicationProvider.getApplicationContext(), repository, reporter) + + private class RecordingReporter : ErrorReporter { + val stages = mutableListOf() + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + stages += attributes["stage"] + } + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationPermissionActionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationPermissionActionTest.kt new file mode 100644 index 0000000000..55bcab2a25 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationPermissionActionTest.kt @@ -0,0 +1,27 @@ +package com.afternote.feature.setting.presentation.notification + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NotificationPermissionActionTest { + @Test + fun `Android 13 이상에서 권한이 없으면 런타임 권한을 요청한다`() { + val action = notificationPermissionAction(sdkInt = 33, permissionGranted = false) + + assertEquals(NotificationPermissionAction.RequestPermission, action) + } + + @Test + fun `Android 12 이하에서는 시스템 알림 설정을 연다`() { + val action = notificationPermissionAction(sdkInt = 32, permissionGranted = false) + + assertEquals(NotificationPermissionAction.OpenSettings, action) + } + + @Test + fun `이미 권한이 있으면 시스템 알림 설정을 연다`() { + val action = notificationPermissionAction(sdkInt = 33, permissionGranted = true) + + assertEquals(NotificationPermissionAction.OpenSettings, action) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt new file mode 100644 index 0000000000..8a3276dbaf --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt @@ -0,0 +1,204 @@ +package com.afternote.feature.setting.presentation.notification + +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.domain.error.PushSettingFailure +import com.afternote.core.model.user.UserPushSetting +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.NoOpErrorReporter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class PushNotificationViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `뉴스레터 저장 성공 시 낙관적 변경을 유지하고 정확한 값을 전송한다`() = + runTest(dispatcher) { + val calls = mutableListOf() + val viewModel = viewModel(calls = calls) + runCurrent() + + viewModel.onNewsletterToggle(false) + + assertFalse(viewModel.uiState.value.isNewsletterOn) + assertTrue(viewModel.uiState.value.isNewsletterUpdating) + runCurrent() + assertFalse(viewModel.uiState.value.isNewsletterOn) + assertFalse(viewModel.uiState.value.isNewsletterUpdating) + assertEquals( + listOf(PushUpdateCall(timeLetter = false, mindRecord = null, afterNote = null)), + calls, + ) + } + + @Test + fun `각 토글 저장 실패 시 이전 값으로 롤백하고 실패 안내를 표시한다`() = + runTest(dispatcher) { + val calls = mutableListOf() + val viewModel = viewModel(calls = calls, failUpdateAttempts = Int.MAX_VALUE) + runCurrent() + + viewModel.onNewsletterToggle(false) + assertFalse(viewModel.uiState.value.isNewsletterOn) + runCurrent() + assertTrue(viewModel.uiState.value.isNewsletterOn) + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + viewModel.onSaveFailureDismiss() + assertNull(viewModel.uiState.value.saveFailure) + + viewModel.onMindRecordToggle(false) + assertFalse(viewModel.uiState.value.isMindRecordOn) + runCurrent() + assertTrue(viewModel.uiState.value.isMindRecordOn) + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + viewModel.onSaveFailureDismiss() + + viewModel.onAfternoteToggle(false) + assertFalse(viewModel.uiState.value.isAfternoteOn) + runCurrent() + assertTrue(viewModel.uiState.value.isAfternoteOn) + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + + assertEquals( + listOf( + PushUpdateCall(timeLetter = false, mindRecord = null, afterNote = null), + PushUpdateCall(timeLetter = null, mindRecord = false, afterNote = null), + PushUpdateCall(timeLetter = null, mindRecord = null, afterNote = false), + ), + calls, + ) + } + + @Test + fun `저장 실패 안내에서 재시도하면 마지막 변경을 다시 저장한다`() = + runTest(dispatcher) { + val calls = mutableListOf() + val viewModel = viewModel(calls = calls, failUpdateAttempts = 1) + runCurrent() + + viewModel.onMindRecordToggle(false) + runCurrent() + + assertTrue(viewModel.uiState.value.isMindRecordOn) + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + + viewModel.onSaveFailureRetry() + + assertNull(viewModel.uiState.value.saveFailure) + assertFalse(viewModel.uiState.value.isMindRecordOn) + runCurrent() + assertFalse(viewModel.uiState.value.isMindRecordOn) + assertNull(viewModel.uiState.value.saveFailure) + assertEquals( + listOf( + PushUpdateCall(timeLetter = null, mindRecord = false, afterNote = null), + PushUpdateCall(timeLetter = null, mindRecord = false, afterNote = null), + ), + calls, + ) + } + + @Test + fun `네트워크 저장 실패는 네트워크 오류 안내로 구분한다`() = + runTest(dispatcher) { + val calls = mutableListOf() + val viewModel = + viewModel( + calls = calls, + failUpdateAttempts = 1, + updateFailure = PushSettingFailure.NetworkUnavailable(IOException("offline")), + ) + runCurrent() + + viewModel.onAfternoteToggle(false) + runCurrent() + + assertEquals(PushNotificationSaveFailure.NETWORK, viewModel.uiState.value.saveFailure) + assertTrue(viewModel.uiState.value.isAfternoteOn) + assertFalse(viewModel.uiState.value.isAfternoteUpdating) + } + + @Test + fun `저장 중 같은 토글을 다시 변경해도 중복 요청하지 않는다`() = + runTest(dispatcher) { + val calls = mutableListOf() + val viewModel = viewModel(calls = calls) + runCurrent() + + viewModel.onNewsletterToggle(false) + viewModel.onNewsletterToggle(true) + + assertFalse(viewModel.uiState.value.isNewsletterOn) + assertTrue(viewModel.uiState.value.isNewsletterUpdating) + runCurrent() + assertEquals( + listOf(PushUpdateCall(timeLetter = false, mindRecord = null, afterNote = null)), + calls, + ) + assertFalse(viewModel.uiState.value.isNewsletterUpdating) + } + + private fun viewModel( + calls: MutableList, + failUpdateAttempts: Int = 0, + updateFailure: Throwable = IllegalStateException("server"), + ): PushNotificationViewModel { + val initial = UserPushSetting(timeLetter = true, mindRecord = true, afterNote = true) + var remainingFailures = failUpdateAttempts + val repository = + FakeSettingNotificationRepository(pushSetting = initial).apply { + onUpdateMyPushSettings = { timeLetter, mindRecord, afterNote -> + calls += PushUpdateCall(timeLetter, mindRecord, afterNote) + if (remainingFailures > 0) { + remainingFailures-- + throw updateFailure + } + initial.copy( + timeLetter = timeLetter ?: initial.timeLetter, + mindRecord = mindRecord ?: initial.mindRecord, + afterNote = afterNote ?: initial.afterNote, + ) + } + } + return PushNotificationViewModel( + context = ApplicationProvider.getApplicationContext(), + notificationRepository = repository, + errorReporter = NoOpErrorReporter, + ) + } +} + +private data class PushUpdateCall( + val timeLetter: Boolean?, + val mindRecord: Boolean?, + val afterNote: Boolean?, +) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContentTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContentTest.kt new file mode 100644 index 0000000000..36ff2ce959 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContentTest.kt @@ -0,0 +1,142 @@ +package com.afternote.feature.setting.presentation.password + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.ui.UiText +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.R +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * 비밀번호 변경 화면의 배선 회귀 가드 (#564). + * + * 화면 쪽에서만 깨질 수 있는 것을 잡는다 — 입력이 Intent 로 나가는지, 제출 버튼이 규칙 미충족에서 + * 실제로 눌리지 않는지(색만 바뀌는 비활성은 「눌러도 아무 일 없는 버튼」이다), 완료 신호가 소비 + * Intent 와 짝을 이뤄 한 번만 나가는지. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class PasswordChangeContentTest { + @get:Rule + val composeRule = createComposeRule() + + private val resources get() = ApplicationProvider.getApplicationContext().resources + + /** + * 두 칸이 각자의 Intent 로 나가는지 본다 — 같은 Intent 에 둘을 배선하면 새 비밀번호가 현재 + * 비밀번호 칸을 덮어써도 컴파일은 통과한다. + * + * 최초 빈 문자열 방출은 `snapshotFlow` 의 첫 값이라 정상이다. 마지막 값으로 단언한다. + */ + @Test + fun typedPasswords_areForwardedAsIntents() { + val intents = mutableListOf() + setContent(PasswordChangeUiState(), intents) + + typeInto(R.string.setting_password_change_current_placeholder, "OldPass1!") + typeInto(R.string.setting_password_change_new_placeholder, "NewPass1!") + + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + intents.filterIsInstance().lastOrNull()?.value == "NewPass1!" + } + assertEquals( + "OldPass1!", + intents.filterIsInstance().last().value, + ) + } + + @Test + fun submitButton_isDisabledUntilStateAllowsIt() { + val intents = mutableListOf() + setContent(PasswordChangeUiState(currentPassword = "OldPass1!", newPassword = "weakpassword"), intents) + + val submit = resources.getString(R.string.setting_password_change_submit) + composeRule.onNode(hasText(submit) and hasClickAction()).assertIsNotEnabled().performClick() + + assertEquals(emptyList(), intents.filterIsInstance()) + } + + @Test + fun submitButton_sendsSubmitIntentWhenStateAllowsIt() { + val intents = mutableListOf() + setContent(PasswordChangeUiState(currentPassword = "OldPass1!", newPassword = "NewPass1!"), intents) + + val submit = resources.getString(R.string.setting_password_change_submit) + composeRule.onNode(hasText(submit) and hasClickAction()).assertIsEnabled().performClick() + + assertEquals(listOf(PasswordChangeIntent.Submit), intents.filterIsInstance()) + } + + @Test + fun errorMessage_isRenderedFromResourceNotServerText() { + setContent( + PasswordChangeUiState(errorMessage = UiText.Resource(R.string.setting_password_change_unchanged)), + mutableListOf(), + ) + + composeRule + .onNodeWithText(resources.getString(R.string.setting_password_change_unchanged)) + .assertIsDisplayed() + } + + @Test + fun changedSignal_notifiesOnceAndIsConsumed() { + val intents = mutableListOf() + var changedCount = 0 + setContent(PasswordChangeUiState(changed = PasswordChanged), intents, onChanged = { changedCount++ }) + + composeRule.waitForIdle() + + assertEquals(1, changedCount) + assertEquals( + listOf(PasswordChangeIntent.ConsumeChanged), + intents.filterIsInstance(), + ) + } + + private fun typeInto( + placeholderRes: Int, + text: String, + ) { + composeRule + .onNode(hasText(resources.getString(placeholderRes)) and hasSetTextAction()) + .performTextInput(text) + } + + private fun setContent( + uiState: PasswordChangeUiState, + intents: MutableList, + onChanged: () -> Unit = {}, + ) { + composeRule.setContent { + AfternoteTheme { + PasswordChangeContent( + uiState = uiState, + onIntent = { intents += it }, + onBackClick = {}, + onChanged = onChanged, + ) + } + } + } + + private companion object { + const val TIMEOUT_MILLIS = 5_000L + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt new file mode 100644 index 0000000000..569e0b0345 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt @@ -0,0 +1,252 @@ +package com.afternote.feature.setting.presentation.password + +import com.afternote.core.domain.error.CoreAuthFailure +import com.afternote.core.domain.repository.account.AccountRepository +import com.afternote.core.model.AccountRegistration +import com.afternote.core.model.FoundAccount +import com.afternote.core.ui.UiText +import com.afternote.feature.setting.presentation.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException + +/** + * 설정 > 비밀번호 변경의 제출 계약 회귀 가드 (#564). + * + * 지키는 것 셋 — + * 1. 규칙을 만족하지 않는 입력은 **서버를 치기 전에** 막는다(서버 `@Pattern` 400 을 왕복해서 알지 않는다). + * 2. 두 필드를 서버 계약 그대로 싣는다. + * 3. 사유가 확인된 실패는 타입별 전용 문구로 갈리고, 확인되지 않은 실패만 폴백으로 내려앉는다. + * + * 저장소 가짜는 Java `Proxy` 가 아니라 실제 구현체다 — `Result` 를 돌려주는 suspend 함수는 + * Proxy 에서 이름이 맹글링돼 분기가 조용히 빗나간다(PR #1828). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PasswordChangeViewModelTest { + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `새 비밀번호가 규칙을 어기면 제출이 저장소를 치지 않는다`() { + val repository = FakeAccountRepository() + val viewModel = PasswordChangeViewModel(repository) + + viewModel.onIntent(PasswordChangeIntent.UpdateCurrentPassword(CURRENT_PASSWORD)) + viewModel.onIntent(PasswordChangeIntent.UpdateNewPassword("weakpassword")) + viewModel.onIntent(PasswordChangeIntent.Submit) + + assertFalse(viewModel.uiState.value.isNewPasswordRuleSatisfied) + assertFalse(viewModel.uiState.value.isSubmitEnabled) + assertEquals(emptyList>(), repository.passwordChangeCalls) + } + + @Test + fun `현재 비밀번호가 비어 있으면 제출이 저장소를 치지 않는다`() { + val repository = FakeAccountRepository() + val viewModel = PasswordChangeViewModel(repository) + + viewModel.onIntent(PasswordChangeIntent.UpdateNewPassword(NEW_PASSWORD)) + viewModel.onIntent(PasswordChangeIntent.Submit) + + assertEquals(emptyList>(), repository.passwordChangeCalls) + } + + @Test + fun `현재 비밀번호는 규칙 검사를 받지 않는다`() { + val repository = FakeAccountRepository() + val viewModel = PasswordChangeViewModel(repository) + + // 옛 규칙으로 만든 비밀번호 — 지금 규칙(특수문자 8종)에는 걸린다. + viewModel.onIntent(PasswordChangeIntent.UpdateCurrentPassword("oldpassword")) + viewModel.onIntent(PasswordChangeIntent.UpdateNewPassword(NEW_PASSWORD)) + viewModel.onIntent(PasswordChangeIntent.Submit) + + assertEquals(listOf("oldpassword" to NEW_PASSWORD), repository.passwordChangeCalls) + } + + @Test + fun `성공하면 두 필드를 그대로 싣고 완료 신호를 세운다`() { + val repository = FakeAccountRepository() + val viewModel = PasswordChangeViewModel(repository) + + submit(viewModel) + + assertEquals(listOf(CURRENT_PASSWORD to NEW_PASSWORD), repository.passwordChangeCalls) + assertEquals(PasswordChanged, viewModel.uiState.value.changed) + assertFalse(viewModel.uiState.value.isSubmitting) + assertNull(viewModel.uiState.value.errorMessage) + } + + @Test + fun `완료 신호는 소비하면 지워진다`() { + val repository = FakeAccountRepository() + val viewModel = PasswordChangeViewModel(repository) + + submit(viewModel) + viewModel.onIntent(PasswordChangeIntent.ConsumeChanged) + + assertNull(viewModel.uiState.value.changed) + } + + @Test + fun `기존과 같은 비밀번호(1206)는 전용 문구로 갈린다`() { + val repository = FakeAccountRepository(result = Result.failure(CoreAuthFailure.PasswordUnchanged(Exception("1206")))) + val viewModel = PasswordChangeViewModel(repository) + + submit(viewModel) + + assertEquals( + UiText.Resource(R.string.setting_password_change_unchanged), + viewModel.uiState.value.errorMessage, + ) + assertFalse(viewModel.uiState.value.isSubmitting) + assertNull(viewModel.uiState.value.changed) + } + + @Test + fun `소셜 가입 계정(1702)은 전용 문구로 갈린다`() { + val repository = FakeAccountRepository(result = Result.failure(CoreAuthFailure.SocialSignUpAccount(Exception("1702")))) + val viewModel = PasswordChangeViewModel(repository) + + submit(viewModel) + + assertEquals( + UiText.Resource(R.string.setting_password_change_social_blocked), + viewModel.uiState.value.errorMessage, + ) + } + + @Test + fun `전송 실패는 네트워크 문구로 갈린다`() { + val repository = FakeAccountRepository(result = Result.failure(CoreAuthFailure.NetworkUnavailable(IOException("offline")))) + val viewModel = PasswordChangeViewModel(repository) + + submit(viewModel) + + assertEquals( + UiText.Resource(R.string.setting_password_change_network_error), + viewModel.uiState.value.errorMessage, + ) + } + + @Test + fun `사유가 확인되지 않은 실패는 폴백 문구로 내려앉는다`() { + val repository = FakeAccountRepository(result = Result.failure(IllegalStateException("1202"))) + val viewModel = PasswordChangeViewModel(repository) + + submit(viewModel) + + assertEquals( + UiText.Resource(R.string.setting_password_change_failed), + viewModel.uiState.value.errorMessage, + ) + } + + @Test + fun `입력을 고치면 직전 실패 안내가 지워진다`() { + val repository = FakeAccountRepository(result = Result.failure(CoreAuthFailure.PasswordUnchanged(Exception("1206")))) + val viewModel = PasswordChangeViewModel(repository) + + submit(viewModel) + viewModel.onIntent(PasswordChangeIntent.UpdateNewPassword("NewPass2!")) + + assertNull(viewModel.uiState.value.errorMessage) + } + + /** + * 유니코드 숫자는 **허용 목록 밖 문자라서** 거절된다 — 서버와 같은 판정이다. + * + * `[0-9]` 를 `\d` 로 되돌리는 변이는 이 단언으로 잡히지 않는다(실측). 허용 목록 + * `[A-Za-z0-9@$!%*#?&]` 이 이미 그 문자들을 닫고 있어 두 정규식의 판정이 갈리는 입력 자체가 + * 없고, JVM 유닛 테스트는 호스트 정규식 엔진이라 Android 의 유니코드 문자 클래스도 재현하지 + * 못한다. `\d` 를 쓰지 않는 이유는 허용 목록이 넓어지는 날 갈리기 때문이고, 그 근거는 + * [SettingPasswordRule] KDoc 이 갖는다. + */ + @Test + fun `규칙은 서버와 같다 - 유니코드 숫자를 숫자로 받지 않는다`() { + assertFalse(SettingPasswordRule.isSatisfied("Abcdefg1!")) + assertFalse(SettingPasswordRule.isSatisfied("Abcdefg١!")) + assertTrue(SettingPasswordRule.isSatisfied("Abcdefg1!")) + } + + @Test + fun `규칙은 서버와 같다 - 허용 목록 밖 문자와 길이를 막는다`() { + assertFalse(SettingPasswordRule.isSatisfied("Abcde1!")) + assertFalse(SettingPasswordRule.isSatisfied("Abcdefghijklmn1!")) + assertFalse(SettingPasswordRule.isSatisfied("Abcdefg1_")) + assertFalse(SettingPasswordRule.isSatisfied("Abcdefg1 !")) + assertFalse(SettingPasswordRule.isSatisfied("Abcdefgh!")) + assertFalse(SettingPasswordRule.isSatisfied("Abcdefg12")) + assertTrue(SettingPasswordRule.isSatisfied("Abcdefghijklm1!")) + } + + private fun submit(viewModel: PasswordChangeViewModel) { + viewModel.onIntent(PasswordChangeIntent.UpdateCurrentPassword(CURRENT_PASSWORD)) + viewModel.onIntent(PasswordChangeIntent.UpdateNewPassword(NEW_PASSWORD)) + viewModel.onIntent(PasswordChangeIntent.Submit) + } + + private companion object { + const val CURRENT_PASSWORD = "OldPass1!" + const val NEW_PASSWORD = "NewPass1!" + } +} + +private class FakeAccountRepository( + private val result: Result = Result.success(Unit), +) : AccountRepository { + val passwordChangeCalls = mutableListOf>() + + override suspend fun passwordChange( + currentPassword: String, + newPassword: String, + ): Result { + passwordChangeCalls += currentPassword to newPassword + return result + } + + override suspend fun sendEmailCode(email: String): Result = error("sendEmailCode 는 이 시나리오에서 호출되면 안 됨") + + override suspend fun verifyEmail( + email: String, + certificateCode: String, + ): Result = error("verifyEmail 은 이 시나리오에서 호출되면 안 됨") + + override suspend fun sendFindCode(email: String): Result = error("sendFindCode 는 이 시나리오에서 호출되면 안 됨") + + override suspend fun findAccount( + email: String, + certificateCode: String, + ): Result = error("findAccount 는 이 시나리오에서 호출되면 안 됨") + + override suspend fun signUp( + email: String, + password: String, + name: String, + profileUrl: String?, + ): Result = error("signUp 은 이 시나리오에서 호출되면 안 됨") + + override suspend fun resetPassword( + email: String, + certificateCode: String, + newPassword: String, + confirmPassword: String, + ): Result = error("resetPassword 는 이 시나리오에서 호출되면 안 됨") +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListObservationTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListObservationTest.kt new file mode 100644 index 0000000000..dbb6fe435a --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListObservationTest.kt @@ -0,0 +1,139 @@ +package com.afternote.feature.setting.presentation.receiver + +import androidx.lifecycle.ViewModelStore +import com.afternote.core.domain.testing.FakeUserReceiverRepository +import com.afternote.core.model.user.Receiver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ReceiverListObservationTest { + private val dispatcher = StandardTestDispatcher() + private val store = ViewModelStore() + private val source = MutableStateFlow(listOf(Receiver(37L, "수신자", "가족"))) + private var starts = 0 + private var stops = 0 + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + store.clear() + Dispatchers.resetMain() + } + + @Test + fun `활성화 전에는 구독하지 않고 중복 활성화도 구독 하나만 만든다`() = + runTest(dispatcher) { + val viewModel = viewModel() + runCurrent() + assertEquals(0, starts) + + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + runCurrent() + + assertEquals(1, starts) + assertEquals( + listOf(37L), + viewModel.uiState.value.receivers + .map { it.receiverId }, + ) + } + + @Test + fun `5초 안에 돌아오면 기존 구독과 목록을 유지한다`() = + runTest(dispatcher) { + val viewModel = viewModel() + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + runCurrent() + viewModel.onIntent(ReceiverListIntent.ObservationStopped) + runCurrent() + advanceTimeBy(4_999) + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + advanceTimeBy(5_001) + runCurrent() + + assertEquals(1, starts) + assertEquals(0, stops) + assertEquals( + listOf(37L), + viewModel.uiState.value.receivers + .map { it.receiverId }, + ) + } + + @Test + fun `5초 뒤 구독은 끝나지만 목록은 남고 재진입에서 최신 목록을 받는다`() = + runTest(dispatcher) { + val viewModel = viewModel() + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + runCurrent() + viewModel.onIntent(ReceiverListIntent.ObservationStopped) + runCurrent() + advanceTimeBy(5_000) + runCurrent() + source.value = listOf(Receiver(91L, "다른 수신자", "친구")) + runCurrent() + + assertEquals(1, stops) + assertEquals( + listOf(37L), + viewModel.uiState.value.receivers + .map { it.receiverId }, + ) + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + runCurrent() + assertEquals(2, starts) + assertEquals( + listOf(91L), + viewModel.uiState.value.receivers + .map { it.receiverId }, + ) + } + + @Test + fun `저장소가 세션 경계에서 빈 목록을 내면 이전 목록을 지운다`() = + runTest(dispatcher) { + val viewModel = viewModel() + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + runCurrent() + source.value = emptyList() + runCurrent() + + assertEquals(emptyList(), viewModel.uiState.value.receivers) + } + + private fun viewModel(): ReceiverListViewModel { + val repository = + FakeUserReceiverRepository( + onReceiverListFlow = { + flow { + starts++ + try { + emitAll(source) + } finally { + stops++ + } + } + }, + ) + return ReceiverListViewModel(repository).also { store.put("receiver-list", it) } + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListViewModelTest.kt new file mode 100644 index 0000000000..89587eb0fa --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListViewModelTest.kt @@ -0,0 +1,214 @@ +package com.afternote.feature.setting.presentation.receiver + +import androidx.lifecycle.ViewModelStore +import com.afternote.core.domain.model.ReceiverListState +import com.afternote.core.domain.testing.FakeUserReceiverRepository +import com.afternote.core.model.user.Receiver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** 공개 Intent와 UiState를 통해 조회 결과, 재구독, 재시도와 세션 격리를 검증한다. */ +@OptIn(ExperimentalCoroutinesApi::class) +class ReceiverListViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + private val store = ViewModelStore() + private val states = MutableSharedFlow() + private val repository = FakeUserReceiverRepository(onReceiverListStateFlow = { states }) + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + store.clear() + Dispatchers.resetMain() + } + + @Test + fun `첫 결과 전에는 행 없는 로딩이고 성공한 0건만 Ready 다`() = + runTest(dispatcher) { + val vm = viewModel() + assertState(vm, ReceiverListLoadState.Loading) + states.emit(ReceiverListState.Loading(null, SESSION_A)) + assertState(vm, ReceiverListLoadState.Loading) + states.emit(ReceiverListState.Success(emptyList())) + assertState(vm, ReceiverListLoadState.Ready) + } + + @Test + fun `첫 실패 뒤 연속 Retry는 요청 하나만 보내고 즉시 로딩이 된다`() = + runTest(dispatcher) { + val vm = viewModel() + states.emit(ReceiverListState.Loading(null, SESSION_A)) + states.emit(ReceiverListState.Failure(null, discardPrevious = false)) + assertState(vm, ReceiverListLoadState.Failure) + vm.onIntent(ReceiverListIntent.Retry) + vm.onIntent(ReceiverListIntent.Retry) + assertEquals(1, repository.refreshReceiverListCalls) + assertState(vm, ReceiverListLoadState.Loading) + } + + @Test + fun `갱신 실패는 마지막 행을 보존하고 Retry 뒤에도 행을 유지한다`() = + runTest(dispatcher) { + val vm = viewModel() + succeed(KIM, PARK) + states.emit(ReceiverListState.Loading(listOf(KIM, PARK), SESSION_A)) + assertState(vm, ReceiverListLoadState.Loading, KIM, PARK) + states.emit(ReceiverListState.Failure(listOf(KIM, PARK), discardPrevious = false)) + assertState(vm, ReceiverListLoadState.RefreshFailure, KIM, PARK) + vm.onIntent(ReceiverListIntent.Retry) + assertEquals(1, repository.refreshReceiverListCalls) + assertState(vm, ReceiverListLoadState.Loading, KIM, PARK) + } + + @Test + fun `직전 성공이 0건이면 갱신 실패는 전면 실패다`() = + runTest(dispatcher) { + val vm = viewModel() + succeed() + states.emit(ReceiverListState.Failure(emptyList(), discardPrevious = false)) + assertState(vm, ReceiverListLoadState.Failure) + } + + @Test + fun `로딩과 성공에서는 Retry가 요청을 보내지 않는다`() = + runTest(dispatcher) { + val vm = viewModel() + vm.onIntent(ReceiverListIntent.Retry) + succeed(KIM) + vm.onIntent(ReceiverListIntent.Retry) + states.emit(ReceiverListState.Loading(listOf(KIM), SESSION_A)) + vm.onIntent(ReceiverListIntent.Retry) + assertEquals(0, repository.refreshReceiverListCalls) + } + + @Test + fun `5초 넘긴 재구독도 같은 세션이면 첫 로딩과 실패에서 행을 유지한다`() = + runTest(dispatcher) { + val vm = viewModel() + succeed(KIM, PARK) + vm.onIntent(ReceiverListIntent.ObservationStopped) + advanceTimeBy(5_001) + runCurrent() + assertEquals(0, states.subscriptionCount.value) + vm.onIntent(ReceiverListIntent.ObservationStarted) + states.emit(ReceiverListState.Loading(null, SESSION_A)) + assertState(vm, ReceiverListLoadState.Loading, KIM, PARK) + states.emit(ReceiverListState.Failure(null, discardPrevious = false)) + assertState(vm, ReceiverListLoadState.RefreshFailure, KIM, PARK) + states.emit(ReceiverListState.Success(listOf(KIM))) + assertState(vm, ReceiverListLoadState.Ready, KIM) + } + + @Test + fun `5초 넘게 중지한 사이 세션이 바뀌면 새 로딩부터 이전 계정 행을 버린다`() = + runTest(dispatcher) { + val vm = viewModel() + succeed(KIM) + vm.onIntent(ReceiverListIntent.ObservationStopped) + advanceTimeBy(5_001) + runCurrent() + vm.onIntent(ReceiverListIntent.ObservationStarted) + states.emit(ReceiverListState.Loading(null, SESSION_B)) + assertState(vm, ReceiverListLoadState.Loading) + states.emit(ReceiverListState.Failure(null, discardPrevious = false)) + assertState(vm, ReceiverListLoadState.Failure) + } + + @Test + fun `SignedOut 뒤 로딩과 실패는 이전 계정 행을 복원하지 않는다`() = + runTest(dispatcher) { + val vm = viewModel() + succeed(KIM) + states.emit(ReceiverListState.SignedOut) + assertState(vm, ReceiverListLoadState.Loading) + states.emit(ReceiverListState.Loading(null, SESSION_B)) + states.emit(ReceiverListState.Failure(null, discardPrevious = false)) + assertState(vm, ReceiverListLoadState.Failure) + } + + @Test + fun `동일 구독의 401은 이전 행을 버리고 이후 실패에서도 복원하지 않는다`() = + runTest(dispatcher) { + val vm = viewModel() + succeed(KIM) + states.emit(ReceiverListState.Loading(listOf(KIM), SESSION_A)) + states.emit(ReceiverListState.Failure(null, discardPrevious = true)) + assertState(vm, ReceiverListLoadState.Failure) + states.emit(ReceiverListState.Loading(null, SESSION_A)) + states.emit(ReceiverListState.Failure(null, discardPrevious = false)) + assertState(vm, ReceiverListLoadState.Failure) + } + + @Test + fun `재구독 첫 요청의 401도 보이던 행을 버린다`() = + runTest(dispatcher) { + val vm = viewModel() + succeed(KIM) + vm.onIntent(ReceiverListIntent.ObservationStopped) + advanceTimeBy(5_001) + runCurrent() + vm.onIntent(ReceiverListIntent.ObservationStarted) + states.emit(ReceiverListState.Loading(null, SESSION_A)) + states.emit(ReceiverListState.Failure(null, discardPrevious = true)) + assertState(vm, ReceiverListLoadState.Failure) + } + + @Test + fun `관측이 종료된 실패 화면의 Retry는 요청을 보내지 않는다`() = + runTest(dispatcher) { + val vm = viewModel() + states.emit(ReceiverListState.Loading(null, SESSION_A)) + states.emit(ReceiverListState.Failure(null, discardPrevious = false)) + vm.onIntent(ReceiverListIntent.ObservationStopped) + advanceTimeBy(5_001) + runCurrent() + vm.onIntent(ReceiverListIntent.Retry) + assertEquals(0, repository.refreshReceiverListCalls) + } + + private fun viewModel(): ReceiverListViewModel = + ReceiverListViewModel(repository).also { + store.put("receiver-list", it) + it.onIntent(ReceiverListIntent.ObservationStarted) + } + + private suspend fun succeed(vararg receivers: Receiver) { + states.emit(ReceiverListState.Loading(null, SESSION_A)) + states.emit(ReceiverListState.Success(receivers.toList())) + } + + private fun assertState( + vm: ReceiverListViewModel, + loadState: ReceiverListLoadState, + vararg receivers: Receiver, + ) { + assertEquals(loadState, vm.uiState.value.loadState) + assertEquals( + receivers.map { it.receiverId }, + vm.uiState.value.receivers + .map { it.receiverId }, + ) + } + + private companion object { + const val SESSION_A = "session-a" + const val SESSION_B = "session-b" + val KIM = Receiver(receiverId = 7L, name = "김수신", relation = "가족") + val PARK = Receiver(receiverId = 11L, name = "박친구", relation = "친구") + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidatorTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverPhoneValidatorTest.kt similarity index 97% rename from feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidatorTest.kt rename to feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverPhoneValidatorTest.kt index d645e1d5a5..7f8cc7c3fd 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverPhoneValidatorTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverPhoneValidatorTest.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.receiver import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterViewModelTest.kt similarity index 73% rename from feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModelTest.kt rename to feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterViewModelTest.kt index 2a4c2ec68b..9c1037bc2f 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModelTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterViewModelTest.kt @@ -1,7 +1,7 @@ -package com.afternote.feature.setting.presentation.viewmodel +package com.afternote.feature.setting.presentation.receiver import com.afternote.core.domain.error.ReceiverRequestRejectedException -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.model.user.ReceiverCreated import com.afternote.core.ui.UiText import com.afternote.feature.setting.presentation.R @@ -39,7 +39,7 @@ class ReceiverRegisterViewModelTest { viewModel.register("홍길동", "딸", "01012345678", "invalid", null) - assertEquals(UiText.Resource(R.string.receiver_email_invalid), viewModel.uiState.value.errorMessage) + assertEquals(UiText.Resource(R.string.setting_receiver_email_invalid), viewModel.uiState.value.errorMessage) assertEquals(0, createCalls.get()) assertFalse(viewModel.uiState.value.isLoading) } @@ -50,7 +50,7 @@ class ReceiverRegisterViewModelTest { viewModel.register("홍길동", "딸", null, "", null) - assertEquals(UiText.Resource(R.string.receiver_email_required), viewModel.uiState.value.errorMessage) + assertEquals(UiText.Resource(R.string.setting_receiver_email_required), viewModel.uiState.value.errorMessage) assertEquals(0, createCalls.get()) assertFalse(viewModel.uiState.value.isLoading) } @@ -61,7 +61,7 @@ class ReceiverRegisterViewModelTest { viewModel.register("홍길동", "딸", null, "receiver@example.com", null) - assertEquals(UiText.Resource(R.string.receiver_phone_required), viewModel.uiState.value.errorMessage) + assertEquals(UiText.Resource(R.string.setting_receiver_phone_required), viewModel.uiState.value.errorMessage) assertEquals(0, createCalls.get()) assertFalse(viewModel.uiState.value.isLoading) } @@ -72,7 +72,7 @@ class ReceiverRegisterViewModelTest { viewModel.register("홍길동", "딸", "123", "receiver@example.com", null) - assertEquals(UiText.Resource(R.string.receiver_phone_invalid), viewModel.uiState.value.errorMessage) + assertEquals(UiText.Resource(R.string.setting_receiver_phone_invalid), viewModel.uiState.value.errorMessage) assertEquals(0, createCalls.get()) assertFalse(viewModel.uiState.value.isLoading) } @@ -80,11 +80,11 @@ class ReceiverRegisterViewModelTest { @Test fun `server rejected message is not shown verbatim to the user`() { val serverMessage = "수신자 이메일은 필수입니다." - val error = ReceiverRequestRejectedException(serverMessage, Exception("origin")) + val error = ReceiverRequestRejectedException(Exception(serverMessage)) assertEquals( - UiText.Resource(R.string.receiver_request_rejected), - error.toReceiverFailureMessage(R.string.receiver_register_failed), + UiText.Resource(R.string.setting_receiver_request_rejected), + error.toReceiverFailureMessage(R.string.setting_receiver_register_failed), ) } @@ -94,14 +94,14 @@ class ReceiverRegisterViewModelTest { viewModel.register("홍길동", "딸", "01012345678", "receiver@example.com", null) - assertEquals(UiText.Resource(R.string.receiver_register_failed), viewModel.uiState.value.errorMessage) + assertEquals(UiText.Resource(R.string.setting_receiver_register_failed), viewModel.uiState.value.errorMessage) assertFalse(viewModel.uiState.value.isLoading) } - private fun repository(failure: Throwable? = null): UserRepository = + private fun repository(failure: Throwable? = null): UserReceiverRepository = Proxy.newProxyInstance( - UserRepository::class.java.classLoader, - arrayOf(UserRepository::class.java), + UserReceiverRepository::class.java.classLoader, + arrayOf(UserReceiverRepository::class.java), ) { _, method, _ -> when (method.name) { "createReceiver" -> { @@ -114,5 +114,5 @@ class ReceiverRegisterViewModelTest { error("Unexpected repository call: ${method.name}") } } - } as UserRepository + } as UserReceiverRepository } diff --git a/feature/timeletter/data/build.gradle.kts b/feature/timeletter/data/build.gradle.kts index 7bb4ae5cf3..35d4a36116 100644 --- a/feature/timeletter/data/build.gradle.kts +++ b/feature/timeletter/data/build.gradle.kts @@ -5,9 +5,21 @@ plugins { android { namespace = "com.afternote.feature.timeletter.data" + + // VoiceRecorderRepositoryImpl 은 프로덕션에서 이 모듈 안에서만 쓰여 internal 로 좁혔다. + // app androidTest 가 실제 MediaRecorder 경계를 검증하려면 internal 을 넓히는 대신 + // 같은 모듈의 testFixtures 에서 공개 계약(VoiceRecorderRepository)만 돌려주는 팩토리를 + // 노출한다 (docs/convention/production-visibility.md, #440 리뷰). + testFixtures { + enable = true + } } dependencies { implementation(projects.feature.timeletter.domain) + implementation(projects.core.common) implementation(projects.core.network) + + testFixturesImplementation(projects.feature.timeletter.domain) + testFixturesImplementation(libs.coroutines.core) } diff --git a/feature/timeletter/data/src/debug/kotlin/com/afternote/feature/timeletter/data/network/TimeLetterMockFixtures.kt b/feature/timeletter/data/src/debug/kotlin/com/afternote/feature/timeletter/data/network/TimeLetterMockFixtures.kt index cb55152d90..a91290572e 100644 --- a/feature/timeletter/data/src/debug/kotlin/com/afternote/feature/timeletter/data/network/TimeLetterMockFixtures.kt +++ b/feature/timeletter/data/src/debug/kotlin/com/afternote/feature/timeletter/data/network/TimeLetterMockFixtures.kt @@ -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 }} @@ -16,7 +16,7 @@ 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 }} @@ -24,12 +24,12 @@ internal object TimeLetterMockFixtures { 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 = @@ -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() } diff --git a/feature/timeletter/data/src/main/AndroidManifest.xml b/feature/timeletter/data/src/main/AndroidManifest.xml index a5918e68ab..29736c8c80 100644 --- a/feature/timeletter/data/src/main/AndroidManifest.xml +++ b/feature/timeletter/data/src/main/AndroidManifest.xml @@ -1,4 +1,16 @@ - \ No newline at end of file + + + + + + +
diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/TimeLetterFileProvider.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/TimeLetterFileProvider.kt new file mode 100644 index 0000000000..75b530ae44 --- /dev/null +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/TimeLetterFileProvider.kt @@ -0,0 +1,5 @@ +package com.afternote.feature.timeletter.data + +import androidx.core.content.FileProvider + +class TimeLetterFileProvider : FileProvider() diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/di/TimeLetterModule.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/di/TimeLetterModule.kt index 805217fd9e..8dd3ffca85 100644 --- a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/di/TimeLetterModule.kt +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/di/TimeLetterModule.kt @@ -1,19 +1,23 @@ package com.afternote.feature.timeletter.data.di import android.content.Context +import com.afternote.core.common.di.IoDispatcher import com.afternote.feature.timeletter.data.api.ReceiverTimeLetterApiService import com.afternote.feature.timeletter.data.api.TimeLetterApiService import com.afternote.feature.timeletter.data.repositoryImpl.FileMetadataRepositoryImpl import com.afternote.feature.timeletter.data.repositoryImpl.ReceiverTimeLetterRepositoryImpl import com.afternote.feature.timeletter.data.repositoryImpl.TimeLetterRepositoryImpl +import com.afternote.feature.timeletter.data.repositoryImpl.VoiceRecorderRepositoryImpl import com.afternote.feature.timeletter.domain.repository.FileMetadataRepository import com.afternote.feature.timeletter.domain.repository.ReceiverTimeLetterRepository import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository +import com.afternote.feature.timeletter.domain.repository.VoiceRecorderRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher import retrofit2.Retrofit import retrofit2.create import javax.inject.Singleton @@ -36,6 +40,13 @@ object TimeLetterModule { @ApplicationContext context: Context, ): FileMetadataRepository = FileMetadataRepositoryImpl(context) + @Provides + @Singleton + fun provideVoiceRecorderRepository( + @ApplicationContext context: Context, + @IoDispatcher ioDispatcher: CoroutineDispatcher, + ): VoiceRecorderRepository = VoiceRecorderRepositoryImpl(context, ioDispatcher) + @Provides @Singleton fun provideReceiverTimeLetterApiService(retrofit: Retrofit): ReceiverTimeLetterApiService = diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/ReceivedTimeLetterDto.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/ReceivedTimeLetterDto.kt index a89774872c..f206ad1e6f 100644 --- a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/ReceivedTimeLetterDto.kt +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/ReceivedTimeLetterDto.kt @@ -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 = emptyList(), - @SerialName("sendAt") val sendAt: String? = null, + @SerialName("title") val title: String?, + @SerialName("blocks") val blocks: List, + @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 diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/TimeLetterDto.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/TimeLetterDto.kt index 7cb0c18803..7264cad0e2 100644 --- a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/TimeLetterDto.kt +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/TimeLetterDto.kt @@ -85,12 +85,13 @@ data class TimeLetterDeleteRequestDto( @SerialName("timeLetterIds") val timeLetterIds: List, ) +// 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, @SerialName("receiverIds") val receiverIds: List, diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/ReceivedTimeLetterMapper.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/ReceivedTimeLetterMapper.kt index 7c5a756beb..2a50034692 100644 --- a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/ReceivedTimeLetterMapper.kt +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/ReceivedTimeLetterMapper.kt @@ -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 = diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/TimeLetterMapper.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/TimeLetterMapper.kt index 24b6a37ce3..dba509e940 100644 --- a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/TimeLetterMapper.kt +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/TimeLetterMapper.kt @@ -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, diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/TimeLetterRepositoryImpl.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/TimeLetterRepositoryImpl.kt index 5726627acb..cdae857bb8 100644 --- a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/TimeLetterRepositoryImpl.kt +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/TimeLetterRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.afternote.feature.timeletter.data.repositoryImpl +import com.afternote.core.network.model.ApiException import com.afternote.core.network.model.requireData import com.afternote.core.network.model.requireStatus import com.afternote.feature.timeletter.data.api.TimeLetterApiService @@ -8,6 +9,7 @@ import com.afternote.feature.timeletter.data.dto.TimeLetterDeleteRequestDto import com.afternote.feature.timeletter.data.dto.TimeLetterUpdateRequestDto import com.afternote.feature.timeletter.data.mapper.toDomain import com.afternote.feature.timeletter.data.mapper.toDto +import com.afternote.feature.timeletter.domain.error.TimeLetterServerRejectionException import com.afternote.feature.timeletter.domain.model.NewTimeLetterBlock import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterDeliveryMode @@ -34,10 +36,12 @@ class TimeLetterRepositoryImpl .toDomain() override suspend fun getTimeLetter(timeLetterId: Long): TimeLetter = - timeLetterApiService - .getTimeLetter(timeLetterId) - .requireData() - .toDomain() + mapTimeLetterRequest { + timeLetterApiService + .getTimeLetter(timeLetterId) + .requireData() + .toDomain() + } override suspend fun createTimeLetter( title: String?, @@ -47,18 +51,20 @@ class TimeLetterRepositoryImpl status: TimeLetterStatus, receiverIds: List, ): TimeLetter = - timeLetterApiService - .createTimeLetter( - TimeLetterCreateRequestDto( - title = title, - sendAt = sendAt, - deliveryMode = deliveryMode.toDto(), - status = status.toDto(), - blocks = blocks.map { it.toDto() }, - receiverIds = receiverIds, - ), - ).requireData() - .toDomain() + mapTimeLetterRequest { + timeLetterApiService + .createTimeLetter( + TimeLetterCreateRequestDto( + title = title, + sendAt = sendAt, + deliveryMode = deliveryMode.toDto(), + status = status.toDto(), + blocks = blocks.map { it.toDto() }, + receiverIds = receiverIds, + ), + ).requireData() + .toDomain() + } override suspend fun updateTimeLetter( timeLetterId: Long, @@ -68,19 +74,21 @@ class TimeLetterRepositoryImpl deliveryMode: TimeLetterDeliveryMode?, status: TimeLetterStatus?, ): TimeLetter = - timeLetterApiService - .updateTimeLetter( - timeLetterId = timeLetterId, - request = - TimeLetterUpdateRequestDto( - title = title, - sendAt = sendAt, - deliveryMode = deliveryMode?.toDto(), - status = status?.toDto(), - blocks = blocks.map { it.toDto() }, - ), - ).requireData() - .toDomain() + mapTimeLetterRequest { + timeLetterApiService + .updateTimeLetter( + timeLetterId = timeLetterId, + request = + TimeLetterUpdateRequestDto( + title = title, + sendAt = sendAt, + deliveryMode = deliveryMode?.toDto(), + status = status?.toDto(), + blocks = blocks.map { it.toDto() }, + ), + ).requireData() + .toDomain() + } override suspend fun deleteTimeLetters(timeLetterIds: List) { timeLetterApiService @@ -93,4 +101,15 @@ class TimeLetterRepositoryImpl .deleteAllTemporary() .requireStatus() } + + private suspend inline fun mapTimeLetterRequest(crossinline request: suspend () -> T): T = + try { + request() + } catch (error: ApiException) { + val serverMessage = error.serverMessage?.takeIf { it.isNotBlank() } + if (error.status in 400..499 && serverMessage != null) { + throw TimeLetterServerRejectionException(error.status, serverMessage, error) + } + throw error + } } diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/VoiceRecorderRepositoryImpl.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/VoiceRecorderRepositoryImpl.kt new file mode 100644 index 0000000000..5ff1730946 --- /dev/null +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/VoiceRecorderRepositoryImpl.kt @@ -0,0 +1,174 @@ +package com.afternote.feature.timeletter.data.repositoryImpl + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import android.os.SystemClock +import androidx.core.content.FileProvider +import com.afternote.core.common.di.IoDispatcher +import com.afternote.feature.timeletter.domain.model.RecordedAudio +import com.afternote.feature.timeletter.domain.repository.VoiceRecorderRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID +import javax.inject.Inject +import kotlin.coroutines.EmptyCoroutineContext + +internal class VoiceRecorderRepositoryImpl + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + ) : VoiceRecorderRepository { + private val recorderLock = Any() + private var recorder: MediaRecorder? = null + private var outputFile: File? = null + private val retainedFiles = linkedSetOf() + private var startedAtMillis: Long = 0L + + init { + // 이전 프로세스가 등록/삭제 없이 죽으면 filesDir 에 원본이 남는다. 새 인스턴스가 + // 생성될 때(=새 프로세스) 그 시점까지 아무도 추적하지 않는 파일은 전부 고아다 (#440 리뷰). + ioDispatcher.dispatch(EmptyCoroutineContext) { + synchronized(recorderLock) { sweepOrphanedFilesLocked() } + } + } + + override suspend fun start(): Result = + withContext(ioDispatcher) { + runCatching { + synchronized(recorderLock) { + discardInternal() + val directory = File(context.filesDir, AUDIO_DIRECTORY).apply { mkdirs() } + val file = File(directory, "${UUID.randomUUID()}.$AUDIO_EXTENSION") + val mediaRecorder = createMediaRecorder() + try { + mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC) + mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + mediaRecorder.setAudioEncodingBitRate(AUDIO_BIT_RATE) + mediaRecorder.setAudioSamplingRate(AUDIO_SAMPLE_RATE) + mediaRecorder.setOutputFile(file.absolutePath) + mediaRecorder.prepare() + mediaRecorder.start() + } catch (failure: Throwable) { + mediaRecorder.runCatching { release() } + file.delete() + throw failure + } + recorder = mediaRecorder + outputFile = file + startedAtMillis = SystemClock.elapsedRealtime() + } + } + } + + override suspend fun stop(): Result = + withContext(ioDispatcher) { + runCatching { + synchronized(recorderLock) { + val activeRecorder = checkNotNull(recorder) { "Voice recording has not started" } + val file = checkNotNull(outputFile) { "Voice recording file is missing" } + val durationMillis = SystemClock.elapsedRealtime() - startedAtMillis + try { + activeRecorder.stop() + } finally { + activeRecorder.release() + recorder = null + } + val uri = + FileProvider.getUriForFile( + context, + "${context.packageName}.timeletter.fileprovider", + file, + ) + RecordedAudio( + uriString = uri.toString(), + fileName = file.name, + mimeType = AUDIO_MIME_TYPE, + durationMillis = durationMillis, + ) + } + }.onFailure { + synchronized(recorderLock) { discardInternal() } + } + } + + override suspend fun discard() { + withContext(ioDispatcher) { synchronized(recorderLock) { discardInternal() } } + } + + override fun retainRecordedFile() { + synchronized(recorderLock) { + outputFile?.let(retainedFiles::add) + outputFile = null + } + } + + override suspend fun deleteRecordedFile(uriString: String) { + withContext(ioDispatcher) { + val uri = android.net.Uri.parse(uriString) + if (uri.authority != "${context.packageName}.timeletter.fileprovider") return@withContext + val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: return@withContext + val file = File(File(context.filesDir, AUDIO_DIRECTORY), fileName) + synchronized(recorderLock) { retainedFiles.remove(file) } + file.delete() + } + } + + override fun release() { + val filesToDelete = + synchronized(recorderLock) { + buildList { + releaseRecorder()?.let(::add) + addAll(retainedFiles) + retainedFiles.clear() + } + } + if (filesToDelete.isNotEmpty()) { + ioDispatcher.dispatch(EmptyCoroutineContext) { + filesToDelete.forEach(File::delete) + } + } + } + + private fun discardInternal() { + releaseRecorder()?.delete() + } + + private fun sweepOrphanedFilesLocked() { + val directory = File(context.filesDir, AUDIO_DIRECTORY) + val currentFile = outputFile + directory.listFiles()?.forEach { file -> + if (file != currentFile) file.delete() + } + } + + private fun releaseRecorder(): File? { + recorder?.runCatching { stop() } + recorder?.release() + recorder = null + val file = outputFile + outputFile = null + startedAtMillis = 0L + return file + } + + @Suppress("DEPRECATION") + private fun createMediaRecorder(): MediaRecorder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + + private companion object { + const val AUDIO_DIRECTORY = "timeletter_audio" + const val AUDIO_EXTENSION = "m4a" + const val AUDIO_MIME_TYPE = "audio/mp4" + const val AUDIO_BIT_RATE = 128_000 + const val AUDIO_SAMPLE_RATE = 44_100 + } + } diff --git a/feature/timeletter/data/src/main/res/xml/timeletter_file_paths.xml b/feature/timeletter/data/src/main/res/xml/timeletter_file_paths.xml new file mode 100644 index 0000000000..327672ca4f --- /dev/null +++ b/feature/timeletter/data/src/main/res/xml/timeletter_file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/feature/timeletter/data/src/testFixtures/kotlin/com/afternote/feature/timeletter/data/testing/VoiceRecorderRepositoryTestFactory.kt b/feature/timeletter/data/src/testFixtures/kotlin/com/afternote/feature/timeletter/data/testing/VoiceRecorderRepositoryTestFactory.kt new file mode 100644 index 0000000000..475f2f06f4 --- /dev/null +++ b/feature/timeletter/data/src/testFixtures/kotlin/com/afternote/feature/timeletter/data/testing/VoiceRecorderRepositoryTestFactory.kt @@ -0,0 +1,17 @@ +package com.afternote.feature.timeletter.data.testing + +import android.content.Context +import com.afternote.feature.timeletter.data.repositoryImpl.VoiceRecorderRepositoryImpl +import com.afternote.feature.timeletter.domain.repository.VoiceRecorderRepository +import kotlinx.coroutines.CoroutineDispatcher + +/** + * `VoiceRecorderRepositoryImpl` 은 프로덕션에서 이 모듈의 `TimeLetterModule` 만 사용해 internal + * 이다. 실제 `MediaRecorder → filesDir → FileProvider` 경계를 검증하는 androidTest 는 다른 + * Gradle 모듈(`app`)에 있으므로, internal 을 넓히는 대신 공개 계약만 돌려주는 이 팩토리를 통해 + * 실제 구현을 얻는다. + */ +fun createVoiceRecorderRepositoryForTesting( + context: Context, + ioDispatcher: CoroutineDispatcher, +): VoiceRecorderRepository = VoiceRecorderRepositoryImpl(context, ioDispatcher) diff --git a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/error/TimeLetterServerRejectionException.kt b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/error/TimeLetterServerRejectionException.kt new file mode 100644 index 0000000000..b000ed9a45 --- /dev/null +++ b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/error/TimeLetterServerRejectionException.kt @@ -0,0 +1,13 @@ +package com.afternote.feature.timeletter.domain.error + +/** + * A 4xx the server explicitly rejected (as opposed to a network/technical failure). + * + * [serverMessage] is the server's raw text — for logs and crash reports only, never for direct + * display. The UI shows a fixed local message for this case; see `TimeLetterWriteError.ServerRejection`. + */ +class TimeLetterServerRejectionException( + val status: Int, + val serverMessage: String, + cause: Throwable, +) : Exception(serverMessage, cause) diff --git a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/RecordedAudio.kt b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/RecordedAudio.kt new file mode 100644 index 0000000000..4bf7119556 --- /dev/null +++ b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/RecordedAudio.kt @@ -0,0 +1,8 @@ +package com.afternote.feature.timeletter.domain.model + +data class RecordedAudio( + val uriString: String, + val fileName: String, + val mimeType: String, + val durationMillis: Long, +) diff --git a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/TimeLetterModel.kt b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/TimeLetterModel.kt index 271f1210e3..b3b302e92b 100644 --- a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/TimeLetterModel.kt +++ b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/TimeLetterModel.kt @@ -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, val receiverIds: List, diff --git a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/repository/VoiceRecorderRepository.kt b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/repository/VoiceRecorderRepository.kt new file mode 100644 index 0000000000..0cca30009d --- /dev/null +++ b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/repository/VoiceRecorderRepository.kt @@ -0,0 +1,17 @@ +package com.afternote.feature.timeletter.domain.repository + +import com.afternote.feature.timeletter.domain.model.RecordedAudio + +interface VoiceRecorderRepository { + suspend fun start(): Result + + suspend fun stop(): Result + + suspend fun discard() + + fun retainRecordedFile() + + suspend fun deleteRecordedFile(uriString: String) + + fun release() +} diff --git a/feature/timeletter/domain/src/test/kotlin/com/afternote/feature/timeletter/domain/testing/FakeTimeLetterRepositoryTest.kt b/feature/timeletter/domain/src/test/kotlin/com/afternote/feature/timeletter/domain/testing/FakeTimeLetterRepositoryTest.kt index 935aa8c3f3..8de26bc64f 100644 --- a/feature/timeletter/domain/src/test/kotlin/com/afternote/feature/timeletter/domain/testing/FakeTimeLetterRepositoryTest.kt +++ b/feature/timeletter/domain/src/test/kotlin/com/afternote/feature/timeletter/domain/testing/FakeTimeLetterRepositoryTest.kt @@ -54,7 +54,6 @@ class FakeTimeLetterRepositoryTest { id = 1L, title = "편지", sendAt = null, - deliveredAt = null, status = status, blocks = emptyList(), receiverIds = listOf(7L), diff --git a/feature/timeletter/domain/src/test/kotlin/com/afternote/feature/timeletter/domain/usecase/ResolveTimeLetterBlocksUseCaseTest.kt b/feature/timeletter/domain/src/test/kotlin/com/afternote/feature/timeletter/domain/usecase/ResolveTimeLetterBlocksUseCaseTest.kt index a5213d5442..f64a516da9 100644 --- a/feature/timeletter/domain/src/test/kotlin/com/afternote/feature/timeletter/domain/usecase/ResolveTimeLetterBlocksUseCaseTest.kt +++ b/feature/timeletter/domain/src/test/kotlin/com/afternote/feature/timeletter/domain/usecase/ResolveTimeLetterBlocksUseCaseTest.kt @@ -96,7 +96,7 @@ class ResolveTimeLetterBlocksUseCaseTest { useCase( listOf( BlockInput.Media("content://image", "image/png", TimeLetterBlockType.IMAGE), - BlockInput.Media("content://audio", "audio/mpeg", TimeLetterBlockType.AUDIO), + BlockInput.Media("content://audio", "audio/mp4", TimeLetterBlockType.AUDIO), BlockInput.Media("content://file", "application/pdf", TimeLetterBlockType.FILE), ), ) diff --git a/feature/timeletter/domain/src/testFixtures/kotlin/com/afternote/feature/timeletter/domain/testing/FakeTimeLetterRepository.kt b/feature/timeletter/domain/src/testFixtures/kotlin/com/afternote/feature/timeletter/domain/testing/FakeTimeLetterRepository.kt index 1055a4f0f0..5ef38106b4 100644 --- a/feature/timeletter/domain/src/testFixtures/kotlin/com/afternote/feature/timeletter/domain/testing/FakeTimeLetterRepository.kt +++ b/feature/timeletter/domain/src/testFixtures/kotlin/com/afternote/feature/timeletter/domain/testing/FakeTimeLetterRepository.kt @@ -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, diff --git a/feature/timeletter/domain/src/testFixtures/kotlin/com/afternote/feature/timeletter/domain/testing/FakeVoiceRecorderRepository.kt b/feature/timeletter/domain/src/testFixtures/kotlin/com/afternote/feature/timeletter/domain/testing/FakeVoiceRecorderRepository.kt new file mode 100644 index 0000000000..89875d8e03 --- /dev/null +++ b/feature/timeletter/domain/src/testFixtures/kotlin/com/afternote/feature/timeletter/domain/testing/FakeVoiceRecorderRepository.kt @@ -0,0 +1,18 @@ +package com.afternote.feature.timeletter.domain.testing + +import com.afternote.feature.timeletter.domain.model.RecordedAudio +import com.afternote.feature.timeletter.domain.repository.VoiceRecorderRepository + +object FakeVoiceRecorderRepository : VoiceRecorderRepository { + override suspend fun start(): Result = unexpectedCall("VoiceRecorderRepository.start") + + override suspend fun stop(): Result = unexpectedCall("VoiceRecorderRepository.stop") + + override suspend fun discard() = Unit + + override fun retainRecordedFile() = Unit + + override suspend fun deleteRecordedFile(uriString: String) = Unit + + override fun release() = Unit +} diff --git a/feature/timeletter/presentation/src/main/AndroidManifest.xml b/feature/timeletter/presentation/src/main/AndroidManifest.xml index a5918e68ab..7795386b05 100644 --- a/feature/timeletter/presentation/src/main/AndroidManifest.xml +++ b/feature/timeletter/presentation/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ - \ No newline at end of file + + + diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/DraftDateFormatter.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/DraftDateFormatter.kt new file mode 100644 index 0000000000..6254de6828 --- /dev/null +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/DraftDateFormatter.kt @@ -0,0 +1,18 @@ +package com.afternote.feature.timeletter.presentation.component + +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import java.util.Locale + +private val draftDateFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd.", Locale.ROOT) + +internal fun formatDraftSendAt(sendAt: String?): String { + if (sendAt.isNullOrBlank()) return "–" + + return try { + LocalDate.parse(sendAt.take(10)).format(draftDateFormatter) + } catch (_: DateTimeParseException) { + "–" + } +} diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/DraftLetterItem.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/DraftLetterItem.kt index d59d2d113b..71e95357c2 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/DraftLetterItem.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/DraftLetterItem.kt @@ -1,69 +1,138 @@ package com.afternote.feature.timeletter.presentation.component import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.button.AfternoteCircularCheckbox import com.afternote.core.ui.button.CheckboxState +import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterStatus +import com.afternote.feature.timeletter.presentation.R @Composable fun DraftLetterItem( draft: TimeLetter, modifier: Modifier = Modifier, + receiverNameMap: Map = emptyMap(), isEditMode: Boolean = false, isSelected: Boolean = false, + onOpen: () -> Unit = {}, onToggle: () -> Unit = {}, ) { - Row( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .height(78.dp) - .then(if (isEditMode) Modifier.clickable { onToggle() } else Modifier), - verticalAlignment = Alignment.CenterVertically, - ) { - if (isEditMode) { - AfternoteCircularCheckbox( - state = if (isSelected) CheckboxState.Default else CheckboxState.None, - onClick = { onToggle() }, - size = 20.dp, - ) + val resolvedReceiverNames = + draft.receiverIds + .mapNotNull(receiverNameMap::get) + val receiverLabel = + when { + draft.receiverIds.isEmpty() -> { + stringResource(R.string.timeletter_draft_recipient_unspecified) + } + + resolvedReceiverNames.size != draft.receiverIds.size -> { + stringResource(R.string.timeletter_draft_recipient_count, draft.receiverIds.size) + } + + else -> { + stringResource( + R.string.timeletter_draft_recipient_names, + resolvedReceiverNames.joinToString(", "), + ) + } } - Column { - Text("수신인 : ${if (draft.receiverIds.isEmpty()) "미지정" else "${draft.receiverIds.size}명"}") - Text(draft.title ?: "제목 없음") + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .then( + if (isEditMode) { + Modifier.clickable(role = Role.Checkbox, onClick = onToggle) + } else { + Modifier.clickable(role = Role.Button, onClick = onOpen) + }, + ).padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isEditMode) { + AfternoteCircularCheckbox( + state = if (isSelected) CheckboxState.Default else CheckboxState.None, + onClick = onToggle, + size = 20.dp, + ) + Spacer(modifier = Modifier.width(16.dp)) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = receiverLabel, + modifier = Modifier.weight(1f), + style = AfternoteDesign.typography.footnoteCaption, + color = AfternoteDesign.colors.gray6, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = + stringResource( + R.string.timeletter_draft_scheduled_date, + formatDraftSendAt(draft.sendAt), + ), + style = AfternoteDesign.typography.footnoteCaption, + color = AfternoteDesign.colors.gray6, + ) + } + Text( + text = + draft.title?.takeIf(String::isNotBlank) + ?: stringResource(R.string.timeletter_draft_untitled), + style = AfternoteDesign.typography.bodyBase, + color = AfternoteDesign.colors.gray9, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } - Spacer(modifier = Modifier.weight(1f)) - Text("발송예정일: ${draft.sendAt ?: "-"}") + HorizontalDivider( + modifier = Modifier.padding(horizontal = 20.dp), + color = AfternoteDesign.colors.gray3, + ) } } @Preview(showBackground = true) @Composable -private fun DraftLetterItemPrev() { +private fun DraftLetterItemPreview() { DraftLetterItem( draft = TimeLetter( id = 1L, title = "미래의 나에게", sendAt = "2026-12-31T00:00:00", - deliveredAt = null, status = TimeLetterStatus.DRAFT, blocks = emptyList(), receiverIds = listOf(1L), ), + receiverNameMap = mapOf(1L to "김지은"), ) } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt index 554fd5cd0c..a0d2781030 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt @@ -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), diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBottomBar.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBottomBar.kt index 32b4f0b45c..68642e2c51 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBottomBar.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBottomBar.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -19,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -91,18 +93,20 @@ fun TimeLetterBottomBar( ).padding(start = 6.dp, end = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { + // 정렬 아이콘 3종은 `core:ui` 승격본을 공유한다 — 종전에는 모듈마다 사본이 있어 + // 같은 아이콘이 화면에 따라 다르게 렌더됐다 (#1404 · #635 의 사본 수렴 방향). AlignButton( - painter = painterResource(R.drawable.ic_align_center), + painter = painterResource(CoreUiR.drawable.core_ui_ic_align_center), selected = textAlign == TextAlign.Center, // 상태 반영 onClick = onAlignCenterClick, ) AlignButton( - painter = painterResource(R.drawable.ic_align_left), + painter = painterResource(CoreUiR.drawable.core_ui_ic_align_left), selected = textAlign == TextAlign.Start, onClick = onAlignLeftClick, ) AlignButton( - painter = painterResource(R.drawable.ic_align_right), + painter = painterResource(CoreUiR.drawable.core_ui_ic_align_right), selected = textAlign == TextAlign.End, onClick = onAlignRightClick, ) @@ -117,12 +121,21 @@ fun TimeLetterBottomBar( color = AfternoteDesign.colors.gray7, ) } - if (draftCount > 0) { + Box( + modifier = + Modifier + .sizeIn(minWidth = 48.dp, minHeight = 48.dp) + .clickable( + onClickLabel = "임시저장 목록 열기", + role = Role.Button, + onClick = onDraftCountClick, + ), + contentAlignment = Alignment.Center, + ) { Text( text = "$draftCount", style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray7, - modifier = Modifier.clickable(onClick = onDraftCountClick), ) } } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterContent.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterContent.kt index 5d84fa96ab..7c87447179 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterContent.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterContent.kt @@ -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), @@ -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), diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeletterListItem.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeletterListItem.kt index 475e8da43c..f0e0472bc6 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeletterListItem.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeletterListItem.kt @@ -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), diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/navigation/TimeLetterNavGraph.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/navigation/TimeLetterNavGraph.kt index c0779d48e9..c28aea6a6d 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/navigation/TimeLetterNavGraph.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/navigation/TimeLetterNavGraph.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.navigation @@ -19,6 +20,8 @@ import com.afternote.feature.timeletter.presentation.screen.sender.TimeletterScr import com.afternote.feature.timeletter.presentation.viewmodel.TimeLetterWriteViewModel import com.afternote.feature.timeletter.presentation.viewmodel.TimeletterViewModel +private const val DRAFT_CHANGED_RESULT = "timeletter_draft_changed" + fun NavGraphBuilder.timeLetterNavGraph( navController: NavController, actions: TimeLetterNavActions, @@ -40,12 +43,14 @@ fun NavGraphBuilder.timeLetterNavGraph( LaunchedEffect(uiState.savedAsDraft) { if (uiState.savedAsDraft) { + navController.notifyPreviousDraftChanged() viewModel.onSavedAsDraftShown() actions.onDraftBack() } } LaunchedEffect(uiState.registered) { if (uiState.registered) { + navController.notifyPreviousDraftChanged() viewModel.onRegisteredShown() actions.onWriteBack() } @@ -75,6 +80,12 @@ fun NavGraphBuilder.timeLetterNavGraph( onAlignCenterClick = { viewModel.setTextAlign(TextAlign.Center) }, onAlignLeftClick = { viewModel.setTextAlign(TextAlign.Start) }, onAlignRightClick = { viewModel.setTextAlign(TextAlign.End) }, + onOpenVoiceRecorder = viewModel::openVoiceRecorder, + onStartVoiceRecording = viewModel::startVoiceRecording, + onStopVoiceRecording = viewModel::stopVoiceRecording, + onRegisterVoiceRecording = viewModel::registerVoiceRecording, + onRetryVoiceRecording = viewModel::retryVoiceRecording, + onDiscardVoiceRecording = viewModel::discardVoiceRecording, onFreePlanLimitConfirm = { // TODO: 구독 화면 및 관련 플로우 구현 시 구독 화면 이동으로 변경 viewModel.dismissFreePlanLimitPopup() @@ -83,8 +94,19 @@ fun NavGraphBuilder.timeLetterNavGraph( ) } - composable { - DraftLetterScreen(onBackClick = actions::onDraftBack) + composable { backStackEntry -> + val refreshRequested by + backStackEntry.savedStateHandle + .getStateFlow(DRAFT_CHANGED_RESULT, false) + .collectAsStateWithLifecycle() + DraftLetterScreen( + onBackClick = actions::onDraftBack, + onOpenDraft = actions::onNavigateToEdit, + refreshRequested = refreshRequested, + onRefreshConsumed = { + backStackEntry.savedStateHandle[DRAFT_CHANGED_RESULT] = false + }, + ) } composable { @@ -126,3 +148,10 @@ fun NavGraphBuilder.timeLetterNavGraph( } } } + +private fun NavController.notifyPreviousDraftChanged() { + previousBackStackEntry + ?.takeIf { it.destination.hasRoute() } + ?.savedStateHandle + ?.set(DRAFT_CHANGED_RESULT, true) +} diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt index 17520ffb1a..d972931609 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt @@ -228,7 +228,6 @@ private fun ReceivedTimeLetter.toTimeLetter() = id = id, title = title, sendAt = deliveredAt, - deliveredAt = deliveredAt, status = status, blocks = blocks, receiverIds = listOf(id), diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/DraftLetterScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/DraftLetterScreen.kt index c97ae8ac2f..1eca58253b 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/DraftLetterScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/DraftLetterScreen.kt @@ -1,19 +1,35 @@ package com.afternote.feature.timeletter.presentation.screen.sender +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.button.AfternoteButton +import com.afternote.core.ui.button.AfternoteButtonType +import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterStatus +import com.afternote.feature.timeletter.presentation.R import com.afternote.feature.timeletter.presentation.component.DraftLetterItem import com.afternote.feature.timeletter.presentation.component.TimeLetterTextButton import com.afternote.feature.timeletter.presentation.viewmodel.DraftLetterUiState @@ -22,23 +38,40 @@ import com.afternote.feature.timeletter.presentation.viewmodel.DraftLetterViewMo @Composable fun DraftLetterScreen( onBackClick: () -> Unit, + onOpenDraft: (Long) -> Unit, modifier: Modifier = Modifier, viewModel: DraftLetterViewModel = hiltViewModel(), + refreshRequested: Boolean = false, + onRefreshConsumed: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val messageRes = (uiState as? DraftLetterUiState.Success)?.messageRes + val message = messageRes?.let { stringResource(it) } + + LaunchedEffect(refreshRequested) { + if (refreshRequested) { + onRefreshConsumed() + viewModel.loadDrafts() + } + } + + LaunchedEffect(messageRes) { + if (message != null) { + snackbarHostState.showSnackbar(message) + viewModel.onMessageShown() + } + } DraftLetterContent( uiState = uiState, onBackClick = onBackClick, - onEditCompleteClick = { - val current = uiState as? DraftLetterUiState.Success ?: return@DraftLetterContent - if (current.isEditMode && current.selectedIds.isNotEmpty()) { - viewModel.deleteSelected() - } else { - viewModel.toggleEditMode() - } - }, + onOpenDraft = onOpenDraft, + onEditCompleteClick = viewModel::toggleEditMode, onToggleSelection = viewModel::toggleSelection, + onDeleteAll = viewModel::deleteAll, + onDeleteSelected = viewModel::deleteSelected, + snackbarHostState = snackbarHostState, modifier = modifier, ) } @@ -47,60 +80,105 @@ fun DraftLetterScreen( private fun DraftLetterContent( uiState: DraftLetterUiState, onBackClick: () -> Unit, + onOpenDraft: (Long) -> Unit, onEditCompleteClick: () -> Unit, onToggleSelection: (Long) -> Unit, + onDeleteAll: () -> Unit, + onDeleteSelected: () -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, modifier: Modifier = Modifier, ) { - val isEditMode = (uiState as? DraftLetterUiState.Success)?.isEditMode ?: false - val selectedIds = (uiState as? DraftLetterUiState.Success)?.selectedIds ?: emptySet() + val successState = uiState as? DraftLetterUiState.Success + val isEditMode = successState?.isEditMode == true Scaffold( modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { DetailTopBar( - title = "임시저장된 레터", + title = stringResource(R.string.timeletter_draft_title), onBackClick = onBackClick, actions = { - TimeLetterTextButton( - text = - when { - !isEditMode -> "수정" - selectedIds.isEmpty() -> "취소" - else -> "삭제" - }, - isActive = isEditMode && selectedIds.isNotEmpty(), - onClick = onEditCompleteClick, - ) + if (successState != null && successState.drafts.isNotEmpty()) { + TimeLetterTextButton( + text = stringResource(if (isEditMode) R.string.timeletter_draft_done else R.string.timeletter_draft_edit), + isActive = isEditMode, + onClick = onEditCompleteClick, + ) + } }, ) }, + bottomBar = { + if (isEditMode) { + AfternoteButton( + text = stringResource(R.string.timeletter_draft_delete_all), + onClick = onDeleteAll, + secondaryText = stringResource(R.string.timeletter_draft_delete_selected), + onSecondaryClick = onDeleteSelected, + type = AfternoteButtonType.Variant5, + isLoading = successState.isDeleting, + isSecondaryEnabled = successState.isDeleteSelectedEnabled, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + ) + } + }, + containerColor = AfternoteDesign.colors.white, ) { innerPadding -> when (uiState) { - is DraftLetterUiState.Loading -> { - Unit + DraftLetterUiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } } is DraftLetterUiState.Error -> { - Unit + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding).padding(20.dp), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(uiState.messageRes), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + } } is DraftLetterUiState.Success -> { - LazyColumn( - modifier = - Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - items( - items = uiState.drafts, - key = { it.id }, - ) { draft -> - DraftLetterItem( - draft = draft, - isEditMode = isEditMode, - isSelected = draft.id in selectedIds, - onToggle = { onToggleSelection(draft.id) }, - ) + Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + Text( + text = + stringResource( + if (uiState.isEditMode) R.string.timeletter_draft_total_selected else R.string.timeletter_draft_total, + if (uiState.isEditMode) uiState.selectedIds.size else uiState.drafts.size, + ), + style = AfternoteDesign.typography.footnoteCaption, + color = AfternoteDesign.colors.gray9, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + if (uiState.drafts.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.timeletter_draft_empty), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + } + } else { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(items = uiState.drafts, key = { it.id }) { draft -> + DraftLetterItem( + draft = draft, + receiverNameMap = uiState.receiverNameMap, + isEditMode = uiState.isEditMode, + isSelected = draft.id in uiState.selectedIds, + onOpen = { onOpenDraft(draft.id) }, + onToggle = { onToggleSelection(draft.id) }, + ) + } + } } } } @@ -108,9 +186,9 @@ private fun DraftLetterContent( } } -@Preview(showBackground = true, name = "일반 - 목록 있음") +@Preview(showBackground = true) @Composable -private fun DraftLetterScreenNormalPreview() { +private fun DraftLetterScreenPreview() { DraftLetterContent( uiState = DraftLetterUiState.Success( @@ -120,7 +198,6 @@ private fun DraftLetterScreenNormalPreview() { id = 1L, title = "첫 번째 레터", sendAt = "2026-12-25T00:00:00", - deliveredAt = null, status = TimeLetterStatus.DRAFT, blocks = emptyList(), receiverIds = listOf(1L), @@ -129,7 +206,6 @@ private fun DraftLetterScreenNormalPreview() { id = 2L, title = "두 번째 레터", sendAt = "2026-06-01T00:00:00", - deliveredAt = null, status = TimeLetterStatus.DRAFT, blocks = emptyList(), receiverIds = listOf(2L), @@ -138,16 +214,19 @@ private fun DraftLetterScreenNormalPreview() { id = 3L, title = null, sendAt = null, - deliveredAt = null, status = TimeLetterStatus.DRAFT, blocks = emptyList(), receiverIds = emptyList(), ), ), + receiverNameMap = mapOf(1L to "김지은", 2L to "이현우"), ), onBackClick = {}, + onOpenDraft = {}, onEditCompleteClick = {}, onToggleSelection = {}, + onDeleteAll = {}, + onDeleteSelected = {}, ) } @@ -163,7 +242,6 @@ private fun DraftLetterScreenEditModePreview() { id = 1L, title = "첫 번째 레터", sendAt = "2026-12-25T00:00:00", - deliveredAt = null, status = TimeLetterStatus.DRAFT, blocks = emptyList(), receiverIds = listOf(1L), @@ -172,7 +250,6 @@ private fun DraftLetterScreenEditModePreview() { id = 2L, title = "두 번째 레터", sendAt = "2026-06-01T00:00:00", - deliveredAt = null, status = TimeLetterStatus.DRAFT, blocks = emptyList(), receiverIds = listOf(2L), @@ -182,7 +259,10 @@ private fun DraftLetterScreenEditModePreview() { selectedIds = setOf(1L), ), onBackClick = {}, + onOpenDraft = {}, onEditCompleteClick = {}, onToggleSelection = {}, + onDeleteAll = {}, + onDeleteSelected = {}, ) } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterDetailScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterDetailScreen.kt index 6718339c00..459968a06b 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterDetailScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterDetailScreen.kt @@ -284,7 +284,6 @@ private fun TimeLetterDetailScreenPrev() { id = 1L, title = "미래의 나에게", sendAt = "2026-12-31T00:00:00", - deliveredAt = null, status = TimeLetterStatus.SCHEDULED, blocks = listOf( diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreen.kt index 8b2583162a..b34294f162 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreen.kt @@ -1,10 +1,20 @@ package com.afternote.feature.timeletter.presentation.screen.sender +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.pm.PackageManager +import android.media.AudioAttributes +import android.media.MediaPlayer import android.net.Uri +import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize @@ -33,6 +43,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField @@ -55,12 +66,17 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import coil3.compose.AsyncImage import com.afternote.core.ui.button.AfternoteButton import com.afternote.core.ui.calendar.BottomSheetCalendar @@ -69,6 +85,7 @@ import com.afternote.core.ui.popup.PopupType import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.timeletter.domain.model.RecordedAudio import com.afternote.feature.timeletter.presentation.R import com.afternote.feature.timeletter.presentation.component.MediaBottomSheetContent import com.afternote.feature.timeletter.presentation.component.RecipientCard @@ -80,10 +97,23 @@ import com.afternote.feature.timeletter.presentation.component.TimeWheelPicker import com.afternote.feature.timeletter.presentation.viewmodel.EditorBlock import com.afternote.feature.timeletter.presentation.viewmodel.TimeLetterWriteError import com.afternote.feature.timeletter.presentation.viewmodel.TimeLetterWriteUiState +import com.afternote.feature.timeletter.presentation.viewmodel.VoiceRecordingState import java.time.LocalDate import java.time.LocalTime import com.afternote.core.ui.R as CoreUiR +private enum class MicrophonePermissionError { + Denied, + PermanentlyDenied, +} + +private tailrec fun Context.findActivity(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } + @OptIn(ExperimentalMaterial3Api::class) @Composable fun TimeLetterWriteScreen( @@ -110,14 +140,26 @@ fun TimeLetterWriteScreen( onAlignCenterClick: () -> Unit = {}, onAlignLeftClick: () -> Unit = {}, onAlignRightClick: () -> Unit = {}, + onOpenVoiceRecorder: () -> Unit = {}, + onStartVoiceRecording: () -> Unit = {}, + onStopVoiceRecording: () -> Unit = {}, + onRegisterVoiceRecording: () -> Unit = {}, + onRetryVoiceRecording: () -> Unit = {}, + onDiscardVoiceRecording: () -> Unit = {}, onFreePlanLimitConfirm: () -> Unit = {}, onFreePlanLimitDismiss: () -> Unit = {}, ) { + val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val sheetState = rememberModalBottomSheetState() var showMediaSheet by remember { mutableStateOf(false) } var showLinkDialog by remember { mutableStateOf(false) } var linkUrlInput by remember { mutableStateOf("") } + var microphonePermissionErrorType by remember { mutableStateOf(null) } + val microphonePermissionError = stringResource(R.string.timeletter_voice_recording_permission_error) + val microphonePermissionSettings = stringResource(R.string.timeletter_voice_recording_permission_settings) + val settingsActionLabel = stringResource(R.string.timeletter_voice_recording_permission_settings_action) + val errorMessage = uiState.error?.message() val textBlockStates = remember(uiState.editingTimeLetterId) { androidx.compose.runtime.mutableStateMapOf() } @@ -137,18 +179,73 @@ fun TimeLetterWriteScreen( rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> uri?.let { onAddAudioBlock(it) } } + val recordAudioPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + onStartVoiceRecording() + } else { + microphonePermissionErrorType = + if ( + context.findActivity()?.let { activity -> + ActivityCompat.shouldShowRequestPermissionRationale( + activity, + Manifest.permission.RECORD_AUDIO, + ) + } == false + ) { + MicrophonePermissionError.PermanentlyDenied + } else { + MicrophonePermissionError.Denied + } + } + } val fileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> uri?.let { onAddFileBlock(it) } } - val errorMessage = uiState.error?.message() LaunchedEffect(uiState.error) { - val message = errorMessage ?: return@LaunchedEffect - snackbarHostState.showSnackbar(message) + val msg = errorMessage ?: return@LaunchedEffect + snackbarHostState.showSnackbar(msg) onErrorShown() } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + if ( + uiState.voiceRecordingState is VoiceRecordingState.Starting || + uiState.voiceRecordingState is VoiceRecordingState.Recording + ) { + onDiscardVoiceRecording() + } + } + + LaunchedEffect(microphonePermissionErrorType) { + val errorType = microphonePermissionErrorType ?: return@LaunchedEffect + val result = + snackbarHostState.showSnackbar( + message = + when (errorType) { + MicrophonePermissionError.Denied -> microphonePermissionError + MicrophonePermissionError.PermanentlyDenied -> microphonePermissionSettings + }, + actionLabel = + if (errorType == MicrophonePermissionError.PermanentlyDenied) { + settingsActionLabel + } else { + null + }, + ) + if (result == SnackbarResult.ActionPerformed) { + context.startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + microphonePermissionErrorType = null + } + val currentOnTitleChanged by rememberUpdatedState(onTitleChanged) LaunchedEffect(titleState) { snapshotFlow { titleState.text.toString() } @@ -264,7 +361,7 @@ fun TimeLetterWriteScreen( }, onVoiceClick = { showMediaSheet = false - audioLauncher.launch("audio/*") + onOpenVoiceRecorder() }, onFileClick = { showMediaSheet = false @@ -279,6 +376,30 @@ fun TimeLetterWriteScreen( } } + if (uiState.showVoiceRecorder) { + VoiceRecorderBottomSheet( + state = uiState.voiceRecordingState, + onDismiss = onDiscardVoiceRecording, + onStart = { + if ( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) { + onStartVoiceRecording() + } else { + recordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + }, + onPickAudioFile = { + onDiscardVoiceRecording() + audioLauncher.launch("audio/*") + }, + onStop = onStopVoiceRecording, + onRegister = onRegisterVoiceRecording, + onRetry = onRetryVoiceRecording, + ) + } + if (showLinkDialog) { AlertDialog( onDismissRequest = { showLinkDialog = false }, @@ -475,14 +596,15 @@ internal fun collectTextBlockContents( @Composable private fun TimeLetterWriteError.message(): String = - stringResource( - when (this) { - TimeLetterWriteError.SEND_DATE_REQUIRED -> R.string.timeletter_write_send_date_required - TimeLetterWriteError.LOAD_FAILED -> R.string.timeletter_write_load_failed - TimeLetterWriteError.RECIPIENT_REQUIRED -> R.string.timeletter_write_recipient_required - TimeLetterWriteError.SAVE_FAILED -> R.string.timeletter_write_save_failed - }, - ) + when (this) { + TimeLetterWriteError.SendDateRequired -> stringResource(R.string.timeletter_write_send_date_required) + TimeLetterWriteError.LoadFailed -> stringResource(R.string.timeletter_write_load_failed) + TimeLetterWriteError.RecipientRequired -> stringResource(R.string.timeletter_write_recipient_required) + TimeLetterWriteError.SaveFailed -> stringResource(R.string.timeletter_write_save_failed) + TimeLetterWriteError.ServerRejection -> stringResource(R.string.timeletter_write_rejected) + TimeLetterWriteError.VoiceRecordingStartFailed -> stringResource(R.string.timeletter_voice_recording_start_error) + TimeLetterWriteError.VoiceRecordingStopFailed -> stringResource(R.string.timeletter_voice_recording_stop_error) + } @Composable private fun TextBlockItem( @@ -567,6 +689,204 @@ private fun ImageBlockItem( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun VoiceRecorderBottomSheet( + state: VoiceRecordingState, + onDismiss: () -> Unit, + onStart: () -> Unit, + onPickAudioFile: () -> Unit, + onStop: () -> Unit, + onRegister: () -> Unit, + onRetry: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = Color.White, + ) { + Text( + text = stringResource(R.string.timeletter_voice_recording_title), + style = AfternoteDesign.typography.h3, + color = AfternoteDesign.colors.gray9, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(24.dp)) + + when (state) { + VoiceRecordingState.Idle -> { + Text( + text = stringResource(R.string.timeletter_voice_recording_instruction), + style = AfternoteDesign.typography.bodyBase, + color = AfternoteDesign.colors.gray7, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(modifier = Modifier.height(24.dp)) + AfternoteButton( + text = stringResource(R.string.timeletter_voice_recording_start), + onClick = onStart, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) + TextButton( + onClick = onPickAudioFile, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text(stringResource(R.string.timeletter_voice_recording_pick_file)) + } + } + + VoiceRecordingState.Starting, VoiceRecordingState.Stopping -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + } + + is VoiceRecordingState.Recording -> { + Text( + text = formatRecordingDuration(state.elapsedMillis), + style = AfternoteDesign.typography.h2, + color = AfternoteDesign.colors.gray9, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(modifier = Modifier.height(24.dp)) + AfternoteButton( + text = stringResource(R.string.timeletter_voice_recording_stop), + onClick = onStop, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) + } + + is VoiceRecordingState.Recorded -> { + RecordedVoiceControls( + state = state, + onRegister = onRegister, + onRetry = onRetry, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +@Composable +private fun ColumnScope.RecordedVoiceControls( + state: VoiceRecordingState.Recorded, + onRegister: () -> Unit, + onRetry: () -> Unit, +) { + val context = LocalContext.current + var isPlaying by remember(state.audio.uriString) { mutableStateOf(false) } + var isPlayerReady by remember(state.audio.uriString) { mutableStateOf(false) } + var playbackFailed by remember(state.audio.uriString) { mutableStateOf(false) } + val player = remember(state.audio.uriString) { MediaPlayer() } + + DisposableEffect(player, state.audio.uriString) { + player.setAudioAttributes( + AudioAttributes + .Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build(), + ) + player.setOnPreparedListener { + isPlayerReady = true + playbackFailed = false + } + player.setOnCompletionListener { isPlaying = false } + player.setOnErrorListener { _, _, _ -> + isPlaying = false + isPlayerReady = false + playbackFailed = true + true + } + runCatching { + player.setDataSource(context, Uri.parse(state.audio.uriString)) + player.prepareAsync() + }.onFailure { + playbackFailed = true + } + + onDispose { player.release() } + } + + Text( + text = + stringResource( + R.string.timeletter_voice_recording_duration, + formatRecordingDuration(state.audio.durationMillis), + ), + style = AfternoteDesign.typography.bodyBase, + color = AfternoteDesign.colors.gray7, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + onClick = { + runCatching { + if (player.isPlaying) { + player.pause() + isPlaying = false + } else { + player.start() + isPlaying = true + } + }.onFailure { + isPlaying = false + playbackFailed = true + } + }, + enabled = isPlayerReady, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + stringResource( + if (isPlaying) { + R.string.timeletter_voice_recording_pause + } else { + R.string.timeletter_voice_recording_play + }, + ), + ) + } + if (playbackFailed) { + Text( + text = stringResource(R.string.timeletter_voice_recording_playback_error), + style = AfternoteDesign.typography.bodyBase, + color = AfternoteDesign.colors.gray7, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + onClick = onRetry, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text(stringResource(R.string.timeletter_voice_recording_retry)) + } + AfternoteButton( + text = stringResource(R.string.timeletter_voice_recording_register), + onClick = onRegister, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) +} + +private fun formatRecordingDuration(durationMillis: Long): String { + val totalSeconds = durationMillis / 1_000L + val minutes = totalSeconds / 60L + val seconds = totalSeconds % 60L + return "%02d:%02d".format(minutes, seconds) +} + @Composable private fun MediaBlockChip( iconRes: Int, @@ -619,3 +939,60 @@ private fun TimeLetterWriteScreenPreview() { TimeLetterWriteScreen() } } + +@Preview(name = "Voice recorder - idle", showBackground = true) +@Composable +private fun VoiceRecorderBottomSheetIdlePreview() { + AfternoteTheme { + VoiceRecorderBottomSheet( + state = VoiceRecordingState.Idle, + onDismiss = {}, + onStart = {}, + onPickAudioFile = {}, + onStop = {}, + onRegister = {}, + onRetry = {}, + ) + } +} + +@Preview(name = "Voice recorder - recording", showBackground = true) +@Composable +private fun VoiceRecorderBottomSheetRecordingPreview() { + AfternoteTheme { + VoiceRecorderBottomSheet( + state = VoiceRecordingState.Recording(elapsedMillis = 65_000L), + onDismiss = {}, + onStart = {}, + onPickAudioFile = {}, + onStop = {}, + onRegister = {}, + onRetry = {}, + ) + } +} + +@Preview(name = "Voice recorder - recorded", showBackground = true) +@Composable +private fun VoiceRecorderBottomSheetRecordedPreview() { + AfternoteTheme { + VoiceRecorderBottomSheet( + state = + VoiceRecordingState.Recorded( + audio = + RecordedAudio( + uriString = "content://preview/voice.m4a", + fileName = "voice.m4a", + mimeType = "audio/mp4", + durationMillis = 65_000L, + ), + ), + onDismiss = {}, + onStart = {}, + onPickAudioFile = {}, + onStop = {}, + onRegister = {}, + onRetry = {}, + ) + } +} diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt index 84639183fa..e4bede3e23 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt @@ -1,11 +1,5 @@ package com.afternote.feature.timeletter.presentation.screen.sender -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -14,9 +8,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -24,15 +16,12 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle -import com.afternote.core.ui.button.FAB.PenFloatingActionButton import com.afternote.core.ui.popup.Popup import com.afternote.core.ui.popup.PopupType -import com.afternote.core.ui.topbar.HomeTopBar import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterList import com.afternote.feature.timeletter.domain.model.TimeLetterStatus import com.afternote.feature.timeletter.presentation.R -import com.afternote.feature.timeletter.presentation.component.EmptyTimeLetterContent import com.afternote.feature.timeletter.presentation.component.TimeLetterContent import com.afternote.feature.timeletter.presentation.viewmodel.TimeletterUiState import com.afternote.feature.timeletter.presentation.viewmodel.TimeletterViewModel @@ -86,43 +75,19 @@ fun TimeletterScreen( } } - Scaffold( + TimeletterScreenContent( + uiState = uiState, + viewMode = viewMode, + onViewModeChange = { viewMode = it }, + snackbarHostState = snackbarHostState, + onLetterClick = onLetterClick, + onSettingClick = onSettingClick, + onWriteClick = onWriteClick, + onEditClick = onEditClick, + onFilterRecipientClick = onFilterRecipientClick, + onDeleteClick = { pendingDeleteId = it }, modifier = modifier, - containerColor = Color.Transparent, - topBar = { HomeTopBar(onSettingClick = onSettingClick) }, - floatingActionButton = { PenFloatingActionButton(onClick = onWriteClick) }, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - ) { paddingValues -> - when (val state = uiState) { - is TimeletterUiState.Loading -> { - Box( - modifier = Modifier.fillMaxSize().padding(paddingValues), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - - is TimeletterUiState.Error -> { - EmptyTimeLetterContent(modifier = Modifier.padding(paddingValues)) - } - - is TimeletterUiState.Success -> { - TimeLetterContent( - letters = state.letters, - receiverNameMap = state.receiverNameMap, - viewMode = viewMode, - onViewModeChange = { viewMode = it }, - selectedFilterReceiverIds = state.selectedFilterReceiverIds, - onFilterClick = onFilterRecipientClick, - onLetterClick = onLetterClick, - onEditClick = onEditClick, - onDeleteClick = { pendingDeleteId = it }, - modifier = Modifier.padding(paddingValues), - ) - } - } - } + ) } private val previewLetters = @@ -133,7 +98,6 @@ private val previewLetters = id = 1L, title = "미래의 나에게", sendAt = "2026-12-31T00:00:00", - deliveredAt = null, status = TimeLetterStatus.SCHEDULED, blocks = emptyList(), receiverIds = listOf(1L), @@ -142,7 +106,6 @@ private val previewLetters = id = 2L, title = "10년 후의 나에게", sendAt = "2035-01-01T00:00:00", - deliveredAt = null, status = TimeLetterStatus.SCHEDULED, blocks = emptyList(), receiverIds = listOf(2L), diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenContent.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenContent.kt new file mode 100644 index 0000000000..beb3d155d3 --- /dev/null +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenContent.kt @@ -0,0 +1,78 @@ +package com.afternote.feature.timeletter.presentation.screen.sender + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.afternote.core.ui.button.FAB.PenFloatingActionButton +import com.afternote.core.ui.topbar.HomeTopBar +import com.afternote.feature.timeletter.presentation.component.EmptyTimeLetterContent +import com.afternote.feature.timeletter.presentation.component.TimeLetterContent +import com.afternote.feature.timeletter.presentation.viewmodel.TimeletterUiState +import com.afternote.feature.timeletter.presentation.viewmodel.ViewMode + +/** + * 타임레터 목록 화면의 상태 없는 본문. + * + * ViewModel·부수효과는 [TimeletterScreen] 이 들고, 이 함수는 넘겨받은 상태만 그린다. + * screenshotTest 가 각 상태를 고정 입력으로 렌더하기 위한 경계다. + */ +@Composable +internal fun TimeletterScreenContent( + uiState: TimeletterUiState, + viewMode: ViewMode, + onViewModeChange: (ViewMode) -> Unit, + snackbarHostState: SnackbarHostState, + onLetterClick: (Long) -> Unit, + onSettingClick: () -> Unit, + onWriteClick: () -> Unit, + onEditClick: (Long) -> Unit, + onFilterRecipientClick: () -> Unit, + onDeleteClick: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + containerColor = Color.Transparent, + topBar = { HomeTopBar(onSettingClick = onSettingClick) }, + floatingActionButton = { PenFloatingActionButton(onClick = onWriteClick) }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { paddingValues -> + when (uiState) { + is TimeletterUiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize().padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + is TimeletterUiState.Error -> { + EmptyTimeLetterContent(modifier = Modifier.padding(paddingValues)) + } + + is TimeletterUiState.Success -> { + TimeLetterContent( + letters = uiState.letters, + receiverNameMap = uiState.receiverNameMap, + viewMode = viewMode, + onViewModeChange = onViewModeChange, + selectedFilterReceiverIds = uiState.selectedFilterReceiverIds, + onFilterClick = onFilterRecipientClick, + onLetterClick = onLetterClick, + onEditClick = onEditClick, + onDeleteClick = onDeleteClick, + modifier = Modifier.padding(paddingValues), + ) + } + } + } +} diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterUiState.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterUiState.kt index e6283c7dbe..5d77d08a66 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterUiState.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterUiState.kt @@ -1,5 +1,6 @@ package com.afternote.feature.timeletter.presentation.viewmodel +import androidx.annotation.StringRes import com.afternote.feature.timeletter.domain.model.TimeLetter sealed interface DraftLetterUiState { @@ -7,11 +8,17 @@ sealed interface DraftLetterUiState { data class Success( val drafts: List, + val receiverNameMap: Map = emptyMap(), val isEditMode: Boolean = false, + val isDeleting: Boolean = false, val selectedIds: Set = emptySet(), - ) : DraftLetterUiState + @StringRes val messageRes: Int? = null, + ) : DraftLetterUiState { + val isDeleteSelectedEnabled: Boolean + get() = selectedIds.isNotEmpty() && !isDeleting + } data class Error( - val message: String, + @StringRes val messageRes: Int, ) : DraftLetterUiState } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt index 9a1d9a87fc..54d8962772 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt @@ -2,8 +2,12 @@ package com.afternote.feature.timeletter.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.repository.UserRepository import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository +import com.afternote.feature.timeletter.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -15,6 +19,7 @@ class DraftLetterViewModel @Inject constructor( private val timeLetterRepository: TimeLetterRepository, + private val userRepository: UserRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(DraftLetterUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() @@ -26,50 +31,95 @@ class DraftLetterViewModel fun loadDrafts() { viewModelScope.launch { _uiState.value = DraftLetterUiState.Loading - runCatching { timeLetterRepository.getTemporaryTimeLetters() } - .onSuccess { result -> - _uiState.value = DraftLetterUiState.Success(drafts = result.timeLetters) - }.onFailure { - _uiState.value = DraftLetterUiState.Error("임시저장 레터를 불러올 수 없습니다.") - } + try { + val result = timeLetterRepository.getTemporaryTimeLetters() + val receiverNameMap = + runCatchingCancellable { userRepository.getReceivers() } + .getOrElse { emptyList() } + .associate { receiver -> receiver.receiverId to receiver.name } + _uiState.value = + DraftLetterUiState.Success( + drafts = result.timeLetters, + receiverNameMap = receiverNameMap, + ) + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (_: Exception) { + _uiState.value = DraftLetterUiState.Error(R.string.timeletter_draft_load_error) + } } } fun toggleEditMode() { val current = _uiState.value as? DraftLetterUiState.Success ?: return + if (current.isDeleting) return _uiState.value = current.copy(isEditMode = !current.isEditMode, selectedIds = emptySet()) } fun toggleSelection(id: Long) { val current = _uiState.value as? DraftLetterUiState.Success ?: return + if (current.isDeleting) return + if (current.drafts.none { it.id == id }) return val updated = if (id in current.selectedIds) current.selectedIds - id else current.selectedIds + id _uiState.value = current.copy(selectedIds = updated) } fun deleteSelected() { val current = _uiState.value as? DraftLetterUiState.Success ?: return - if (current.selectedIds.isEmpty()) return + if (current.isDeleting || current.selectedIds.isEmpty()) return + val selectedIds = current.selectedIds + _uiState.value = current.copy(isDeleting = true, messageRes = null) viewModelScope.launch { - runCatching { - timeLetterRepository.deleteTimeLetters(current.selectedIds.toList()) - }.onSuccess { - _uiState.value = - current.copy( - drafts = current.drafts.filter { it.id !in current.selectedIds }, + try { + timeLetterRepository.deleteTimeLetters(selectedIds.toList()) + updateSuccessState { latest -> + latest.copy( + drafts = latest.drafts.filter { it.id !in selectedIds }, selectedIds = emptySet(), isEditMode = false, ) + } + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (_: Exception) { + updateSuccessState { latest -> latest.copy(messageRes = R.string.timeletter_draft_delete_error) } + } finally { + updateSuccessState { latest -> latest.copy(isDeleting = false) } } } } fun deleteAll() { + val current = _uiState.value as? DraftLetterUiState.Success ?: return + if (current.isDeleting) return + _uiState.value = current.copy(isDeleting = true, messageRes = null) viewModelScope.launch { - runCatching { + try { timeLetterRepository.deleteAllTemporary() - }.onSuccess { - _uiState.value = DraftLetterUiState.Success(drafts = emptyList()) + updateSuccessState { latest -> + latest.copy( + drafts = emptyList(), + selectedIds = emptySet(), + isEditMode = false, + ) + } + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (_: Exception) { + updateSuccessState { latest -> latest.copy(messageRes = R.string.timeletter_draft_delete_error) } + } finally { + updateSuccessState { latest -> latest.copy(isDeleting = false) } } } } + + fun onMessageShown() { + val current = _uiState.value as? DraftLetterUiState.Success ?: return + _uiState.value = current.copy(messageRes = null) + } + + private inline fun updateSuccessState(transform: (DraftLetterUiState.Success) -> DraftLetterUiState.Success) { + val current = _uiState.value as? DraftLetterUiState.Success ?: return + _uiState.value = transform(current) + } } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteUiState.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteUiState.kt index 547264359f..07c7de5843 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteUiState.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteUiState.kt @@ -2,6 +2,7 @@ package com.afternote.feature.timeletter.presentation.viewmodel import android.net.Uri import androidx.compose.ui.text.style.TextAlign +import com.afternote.feature.timeletter.domain.model.RecordedAudio data class TimeLetterWriteUiState( val editingTimeLetterId: Long? = null, @@ -23,14 +24,42 @@ data class TimeLetterWriteUiState( val nextBlockId: Long = 1L, val savedAsDraft: Boolean = false, val registered: Boolean = false, + val showVoiceRecorder: Boolean = false, + val voiceRecordingState: VoiceRecordingState = VoiceRecordingState.Idle, val showFreePlanLimitPopup: Boolean = false, ) -enum class TimeLetterWriteError { - SEND_DATE_REQUIRED, - LOAD_FAILED, - RECIPIENT_REQUIRED, - SAVE_FAILED, +sealed interface TimeLetterWriteError { + data object SendDateRequired : TimeLetterWriteError + + data object LoadFailed : TimeLetterWriteError + + data object RecipientRequired : TimeLetterWriteError + + data object SaveFailed : TimeLetterWriteError + + /** A 4xx the server explicitly rejected. Shown as a generic message — the server's own text is not user-facing. */ + data object ServerRejection : TimeLetterWriteError + + data object VoiceRecordingStartFailed : TimeLetterWriteError + + data object VoiceRecordingStopFailed : TimeLetterWriteError +} + +sealed interface VoiceRecordingState { + data object Idle : VoiceRecordingState + + data object Starting : VoiceRecordingState + + data class Recording( + val elapsedMillis: Long, + ) : VoiceRecordingState + + data object Stopping : VoiceRecordingState + + data class Recorded( + val audio: RecordedAudio, + ) : VoiceRecordingState } sealed class EditorBlock { diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModel.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModel.kt index fe2b094355..30fd000bf8 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModel.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModel.kt @@ -1,6 +1,7 @@ package com.afternote.feature.timeletter.presentation.viewmodel import android.net.Uri +import android.os.SystemClock import androidx.compose.ui.text.style.TextAlign import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -8,6 +9,7 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserRepository +import com.afternote.feature.timeletter.domain.error.TimeLetterServerRejectionException import com.afternote.feature.timeletter.domain.model.BlockInput import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterBlockType @@ -15,10 +17,13 @@ import com.afternote.feature.timeletter.domain.model.TimeLetterDeliveryMode import com.afternote.feature.timeletter.domain.model.TimeLetterStatus import com.afternote.feature.timeletter.domain.repository.FileMetadataRepository import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository +import com.afternote.feature.timeletter.domain.repository.VoiceRecorderRepository import com.afternote.feature.timeletter.domain.usecase.CreateTimeLetterUseCase import com.afternote.feature.timeletter.domain.usecase.ResolveTimeLetterBlocksUseCase import com.afternote.feature.timeletter.presentation.navigation.TimeLetterRoute import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -38,6 +43,7 @@ class TimeLetterWriteViewModel private val timeLetterRepository: TimeLetterRepository, private val userRepository: UserRepository, private val fileMetadataRepository: FileMetadataRepository, + private val voiceRecorderRepository: VoiceRecorderRepository, private val savedStateHandle: SavedStateHandle, ) : ViewModel() { private val editingTimeLetterId = @@ -52,7 +58,16 @@ class TimeLetterWriteViewModel val uiState: StateFlow = _uiState.asStateFlow() private var receiverNameMap: Map = emptyMap() + private var recordingTimerJob: Job? = null private var isCheckingRegisterLimit: Boolean = false + private var originalEditingStatus: TimeLetterStatus? = null + + /** + * 화면이 닫힌 뒤 도착하는 start()/stop() 완료를 걸러낸다. discard/retry/openVoiceRecorder + * 로 현재 녹음 시도를 포기할 때마다 증가시켜, 그 이전에 시작된 start()/stop() 이 나중에 + * 성공하더라도 UI 를 되살리지 않고 즉시 파일을 정리하게 한다 (#440 리뷰). + */ + private var recorderGeneration = 0 init { viewModelScope.launch { @@ -144,22 +159,22 @@ class TimeLetterWriteViewModel if (state.isSaving || isCheckingRegisterLimit) return if (state.recipientIds.isEmpty()) { - _uiState.update { it.copy(error = TimeLetterWriteError.RECIPIENT_REQUIRED) } + _uiState.update { it.copy(error = TimeLetterWriteError.RecipientRequired) } return } if (state.sendAt == null) { - _uiState.update { it.copy(error = TimeLetterWriteError.SEND_DATE_REQUIRED) } + _uiState.update { it.copy(error = TimeLetterWriteError.SendDateRequired) } return } isCheckingRegisterLimit = true viewModelScope.launch { try { - if (state.editingTimeLetterId == null) { + if (originalEditingStatus != TimeLetterStatus.SCHEDULED) { val registeredCount = runCatchingCancellable { timeLetterRepository.getTimeLetters().totalCount } .getOrElse { _uiState.update { current -> - current.copy(error = TimeLetterWriteError.LOAD_FAILED) + current.copy(error = TimeLetterWriteError.LoadFailed) } return@launch } @@ -219,6 +234,141 @@ class TimeLetterWriteViewModel } } + fun openVoiceRecorder() { + recorderGeneration++ + _uiState.update { + it.copy( + showVoiceRecorder = true, + voiceRecordingState = VoiceRecordingState.Idle, + ) + } + } + + fun startVoiceRecording() { + if (_uiState.value.voiceRecordingState !is VoiceRecordingState.Idle) return + val generation = recorderGeneration + _uiState.update { it.copy(voiceRecordingState = VoiceRecordingState.Starting) } + viewModelScope.launch { + voiceRecorderRepository + .start() + .onSuccess { + if (generation == recorderGeneration) { + _uiState.update { state -> + state.copy(voiceRecordingState = VoiceRecordingState.Recording(0L)) + } + startRecordingTimer() + } else { + // 화면이 이미 닫혀 이 시도를 포기한 뒤 뒤늦게 성공한 경우다. + // UI 는 되살리지 않고 방금 시작된 녹음을 바로 회수한다. + voiceRecorderRepository.discard() + } + }.onFailure { + if (generation == recorderGeneration) { + _uiState.update { state -> + state.copy( + voiceRecordingState = VoiceRecordingState.Idle, + error = TimeLetterWriteError.VoiceRecordingStartFailed, + ) + } + } + } + } + } + + fun stopVoiceRecording() { + if (_uiState.value.voiceRecordingState !is VoiceRecordingState.Recording) return + val generation = recorderGeneration + recordingTimerJob?.cancel() + recordingTimerJob = null + _uiState.update { it.copy(voiceRecordingState = VoiceRecordingState.Stopping) } + viewModelScope.launch { + voiceRecorderRepository + .stop() + .onSuccess { audio -> + if (generation == recorderGeneration) { + _uiState.update { state -> + state.copy(voiceRecordingState = VoiceRecordingState.Recorded(audio)) + } + } else { + voiceRecorderRepository.deleteRecordedFile(audio.uriString) + } + }.onFailure { + if (generation == recorderGeneration) { + _uiState.update { state -> + state.copy( + voiceRecordingState = VoiceRecordingState.Idle, + error = TimeLetterWriteError.VoiceRecordingStopFailed, + ) + } + } + } + } + } + + fun registerVoiceRecording() { + val recorded = _uiState.value.voiceRecordingState as? VoiceRecordingState.Recorded ?: return + val audio = recorded.audio + voiceRecorderRepository.retainRecordedFile() + addMediaBlockInternal { id -> + EditorBlock.Audio( + id = id, + uri = Uri.parse(audio.uriString), + name = audio.fileName, + mimeType = audio.mimeType, + ) + } + _uiState.update { + it.copy( + showVoiceRecorder = false, + voiceRecordingState = VoiceRecordingState.Idle, + ) + } + } + + fun discardVoiceRecording() { + recorderGeneration++ + recordingTimerJob?.cancel() + recordingTimerJob = null + viewModelScope.launch { + voiceRecorderRepository.discard() + _uiState.update { + it.copy( + showVoiceRecorder = false, + voiceRecordingState = VoiceRecordingState.Idle, + ) + } + } + } + + fun retryVoiceRecording() { + recorderGeneration++ + recordingTimerJob?.cancel() + recordingTimerJob = null + viewModelScope.launch { + voiceRecorderRepository.discard() + _uiState.update { it.copy(voiceRecordingState = VoiceRecordingState.Idle) } + } + } + + private fun startRecordingTimer() { + recordingTimerJob?.cancel() + recordingTimerJob = + viewModelScope.launch { + val startedAtMillis = SystemClock.elapsedRealtime() + while (_uiState.value.voiceRecordingState is VoiceRecordingState.Recording) { + delay(RECORDING_TIMER_INTERVAL_MILLIS) + val elapsedMillis = SystemClock.elapsedRealtime() - startedAtMillis + _uiState.update { state -> + if (state.voiceRecordingState is VoiceRecordingState.Recording) { + state.copy(voiceRecordingState = VoiceRecordingState.Recording(elapsedMillis)) + } else { + state + } + } + } + } + } + fun addFileBlock(uri: Uri) { viewModelScope.launch { val uriString = uri.toString() @@ -258,6 +408,10 @@ class TimeLetterWriteViewModel } fun removeBlock(id: Long) { + val removedAudioUri = + (_uiState.value.editorBlocks.firstOrNull { it.id == id } as? EditorBlock.Audio) + ?.uri + ?.toString() _uiState.update { state -> val filtered = state.editorBlocks.filter { it.id != id } if (filtered.isEmpty()) { @@ -270,6 +424,9 @@ class TimeLetterWriteViewModel state.copy(editorBlocks = filtered) } } + removedAudioUri?.let { uri -> + viewModelScope.launch { voiceRecorderRepository.deleteRecordedFile(uri) } + } } private fun save( @@ -280,7 +437,7 @@ class TimeLetterWriteViewModel val state = _uiState.value if (state.isSaving) return if (state.recipientIds.isEmpty()) { - _uiState.update { it.copy(error = TimeLetterWriteError.RECIPIENT_REQUIRED) } + _uiState.update { it.copy(error = TimeLetterWriteError.RecipientRequired) } return } @@ -289,9 +446,11 @@ class TimeLetterWriteViewModel try { val sendAt = state.sendAt?.let { date -> - "${date}T${ - state.sendHour.toString().padStart(2, '0') - }:${state.sendMinute.toString().padStart(2, '0')}:00" + formatSendAt( + date = date, + hour = state.sendHour, + minute = state.sendMinute, + ) } val saveResult = if (state.editingTimeLetterId == null) { @@ -321,15 +480,25 @@ class TimeLetterWriteViewModel } saveResult .onSuccess { + state.editorBlocks + .filterIsInstance() + .forEach { voiceRecorderRepository.deleteRecordedFile(it.uri.toString()) } if (status == TimeLetterStatus.DRAFT) { loadDraftCount() _uiState.update { it.copy(savedAsDraft = true) } } else { _uiState.update { it.copy(registered = true) } } - }.onFailure { + }.onFailure { error -> _uiState.update { - it.copy(error = TimeLetterWriteError.SAVE_FAILED) + it.copy( + error = + if (error is TimeLetterServerRejectionException) { + TimeLetterWriteError.ServerRejection + } else { + TimeLetterWriteError.SaveFailed + }, + ) } } } finally { @@ -341,6 +510,7 @@ class TimeLetterWriteViewModel private suspend fun loadEditingTimeLetter(timeLetterId: Long) { runCatchingCancellable { timeLetterRepository.getTimeLetter(timeLetterId) } .onSuccess { letter -> + originalEditingStatus = letter.status val editorBlocks = letter.toEditorBlocks() val sendAtDate = letter.sendAt?.take(10) val sendHour = @@ -377,9 +547,18 @@ class TimeLetterWriteViewModel nextBlockId = (editorBlocks.maxOfOrNull { block -> block.id } ?: 0L) + 1L, ) } - }.onFailure { + }.onFailure { error -> _uiState.update { it.copy(isLoadingEditingLetter = false) } - _uiState.update { it.copy(error = TimeLetterWriteError.LOAD_FAILED) } + _uiState.update { + it.copy( + error = + if (error is TimeLetterServerRejectionException) { + TimeLetterWriteError.ServerRejection + } else { + TimeLetterWriteError.LoadFailed + }, + ) + } } } @@ -518,7 +697,20 @@ class TimeLetterWriteViewModel } } + override fun onCleared() { + recordingTimerJob?.cancel() + voiceRecorderRepository.release() + super.onCleared() + } + private companion object { + const val RECORDING_TIMER_INTERVAL_MILLIS = 1_000L const val FREE_PLAN_REGISTER_LIMIT = 3 } } + +private fun formatSendAt( + date: String, + hour: Int, + minute: Int, +): String = "${date}T${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}:00" diff --git a/feature/timeletter/presentation/src/main/res/values/strings.xml b/feature/timeletter/presentation/src/main/res/values/strings.xml index 7f44e760bb..deca9f35d2 100644 --- a/feature/timeletter/presentation/src/main/res/values/strings.xml +++ b/feature/timeletter/presentation/src/main/res/values/strings.xml @@ -4,9 +4,44 @@ 타임레터를 불러올 수 없습니다. 수신자를 선택해주세요. 저장에 실패했어요. 다시 시도해주세요. + 요청을 처리할 수 없어요. 다시 확인해주세요. + 음성 메시지 녹음 + 녹음 버튼을 눌러 음성 메시지를 남겨주세요. + 녹음 시작 + 기기에서 음성 파일 선택 + 녹음 완료 + 녹음 시간 %1$s + 녹음 듣기 + 재생 일시정지 + 녹음 파일을 재생하지 못했습니다. 다시 녹음해 주세요. + 다시 녹음 + 음성 메시지 등록 + 음성 메시지를 녹음하려면 마이크 권한이 필요합니다. + 마이크 권한을 허용하려면 앱 설정에서 권한을 변경해 주세요. + 설정 + 음성 녹음을 시작하지 못했습니다. + 음성 녹음을 완료하지 못했습니다. 다시 시도해 주세요. 타임레터를 삭제하시겠습니까? 예 아니오 타임레터를 삭제하지 못했습니다. 다시 시도해 주세요. 이름으로 검색하기 + 임시 저장된 레터 + 선택 + 완료 + 총 %1$d 개 + 총 %1$d 개 선택 + 수신인 %1$s + 수신인 %1$d명 + 수신인이 지정되지 않았습니다. + 발송 예정일 %1$s + 제목 없음 + 전체 삭제 + 선택 삭제 + 임시 저장된 레터가 없습니다. + 임시 저장된 레터를 불러올 수 없습니다. + 임시 저장된 레터를 삭제하지 못했습니다. + 발송 날짜를 선택해주세요. + 타임레터를 불러올 수 없습니다. + 저장에 실패했어요. 다시 시도해주세요. diff --git a/feature/timeletter/presentation/src/screenshotTest/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTest.kt b/feature/timeletter/presentation/src/screenshotTest/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTest.kt index 8cfb978a22..668bda0ad5 100644 --- a/feature/timeletter/presentation/src/screenshotTest/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTest.kt +++ b/feature/timeletter/presentation/src/screenshotTest/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTest.kt @@ -86,7 +86,6 @@ private val previewLetters = id = 1L, title = "미래의 나에게", sendAt = "2026-12-31T00:00:00", - deliveredAt = null, status = TimeLetterStatus.SCHEDULED, blocks = emptyList(), receiverIds = listOf(1L), @@ -95,7 +94,6 @@ private val previewLetters = id = 2L, title = "10년 후의 나에게", sendAt = "2035-01-01T00:00:00", - deliveredAt = null, status = TimeLetterStatus.SCHEDULED, blocks = emptyList(), receiverIds = listOf(2L), diff --git a/feature/timeletter/presentation/src/screenshotTestDebug/reference/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTestKt/timeletterScreenBlockCompactScreenshot_c2abd7e6_0.png b/feature/timeletter/presentation/src/screenshotTestDebug/reference/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTestKt/timeletterScreenBlockCompactScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..5a54c7a096 Binary files /dev/null and b/feature/timeletter/presentation/src/screenshotTestDebug/reference/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTestKt/timeletterScreenBlockCompactScreenshot_c2abd7e6_0.png differ diff --git a/feature/timeletter/presentation/src/screenshotTestDebug/reference/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTestKt/timeletterScreenBlockScreenshot_748aa731_0.png b/feature/timeletter/presentation/src/screenshotTestDebug/reference/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTestKt/timeletterScreenBlockScreenshot_748aa731_0.png new file mode 100644 index 0000000000..6c905530e7 Binary files /dev/null and b/feature/timeletter/presentation/src/screenshotTestDebug/reference/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTestKt/timeletterScreenBlockScreenshot_748aa731_0.png differ diff --git a/feature/timeletter/presentation/src/screenshotTestDebug/reference/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTestKt/timeletterScreenEmptyScreenshot_748aa731_0.png b/feature/timeletter/presentation/src/screenshotTestDebug/reference/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTestKt/timeletterScreenEmptyScreenshot_748aa731_0.png new file mode 100644 index 0000000000..1ac2bee7c2 Binary files /dev/null and b/feature/timeletter/presentation/src/screenshotTestDebug/reference/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTestKt/timeletterScreenEmptyScreenshot_748aa731_0.png differ diff --git a/feature/timeletter/presentation/src/screenshotTestDebug/reference/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTestKt/timeletterScreenListScreenshot_748aa731_0.png b/feature/timeletter/presentation/src/screenshotTestDebug/reference/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTestKt/timeletterScreenListScreenshot_748aa731_0.png new file mode 100644 index 0000000000..438c204a42 Binary files /dev/null and b/feature/timeletter/presentation/src/screenshotTestDebug/reference/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTestKt/timeletterScreenListScreenshot_748aa731_0.png differ diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterFlowTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterFlowTest.kt index 49c1844219..d5ae2faadf 100644 --- a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterFlowTest.kt +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterFlowTest.kt @@ -15,6 +15,7 @@ import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterStatus import com.afternote.feature.timeletter.domain.testing.FakeFileMetadataRepository import com.afternote.feature.timeletter.domain.testing.FakeTimeLetterRepository +import com.afternote.feature.timeletter.domain.testing.FakeVoiceRecorderRepository import com.afternote.feature.timeletter.domain.usecase.CreateTimeLetterUseCase import com.afternote.feature.timeletter.domain.usecase.ResolveTimeLetterBlocksUseCase import com.afternote.feature.timeletter.presentation.screen.sender.TimeLetterWriteScreen @@ -73,7 +74,6 @@ class TimeLetterFlowTest { id = 1L, title = call.title, sendAt = call.sendAt, - deliveredAt = null, status = call.status, blocks = emptyList(), receiverIds = call.receiverIds, @@ -89,7 +89,7 @@ class TimeLetterFlowTest { viewModel.register("가을 편지", mapOf(0L to "잊지 않을게")) } composeRule.waitUntil(timeoutMillis = 5_000) { - viewModel.uiState.value.error == TimeLetterWriteError.SAVE_FAILED + viewModel.uiState.value.error == TimeLetterWriteError.SaveFailed } assertFalse(viewModel.uiState.value.registered) @@ -152,6 +152,7 @@ class TimeLetterFlowTest { fileName = "fixture", mimeType = "application/pdf", ), + voiceRecorderRepository = FakeVoiceRecorderRepository, savedStateHandle = SavedStateHandle(mapOf("timeLetterId" to null)), ) } @@ -159,14 +160,10 @@ class TimeLetterFlowTest { private fun timeLetterFlowUserRepository(): FakeUserRepository = FakeUserRepository.strict().apply { - receiverState.value = listOf(Receiver(7L, "김수신", "가족", "fake-auth-7")) + receiverState.value = listOf(Receiver(7L, "김수신", "가족")) onReceiverListFlow = null onGetReceivers = null onCreateReceiver = null onGetMyProfile = null onUpdateMyProfile = null - onDeleteAccount = null - onGetMyPushSettings = null - onUpdateMyPushSettings = null - onGetConnectedAccounts = null } diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterLifecycleTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterLifecycleTest.kt index 7454ecf752..89bd9bedfe 100644 --- a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterLifecycleTest.kt +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterLifecycleTest.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasScrollAction import androidx.compose.ui.test.hasText @@ -28,9 +30,11 @@ import com.afternote.feature.timeletter.domain.model.TimeLetterBlockType import com.afternote.feature.timeletter.domain.model.TimeLetterDeliveryMode import com.afternote.feature.timeletter.domain.model.TimeLetterList import com.afternote.feature.timeletter.domain.model.TimeLetterStatus +import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository import com.afternote.feature.timeletter.domain.testing.FakeFileMetadataRepository import com.afternote.feature.timeletter.domain.testing.FakeReceiverTimeLetterRepository import com.afternote.feature.timeletter.domain.testing.FakeTimeLetterRepository +import com.afternote.feature.timeletter.domain.testing.FakeVoiceRecorderRepository import com.afternote.feature.timeletter.domain.usecase.CreateTimeLetterUseCase import com.afternote.feature.timeletter.domain.usecase.ResolveTimeLetterBlocksUseCase import com.afternote.feature.timeletter.presentation.screen.recipient.RecipientTimeLetterDetailScreen @@ -158,32 +162,57 @@ class TimeLetterLifecycleTest { @Test fun drafts_selectionDeleteReentryAndDeleteAll_reloadDurableRepositoryState() { - val firstDraft = timeLetter(id = 31L, title = "첫 임시 편지", status = TimeLetterStatus.DRAFT) - val secondDraft = timeLetter(id = 32L, title = "둘째 임시 편지", status = TimeLetterStatus.DRAFT) + val firstDraft = + timeLetter( + id = 31L, + title = "한 줄을 넘길 만큼 아주 긴 첫 임시 편지 제목이 레이아웃 높이를 늘리지 않아야 합니다", + status = TimeLetterStatus.DRAFT, + ) + val secondDraft = + timeLetter( + id = 32L, + title = "둘째 임시 편지", + sendAt = "2026-11-02T10:00:00", + status = TimeLetterStatus.DRAFT, + ) val repository = FakeTimeLetterRepository( temporaryLetters = TimeLetterList(listOf(firstDraft, secondDraft), totalCount = 2), ) - var activeViewModel by mutableStateOf(DraftLetterViewModel(repository)) + val userRepository = privateUserRepository(testReceivers) + var openedDraftId: Long? = null + var activeViewModel by mutableStateOf(DraftLetterViewModel(repository, userRepository)) composeRule.setContent { AfternoteTheme { - DraftLetterScreen(onBackClick = {}, viewModel = activeViewModel) + DraftLetterScreen( + onBackClick = {}, + onOpenDraft = { openedDraftId = it }, + viewModel = activeViewModel, + onRefreshConsumed = {}, + ) } } - composeRule.onNodeWithText("첫 임시 편지").assertIsDisplayed() + composeRule.onNodeWithText("한 줄을 넘길 만큼 아주 긴 첫 임시 편지 제목", substring = true).assertIsDisplayed() + composeRule.onNodeWithText("발송 예정일 2026.10.01.").assertIsDisplayed() composeRule.onNodeWithText("둘째 임시 편지").assertIsDisplayed() - - composeRule.onNodeWithText("수정").performClick() - composeRule.onNode(hasText("첫 임시 편지") and hasClickAction()).performClick() - composeRule.onNodeWithText("삭제").performClick() + composeRule.onNode(hasText("한 줄을 넘길 만큼 아주 긴 첫 임시 편지 제목", substring = true) and hasClickAction()).performClick() + assertEquals(31L, openedDraftId) + + composeRule.onNodeWithText("선택").performClick() + composeRule.onNodeWithText("선택 삭제").assertIsNotEnabled() + composeRule.onNode(hasText("한 줄을 넘길 만큼 아주 긴 첫 임시 편지 제목", substring = true) and hasClickAction()).performClick() + composeRule.onNodeWithText("선택 삭제").assertIsEnabled() + composeRule.onNodeWithText("선택 삭제").performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT) { repository.deleteCalls == listOf(listOf(31L)) } - composeRule.onNodeWithText("첫 임시 편지").assertDoesNotExist() + composeRule.onNodeWithText("한 줄을 넘길 만큼 아주 긴 첫 임시 편지 제목", substring = true).assertDoesNotExist() composeRule.onNodeWithText("둘째 임시 편지").assertIsDisplayed() - composeRule.runOnIdle { activeViewModel = DraftLetterViewModel(repository) } + composeRule.runOnIdle { + activeViewModel = DraftLetterViewModel(repository, userRepository) + } composeRule.waitUntil(timeoutMillis = TIMEOUT) { repository.getTemporaryTimeLettersCalls == 2 } - composeRule.onNodeWithText("첫 임시 편지").assertDoesNotExist() + composeRule.onNodeWithText("한 줄을 넘길 만큼 아주 긴 첫 임시 편지 제목", substring = true).assertDoesNotExist() composeRule.onNodeWithText("둘째 임시 편지").assertIsDisplayed() composeRule.runOnIdle { activeViewModel.deleteAll() } @@ -196,6 +225,49 @@ class TimeLetterLifecycleTest { assertTrue(repository.temporaryLetters.timeLetters.isEmpty()) } + @Test + fun draftReturnResult_refreshesRepositoryExactlyOnce() { + val draft = timeLetter(id = 35L, title = "등록 전 임시 편지", status = TimeLetterStatus.DRAFT) + val repository = + PrivateTimeLetterRepository( + draftLetters = TimeLetterList(listOf(draft), totalCount = 1), + ) + val viewModel = DraftLetterViewModel(repository, privateUserRepository(testReceivers)) + var refreshRequested by mutableStateOf(false) + + composeRule.setContent { + AfternoteTheme { + DraftLetterScreen( + onBackClick = {}, + onOpenDraft = {}, + viewModel = viewModel, + refreshRequested = refreshRequested, + onRefreshConsumed = { refreshRequested = false }, + ) + } + } + composeRule.onNodeWithText("등록 전 임시 편지").assertIsDisplayed() + assertEquals(1, repository.temporaryListCalls) + + composeRule.runOnIdle { + repository.replaceDraftLetters(TimeLetterList(emptyList(), totalCount = 0)) + refreshRequested = true + } + // v2 rule 은 StandardTestDispatcher 라 LaunchedEffect(refreshRequested) 가 트리거한 + // viewModelScope 코루틴을 waitUntil 폴링만으로는 못 잡는다 — 한 번 명시적으로 idle 해 + // 재조합·이펙트를 먼저 흘려보낸다. + composeRule.waitForIdle() + + composeRule.waitUntil(timeoutMillis = TIMEOUT) { + repository.temporaryListCalls == 2 && + viewModel.uiState.value.let { it is DraftLetterUiState.Success && it.drafts.isEmpty() } + } + composeRule.onNodeWithText("등록 전 임시 편지").assertDoesNotExist() + composeRule.runOnIdle { assertFalse(refreshRequested) } + composeRule.waitForIdle() + assertEquals(2, repository.temporaryListCalls) + } + @Test fun editingExistingLetter_showsLoadedStateAndSendsExactUpdatePayload() { val existingLetter = @@ -358,6 +430,7 @@ class TimeLetterLifecycleTest { timeLetterRepository = repository, userRepository = userRepository, fileMetadataRepository = FakeFileMetadataRepository.strict(), + voiceRecorderRepository = FakeVoiceRecorderRepository, savedStateHandle = SavedStateHandle(mapOf("timeLetterId" to timeLetterId)), ) } @@ -367,12 +440,120 @@ class TimeLetterLifecycleTest { val testReceivers = listOf( - Receiver(receiverId = 7L, name = "김수신", relation = "가족", authCode = "auth-7"), - Receiver(receiverId = 8L, name = "박친구", relation = "친구", authCode = "auth-8"), + Receiver(receiverId = 7L, name = "김수신", relation = "가족"), + Receiver(receiverId = 8L, name = "박친구", relation = "친구"), ) } } +private data class PrivateTimeLetterUpdateCall( + val timeLetterId: Long, + val title: String?, + val blocks: List, + val sendAt: String?, + val deliveryMode: TimeLetterDeliveryMode?, + val status: TimeLetterStatus?, +) + +private class PrivateTimeLetterRepository( + registeredLetters: TimeLetterList = TimeLetterList(emptyList(), 0), + draftLetters: TimeLetterList = TimeLetterList(emptyList(), 0), + private val editingLetter: TimeLetter? = null, +) : TimeLetterRepository { + var registeredLetters = registeredLetters + private set + var draftLetters = draftLetters + private set + var nextListLoad: CompletableDeferred>? = null + val deleteResults = ArrayDeque>() + val deleteCalls = mutableListOf>() + val updateCalls = mutableListOf() + var listCalls = 0 + private set + var temporaryListCalls = 0 + private set + var detailCalls = 0 + private set + var createCalls = 0 + private set + var deleteAllTemporaryCalls = 0 + private set + + fun replaceDraftLetters(value: TimeLetterList) { + draftLetters = value + } + + override suspend fun getTimeLetters(): TimeLetterList { + listCalls += 1 + val pending = nextListLoad + nextListLoad = null + val loaded = pending?.await()?.getOrThrow() ?: registeredLetters + registeredLetters = loaded + return loaded + } + + override suspend fun getTemporaryTimeLetters(): TimeLetterList { + temporaryListCalls += 1 + return draftLetters + } + + override suspend fun getTimeLetter(timeLetterId: Long): TimeLetter { + detailCalls += 1 + return requireNotNull(editingLetter).also { assertEquals(timeLetterId, it.id) } + } + + override suspend fun createTimeLetter( + title: String?, + blocks: List, + sendAt: String?, + deliveryMode: TimeLetterDeliveryMode, + status: TimeLetterStatus, + receiverIds: List, + ): TimeLetter { + createCalls += 1 + return timeLetter( + id = 999L, + title = title, + receiverIds = receiverIds, + sendAt = sendAt, + status = status, + ) + } + + override suspend fun updateTimeLetter( + timeLetterId: Long, + title: String?, + blocks: List, + sendAt: String?, + deliveryMode: TimeLetterDeliveryMode?, + status: TimeLetterStatus?, + ): TimeLetter { + updateCalls += + PrivateTimeLetterUpdateCall( + timeLetterId = timeLetterId, + title = title, + blocks = blocks, + sendAt = sendAt, + deliveryMode = deliveryMode, + status = status, + ) + return requireNotNull(editingLetter).copy(title = title, sendAt = sendAt) + } + + override suspend fun deleteTimeLetters(timeLetterIds: List) { + deleteCalls += timeLetterIds + val result = deleteResults.removeFirstOrNull() ?: Result.success(Unit) + result.getOrThrow() + registeredLetters = registeredLetters.without(timeLetterIds) + draftLetters = draftLetters.without(timeLetterIds) + } + + override suspend fun deleteAllTemporary() { + deleteAllTemporaryCalls += 1 + draftLetters = TimeLetterList(emptyList(), 0) + } +} + private fun privateUserRepository(receivers: List): FakeUserRepository = FakeUserRepository.strict().apply { onReceiverListFlow = { flowOf(receivers) } @@ -406,7 +587,6 @@ private fun timeLetter( id = id, title = title, sendAt = sendAt, - deliveredAt = null, status = status, blocks = blocks, receiverIds = receiverIds, diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/component/DraftDateFormatterTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/component/DraftDateFormatterTest.kt new file mode 100644 index 0000000000..db63e5e6f8 --- /dev/null +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/component/DraftDateFormatterTest.kt @@ -0,0 +1,23 @@ +package com.afternote.feature.timeletter.presentation.component + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DraftDateFormatterTest { + @Test + fun `ISO date-time is formatted for the draft list`() { + assertEquals("2026.07.31.", formatDraftSendAt("2026-07-31T00:00:00")) + } + + @Test + fun `date-only value is formatted for the draft list`() { + assertEquals("2029.11.20.", formatDraftSendAt("2029-11-20")) + } + + @Test + fun `missing or malformed value uses dash`() { + assertEquals("–", formatDraftSendAt(null)) + assertEquals("–", formatDraftSendAt("")) + assertEquals("–", formatDraftSendAt("not-a-date")) + } +} diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreenTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreenTest.kt index 66d9132b95..0f193b964d 100644 --- a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreenTest.kt +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreenTest.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.timeletter.presentation.viewmodel.TimeLetterWriteUiState import org.junit.Assert.assertEquals @@ -54,4 +55,20 @@ class TimeLetterWriteScreenTest { assertEquals("existing title", titleState.text.toString()) } } + + @Test + fun `voice recorder keeps audio file picker available`() { + composeRule.setContent { + AfternoteTheme { + TimeLetterWriteScreen( + uiState = TimeLetterWriteUiState(showVoiceRecorder = true), + titleState = TextFieldState(), + ) + } + } + + composeRule + .onNodeWithText("기기에서 음성 파일 선택") + .assertExists() + } } diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModelTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModelTest.kt new file mode 100644 index 0000000000..43a2e86603 --- /dev/null +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModelTest.kt @@ -0,0 +1,187 @@ +package com.afternote.feature.timeletter.presentation.viewmodel + +import com.afternote.core.domain.repository.UserRepository +import com.afternote.feature.timeletter.domain.model.NewTimeLetterBlock +import com.afternote.feature.timeletter.domain.model.TimeLetter +import com.afternote.feature.timeletter.domain.model.TimeLetterDeliveryMode +import com.afternote.feature.timeletter.domain.model.TimeLetterList +import com.afternote.feature.timeletter.domain.model.TimeLetterStatus +import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.lang.reflect.Proxy + +@OptIn(ExperimentalCoroutinesApi::class) +class DraftLetterViewModelTest { + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `receiver load failure keeps drafts visible`() { + val viewModel = + DraftLetterViewModel( + timeLetterRepository { methodName, _ -> + when (methodName) { + "getTemporaryTimeLetters" -> testDrafts + else -> error("Unexpected repository call: $methodName") + } + }, + userRepository { throw IllegalStateException("receiver load failed") }, + ) + + val state = viewModel.uiState.value as DraftLetterUiState.Success + + assertEquals(testDrafts.timeLetters, state.drafts) + assertTrue(state.receiverNameMap.isEmpty()) + } + + @Test + fun `duplicate delete requests are ignored while deletion is running`() { + val deleteResult = CompletableDeferred() + var deleteCalls = 0 + val viewModel = + DraftLetterViewModel( + object : TimeLetterRepository { + override suspend fun getTemporaryTimeLetters(): TimeLetterList = testDrafts + + override suspend fun deleteAllTemporary() { + deleteCalls += 1 + deleteResult.await() + } + + override suspend fun getTimeLetters(): TimeLetterList = unexpectedCall() + + override suspend fun getTimeLetter(timeLetterId: Long): TimeLetter = unexpectedCall() + + override suspend fun createTimeLetter( + title: String?, + blocks: List, + sendAt: String?, + deliveryMode: TimeLetterDeliveryMode, + status: TimeLetterStatus, + receiverIds: List, + ): TimeLetter = unexpectedCall() + + override suspend fun updateTimeLetter( + timeLetterId: Long, + title: String?, + blocks: List, + sendAt: String?, + deliveryMode: TimeLetterDeliveryMode?, + status: TimeLetterStatus?, + ): TimeLetter = unexpectedCall() + + override suspend fun deleteTimeLetters(timeLetterIds: List) = unexpectedCall() + }, + userRepository { emptyList() }, + ) + + viewModel.deleteAll() + viewModel.deleteAll() + + assertEquals(1, deleteCalls) + assertTrue((viewModel.uiState.value as DraftLetterUiState.Success).isDeleting) + + deleteResult.complete(Unit) + + val state = viewModel.uiState.value as DraftLetterUiState.Success + assertTrue(state.drafts.isEmpty()) + assertFalse(state.isDeleting) + } + + @Test + fun `delete failure keeps latest drafts and exposes message`() { + val viewModel = + DraftLetterViewModel( + timeLetterRepository { methodName, _ -> + when (methodName) { + "getTemporaryTimeLetters" -> testDrafts + "deleteAllTemporary" -> throw IllegalStateException("delete failed") + else -> error("Unexpected repository call: $methodName") + } + }, + userRepository { emptyList() }, + ) + + viewModel.deleteAll() + + val state = viewModel.uiState.value as DraftLetterUiState.Success + assertEquals(testDrafts.timeLetters, state.drafts) + assertFalse(state.isDeleting) + assertEquals(com.afternote.feature.timeletter.presentation.R.string.timeletter_draft_delete_error, state.messageRes) + } + + @Test + fun `delete selected is disabled when no draft is selected`() { + val state = DraftLetterUiState.Success(drafts = testDrafts.timeLetters) + + assertFalse(state.isDeleteSelectedEnabled) + } + + @Test + fun `delete selected is enabled when a draft is selected`() { + val state = + DraftLetterUiState.Success( + drafts = testDrafts.timeLetters, + selectedIds = setOf(testDrafts.timeLetters.single().id), + ) + + assertTrue(state.isDeleteSelectedEnabled) + } + + private fun timeLetterRepository(handler: (String, Array?) -> Any?): TimeLetterRepository = + Proxy.newProxyInstance( + TimeLetterRepository::class.java.classLoader, + arrayOf(TimeLetterRepository::class.java), + ) { _, method, args -> handler(method.name, args) } as TimeLetterRepository + + private fun userRepository(getReceivers: () -> Any): UserRepository = + Proxy.newProxyInstance( + UserRepository::class.java.classLoader, + arrayOf(UserRepository::class.java), + ) { _, method, _ -> + when (method.name) { + "getReceiverListFlow" -> flowOf(emptyList()) + "getReceivers" -> getReceivers() + else -> error("Unexpected user repository call: ${method.name}") + } + } as UserRepository + + private fun unexpectedCall(): T = error("Unexpected repository call") + + private companion object { + val testDrafts = + TimeLetterList( + timeLetters = + listOf( + TimeLetter( + id = 1L, + title = "draft", + sendAt = null, + status = TimeLetterStatus.DRAFT, + blocks = emptyList(), + receiverIds = listOf(1L), + ), + ), + totalCount = 1, + ) + } +} diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModelTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModelTest.kt index 1615b1d6ac..c9d58600c2 100644 --- a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModelTest.kt +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModelTest.kt @@ -3,8 +3,12 @@ package com.afternote.feature.timeletter.presentation.viewmodel import androidx.lifecycle.SavedStateHandle import com.afternote.core.domain.testing.FakePhotoUploadRepository import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.feature.timeletter.domain.model.TimeLetter +import com.afternote.feature.timeletter.domain.model.TimeLetterList +import com.afternote.feature.timeletter.domain.model.TimeLetterStatus import com.afternote.feature.timeletter.domain.testing.FakeFileMetadataRepository import com.afternote.feature.timeletter.domain.testing.FakeTimeLetterRepository +import com.afternote.feature.timeletter.domain.testing.FakeVoiceRecorderRepository import com.afternote.feature.timeletter.domain.usecase.CreateTimeLetterUseCase import com.afternote.feature.timeletter.domain.usecase.ResolveTimeLetterBlocksUseCase import kotlinx.coroutines.CancellationException @@ -20,6 +24,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -52,7 +57,7 @@ class TimeLetterWriteViewModelTest { advanceUntilIdle() assertEquals(0, repository.createCalls.size) - assertEquals(TimeLetterWriteError.RECIPIENT_REQUIRED, viewModel.uiState.value.error) + assertEquals(TimeLetterWriteError.RecipientRequired, viewModel.uiState.value.error) assertFalse(viewModel.uiState.value.savedAsDraft) } @@ -68,7 +73,7 @@ class TimeLetterWriteViewModelTest { assertEquals(0, repository.getTimeLettersCalls) assertEquals(0, repository.createCalls.size) - assertEquals(TimeLetterWriteError.RECIPIENT_REQUIRED, viewModel.uiState.value.error) + assertEquals(TimeLetterWriteError.RecipientRequired, viewModel.uiState.value.error) assertFalse(viewModel.uiState.value.registered) } @@ -86,7 +91,7 @@ class TimeLetterWriteViewModelTest { advanceUntilIdle() assertEquals(1, repository.createCalls.size) - assertEquals(TimeLetterWriteError.SAVE_FAILED, viewModel.uiState.value.error) + assertEquals(TimeLetterWriteError.SaveFailed, viewModel.uiState.value.error) assertFalse(viewModel.uiState.value.savedAsDraft) } @@ -141,7 +146,52 @@ class TimeLetterWriteViewModelTest { assertFalse(viewModel.uiState.value.savedAsDraft) } - private fun viewModel(repository: FakeTimeLetterRepository): TimeLetterWriteViewModel { + @Test + fun `draft edit checks the free plan limit before scheduling`() = + runTest { + val editingLetter = testEditingLetter(status = TimeLetterStatus.DRAFT) + val repository = + FakeTimeLetterRepository( + registeredLetters = TimeLetterList(emptyList(), totalCount = 3), + details = mapOf(editingLetter.id to editingLetter), + ) + val viewModel = viewModel(repository, editingLetter.id) + advanceUntilIdle() + + viewModel.register(title = "title", textContents = emptyMap()) + advanceUntilIdle() + + assertEquals(1, repository.getTimeLettersCalls) + assertEquals(0, repository.updateCalls.size) + assertTrue(viewModel.uiState.value.showFreePlanLimitPopup) + assertFalse(viewModel.uiState.value.registered) + } + + @Test + fun `scheduled edit does not check the free plan limit again`() = + runTest { + val editingLetter = testEditingLetter(status = TimeLetterStatus.SCHEDULED) + val repository = + FakeTimeLetterRepository( + registeredLetters = TimeLetterList(emptyList(), totalCount = 3), + details = mapOf(editingLetter.id to editingLetter), + ) + val viewModel = viewModel(repository, editingLetter.id) + advanceUntilIdle() + + viewModel.register(title = "title", textContents = emptyMap()) + advanceUntilIdle() + + assertEquals(0, repository.getTimeLettersCalls) + assertEquals(1, repository.updateCalls.size) + assertFalse(viewModel.uiState.value.showFreePlanLimitPopup) + assertTrue(viewModel.uiState.value.registered) + } + + private fun viewModel( + repository: FakeTimeLetterRepository, + editingTimeLetterId: Long? = null, + ): TimeLetterWriteViewModel { val resolveUseCase = ResolveTimeLetterBlocksUseCase(FakePhotoUploadRepository.strict()) return TimeLetterWriteViewModel( createTimeLetterUseCase = CreateTimeLetterUseCase(repository, resolveUseCase), @@ -153,7 +203,18 @@ class TimeLetterWriteViewModelTest { onGetReceivers = { emptyList() } }, fileMetadataRepository = FakeFileMetadataRepository.strict(), - savedStateHandle = SavedStateHandle(mapOf("timeLetterId" to null)), + voiceRecorderRepository = FakeVoiceRecorderRepository, + savedStateHandle = SavedStateHandle(mapOf("timeLetterId" to editingTimeLetterId)), ) } + + private fun testEditingLetter(status: TimeLetterStatus): TimeLetter = + TimeLetter( + id = 10L, + title = "existing title", + sendAt = "2026-08-29T19:30:00", + status = status, + blocks = emptyList(), + receiverIds = listOf(1L), + ) } diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt index d1f54f36dc..c144434c64 100644 --- a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt @@ -137,7 +137,6 @@ class TimeletterViewModelTest { id = 1L, title = "제목", sendAt = null, - deliveredAt = null, status = TimeLetterStatus.SCHEDULED, blocks = emptyList(), receiverIds = emptyList(), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 45347d22a7..c270032fe7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,8 @@ hilt = "2.60.1" hiltNavigation = "1.4.0" ksp = "2.3.10" navigation = "2.9.8" +# Navigation 3 — 로컬 스택 이관(#1698). Nav2 는 루트 NavHost 가 #1702 까지 계속 쓰므로 공존한다. +navigation3 = "1.1.6" okhttp = "5.4.0" retrofit = "3.0.0" kotlinxSerializationJson = "1.11.0" @@ -51,9 +53,9 @@ konsist = "0.17.3" kover = "0.9.9" baselineProfile = "1.5.0-rc02" profileInstaller = "1.4.1" -# 빌드·테스트 클래스패스 전용 보안 하한 — GHSA-574f-3g2m-x479(critical) 등 3건이 1.84 에서 패치(#921). +# 빌드·테스트 클래스패스 전용 보안 하한 — GHSA-9pwp-9qqc-pr26·GHSA-qp49-qgx5-5m26 이 1.85 에서 패치(#2132). # 루트 build.gradle.kts 의 buildscript 블록은 카탈로그 accessor 를 못 써 같은 값을 리터럴로 둔다. -bouncycastle = "1.84" +bouncycastle = "1.85" netty = "4.1.137.Final" commonsLang3 = "3.18.0" jose4j = "0.9.6" @@ -97,6 +99,9 @@ hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testi hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-navigation = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "hiltNavigation" } android-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } +androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "navigation3" } +androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3" } +androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelKtx" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } @@ -114,6 +119,7 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coilNetworkOkhttp" } coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coilCompose" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" } compose-wheel-picker = { group = "com.github.zj565061763", name = "compose-wheel-picker", version.ref = "composeWheelPicker" } coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } diff --git a/konsist/build.gradle.kts b/konsist/build.gradle.kts index 6d427ddeb5..3e426b8f01 100644 --- a/konsist/build.gradle.kts +++ b/konsist/build.gradle.kts @@ -50,6 +50,11 @@ tasks.withType().configureEach { // 좁히면 그만큼 가드가 다시 침묵한다. include("**/src/**/*.kt", "**/*.gradle.kts") + // 정렬 아이콘 가드(AlignIconSharedAssetKonsistTest)는 `.kt` 가 아니라 **드로어블 벡터**를 + // 읽는다. 그 가드가 막으려는 변경이 곧 「모듈에 사본 xml 을 되살리는 것」이라, 이 include + // 가 없으면 정확히 그 변경에서만 태스크가 `UP-TO-DATE` 로 침묵한다 — 실측으로 확인했다. + include("**/src/**/res/drawable*/**/*.xml") + // konsist 는 빌드 산출물과 `.gradle` 을 자기 힘으로 걸러낸다. 여기서도 빼야 // 생성 소스가 입력에 섞여 매 빌드 입력이 흔들리는 것을 막는다. exclude("**/build/**", "**/.gradle/**", "**/.git/**") diff --git a/konsist/src/test/kotlin/com/afternote/konsist/AfternoteKonsistScope.kt b/konsist/src/test/kotlin/com/afternote/konsist/AfternoteKonsistScope.kt index 46e0283035..6dccf71d41 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/AfternoteKonsistScope.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/AfternoteKonsistScope.kt @@ -75,6 +75,9 @@ internal object AfternoteKonsistScope { /** [productionFiles] 와 같은 기준을 임의의 스코프에 적용한다. 재발 가드가 새로 읽은 스코프에 쓴다. */ fun productionFilesOf(target: KoScope): List = target.files.filterNot { it.sourceSetName.isTestSourceSet() } + /** 테스트 소스셋(test·androidTest·testFixtures·screenshotTest…)의 파일 — [productionFiles] 의 여집합. */ + val testFiles: List get() = scope.files.filter { it.sourceSetName.isTestSourceSet() } + /** 캐시를 타지 않고 지금의 [scanRoots] 를 다시 읽는다. 재발 가드([ScanScopeKonsistTest])가 쓴다. */ fun scanFresh(): KoScope = Konsist.scopeFromExternalDirectories(scanRoots().map { it.path }) diff --git a/konsist/src/test/kotlin/com/afternote/konsist/AlignIconSharedAssetKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/AlignIconSharedAssetKonsistTest.kt new file mode 100644 index 0000000000..1d122de40d --- /dev/null +++ b/konsist/src/test/kotlin/com/afternote/konsist/AlignIconSharedAssetKonsistTest.kt @@ -0,0 +1,97 @@ +package com.afternote.konsist + +import org.junit.Test +import java.io.File + +/** + * 정렬 아이콘(왼쪽·가운데·오른쪽) 사본 재분열 가드 (#1404 · #635). + * + * 마인드레코드와 타임레터는 같은 정렬 아이콘을 **각자의 사본**으로 들고 있었다. 사본이 갈리면 + * 눈에 안 보이는 방식으로 어긋난다 — 실제로 #1668 의 core 승격본은 처음에 `strokeAlpha="0.6"` 을 + * 빠뜨렸다. 두 경로 다 `tint` 를 얹으므로 한쪽만 alpha 가 더 곱해져, 같은 2.4 스트로크인데도 + * 화면에 찍히는 농도가 달랐다. 컴파일도 스크린샷도 이걸 잡지 못한다 — 각자 자기 사본을 보니까. + * + * 그래서 「같아야 한다」가 아니라 **「한 벌만 존재한다」** 를 지킨다. 벌이 하나면 어긋날 자리가 없다. + */ +class AlignIconSharedAssetKonsistTest { + /** + * 정렬 아이콘 벡터는 `core:ui` 밖에 있으면 안 된다. + * + * 이름 규칙이 아니라 **경로**로 판정한다 — 사본은 늘 새 이름을 달고 돌아오기 때문이다 + * (`ic_align_left` → `mindrecord_align_left`). + */ + @Test + fun `정렬 아이콘 벡터는 core ui 한 벌만 있다`() { + val strays = alignIconVectors().filterNot { it.path.contains(CORE_UI_DRAWABLE) } + + check(strays.isEmpty()) { + buildString { + appendLine("`core:ui` 밖에 정렬 아이콘 사본이 있다 (${strays.size}건).") + appendLine("$CORE_UI_RESOURCE_PREFIX 승격본을 참조한다 — 사본을 두면 alpha·스트로크가 조용히 갈린다 (#1404).") + appendLine() + strays.map { it.toRelativeString(AfternoteKonsistScope.projectRoot) }.sorted().forEach { appendLine(" $it") } + } + } + } + + /** + * 승격본이 실제로 있어야 한다. + * + * 위 검사는 「밖에 없다」만 보므로, 승격본까지 통째로 사라지면 둘 다 초록으로 통과한다. + */ + @Test + fun `core ui 승격본 세 벌이 실재한다`() { + val promoted = alignIconVectors().map { it.nameWithoutExtension }.sorted() + + check(promoted == EXPECTED_PROMOTED) { + "정렬 아이콘 승격본이 기대와 다르다. 기대=$EXPECTED_PROMOTED 실제=$promoted" + } + } + + /** + * 호출부는 승격본 이름으로만 정렬 아이콘을 집는다. + * + * 파일만 지우고 참조가 남으면 빌드가 깨져 바로 드러나지만, **새 사본을 다른 모듈에 만들고** + * 옛 이름으로 참조하는 경우는 위 두 검사가 잡은 뒤에도 이 검사가 이유를 짚어 준다. + */ + @Test + fun `호출부는 승격본 이름으로 정렬 아이콘을 참조한다`() { + val offenders = + AfternoteKonsistScope.files + .filter { file -> LEGACY_REFERENCE.containsMatchIn(file.text) } + .map { it.path.substringAfter(AfternoteKonsistScope.projectRoot.path + File.separator) } + + check(offenders.isEmpty()) { + buildString { + appendLine("승격 전 이름으로 정렬 아이콘을 참조하는 파일이 있다 (${offenders.size}건).") + appendLine("$CORE_UI_RESOURCE_PREFIX 로 바꾼다 (#1404).") + appendLine() + offenders.sorted().forEach { appendLine(" $it") } + } + } + } + + /** 모듈 `src` 아래 전수에서 정렬 아이콘 벡터를 찾는다. 스캔 뿌리는 [AfternoteKonsistScope] 와 같다. */ + private fun alignIconVectors(): List = + AfternoteKonsistScope + .scanRoots() + .flatMap { root -> root.walkTopDown().filter { it.isFile && it.isAlignIconVector() } } + + private fun File.isAlignIconVector(): Boolean = + extension == VECTOR_EXTENSION && parentFile?.name?.startsWith(DRAWABLE_DIR) == true && ALIGN_ICON_NAME.matches(nameWithoutExtension) + + private companion object { + /** `..._align_left` · `..._align_center` · `..._align_right` — 접두는 모듈마다 다르므로 열어 둔다. */ + val ALIGN_ICON_NAME = Regex("""^.*_align_(left|center|right)$""") + + /** 승격 전 이름으로 정렬 아이콘을 집는 호출부. `R.drawable.` 이 붙은 것만 본다. */ + val LEGACY_REFERENCE = Regex("""R\.drawable\.(?!core_ui_)\w*ic?_?align_(left|center|right)\b""") + + val EXPECTED_PROMOTED = listOf("core_ui_ic_align_center", "core_ui_ic_align_left", "core_ui_ic_align_right") + + val CORE_UI_DRAWABLE = "core${File.separator}ui${File.separator}src" + const val CORE_UI_RESOURCE_PREFIX = "core_ui_ic_align_*" + const val DRAWABLE_DIR = "drawable" + const val VECTOR_EXTENSION = "xml" + } +} diff --git a/konsist/src/test/kotlin/com/afternote/konsist/DailyNotificationScheduleKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/DailyNotificationScheduleKonsistTest.kt new file mode 100644 index 0000000000..b77c359e9e --- /dev/null +++ b/konsist/src/test/kotlin/com/afternote/konsist/DailyNotificationScheduleKonsistTest.kt @@ -0,0 +1,90 @@ +package com.afternote.konsist + +import com.lemonappdev.konsist.api.declaration.KoFileDeclaration +import org.junit.Test + +/** + * 데일리 알림 예약은 로그인 관찰자 한 곳에서만 한다 (#2146). + * + * 데일리 알림 문구는 계정의 기록을 재촉한다. 세션을 보지 않는 자리에서 예약을 넣으면 로그인하지 않은 + * 기기에도 매일 알림이 뜬다. #2146 이 그 경우였다. App Startup Initializer 가 기동마다 예약했고, + * Initializer 는 Hilt 에 닿지 못해 세션을 볼 수 없었다. + * + * 지금은 `DailyNotificationScheduleSynchronizer` 가 로그인 상태를 보고 예약과 취소를 함께 맡는다. + * 다른 자리에서 예약을 넣으면 그 관찰자의 취소와 어긋나고, 로그아웃한 기기에 예약이 다시 생긴다. + * + * 관찰자를 `GlobalApplication` 이 시작하는지도 본다. 시작 줄을 지워도 컴파일은 통과하고, 그러면 + * 로그인한 사용자도 알림을 못 받는다. + */ +class DailyNotificationScheduleKonsistTest { + @Test + fun `데일리 알림 예약은 로그인 관찰자만 부른다`() { + val callers = + AfternoteKonsistScope.productionFiles + .filter { SCHEDULE_REFERENCE.containsMatchIn(it.text.withoutComments()) } + .map { it.normalizedProjectPath() } + .toSet() + + check(OBSERVER_PATH in callers) { + "$OBSERVER_PATH 에서 scheduleDailyNotification 호출을 못 찾았다. 관찰자를 옮기거나 이름을 바꿨으면 " + + "이 가드를 함께 고칠 것. 못 찾은 채 초록이면 이 가드는 아무것도 안 본 것이다." + } + + val violations = (callers - OBSERVER_PATH).sorted() + check(violations.isEmpty()) { + buildString { + appendLine("로그인 관찰자 밖에서 데일리 알림을 예약한다 (${violations.size}건).") + appendLine("세션을 보지 않고 예약하면 로그인하지 않은 기기에도 매일 알림이 뜬다 (#2146).") + appendLine() + violations.forEach { appendLine(" $it") } + appendLine() + appendLine("예약·취소는 $OBSERVER_PATH 가 로그인 상태를 보고 한다.") + } + } + } + + @Test + fun `GlobalApplication 이 데일리 알림 관찰자를 시작한다`() { + val source = singleFile(APPLICATION_PATH).text.withoutComments() + val property = OBSERVER_PROPERTY.find(source)?.groupValues?.get(1) + + check(property != null) { + "$APPLICATION_PATH 에 $OBSERVER_CLASS 주입 필드가 없다. 관찰자를 시작하지 않으면 로그인한 사용자도 " + + "데일리 알림을 못 받는다 (#2146)." + } + check(Regex("""\b${Regex.escape(property)}\s*\.\s*observeLogin\s*\(""").containsMatchIn(source)) { + "$APPLICATION_PATH 가 $property.observeLogin() 을 부르지 않는다. 주입만 하고 시작하지 않으면 " + + "예약도 취소도 일어나지 않는다 (#2146)." + } + } + + private fun singleFile(projectPath: String): KoFileDeclaration { + val candidates = AfternoteKonsistScope.files.filter { it.normalizedProjectPath() == projectPath } + + check(candidates.size == 1) { + "$projectPath 가 ${candidates.size}건 잡혔다. 파일이 옮겨졌거나 사본이 섞였다.\n" + + candidates.joinToString("\n") { " " + it.path } + } + return candidates.single() + } + + private fun String.withoutComments(): String = replace(BLOCK_COMMENT, " ").replace(LINE_COMMENT, " ") + + private fun KoFileDeclaration.normalizedProjectPath(): String = projectPath.replace('\\', '/').trimStart('/') + + private companion object { + const val OBSERVER_CLASS = "DailyNotificationScheduleSynchronizer" + const val OBSERVER_PATH = + "app/src/main/java/com/afternote/afternote_fe/notification/$OBSERVER_CLASS.kt" + const val APPLICATION_PATH = "app/src/main/java/com/afternote/afternote_fe/GlobalApplication.kt" + + /** 호출과 함수 참조(`::scheduleDailyNotification`)를 잡고, 선언(`fun scheduleDailyNotification`)은 뺀다. */ + val SCHEDULE_REFERENCE = Regex("""(? + val file = + AfternoteKonsistScope.files.singleOrNull { it.path.endsWith("/$fileName") } + ?: error("$fileName 을 찾지 못했다 — 파일을 옮겼으면 이 가드도 함께 고칠 것 (#1713).") + // 주석에 적힌 예시가 실제 적용으로 세어지지 않게 본문만 본다 (#1713 리뷰). + CONTENT_PADDING.findAll(file.text.withoutComments()).count() + } + + check(actual == EXPECTED) { + buildString { + appendLine("FAB 하단 여백을 두는 자리가 달라졌다 (#1713).") + appendLine(" 기대: $EXPECTED") + appendLine(" 실제: $actual") + appendLine() + appendLine("줄었다면 그 목록의 마지막 항목이 FAB 뒤로 들어간다.") + appendLine("의도한 변경이면 EXPECTED 를 함께 고칠 것.") + } + } + } + + private companion object { + /** 블록·줄 주석 제거. 형제 가드(`ScanScopeKonsistTest`)와 같은 관용구다. */ + private fun String.withoutComments(): String = replace(BLOCK_COMMENT, " ").replace(LINE_COMMENT, " ") + + val BLOCK_COMMENT = Regex("/[*][\\s\\S]*?[*]/") + val LINE_COMMENT = Regex("//[^\n]*") + + /** `contentPadding = PaddingValues(bottom = AfternoteFabContentBottomPadding)` 한 줄. */ + val CONTENT_PADDING = + Regex("""contentPadding\s*=\s*PaddingValues\(\s*bottom\s*=\s*AfternoteFabContentBottomPadding\s*,?\s*\)""") + + /** 파일별 적용 지점 수. 일기 화면은 목록형·캘린더형 둘이라 2 다. */ + val EXPECTED = + mapOf( + "DiaryScreen.kt" to 2, + "DailyQuestionAnswerListScreen.kt" to 1, + ) + } +} diff --git a/konsist/src/test/kotlin/com/afternote/konsist/KdocNestedBlockCommentKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/KdocNestedBlockCommentKonsistTest.kt new file mode 100644 index 0000000000..0193b0e6c9 --- /dev/null +++ b/konsist/src/test/kotlin/com/afternote/konsist/KdocNestedBlockCommentKonsistTest.kt @@ -0,0 +1,97 @@ +package com.afternote.konsist + +import com.lemonappdev.konsist.api.declaration.KoFileDeclaration +import org.junit.Test + +/** + * KDoc 안에 블록 주석 여는 토큰(슬래시 뒤 별)을 두지 않는다 (#1889). + * + * Kotlin 의 블록 주석은 **중첩된다.** KDoc 본문에 «별 둘·슬래시·별» 로 이어지는 경로 글롭을 적으면 + * 그 안의 «슬래시·별» 이 새 주석을 열고, 처음 만나는 닫는 토큰은 안쪽 것만 닫는다 — 바깥 KDoc 은 + * 파일 끝까지 열린 채라 **그 아래 선언이 전부 사라진다.** 컴파일러는 «Unclosed comment» 한 줄만 내고, + * 다른 모듈에선 Dagger 가 「type could not be resolved」 로만 말해 증상과 원인이 파일 하나만큼 떨어진다. + * + * 이 파일 자체가 첫 실측이다 — 초판은 이 KDoc 에 글롭을 그대로 적었다가 그 에러로 컴파일이 죽었다. + * + * 문자열 리터럴 안의 토큰은 주석이 아니므로 먼저 걷어 낸 뒤 KDoc 만 훑는다. + */ +class KdocNestedBlockCommentKonsistTest { + @Test + fun `KDoc 안에서 블록 주석이 다시 열리지 않는다`() { + var kdocBlocks = 0 + val violations = + AfternoteKonsistScope.files.flatMap { file -> + val source = file.text.withoutStringLiterals() + kdocBlocks += countKdocBlocks(source) + nestedOpeners(source).map { line -> "${file.normalizedProjectPath()}:$line" } + } + + check(kdocBlocks > MINIMUM_KDOC_BLOCKS) { + "KDoc 블록이 ${kdocBlocks}개뿐이다 — 스캔 범위가 비었거나 파서가 깨졌다. 이 가드는 초록이어도 아무것도 안 본 것이다." + } + check(violations.isEmpty()) { + buildString { + appendLine("KDoc 안에서 블록 주석이 다시 열렸다 (${violations.size}건).") + appendLine("Kotlin 블록 주석은 중첩되므로 바깥 KDoc 이 닫히지 않아 그 아래 선언이 통째로 사라진다 — Dagger 는 «could not be resolved» 로만 말한다.") + appendLine() + violations.sorted().forEach { appendLine(" $it") } + appendLine() + appendLine("경로 글롭이면 별과 슬래시가 «슬래시·별» 순으로 붙지 않게 풀어 쓰거나 코드 스팬 밖으로 뺀다 (#1889).") + } + } + } + + /** + * KDoc 여는 토큰마다 깊이를 세며 걷는다. 안에서 여는 토큰을 또 만나 깊이가 2가 되는 순간이 위반이고, + * 그 줄 번호를 돌려준다. 바깥이 닫힌 뒤엔 다음 KDoc 부터 다시 센다. + */ + private fun nestedOpeners(source: String): List { + val lines = mutableListOf() + var index = source.indexOf(KDOC_OPEN) + while (index >= 0) { + var depth = 1 + var cursor = index + KDOC_OPEN.length + while (cursor < source.length - 1 && depth > 0) { + when { + source.startsWith(BLOCK_OPEN, cursor) -> { + depth += 1 + if (depth == 2) lines += source.lineNumberAt(cursor) + cursor += BLOCK_OPEN.length + } + + source.startsWith(BLOCK_CLOSE, cursor) -> { + depth -= 1 + cursor += BLOCK_CLOSE.length + } + + else -> { + cursor += 1 + } + } + } + index = source.indexOf(KDOC_OPEN, maxOf(cursor, index + KDOC_OPEN.length)) + } + return lines + } + + private fun countKdocBlocks(source: String): Int = Regex(Regex.escape(KDOC_OPEN)).findAll(source).count() + + private fun String.lineNumberAt(offset: Int): Int = 1 + substring(0, offset).count { it == '\n' } + + /** 원시 문자열·일반 문자열 리터럴을 공백으로 바꾼다 — 그 안의 토큰은 주석이 아니다. */ + private fun String.withoutStringLiterals(): String = replace(RAW_STRING, " ").replace(STRING, " ") + + private fun KoFileDeclaration.normalizedProjectPath(): String = projectPath.replace('\\', '/').trimStart('/') + + private companion object { + val KDOC_OPEN = "/" + "**" + val BLOCK_OPEN = "/" + "*" + val BLOCK_CLOSE = "*" + "/" + + /** 저장소의 KDoc 블록은 수천 개다 — 이 아래면 스캔이 비었다고 본다. */ + const val MINIMUM_KDOC_BLOCKS = 500 + + val RAW_STRING = Regex("\"\"\"[\\s\\S]*?\"\"\"") + val STRING = Regex("\"(?:[^\"\\\\\\n]|\\\\.)*\"") + } +} diff --git a/konsist/src/test/kotlin/com/afternote/konsist/LightSystemBarLockKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/LightSystemBarLockKonsistTest.kt new file mode 100644 index 0000000000..e0c0b1a297 --- /dev/null +++ b/konsist/src/test/kotlin/com/afternote/konsist/LightSystemBarLockKonsistTest.kt @@ -0,0 +1,38 @@ +package com.afternote.konsist + +import com.lemonappdev.konsist.api.verify.assertFalse +import org.junit.Test + +/** + * 시스템바가 라이트로 잠긴 채 남는지 (#1719). + * + * `AfternoteTheme` 이 라이트로 잠긴 동안 Compose 는 다크 기기에서도 `gray1`(#FAFAFA) 을 + * 상태바 밑까지 그린다. 무인자 `enableEdgeToEdge()` 는 `SystemBarStyle.auto` 라 그 기기에서 + * 흰 아이콘을 고르므로, 밝은 배경 위에 흰 시계·배터리가 얹혀 사라진다. + * + * 그래서 `app` 의 호출부는 [com.afternote.afternote_fe.enableLightEdgeToEdge] 하나로 모았다. + * 이 가드는 **무인자 호출이 다시 스며드는 것**을 막는다 — 새 Activity 를 만들 때 손이 먼저 + * 기억하는 형태가 그쪽이라서다. + * + * 다크 팔레트가 확정돼 잠금을 풀 때는 이 가드도 함께 지운다. 되돌릴 자리는 셋이다 — + * `AfternoteTheme` 의 `isDarkTheme` 기본값 · `enableLightEdgeToEdge()` · `themes.xml` 의 + * `windowLightStatusBar`. + */ +class LightSystemBarLockKonsistTest { + @Test + fun `app 은 무인자 enableEdgeToEdge 를 쓰지 않는다`() { + AfternoteKonsistScope + .files + .filter { file -> file.path.contains("/app/src/") } + .filterNot { file -> file.path.endsWith("/$HELPER_FILE") } + .assertFalse { file -> BARE_CALL.containsMatchIn(file.text) } + } + + private companion object { + /** 잠금을 실제로 거는 자리. 여기만 `enableEdgeToEdge` 를 인자와 함께 부른다. */ + const val HELPER_FILE = "LightEdgeToEdge.kt" + + /** `enableEdgeToEdge()` — 인자 없는 호출. 공백만 허용한다. */ + val BARE_CALL = Regex("""\benableEdgeToEdge\s*\(\s*\)""") + } +} diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MindRecordNavGraphWiringKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MindRecordNavGraphWiringKonsistTest.kt new file mode 100644 index 0000000000..6366639420 --- /dev/null +++ b/konsist/src/test/kotlin/com/afternote/konsist/MindRecordNavGraphWiringKonsistTest.kt @@ -0,0 +1,112 @@ +package com.afternote.konsist + +import com.lemonappdev.konsist.api.declaration.KoFileDeclaration +import org.junit.Test + +/** + * `mindRecordNavGraph` 의 «한 칸 뒤로» 매핑 회귀 가드 (#1562). + * + * #1311 이 단순 back 위임 5개를 `popBack` 하나로 합치면서, 자리마다 이름이 달라 + * (`onWriteSubmitSuccess`·`onWriteBack`·`onDraftListBack`) **이름 자체가 오배선을 드러내던 방어가 + * 사라졌다.** 이제 매핑 7줄이 모두 `() -> Unit` 이라 서로 바꿔 붙여도 컴파일이 통과한다. + * + * `onBackClick` 5줄은 계측이 실제로 태운다 — `MindRecordBackStackAndroidTest` 의 + * `screenBackButton_...` 이 각 화면의 뒤로가기를 눌러 `routeOf()` 로 복귀 route 를 대조한다. + * + * **`onSubmitSuccess` 2줄은 그 계측이 지나가지 않는다.** 제출 성공 경로라 서버 제출을 태워야 하고, + * 그 비용이 이 한 줄의 값을 넘는다(#1562 판정). 그래서 계측 대신 **매핑 그 자체를 소스에서** 본다. + * + * ### 이 가드가 잡는 것과 못 잡는 것 + * + * 잡는 것 — `onSubmitSuccess = actions::onNavigateToDraftList` 처럼 **다른 명령으로 바꿔 붙이는 것**. + * 실제로 그렇게 바꿔도 계측 3건이 전부 초록이라는 것을 #1466 리뷰에서 실측했다. + * + * 못 잡는 것 — `popBack` **구현**이 잘못되는 경우. 그건 계측이 본다(다섯 목적지에서 홈으로 복귀 + + * 백스택 잔재 0). 즉 이 가드는 「어느 명령에 붙었나」만, 계측은 「그 명령이 무엇을 하나」만 본다. + * + * 텍스트 검사라 `actions::popBack` 을 다른 표기(`{ actions.popBack() }`)로 적으면 헛짚는다. + * 그때는 이 가드가 아니라 표기를 맞추는 편이 낫다 — 매핑 7줄이 같은 모양이어야 한 눈에 대조된다. + */ +class MindRecordNavGraphWiringKonsistTest { + @Test + fun `제출 성공은 한 칸 뒤로 명령에 붙는다`() { + // 공유 진입점을 쓴다 — `Konsist.scopeFromProject()` 는 저장소 루트를 통째로 walk 해 + // 워크트리(`.claude/`·`.codex/`)의 다른 브랜치 사본까지 끌고 들어온다 (#1659). + // 실제로 이 가드를 처음 돌렸을 때 그 사본의 옛 매핑(`actions::onWriteSubmitSuccess`)이 + // 걸려 헛짚었다. `ScanScopeKonsistTest` 가 그 규칙을 강제한다. + val candidates = + AfternoteKonsistScope + .files + .filter { it.normalizedProjectPath() == NAV_GRAPH_PATH } + + check(candidates.size == 1) { + "$NAV_GRAPH_PATH 가 ${candidates.size}건 잡혔다 — 파일이 옮겨졌거나 사본이 섞였다.\n" + + candidates.joinToString("\n") { " " + it.path } + } + val source = candidates.single().text + + val bindings = SUBMIT_SUCCESS.findAll(source).map { it.groupValues[1].trim() }.toList() + + // **개수까지 못 박는다.** `isNotEmpty()` 면 두 줄 중 하나가 통째로 지워져도 남은 한 줄 + // 때문에 통과한다 — 두 화면의 콜백이 `= {}` 디폴트라 인자를 지워도 컴파일은 되고, 그 + // 화면의 «제출 후 이동» 만 조용히 사라진다 (#1562 리뷰). + check(bindings.size == WRITE_SCREEN_COUNT) { + "onSubmitSuccess 매핑이 ${bindings.size}건이다 — 데일리질문·일기 두 화면이라 $WRITE_SCREEN_COUNT 건이어야 한다.\n" + + "화면이 늘거나 줄었으면 WRITE_SCREEN_COUNT 와 아래 화면별 단언을 함께 고칠 것." + } + + // 화면별로도 본다 — 개수만 맞고 자리가 뒤바뀌는 경우를 가른다. + WRITE_SCREENS.forEach { screen -> + val screenBinding = screenBinding(source, screen) + check(screenBinding == EXPECTED_BINDING) { + "$screen 의 onSubmitSuccess 가 «$EXPECTED_BINDING» 이 아니라 «$screenBinding» 이다 (#1562)." + } + } + } + + /** `(` 부터 그 호출의 닫는 괄호까지에서 `onSubmitSuccess` 바인딩을 뽑는다. */ + private fun screenBinding( + source: String, + screen: String, + ): String? { + val start = source.indexOf("$screen(").takeIf { it >= 0 } ?: return null + var depth = 0 + var end = start + for (i in start until source.length) { + when (source[i]) { + '(' -> { + depth++ + } + + ')' -> { + depth-- + if (depth == 0) { + end = i + break + } + } + } + } + return SUBMIT_SUCCESS + .find(source.substring(start, end)) + ?.groupValues + ?.get(1) + ?.trim() + } + + // 다른 가드와 같은 관례 — konsist 가 주는 projectPath 를 OS 구분자·선행 슬래시 없이 맞춘다. + private fun KoFileDeclaration.normalizedProjectPath(): String = projectPath.replace('\\', '/').trimStart('/') + + private companion object { + const val NAV_GRAPH_PATH = + "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/" + + "presentation/navigation/MindRecordNavGraph.kt" + const val EXPECTED_BINDING = "actions::popBack" + + /** 제출 성공 경로를 갖는 작성 화면. 늘어나면 여기와 위 단언을 함께 고친다. */ + val WRITE_SCREENS = listOf("DailyQuestionWriteScreen", "DiaryWriteScreen") + val WRITE_SCREEN_COUNT = WRITE_SCREENS.size + + val SUBMIT_SUCCESS = Regex("""onSubmitSuccess\s*=\s*([^,\n]+)""") + } +} diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt new file mode 100644 index 0000000000..88993df1b6 --- /dev/null +++ b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt @@ -0,0 +1,482 @@ +package com.afternote.konsist + +import com.lemonappdev.konsist.api.declaration.KoClassDeclaration +import com.lemonappdev.konsist.api.declaration.KoFileDeclaration +import com.lemonappdev.konsist.api.declaration.KoParentDeclaration +import com.lemonappdev.konsist.api.provider.KoParentProvider +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * MVI 계약 회귀 가드 (#1801). + * + * 강제 없이 시작하면 반쪽 도입으로 파편화만 늘어난다 — 단일 `UiState` 노출은 32개 화면에 + * 정착했는데 그 아래 전이 경로는 화면마다 갈렸고, 진입점을 하나로 모은 화면은 51개 중 + * 1개뿐이었다. 규약을 문서로만 두면 같은 일이 반복된다. + * + * 규칙은 셋이다. + * - **A.** `MviViewModel` 상속체는 `MutableStateFlow`·`MutableSharedFlow`·`Channel` 을 직접 + * 선언하지 않는다. 별도 상태 홀더를 두면 전이가 `reduce` 밖으로 새고 베이스를 도입한 의미가 + * 사라진다. `Channel` 금지는 #1502 가 세우려는 규칙과 같은 방향이라 **두 규칙을 하나로 합친다** — + * 따로 두면 나중에 서로를 덮는다. + * - **B.** `feature/…/presentation` 의 ViewModel 은 `MviViewModel` 을 상속한다. 전환 전 + * 49개는 [PENDING_MVI_MIGRATION] 예외로 두고, 모듈 전환 이슈가 닫힐 때마다 뺀다. + * - **C.** `MviIntent`·`ReducerEvent` 를 직접 구현하는 화면 계약 타입은 `sealed interface` 다. + * 열려 있으면 `when` 이 전수 분기를 보장하지 못해 진입점 단일화의 이득이 사라진다. + * + * 기준 문서는 `docs/convention/mvi.md` 다. + * + * ### `Effect` 규칙이 없는 이유 + * 이슈 본문의 규칙 C 는 `Effect` 를 함께 적었지만, #1800 이 베이스에서 `Effect` 타입 + * 파라미터를 뺐다(3타입). 일회성 신호는 `UiState` 의 nullable 필드로 나르는 것이 이 저장소의 + * 정본 규약(#228)이라, 존재하지 않는 타입에 규칙을 걸지 않는다. + * + * ### [PENDING_MVI_MIGRATION] + * 해소된 항목이 남으면 **경고만** 낸다 — 전환 PR 과 목록 정리 PR 이 서로를 깨뜨리지 않게 + * 하는 이 저장소의 관례다([ResponseDtoContractKonsistTest] #933 전례). + */ +class MviContractKonsistTest { + @get:Rule + val fixture = TemporaryFolder() + + @Test + fun `MviViewModel 상속체는 상태 홀더를 직접 선언하지 않는다`() { + val violations = stateHolderDeclarations(AfternoteKonsistScope.productionFiles) + + check(violations.isEmpty()) { + buildString { + appendLine("MviViewModel 상속체가 상태 홀더를 직접 선언한다 (${violations.size}건).") + appendLine("전이가 reduce 밖으로 새면 베이스를 상속한 의미가 없다 (#1800).") + appendLine() + violations.sorted().forEach { appendLine(" $it") } + appendLine() + appendLine("상태는 dispatch(ReducerEvent) 로만 바꾸고, 일회성 신호는 UiState 의") + appendLine("nullable 필드로 흡수한다 (#228). 기준: docs/convention/mvi.md") + } + } + } + + @Test + fun `feature presentation 의 ViewModel 은 MviViewModel 을 상속한다`() { + val violations = unmigratedViewModels(AfternoteKonsistScope.productionFiles) - PENDING_MVI_MIGRATION + + check(violations.isEmpty()) { + buildString { + appendLine("MviViewModel 을 상속하지 않는 ViewModel 이 새로 생겼다 (${violations.size}건).") + appendLine("진입점이 화면마다 갈리면 소비 함수를 빠뜨려도 컴파일이 통과한다.") + appendLine() + violations.sorted().forEach { appendLine(" $it") } + appendLine() + appendLine("MviViewModel 를 상속해 onIntent 하나로 모은다.") + appendLine("기준: docs/convention/mvi.md") + } + } + } + + @Test + fun `해소된 항목은 경고로 알린다`() { + val stale = PENDING_MVI_MIGRATION - unmigratedViewModels(AfternoteKonsistScope.productionFiles) + if (stale.isEmpty()) return + + println( + buildString { + appendLine("[경고] PENDING_MVI_MIGRATION 에 지금 소스에 없는 항목이 남아 있다 (${stale.size}건).") + appendLine("전환이 끝났거나(그러면 지운다), 아직 도착하지 않은 선등재다(PENDING_ARRIVAL_*).") + appendLine("목록에서 지워야 다음 미전환 ViewModel 이 이 자리에 숨지 않는다.") + appendLine("목록이 비면 규칙 B 의 예외 자체를 지운다.") + appendLine() + stale.sorted().forEach { appendLine(" $it") } + }, + ) + } + + @Test + fun `Intent 와 ReducerEvent 는 sealed interface 다`() { + val violations = openContractTypes(AfternoteKonsistScope.productionFiles) + + check(violations.isEmpty()) { + buildString { + appendLine("MviIntent·ReducerEvent 를 sealed interface 밖에서 구현한다 (${violations.size}건).") + appendLine("열려 있으면 onIntent·reduce 의 when 이 전수 분기를 보장하지 못한다.") + appendLine() + violations.sorted().forEach { appendLine(" $it") } + appendLine() + appendLine("화면 계약은 `sealed interface XxxIntent : MviIntent` 로 닫고,") + appendLine("각 갈래를 그 안의 data object·data class 로 둔다. 기준: docs/convention/mvi.md") + } + } + } + + @Test + fun `규칙 A - 베이스를 상속한 곳의 상태 홀더만 잡는다`() { + val root = fixture.newFolder("rule-a") + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/HolderViewModel.kt", + """ + package sample + + class HolderViewModel : MviViewModel(SampleUiState()) { + private val _extra = MutableStateFlow(0) + private val signals = Channel() + private val plain = 0 + } + """, + ) + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/LegacyViewModel.kt", + """ + package sample + + class LegacyViewModel : ViewModel() { + private val _uiState = MutableStateFlow(SampleUiState()) + } + """, + ) + + val violations = stateHolderDeclarations(fixtureFiles(root)) + + // 전환 전 ViewModel 의 상태 홀더는 규칙 A 의 대상이 아니다 — 그건 규칙 B 가 본다. + assertEquals(2, violations.size) + check(violations.any { it.endsWith("HolderViewModel._extra") }) { violations.toString() } + check(violations.any { it.endsWith("HolderViewModel.signals") }) { violations.toString() } + } + + @Test + fun `규칙 B - feature presentation 의 미전환 ViewModel 만 잡는다`() { + val root = fixture.newFolder("rule-b") + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/LegacyViewModel.kt", + """ + package sample + + class LegacyViewModel : ViewModel() + """, + ) + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/MigratedViewModel.kt", + """ + package sample + + class MigratedViewModel : MviViewModel(SampleUiState()) + """, + ) + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/BaseViewModel.kt", + """ + package sample + + abstract class BaseViewModel : ViewModel() + """, + ) + root.writeKotlin( + "app/src/main/kotlin/app/AppViewModel.kt", + """ + package app + + class AppViewModel : ViewModel() + """, + ) + + val violations = unmigratedViewModels(fixtureFiles(root)) + + // app 은 규칙 B 의 대상이 아니고(#1809 몫), 추상 베이스와 전환된 것도 위반이 아니다. + assertEquals(setOf("sample.LegacyViewModel"), violations) + } + + @Test + fun `규칙 C - 마커를 직접 구현하는 열린 타입만 잡는다`() { + val root = fixture.newFolder("rule-c") + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/SampleContract.kt", + """ + package sample + + sealed interface SampleIntent : MviIntent { + data object Submit : SampleIntent + } + + interface LooseIntent : MviIntent + + class EventCarrier : ReducerEvent + + data object Dismiss : ReducerEvent + + sealed interface SampleEvent : ReducerEvent + """, + ) + + val violations = openContractTypes(fixtureFiles(root)) + + // sealed 하위 갈래는 마커가 아니라 자기 계약 타입을 구현하므로 대상이 아니다. + // 최상위 `data object` 는 KoObjectDeclaration 이라 classes() 에 안 들어오는데, + // `class EventCarrier` 와 계약상 같은 구멍이므로 함께 잡아야 한다. + assertEquals(3, violations.size) + check(violations.any { it.endsWith("interface LooseIntent") }) { violations.toString() } + check(violations.any { it.endsWith("class EventCarrier") }) { violations.toString() } + check(violations.any { it.endsWith("object Dismiss") }) { violations.toString() } + } + + @Test + fun `규칙 A·B - 중간 추상 베이스를 낀 상속도 MviViewModel 로 본다`() { + val root = fixture.newFolder("indirect-parents") + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/BaseFooViewModel.kt", + """ + package sample + + abstract class BaseFooViewModel(initial: S) : MviViewModel(initial) + """, + ) + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/FooViewModel.kt", + """ + package sample + + class FooViewModel : BaseFooViewModel(SampleUiState()) { + private val nav = Channel() + } + """, + ) + + val files = fixtureFiles(root) + + // 규칙 A — 직계만 보면 이 Channel 을 통째로 놓친다. + val holders = stateHolderDeclarations(files) + assertEquals(1, holders.size) + check(holders.single().endsWith("FooViewModel.nav")) { holders.toString() } + + // 규칙 B — 같은 이유로 정상 MVI ViewModel 을 「미전환」으로 헛짚지 않아야 한다. + assertEquals(emptySet(), unmigratedViewModels(files)) + } + + /** 규칙 B 위반 후보 — `feature/…/presentation` main 소스의 미전환 ViewModel FQN. */ + private fun unmigratedViewModels(files: List): Set { + val extendsMvi = mviViewModelSubclassTest(files) + return files + .filter { FEATURE_PRESENTATION_MAIN.containsMatchIn(it.normalizedProjectPath()) } + .flatMap { file -> + file + .classes() + .filter { it.name.endsWith(VIEW_MODEL_SUFFIX) } + .filterNot { it.hasAbstractModifier } + .filterNot(extendsMvi) + .map { "${file.packagee?.name}.${it.name}" } + }.toSet() + } + + /** 규칙 A 위반 — 베이스를 상속한 ViewModel 이 직접 든 상태 홀더. */ + private fun stateHolderDeclarations(files: List): List { + val extendsMvi = mviViewModelSubclassTest(files) + return files.flatMap { file -> + file + .classes() + .filter(extendsMvi) + .flatMap { declaration -> + declaration + .properties() + .filter { property -> STATE_HOLDER.containsMatchIn(property.text) } + .map { property -> "${file.normalizedProjectPath()} — ${declaration.name}.${property.name}" } + } + } + } + + /** 규칙 C 위반 — 마커를 직접 구현하는데 `sealed interface` 가 아닌 타입. */ + private fun openContractTypes(files: List): List = + files.flatMap { file -> + val openInterfaces = + file + .interfaces() + .filter { it.implementsMarker() } + .filterNot { it.hasSealedModifier } + .map { "${file.normalizedProjectPath()} — interface ${it.name}" } + val classes = + file + .classes() + .filter { it.implementsMarker() } + .map { "${file.normalizedProjectPath()} — class ${it.name}" } + // object 선언은 KoObjectDeclaration 이라 classes() 에 안 들어온다. 최상위 + // `data object Dismiss : ReducerEvent` 는 `class EventCarrier : ReducerEvent` 와 + // 계약상 같은 구멍이므로 선언 종류로 갈라 판정하지 않는다. + val objects = + file + .objects() + .filter { it.implementsMarker() } + .map { "${file.normalizedProjectPath()} — object ${it.name}" } + openInterfaces + classes + objects + } + + /** + * 상속 사슬을 **직접 걷는다.** + * + * 중간 추상 베이스를 하나 끼우면(`class Foo : BaseFoo`, `abstract class BaseFoo : MviViewModel`) + * 직계 판정으로는 규칙 A 의 대상에서 빠져 상태 홀더를 놓치고, 규칙 B 는 그 정상 ViewModel 을 + * 「미전환」으로 헛짚는다. + * + * **Konsist 의 `parents(indirectParents = true)` 로는 안 닫힌다** — 0.17.3 에서 이 스코프 + * 구성으로는 직계와 **같은 목록**을 돌려준다. 마커를 같은 스코프에 정의해 두고 재어도 그렇다. + * 그래서 스캔한 파일에서 이름→선언 색인을 만들어 사슬을 직접 따라간다. + */ + private fun mviViewModelSubclassTest(files: List): (KoClassDeclaration) -> Boolean { + val byName = files.flatMap { it.classes() }.associateBy { it.name } + + fun extends( + declaration: KoClassDeclaration, + seen: MutableSet, + ): Boolean { + if (!seen.add(declaration.name)) return false + return declaration.parents().any { parent -> + val parentName = parent.markerName() + parentName == MVI_VIEW_MODEL || byName[parentName]?.let { extends(it, seen) } == true + } + } + + return { extends(it, mutableSetOf()) } + } + + /** 가드마다 같은 형태로 두는 경로 정규화 — `projectPath` 는 OS 구분자와 선행 `/` 가 섞인다. */ + private fun KoFileDeclaration.normalizedProjectPath(): String = projectPath.replace('\\', '/').trimStart('/') + + private fun File.writeKotlin( + path: String, + content: String, + ) { + val target = File(this, path) + target.parentFile.mkdirs() + target.writeText(content.trimIndent()) + } + + private fun fixtureFiles(root: File): List = AfternoteKonsistScope.scanExternalDirectories(listOf(root)).files + + private companion object { + const val MVI_VIEW_MODEL = "MviViewModel" + const val VIEW_MODEL_SUFFIX = "ViewModel" + + /** 시작에 붙이지 않는다 — 회귀 fixture 는 저장소 밖 임시 디렉터리 밑에서 같은 구조를 만든다. */ + val FEATURE_PRESENTATION_MAIN = Regex("""feature/[^/]+/presentation/src/main/""") + + /** + * 상태 홀더 선언. 타입을 생략하고 `= MutableStateFlow(...)` 로 추론시키는 것이 이 저장소의 + * 관례라, 선언 타입만 보면 대부분을 놓친다. 그래서 선언 텍스트에서 찾는다. + */ + val STATE_HOLDER = Regex("""\b(MutableStateFlow|MutableSharedFlow|Channel)\s*[(<]""") + + /** #1804 가 뺀다. */ + private val ISSUE_1804_AFTERNOTE = + setOf( + "com.afternote.feature.afternote.presentation.AfternoteHostViewModel", + "com.afternote.feature.afternote.presentation.detail.AfternoteDetailViewModel", + "com.afternote.feature.afternote.presentation.editor.AfternoteEditorViewModel", + "com.afternote.feature.afternote.presentation.editor.memorial.AddSongViewModel", + "com.afternote.feature.afternote.presentation.editor.receiver.SelectReceiverViewModel", + "com.afternote.feature.afternote.presentation.home.AfternoteHomeViewModel", + "com.afternote.feature.afternote.presentation.receiver.afternotelist.ReceiverAfternoteHomeViewModel", + "com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel", + "com.afternote.feature.afternote.presentation.receiver.playlist.ReceiverMemorialPlaylistViewModel", + ) + + /** #1808 이 뺀다. */ + private val ISSUE_1808_HOME = + setOf( + "com.afternote.feature.home.presentation.HomeTabViewModel", + // #1666 이 receiver.presentation.home 에서 옮겼다 — 소속 이슈도 #1803 이 아니라 여기다. + "com.afternote.feature.home.presentation.receiver.ReceiverHomeViewModel", + ) + + /** #1807 이 뺀다. */ + private val ISSUE_1807_MINDRECORD = + setOf( + "com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionListViewModel", + "com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel", + "com.afternote.feature.mindrecord.presentation.viewmodel.DiaryListViewModel", + "com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewModel", + "com.afternote.feature.mindrecord.presentation.viewmodel.DraftListViewModel", + "com.afternote.feature.mindrecord.presentation.viewmodel.MemoriesCardViewModel", + "com.afternote.feature.mindrecord.presentation.viewmodel.MemorySpaceViewModel", + "com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordViewModel", + "com.afternote.feature.mindrecord.presentation.viewmodel.RecordDetailViewModel", + "com.afternote.feature.mindrecord.presentation.viewmodel.WeeklyReportViewModel", + ) + + /** + * 아직 develop 에 없다. #457(PR #1624, 승인 완료)이 들여오는 네 번째 onboarding ViewModel 이라, + * 이 가드가 먼저 머지되면 규칙이 생기기 전에 쓰인 그 PR 이 규칙 B 로 빨개진다. 머지 순서가 + * 어느 쪽이든 develop 이 red 가 되지 않도록 미리 등재한다 — 그때까지는 「해소된 항목」 경고로만 + * 남는다. 전환은 #1802 후속 몫이다. + */ + private val PENDING_ARRIVAL_ONBOARDING = + setOf( + "com.afternote.feature.onboarding.presentation.findaccount.FindPasswordViewModel", + ) + + /** #1803 이 뺀다. */ + private val ISSUE_1803_RECEIVER = + setOf( + "com.afternote.feature.receiver.presentation.deliveryverification.DeliveryVerificationFlowViewModel", + "com.afternote.feature.receiver.presentation.deliveryverification.DocumentUploadViewModel", + "com.afternote.feature.receiver.presentation.deliveryverification.IdentityVerificationViewModel", + "com.afternote.feature.receiver.presentation.deliveryverification.MasterKeyViewModel", + // 이 스택의 base 에는 아직 여기 있다. develop 은 #1666 으로 feature/home 으로 옮겼고 + // 그쪽 FQN 은 ISSUE_1808_HOME 에 있다 — 스택이 develop 을 들이면 이 줄을 지운다. + "com.afternote.feature.receiver.presentation.home.ReceiverHomeViewModel", + "com.afternote.feature.receiver.presentation.recordsbox.ReceivedRecordsViewModel", + "com.afternote.feature.receiver.presentation.recordsbox.SenderRegistrationViewModel", + "com.afternote.feature.receiver.presentation.senderdetail.SenderDetailViewModel", + ) + + /** #1805 가 뺀다. `Channel` 5곳 흡수(#1502)가 선행이다. */ + private val ISSUE_1805_SETTING = + setOf( + "com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel", + "com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel", + "com.afternote.feature.setting.presentation.delivery.DeliveryConditionViewModel", + "com.afternote.feature.setting.presentation.home.SettingViewModel", + "com.afternote.feature.setting.presentation.notification.PushNotificationViewModel", + "com.afternote.feature.setting.presentation.passkey.PassKeyViewModel", + "com.afternote.feature.setting.presentation.profile.ProfileEditViewModel", + "com.afternote.feature.setting.presentation.receiver.ReceiverEditViewModel", + "com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel", + ) + + /** #1806 이 뺀다. */ + private val ISSUE_1806_TIMELETTER = + setOf( + "com.afternote.feature.timeletter.presentation.viewmodel.DraftLetterViewModel", + "com.afternote.feature.timeletter.presentation.viewmodel.RecipientListViewModel", + "com.afternote.feature.timeletter.presentation.viewmodel.RecipientTimeLetterDetailViewModel", + "com.afternote.feature.timeletter.presentation.viewmodel.RecipientTimeletterViewModel", + "com.afternote.feature.timeletter.presentation.viewmodel.TimeLetterDetailViewModel", + "com.afternote.feature.timeletter.presentation.viewmodel.TimeLetterWriteViewModel", + "com.afternote.feature.timeletter.presentation.viewmodel.TimeletterViewModel", + ) + + /** + * 전환 전 46개 — onboarding 3개는 #1802 파일럿이 전환해 빠졌다. `app` 의 ViewModel 2개는 이 규칙의 대상이 아니라 목록에도 없다 — + * 규칙 B 가 `feature/…/presentation` 만 보기 때문이고, 그 2개는 #1809 가 처리한다. + * + * 목록이 비면 규칙 B 의 예외(`- PENDING_MVI_MIGRATION`)도 함께 지운다. + */ + val PENDING_MVI_MIGRATION = + PENDING_ARRIVAL_ONBOARDING + + ISSUE_1803_RECEIVER + + ISSUE_1804_AFTERNOTE + + ISSUE_1805_SETTING + + ISSUE_1806_TIMELETTER + + ISSUE_1807_MINDRECORD + + ISSUE_1808_HOME + } +} + +/** `MviViewModel` 처럼 타입 인자가 붙어도 이름만 남긴다. */ +private fun KoParentDeclaration.markerName(): String = name.substringBefore('<').trim() + +/** + * **직계 부모만 본다.** 간접까지 보면 `sealed interface XxxIntent : MviIntent` 의 하위 갈래 + * (`data object Submit : XxxIntent`)가 전부 「마커를 직접 구현」으로 잡혀 규칙 C 가 자기 처방을 + * 위반으로 신고한다 — fixture 로 확인했다. + */ +private fun KoParentProvider.implementsMarker(): Boolean = parents().any { it.markerName() in MVI_MARKERS } + +private val MVI_MARKERS = setOf("MviIntent", "ReducerEvent") diff --git a/konsist/src/test/kotlin/com/afternote/konsist/NoOpCallbackDefaultKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/NoOpCallbackDefaultKonsistTest.kt index f0ba672233..14b0e40811 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/NoOpCallbackDefaultKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/NoOpCallbackDefaultKonsistTest.kt @@ -28,7 +28,7 @@ import org.junit.Test * * ### [LEGACY_NO_OP_DEFAULT_FILES] * 가드 도입 시점(#1388)에 남아 있던 잔여 파일. 모듈 담당별 후속 PR 이 청소하며 목록에서 뺀다. - * afternote(#1388 본체) · receiver 는 청소 완료로 빠졌고, 남은 것은 mindrecord·home(#1540) · timeletter(#1541) 몫이다. + * afternote(#1388 본체) · receiver · **mindrecord·home(#1540)** 은 청소 완료로 빠졌고, 남은 것은 timeletter(#1541) 몫이다. * 목록에 있는 파일은 위반이 **있어도 없어도 통과**한다(관대 판정) — 파일을 청소하는 PR 과 * 목록을 갱신하는 PR 의 머지 순서가 develop 을 red 로 만들지 않게 하기 위해서다 * ([ResponseDtoContractKonsistTest] 의 #933 전례와 같은 구조). 이미 청소된 항목이 목록에 @@ -137,31 +137,11 @@ class NoOpCallbackDefaultKonsistTest { val WHITESPACE = Regex("""\s""") - /** home — #1388 모듈 몫 후속 PR 이 청소한다. */ - private val HOME = - setOf( - "feature/home/presentation/WeeklySummaryGrid.kt", - ) + /** home — #1540 에서 청소 완료. */ + private val HOME = emptySet() - /** mindrecord — #1388 모듈 몫 후속 PR 이 청소한다. */ - private val MINDRECORD = - setOf( - "feature/mindrecord/presentation/component/DailyQuestionListCard.kt", - "feature/mindrecord/presentation/component/DiaryCard.kt", - "feature/mindrecord/presentation/component/DiaryComponent.kt", - "feature/mindrecord/presentation/component/KeyBoardToolBar.kt", - "feature/mindrecord/presentation/component/MemoriesCard.kt", - "feature/mindrecord/presentation/component/TodayQuestionCard.kt", - "feature/mindrecord/presentation/component/WeeklyReportReviewCard.kt", - "feature/mindrecord/presentation/component/WriteTextField.kt", - "feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt", - "feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt", - "feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt", - "feature/mindrecord/presentation/screen/sender/DiaryScreen.kt", - "feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt", - "feature/mindrecord/presentation/screen/sender/DraftListScreen.kt", - "feature/mindrecord/presentation/screen/sender/HomeScreen.kt", - ) + /** mindrecord — #1540 에서 청소 완료. */ + private val MINDRECORD = emptySet() /** timeletter — #1388 모듈 몫 후속 PR 이 청소한다 (TimeLetterWriteScreen 은 #778 과 조정). */ private val TIMELETTER = diff --git a/konsist/src/test/kotlin/com/afternote/konsist/PresentationLayerDependencyKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/PresentationLayerDependencyKonsistTest.kt index eab211e88d..46f6c5b65e 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/PresentationLayerDependencyKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/PresentationLayerDependencyKonsistTest.kt @@ -1,5 +1,6 @@ package com.afternote.konsist +import com.lemonappdev.konsist.api.declaration.KoFileDeclaration import com.lemonappdev.konsist.api.ext.list.withPackage import org.junit.Test @@ -15,10 +16,16 @@ import org.junit.Test * `core:datastore` 의존까지 지운 의도적 계층 이행이었는데 setting 만 빠졌다. 이후 아무 * 검사도 이를 알리지 않았다. * - * ### 검사 대상은 main 소스뿐이다 - * 테스트 소스는 `UserRepositoryImpl` 을 직접 조립하는 자리가 남아 있는데(#930 과 얽혀 - * `internal` 로 닫지 못하는 그 조립이다), 그건 프로덕션 계층 우회와 성격이 다르다. - * 한 번에 막으면 #930 이 선행돼야 하므로 1차는 프로덕션 소스로 한정한다. + * ### 테스트 소스도 본다 (#1898, #1432 의 2차) + * 1차는 프로덕션 소스로 한정했다 — 테스트 소스가 `UserRepositoryImpl` 을 직접 조립하는 자리가 남아 + * 있었고(#930 과 얽혀 `internal` 로 닫지 못하던 그 조립), 그것을 함께 막으려면 #930 이 선행돼야 했다. + * #930 이 닫히고 마지막 직접 조립도 PR #1595 에서 사라져 이제 테스트 소스에도 같은 규칙을 건다. + * + * 테스트가 다른 모듈의 구현을 직접 조립하면 그 구현의 생성자·visibility·DI 조립이 **테스트 사정에 + * 묶인다** — PR #1595 첫 리비전이 app androidTest·feature:setting 테스트의 직접 조립 때문에 + * `UserRepositoryImpl` 을 public·수동 조립으로 남겼던 것이 그 실사례다. 프로덕션은 테스트에 맞추지 + * 않는다. 테스트는 자기 층에서 한다 — 같은 모듈이면 구현을 조립하고, 다른 모듈이면 testFixtures 의 + * Fake 나 Hilt 그래프를 쓴다. 그래서 `core:data`·`core:datastore` **자신의** 테스트만 대상에서 뺀다. * * ### [KNOWN_LAYER_BYPASSES] * 도입 시점에 남아 있던 잔여. 수복은 담당 이슈 몫이고 이 가드는 「다음에 또 생겨도 모른다」 @@ -44,6 +51,23 @@ class PresentationLayerDependencyKonsistTest { } } + @Test + fun `테스트 소스도 다른 모듈의 data 구현을 직접 참조하지 않는다`() { + val violations = testSourceLayerBypasses() + + check(violations.isEmpty()) { + buildString { + appendLine("테스트 소스가 다른 모듈의 data 계층 구현을 직접 참조한다 (${violations.size}건).") + appendLine("테스트가 구현을 직접 조립하면 그 구현의 생성자·visibility·DI 조립이 테스트 사정에 묶인다 —") + appendLine("프로덕션은 테스트에 맞추지 않는다 (#1898).") + appendLine() + violations.sorted().forEach { appendLine(" $it") } + appendLine() + appendLine("테스트는 자기 층에서 한다 — 같은 모듈이면 구현을 조립하고, 다른 모듈이면 testFixtures 의 Fake 나 Hilt 그래프를 쓴다.") + } + } + } + @Test fun `해소된 항목은 경고로 알린다`() { val stale = KNOWN_LAYER_BYPASSES - layerBypasses() @@ -70,13 +94,30 @@ class PresentationLayerDependencyKonsistTest { /** * 프로덕션 소스의 presentation 파일. [AfternoteKonsistScope.productionFiles] 가 test 소스셋을 빼, - * 테스트 소스의 조립 코드가 섞이지 않는다. + * 테스트 소스는 [testSourceLayerBypasses] 가 따로 본다. */ private fun presentationFiles() = AfternoteKonsistScope .productionFiles .withPackage("com.afternote..presentation..") + /** + * 테스트 소스셋 파일 중 `core:data`·`core:datastore` **자신의** 것은 뺀다 — 자기 구현을 조립하는 + * 자리다. 그 밖(feature·app·core 나머지)의 test·androidTest·testFixtures 가 구현 패키지를 import 하면 위반이다. + */ + private fun testSourceLayerBypasses(): Set = + AfternoteKonsistScope + .testFiles + .filterNot { file -> DATA_LAYER_MODULE_PREFIXES.any { file.normalizedProjectPath().startsWith(it) } } + .flatMap { file -> + file.imports + .map { it.name } + .filter { FORBIDDEN_DATA_IMPORT.matches(it) } + .map { "${file.normalizedProjectPath()}: $it" } + }.toSet() + + private fun KoFileDeclaration.normalizedProjectPath(): String = projectPath.replace('\\', '/').trimStart('/') + private companion object { /** * `core:datastore` 전부와 `core:data` 의 구현 패키지. @@ -89,11 +130,14 @@ class PresentationLayerDependencyKonsistTest { Regex("""^com\.afternote\.core\.datastore\..*$|^com\.afternote\.core\.data\.repoimpl\..*$""") /** - * #635 (setting 4건 중 3번) 이 수복한다. `PassKeyViewModel` 이 `UserProfileRepository` + * #635 (setting 4건 중 3번) 이 수복한다. `PassKeyViewModel` 이 `UserProfileCacheRepository` * 를 건너뛰고 `UserProfileDataSource` 를 직접 주입받는다. setting 은 다른 담당이라 * 이 PR 에서 고치지 않는다. */ val KNOWN_LAYER_BYPASSES = setOf("PassKeyViewModel: com.afternote.core.datastore.UserProfileDataSource") + + /** 구현이 사는 모듈 — 이 모듈들의 테스트는 자기 구현을 조립하는 것이 맞다. */ + val DATA_LAYER_MODULE_PREFIXES = listOf("core/data/", "core/datastore/") } } diff --git a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt index b51b77e408..ad93465dcf 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt @@ -30,7 +30,9 @@ import java.io.File * 3. 테스트 소스가 해당 FQN을 직접 import하거나 같은 패키지에서 이름을 참조한다. * * member·constructor의 최소 visibility는 Kotlin 의미 분석 없이는 동명이인과 생성 코드 사용을 - * 안전하게 구분할 수 없다. 그 경계는 #1671~#1677과 코드 리뷰가 담당한다. + * 안전하게 구분할 수 없다. 그 경계는 #1671~#1677과 코드 리뷰가 담당한다. 단 **PR 이 새로 넣은** + * 멤버 함수가 main 참조 없이 테스트만 참조하는 경우는 Repository Quality 의 + * `validate-test-only-production-declarations.mjs` 가 PR diff 단위로 막는다 (#1895). */ class ProductionVisibilityKonsistTest { @get:Rule @@ -281,76 +283,51 @@ class ProductionVisibilityKonsistTest { } private companion object { - val ISSUE_1671_PLATFORM = - setOf( - "app/src/main/java/com/afternote/afternote_fe/messaging/AfternoteFirebaseMessagingService.kt::class FcmNotificationContent [internal]", - "app/src/main/java/com/afternote/afternote_fe/messaging/AfternoteFirebaseMessagingService.kt::object FcmNotificationContentResolver [internal]", - "app/src/main/java/com/afternote/afternote_fe/messaging/AfternoteFirebaseMessagingService.kt::object FcmNotificationIdentity [internal]", - "build-logic/src/main/kotlin/BuildFingerprint.kt::function debugVersionNameSuffix [implicit-public]", - "build-logic/src/main/kotlin/BuildFingerprint.kt::function shortCommitSha [implicit-public]", - "build-logic/src/main/kotlin/BuildFingerprint.kt::property UNKNOWN_COMMIT_MARKER [implicit-public]", - "build-logic/src/main/kotlin/VersionCode.kt::property DEFAULT_AFTERNOTE_VERSION_CODE [implicit-public]", - "build-logic/src/main/kotlin/VersionCode.kt::property MAX_PLAY_VERSION_CODE [implicit-public]", - ) + /** #1671 이 8건을 전부 `private` 로 줄였다. 형제 이슈들이 각자 자기 목록을 비우는 중이라 자리는 남긴다. */ + val ISSUE_1671_PLATFORM = emptySet() + /** + * #1672 잔여분 — `PROFILE_ICON_TEST_TAG` 를 참조하는 테스트가 **다른 모듈**(feature:afternote, + * feature:receiver presentation)에 있어 core 안에서 닫히지 않는다. 좁히려면 그 모듈들의 + * 테스트를 함께 고쳐야 하므로 담당 모듈 이슈로 넘긴다. + */ val ISSUE_1672_CORE = setOf( - "core/data/src/main/java/com/afternote/core/data/mapper/delivery/DeliveryConditionMapper.kt::function toDto [implicit-public]", - "core/network/src/main/kotlin/com/afternote/core/network/di/NetworkModule.kt::class SlowEndpointCallFactory [internal]", - "core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt::class TokenReissueFailureException [internal]", - "core/ui/src/main/kotlin/com/afternote/core/ui/ViewModeSwitcher.kt::property VIEW_MODE_INDICATOR_TEST_TAG [internal]", - "core/ui/src/main/kotlin/com/afternote/core/ui/ViewModeSwitcher.kt::property VIEW_MODE_PILL_TEST_TAG [internal]", - "core/ui/src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt::function DatePickerContent [implicit-public]", - "core/ui/src/main/kotlin/com/afternote/core/ui/popup/ErrorPopup.kt::function AfternoteErrorPopupContent [internal]", - "core/ui/src/main/kotlin/com/afternote/core/ui/popup/Popup.kt::function PopupContent [internal]", "core/ui/src/main/kotlin/com/afternote/core/ui/topbar/HomeTopBar.kt::property PROFILE_ICON_TEST_TAG [implicit-public]", ) + /** + * 남은 항목은 MVI 전환(#1804)이 다시 열 화면 seam 이라 #1817 이 그 뒤에 판정한다. + * #1673 의 나머지(mapper·test tag·Saver·목록 매퍼)는 해소돼 목록에서 빠졌다. + */ val ISSUE_1673_AFTERNOTE = setOf( "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailRoute.kt::function DeleteInProgressOverlay [internal]", - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreen.kt::property MEMORIAL_VIDEO_CARD_TEST_TAG [internal]", "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContent.kt::function EditorContent [internal]", "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorScreen.kt::function editorContentSignature [internal]", "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt::function toAfternoteEditorError [internal]", - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/mapper/EditorReceiverMapping.kt::function toAfternoteEditorReceiver [internal]", - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialVideoUpload.kt::property MEMORIAL_VIDEO_ADD_TEST_TAG [internal]", "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheet.kt::function EditorServiceSelectionSheetContent [internal]", - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheet.kt::function filterEditorServiceOptions [internal]", - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheet.kt::function serviceSelectionSheetTitleResOrNull [internal]", - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteEditorState.kt::property editorMessagesSaver [internal]", - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteTypeFilterRow.kt::property AFTERNOTE_CATEGORY_MORE_INDICATOR_TEST_TAG [internal]", - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteNavGraphTheme.kt::function AfternoteLightTheme [implicit-public]", - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeViewModel.kt::function toUiModel [internal]", - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/util/AfternoteDisplayRes.kt::function getIconResForType [implicit-public]", ) + /** + * 남은 항목은 MVI 전환(#1807)이 다시 열 Content seam 이라 #1818 이 그 뒤에 판정한다. + * #1674 의 나머지(매퍼 2건·화면 helper 3건·요약 helper 2건·주간 날짜 복원)는 해소돼 + * 목록에서 빠졌다 — 같은 계약은 공개 소유자(매퍼 진입점·ViewModel 상태·화면)로 본다. + */ val ISSUE_1674_MINDRECORD = setOf( - "feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt::function toDomainOrNull [implicit-public]", "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/hometab/HomeTabMindRecordLazyItems.kt::function MemoriesSectionContent [internal]", "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/memoryspace/MemorySpaceScreen.kt::function MemorySpaceContent [internal]", - "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt::function findOpenedRecord [internal]", "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt::function DailyQuestionWriteScreenContent [internal]", "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryScreen.kt::function DiaryListContent [internal]", "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt::function DiaryWriteScreenContent [internal]", - "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt::function emotionCardDescription [internal]", - "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt::function recordedSummaryHighlights [internal]", - "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt::function toDomainMessage [internal]", - "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/RecordDetailViewModel.kt::function firstImageUrl [internal]", - "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecordedDays.kt::function resolveDateInWeekOrNull [internal]", ) - val ISSUE_1675_ONBOARDING = - setOf( - "feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileScreen.kt::function handleProfileImagePickerResult [internal]", - ) + /** #1675·#1829 해소. Screen과 Content는 각자 파일에서 수명·렌더 책임을 갖는다. */ + val ISSUE_1675_ONBOARDING = emptySet() val ISSUE_1676_RECEIVER = setOf( - "feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt::class ReceiverListDecodingFailure [internal]", - "feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt::class ReceiverListMappingFailure [internal]", - "feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt::function toDomainOrNull [implicit-public]", "feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadScreen.kt::function DocumentUploadScreenContent [internal]", "feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationEmailScreen.kt::function IdentityVerificationEmailScreenContent [internal]", "feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyScreen.kt::function MasterKeyScreenContent [internal]", @@ -364,6 +341,21 @@ class ProductionVisibilityKonsistTest { "feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreen.kt::function collectTextBlockContents [internal]", ) + /** + * #1789 가 배선하면 해소된다. + * + * 비밀번호 찾기 3화면과 그 ViewModel 은 #457 에서 구현됐지만, 그래프 등록은 Nav3 + * 이관(#1698) 뒤로 미뤘다 — Nav2 로 먼저 배선하면 같은 32줄을 두 번 쓴다. 그동안 + * 프로덕션 참조가 없어 여기 실린다. private 축소는 답이 아니다 (파일 밖 소비가 곧 생긴다). + */ + val ISSUE_1789_ONBOARDING_PENDING_WIRING = + setOf( + "feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordCompleteScreen.kt::function FindPasswordCompleteScreen [implicit-public]", + "feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordResetScreen.kt::function FindPasswordResetScreen [implicit-public]", + "feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordScreen.kt::function FindPasswordScreen [implicit-public]", + "feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindPasswordViewModel.kt::class FindPasswordViewModel [internal]", + ) + /** #1671~#1677이 줄인다. 신규와 stale 항목 모두 실패해 목록은 정확한 현재 부채다. */ val LEGACY_TEST_DRIVEN_VISIBILITY = ISSUE_1671_PLATFORM + @@ -372,7 +364,8 @@ class ProductionVisibilityKonsistTest { ISSUE_1674_MINDRECORD + ISSUE_1675_ONBOARDING + ISSUE_1676_RECEIVER + - ISSUE_1677_TIMELETTER + ISSUE_1677_TIMELETTER + + ISSUE_1789_ONBOARDING_PENDING_WIRING val LEGACY_VISIBLE_FOR_TESTING_FILES = setOf( @@ -389,18 +382,18 @@ class ProductionVisibilityKonsistTest { "app/src/main/java/com/afternote/afternote_fe/notification/" + "NotificationIntentContract.kt::class NotificationEntrySource [internal]" to "NotificationEntryRequest.source의 내부 계약 타입", - "core/ui/src/main/kotlin/com/afternote/core/ui/button/" + - "AfternoteRadioGroup.kt::function AfternoteRadioGroup [implicit-public]" to - "core:ui README에 문서화된 공용 컴포넌트", + "app/src/main/java/com/afternote/afternote_fe/update/" + + "ForceUpdateGate.kt::class ForceUpdatePrompt [implicit-public]" to + "ForceUpdateGate.prompt 가 노출하고 MainActivity 가 타입 추론으로 소비", + "core/ui/src/main/kotlin/com/afternote/core/ui/popup/" + + "AfternoteActionMenu.kt::class ActionMenuItem [implicit-public]" to + "AfternoteActionMenu 파라미터와 editDeleteActionMenuItems 반환값을 외부 feature가 타입 추론 소비", "feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/" + "ListItem.kt::class Account [implicit-public]" to "ListItem.account가 노출하는 도메인 타입", "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/" + "presentation/editor/memorial/MemorialMediaSourceState.kt::class MemorialMediaSourceState [internal]" to "다른 프로덕션 파일이 반환값을 타입 추론으로 소비", - "feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/" + - "OnboardingTermsScreen.kt::class TermsType [implicit-public]" to - "OnboardingTermsScreen 콜백이 노출하는 화면 계약 타입", "feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/" + "ReceiverAfternoteDto.kt::class ReceivedMemorialVideoDto [implicit-public]" to "ReceivedPlaylistDto.memorialVideo가 노출하는 직렬화 계약 타입", diff --git a/konsist/src/test/kotlin/com/afternote/konsist/ReceiverHomeResourceKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/ReceiverHomeResourceKonsistTest.kt index 019e0c3311..d88cbccb48 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/ReceiverHomeResourceKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/ReceiverHomeResourceKonsistTest.kt @@ -19,7 +19,7 @@ class ReceiverHomeResourceKonsistTest { fun `수신자 홈은 다른 feature 의 R 을 참조하지 않는다`() { AfternoteKonsistScope .files - .withPackage("com.afternote.feature.receiver.presentation.home..") + .withPackage("com.afternote.feature.home.presentation.receiver..") .assertFalse { file -> file.imports.any { import -> FOREIGN_FEATURE_RESOURCE.matches(import.name) } || // import 만 보면 FQN 참조가 그대로 빠져나간다 — 이 패키지의 screenshotTest 가 @@ -30,12 +30,12 @@ class ReceiverHomeResourceKonsistTest { } private companion object { - /** 수신자 자신이 아닌 feature 의 `R` (또는 그 하위 참조). */ + /** 수신자 홈이 사는 `home` 모듈이 아닌 feature 의 `R` (또는 그 하위 참조). #1462 로 모듈이 바뀌었다. */ val FOREIGN_FEATURE_RESOURCE = - Regex("""^com\.afternote\.feature\.(?!receiver\.)[a-z]+\.presentation\.R(\..*)?$""") + Regex("""^com\.afternote\.feature\.(?!home\.)[a-z]+\.presentation\.R(\..*)?$""") /** 같은 참조를 본문 어디서든 — FQN 으로 쓴 자리를 잡는다. 주석·문자열 오탐은 감수한다. */ val FOREIGN_FEATURE_RESOURCE_REFERENCE = - Regex("""com\.afternote\.feature\.(?!receiver\.)[a-z]+\.presentation\.R\.""") + Regex("""com\.afternote\.feature\.(?!home\.)[a-z]+\.presentation\.R\.""") } } diff --git a/konsist/src/test/kotlin/com/afternote/konsist/RootNavHostPredictiveBackKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/RootNavHostPredictiveBackKonsistTest.kt new file mode 100644 index 0000000000..d8d0885bee --- /dev/null +++ b/konsist/src/test/kotlin/com/afternote/konsist/RootNavHostPredictiveBackKonsistTest.kt @@ -0,0 +1,135 @@ +package com.afternote.konsist + +import com.lemonappdev.konsist.api.declaration.KoFileDeclaration +import org.junit.Test + +/** + * 루트 `NavHost` 의 predictive back pop 전환 결선 가드 (#1869). + * + * 이 저장소는 네비게이션 엔진을 둘 쓴다. Nav3 로컬 스택은 `predictivePopTransitionSpec` 을 안 넘겨도 + * 라이브러리 기본값이 앞 화면을 줄여 주지만, **Nav2 루트 `NavHost` 의 pop 기본값은 fade 라 앞 화면이 + * 제자리에서 흐려지기만 한다** — 그래서 진행 중 두 화면이 같은 크기로 겹쳐 보였다(#1869 실기기 재현). + * + * 어긋난 축이 「화면이 어떻게 보이는가」 하나라서 기존 자동 검사가 전부 지나간다. + * `PredictiveBackNavigationTest`(5건)는 백스택 상태만 보고, Robolectric 은 프레임을 돌리지 않는다. + * `popEnterTransition`·`popExitTransition` 두 인자는 **기본값이 있어 지워도 컴파일이 통과하고**, + * 그대로 #1869 이전 상태로 되돌아간다. 그 되돌림을 소스에서 본다. + * + * ### 이 가드가 잡는 것과 못 잡는 것 + * + * 잡는 것 — 두 인자 중 하나라도 사라지는 것, 다른 값으로 갈아 끼우는 것, core/ui 의 공용 상수를 + * 두고 app 안에서 따로 정의한 값을 넘기는 것(import 까지 본다), 그리고 core/ui 쪽 상수가 + * 이름을 바꾸거나 사라지는 것. + * + * 못 잡는 것 — 상수의 **값**이 Nav3 기본값에서 벗어나는 것. 픽셀 판정이라 텍스트로는 셀 수 없고, + * 그 몫은 `docs/qa/predictive-back.md` 의 수동 절차가 받는다. + * + * 전진(push) 쪽 `enterTransition`·`exitTransition` 은 보지 않는다 — #1869 의 완료 조건 밖이라 + * 손대지 않았고, 없는 결선을 요구하면 이 가드가 그 자체로 오탐이 된다. + */ +class RootNavHostPredictiveBackKonsistTest { + @Test + fun `루트 NavHost 는 core ui 의 predictive back pop 전환을 넘긴다`() { + // 공유 진입점을 쓴다 — 워크트리 사본이 섞이면 남의 브랜치 소스로 헛짚는다 (#1659, ScanScopeKonsistTest). + val source = singleFile(APP_NAVIGATION_PATH).text + val arguments = rootNavHostArguments(source) + + check(arguments != null) { + "$APP_NAVIGATION_PATH 에서 루트 «NavHost(» 호출을 찾지 못했다 — 루트가 NavDisplay 로 바뀌었다면(#1702) " + + "이 가드를 predictivePopTransitionSpec 기준으로 옮긴다." + } + + EXPECTED_BINDINGS.forEach { (parameter, constant) -> + val binding = + Regex("""$parameter\s*=\s*([^,\n]+)""") + .find(arguments) + ?.groupValues + ?.get(1) + ?.trim() + + check(binding != null) { + "루트 NavHost 에 $parameter 인자가 없다 (#1869).\n" + + "기본값은 fade 라, 지우면 predictive back 진행 중 앞 화면이 줄지 않고 뒤 화면과 겹쳐 그려진다.\n" + + " $parameter = { $constant }," + } + check(binding.references(constant)) { + "루트 NavHost 의 $parameter 가 «$constant» 이 아니라 «$binding» 을 넘긴다 (#1869)." + } + // 같은 이름을 app 안에서 새로 정의해도 위 단언은 통과한다 — 출처가 core/ui 인지 import 로 본다. + check(source.contains("import $CONSTANT_PACKAGE.$constant")) { + "$APP_NAVIGATION_PATH 가 $CONSTANT_PACKAGE.$constant 을 import 하지 않는다 — " + + "두 엔진이 같은 값을 쓰도록 상수는 core/ui 한 곳에 둔다 (#1869)." + } + } + } + + @Test + fun `predictive back pop 전환은 core ui 가 소유한다`() { + val declared = singleFile(TRANSITIONS_PATH).properties().map { it.name }.toSet() + val missing = EXPECTED_BINDINGS.values.filterNot(declared::contains) + + check(missing.isEmpty()) { + "$TRANSITIONS_PATH 에 ${missing.joinToString()} 이 없다 — 루트 NavHost 와 #1702 의 NavDisplay 가 " + + "같은 값을 넘기려면 이 파일이 단일 출처여야 한다 (#1869)." + } + } + + private fun singleFile(projectPath: String): KoFileDeclaration { + val candidates = AfternoteKonsistScope.files.filter { it.normalizedProjectPath() == projectPath } + + check(candidates.size == 1) { + "$projectPath 가 ${candidates.size}건 잡혔다 — 파일이 옮겨졌거나 사본이 섞였다.\n" + + candidates.joinToString("\n") { " " + it.path } + } + return candidates.single() + } + + /** + * 루트 `NavHost(` 의 여는 괄호부터 짝이 맞는 닫는 괄호까지를 돌려준다. 후행 람다(destination 등록부 + * 40여 개)는 괄호 밖이라 들어오지 않는다. + * + * 이름은 식별자 경계로 찾는다 — 같은 파일에 `AfternoteNavHost(`·`ReceiverNavHost(` 처럼 끝이 + * 겹치는 호출이 여럿이라, 단순 `indexOf` 는 그중 무엇이 먼저 오느냐에 결과가 달라진다. + */ + private fun rootNavHostArguments(source: String): String? { + val open = NAV_HOST_CALL.find(source)?.range?.last ?: return null + var depth = 0 + for (index in open until source.length) { + when (source[index]) { + '(' -> { + depth++ + } + + ')' -> { + depth-- + if (depth == 0) return source.substring(open + 1, index) + } + } + } + return null + } + + /** 이름을 «식별자로» 참조하는지 — 부분 일치(`PredictiveBackPopEnterOverride`)를 배제한다. */ + private fun String.references(name: String): Boolean = + Regex("""(? usedPaths.none { it.endsWith(suffix) } } - if (stale.isEmpty()) return - - println( - buildString { - appendLine("[경고] LEGACY_STANDALONE_RADIO_FILES 에 이미 해소된 항목이 남아 있다 (${stale.size}건).") - appendLine("목록에서 지워야 다음 위반이 이 자리에 숨지 않는다.") - appendLine("목록이 비면 CustomRadioButton 선언과 이 가드를 함께 걷는다 (#649).") - appendLine() - stale.sorted().forEach { appendLine(" $it") } - }, - ) - } - - private fun isLegacy(path: String): Boolean = LEGACY_STANDALONE_RADIO_FILES.any { path.endsWith(it) } - - /** `core:ui` 의 선언 자리를 뺀, 단품을 참조하는 파일들의 프로젝트 상대 경로. */ - private fun standaloneRadioUsages(): List = - guardedFiles() - .filter { file -> STANDALONE_RADIO_REFERENCE.containsMatchIn(file.text) } - .map { it.normalizedProjectPath() } - - private fun guardedFiles(): List = - AfternoteKonsistScope.files - .filter { file -> - val path = file.normalizedProjectPath() - GUARDED_MODULE_PREFIXES.any { path.startsWith(it) } && !path.startsWith(DECLARATION_PACKAGE_PREFIX) - } - - private fun KoFileDeclaration.normalizedProjectPath(): String = projectPath.replace('\\', '/').trimStart('/') - - private companion object { - /** - * 단품 참조 — import·호출·FQN 을 한 벌로 잡는다. - * - * import 만 보면 `com.afternote.core.ui.button.CustomRadioButton(...)` 처럼 FQN 으로 쓴 - * 자리가 빠져나간다 ([ReceiverHomeResourceKonsistTest] 와 같은 손버릇). 본문 전체를 본다. - * 주석·문자열 안의 언급까지 잡히는 오탐은 감수한다 — 이관을 권하는 문구는 그룹 API 이름을 - * 쓰면 되고, 단품 이름을 적어야 할 곳은 `core:ui` 안(제외 대상)뿐이다. - */ - val STANDALONE_RADIO_REFERENCE = Regex("""\bCustomRadioButton\b""") - - val GUARDED_MODULE_PREFIXES = listOf("app/", "core/", "feature/") - - /** 단품 선언과 그 존치 근거가 사는 자리 — 유일한 예외. */ - const val DECLARATION_PACKAGE_PREFIX = "core/ui/src/main/kotlin/com/afternote/core/ui/button/" - - /** - * setting — #1396 이 `AfternoteRadioGroup` 으로 이관하며 목록에서 뺀다. - * - * `RadioGroupCard` 는 카드 테두리에 `selectable` 을 두고 단품을 비인터랙티브 인디케이터로만 - * 쓰고 있어 오용은 아니지만, 단품 선언을 붙들고 있는 마지막 사용처다. - */ - private val SETTING: Set = - setOf( - "feature/setting/presentation/component/RadioGroupCard.kt", - ) - - /** 목록이 비면 `setOf()` 의 타입을 못 잡는 일이 없도록 원소 타입을 명시해 둔다. */ - val LEGACY_STANDALONE_RADIO_FILES: Set = SETTING - } -} diff --git a/konsist/src/test/kotlin/com/afternote/konsist/StartupInitializerHiltKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/StartupInitializerHiltKonsistTest.kt new file mode 100644 index 0000000000..5cfbea7a2a --- /dev/null +++ b/konsist/src/test/kotlin/com/afternote/konsist/StartupInitializerHiltKonsistTest.kt @@ -0,0 +1,68 @@ +package com.afternote.konsist + +import com.lemonappdev.konsist.api.declaration.KoClassDeclaration +import org.junit.Test + +/** + * `androidx.startup.Initializer` 구현체는 Hilt 엔트리포인트를 꺼내지 않는다 (#1889). + * + * `Initializer.create()` 는 `ContentProvider` 단계에서, 즉 `Application.onCreate()` **이전**에 돈다. + * 거기서 `EntryPointAccessors.fromApplication(...)` 으로 Hilt 그래프를 만지면 프로덕션에선 우연히 + * 되지만, 앱 모듈 androidTest 는 `HiltTestApplication` 이 아직 그래프를 세우기 전이라 **전멸한다** — + * 테스트마다 같은 자리에서 죽고 원인은 앱 코드 쪽에 있어 한참을 헤맨다. + * + * Hilt 가 필요한 시작 훅은 `GlobalApplication.onCreate` 에 둔다. `Initializer` 는 Hilt 없이 되는 + * 일(SDK 초기화)만 맡는다. 세션을 봐야 하는 일도 Hilt 가 필요하다. 데일리 알림 예약이 그래서 옮겨 갔다 (#2146). + */ +class StartupInitializerHiltKonsistTest { + @Test + fun `Initializer 구현체는 Hilt 엔트리포인트에 닿지 않는다`() { + val initializers = initializerClasses() + val seen = initializers.map { it.name }.toSet() + + check(KNOWN_INITIALIZERS.all { it in seen }) { + "알던 Initializer 를 못 찾았다: ${KNOWN_INITIALIZERS - seen}. 옮겼거나 지웠으면 이 목록을 함께 고칠 것 — 못 찾은 채 초록이면 이 가드는 아무것도 안 본 것이다." + } + + val violations = + initializers + .filter { initializer -> + val code = initializer.containingFile.text.withoutComments() + HILT_ENTRY_POINT_ACCESSORS.any { it in code } + }.map { "${it.containingFile.normalizedProjectPath()} — ${it.name}" } + + check(violations.isEmpty()) { + buildString { + appendLine("Initializer 안에서 Hilt 엔트리포인트를 꺼낸다 (${violations.size}건).") + appendLine("Initializer 는 Application.onCreate 이전에 돌아 앱 모듈 androidTest 가 전멸한다.") + appendLine() + violations.sorted().forEach { appendLine(" $it") } + appendLine() + appendLine("Hilt 가 필요한 시작 훅은 GlobalApplication.onCreate 로 옮긴다 (#1889).") + } + } + } + + private fun initializerClasses(): List = + AfternoteKonsistScope.productionFiles + .flatMap { it.classes() } + // konsist 는 부모 이름을 제네릭 인자까지 붙여 준다(`Initializer`) — 타입 인자 앞까지만 본다. + .filter { klass -> klass.parents().any { parent -> parent.name.substringBefore('<').substringAfterLast('.') == INITIALIZER } } + + private fun String.withoutComments(): String = replace(BLOCK_COMMENT, " ").replace(LINE_COMMENT, " ") + + private fun com.lemonappdev.konsist.api.declaration.KoFileDeclaration.normalizedProjectPath(): String = + projectPath.replace('\\', '/').trimStart('/') + + private companion object { + const val INITIALIZER = "Initializer" + + /** 지금 저장소의 Initializer 구현. 하나라도 안 보이면 스캔이 잘못된 것이다. */ + val KNOWN_INITIALIZERS = setOf("KakaoInitializer") + + val HILT_ENTRY_POINT_ACCESSORS = listOf("EntryPointAccessors", "EntryPoints.get") + + val BLOCK_COMMENT = Regex("""/\*[\s\S]*?\*/""") + val LINE_COMMENT = Regex("""//[^\n]*""") + } +} diff --git a/scripts/verify-play-release-bundle.sh b/scripts/verify-play-release-bundle.sh index 0fe16e3551..d1d1f9194d 100755 --- a/scripts/verify-play-release-bundle.sh +++ b/scripts/verify-play-release-bundle.sh @@ -56,15 +56,59 @@ strict_status=0 verification_output="$(jarsigner -verify -strict "${bundle_path}" 2>&1)" || strict_status=$? verify_jarsigner_result "${strict_status}" "${verification_output}" -if command -v shasum >/dev/null 2>&1; then - bundle_sha256="$(shasum -a 256 "${bundle_path}" | awk '{print $1}')" -elif command -v sha256sum >/dev/null 2>&1; then - bundle_sha256="$(sha256sum "${bundle_path}" | awk '{print $1}')" +sha256_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + echo "SHA-256 계산 도구(shasum 또는 sha256sum)가 없습니다." >&2 + return 1 + fi +} + +# 미주입 로컬 빌드도 Gradle 과 같은 기본값으로 대조한다. 값을 중복 선언하면 Gradle 의 +# 기본값이 바뀌었을 때 올바른 AAB 를 거절하므로 원본 상수를 읽되, 읽기 실패는 허용하지 않는다. +if [[ "${AFTERNOTE_VERSION_CODE+x}" == "x" ]]; then + expected_version_code="$(printf '%s' "${AFTERNOTE_VERSION_CODE}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" else - echo "SHA-256 계산 도구(shasum 또는 sha256sum)가 없습니다." >&2 + expected_version_code="$(sed -nE 's/^(private )?const val DEFAULT_AFTERNOTE_VERSION_CODE = ([0-9_]+)$/\2/p' \ + "${repo_root}/build-logic/src/main/kotlin/VersionCode.kt" | tr -d '_')" +fi +if [[ ! "${expected_version_code}" =~ ^[1-9][0-9]*$ ]] || + [[ ${#expected_version_code} -gt 10 ]] || [[ "${expected_version_code}" -gt 2100000000 ]]; then + echo "AFTERNOTE_VERSION_CODE 또는 Gradle 기본값은 1 이상 2100000000 이하의 10진 정수여야 합니다." >&2 + exit 1 +fi + +# preflight 가 검증한 jar 를 재사용할 수 있다. 로컬/Play CI 에 jar 가 없으면 같은 고정 +# 버전을 일회용 경로에 받고, 제공된 jar 도 실행 직전에 정본 SHA-256 과 대조한다. +readonly bundletool_version="1.18.3" +readonly bundletool_sha256="a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29" +bundletool_jar="${BUNDLETOOL_JAR:-}" +if [[ -z "${bundletool_jar}" ]]; then + bundletool_dir="$(mktemp -d "${TMPDIR:-/tmp}/afternote-bundletool.XXXXXX")" + trap 'rm -rf "${bundletool_dir}"' EXIT + bundletool_jar="${bundletool_dir}/bundletool-all-${bundletool_version}.jar" + curl --fail --location --silent --show-error \ + --output "${bundletool_jar}" \ + "https://github.com/google/bundletool/releases/download/${bundletool_version}/bundletool-all-${bundletool_version}.jar" +fi +if [[ ! -s "${bundletool_jar}" ]] || [[ "$(sha256_file "${bundletool_jar}")" != "${bundletool_sha256}" ]]; then + echo "bundletool SHA-256 불일치 또는 파일 누락: ${bundletool_jar}" >&2 + exit 1 +fi +if ! manifest_version_code="$(java -jar "${bundletool_jar}" dump manifest \ + --bundle="${bundle_path}" --module=base --xpath=/manifest/@android:versionCode)"; then + echo "bundletool 로 AAB manifest versionCode를 읽지 못했습니다." >&2 + exit 1 +fi +if [[ ! "${manifest_version_code}" =~ ^[1-9][0-9]*$ ]] || [[ "${manifest_version_code}" != "${expected_version_code}" ]]; then + echo "AAB manifest versionCode(${manifest_version_code})가 기대값(${expected_version_code})과 다릅니다." >&2 exit 1 fi +bundle_sha256="$(sha256_file "${bundle_path}")" signer_sha256="$( keytool -printcert -jarfile "${bundle_path}" | awk -F': ' '/SHA256:/{print $2; exit}' @@ -77,6 +121,7 @@ fi bundle_size="$(wc -c < "${bundle_path}" | tr -d '[:space:]')" printf 'AAB: %s\n' "${bundle_path}" +printf 'versionCode: %s\n' "${manifest_version_code}" printf '크기(bytes): %s\n' "${bundle_size}" printf 'AAB SHA-256: %s\n' "${bundle_sha256}" printf '서명 인증서 SHA-256: %s\n' "${signer_sha256}"