diff --git a/.maestro/README-matching.md b/.maestro/README-matching.md index 5c4fd53d..3d278642 100644 --- a/.maestro/README-matching.md +++ b/.maestro/README-matching.md @@ -44,17 +44,22 @@ The pre-existing-room prerequisite intentionally avoids creating unsolicited pee - The overlay and native `AndroidView(MapView)` fill the same Compose map slot. Target bounds use its actual `onSizeChanged` size, with no status-bar or screen-size correction. Targets are exposed only when the actual viewport starts at `(0,0)` and exactly matches that size. Nonzero origins, size mismatches, null projections, and partly off-map bitmap bounds produce no target; no offset conversion is guessed. Text-only labels do not provide a fabricated bitmap target. `Label.isShow` is checked but is not treated as post-collision visibility proof. SDK label creation, visibility configuration, and projection do not prove hit testing; the actual callback and destination assertions remain mandatory. - Camera movement start clears targets. Redraw, actual camera movement end, viewport change, and native map-size change refresh projections. Map start/error, removal of observed labels, or disposal clears targets. No ranks, layers, camera destinations, or collision behavior are changed. The same debug/armed boundary gates member/search row descriptions; a missing target fails rather than selecting a public row. - Permission denial and allowed-permission/null-location are different production branches. The shared denial helper accepts only the exact `permission_deny_button` and `permission_deny_and_dont_ask_again_button` variants, then asserts that the foreground-allow button is absent. It neither grants permission nor presses Back when no dialog exists. This suite asserts the real permission-denial guidance and absence of room entry. It does not claim null-location coverage. Register/search/list/map navigation uses explicit destinations, and room/profile navigation uses Back before accessing the previously hidden bottom tabs. Saved map browsing position remains available when location permission is denied. +- On a denied-permission restart, the flow requires an actual OS denial button and taps it unconditionally. It asserts that the permission controls are gone before waiting for map readiness and requiring the destination controls. Map or navigation readiness cannot satisfy the permission wait. A missing prompt fails instead of skipping denial. -## Safe map diagnostics +## Safe map and place-load diagnostics The collector preserves map-start evidence even when a flow later fails after SDK readiness. In [Maestro run `39012430640`](https://github.com/projects200/android/actions/runs/39012430640), all three matching flows passed `matching_map_ready`; their later failures are separate from map initialization. Readiness alone does not prove marker hit testing or chat navigation. Each flow writes `/matching-map-diagnostics.json` before fixture cleanup or the final app-data clear, including Maestro assertion, timeout, interruption, and runner failures. The optional shared `collect_diagnostics` hook does not replace `after_flow` verification. A collection failure adds only `feature-diagnostics-failed`; the original UI failure remains, and fixture cleanup still runs. Failed restoration still preserves private recovery state instead of clearing it. Other features without this hook keep their existing lifecycle. -The artifact schema is `{"schemaVersion":1,"status":"ok","finalState":"failed","events":[{"sequence":1,"attempt":1,"state":"loading"},{"sequence":2,"attempt":1,"state":"failed","category":"initialization","sdkErrorCode":1001}],"labelClicks":[]}`. `sequence` is the original evidence-array position, including gaps for private non-map events; `attempt` is the sequence of that MapView's actual start. Re-entry/recreation starts a new attempt. Map states are `loading`, `ready`, and `failed`; a failed attempt cannot later become ready. The existing `matching_map_loading` and `matching_map_ready` selectors retain their meaning, and SDK failure exposes `matching_map_failed` only inside the existing debug/armed boundary. Unarmed/release screens do not expose E2E tags or member IDs; failures show only the existing generic error text. +The artifact schema is `{"schemaVersion":1,"status":"ok","finalState":"failed","events":[{"sequence":1,"attempt":1,"state":"loading"},{"sequence":2,"attempt":1,"state":"failed","category":"initialization","sdkErrorCode":1001}],"labelClicks":[],"placeLoads":[]}`. `sequence` is the original evidence-array position, including gaps for private non-map events; `attempt` is the sequence of that MapView's actual start. Re-entry/recreation starts a new attempt. Map states are `loading`, `ready`, and `failed`; a failed attempt cannot later become ready. The existing `matching_map_loading` and `matching_map_ready` selectors retain their meaning, and SDK failure exposes `matching_map_failed` only inside the existing debug/armed boundary. Unarmed/release screens do not expose E2E tags or member IDs; failures show only the existing generic error text. `labelClicks` records actual SDK label callbacks as `{"sequence":3,"attempt":1,"kind":"cluster"}`. The fixed kinds are `cluster`, `place`, and `unknown`; they contain no tags, coordinates, or identities. A callback must belong to the current ready attempt. Dispatch uses the rendered label's own cluster/place tag, not a new coordinate comparison. `place` means the actual callback selected a primary-place label; an empty array does not establish a peer-marker callback. These diagnostics do not replace the exact peer/location row or room-ownership assertions. +`placeLoads` records actual list-usecase boundaries, not map readiness. A start is `{"sequence":4,"request":4,"state":"started","elapsedMs":0}`. A terminal event has the same `request`, its own original `sequence`, monotonic elapsed milliseconds, and state `success`, `error`, `cancelled`, or `exception`. Only `error` and `exception` include `category`: `network`, `mapping`, `auth`, `server`, or `unknown`. Known result codes and direct IOException types determine these categories; no raw code, error text, exception details, place count, or identity is exported. + +Overlapping list requests retain separate start sequences. Each request permits at most one terminal event. A start without a terminal means no terminal was recorded: the request may still be pending, or terminal evidence may have failed to write. It does not establish a network failure or success. Diagnostic write failures do not replace the usecase result; cancellation and thrown exceptions retain their existing propagation. Release and unarmed execution do not create request timers or records. These events never change `finalState`, list readiness, toast behavior, or timeouts. + Only the SDK's public integer `MapAuthException.getErrorCode()` and supported direct network exception types are classified: | Category | Public evidence / limitation | @@ -66,7 +71,7 @@ Only the SDK's public integer `MapAuthException.getErrorCode()` and supported di | `request` / `quota` | 400 / 429 respectively. | | `unknown` | -1, an unrecognized signed 32-bit SDK code, or another callback exception with no SDK code. No message/cause-based guesses. | -`finalState` is the latest attempt's observed state; label clicks do not change it. Valid evidence without map events yields `not_observed`; loading or no callback is not an authentication diagnosis. Missing/unreadable evidence, invalid schema/types/order, mismatched run identity, and a file over 1 MiB yield fixed `status` values `read_failed`, `invalid_evidence`, `stale_evidence`, and `size_limit` respectively, with `finalState:"unknown"` and empty `events`/`labelClicks`. At most 1000 source events are accepted. Booleans are not integer codes, attempts, or sequence numbers. Unknown fields/categories/kinds and inconsistent code/category pairs fail closed. +`finalState` is the latest map attempt's observed state; label clicks and place loads do not change it. Valid evidence without map events yields `not_observed`; loading or no callback is not an authentication diagnosis. Missing/unreadable evidence, invalid schema/types/order, mismatched run identity, and a file over 1 MiB yield fixed `status` values `read_failed`, `invalid_evidence`, `stale_evidence`, and `size_limit` respectively, with `finalState:"unknown"` and empty `events`/`labelClicks`/`placeLoads`. At most 1000 source events are accepted. Booleans are not integer codes, attempts, request IDs, durations, or sequence numbers. Unknown fields/categories/kinds and inconsistent code/category pairs fail closed. The collector uses bounded app-private `run-as` reads, checks the current run ID, and builds new allowlisted JSON objects. It never exports the original root/events, member/place/room identifiers, coordinates, URLs, tokens, exception messages/causes/stacks/classes, or logcat. Raw evidence remains app-private; the artifact is mode 0600 and contains no run ID. Existing logcat exclusion and raw Maestro temporary-file cleanup remain unchanged. Inspect this artifact alongside the existing sanitized flow artifacts. Successful collection alone does not prove Kakao registration, SDK readiness, or a successful device run. @@ -82,14 +87,14 @@ After a complete restoration check, feature cleanup retains its private snapshot No local build, test, formatter, linter, adb, Python, or Maestro command was executed for this patch. Preserved tests cover ownership, debug/armed evidence, SDK code classification, terminal failed readiness, delayed deletion/load state, stale reverse-geocoding responses, private append order, map recreation, 0600 permissions, and the 1000-event limit. The local `test_run_tests.py` transport regressions remain intact: reviewed failure categories, confidentiality, exact operation/feature/status scope, malformed reasons, and private-result retention. Existing regressions cover consumed-search-result replay, actual-label dispatch without coordinate matching, and safe label-click projection. New `MatchingMapTargetTest` cases exercise bitmap-centered bounds, absent/outside projections, clipped targets, invalid sizes, and unsupported viewport origins/sizes. They do not claim collision visibility or SDK input delivery. Existing Maestro validation/member-to-chat flows retain actual marker, exact peer/room, and denial-path assertions; their execution after this change remains pending. -The latest analyzed [Maestro run `39020625550`, attempt 2](https://github.com/projects200/android/actions/runs/39020625550/attempts/2) passed all 12 common flows and `matching-place-crud`. Its report contains 15 flows, two failures, and no skips. Validation and member-to-chat still failed. This patch changes neither GPU selection nor signing. +The latest analyzed [Maestro run `39114978487`, attempt 2](https://github.com/projects200/android/actions/runs/39114978487/attempts/2) executed all 23 flows: 21 passed and two matching flows failed. `smoke-tabs`, all five timer flows, and `matching-place-crud` passed. This patch changes neither GPU selection nor signing. The received evidence separates the two remaining failures: - **Place CRUD:** the full flow now passes, including rename and deletion. The flow, persisted ID/address assertions, and complete baseline verification remain unchanged. -- **Validation:** the first `permission_deny_button` dialog was denied successfully. After Add opened the search screen, Android presented `permission_deny_and_dont_ask_again_button`. The old helper skipped that button, and its foreground-allow absence assertion failed. Both condition and tap selectors now recognize the two exact denial variants. All helper callers retain their denied-path assertions; device verification of the new selector remains pending. -- **Member to chat:** SDK readiness and the old peer/location label-creation pattern passed. The externally configured point produced a real SDK `kind:"place"` callback and `places_loaded`, not `rv_members`. The tag dispatcher selected the actual primary-place route correctly. The artifacts did not distinguish overlap from bad calibration. The flow now locates a passive target at the actual SDK-created peer cluster's projected center, both after normal recentering and after denied-permission camera restoration. It still requires the genuine SDK peer route. This observation change is not a claim that the prior failure now passes. +- **Validation:** the initial place-list request still showed `places_loading` after its 20-second wait. The artifact contains a ready map but no list-request outcome. No app crash was reported. This patch adds request-boundary diagnostics; it does not claim a list-load fix or lengthen the timeout. +- **Member to chat:** the real marker, peer profile, room entry, and return to map succeeded. After restart with location denied, a new OS permission dialog covered the bottom navigation. The flow waited for a navigation node, then tried to tap it before handling the dialog. Both denied-restart flows now handle the prompt before map interaction and require actual map readiness afterward. Marker targeting no longer requires an external percentage point. The fixture still derives location from the authorized peer's actual server place and preserves all peer/catalog/bounds/room prerequisites. Missing or unsupported runtime projections fail closed. Private screenshots and actual callback/destination evidence remain necessary to diagnose any collision or touch-delivery failure; do not publish coordinates, identities, or private snapshots. -Required remote verification includes matching unit tests, `:app:testE2eUnitTest`, instrumentation evidence tests, Python transport/feature-hook tests, and authenticated `--suite matching`. New runtime evidence must name all three flows, prove persisted ID/address and room-peer checks, and show cleanup after a mid-flow failure. Recovery verification includes a failure after feature cleanup and a retry with the same request: snapshot/evidence must remain unchanged until final successful host clear, and baseline data must remain intact. Record the selected suite/device API: combined `ci` used API 34, while the matching descriptor declares API 30. Native/Compose size agreement, actual SDK tapping, camera-dependent target refresh, saved-camera restoration after denial, real catalog stability, and release/unarmed accessibility remain runtime checks. Missing prerequisites must fail explicitly; they must not cause skipped assertions or fabricated fixtures. +Required remote verification includes matching unit tests, `:app:testE2eUnitTest`, instrumentation evidence tests, Python transport/feature-hook tests, and authenticated `--suite matching`. The new diagnostics tests cover fixed error categories, release/unarmed gating, actual private file records and permissions, unfinished/interleaved requests, and malformed/stale/oversized public evidence. They have not been run locally. New runtime evidence must name all three flows, prove persisted ID/address and room-peer checks, and show cleanup after a mid-flow failure. Recovery verification includes a failure after feature cleanup and a retry with the same request: snapshot/evidence must remain unchanged until final successful host clear, and baseline data must remain intact. Record the selected suite/device API: combined `ci` used API 34, while the matching descriptor declares API 30. Native/Compose size agreement, actual SDK tapping, camera-dependent target refresh, saved-camera restoration after denial, real catalog stability, and release/unarmed accessibility remain runtime checks. Missing prerequisites must fail explicitly; they must not cause skipped assertions or fabricated fixtures. diff --git a/.maestro/flows/matching-member-to-chat.yaml b/.maestro/flows/matching-member-to-chat.yaml index 2e4e44be..3f70fdcc 100644 --- a/.maestro/flows/matching-member-to-chat.yaml +++ b/.maestro/flows/matching-member-to-chat.yaml @@ -66,19 +66,27 @@ tags: all: allow notifications: allow location: deny +# SDK 초기화 뒤 OS 권한 창이 늦게 뜰 수 있으므로 실제 거부 버튼을 확인한 뒤에만 진행합니다. - extendedWaitUntil: visible: - id: matching_nav_graph + id: ".*:id/permission_deny(?:_and_dont_ask_again)?_button" timeout: 60000 - tapOn: - id: matching_nav_graph -- runFlow: ../subflows/matching-deny-location-prompt.yaml + id: ".*:id/permission_deny(?:_and_dont_ask_again)?_button" +- assertNotVisible: + id: ".*:id/permission_allow_foreground_only_button" +- assertNotVisible: + id: ".*:id/permission_deny(?:_and_dont_ask_again)?_button" - runFlow: ../subflows/matching-dismiss-guides.yaml - extendedWaitUntil: visible: id: matching_map_ready timeout: 30000 -- runFlow: ../subflows/matching-deny-location-prompt.yaml +- assertVisible: + id: matching_map_ready +- assertVisible: + id: matching_nav_graph +- assertVisible: "운동 장소 목록" # 위치 권한을 거부해도 저장한 지도 카메라는 보존됩니다. 같은 peer·장소 마커를 다시 검증합니다. - extendedWaitUntil: visible: ${MATCHING_PEER_MARKER_PATTERN} diff --git a/.maestro/flows/matching-place-validation.yaml b/.maestro/flows/matching-place-validation.yaml index 51af28e8..9997a30e 100644 --- a/.maestro/flows/matching-place-validation.yaml +++ b/.maestro/flows/matching-place-validation.yaml @@ -62,19 +62,27 @@ tags: all: allow notifications: allow location: deny +# SDK 초기화 뒤 OS 권한 창이 늦게 뜰 수 있으므로 실제 거부 버튼을 확인한 뒤에만 진행합니다. - extendedWaitUntil: visible: - id: matching_nav_graph + id: ".*:id/permission_deny(?:_and_dont_ask_again)?_button" timeout: 60000 - tapOn: - id: matching_nav_graph -- runFlow: ../subflows/matching-deny-location-prompt.yaml + id: ".*:id/permission_deny(?:_and_dont_ask_again)?_button" +- assertNotVisible: + id: ".*:id/permission_allow_foreground_only_button" +- assertNotVisible: + id: ".*:id/permission_deny(?:_and_dont_ask_again)?_button" - runFlow: ../subflows/matching-dismiss-guides.yaml - extendedWaitUntil: visible: id: matching_map_ready timeout: 30000 -- runFlow: ../subflows/matching-deny-location-prompt.yaml +- assertVisible: + id: matching_map_ready +- assertVisible: + id: matching_nav_graph +- assertVisible: "운동 장소 목록" - tapOn: "운동 장소 목록" - extendedWaitUntil: visible: diff --git a/.maestro/scripts/features/matching.py b/.maestro/scripts/features/matching.py index d9e4a221..cdaa1d54 100644 --- a/.maestro/scripts/features/matching.py +++ b/.maestro/scripts/features/matching.py @@ -75,7 +75,7 @@ def after_flow(core, device, flow, variables, credentials, run_id): } -def _diagnostics(status, events=None, label_clicks=None): +def _diagnostics(status, events=None, label_clicks=None, place_loads=None): events = events or [] return { "schemaVersion": 1, @@ -83,6 +83,7 @@ def _diagnostics(status, events=None, label_clicks=None): "finalState": events[-1]["state"] if events else ("not_observed" if status == "ok" else "unknown"), "events": events, "labelClicks": label_clicks or [], + "placeLoads": place_loads or [], } @@ -104,6 +105,8 @@ def _map_diagnostics(raw, run_id): return _diagnostics("invalid_evidence") events = [] label_clicks = [] + place_loads = [] + pending_place_loads = set() attempt = None previous_state = None for sequence, event in enumerate(source, 1): @@ -111,6 +114,35 @@ def _map_diagnostics(raw, run_id): return _diagnostics("invalid_evidence") if event["type"] in {"places", "chat", "denied"}: continue + if event["type"] == "place_load": + state = event.get("state") + keys = {"type", "sequence", "request", "state", "elapsedMs"} + if type(state) is not str or state not in {"started", "success", "error", "cancelled", "exception"}: + return _diagnostics("invalid_evidence") + if state in {"error", "exception"}: + keys.add("category") + if (set(event) != keys + or type(event["sequence"]) is not int or event["sequence"] != sequence + or type(event["request"]) is not int or not 1 <= event["request"] <= MAX_EVENTS + or type(event["elapsedMs"]) is not int or not 0 <= event["elapsedMs"] <= 9223372036854775807): + return _diagnostics("invalid_evidence") + request = event["request"] + if state == "started": + if request != sequence or event["elapsedMs"] != 0: + return _diagnostics("invalid_evidence") + pending_place_loads.add(request) + elif request not in pending_place_loads: + return _diagnostics("invalid_evidence") + else: + pending_place_loads.remove(request) + safe = {"sequence": sequence, "request": request, "state": state, "elapsedMs": event["elapsedMs"]} + if state in {"error", "exception"}: + category = event["category"] + if type(category) is not str or category not in {"network", "mapping", "auth", "server", "unknown"}: + return _diagnostics("invalid_evidence") + safe["category"] = category + place_loads.append(safe) + continue if event["type"] == "map_label_click": if (set(event) != {"type", "sequence", "attempt", "kind"} or type(event["sequence"]) is not int or event["sequence"] != sequence @@ -153,7 +185,7 @@ def _map_diagnostics(raw, run_id): safe["sdkErrorCode"] = code events.append(safe) previous_state = state - return _diagnostics("ok", events, label_clicks) + return _diagnostics("ok", events, label_clicks, place_loads) def collect_diagnostics(core, device, flow, variables, credentials, run_id, output): diff --git a/.maestro/scripts/features/test_matching.py b/.maestro/scripts/features/test_matching.py index 8bb34530..8c1c3313 100644 --- a/.maestro/scripts/features/test_matching.py +++ b/.maestro/scripts/features/test_matching.py @@ -93,7 +93,7 @@ def collect(self, events=None, status="ok", raw=None, run_id=None): self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) result = json.loads(content) self.assertEqual(result["status"], status) - self.assertEqual(set(result), {"schemaVersion", "status", "finalState", "events", "labelClicks"}) + self.assertEqual(set(result), {"schemaVersion", "status", "finalState", "events", "labelClicks", "placeLoads"}) self.assertEqual(result["schemaVersion"], 1) return result @@ -106,6 +106,15 @@ def failed(self, code, category, sequence=2, attempt=1): "category": category, "sdkErrorCode": code, } + def place_load(self, sequence=1, request=None, state="started", elapsed_ms=0, category=None): + event = { + "type": "place_load", "sequence": sequence, "request": sequence if request is None else request, + "state": state, "elapsedMs": elapsed_ms, + } + if category is not None: + event["category"] = category + return event + def test_only_safe_map_fields_survive_private_fixture_evidence(self): result = self.collect([ {"type": "places", "places": [{"id": self.secret, "address": self.secret}]}, @@ -229,6 +238,120 @@ def test_stale_broken_or_oversized_evidence_exports_only_fixed_status(self): self.assertEqual(len(self.collect(events)["events"]), 1000) self.collect(events + [self.loading(1001)], status="invalid_evidence") + def test_place_load_start_without_terminal_remains_unfinished(self): + self.assertEqual(self.collect()["placeLoads"], []) + result = self.collect([self.place_load()]) + self.assertEqual(result["finalState"], "not_observed") + self.assertEqual(result["events"], []) + self.assertEqual(result["placeLoads"], [ + {"sequence": 1, "request": 1, "state": "started", "elapsedMs": 0}, + ]) + + def test_place_load_terminals_export_only_fixed_categories_and_elapsed_time(self): + for state, categories in ( + ("success", (None,)), ("cancelled", (None,)), + ("error", ("network", "mapping", "auth", "server", "unknown")), + ("exception", ("network", "mapping", "auth", "server", "unknown")), + ): + for category in categories: + with self.subTest(state=state, category=category): + result = self.collect([ + self.place_load(), + self.place_load(2, 1, state, 30123, category), + ]) + expected = {"sequence": 2, "request": 1, "state": state, "elapsedMs": 30123} + if category is not None: + expected["category"] = category + self.assertEqual(result["placeLoads"][-1], expected) + self.assertEqual(result["finalState"], "not_observed") + + def test_interleaved_place_requests_preserve_map_labels_and_global_sequence(self): + result = self.collect([ + self.loading(), + self.place_load(2), + {"type": "map", "sequence": 3, "attempt": 1, "state": "ready"}, + {"type": "places", "places": [{"id": self.secret, "latitude": self.secret}]}, + self.place_load(5), + self.place_load(6, 5, "error", 12, "unknown"), + {"type": "map_label_click", "sequence": 7, "attempt": 1, "kind": "cluster"}, + {"type": "chat", "peerId": self.secret, "roomId": self.secret}, + self.place_load(9, 2, "success", 50), + {"type": "map_label_click", "sequence": 10, "attempt": 1, "kind": "place"}, + self.place_load(11), + {"type": "denied", "locationId": self.secret}, + ]) + self.assertEqual(result["finalState"], "ready") + self.assertEqual(result["events"], [ + {"sequence": 1, "attempt": 1, "state": "loading"}, + {"sequence": 3, "attempt": 1, "state": "ready"}, + ]) + self.assertEqual(result["labelClicks"], [ + {"sequence": 7, "attempt": 1, "kind": "cluster"}, + {"sequence": 10, "attempt": 1, "kind": "place"}, + ]) + self.assertEqual(result["placeLoads"], [ + {"sequence": 2, "request": 2, "state": "started", "elapsedMs": 0}, + {"sequence": 5, "request": 5, "state": "started", "elapsedMs": 0}, + {"sequence": 6, "request": 5, "state": "error", "elapsedMs": 12, "category": "unknown"}, + {"sequence": 9, "request": 2, "state": "success", "elapsedMs": 50}, + {"sequence": 11, "request": 11, "state": "started", "elapsedMs": 0}, + ]) + + def test_place_load_fields_types_and_categories_fail_closed(self): + valid = self.place_load(2, 1, "error", 30, "unknown") + invalid = [ + {**valid, "message": self.secret}, {**valid, "errorCode": self.secret}, + {**valid, "cause": self.secret}, {**valid, "class": self.secret}, + {**valid, "url": self.secret}, {**valid, "placeId": self.secret}, + {**valid, "count": 2}, {**valid, "latitude": 37.5}, + {**valid, "category": self.secret}, {**valid, "category": []}, + {**valid, "category": None}, {**valid, "state": self.secret}, + {**valid, "state": []}, {**valid, "sequence": True}, + {**valid, "sequence": 2.0}, {**valid, "sequence": 3}, + {**valid, "request": True}, {**valid, "request": 1.0}, + {**valid, "request": 0}, {**valid, "request": 1001}, + {**valid, "elapsedMs": True}, {**valid, "elapsedMs": 1.0}, + {**valid, "elapsedMs": "30"}, {**valid, "elapsedMs": -1}, + {**valid, "elapsedMs": 9223372036854775808}, + self.place_load(2, 1, "error", 30), + self.place_load(2, 1, "exception", 30), + self.place_load(2, 1, "success", 30, "unknown"), + self.place_load(2, 1, "cancelled", 30, "unknown"), + ] + invalid.extend({key: value for key, value in valid.items() if key != missing} for missing in valid) + for event in invalid: + with self.subTest(event=event): + result = self.collect([self.place_load(), event], status="invalid_evidence") + self.assertEqual(result["events"], []) + self.assertEqual(result["labelClicks"], []) + self.assertEqual(result["placeLoads"], []) + self.assertEqual(result["finalState"], "unknown") + + def test_place_load_requires_one_start_and_at_most_one_terminal_per_request(self): + cases = [ + [self.place_load(1, 1, "success", 0)], + [self.place_load(elapsed_ms=1)], + [self.place_load(category="unknown")], + [self.place_load(request=2)], + [self.place_load(), self.place_load(2, 1)], + [self.place_load(), self.place_load(2, 2, "success", 1)], + [self.place_load(), self.place_load(2, 1, "success", 1), self.place_load(3, 1, "cancelled", 2)], + [self.place_load(), self.place_load(2, 1, "error", 1, "network"), self.place_load(3, 1, "success", 2)], + [self.loading(), self.place_load(2, 1, "exception", 1, "unknown")], + ] + for events in cases: + with self.subTest(events=events): + self.assertEqual(self.collect(events, status="invalid_evidence")["placeLoads"], []) + + def test_stale_malformed_and_limited_place_evidence_never_exports_partial_progress(self): + self.assertEqual(self.collect([self.place_load()], run_id=self.secret, status="stale_evidence")["placeLoads"], []) + malformed = json.dumps({"runId": self.run_id, "events": [self.place_load()]}).encode()[:-1] + self.assertEqual(self.collect(raw=malformed, status="invalid_evidence")["placeLoads"], []) + events = [self.place_load(index) for index in range(1, MATCHING.MAX_EVENTS + 1)] + self.assertEqual(len(self.collect(events)["placeLoads"]), MATCHING.MAX_EVENTS) + result = self.collect(events + [self.place_load(MATCHING.MAX_EVENTS + 1)], status="invalid_evidence") + self.assertEqual(result["placeLoads"], []) + def test_private_read_failure_is_fixed_and_does_not_export_exception(self): self.device.require.side_effect = RuntimeError(self.secret) with self.assertRaisesRegex(Failure, r"^matching-map-diagnostics-unavailable$"): @@ -237,7 +360,8 @@ def test_private_read_failure_is_fixed_and_does_not_export_exception(self): ) path = self.output / MATCHING.DIAGNOSTICS_FILE self.assertEqual(json.loads(path.read_text()), { - "schemaVersion": 1, "status": "read_failed", "finalState": "unknown", "events": [], "labelClicks": [], + "schemaVersion": 1, "status": "read_failed", "finalState": "unknown", + "events": [], "labelClicks": [], "placeLoads": [], }) self.assertNotIn(self.secret, path.read_text()) diff --git a/README.md b/README.md index 0f3a3fcb..102650eb 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,9 @@ ### 현재 지원 범위 -- 타이머의 로컬 저장과 달리, 현재 [운동 기록 Repository](data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt)의 목록·상세·횟수 조회는 서버를 호출합니다. Room 저장 계층이 있다는 이유만으로 앱 전체의 오프라인 지원을 의미하지는 않습니다. +- `offline_cache_enabled`가 켜지면 [운동 기록 Repository](data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt)는 캘린더·목록·상세를 서버에서 갱신한 뒤 Room 스냅샷으로 반환합니다. 꺼지면 기존 서버 조회를 유지합니다. 최초 진입·새 날짜/월 선택·화면의 **새로고침**은 서버를 조회합니다. 복귀·앱 전경 전환은 첫 시작을 제외하고 Room만 한 번 다시 읽으며 선택 날짜·월·스크롤을 보존합니다. 재연결은 화면이나 작성 중인 폼을 교체하지 않습니다. +- 오프라인에서는 저장된 결과(성공한 빈 목록 포함)를 읽고, 미조회 날짜는 네트워크 오류로 구분합니다. 온라인 갱신 실패는 저장된 화면을 유지하면서 한 번 알립니다. 계정별 로컬 ID와 동기화 대기 표시를 사용하며, 기존 생성·수정·삭제·이미지 작업은 여전히 온라인 전용입니다. 자신의 쓰기는 Room 목록·상세·날짜별 횟수를 함께 맞춥니다. 쓰기 후 캐시 우선 조회는 유효한 스냅샷이 없고 온라인일 때만 서버에서 다시 읽습니다. 서버 쓰기 성공 뒤 캐시 반영 실패는 성공 결과와 경고로 전달하며 쓰기를 재시도하지 않습니다. +- Room 2→3은 빈 조회의 계정별 메타데이터 테이블만 추가하는 마이그레이션입니다. 플래그 OFF 쓰기 성공이나 캐시 반영 실패는 계정별 무효 표시를 동기 저장합니다. 표시를 저장한 다음 ON 조회는 동기화 완료 목록·상세·횟수·빈 조회 메타데이터를 한 트랜잭션으로 버린 뒤 표시를 지웁니다. 정리 실패 시 오래된 스냅샷을 반환하지 않으며 대기 행과 타이머는 보존합니다. 무효 표시 저장 자체가 실패하면 플래그와 무관하게 서버 쓰기 성공에 `CACHE_ERROR` 경고를 붙이고 서버 쓰기를 재시도하지 않습니다. 이 저장 실패 때에는 오래된 캐시가 남을 수 있습니다. 이미지 업로드 후 상세 조회 실패도 캐시 경고를 전달하고 무효 표시 저장을 시도합니다. - CI 단위 테스트 명령에는 Kotlin/JVM 모듈인 `:domain:test`가 포함되어 있지 않습니다. 로컬 전체 단위 테스트 안내에는 이를 별도로 포함했습니다. Maestro도 현재 CI에서 실행하지 않습니다. - `dev` 워크플로우에는 배포를 push로 제한하는 조건이 없어 PR에도 배포 단계가 있습니다. 배포 구성은 공개 출시 여부나 실제 배포 성공을 뜻하지 않습니다. diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroFixtureCleaner.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroFixtureCleaner.kt index 59deb7be..5045df6c 100644 --- a/app/src/e2e/java/com/project200/undabang/e2e/MaestroFixtureCleaner.kt +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroFixtureCleaner.kt @@ -15,7 +15,7 @@ internal class MaestroFixtureCleaner today: LocalDate, ) { // 계정 확인은 호출자가 실제 registration-status 응답으로 완료한 뒤에만 들어옵니다 - val candidates = linkedSetOf() + val candidates = linkedSetOf() for (range in MaestroFixturePolicy.monthlyRanges(fixtureStartDate, today)) { val counts = readOrFail(exerciseRecordRepository.getExerciseCountByRange(range.start, range.endInclusive)) @@ -27,7 +27,7 @@ internal class MaestroFixtureCleaner if (count.count == 0) continue val records = readOrFail(exerciseRecordRepository.getExerciseRecordList(count.date)) for (record in records) { - if (record.recordId <= 0 || record.startTime.toLocalDate() != count.date) { + if (record.recordId.isBlank() || record.syncPending || record.startTime.toLocalDate() != count.date) { throw MaestroSetupFailure("fixture_read_failed") } if (MaestroFixturePolicy.ownsTitle(record.title)) { @@ -40,7 +40,7 @@ internal class MaestroFixtureCleaner // 모든 월의 조회가 성공한 뒤 삭제하며, 조회 후 일반 제목으로 바뀐 기록은 보존합니다 for (recordId in candidates) { val record = readOrFail(exerciseRecordRepository.getExerciseDetail(recordId)) - if (record.startedAt.toLocalDate() !in fixtureStartDate..today) { + if (record.syncPending || !record.canModify || record.startedAt.toLocalDate() !in fixtureStartDate..today) { throw MaestroSetupFailure("fixture_read_failed") } if (!MaestroFixturePolicy.ownsTitle(record.title)) continue @@ -52,7 +52,10 @@ internal class MaestroFixtureCleaner private fun readOrFail(result: BaseResult): T = when (result) { - is BaseResult.Success -> result.data + is BaseResult.Success -> { + if (result.refreshError != null || result.isCachedSnapshot) throw MaestroSetupFailure("fixture_read_failed") + result.data + } is BaseResult.Error -> throw MaestroSetupFailure("fixture_read_failed") } } diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroFixtureCleanerTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroFixtureCleanerTest.kt index ffcc871d..bef6ab9e 100644 --- a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroFixtureCleanerTest.kt +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroFixtureCleanerTest.kt @@ -34,19 +34,19 @@ class MaestroFixtureCleanerTest { item(4, "$title-extra"), ), ) - coEvery { exerciseRecordRepository.getExerciseDetail(1) } returns success(detail(title)) - coEvery { exerciseRecordRepository.getExerciseDetail(2) } returns success(detail("$title-edited")) + coEvery { exerciseRecordRepository.getExerciseDetail("1") } returns success(detail(title)) + coEvery { exerciseRecordRepository.getExerciseDetail("2") } returns success(detail("$title-edited")) coEvery { exerciseRecordRepository.deleteExerciseRecord(any()) } returns success(Unit) // When cleaner.clean(today, today) // Then - coVerify(exactly = 1) { exerciseRecordRepository.deleteExerciseRecord(1) } - coVerify(exactly = 1) { exerciseRecordRepository.deleteExerciseRecord(2) } - coVerify(exactly = 0) { exerciseRecordRepository.deleteExerciseRecord(3) } - coVerify(exactly = 0) { exerciseRecordRepository.deleteExerciseRecord(4) } - coVerify(exactly = 0) { exerciseRecordRepository.getExerciseDetail(3) } + coVerify(exactly = 1) { exerciseRecordRepository.deleteExerciseRecord("1") } + coVerify(exactly = 1) { exerciseRecordRepository.deleteExerciseRecord("2") } + coVerify(exactly = 0) { exerciseRecordRepository.deleteExerciseRecord("3") } + coVerify(exactly = 0) { exerciseRecordRepository.deleteExerciseRecord("4") } + coVerify(exactly = 0) { exerciseRecordRepository.getExerciseDetail("3") } } @Test @@ -56,7 +56,7 @@ class MaestroFixtureCleanerTest { coEvery { exerciseRecordRepository.getExerciseCountByRange(today, today) } returns success(listOf(ExerciseCount(today, 1))) coEvery { exerciseRecordRepository.getExerciseRecordList(today) } returns success(listOf(item(1, title))) - coEvery { exerciseRecordRepository.getExerciseDetail(1) } returns success(detail("my real exercise")) + coEvery { exerciseRecordRepository.getExerciseDetail("1") } returns success(detail("my real exercise")) // When cleaner.clean(today, today) @@ -123,18 +123,43 @@ class MaestroFixtureCleanerTest { coEvery { exerciseRecordRepository.getExerciseCountByRange(today, today) } returns success(listOf(ExerciseCount(today, 1))) coEvery { exerciseRecordRepository.getExerciseRecordList(today) } returns success(listOf(item(1, title))) - coEvery { exerciseRecordRepository.getExerciseDetail(1) } returns success(detail(title)) - coEvery { exerciseRecordRepository.deleteExerciseRecord(1) } returns serverError() + coEvery { exerciseRecordRepository.getExerciseDetail("1") } returns success(detail(title)) + coEvery { exerciseRecordRepository.deleteExerciseRecord("1") } returns serverError() // When, Then expectFailure("fixture_delete_failed") { cleaner.clean(today, today) } } + @Test + fun `오프라인 스냅샷이나 갱신 실패를 근거로 기록을 지우지 않는다`() = + runTest { + coEvery { exerciseRecordRepository.getExerciseCountByRange(today, today) } returns + BaseResult.Success(listOf(ExerciseCount(today, 1)), isCachedSnapshot = true) + expectFailure("fixture_read_failed") { cleaner.clean(today, today) } + coVerify(exactly = 0) { exerciseRecordRepository.deleteExerciseRecord(any()) } + + coEvery { exerciseRecordRepository.getExerciseCountByRange(today, today) } returns + BaseResult.Success(listOf(ExerciseCount(today, 1)), refreshError = BaseResult.Error("401", "인증 실패")) + expectFailure("fixture_read_failed") { cleaner.clean(today, today) } + coVerify(exactly = 0) { exerciseRecordRepository.deleteExerciseRecord(any()) } + } + + @Test + fun `상세에 대기 변경이 남아 있으면 서버 삭제를 실행하지 않는다`() = + runTest { + coEvery { exerciseRecordRepository.getExerciseCountByRange(today, today) } returns success(listOf(ExerciseCount(today, 1))) + coEvery { exerciseRecordRepository.getExerciseRecordList(today) } returns success(listOf(item(1, title))) + coEvery { exerciseRecordRepository.getExerciseDetail("1") } returns + success(detail(title).copy(syncPending = true, canModify = false)) + expectFailure("fixture_read_failed") { cleaner.clean(today, today) } + coVerify(exactly = 0) { exerciseRecordRepository.deleteExerciseRecord(any()) } + } + private fun item( id: Long, itemTitle: String, date: LocalDate = today, - ) = ExerciseListItem(id, itemTitle, "OTHER", date.atTime(9, 0), date.atTime(10, 0), null) + ) = ExerciseListItem(id.toString(), itemTitle, "OTHER", date.atTime(9, 0), date.atTime(10, 0), null) private fun detail(recordTitle: String) = ExerciseRecord(recordTitle, "", "OTHER", today.atTime(9, 0), today.atTime(10, 0), "", null) diff --git a/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt b/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt index 712368d5..40924e57 100644 --- a/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt +++ b/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt @@ -5,28 +5,22 @@ import com.project200.data.local.PreferenceManager import com.project200.data.local.UndabangDatabase import com.project200.data.local.dao.ExerciseCountDao import com.project200.data.local.dao.ExerciseRecordDao +import com.project200.data.local.entity.ExerciseCacheQueryEntity +import com.project200.data.local.entity.ExerciseCountEntity +import com.project200.data.local.entity.ExerciseListItemEntity +import com.project200.data.local.entity.SyncState import com.project200.data.mapper.toEntity import com.project200.data.mapper.toModel import com.project200.data.mapper.toSyncedEntity import com.project200.domain.model.ExerciseCount import com.project200.domain.model.ExerciseListItem import com.project200.domain.model.ExerciseRecord -import timber.log.Timber import java.time.LocalDate +import java.time.temporal.ChronoUnit import java.util.UUID import javax.inject.Inject -/** - * 운동 기록 캐시를 읽고 씁니다. - * - * 읽기는 한 번에 끝납니다. 화면이 지속 구독을 하지 않아서 한 번 그린 값이 - * 저절로 바뀌지 않습니다 - * - * 계정 조건을 여기서 한 번에 겁니다. 호출부가 memberId를 넘기지 않게 해서 - * 조건을 빠뜨린 쿼리가 나올 자리를 없앱니다 - * - * 로그아웃 상태에서는 읽기가 빈 값을, 쓰기가 아무 일도 하지 않습니다 - */ +/** 계정별 일회성 Room 스냅샷입니다. 네트워크 요청 전에 잡은 계정으로만 반영합니다. */ class ExerciseRecordLocalDataSource @Inject constructor( @@ -35,129 +29,287 @@ class ExerciseRecordLocalDataSource private val exerciseRecordDao: ExerciseRecordDao, private val preferenceManager: PreferenceManager, ) { + fun currentMemberId(): String? = preferenceManager.getMemberId() + + fun isCurrentMember(memberId: String?): Boolean = memberId != null && currentMemberId() == memberId + + fun invalidateCache(memberId: String) { + preferenceManager.setExerciseCacheInvalidated(memberId, true) + } + + /** 무효 표시를 지우기 전에 동기화 완료 캐시와 빈 조회 메타데이터를 함께 버립니다. */ + suspend fun prepareCache(memberId: String?) { + if (memberId == null || !isCurrentMember(memberId) || !preferenceManager.isExerciseCacheInvalidated(memberId)) return + database.withTransaction { + exerciseRecordDao.deleteSyncedList(memberId) + exerciseRecordDao.deleteSyncedDetails(memberId) + exerciseCountDao.deleteByMember(memberId) + exerciseRecordDao.deleteQueries(memberId) + } + preferenceManager.setExerciseCacheInvalidated(memberId, false) + } + suspend fun getCountsByRange( startDate: LocalDate, endDate: LocalDate, + memberId: String? = currentMemberId(), ): List { - val memberId = currentMemberId() ?: return emptyList() - return exerciseCountDao.getRange(memberId, startDate, endDate).map { it.toModel() } + if (!isCurrentMember(memberId)) return emptyList() + return database.withTransaction { + val counts = exerciseCountDao.getRange(memberId!!, startDate, endDate).associate { it.date to it.count } + val pending = exerciseRecordDao.getPendingInRange(memberId, startDate, endDate).groupBy { it.date } + (counts.keys + pending.keys).sorted().map { date -> + val rows = pending[date].orEmpty() + val created = rows.count { it.syncState == SyncState.CREATE_PENDING } + val deleted = rows.count { it.syncState == SyncState.DELETE_PENDING } + val delta = created - deleted + val count = maxOf((counts[date] ?: 0) + delta, rows.count { it.syncState != SyncState.DELETE_PENDING }) + ExerciseCount(date, count.coerceAtLeast(0), rows.any { it.syncState != SyncState.DELETE_PENDING }) + } + } + } + + suspend fun getCountsSnapshot( + startDate: LocalDate, + endDate: LocalDate, + memberId: String?, + ): List? { + if (!isCurrentMember(memberId)) return null + return database.withTransaction { + val counts = getCountsByRange(startDate, endDate, memberId) + val expectedDays = ChronoUnit.DAYS.between(startDate, endDate) + 1 + val hasVisibleRecords = counts.any { it.count > 0 || it.syncPending } + if (hasVisibleRecords || exerciseRecordDao.hasCountRange(memberId!!, startDate, endDate, expectedDays)) counts else null + } } - /** 서버 응답에는 운동한 날만 들어 있어서 구간을 비우고 다시 채웁니다 */ suspend fun replaceCountsByRange( startDate: LocalDate, endDate: LocalDate, counts: List, + memberId: String? = currentMemberId(), ) { - val memberId = currentMemberId() ?: return + if (!isCurrentMember(memberId)) return database.withTransaction { - exerciseCountDao.deleteRange(memberId, startDate, endDate) - exerciseCountDao.upsertAll(counts.map { it.toEntity(memberId) }) + if (!isCurrentMember(memberId)) return@withTransaction + exerciseCountDao.deleteRange(memberId!!, startDate, endDate) + exerciseCountDao.upsertAll(counts.filter { it.date in startDate..endDate }.map { it.toEntity(memberId) }) + var date = startDate + while (!date.isAfter(endDate)) { + exerciseRecordDao.saveQuery(ExerciseCacheQueryEntity(memberId, "count", date)) + date = date.plusDays(1) + } + } + } + + suspend fun getListByDate( + date: LocalDate, + memberId: String? = currentMemberId(), + ): List { + if (!isCurrentMember(memberId)) return emptyList() + return exerciseRecordDao.getListByDate(memberId!!, date).map { it.toModel() } + } + + suspend fun getListSnapshot( + date: LocalDate, + memberId: String?, + ): List? { + if (!isCurrentMember(memberId)) return null + return database.withTransaction { + val items = getListByDate(date, memberId) + if (items.isNotEmpty() || exerciseRecordDao.hasQuery(memberId!!, "list", date)) items else null } } - /** - * 서버 ID가 없는 행은 아직 화면이 다룰 수 없어 건너뜁니다. - * 화면이 로컬 ID로 기록을 식별하게 바뀌면 이 필터가 사라집니다 (#584) - */ - suspend fun getListByDate(date: LocalDate): List { - val memberId = currentMemberId() ?: return emptyList() - return exerciseRecordDao.getListByDate(memberId, date).mapNotNull { it.toModel() } - } - - /** - * 서버에서 지워진 기록이 남지 않도록 그날 목록을 갈아끼웁니다. - * - * 아직 못 올린 로컬 변경은 서버 값으로 덮지 않습니다. 대기 행을 남기는 것만으로는 - * 부족하고, 그 기록에 해당하는 서버 항목 자체를 반영 대상에서 빼야 합니다. - * 같은 localId에 SYNCED와 서버 값을 쓰면 행을 지우지 않았을 뿐 결과가 같습니다 - * - * 이미 캐시에 있던 기록은 localId를 이어 씁니다. 목록을 새로 받아도 - * 그 기록을 가리키던 화면과 이미지가 같은 행을 계속 가리킵니다 - */ + /** items는 원격 매퍼의 서버 ID 문자열입니다. 저장한 뒤 읽는 값은 localId로 바뀝니다. */ suspend fun replaceSyncedListByDate( date: LocalDate, items: List, + memberId: String? = currentMemberId(), ) { - val memberId = currentMemberId() ?: return + if (!isCurrentMember(memberId)) return database.withTransaction { - val pendingServerIds = - exerciseRecordDao.getPendingServerIds(memberId, date).toSet() - - // 대기 행이 걸린 기록은 로컬이 우선이라 서버 값을 반영하지 않습니다 - val incoming = items.withIndex().filterNot { it.value.recordId in pendingServerIds } - val incomingServerIds = incoming.map { it.value.recordId } - + if (!isCurrentMember(memberId)) return@withTransaction + val pendingServerIds = exerciseRecordDao.getAllPendingServerIds(memberId!!).toSet() + val incoming = items.withIndex().filterNot { it.value.recordId.toLong() in pendingServerIds } + val incomingServerIds = incoming.map { it.value.recordId.toLong() } val reusableLocalIds = if (incomingServerIds.isEmpty()) { emptyMap() } else { - exerciseRecordDao.getSyncedByServerIds(memberId, incomingServerIds) - .associate { it.serverId to it.localId } + exerciseRecordDao.getSyncedByServerIds(memberId, incomingServerIds).associate { it.serverId to it.localId } } - - // 서버 목록에서 빠진 기록은 상세도 함께 정리합니다 - val removedLocalIds = - exerciseRecordDao.getSyncedByDate(memberId, date) - .filterNot { it.serverId in incomingServerIds } - .map { it.localId } - - exerciseRecordDao.deleteSyncedListByDate(memberId, date) - if (removedLocalIds.isNotEmpty()) { - exerciseRecordDao.deleteDetailsByLocalIds(memberId, removedLocalIds) - } - if (incomingServerIds.isNotEmpty()) { - exerciseRecordDao.deleteSyncedOnOtherDates(memberId, date, incomingServerIds) - } - + val existing = exerciseRecordDao.getSyncedByDate(memberId, date).filterNot { it.serverId in pendingServerIds } + val removedLocalIds = existing.filterNot { it.serverId in incomingServerIds }.map { it.localId } + existing.forEach { exerciseRecordDao.deleteListItem(memberId, it.localId) } + if (removedLocalIds.isNotEmpty()) exerciseRecordDao.deleteDetailsByLocalIds(memberId, removedLocalIds) + if (incomingServerIds.isNotEmpty()) exerciseRecordDao.deleteSyncedOnOtherDates(memberId, date, incomingServerIds) exerciseRecordDao.upsertListItems( incoming.map { (sortOrder, item) -> - val localId = reusableLocalIds[item.recordId] ?: newLocalId() - item.toSyncedEntity(memberId, localId, date, sortOrder) + val id = + reusableLocalIds[item.recordId.toLong()] + ?: exerciseRecordDao.findDetailLocalId(memberId, item.recordId.toLong()) + ?: newLocalId() + item.toSyncedEntity(memberId, id, date, sortOrder) }, ) + exerciseRecordDao.saveQuery(ExerciseCacheQueryEntity(memberId, "list", date)) } } - suspend fun getDetailByServerId(serverId: Long): ExerciseRecord? { - val memberId = currentMemberId() ?: return null - return exerciseRecordDao.getDetailByServerId(memberId, serverId)?.toModel() + suspend fun resolveServerId( + recordId: String, + memberId: String?, + ): Long? { + if (!isCurrentMember(memberId)) return null + val detail = exerciseRecordDao.getDetailByLocalId(memberId!!, recordId) + val item = exerciseRecordDao.getListItem(memberId, recordId) + if (detail != null || item != null) return detail?.serverId ?: item?.serverId + // 서버 응답으로 막 생성한 ID도 캐시 저장 전에는 이 경계로 들어올 수 있습니다. + return recordId.toLongOrNull()?.takeIf { it > 0 } } - suspend fun getDetailByLocalId(localId: String): ExerciseRecord? { - val memberId = currentMemberId() ?: return null - return exerciseRecordDao.getDetailByLocalId(memberId, localId)?.toModel() + suspend fun getDetailByServerId( + serverId: Long, + memberId: String? = currentMemberId(), + ): ExerciseRecord? { + if (!isCurrentMember(memberId)) return null + return exerciseRecordDao + .getDetailByServerId(memberId!!, serverId) + ?.takeIf { it.syncState != SyncState.DELETE_PENDING } + ?.toModel() + } + + suspend fun getDetailByLocalId( + localId: String, + memberId: String? = currentMemberId(), + ): ExerciseRecord? { + if (!isCurrentMember(memberId)) return null + return exerciseRecordDao + .getDetailByLocalId(memberId!!, localId) + ?.takeIf { it.syncState != SyncState.DELETE_PENDING } + ?.toModel() } - /** 상세를 받아온 기록이 목록에도 있으면 그 localId를 씁니다 */ suspend fun saveSyncedDetail( serverId: Long, record: ExerciseRecord, - ) { - val memberId = currentMemberId() ?: return - database.withTransaction { + memberId: String? = currentMemberId(), + previousLocalId: String? = null, + ): String? { + if (!isCurrentMember(memberId)) return null + return database.withTransaction { + if (!isCurrentMember(memberId)) return@withTransaction null val localId = - exerciseRecordDao.findDetailLocalId(memberId, serverId) - ?: exerciseRecordDao.findListItemLocalId(memberId, serverId) + exerciseRecordDao.findListItemLocalId(memberId!!, serverId) + ?: exerciseRecordDao.findDetailLocalId(memberId, serverId) + ?: previousLocalId ?: newLocalId() - exerciseRecordDao.upsertDetail(record.toSyncedEntity(memberId, localId, serverId)) + if (serverId !in exerciseRecordDao.getAllPendingServerIds(memberId)) { + exerciseRecordDao.upsertDetail(record.toSyncedEntity(memberId, localId, serverId)) + } + localId } } - /** 기록이 삭제되면 목록과 상세를 함께 지웁니다 */ - suspend fun deleteRecord(localId: String) { - val memberId = currentMemberId() ?: return - database.withTransaction { - exerciseRecordDao.deleteListItem(memberId, localId) - exerciseRecordDao.deleteDetail(memberId, localId) + /** 쓰기 성공 후 목록·상세·집계를 같은 트랜잭션으로 맞춥니다. 날짜 이동도 이전 날짜에서 뺍니다. */ + suspend fun reconcileRecord( + serverId: Long, + record: ExerciseRecord, + memberId: String?, + ): String? { + if (!isCurrentMember(memberId)) return null + return database.withTransaction { + if (!isCurrentMember(memberId)) return@withTransaction null + val id = localId(memberId!!, serverId) + if (serverId in exerciseRecordDao.getAllPendingServerIds(memberId)) return@withTransaction id + val oldItem = exerciseRecordDao.getListItem(memberId, id) + val oldDetail = exerciseRecordDao.getDetailByServerId(memberId, serverId) + val oldDate = oldItem?.date ?: oldDetail?.startedAt?.toLocalDate() + val date = record.startedAt.toLocalDate() + exerciseRecordDao.upsertDetail(record.toSyncedEntity(memberId, id, serverId)) + exerciseRecordDao.upsertListItems( + listOf( + ExerciseListItemEntity( + memberId, + id, + serverId, + SyncState.SYNCED, + date, + oldItem?.sortOrder ?: 0, + record.title, + record.personalType, + record.startedAt, + record.endedAt, + record.pictures?.map { it.url }, + ), + ), + ) + if (oldDate != date) { + oldDate?.let { adjustCount(memberId, it, -1) } + adjustCount(memberId, date, 1) + } + id } } - private fun newLocalId(): String = UUID.randomUUID().toString() + suspend fun reconcileDeletedRecord( + serverId: Long, + memberId: String?, + ) { + if (!isCurrentMember(memberId)) return + database.withTransaction { + if (!isCurrentMember(memberId)) return@withTransaction + if (serverId in exerciseRecordDao.getAllPendingServerIds(memberId!!)) return@withTransaction + val id = + exerciseRecordDao.findListItemLocalId(memberId, serverId) + ?: exerciseRecordDao.findDetailLocalId(memberId, serverId) + ?: return@withTransaction + val date = + exerciseRecordDao.getListItem(memberId, id)?.date + ?: exerciseRecordDao.getDetailByLocalId(memberId, id)?.startedAt?.toLocalDate() + exerciseRecordDao.deleteListItem(memberId, id) + exerciseRecordDao.deleteDetail(memberId, id) + date?.let { adjustCount(memberId, it, -1) } + } + } - private fun currentMemberId(): String? { - return preferenceManager.getMemberId().also { - if (it == null) Timber.w("회원ID가 없어 운동 기록 캐시를 건너뜁니다") + /** 업로드 응답에는 URL이 없습니다. 후속 상세 조회가 실패하면 오래된 이미지를 내보내지 않습니다. */ + suspend fun invalidateImages( + serverId: Long, + memberId: String?, + ) { + if (!isCurrentMember(memberId)) return + database.withTransaction { + if (!isCurrentMember(memberId)) return@withTransaction + if (serverId in exerciseRecordDao.getAllPendingServerIds(memberId!!)) return@withTransaction + val id = + exerciseRecordDao.findListItemLocalId(memberId, serverId) + ?: exerciseRecordDao.findDetailLocalId(memberId, serverId) + ?: return@withTransaction + exerciseRecordDao.deleteDetail(memberId, id) + exerciseRecordDao.getListItem(memberId, id)?.let { + exerciseRecordDao.upsertListItems(listOf(it.copy(thumbnailUrls = null))) + } } } + + private suspend fun localId( + memberId: String, + serverId: Long, + ): String = + exerciseRecordDao.findListItemLocalId(memberId, serverId) + ?: exerciseRecordDao.findDetailLocalId(memberId, serverId) + ?: newLocalId() + + private suspend fun adjustCount( + memberId: String, + date: LocalDate, + delta: Int, + ) { + val count = exerciseCountDao.getRange(memberId, date, date).singleOrNull()?.count ?: 0 + exerciseCountDao.upsertAll(listOf(ExerciseCountEntity(memberId, date, (count + delta).coerceAtLeast(0)))) + } + + private fun newLocalId(): String = UUID.randomUUID().toString() } diff --git a/data/src/main/java/com/project200/data/di/DatabaseModule.kt b/data/src/main/java/com/project200/data/di/DatabaseModule.kt index 8c80dce7..efaf6aa7 100644 --- a/data/src/main/java/com/project200/data/di/DatabaseModule.kt +++ b/data/src/main/java/com/project200/data/di/DatabaseModule.kt @@ -2,6 +2,8 @@ package com.project200.data.di import android.content.Context import androidx.room.Room +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import com.project200.data.impl.RoomSessionDataCleaner import com.project200.data.local.UndabangDatabase import com.project200.data.local.UndabangTypeConverters @@ -26,6 +28,17 @@ abstract class DatabaseModule { abstract fun bindSessionDataCleaner(roomSessionDataCleaner: RoomSessionDataCleaner): SessionDataCleaner companion object { + /** 메타데이터만 추가합니다. 기존 계정·대기 행·타이머는 재생성하지 않습니다. */ + val MIGRATION_2_3 = + object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS exercise_cache_query " + + "(memberId TEXT NOT NULL, queryType TEXT NOT NULL, date TEXT NOT NULL, PRIMARY KEY(memberId, queryType, date))", + ) + } + } + @Provides @Singleton fun provideUndabangTypeConverters(moshi: Moshi): UndabangTypeConverters { @@ -33,7 +46,7 @@ abstract class DatabaseModule { } /** - * 출시 전이라 마이그레이션 대신 재생성을 씁니다. + * 2→3은 대기 행을 보존하는 추가 마이그레이션을 씁니다. 미지원 경로의 출시 전 재생성 정책은 유지합니다. * * 스키마를 바꿀 때는 version을 올립니다. version을 그대로 두고 스키마만 바꾸면 * identityHash가 어긋나 재생성 없이 크래시합니다 @@ -53,6 +66,7 @@ abstract class DatabaseModule { UndabangDatabase.DATABASE_NAME, ) .addTypeConverter(typeConverters) + .addMigrations(MIGRATION_2_3) .fallbackToDestructiveMigration() .build() } diff --git a/data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt index cbb06f67..bdd14e40 100644 --- a/data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt @@ -3,7 +3,10 @@ package com.project200.data.impl import android.content.Context import androidx.core.net.toUri import com.project200.common.di.IoDispatcher +import com.project200.common.utils.NetworkMonitor +import com.project200.data.datasource.ExerciseRecordLocalDataSource import com.project200.data.datasource.ExerciseRecordRemoteDataSource +import com.project200.data.datasource.FeatureFlagDataSource import com.project200.data.dto.ExerciseIdDto import com.project200.data.dto.GetExerciseCountByRangeDTO import com.project200.data.dto.GetExerciseRecordData @@ -22,6 +25,9 @@ import com.project200.domain.model.ExerciseRecordCreationResult import com.project200.domain.repository.ExerciseRecordRepository import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import timber.log.Timber import java.time.LocalDate import javax.inject.Inject @@ -31,116 +37,326 @@ class ExerciseRecordRepositoryImpl @Inject constructor( private val remoteDataSource: ExerciseRecordRemoteDataSource, + private val localDataSource: ExerciseRecordLocalDataSource, + private val featureFlagDataSource: FeatureFlagDataSource, + private val networkMonitor: NetworkMonitor, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, @ApplicationContext private val context: Context, ) : ExerciseRecordRepository { + // 갱신 응답이 자신의 쓰기 뒤에 반영되어 새 값을 되돌리지 않도록 요청과 반영을 함께 직렬화합니다. + private val mutex = Mutex() + override suspend fun getExerciseCountByRange( startDate: LocalDate, endDate: LocalDate, - ): BaseResult> { - return apiCallBuilder( - ioDispatcher = ioDispatcher, - apiCall = { remoteDataSource.getExerciseCountsByRange(startDate, endDate) }, - mapper = { dtoList: List? -> - dtoList?.map { it.toModel() } ?: throw NoSuchElementException("구간별 운동 횟수 조회 응답 데이터가 없습니다.") - }, - ) - } + refresh: Boolean, + localOnly: Boolean, + ): BaseResult> = + serialized { + cachedRead( + refresh = refresh, + localOnly = localOnly, + read = { localDataSource.getCountsSnapshot(startDate, endDate, it) }, + save = { memberId, data -> localDataSource.replaceCountsByRange(startDate, endDate, data, memberId) }, + remote = { + apiCallBuilder( + ioDispatcher, + { remoteDataSource.getExerciseCountsByRange(startDate, endDate) }, + { dto: List? -> + dto?.map { it.toModel() } ?: throw NoSuchElementException("구간별 운동 횟수 조회 응답 데이터가 없습니다.") + }, + ) + }, + ) + } - override suspend fun getExerciseDetail(recordId: Long): BaseResult { - return apiCallBuilder( - ioDispatcher = ioDispatcher, - apiCall = { remoteDataSource.getExerciseRecordDetail(recordId) }, - mapper = { dto: GetExerciseRecordData? -> - dto?.toModel() ?: throw NoSuchElementException("운동 상세 정보 응답 데이터가 없습니다.") - }, - ) - } + override suspend fun getExerciseRecordList( + date: LocalDate, + refresh: Boolean, + localOnly: Boolean, + ): BaseResult> = + serialized { + cachedRead( + refresh = refresh, + localOnly = localOnly, + read = { localDataSource.getListSnapshot(date, it) }, + save = { memberId, data -> localDataSource.replaceSyncedListByDate(date, data, memberId) }, + remote = { + apiCallBuilder( + ioDispatcher, + { remoteDataSource.getExerciseList(date) }, + { dto: List? -> dto?.map { it.toModel() } ?: emptyList() }, + ) + }, + ) + } - override suspend fun getExerciseRecordList(date: LocalDate): BaseResult> { - return apiCallBuilder( - ioDispatcher = ioDispatcher, - apiCall = { remoteDataSource.getExerciseList(date) }, - mapper = { dtoList: List? -> - dtoList?.map { it.toModel() } ?: emptyList() - }, - ) - } + override suspend fun getExerciseDetail( + recordId: String, + refresh: Boolean, + localOnly: Boolean, + ): BaseResult = + serialized { + val enabled = featureFlagDataSource.isOfflineCacheEnabled() + val memberId = if (enabled) localDataSource.currentMemberId() else null + val serverId = serverId(recordId, memberId) + cachedRead( + refresh = refresh && serverId != null, + localOnly = localOnly || serverId == null, + owner = memberId, + read = { + localDataSource.getDetailByLocalId(recordId, it) + ?: serverId?.let { id -> localDataSource.getDetailByServerId(id, it) } + }, + save = { owner, data -> + localDataSource.saveSyncedDetail(serverId!!, data, owner, recordId.takeIf { it.toLongOrNull() == null }) + }, + remote = { serverId?.let { remoteDetail(it) } ?: localOnlyError() }, + ) + } - override suspend fun createExerciseRecord(record: ExerciseRecord): BaseResult { - return apiCallBuilder( - ioDispatcher = ioDispatcher, - apiCall = { remoteDataSource.postExerciseRecord(record.toPostExerciseDTO()) }, - mapper = { dto: PostExerciseResponseDTO? -> - dto?.toModel() ?: throw NoSuchElementException("운동 기록 생성 응답 데이터가 없습니다.") - }, - ) + private suspend fun cachedRead( + refresh: Boolean, + localOnly: Boolean, + read: suspend (String?) -> T?, + save: suspend (String?, T) -> Unit, + remote: suspend () -> BaseResult, + owner: String? = null, + ): BaseResult { + if (!featureFlagDataSource.isOfflineCacheEnabled()) return remote() + val memberId = owner ?: localDataSource.currentMemberId() + if (!localDataSource.isCurrentMember(memberId)) return accountChangedError() + val connected = networkMonitor.isCurrentlyConnected() + try { + localDataSource.prepareCache(memberId) + if (!localDataSource.isCurrentMember(memberId)) return accountChangedError() + if (!refresh || !connected || localOnly) { + val snapshot = read(memberId) + if (!localDataSource.isCurrentMember(memberId)) return accountChangedError() + if (snapshot != null) return BaseResult.Success(snapshot, isCachedSnapshot = true) + if (!connected || localOnly) return networkError() + } + val result = remote() + if (!localDataSource.isCurrentMember(memberId)) return accountChangedError() + return when (result) { + is BaseResult.Success -> { + save(memberId, result.data) + val snapshot = read(memberId) + if (!localDataSource.isCurrentMember(memberId)) { + accountChangedError() + } else { + snapshot?.let { BaseResult.Success(it) } ?: cacheError() + } + } + is BaseResult.Error -> { + val snapshot = read(memberId) + if (!localDataSource.isCurrentMember(memberId)) { + accountChangedError() + } else if (snapshot == null) { + result + } else { + BaseResult.Success(snapshot, refreshError = result, isCachedSnapshot = true) + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "운동 기록 캐시 조회 실패") + return cacheError(e) + } } + override suspend fun createExerciseRecord(record: ExerciseRecord): BaseResult = + serialized { + val enabled = featureFlagDataSource.isOfflineCacheEnabled() + val memberId = writeMemberId() + val result = + apiCallBuilder( + ioDispatcher, + { remoteDataSource.postExerciseRecord(record.toPostExerciseDTO()) }, + { dto: PostExerciseResponseDTO? -> dto?.toModel() ?: throw NoSuchElementException("운동 기록 생성 응답 데이터가 없습니다.") }, + ) + if (result !is BaseResult.Success) return@serialized result + var id = result.data.recordId + val error = + cacheAfterWrite(memberId, enabled) { + id = localDataSource.reconcileRecord(id.toLong(), record, memberId) ?: id + } + BaseResult.Success(result.data.copy(recordId = id), error) + } + override suspend fun updateExerciseRecord( record: ExerciseRecord, - recordId: Long, - ): BaseResult { - return apiCallBuilder( - ioDispatcher = ioDispatcher, - apiCall = { remoteDataSource.patchExerciseRecord(recordId, record.toPatchExerciseDTO()) }, - mapper = { exerciseIdDto: ExerciseIdDto? -> - exerciseIdDto?.exerciseId ?: throw NoSuchElementException("운동 기록 수정 응답 데이터가 없습니다.") - }, - ) - } + recordId: String, + ): BaseResult = + serialized { + val enabled = featureFlagDataSource.isOfflineCacheEnabled() + val memberId = writeMemberId() + val id = serverId(recordId, memberId) ?: return@serialized localOnlyError() + val previous = if (enabled) localDataSource.getDetailByServerId(id, memberId) else null + val result = + apiCallBuilder( + ioDispatcher, + { remoteDataSource.patchExerciseRecord(id, record.toPatchExerciseDTO()) }, + { dto: ExerciseIdDto? -> dto?.exerciseId ?: throw NoSuchElementException("운동 기록 수정 응답 데이터가 없습니다.") }, + ) + when (result) { + is BaseResult.Error -> result + is BaseResult.Success -> { + val error = + cacheAfterWrite(memberId, enabled) { + localDataSource.reconcileRecord(id, record.copy(pictures = record.pictures ?: previous?.pictures), memberId) + } + BaseResult.Success(recordId, error) + } + } + } override suspend fun uploadExerciseRecordImages( - recordId: Long, + recordId: String, images: List, - ): BaseResult { - val imageUris = - images.mapNotNull { - try { - it.toUri() - } catch (e: CancellationException) { - Timber.w(e, "CancellationException: $it") - throw e - } catch (e: Exception) { - Timber.w(e, "Invalid URI string: $it") - null + ): BaseResult = + serialized { + val enabled = featureFlagDataSource.isOfflineCacheEnabled() + val memberId = writeMemberId() + val id = serverId(recordId, memberId) ?: return@serialized localOnlyError() + val imageUris = + images.mapNotNull { + try { + it.toUri() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Invalid URI string: $it") + null + } } + val imageParts = imageUris.mapNotNull { it.toMultipartBodyPart(context, "pictures") } + if (imageParts.isEmpty() && imageUris.isNotEmpty()) { + return@serialized BaseResult.Error("CONVERSION_FAILED", "이미지 파일 변환에 실패했습니다.") } - - val imageParts = - imageUris.mapNotNull { uri -> - uri.toMultipartBodyPart(context, "pictures") + val result = + apiCallBuilder( + ioDispatcher, + { remoteDataSource.postExerciseImages(id, imageParts) }, + { dto: ExerciseIdDto? -> dto?.exerciseId ?: throw NoSuchElementException("이미지 업로드 응답 데이터가 없습니다.") }, + ) + when (result) { + is BaseResult.Error -> result + is BaseResult.Success -> { + val error = + cacheAfterWrite(memberId, enabled) { + when (val detail = remoteDetail(id)) { + is BaseResult.Success -> localDataSource.reconcileRecord(id, detail.data, memberId) + is BaseResult.Error -> { + localDataSource.invalidateImages(id, memberId) + throw IllegalStateException(detail.message, detail.cause) + } + } + } + BaseResult.Success(recordId, error) + } } - - if (imageParts.isEmpty() && imageUris.isNotEmpty()) { - return BaseResult.Error("CONVERSION_FAILED", "이미지 파일 변환에 실패했습니다.") } - return apiCallBuilder( - ioDispatcher = ioDispatcher, - apiCall = { remoteDataSource.postExerciseImages(recordId, imageParts) }, - mapper = { exerciseIdDto: ExerciseIdDto? -> - exerciseIdDto?.exerciseId ?: throw NoSuchElementException("이미지 업로드 응답 데이터가 없습니다.") - }, - ) - } - override suspend fun deleteExerciseRecordImages( - recordId: Long, + recordId: String, imageIds: List, - ): BaseResult { - return apiCallBuilder( - ioDispatcher = ioDispatcher, - apiCall = { remoteDataSource.deleteExerciseImages(recordId, imageIds) }, - mapper = { Unit }, - ) - } + ): BaseResult = + serialized { + val enabled = featureFlagDataSource.isOfflineCacheEnabled() + val memberId = writeMemberId() + val id = serverId(recordId, memberId) ?: return@serialized localOnlyError() + val previous = if (enabled) localDataSource.getDetailByServerId(id, memberId) else null + val result = apiCallBuilder(ioDispatcher, { remoteDataSource.deleteExerciseImages(id, imageIds) }, { Unit }) + if (result !is BaseResult.Success) return@serialized result + val error = + cacheAfterWrite(memberId, enabled) { + if (previous != null) { + localDataSource.reconcileRecord( + id, + previous.copy(pictures = previous.pictures?.filterNot { it.id in imageIds }), + memberId, + ) + } else { + localDataSource.invalidateImages(id, memberId) + } + } + BaseResult.Success(Unit, error) + } + + override suspend fun deleteExerciseRecord(recordId: String): BaseResult = + serialized { + val enabled = featureFlagDataSource.isOfflineCacheEnabled() + val memberId = writeMemberId() + val id = serverId(recordId, memberId) ?: return@serialized localOnlyError() + val result = apiCallBuilder(ioDispatcher, { remoteDataSource.deleteExerciseRecord(id) }, { Unit }) + if (result !is BaseResult.Success) return@serialized result + val error = cacheAfterWrite(memberId, enabled) { localDataSource.reconcileDeletedRecord(id, memberId) } + BaseResult.Success(Unit, error) + } - override suspend fun deleteExerciseRecord(recordId: Long): BaseResult { - return apiCallBuilder( - ioDispatcher = ioDispatcher, - apiCall = { remoteDataSource.deleteExerciseRecord(recordId) }, - mapper = { Unit }, + private suspend fun remoteDetail(serverId: Long): BaseResult = + apiCallBuilder( + ioDispatcher, + { remoteDataSource.getExerciseRecordDetail(serverId) }, + { dto: GetExerciseRecordData? -> dto?.toModel() ?: throw NoSuchElementException("운동 상세 정보 응답 데이터가 없습니다.") }, ) + + private suspend fun serverId( + recordId: String, + memberId: String?, + ): Long? = + recordId.toLongOrNull()?.takeIf { it > 0 } + ?: localDataSource.resolveServerId(recordId, memberId ?: localDataSource.currentMemberId()) + + private fun writeMemberId(): String? = + try { + localDataSource.currentMemberId() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "운동 기록 쓰기 계정 조회 실패") + null + } + + private fun invalidateAfterWrite(memberId: String?): BaseResult.Error? = + try { + memberId?.let { localDataSource.invalidateCache(it) } + null + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "운동 기록 캐시 무효 상태 저장 실패") + cacheError(e) + } + + private suspend fun cacheAfterWrite( + memberId: String?, + enabled: Boolean, + block: suspend () -> Unit, + ): BaseResult.Error? { + if (!enabled) return invalidateAfterWrite(memberId) + return try { + block() + null + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + invalidateAfterWrite(memberId) + Timber.e(e, "서버 쓰기 후 운동 기록 캐시 반영 실패") + cacheError(e) + } } + + private suspend fun serialized(block: suspend () -> T): T = withContext(ioDispatcher) { mutex.withLock { block() } } + + private fun networkError() = BaseResult.Error("NETWORK_ERROR", "네트워크 연결 오류가 발생했습니다.") + + private fun localOnlyError() = BaseResult.Error("LOCAL_ONLY_RECORD", "동기화 대기 중인 기록은 서버에서 수정하거나 삭제할 수 없습니다.") + + private fun accountChangedError() = BaseResult.Error("ACCOUNT_CHANGED", "계정이 변경되어 기록을 불러올 수 없습니다.") + + private fun cacheError(cause: Throwable? = null) = BaseResult.Error("CACHE_ERROR", "저장된 운동 기록을 불러오는데 실패했습니다.", cause) } diff --git a/data/src/main/java/com/project200/data/local/PreferenceManager.kt b/data/src/main/java/com/project200/data/local/PreferenceManager.kt index 68a2ba29..63516fad 100644 --- a/data/src/main/java/com/project200/data/local/PreferenceManager.kt +++ b/data/src/main/java/com/project200/data/local/PreferenceManager.kt @@ -32,8 +32,20 @@ class PreferenceManager } } + fun isExerciseCacheInvalidated(memberId: String): Boolean = prefs.getBoolean(KEY_EXERCISE_CACHE_INVALIDATED + memberId, false) + + fun setExerciseCacheInvalidated( + memberId: String, + invalidated: Boolean, + ) { + check(prefs.edit().putBoolean(KEY_EXERCISE_CACHE_INVALIDATED + memberId, invalidated).commit()) { + "운동 기록 캐시 무효 상태를 저장하지 못했습니다." + } + } + companion object { private const val PREFS_NAME = "undabangPrefs" private const val KEY_MEMBER_ID = "member_id" + private const val KEY_EXERCISE_CACHE_INVALIDATED = "exercise_cache_invalidated_" } } diff --git a/data/src/main/java/com/project200/data/local/UndabangDatabase.kt b/data/src/main/java/com/project200/data/local/UndabangDatabase.kt index 5a81bf25..8f80e35b 100644 --- a/data/src/main/java/com/project200/data/local/UndabangDatabase.kt +++ b/data/src/main/java/com/project200/data/local/UndabangDatabase.kt @@ -7,6 +7,7 @@ import com.project200.data.local.dao.ExerciseCountDao import com.project200.data.local.dao.ExerciseRecordDao import com.project200.data.local.dao.TimerDao import com.project200.data.local.entity.CustomTimerEntity +import com.project200.data.local.entity.ExerciseCacheQueryEntity import com.project200.data.local.entity.ExerciseCountEntity import com.project200.data.local.entity.ExerciseListItemEntity import com.project200.data.local.entity.ExerciseRecordDetailEntity @@ -23,10 +24,11 @@ import com.project200.data.local.entity.SimpleTimerEntity ExerciseCountEntity::class, ExerciseListItemEntity::class, ExerciseRecordDetailEntity::class, + ExerciseCacheQueryEntity::class, SimpleTimerEntity::class, CustomTimerEntity::class, ], - version = 2, + version = 3, exportSchema = true, ) @TypeConverters(UndabangTypeConverters::class) diff --git a/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt b/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt index 58ce7895..7588d9cf 100644 --- a/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt +++ b/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt @@ -8,6 +8,9 @@ import java.time.LocalDate @Dao interface ExerciseCountDao { + @Query("DELETE FROM exercise_count WHERE memberId = :memberId") + suspend fun deleteByMember(memberId: String) + /** 화면은 진입할 때 한 번 읽습니다. 지속 구독을 두지 않아 그린 뒤 저절로 바뀌지 않습니다 */ @Query( "SELECT * FROM exercise_count " + diff --git a/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt b/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt index 38b95473..4138e31d 100644 --- a/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt +++ b/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt @@ -3,6 +3,7 @@ package com.project200.data.local.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Upsert +import com.project200.data.local.entity.ExerciseCacheQueryEntity import com.project200.data.local.entity.ExerciseListItemEntity import com.project200.data.local.entity.ExerciseRecordDetailEntity import com.project200.data.local.entity.SyncState @@ -10,6 +11,67 @@ import java.time.LocalDate @Dao interface ExerciseRecordDao { + @Query("DELETE FROM exercise_cache_query WHERE memberId = :memberId") + suspend fun deleteQueries(memberId: String) + + @Query("DELETE FROM exercise_list_item WHERE memberId = :memberId AND syncState = :synced") + suspend fun deleteSyncedList( + memberId: String, + synced: SyncState = SyncState.SYNCED, + ) + + @Query("DELETE FROM exercise_record_detail WHERE memberId = :memberId AND syncState = :synced") + suspend fun deleteSyncedDetails( + memberId: String, + synced: SyncState = SyncState.SYNCED, + ) + + @Query("SELECT EXISTS(SELECT 1 FROM exercise_cache_query WHERE memberId = :memberId AND queryType = :queryType AND date = :date)") + suspend fun hasQuery( + memberId: String, + queryType: String, + date: LocalDate, + ): Boolean + + @Upsert + suspend fun saveQuery(query: ExerciseCacheQueryEntity) + + @Query( + "SELECT COUNT(*) = :expectedDays FROM exercise_cache_query " + + "WHERE memberId = :memberId AND queryType = 'count' AND date BETWEEN :startDate AND :endDate", + ) + suspend fun hasCountRange( + memberId: String, + startDate: LocalDate, + endDate: LocalDate, + expectedDays: Long, + ): Boolean + + @Query("SELECT * FROM exercise_list_item WHERE memberId = :memberId AND localId = :localId") + suspend fun getListItem( + memberId: String, + localId: String, + ): ExerciseListItemEntity? + + @Query("SELECT * FROM exercise_list_item WHERE memberId = :memberId AND date BETWEEN :startDate AND :endDate AND syncState != :synced") + suspend fun getPendingInRange( + memberId: String, + startDate: LocalDate, + endDate: LocalDate, + synced: SyncState = SyncState.SYNCED, + ): List + + /** 날짜를 옮긴 대기 행과 목록/상세 중 한쪽에만 있는 대기 행도 보호합니다. */ + @Query( + "SELECT serverId FROM exercise_list_item WHERE memberId = :memberId AND serverId IS NOT NULL AND syncState != :synced " + + "UNION SELECT serverId FROM exercise_record_detail " + + "WHERE memberId = :memberId AND serverId IS NOT NULL AND syncState != :synced", + ) + suspend fun getAllPendingServerIds( + memberId: String, + synced: SyncState = SyncState.SYNCED, + ): List + /** 화면은 진입할 때 한 번 읽습니다. 지속 구독을 두지 않아 그린 뒤 저절로 바뀌지 않습니다 */ @Query( "SELECT * FROM exercise_list_item " + @@ -86,7 +148,7 @@ interface ExerciseRecordDao { localId: String, ): ExerciseRecordDetailEntity? - /** 화면이 아직 서버 ID로 상세에 들어옵니다. localId 기준 진입은 #584에서 바뀝니다 */ + /** 서버 식별자는 원격 요청 경계에서만 사용합니다. 화면은 localId로 들어옵니다. */ @Query("SELECT * FROM exercise_record_detail WHERE memberId = :memberId AND serverId = :serverId") suspend fun getDetailByServerId( memberId: String, @@ -124,10 +186,11 @@ interface ExerciseRecordDao { ) /** 서버 목록에서 사라진 기록의 상세를 함께 정리합니다 */ - @Query("DELETE FROM exercise_record_detail WHERE memberId = :memberId AND localId IN (:localIds)") + @Query("DELETE FROM exercise_record_detail WHERE memberId = :memberId AND localId IN (:localIds) AND syncState = :synced") suspend fun deleteDetailsByLocalIds( memberId: String, localIds: List, + synced: SyncState = SyncState.SYNCED, ) @Upsert diff --git a/data/src/main/java/com/project200/data/local/entity/ExerciseCacheQueryEntity.kt b/data/src/main/java/com/project200/data/local/entity/ExerciseCacheQueryEntity.kt new file mode 100644 index 00000000..9b6193ab --- /dev/null +++ b/data/src/main/java/com/project200/data/local/entity/ExerciseCacheQueryEntity.kt @@ -0,0 +1,12 @@ +package com.project200.data.local.entity + +import androidx.room.Entity +import java.time.LocalDate + +/** 행이 없는 성공 응답과 미조회 날짜를 구분합니다. 계정 정리와 날짜별 보존 정책을 함께 적용합니다. */ +@Entity(tableName = "exercise_cache_query", primaryKeys = ["memberId", "queryType", "date"]) +data class ExerciseCacheQueryEntity( + val memberId: String, + val queryType: String, + val date: LocalDate, +) diff --git a/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt b/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt index d9539e7b..fd96c4f3 100644 --- a/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt +++ b/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt @@ -36,7 +36,7 @@ fun ExerciseListItem.toSyncedEntity( return ExerciseListItemEntity( memberId = memberId, localId = localId, - serverId = recordId, + serverId = recordId.toLong(), syncState = SyncState.SYNCED, date = date, sortOrder = sortOrder, @@ -48,15 +48,16 @@ fun ExerciseListItem.toSyncedEntity( ) } -/** 서버 ID가 아직 없는 행은 화면이 다룰 수 없어 null을 돌려줍니다 */ -fun ExerciseListItemEntity.toModel(): ExerciseListItem? { +/** 화면과 내비게이션은 서버 ID 유무와 관계없이 같은 localId를 씁니다. */ +fun ExerciseListItemEntity.toModel(): ExerciseListItem { return ExerciseListItem( - recordId = serverId ?: return null, + recordId = localId, title = title, type = personalType, startTime = startedAt, endTime = endedAt, imageUrl = thumbnailUrls, + syncPending = syncState != SyncState.SYNCED, ) } @@ -89,5 +90,7 @@ fun ExerciseRecordDetailEntity.toModel(): ExerciseRecord { endedAt = endedAt, location = location, pictures = pictures?.map { ExerciseRecordPicture(it.id, it.url) }, + syncPending = syncState != SyncState.SYNCED, + canModify = serverId != null && syncState == SyncState.SYNCED, ) } diff --git a/data/src/main/java/com/project200/data/mapper/ExerciseMapper.kt b/data/src/main/java/com/project200/data/mapper/ExerciseMapper.kt index 218336bb..42d792aa 100644 --- a/data/src/main/java/com/project200/data/mapper/ExerciseMapper.kt +++ b/data/src/main/java/com/project200/data/mapper/ExerciseMapper.kt @@ -58,7 +58,7 @@ fun ExerciseRecord.toPatchExerciseDTO(): PatchExerciseRequestDto { fun GetExerciseRecordListDto.toModel(): ExerciseListItem { return ExerciseListItem( - recordId = this.exerciseId, + recordId = this.exerciseId.toString(), title = this.exerciseTitle, type = this.exercisePersonalType, startTime = this.exerciseStartedAt, @@ -69,7 +69,7 @@ fun GetExerciseRecordListDto.toModel(): ExerciseListItem { fun PostExerciseResponseDTO.toModel(): ExerciseRecordCreationResult { return ExerciseRecordCreationResult( - recordId = this.exerciseId, + recordId = this.exerciseId.toString(), earnedPoints = this.earnedPoints, ) } diff --git a/data/src/test/java/com/project200/data/datasource/ExerciseRecordLocalDataSourceTest.kt b/data/src/test/java/com/project200/data/datasource/ExerciseRecordLocalDataSourceTest.kt index 6d909a55..58be106d 100644 --- a/data/src/test/java/com/project200/data/datasource/ExerciseRecordLocalDataSourceTest.kt +++ b/data/src/test/java/com/project200/data/datasource/ExerciseRecordLocalDataSourceTest.kt @@ -1,11 +1,13 @@ package com.project200.data.datasource import com.google.common.truth.Truth.assertThat +import com.project200.data.di.DatabaseModule import com.project200.data.local.PreferenceManager import com.project200.data.local.UndabangDatabase import com.project200.data.local.createInMemoryDatabase import com.project200.data.local.entity.ExerciseListItemEntity import com.project200.data.local.entity.ExerciseRecordDetailEntity +import com.project200.data.local.entity.SimpleTimerEntity import com.project200.data.local.entity.SyncState import com.project200.domain.model.ExerciseCount import com.project200.domain.model.ExerciseListItem @@ -57,7 +59,7 @@ class ExerciseRecordLocalDataSourceTest { private fun serverItem(recordId: Long) = ExerciseListItem( - recordId = recordId, + recordId = recordId.toString(), title = "제목 $recordId", type = "WEIGHT", startTime = LocalDateTime.parse("2026-09-10T10:00:00"), @@ -222,7 +224,7 @@ class ExerciseRecordLocalDataSourceTest { // ── 읽기 ──────────────────────────────── @Test - fun `목록 읽기 - 서버 ID가 없는 행은 아직 걸러진다`() = + fun `목록 읽기 - 서버 ID 없이도 로컬 식별자와 대기 표시를 유지한다`() = runTest { // Given insertRow("local-only", serverId = null, syncState = SyncState.CREATE_PENDING) @@ -231,8 +233,9 @@ class ExerciseRecordLocalDataSourceTest { // When val result = dataSource.getListByDate(date) - // Then: 화면이 서버 ID로 기록을 식별하는 동안의 동작이다 (#584에서 바뀐다) - assertThat(result.map { it.recordId }).containsExactly(1L) + assertThat(result.map { it.recordId }).containsExactly("local-only", "synced") + assertThat(result.single { it.recordId == "local-only" }.syncPending).isTrue() + assertThat(result.single { it.recordId == "synced" }.syncPending).isFalse() } @Test @@ -289,13 +292,96 @@ class ExerciseRecordLocalDataSourceTest { insertDetail("target", serverId = 1L) // When - dataSource.deleteRecord("target") + dataSource.reconcileDeletedRecord(1L, MEMBER_ID) // Then assertThat(database.exerciseRecordDao().getListByDate(MEMBER_ID, date)).isEmpty() assertThat(database.exerciseRecordDao().getDetailByLocalId(MEMBER_ID, "target")).isNull() } + @Test + fun `상세를 먼저 받은 뒤 목록을 받아도 같은 로컬 식별자를 쓴다`() = + runTest { + val id = dataSource.saveSyncedDetail(7L, record()) + dataSource.replaceSyncedListByDate(date, listOf(serverItem(7L))) + assertThat(dataSource.getListByDate(date).single().recordId).isEqualTo(id) + assertThat(dataSource.getDetailByLocalId(id!!)).isNotNull() + } + + @Test + fun `성공한 빈 조회만 캐시로 인정하고 다른 계정에는 노출하지 않는다`() = + runTest { + assertThat(dataSource.getListSnapshot(date, MEMBER_ID)).isNull() + assertThat(dataSource.getCountsSnapshot(date, otherDate, MEMBER_ID)).isNull() + dataSource.replaceSyncedListByDate(date, emptyList()) + dataSource.replaceCountsByRange(date, otherDate, emptyList()) + assertThat(dataSource.getListSnapshot(date, MEMBER_ID)).isEmpty() + assertThat(dataSource.getCountsSnapshot(date, otherDate, MEMBER_ID)).isEmpty() + + every { preferenceManager.getMemberId() } returns "member-b" + assertThat(dataSource.getListSnapshot(date, "member-b")).isNull() + assertThat(dataSource.getCountsSnapshot(date, otherDate, "member-b")).isNull() + // 요청 전에 캡처한 이전 계정도 현재 계정의 결과로 바뀌지 않습니다. + assertThat(dataSource.getListSnapshot(date, MEMBER_ID)).isNull() + dataSource.replaceSyncedListByDate(date, listOf(serverItem(7L)), MEMBER_ID) + assertThat(database.exerciseRecordDao().getListByDate("member-b", date)).isEmpty() + } + + @Test + fun `다른 날짜로 이동한 대기 행은 이전 날짜의 서버 목록으로 복원하지 않는다`() = + runTest { + insertRow("moved-pending", 7L, SyncState.UPDATE_PENDING, otherDate) + dataSource.replaceSyncedListByDate(date, listOf(serverItem(7L))) + assertThat(dataSource.getListByDate(date)).isEmpty() + assertThat(dataSource.getListByDate(otherDate).single().recordId).isEqualTo("moved-pending") + } + + @Test + fun `상세에만 있는 대기 변경은 목록 삭제 상세 갱신 자신의 쓰기 반영에도 보존된다`() = + runTest { + insertRow("protected", 7L, SyncState.SYNCED) + insertDetail("protected", 7L) + val detail = database.exerciseRecordDao().getDetailByLocalId(MEMBER_ID, "protected")!! + database.exerciseRecordDao().upsertDetail(detail.copy(syncState = SyncState.UPDATE_PENDING)) + + dataSource.replaceSyncedListByDate(date, emptyList()) + dataSource.saveSyncedDetail(7L, record().copy(title = "서버 값")) + dataSource.reconcileRecord(7L, record().copy(title = "쓰기 값"), MEMBER_ID) + dataSource.reconcileDeletedRecord(7L, MEMBER_ID) + assertThat(dataSource.getListByDate(date).single().recordId).isEqualTo("protected") + assertThat(dataSource.getDetailByLocalId("protected")?.title).isEqualTo(detail.title) + assertThat(dataSource.getDetailByLocalId("protected")?.syncPending).isTrue() + } + + @Test + fun `빈 조회 메타데이터 추가 마이그레이션은 기존 계정 대기 행과 타이머를 보존한다`() = + runTest { + insertRow("pending", null, SyncState.CREATE_PENDING) + insertDetail("detail", 7L) + val timer = SimpleTimerEntity(MEMBER_ID, "timer", null, SyncState.CREATE_PENDING, 60, "request", null, 1L) + database.timerDao().upsertSimpleTimer(timer) + val sql = database.openHelper.writableDatabase + sql.execSQL("DROP TABLE exercise_cache_query") + DatabaseModule.MIGRATION_2_3.migrate(sql) + dataSource.replaceSyncedListByDate(otherDate, emptyList()) + + assertThat(dataSource.getListByDate(date).single().recordId).isEqualTo("pending") + assertThat(dataSource.getDetailByLocalId("detail")).isNotNull() + assertThat(database.timerDao().getSimpleTimer(MEMBER_ID, "timer")).isEqualTo(timer) + assertThat(dataSource.getListSnapshot(otherDate, MEMBER_ID)).isEmpty() + } + + @Test + fun `집계의 조회 표식 없는 0행은 성공한 빈 쿼리로 꾸미지 않는다`() = + runTest { + insertRow("target", 1L, SyncState.SYNCED) + dataSource.reconcileDeletedRecord(1L, MEMBER_ID) + assertThat(dataSource.getCountsSnapshot(date, otherDate, MEMBER_ID)).isNull() + dataSource.replaceCountsByRange(date, otherDate, emptyList()) + assertThat(dataSource.getCountsSnapshot(date, otherDate, MEMBER_ID)).isEmpty() + assertThat(dataSource.getCountsSnapshot(date, otherDate.plusDays(1), MEMBER_ID)).isNull() + } + companion object { private const val MEMBER_ID = "member-a" } diff --git a/data/src/test/java/com/project200/data/impl/ExerciseRecordRepositoryImplTest.kt b/data/src/test/java/com/project200/data/impl/ExerciseRecordRepositoryImplTest.kt new file mode 100644 index 00000000..858e42ad --- /dev/null +++ b/data/src/test/java/com/project200/data/impl/ExerciseRecordRepositoryImplTest.kt @@ -0,0 +1,704 @@ +package com.project200.data.impl + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import com.project200.common.utils.NetworkMonitor +import com.project200.data.datasource.ExerciseRecordLocalDataSource +import com.project200.data.datasource.ExerciseRecordRemoteDataSource +import com.project200.data.datasource.FeatureFlagDataSource +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.ExerciseIdDto +import com.project200.data.dto.GetExerciseCountByRangeDTO +import com.project200.data.dto.GetExerciseRecordData +import com.project200.data.dto.GetExerciseRecordListDto +import com.project200.data.dto.PictureData +import com.project200.data.dto.PostExerciseResponseDTO +import com.project200.data.local.PreferenceManager +import com.project200.data.local.UndabangDatabase +import com.project200.data.local.createInMemoryDatabase +import com.project200.data.local.entity.ExerciseListItemEntity +import com.project200.data.local.entity.SyncState +import com.project200.data.mapper.toModel +import com.project200.data.mapper.toSyncedEntity +import com.project200.domain.model.BaseResult +import com.project200.domain.model.ExerciseRecord +import com.project200.domain.model.ExerciseRecordPicture +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import okhttp3.MultipartBody +import okio.Buffer +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.io.File +import java.io.IOException +import java.time.LocalDate + +/** Room과 로컬 소스는 실제 구현을 사용하여 소비자가 받는 스냅샷을 검증합니다. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ExerciseRecordRepositoryImplTest { + private lateinit var database: UndabangDatabase + private lateinit var local: ExerciseRecordLocalDataSource + private lateinit var repository: ExerciseRecordRepositoryImpl + private val remote = mockk() + private val flags = mockk() + private val network = mockk() + private val preferences = mockk() + private val date = LocalDate.of(2026, 10, 10) + private val nextDate = date.plusDays(1) + private var memberId = "member-a" + private val invalidatedMembers = mutableSetOf() + + @Before + fun setUp() { + database = createInMemoryDatabase() + every { preferences.getMemberId() } answers { memberId } + every { preferences.isExerciseCacheInvalidated(any()) } answers { firstArg() in invalidatedMembers } + every { preferences.setExerciseCacheInvalidated(any(), any()) } answers { + if (secondArg()) invalidatedMembers.add(firstArg()) else invalidatedMembers.remove(firstArg()) + Unit + } + every { flags.isOfflineCacheEnabled() } returns true + every { network.isCurrentlyConnected() } returns true + local = ExerciseRecordLocalDataSource(database, database.exerciseCountDao(), database.exerciseRecordDao(), preferences) + repository = + ExerciseRecordRepositoryImpl( + remote, + local, + flags, + network, + UnconfinedTestDispatcher(), + RuntimeEnvironment.getApplication(), + ) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun `온라인 캘린더 목록 상세는 Room을 거쳐 동일한 로컬 식별자로 읽힌다`() = + runTest { + coEvery { remote.getExerciseCountsByRange(date, nextDate) } returns response(listOf(GetExerciseCountByRangeDTO(date, 1))) + coEvery { remote.getExerciseList(date) } returns response(listOf(itemDto())) + coEvery { remote.getExerciseRecordDetail(7L) } returns response(detailDto()) + + val counts = repository.getExerciseCountByRange(date, nextDate) as BaseResult.Success + val items = repository.getExerciseRecordList(date) as BaseResult.Success + val id = items.data.single().recordId + val detail = repository.getExerciseDetail(id) as BaseResult.Success + + assertThat(id).isNotEqualTo("7") + assertThat(counts.data).isEqualTo(local.getCountsByRange(date, nextDate)) + assertThat(items.data).isEqualTo(local.getListByDate(date)) + assertThat(detail.data).isEqualTo(local.getDetailByLocalId(id)) + assertThat(database.exerciseRecordDao().getDetailByLocalId(memberId, id)?.serverId).isEqualTo(7L) + + repository.getExerciseRecordList(date) + assertThat(local.getListByDate(date).single().recordId).isEqualTo(id) + } + + @Test + fun `오프라인 적중과 성공한 빈 결과는 읽고 미조회 쿼리는 네트워크 오류다`() = + runTest { + local.replaceSyncedListByDate(date, listOf(itemDto().toModel())) + local.saveSyncedDetail(7L, record()) + local.replaceSyncedListByDate(nextDate, emptyList()) + local.replaceCountsByRange(date, nextDate, emptyList()) + every { network.isCurrentlyConnected() } returns false + + val id = local.getListByDate(date).single().recordId + val hit = repository.getExerciseRecordList(date) as BaseResult.Success + assertThat(hit.data.single().recordId).isEqualTo(id) + assertThat(hit.refreshError).isNull() + assertThat(hit.isCachedSnapshot).isTrue() + assertThat((repository.getExerciseDetail(id) as BaseResult.Success).data.title).isEqualTo("운동") + assertThat((repository.getExerciseRecordList(nextDate) as BaseResult.Success).data).isEmpty() + assertThat((repository.getExerciseCountByRange(date, nextDate) as BaseResult.Success).data).isEmpty() + assertThat((repository.getExerciseRecordList(nextDate.plusDays(1)) as BaseResult.Error).errorCode).isEqualTo("NETWORK_ERROR") + assertThat((repository.getExerciseDetail("missing-local") as BaseResult.Error).errorCode).isEqualTo("NETWORK_ERROR") + assertThat( + (repository.getExerciseCountByRange(nextDate.plusDays(1), nextDate.plusDays(2)) as BaseResult.Error).errorCode, + ).isEqualTo("NETWORK_ERROR") + coVerify(exactly = 0) { remote.getExerciseList(any()) } + coVerify(exactly = 0) { remote.getExerciseRecordDetail(any()) } + } + + @Test + fun `온라인 네트워크 실패와 인증 실패는 캐시와 함께 서로 다른 오류를 전달한다`() = + runTest { + local.replaceSyncedListByDate(date, listOf(itemDto().toModel())) + val id = local.getListByDate(date).single().recordId + coEvery { remote.getExerciseList(date) } throws IOException("연결 실패") + val networkResult = repository.getExerciseRecordList(date) as BaseResult.Success + assertThat(networkResult.data.single().recordId).isEqualTo(id) + assertThat(networkResult.refreshError?.errorCode).isEqualTo("NETWORK_ERROR") + + coEvery { remote.getExerciseList(date) } returns BaseResponse(succeed = false, code = "401", message = "인증 실패") + val authResult = repository.getExerciseRecordList(date) as BaseResult.Success + assertThat(authResult.refreshError?.errorCode).isEqualTo("401") + assertThat(authResult.isCachedSnapshot).isTrue() + assertThat(authResult.data.single().recordId).isEqualTo(id) + coEvery { remote.getExerciseList(nextDate) } returns BaseResponse(succeed = false, code = "BUSINESS_ERROR", message = "조회 불가") + assertThat((repository.getExerciseRecordList(nextDate) as BaseResult.Error).errorCode).isEqualTo("BUSINESS_ERROR") + } + + @Test + fun `플래그가 꺼지면 연결 상태나 Room 쓰기에 의존하지 않고 서버 응답과 오류를 반환한다`() = + runTest { + every { flags.isOfflineCacheEnabled() } returns false + every { network.isCurrentlyConnected() } returns false + val brokenLocal = mockk() + val legacy = + ExerciseRecordRepositoryImpl( + remote, + brokenLocal, + flags, + network, + UnconfinedTestDispatcher(), + RuntimeEnvironment.getApplication(), + ) + coEvery { remote.getExerciseCountsByRange(date, nextDate) } returns response(listOf(GetExerciseCountByRangeDTO(date, 1))) + coEvery { remote.getExerciseList(date) } returns response(listOf(itemDto())) + coEvery { remote.getExerciseRecordDetail(7L) } returns response(detailDto()) + coEvery { remote.postExerciseRecord(any()) } returns response(PostExerciseResponseDTO(7L, 3)) + + assertThat( + (legacy.getExerciseCountByRange(date, nextDate, refresh = false) as BaseResult.Success).data.single().count, + ).isEqualTo(1) + assertThat((legacy.getExerciseRecordList(date, refresh = false) as BaseResult.Success).data.single().recordId).isEqualTo("7") + assertThat((legacy.getExerciseDetail("7") as BaseResult.Success).data.title).isEqualTo("운동") + val created = legacy.createExerciseRecord(record()) as BaseResult.Success + assertThat(created.data.recordId).isEqualTo("7") + assertThat(created.data.earnedPoints).isEqualTo(3) + assertThat(created.refreshError).isNull() + assertThat(created.isCachedSnapshot).isFalse() + coEvery { remote.getExerciseList(date) } throws IOException("연결 없음") + assertThat((legacy.getExerciseRecordList(date) as BaseResult.Error).errorCode).isEqualTo("NETWORK_ERROR") + coEvery { remote.getExerciseList(date) } returns BaseResponse(succeed = false, code = "401", message = "인증 실패") + assertThat((legacy.getExerciseRecordList(date) as BaseResult.Error).errorCode).isEqualTo("401") + coVerify(exactly = 0) { brokenLocal.replaceSyncedListByDate(any(), any(), any()) } + } + + @Test + fun `로컬 전용 생성 대기는 목록 캘린더 상세에 보이지만 서버 쓰기를 하지 않는다`() = + runTest { + database.exerciseRecordDao().upsertListItems( + listOf( + ExerciseListItemEntity( + memberId, + "local-only", + null, + SyncState.CREATE_PENDING, + date, + 0, + "대기 운동", + "OTHER", + date.atTime(9, 0), + date.atTime(10, 0), + null, + ), + ), + ) + database.exerciseRecordDao().upsertDetail( + record().toSyncedEntity(memberId, "local-only", 7L).copy(serverId = null, syncState = SyncState.CREATE_PENDING), + ) + every { network.isCurrentlyConnected() } returns false + + val item = (repository.getExerciseRecordList(date) as BaseResult.Success).data.single() + val count = (repository.getExerciseCountByRange(date, date) as BaseResult.Success).data.single() + val detail = (repository.getExerciseDetail(item.recordId) as BaseResult.Success).data + assertThat(item.recordId).isEqualTo("local-only") + assertThat(item.syncPending).isTrue() + assertThat(count.count).isEqualTo(1) + assertThat(count.syncPending).isTrue() + assertThat(detail.canModify).isFalse() + assertThat(detail.syncPending).isTrue() + assertThat( + (repository.updateExerciseRecord(record(), item.recordId) as BaseResult.Error).errorCode, + ).isEqualTo("LOCAL_ONLY_RECORD") + assertThat((repository.deleteExerciseRecord(item.recordId) as BaseResult.Error).errorCode).isEqualTo("LOCAL_ONLY_RECORD") + assertThat( + (repository.deleteExerciseRecordImages(item.recordId, listOf(1L)) as BaseResult.Error).errorCode, + ).isEqualTo("LOCAL_ONLY_RECORD") + assertThat( + (repository.uploadExerciseRecordImages(item.recordId, emptyList()) as BaseResult.Error).errorCode, + ).isEqualTo("LOCAL_ONLY_RECORD") + coVerify(exactly = 0) { remote.patchExerciseRecord(any(), any()) } + coVerify(exactly = 0) { remote.deleteExerciseRecord(any()) } + } + + @Test + fun `자신의 생성 날짜 이동 삭제는 로컬 재조회와 집계를 함께 갱신한다`() = + runTest { + local.replaceCountsByRange(date, nextDate, emptyList()) + local.replaceSyncedListByDate(date, emptyList()) + local.replaceSyncedListByDate(nextDate, emptyList()) + coEvery { remote.postExerciseRecord(any()) } returns response(PostExerciseResponseDTO(7L, 3)) + coEvery { remote.patchExerciseRecord(7L, any()) } returns response(ExerciseIdDto(7L)) + coEvery { remote.deleteExerciseRecord(7L) } returns response(null) + + val created = (repository.createExerciseRecord(record()) as BaseResult.Success).data + val id = created.recordId + assertThat(id).isNotEqualTo("7") + assertThat(created.earnedPoints).isEqualTo(3) + assertThat((repository.getExerciseRecordList(date, refresh = false) as BaseResult.Success).data.single().recordId).isEqualTo(id) + assertThat( + (repository.getExerciseCountByRange(date, nextDate, refresh = false) as BaseResult.Success).data.sumOf { it.count }, + ).isEqualTo(1) + + repository.updateExerciseRecord(record(nextDate).copy(title = "옮긴 운동"), id) + assertThat((repository.getExerciseRecordList(date, refresh = false) as BaseResult.Success).data).isEmpty() + assertThat( + (repository.getExerciseRecordList(nextDate, refresh = false) as BaseResult.Success).data.single().recordId, + ).isEqualTo(id) + assertThat((repository.getExerciseDetail(id, refresh = false) as BaseResult.Success).data.title).isEqualTo("옮긴 운동") + val movedResult = repository.getExerciseCountByRange(date, nextDate, refresh = false) as BaseResult.Success + val movedCounts = movedResult.data.associate { it.date to it.count } + assertThat(movedCounts[date]).isEqualTo(0) + assertThat(movedCounts[nextDate]).isEqualTo(1) + + repository.deleteExerciseRecord(id) + assertThat((repository.getExerciseRecordList(nextDate, refresh = false) as BaseResult.Success).data).isEmpty() + assertThat(repository.getExerciseDetail(id, refresh = false)).isInstanceOf(BaseResult.Error::class.java) + assertThat( + (repository.getExerciseCountByRange(date, nextDate, refresh = false) as BaseResult.Success).data.sumOf { it.count }, + ).isEqualTo(0) + coVerify(exactly = 0) { remote.getExerciseList(any()) } + coVerify(exactly = 0) { remote.getExerciseRecordDetail(any()) } + } + + @Test + fun `자신의 이미지 삭제는 썸네일과 상세를 맞추고 업로드 후 조회 실패는 이미지 캐시를 무효화한다`() = + runTest { + val id = + local.reconcileRecord( + 7L, + record().copy(pictures = listOf(ExerciseRecordPicture(1L, "one"), ExerciseRecordPicture(2L, "two"))), + memberId, + )!! + coEvery { remote.deleteExerciseImages(7L, listOf(1L)) } returns response(null) + repository.deleteExerciseRecordImages(id, listOf(1L)) + assertThat(local.getListByDate(date).single().imageUrl).containsExactly("two") + assertThat(local.getDetailByLocalId(id)?.pictures?.map { it.id }).containsExactly(2L) + + coEvery { remote.postExerciseImages(7L, any()) } returns response(ExerciseIdDto(7L)) + coEvery { remote.getExerciseRecordDetail(7L) } throws IOException("후속 상세 실패") + assertThat(repository.uploadExerciseRecordImages(id, emptyList())).isInstanceOf(BaseResult.Success::class.java) + assertThat(local.getDetailByLocalId(id)).isNull() + assertThat(local.getListByDate(date).single().imageUrl).isNull() + } + + @Test + fun `이미지 업로드 뒤 실제 상세 응답의 URL을 동일한 로컬 기록에 반영한다`() = + runTest { + val id = local.reconcileRecord(7L, record(), memberId)!! + val image = File.createTempFile("exercise-upload-", ".jpg", RuntimeEnvironment.getApplication().cacheDir) + try { + val imageBytes = byteArrayOf(1, 2, 3) + image.writeBytes(imageBytes) + val parts = slot>() + coEvery { remote.postExerciseImages(7L, capture(parts)) } returns response(ExerciseIdDto(7L)) + coEvery { remote.getExerciseRecordDetail(7L) } returns + response( + detailDto().copy(pictureDataList = listOf(PictureData(11L, "https://server/photo.jpg", "photo", "jpg"))), + ) + + val result = repository.uploadExerciseRecordImages(id, listOf(Uri.fromFile(image).toString())) as BaseResult.Success + assertThat(result.data).isEqualTo(id) + assertThat(result.refreshError).isNull() + val uploaded = Buffer() + parts.captured.single().body.writeTo(uploaded) + assertThat(uploaded.readByteArray()).isEqualTo(imageBytes) + assertThat(local.getListByDate(date).single().imageUrl).containsExactly("https://server/photo.jpg") + assertThat(local.getDetailByLocalId(id)?.pictures).containsExactly(ExerciseRecordPicture(11L, "https://server/photo.jpg")) + assertThat(local.getListByDate(date).single().recordId).isEqualTo(id) + } finally { + image.delete() + } + } + + @Test + fun `갱신과 자신의 쓰기가 겹쳐도 오래된 응답이 쓰기 뒤에 반영되지 않는다`() = + runTest { + val id = local.reconcileRecord(7L, record(), memberId)!! + val entered = CompletableDeferred() + val pendingResponse = CompletableDeferred>>() + coEvery { remote.getExerciseList(date) } coAnswers { + entered.complete(Unit) + pendingResponse.await() + } + coEvery { remote.patchExerciseRecord(7L, any()) } returns response(ExerciseIdDto(7L)) + val refresh = async { repository.getExerciseRecordList(date) } + entered.await() + val edit = async { repository.updateExerciseRecord(record().copy(title = "새 내용"), id) } + pendingResponse.complete(response(listOf(itemDto()))) + refresh.await() + edit.await() + assertThat(local.getListByDate(date).single().title).isEqualTo("새 내용") + assertThat(local.getDetailByLocalId(id)?.title).isEqualTo("새 내용") + } + + @Test + fun `요청 중 계정이 바뀌면 응답을 다른 계정에 저장하거나 화면에 반환하지 않는다`() = + runTest { + coEvery { remote.getExerciseList(date) } coAnswers { + memberId = "member-b" + response(listOf(itemDto())) + } + assertThat((repository.getExerciseRecordList(date) as BaseResult.Error).errorCode).isEqualTo("ACCOUNT_CHANGED") + assertThat(local.getListByDate(date)).isEmpty() + assertThat(database.exerciseRecordDao().getListByDate("member-a", date)).isEmpty() + } + + @Test + fun `요청 취소를 캐시 성공이나 오류로 숨기지 않는다`() = + runTest { + coEvery { remote.getExerciseList(date) } throws CancellationException("취소") + try { + repository.getExerciseRecordList(date) + throw AssertionError("취소가 전파되지 않았습니다") + } catch (expected: CancellationException) { + assertThat(expected.message).isEqualTo("취소") + } + } + + @Test + fun `플래그 OFF 삭제 뒤 ON 오프라인 조회는 이전 목록과 상세를 반환하지 않는다`() = + runTest { + val id = local.reconcileRecord(7L, record(), memberId)!! + local.replaceCountsByRange(date, date, listOf(com.project200.domain.model.ExerciseCount(date, 1))) + local.replaceSyncedListByDate(date, listOf(itemDto().toModel())) + every { flags.isOfflineCacheEnabled() } returns false + coEvery { remote.deleteExerciseRecord(7L) } returns response(null) + assertThat(repository.deleteExerciseRecord("7")).isInstanceOf(BaseResult.Success::class.java) + assertThat(invalidatedMembers).contains(memberId) + + every { flags.isOfflineCacheEnabled() } returns true + every { network.isCurrentlyConnected() } returns false + assertThat(repository.getExerciseRecordList(date, refresh = false)).isInstanceOf(BaseResult.Error::class.java) + assertThat(repository.getExerciseDetail(id, refresh = false)).isInstanceOf(BaseResult.Error::class.java) + assertThat(local.getDetailByLocalId(id)).isNull() + assertThat(local.getCountsSnapshot(date, date, memberId)).isNull() + assertThat(invalidatedMembers).isEmpty() + } + + @Test + fun `쓰기 반영 실패 뒤 다음 조회는 오래된 캐시를 버리고 모든 대기 상태를 보존한다`() = + runTest { + val id = local.reconcileRecord(7L, record(), memberId)!! + local.replaceCountsByRange(date, date, listOf(com.project200.domain.model.ExerciseCount(date, 1))) + local.replaceSyncedListByDate(date, listOf(itemDto().toModel())) + val dao = database.exerciseRecordDao() + dao.upsertListItems( + listOf(dao.getListItem(memberId, id)!!.copy(memberId = "member-b", localId = "other-member", serverId = 8L)), + ) + dao.upsertDetail(record().toSyncedEntity("member-b", "other-member", 8L)) + listOf(SyncState.CREATE_PENDING, SyncState.UPDATE_PENDING, SyncState.DELETE_PENDING).forEachIndexed { index, state -> + val pendingId = "pending-$index" + dao.upsertListItems( + listOf( + ExerciseListItemEntity( + memberId, + pendingId, + null, + state, + date, + index, + "대기", + "OTHER", + date.atTime(9, 0), + date.atTime(10, 0), + null, + ), + ), + ) + dao.upsertDetail(record().toSyncedEntity(memberId, pendingId, 20L + index).copy(syncState = state)) + } + val failingLocal = spyk(local) + coEvery { failingLocal.reconcileDeletedRecord(7L, memberId) } throws IOException("Room 쓰기 실패") + val writer = + ExerciseRecordRepositoryImpl( + remote, + failingLocal, + flags, + network, + UnconfinedTestDispatcher(), + RuntimeEnvironment.getApplication(), + ) + coEvery { remote.deleteExerciseRecord(7L) } returns response(null) + val result = writer.deleteExerciseRecord(id) as BaseResult.Success + assertThat(result.refreshError?.errorCode).isEqualTo("CACHE_ERROR") + assertThat(invalidatedMembers).contains(memberId) + + every { network.isCurrentlyConnected() } returns false + val snapshot = repository.getExerciseRecordList(date, refresh = false) as BaseResult.Success + assertThat(snapshot.data.map { it.recordId }).containsExactly("pending-0", "pending-1") + assertThat(local.getDetailByLocalId(id)).isNull() + listOf(SyncState.CREATE_PENDING, SyncState.UPDATE_PENDING, SyncState.DELETE_PENDING).forEachIndexed { index, state -> + assertThat(dao.getListItem(memberId, "pending-$index")?.syncState).isEqualTo(state) + assertThat(dao.getDetailByLocalId(memberId, "pending-$index")?.syncState).isEqualTo(state) + } + assertThat(dao.hasQuery(memberId, "list", date)).isFalse() + assertThat(dao.hasCountRange(memberId, date, date, 1)).isFalse() + assertThat(invalidatedMembers).isEmpty() + assertThat(dao.getListItem("member-b", "other-member")).isNotNull() + assertThat(dao.getDetailByLocalId("member-b", "other-member")).isNotNull() + coVerify(exactly = 1) { remote.deleteExerciseRecord(7L) } + } + + @Test + fun `무효 캐시 정리가 실패하면 스냅샷을 읽지 않고 무효 표시를 유지한다`() = + runTest { + local.reconcileRecord(7L, record(), memberId) + invalidatedMembers.add(memberId) + val failingDao = spyk(database.exerciseRecordDao()) + coEvery { failingDao.deleteSyncedDetails(memberId) } throws IOException("정리 실패") + val failingLocal = spyk(ExerciseRecordLocalDataSource(database, database.exerciseCountDao(), failingDao, preferences)) + val reader = + ExerciseRecordRepositoryImpl( + remote, + failingLocal, + flags, + network, + UnconfinedTestDispatcher(), + RuntimeEnvironment.getApplication(), + ) + every { network.isCurrentlyConnected() } returns false + assertThat(reader.getExerciseRecordList(date, refresh = false)).isInstanceOf(BaseResult.Error::class.java) + assertThat(invalidatedMembers).contains(memberId) + coVerify(exactly = 0) { failingLocal.getListSnapshot(any(), any()) } + assertThat(local.getListByDate(date).single().title).isEqualTo("운동") + } + + @Test + fun `로컬 우선 조회의 온라인 미적중은 서버로 갱신하지만 복귀 전용 조회는 요청하지 않는다`() = + runTest { + coEvery { remote.getExerciseList(date) } returns response(listOf(itemDto())) + val loaded = repository.getExerciseRecordList(date, refresh = false) as BaseResult.Success + assertThat(loaded.data.single().title).isEqualTo("운동") + assertThat((repository.getExerciseRecordList(date, refresh = false) as BaseResult.Success).data).isEqualTo(loaded.data) + assertThat( + repository.getExerciseRecordList(nextDate, refresh = false, localOnly = true), + ).isInstanceOf(BaseResult.Error::class.java) + coVerify(exactly = 1) { remote.getExerciseList(date) } + coVerify(exactly = 0) { remote.getExerciseList(nextDate) } + } + + @Test + fun `플래그 OFF의 모든 서버 쓰기는 무효 표시 저장 실패를 성공 경고로 반환하고 재시도하지 않는다`() = + runTest { + every { flags.isOfflineCacheEnabled() } returns false + val failure = IOException("무효 표시 저장 실패") + every { preferences.setExerciseCacheInvalidated(memberId, true) } throws failure + coEvery { remote.postExerciseRecord(any()) } returns response(PostExerciseResponseDTO(7L, 3)) + coEvery { remote.patchExerciseRecord(7L, any()) } returns response(ExerciseIdDto(7L)) + coEvery { remote.postExerciseImages(7L, any()) } returns response(ExerciseIdDto(7L)) + coEvery { remote.deleteExerciseImages(7L, any()) } returns response(null) + coEvery { remote.deleteExerciseRecord(7L) } returns response(null) + + val created = repository.createExerciseRecord(record()) as BaseResult.Success + val updated = repository.updateExerciseRecord(record(), "7") as BaseResult.Success + val uploaded = repository.uploadExerciseRecordImages("7", emptyList()) as BaseResult.Success + val imagesDeleted = repository.deleteExerciseRecordImages("7", listOf(1L)) as BaseResult.Success + val deleted = repository.deleteExerciseRecord("7") as BaseResult.Success + + listOf(created, updated, uploaded, imagesDeleted, deleted).forEach { result -> + assertThat(result.refreshError?.errorCode).isEqualTo("CACHE_ERROR") + assertThat(result.refreshError?.cause).isSameInstanceAs(failure) + assertThat(result.isCachedSnapshot).isFalse() + } + assertThat(created.data.recordId).isEqualTo("7") + assertThat(created.data.earnedPoints).isEqualTo(3) + assertThat(updated.data).isEqualTo("7") + assertThat(uploaded.data).isEqualTo("7") + assertThat(imagesDeleted.data).isEqualTo(Unit) + assertThat(deleted.data).isEqualTo(Unit) + assertThat(invalidatedMembers).isEmpty() + verify(exactly = 5) { preferences.setExerciseCacheInvalidated(memberId, true) } + coVerify(exactly = 1) { remote.postExerciseRecord(any()) } + coVerify(exactly = 1) { remote.patchExerciseRecord(7L, any()) } + coVerify(exactly = 1) { remote.postExerciseImages(7L, any()) } + coVerify(exactly = 1) { remote.deleteExerciseImages(7L, listOf(1L)) } + coVerify(exactly = 1) { remote.deleteExerciseRecord(7L) } + } + + @Test + fun `플래그 ON 캐시 반영과 무효 표시 저장이 함께 실패해도 서버 성공과 반영 오류를 반환한다`() = + runTest { + val id = local.reconcileRecord(7L, record(), memberId)!! + val failure = IOException("Room 반영 실패") + val failingLocal = spyk(local) + coEvery { failingLocal.reconcileDeletedRecord(7L, memberId) } throws failure + every { preferences.setExerciseCacheInvalidated(memberId, true) } throws IOException("무효 표시 저장 실패") + val writer = + ExerciseRecordRepositoryImpl( + remote, + failingLocal, + flags, + network, + UnconfinedTestDispatcher(), + RuntimeEnvironment.getApplication(), + ) + coEvery { remote.deleteExerciseRecord(7L) } returns response(null) + + val result = writer.deleteExerciseRecord(id) as BaseResult.Success + + assertThat(result.data).isEqualTo(Unit) + assertThat(result.refreshError?.errorCode).isEqualTo("CACHE_ERROR") + assertThat(result.refreshError?.cause).isSameInstanceAs(failure) + assertThat(result.isCachedSnapshot).isFalse() + assertThat(invalidatedMembers).isEmpty() + verify(exactly = 1) { preferences.setExerciseCacheInvalidated(memberId, true) } + coVerify(exactly = 1) { remote.deleteExerciseRecord(7L) } + } + + @Test + fun `플래그 OFF 쓰기 중 계정이 바뀌어도 무효 표시 실패는 원래 회원의 경고로 반환한다`() = + runTest { + every { flags.isOfflineCacheEnabled() } returns false + val failure = IOException("원래 회원 무효 표시 저장 실패") + every { preferences.setExerciseCacheInvalidated("member-a", true) } throws failure + coEvery { remote.deleteExerciseRecord(7L) } coAnswers { + memberId = "member-b" + response(null) + } + + val result = repository.deleteExerciseRecord("7") as BaseResult.Success + + assertThat(result.refreshError?.errorCode).isEqualTo("CACHE_ERROR") + assertThat(result.refreshError?.cause).isSameInstanceAs(failure) + verify(exactly = 1) { preferences.setExerciseCacheInvalidated("member-a", true) } + verify(exactly = 0) { preferences.setExerciseCacheInvalidated("member-b", any()) } + assertThat(invalidatedMembers).isEmpty() + coVerify(exactly = 1) { remote.deleteExerciseRecord(7L) } + } + + @Test + fun `복귀 전용 미적중은 온라인에서도 캘린더와 상세 서버를 요청하지 않는다`() = + runTest { + assertThat( + repository.getExerciseCountByRange(date, nextDate, refresh = false, localOnly = true), + ).isInstanceOf(BaseResult.Error::class.java) + assertThat(repository.getExerciseDetail("7", refresh = false, localOnly = true)).isInstanceOf(BaseResult.Error::class.java) + coVerify(exactly = 0) { remote.getExerciseCountsByRange(any(), any()) } + coVerify(exactly = 0) { remote.getExerciseRecordDetail(any()) } + } + + @Test + fun `온라인 로컬 우선 캘린더와 상세 미적중은 서버 결과를 Room으로 읽는다`() = + runTest { + coEvery { remote.getExerciseCountsByRange(date, nextDate) } returns response(listOf(GetExerciseCountByRangeDTO(date, 1))) + coEvery { remote.getExerciseRecordDetail(7L) } returns response(detailDto()) + assertThat( + (repository.getExerciseCountByRange(date, nextDate, refresh = false) as BaseResult.Success).data.single().count, + ).isEqualTo(1) + assertThat( + (repository.getExerciseDetail("7", refresh = false) as BaseResult.Success).data, + ).isEqualTo(local.getDetailByServerId(7L)) + coVerify(exactly = 1) { remote.getExerciseCountsByRange(date, nextDate) } + coVerify(exactly = 1) { remote.getExerciseRecordDetail(7L) } + } + + @Test + fun `플래그 OFF의 모든 서버 쓰기는 회원 캐시를 무효로 표시한다`() = + runTest { + every { flags.isOfflineCacheEnabled() } returns false + coEvery { remote.postExerciseRecord(any()) } returns response(PostExerciseResponseDTO(7L, 3)) + coEvery { remote.patchExerciseRecord(7L, any()) } returns response(ExerciseIdDto(7L)) + coEvery { remote.postExerciseImages(7L, any()) } returns response(ExerciseIdDto(7L)) + coEvery { remote.deleteExerciseImages(7L, any()) } returns response(null) + coEvery { remote.deleteExerciseRecord(7L) } returns response(null) + val writes: List BaseResult<*>> = + listOf( + { repository.createExerciseRecord(record()) }, + { repository.updateExerciseRecord(record(), "7") }, + { repository.uploadExerciseRecordImages("7", emptyList()) }, + { repository.deleteExerciseRecordImages("7", listOf(1L)) }, + { repository.deleteExerciseRecord("7") }, + ) + writes.forEach { write -> + invalidatedMembers.clear() + assertThat(write()).isInstanceOf(BaseResult.Success::class.java) + assertThat(invalidatedMembers).containsExactly(memberId) + } + } + + @Test + fun `플래그 OFF 쓰기 후 무효 표시의 취소는 숨기거나 서버 쓰기를 반복하지 않는다`() = + runTest { + every { flags.isOfflineCacheEnabled() } returns false + every { preferences.setExerciseCacheInvalidated(any(), true) } throws CancellationException("무효 표시 취소") + coEvery { remote.deleteExerciseRecord(7L) } returns response(null) + try { + repository.deleteExerciseRecord("7") + throw AssertionError("취소가 전파되지 않았습니다") + } catch (expected: CancellationException) { + assertThat(expected.message).isEqualTo("무효 표시 취소") + } + coVerify(exactly = 1) { remote.deleteExerciseRecord(7L) } + } + + @Test + fun `플래그 ON 반영 실패 후 무효 표시의 취소는 캐시 경고로 숨기지 않는다`() = + runTest { + val id = local.reconcileRecord(7L, record(), memberId)!! + val failingLocal = spyk(local) + coEvery { failingLocal.reconcileDeletedRecord(7L, memberId) } throws IOException("Room 반영 실패") + every { preferences.setExerciseCacheInvalidated(memberId, true) } throws CancellationException("무효 표시 취소") + val writer = + ExerciseRecordRepositoryImpl( + remote, + failingLocal, + flags, + network, + UnconfinedTestDispatcher(), + RuntimeEnvironment.getApplication(), + ) + coEvery { remote.deleteExerciseRecord(7L) } returns response(null) + + try { + writer.deleteExerciseRecord(id) + throw AssertionError("취소가 전파되지 않았습니다") + } catch (expected: CancellationException) { + assertThat(expected.message).isEqualTo("무효 표시 취소") + } + coVerify(exactly = 1) { remote.deleteExerciseRecord(7L) } + } + + @Test + fun `무효 상세의 온라인 재조회는 열린 화면의 로컬 식별자를 유지한다`() = + runTest { + val id = local.reconcileRecord(7L, record(), memberId)!! + invalidatedMembers.add(memberId) + coEvery { remote.getExerciseRecordDetail(7L) } returns response(detailDto().copy(exerciseTitle = "새 상세")) + val result = repository.getExerciseDetail(id, refresh = false) as BaseResult.Success + assertThat(local.getDetailByLocalId(id)).isEqualTo(result.data) + assertThat(database.exerciseRecordDao().getDetailByLocalId(memberId, id)?.serverId).isEqualTo(7L) + coVerify(exactly = 1) { remote.getExerciseRecordDetail(7L) } + } + + private fun record(day: LocalDate = date) = ExerciseRecord("운동", "본문", "OTHER", day.atTime(9, 0), day.atTime(10, 0), "공원", null) + + private fun itemDto() = GetExerciseRecordListDto(7L, "운동", "OTHER", date.atTime(9, 0), date.atTime(10, 0), null) + + private fun detailDto() = GetExerciseRecordData("운동", "본문", "OTHER", date.atTime(9, 0), date.atTime(10, 0), "공원", null) + + private fun response(data: T): BaseResponse = BaseResponse(succeed = true, code = "200", message = "", data = data) +} diff --git a/data/src/test/java/com/project200/data/local/ExerciseCacheInvalidationPreferenceTest.kt b/data/src/test/java/com/project200/data/local/ExerciseCacheInvalidationPreferenceTest.kt new file mode 100644 index 00000000..1737b79a --- /dev/null +++ b/data/src/test/java/com/project200/data/local/ExerciseCacheInvalidationPreferenceTest.kt @@ -0,0 +1,32 @@ +package com.project200.data.local + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ExerciseCacheInvalidationPreferenceTest { + @Test + fun `무효 표시는 새 설정 인스턴스에도 남고 회원별로 독립적으로 해제된다`() { + val context: android.content.Context = RuntimeEnvironment.getApplication() + val preferences = PreferenceManager(context) + val firstMember = "cache-invalidation-member-a" + val secondMember = "cache-invalidation-member-b" + try { + preferences.setExerciseCacheInvalidated(firstMember, true) + preferences.setExerciseCacheInvalidated(secondMember, true) + val reopened = PreferenceManager(context) + assertThat(reopened.isExerciseCacheInvalidated(firstMember)).isTrue() + assertThat(reopened.isExerciseCacheInvalidated(secondMember)).isTrue() + reopened.setExerciseCacheInvalidated(firstMember, false) + val nextInstance = PreferenceManager(context) + assertThat(nextInstance.isExerciseCacheInvalidated(firstMember)).isFalse() + assertThat(nextInstance.isExerciseCacheInvalidated(secondMember)).isTrue() + } finally { + preferences.setExerciseCacheInvalidated(firstMember, false) + preferences.setExerciseCacheInvalidated(secondMember, false) + } + } +} diff --git a/domain/src/main/java/com/project200/domain/model/BaseResult.kt b/domain/src/main/java/com/project200/domain/model/BaseResult.kt index eebf75ea..32e72c32 100644 --- a/domain/src/main/java/com/project200/domain/model/BaseResult.kt +++ b/domain/src/main/java/com/project200/domain/model/BaseResult.kt @@ -1,7 +1,12 @@ package com.project200.domain.model sealed class BaseResult { - data class Success(val data: T) : BaseResult() + /** refreshError는 갱신 오류이며, isCachedSnapshot은 이번 조회에서 서버 확인 없이 반환한 값입니다. */ + data class Success( + val data: T, + val refreshError: Error? = null, + val isCachedSnapshot: Boolean = false, + ) : BaseResult() data class Error( val errorCode: String? = null, val message: String?, diff --git a/domain/src/main/java/com/project200/domain/model/ExerciseModel.kt b/domain/src/main/java/com/project200/domain/model/ExerciseModel.kt index d0f95682..0c2f25b4 100644 --- a/domain/src/main/java/com/project200/domain/model/ExerciseModel.kt +++ b/domain/src/main/java/com/project200/domain/model/ExerciseModel.kt @@ -10,7 +10,9 @@ data class ExerciseRecord( val startedAt: LocalDateTime, val endedAt: LocalDateTime, val location: String, - val pictures: List? + val pictures: List?, + val syncPending: Boolean = false, + val canModify: Boolean = true ) data class ExerciseRecordPicture( @@ -19,20 +21,22 @@ data class ExerciseRecordPicture( ) data class ExerciseListItem( - val recordId: Long, + val recordId: String, val title: String, val type: String, val startTime: LocalDateTime, val endTime: LocalDateTime, - val imageUrl: List? + val imageUrl: List?, + val syncPending: Boolean = false ) data class ExerciseCount( val date: LocalDate, - val count: Int + val count: Int, + val syncPending: Boolean = false ) data class ExerciseRecordCreationResult( - val recordId: Long, + val recordId: String, val earnedPoints: Int ) \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/model/ExerciseResult.kt b/domain/src/main/java/com/project200/domain/model/ExerciseResult.kt index 6f479538..2fae77df 100644 --- a/domain/src/main/java/com/project200/domain/model/ExerciseResult.kt +++ b/domain/src/main/java/com/project200/domain/model/ExerciseResult.kt @@ -1,28 +1,31 @@ package com.project200.domain.model sealed class SubmissionResult { - data class Success(val recordId: Long, val earnedPoints: Int) : SubmissionResult() - data class PartialSuccess(val recordId: Long, val messageId: Int) : SubmissionResult() + data class Success(val recordId: String, val earnedPoints: Int, val refreshError: BaseResult.Error? = null) : SubmissionResult() + data class PartialSuccess(val recordId: String, val messageId: Int, val refreshError: BaseResult.Error? = null) : SubmissionResult() data class Failure(val message: String, val cause: Throwable? = null) : SubmissionResult() } sealed class ExerciseEditResult { + open val refreshError: BaseResult.Error? get() = null // 모든 작업이 성공적으로 완료 - data class Success(val recordId: Long) : ExerciseEditResult() + data class Success(val recordId: String, override val refreshError: BaseResult.Error? = null) : ExerciseEditResult() // 내용 생성(또는 수정) 실패 data class ContentFailure( - val recordId: Long, + val recordId: String, val message: String, - val cause: Throwable? = null + val cause: Throwable? = null, + override val refreshError: BaseResult.Error? = null ) : ExerciseEditResult() // 내용 수정은 성공, 이미지 처리(삭제 또는 업로드) 중 하나 이상 실패 data class ImageFailure( - val recordId: Long, + val recordId: String, val message: String, - val cause: Throwable? = null + val cause: Throwable? = null, + override val refreshError: BaseResult.Error? = null ) : ExerciseEditResult() // 그 외 예측하지 못한 전체적인 실패 diff --git a/domain/src/main/java/com/project200/domain/repository/ExerciseRecordRepository.kt b/domain/src/main/java/com/project200/domain/repository/ExerciseRecordRepository.kt index ef14386d..66ecf09f 100644 --- a/domain/src/main/java/com/project200/domain/repository/ExerciseRecordRepository.kt +++ b/domain/src/main/java/com/project200/domain/repository/ExerciseRecordRepository.kt @@ -8,12 +8,13 @@ import com.project200.domain.model.ExerciseRecordCreationResult import java.time.LocalDate interface ExerciseRecordRepository { - suspend fun getExerciseCountByRange(startDate: LocalDate, endDate: LocalDate): BaseResult> - suspend fun getExerciseDetail(recordId: Long): BaseResult - suspend fun getExerciseRecordList(date: LocalDate): BaseResult> + /** refresh=false는 캐시 우선이며 온라인 미적중은 갱신합니다. 복귀 시 localOnly=true로 서버 조회를 막습니다. */ + suspend fun getExerciseCountByRange(startDate: LocalDate, endDate: LocalDate, refresh: Boolean = true, localOnly: Boolean = false): BaseResult> + suspend fun getExerciseDetail(recordId: String, refresh: Boolean = true, localOnly: Boolean = false): BaseResult + suspend fun getExerciseRecordList(date: LocalDate, refresh: Boolean = true, localOnly: Boolean = false): BaseResult> suspend fun createExerciseRecord(record: ExerciseRecord): BaseResult - suspend fun updateExerciseRecord(record: ExerciseRecord, recordId: Long): BaseResult - suspend fun uploadExerciseRecordImages(recordId:Long, images: List): BaseResult - suspend fun deleteExerciseRecordImages(recordId: Long, imageIds: List): BaseResult - suspend fun deleteExerciseRecord(recordId: Long): BaseResult + suspend fun updateExerciseRecord(record: ExerciseRecord, recordId: String): BaseResult + suspend fun uploadExerciseRecordImages(recordId: String, images: List): BaseResult + suspend fun deleteExerciseRecordImages(recordId: String, imageIds: List): BaseResult + suspend fun deleteExerciseRecord(recordId: String): BaseResult } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/DeleteExerciseRecordImagesUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/DeleteExerciseRecordImagesUseCase.kt index 9a9ee5ae..3cc25b91 100644 --- a/domain/src/main/java/com/project200/domain/usecase/DeleteExerciseRecordImagesUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/DeleteExerciseRecordImagesUseCase.kt @@ -7,7 +7,7 @@ import javax.inject.Inject class DeleteExerciseRecordImagesUseCase @Inject constructor( private val exerciseRecordRepository: ExerciseRecordRepository ) { - suspend operator fun invoke(exerciseId: Long, imageIds: List): BaseResult { + suspend operator fun invoke(exerciseId: String, imageIds: List): BaseResult { return exerciseRecordRepository.deleteExerciseRecordImages(exerciseId, imageIds) } } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/DeleteExerciseRecordUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/DeleteExerciseRecordUseCase.kt index 0705751d..4c4fcde1 100644 --- a/domain/src/main/java/com/project200/domain/usecase/DeleteExerciseRecordUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/DeleteExerciseRecordUseCase.kt @@ -7,7 +7,7 @@ import javax.inject.Inject class DeleteExerciseRecordUseCase @Inject constructor( private val exerciseRecordRepository: ExerciseRecordRepository ) { - suspend operator fun invoke(recordId: Long): BaseResult { + suspend operator fun invoke(recordId: String): BaseResult { return exerciseRecordRepository.deleteExerciseRecord(recordId) } } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/EditExerciseRecordUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/EditExerciseRecordUseCase.kt index fd28b050..bd3c4fbe 100644 --- a/domain/src/main/java/com/project200/domain/usecase/EditExerciseRecordUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/EditExerciseRecordUseCase.kt @@ -21,7 +21,7 @@ class EditExerciseRecordUseCase @Inject constructor( private val uploadExerciseRecordImagesUseCase: UploadExerciseRecordImagesUseCase ) { suspend operator fun invoke( - recordId: Long, + recordId: String, recordToUpdate: ExerciseRecord, isContentChanges: Boolean, imagesToDelete: List, @@ -34,7 +34,7 @@ class EditExerciseRecordUseCase @Inject constructor( if (!isContentChanges) return@async ExerciseEditResult.Success(recordId) when (val result = updateExerciseRecordUseCase(recordId, recordToUpdate)) { - is BaseResult.Success -> ExerciseEditResult.Success(recordId) + is BaseResult.Success -> ExerciseEditResult.Success(recordId, result.refreshError) is BaseResult.Error -> ExerciseEditResult.ContentFailure( recordId = recordId, message = result.message ?: EDIT_CONTENT_FAIL, @@ -50,6 +50,7 @@ class EditExerciseRecordUseCase @Inject constructor( return@async ExerciseEditResult.Success(recordId) } + var refreshError: BaseResult.Error? = null // 이미지 삭제 if(imagesToDelete.isNotEmpty()) { val deleteResult = deleteExerciseRecordImagesUseCase(recordId, imagesToDelete) @@ -60,6 +61,7 @@ class EditExerciseRecordUseCase @Inject constructor( cause = deleteResult.cause ) } + refreshError = (deleteResult as BaseResult.Success).refreshError } // 이미지 업로드 @@ -69,11 +71,13 @@ class EditExerciseRecordUseCase @Inject constructor( return@async ExerciseEditResult.ImageFailure( recordId = recordId, message = uploadResult.message ?: IMAGE_FAIL, - cause = uploadResult.cause + cause = uploadResult.cause, + refreshError = refreshError ) } + refreshError = refreshError ?: (uploadResult as BaseResult.Success).refreshError } - return@async ExerciseEditResult.Success(recordId) + return@async ExerciseEditResult.Success(recordId, refreshError) } // 내용 수정, 이미지 삭제 및 업로드 작업 결과 수집 @@ -82,10 +86,10 @@ class EditExerciseRecordUseCase @Inject constructor( // 최종 결과 처리 return@coroutineScope when { - contentResult is ExerciseEditResult.ContentFailure -> contentResult - imageResult is ExerciseEditResult.ImageFailure -> imageResult + contentResult is ExerciseEditResult.ContentFailure -> contentResult.copy(refreshError = imageResult.refreshError) + imageResult is ExerciseEditResult.ImageFailure -> imageResult.copy(refreshError = contentResult.refreshError ?: imageResult.refreshError) contentResult is ExerciseEditResult.Success && imageResult is ExerciseEditResult.Success -> - ExerciseEditResult.Success(recordId) + ExerciseEditResult.Success(recordId, contentResult.refreshError ?: imageResult.refreshError) else -> ExerciseEditResult.Failure(UNEXPECTED_ERROR) } } catch (e: Exception) { diff --git a/domain/src/main/java/com/project200/domain/usecase/GetExerciseCountInMonthUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/GetExerciseCountInMonthUseCase.kt index 50c4b19b..e3ac56f6 100644 --- a/domain/src/main/java/com/project200/domain/usecase/GetExerciseCountInMonthUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/GetExerciseCountInMonthUseCase.kt @@ -11,8 +11,10 @@ class GetExerciseCountInMonthUseCase @Inject constructor( ) { suspend operator fun invoke( startDate: LocalDate, - endDate: LocalDate + endDate: LocalDate, + refresh: Boolean = true, + localOnly: Boolean = false ): BaseResult> { - return exerciseRecordRepository.getExerciseCountByRange(startDate, endDate) + return exerciseRecordRepository.getExerciseCountByRange(startDate, endDate, refresh, localOnly) } } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/GetExerciseRecordDetailUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/GetExerciseRecordDetailUseCase.kt index e5f777e1..bc4b17b5 100644 --- a/domain/src/main/java/com/project200/domain/usecase/GetExerciseRecordDetailUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/GetExerciseRecordDetailUseCase.kt @@ -8,7 +8,7 @@ import javax.inject.Inject class GetExerciseRecordDetailUseCase @Inject constructor( private val getExerciseRecordRepository: ExerciseRecordRepository ) { - suspend operator fun invoke(recordId: Long): BaseResult { - return getExerciseRecordRepository.getExerciseDetail(recordId) + suspend operator fun invoke(recordId: String, refresh: Boolean = true, localOnly: Boolean = false): BaseResult { + return getExerciseRecordRepository.getExerciseDetail(recordId, refresh, localOnly) } } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/GetExerciseRecordListUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/GetExerciseRecordListUseCase.kt index 21a7c911..aba1cbec 100644 --- a/domain/src/main/java/com/project200/domain/usecase/GetExerciseRecordListUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/GetExerciseRecordListUseCase.kt @@ -9,7 +9,7 @@ import javax.inject.Inject class GetExerciseRecordListUseCase @Inject constructor( private val exerciseRecordRepository: ExerciseRecordRepository ) { - suspend operator fun invoke(date: LocalDate): BaseResult> { - return exerciseRecordRepository.getExerciseRecordList(date) + suspend operator fun invoke(date: LocalDate, refresh: Boolean = true, localOnly: Boolean = false): BaseResult> { + return exerciseRecordRepository.getExerciseRecordList(date, refresh, localOnly) } } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/UpdateExerciseRecordUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/UpdateExerciseRecordUseCase.kt index e3d954f2..bf39be9b 100644 --- a/domain/src/main/java/com/project200/domain/usecase/UpdateExerciseRecordUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/UpdateExerciseRecordUseCase.kt @@ -8,7 +8,7 @@ import javax.inject.Inject class UpdateExerciseRecordUseCase @Inject constructor( private val exerciseRecordRepository: ExerciseRecordRepository ) { - suspend operator fun invoke(recordId: Long, record: ExerciseRecord): BaseResult { + suspend operator fun invoke(recordId: String, record: ExerciseRecord): BaseResult { return exerciseRecordRepository.updateExerciseRecord(record, recordId) } } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/UploadExerciseRecordImagesUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/UploadExerciseRecordImagesUseCase.kt index 34090dfd..055606d6 100644 --- a/domain/src/main/java/com/project200/domain/usecase/UploadExerciseRecordImagesUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/UploadExerciseRecordImagesUseCase.kt @@ -7,7 +7,7 @@ import javax.inject.Inject class UploadExerciseRecordImagesUseCase @Inject constructor( private val exerciseRecordRepository: ExerciseRecordRepository ) { - suspend operator fun invoke(exerciseId: Long, images: List): BaseResult { + suspend operator fun invoke(exerciseId: String, images: List): BaseResult { return exerciseRecordRepository.uploadExerciseRecordImages(exerciseId, images) } } \ No newline at end of file diff --git a/domain/src/test/java/com/project200/domain/usecase/CreateExerciseRecordUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/CreateExerciseRecordUseCaseTest.kt index 903118ef..d179e973 100644 --- a/domain/src/test/java/com/project200/domain/usecase/CreateExerciseRecordUseCaseTest.kt +++ b/domain/src/test/java/com/project200/domain/usecase/CreateExerciseRecordUseCaseTest.kt @@ -31,7 +31,7 @@ class CreateExerciseRecordUseCaseTest { title = "테스트", detail = "", personalType = "", startedAt = LocalDateTime.now(), endedAt = LocalDateTime.now(), location = "", pictures = null ) - private val recordId = 1L + private val recordId = "record-1" private val earnedPoints = 3 private val creationResult = ExerciseRecordCreationResult(recordId, earnedPoints) diff --git a/domain/src/test/java/com/project200/domain/usecase/DeleteExerciseRecordImagesUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/DeleteExerciseRecordImagesUseCaseTest.kt index 91731905..4252932f 100644 --- a/domain/src/test/java/com/project200/domain/usecase/DeleteExerciseRecordImagesUseCaseTest.kt +++ b/domain/src/test/java/com/project200/domain/usecase/DeleteExerciseRecordImagesUseCaseTest.kt @@ -24,7 +24,7 @@ class DeleteExerciseRecordImagesUseCaseTest { private lateinit var useCase: DeleteExerciseRecordImagesUseCase - private val exerciseId = 1L + private val exerciseId = "record-1" private val imageIdsToDelete = listOf(10L, 11L) @Before diff --git a/domain/src/test/java/com/project200/domain/usecase/DeleteExerciseRecordUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/DeleteExerciseRecordUseCaseTest.kt index 064d6264..c774f264 100644 --- a/domain/src/test/java/com/project200/domain/usecase/DeleteExerciseRecordUseCaseTest.kt +++ b/domain/src/test/java/com/project200/domain/usecase/DeleteExerciseRecordUseCaseTest.kt @@ -24,7 +24,7 @@ class DeleteExerciseRecordUseCaseTest { private lateinit var useCase: DeleteExerciseRecordUseCase - private val recordId = 123L + private val recordId = "record-123" @Before fun setUp() { diff --git a/domain/src/test/java/com/project200/domain/usecase/EditExerciseRecordUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/EditExerciseRecordUseCaseTest.kt index 40254623..ea2adb5e 100644 --- a/domain/src/test/java/com/project200/domain/usecase/EditExerciseRecordUseCaseTest.kt +++ b/domain/src/test/java/com/project200/domain/usecase/EditExerciseRecordUseCaseTest.kt @@ -32,7 +32,7 @@ class EditExerciseRecordUseCaseTest { private lateinit var useCase: EditExerciseRecordUseCase // 테스트용 데이터 - private val recordId = 1L + private val recordId = "record-1" private val recordToUpdate = ExerciseRecord( title = "title", detail = "detail", personalType = "type", startedAt = LocalDateTime.now(), endedAt = LocalDateTime.now(), location = "loc", pictures = null @@ -155,4 +155,29 @@ class EditExerciseRecordUseCaseTest { assertThat(result).isInstanceOf(ExerciseEditResult.ImageFailure::class.java) assertThat((result as ExerciseEditResult.ImageFailure).message).isEqualTo(error.message) } + @Test + fun `내용 수정과 이미지 작업의 캐시 경고는 성공으로 합치고 서버 작업을 재시도하지 않는다`() = runTest { + val warning = BaseResult.Error("CACHE_ERROR", "로컬 반영 실패") + coEvery { mockUpdateExerciseRecordUseCase(recordId, recordToUpdate) } returns BaseResult.Success(recordId, warning) + coEvery { mockDeleteExerciseRecordImagesUseCase(recordId, imagesToDelete) } returns BaseResult.Success(Unit, warning) + coEvery { mockUploadExerciseRecordImagesUseCase(recordId, newImages) } returns BaseResult.Success(recordId, warning) + val result = useCase(recordId, recordToUpdate, true, imagesToDelete, newImages) as ExerciseEditResult.Success + assertThat(result.refreshError).isEqualTo(warning) + coVerify(exactly = 1) { mockUpdateExerciseRecordUseCase(recordId, recordToUpdate) } + coVerify(exactly = 1) { mockDeleteExerciseRecordImagesUseCase(recordId, imagesToDelete) } + coVerify(exactly = 1) { mockUploadExerciseRecordImagesUseCase(recordId, newImages) } + } + + @Test + fun `내용 변경 없이 이미지 삭제나 업로드만 성공해도 캐시 경고를 잃지 않는다`() = runTest { + val warning = BaseResult.Error("CACHE_ERROR", "이미지 로컬 반영 실패") + coEvery { mockDeleteExerciseRecordImagesUseCase(recordId, imagesToDelete) } returns BaseResult.Success(Unit, warning) + coEvery { mockUploadExerciseRecordImagesUseCase(recordId, newImages) } returns BaseResult.Success(recordId, warning) + val deleted = useCase(recordId, recordToUpdate, false, imagesToDelete, emptyList()) as ExerciseEditResult.Success + val uploaded = useCase(recordId, recordToUpdate, false, emptyList(), newImages) as ExerciseEditResult.Success + assertThat(deleted.refreshError).isEqualTo(warning) + assertThat(uploaded.refreshError).isEqualTo(warning) + coVerify(exactly = 0) { mockUpdateExerciseRecordUseCase(any(), any()) } + } + } \ No newline at end of file diff --git a/domain/src/test/java/com/project200/domain/usecase/GetExerciseCountInMonthUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/GetExerciseCountInMonthUseCaseTest.kt deleted file mode 100644 index eb416d14..00000000 --- a/domain/src/test/java/com/project200/domain/usecase/GetExerciseCountInMonthUseCaseTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.project200.domain.usecase - -import com.google.common.truth.Truth.assertThat -import com.project200.domain.model.BaseResult -import com.project200.domain.model.ExerciseCount -import com.project200.domain.repository.ExerciseRecordRepository -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.impl.annotations.MockK -import io.mockk.junit4.MockKRule -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import java.time.LocalDate - -@ExperimentalCoroutinesApi -class GetExerciseCountInMonthUseCaseTest { - - @get:Rule - val mockkRule = MockKRule(this) - - @MockK - private lateinit var mockRepository: ExerciseRecordRepository - - private lateinit var useCase: GetExerciseCountInMonthUseCase - - private val startDate: LocalDate = LocalDate.of(2025, 6, 1) - private val endDate: LocalDate = LocalDate.of(2025, 6, 30) - private val sampleCounts = listOf( - ExerciseCount(date = LocalDate.of(2025, 6, 5), count = 2), - ExerciseCount(date = LocalDate.of(2025, 6, 10), count = 1) - ) - - @Before - fun setUp() { - useCase = GetExerciseCountInMonthUseCase(mockRepository) - } - - @Test - fun `invoke 호출 시 repository getExerciseCountByRange 호출 및 성공 결과 반환`() = runTest { - // Given - val successResult = BaseResult.Success(sampleCounts) - coEvery { mockRepository.getExerciseCountByRange(startDate, endDate) } returns successResult - - // When - val result = useCase(startDate, endDate) - - // Then - coVerify(exactly = 1) { mockRepository.getExerciseCountByRange(startDate, endDate) } - assertThat(result).isEqualTo(successResult) - assertThat((result as BaseResult.Success).data).hasSize(2) - } - - @Test - fun `invoke 호출 시 repository getExerciseCountByRange 호출 및 실패 결과 반환`() = runTest { - // Given - val errorResult = BaseResult.Error("FETCH_ERROR", "Error fetching counts") - coEvery { mockRepository.getExerciseCountByRange(startDate, endDate) } returns errorResult - - // When - val result = useCase(startDate, endDate) - - // Then - coVerify(exactly = 1) { mockRepository.getExerciseCountByRange(startDate, endDate) } - assertThat(result).isEqualTo(errorResult) - } -} \ No newline at end of file diff --git a/domain/src/test/java/com/project200/domain/usecase/GetExerciseRecordDetailUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/GetExerciseRecordDetailUseCaseTest.kt deleted file mode 100644 index b1971de9..00000000 --- a/domain/src/test/java/com/project200/domain/usecase/GetExerciseRecordDetailUseCaseTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.project200.domain.usecase - -import com.google.common.truth.Truth.assertThat -import com.project200.domain.model.BaseResult -import com.project200.domain.model.ExerciseRecord -import com.project200.domain.model.ExerciseRecordPicture -import com.project200.domain.repository.ExerciseRecordRepository -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.impl.annotations.MockK -import io.mockk.junit4.MockKRule -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import java.time.LocalDateTime - -@ExperimentalCoroutinesApi -class GetExerciseRecordDetailUseCaseTest { - - @get:Rule - val mockkRule = MockKRule(this) - - @MockK - private lateinit var mockRepository: ExerciseRecordRepository - - private lateinit var useCase: GetExerciseRecordDetailUseCase - - // 테스트용 샘플 데이터 - private val now: LocalDateTime = LocalDateTime.now() - private val sampleRecord = ExerciseRecord( - title = "Morning Swim", - detail = "A good 1km swim.", - personalType = "Swimming", - startedAt = now.minusHours(2), - endedAt = now.minusHours(1), - location = "Community Pool", - pictures = listOf(ExerciseRecordPicture(1L, "http://example.com/swim.jpg")) - ) - - @Before - fun setUp() { - useCase = GetExerciseRecordDetailUseCase(mockRepository) - } - - @Test - fun `invoke 호출 시 Repository의 getExerciseDetail을 호출하고 결과를 반환`() = runTest { - // Given - val recordId = 789L - val successResultFromRepo = BaseResult.Success(sampleRecord) - - coEvery { mockRepository.getExerciseDetail(recordId) } returns successResultFromRepo - - // When - val actualResult = useCase.invoke(recordId) - - // Then - coVerify(exactly = 1) { mockRepository.getExerciseDetail(recordId) } - assertThat(actualResult).isEqualTo(successResultFromRepo) - assertThat((actualResult as BaseResult.Success).data.personalType).isEqualTo("Swimming") - } - - @Test - fun `invoke 호출 시 Repository가 에러를 반환하면 해당 에러를 그대로 반환`() = runTest { - // Given - val recordId = 101L - val errorResultFromRepo = BaseResult.Error( "101", "Database error") - coEvery { mockRepository.getExerciseDetail(recordId) } returns errorResultFromRepo - - // When - val actualResult = useCase.invoke(recordId) - - // Then - coVerify(exactly = 1) { mockRepository.getExerciseDetail(recordId) } - assertThat(actualResult).isEqualTo(errorResultFromRepo) - assertThat((actualResult as BaseResult.Error).errorCode).isEqualTo("101") - } -} \ No newline at end of file diff --git a/domain/src/test/java/com/project200/domain/usecase/GetExerciseRecordListUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/GetExerciseRecordListUseCaseTest.kt deleted file mode 100644 index eb62454e..00000000 --- a/domain/src/test/java/com/project200/domain/usecase/GetExerciseRecordListUseCaseTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.project200.domain.usecase - -import com.google.common.truth.Truth.assertThat -import com.project200.domain.model.BaseResult -import com.project200.domain.model.ExerciseListItem // 수정된 모델 임포트 -import com.project200.domain.repository.ExerciseRecordRepository -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.impl.annotations.MockK -import io.mockk.junit4.MockKRule -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import java.time.LocalDate -import java.time.LocalDateTime - -@ExperimentalCoroutinesApi -class GetExerciseRecordListUseCaseTest { - - @get:Rule - val mockkRule = MockKRule(this) - - @MockK - private lateinit var mockRepository: ExerciseRecordRepository - - private lateinit var useCase: GetExerciseRecordListUseCase - - private val testDate: LocalDate = LocalDate.of(2024, 5, 26) - private val sampleList = listOf( - ExerciseListItem( - recordId = 1L, - title = "아침 달리기", - type = "달리기", - startTime = LocalDateTime.now().minusHours(2), - endTime = LocalDateTime.now().minusHours(1), - imageUrl = listOf("http://example.com/run.jpg", "http://example.com/run.jpg") - ), - ExerciseListItem( - recordId = 2L, - title = "저녁 헬스", - type = "웨이트 트레이닝", - startTime = LocalDateTime.now().minusHours(10), - endTime = LocalDateTime.now().minusHours(9), - imageUrl = null - ) - ) - - @Before - fun setUp() { - useCase = GetExerciseRecordListUseCase(mockRepository) - } - - @Test - fun `invoke 호출 시 Repository의 getExerciseRecordList를 호출하고 성공 결과를 반환`() = runTest { - // Given - val successResult = BaseResult.Success(sampleList) - coEvery { mockRepository.getExerciseRecordList(testDate) } returns successResult - - // When - val result = useCase.invoke(testDate) - - // Then - coVerify(exactly = 1) { mockRepository.getExerciseRecordList(testDate) } - assertThat(result).isEqualTo(successResult) - assertThat((result as BaseResult.Success).data).hasSize(2) - assertThat(result.data[0].title).isEqualTo("아침 달리기") - assertThat(result.data[0].type).isEqualTo("달리기") - assertThat(result.data[1].imageUrl).isNull() - } - - @Test - fun `invoke 호출 시 Repository가 에러를 반환하면 해당 에러를 그대로 반환`() = runTest { - // Given - val errorResult = BaseResult.Error("FETCH_ERROR", "목록을 불러오는데 실패했습니다.") - coEvery { mockRepository.getExerciseRecordList(testDate) } returns errorResult - - // When - val result = useCase.invoke(testDate) - - // Then - coVerify(exactly = 1) { mockRepository.getExerciseRecordList(testDate) } - assertThat(result).isEqualTo(errorResult) - assertThat((result as BaseResult.Error).errorCode).isEqualTo("FETCH_ERROR") - } -} \ No newline at end of file diff --git a/domain/src/test/java/com/project200/domain/usecase/UpdateExerciseRecordUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/UpdateExerciseRecordUseCaseTest.kt index a7a8601f..7d6d103e 100644 --- a/domain/src/test/java/com/project200/domain/usecase/UpdateExerciseRecordUseCaseTest.kt +++ b/domain/src/test/java/com/project200/domain/usecase/UpdateExerciseRecordUseCaseTest.kt @@ -26,7 +26,7 @@ class UpdateExerciseRecordUseCaseTest { private lateinit var useCase: UpdateExerciseRecordUseCase - private val recordId = 1L + private val recordId = "record-1" private val sampleRecordToUpdate = ExerciseRecord( title = "Updated Title", detail = "Updated detail", personalType = "Running", startedAt = LocalDateTime.now(), endedAt = LocalDateTime.now(), location = "Park", pictures = null diff --git a/domain/src/test/java/com/project200/domain/usecase/UploadExerciseRecordImagesUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/UploadExerciseRecordImagesUseCaseTest.kt index 7eec69bf..8e2670c9 100644 --- a/domain/src/test/java/com/project200/domain/usecase/UploadExerciseRecordImagesUseCaseTest.kt +++ b/domain/src/test/java/com/project200/domain/usecase/UploadExerciseRecordImagesUseCaseTest.kt @@ -24,7 +24,7 @@ class UploadExerciseRecordImagesUseCaseTest { private lateinit var useCase: UploadExerciseRecordImagesUseCase - private val recordId = 1L + private val recordId = "record-1" private val images = listOf("uri1", "uri2") @Before diff --git a/feature/exercise/src/main/AndroidManifest.xml b/feature/exercise/src/main/AndroidManifest.xml index 44008a43..f0f34af3 100644 --- a/feature/exercise/src/main/AndroidManifest.xml +++ b/feature/exercise/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ - - + + \ No newline at end of file diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt index 5c3e90db..aedb54b5 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt @@ -16,11 +16,11 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.project200.domain.model.BaseResult +import com.project200.feature.exercise.main.ExerciseMainFragment import com.project200.presentation.compose.applyAppTheme import com.project200.presentation.compose.components.feedback.UndabangAlertDialogFragment import com.project200.presentation.compose.components.feedback.UndabangBottomSheet import com.project200.presentation.utils.UiState -import com.project200.presentation.utils.mapFailureToString import com.project200.undabang.feature.exercise.R import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @@ -48,6 +48,7 @@ class ExerciseDetailFragment : Fragment() { onBackClick = { findNavController().navigateUp() }, onShareClick = ::handleShareClick, onMenuClick = ::showExerciseDetailMenu, + onRefreshClick = { viewModel.getExerciseRecord(args.recordId, force = true) }, ) } } @@ -61,21 +62,21 @@ class ExerciseDetailFragment : Fragment() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { - viewModel.exerciseRecord.collect { state -> - // 에러 시 토스트 - if (state is UiState.Error) { - Toast.makeText( - requireContext(), - requireContext().mapFailureToString(state.failure), - Toast.LENGTH_SHORT, - ).show() - } + viewModel.refreshMessages.collect { message -> + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() } } launch { viewModel.deleteResult.collect { result -> + viewModel.consumeDeleteResult() when (result) { - is BaseResult.Success -> findNavController().popBackStack() + is BaseResult.Success -> { + if (result.refreshError != null) { + Toast.makeText(requireContext(), R.string.exercise_saved_cache_refresh_fail, Toast.LENGTH_LONG).show() + } + parentFragmentManager.setFragmentResult(ExerciseMainFragment.KEY_EXERCISE_WRITTEN, Bundle()) + findNavController().popBackStack() + } is BaseResult.Error -> { Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() } @@ -93,7 +94,7 @@ class ExerciseDetailFragment : Fragment() { val savedStateHandle = findNavController().currentBackStackEntry?.savedStateHandle savedStateHandle?.getLiveData(KEY_RECORD_UPDATED)?.observe(viewLifecycleOwner) { shouldRefresh -> if (shouldRefresh) { - viewModel.getExerciseRecord(args.recordId) + viewModel.onOwnWrite(args.recordId) savedStateHandle.remove(KEY_RECORD_UPDATED) } } @@ -133,6 +134,16 @@ class ExerciseDetailFragment : Fragment() { ) } + override fun onResume() { + super.onResume() + viewModel.onScreenStarted(args.recordId) + } + + override fun onStop() { + viewModel.onScreenStopped() + super.onStop() + } + companion object { const val KEY_RECORD_UPDATED = "record_updated" } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailScreen.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailScreen.kt index 46656ede..6979eabe 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailScreen.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -62,6 +63,7 @@ fun ExerciseDetailScreen( onShareClick: () -> Unit, onMenuClick: () -> Unit, modifier: Modifier = Modifier, + onRefreshClick: () -> Unit = {}, ) { UndabangScaffold( modifier = modifier.semantics { testTagsAsResourceId = true }, @@ -70,6 +72,7 @@ fun ExerciseDetailScreen( title = stringResource(R.string.exercise_detail), onNavigationClick = onBackClick, actions = { + TextButton(onClick = onRefreshClick) { Text(stringResource(R.string.exercise_refresh)) } // 우측: 공유 + 메뉴 IconButton(onClick = onShareClick) { Icon( @@ -81,6 +84,7 @@ fun ExerciseDetailScreen( IconButton( onClick = onMenuClick, modifier = Modifier.testTag("exercise_detail_menu"), + enabled = (state as? UiState.Success)?.data?.canModify == true, ) { Icon( painter = painterResource(R.drawable.ic_menu), @@ -177,6 +181,13 @@ private fun DetailContent( } Spacer(Modifier.height(20.dp)) + if (record.syncPending) { + Text( + text = stringResource(R.string.exercise_sync_pending), + modifier = Modifier.padding(horizontal = 20.dp), + color = ColorMain, + ) + } // 제목 (항상 표시) DetailField( diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt index 95ed7dbe..83302042 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt @@ -9,12 +9,15 @@ import com.project200.domain.usecase.GetExerciseRecordDetailUseCase import com.project200.presentation.utils.UiState import com.project200.presentation.utils.mapCodeToFailure import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -31,28 +34,75 @@ class ExerciseDetailViewModel // 1회성 이벤트. emit 직후 popBackStack/토스트로 화면 사라짐 private val _deleteResult = MutableSharedFlow>(replay = 1) val deleteResult: SharedFlow> = _deleteResult.asSharedFlow() + private val notifications = Channel(Channel.BUFFERED) + val refreshMessages = notifications.receiveAsFlow() + private var initializedRecordId: String? = null + private var requestId = 0L + private var hasStarted = false + private var screenStarted = false + private var localReadSinceStop = false - fun getExerciseRecord(recordId: Long) { + fun getExerciseRecord( + recordId: String, + refresh: Boolean = true, + force: Boolean = false, + localOnly: Boolean = false, + ) { + if (!force && initializedRecordId == recordId) return + initializedRecordId = recordId + val currentRequest = ++requestId viewModelScope.launch { delay(LOADING_DELAY) - when (val result = exerciseRecordDetailUseCase(recordId)) { + val result = exerciseRecordDetailUseCase(recordId, refresh, localOnly) + if (currentRequest != requestId) return@launch + when (result) { is BaseResult.Success -> { _exerciseRecord.value = UiState.Success(result.data) + result.refreshError?.let { notifications.send(it.message ?: "기록을 새로고침하지 못했습니다.") } } is BaseResult.Error -> { - val failure = mapCodeToFailure(result.errorCode, result.message) - _exerciseRecord.value = UiState.Error(failure) + if (refresh && _exerciseRecord.value is UiState.Success) { + notifications.send(result.message ?: "기록을 새로고침하지 못했습니다.") + } else { + val failure = mapCodeToFailure(result.errorCode, result.message) + _exerciseRecord.value = UiState.Error(failure) + notifications.send(result.message ?: "기록을 불러오는데 실패했습니다.") + } } } } } - fun deleteExerciseRecord(recordId: Long) { + fun onScreenStarted(recordId: String) { + if (screenStarted) return + screenStarted = true + if (hasStarted && !localReadSinceStop) { + getExerciseRecord(recordId, refresh = false, force = true, localOnly = true) + } + hasStarted = true + } + + fun onScreenStopped() { + screenStarted = false + localReadSinceStop = false + } + + fun onOwnWrite(recordId: String) { + localReadSinceStop = true + getExerciseRecord(recordId, refresh = false, force = true) + } + + fun deleteExerciseRecord(recordId: String) { viewModelScope.launch { _deleteResult.emit(deleteExerciseRecordUseCase(recordId)) } } + @OptIn(ExperimentalCoroutinesApi::class) + fun consumeDeleteResult() { + _deleteResult.resetReplayCache() + } + companion object { private const val LOADING_DELAY = 300L } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt index c7d5568f..83ce28d8 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt @@ -26,6 +26,7 @@ import com.project200.common.constants.RuleConstants.MAX_IMAGE import com.project200.domain.model.ExerciseEditResult import com.project200.domain.model.SubmissionResult import com.project200.feature.exercise.detail.ExerciseDetailFragment +import com.project200.feature.exercise.main.ExerciseMainFragment import com.project200.presentation.compose.applyAppTheme import com.project200.presentation.compose.components.feedback.UndabangBottomSheet import com.project200.presentation.utils.ImageUtils.compressImage @@ -92,7 +93,7 @@ class ExerciseFormFragment : Fragment() { ObservePlaceSearchResult { name -> selectedLocation = name } ExerciseFormScreen( - isEditMode = args.recordId != -1L, + isEditMode = args.recordId.isNotEmpty(), title = title, selectedType = selectedType, directTypeInput = directTypeInput, @@ -157,6 +158,11 @@ class ExerciseFormFragment : Fragment() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.refreshMessages.collect { messageId -> + Toast.makeText(requireContext(), messageId, Toast.LENGTH_SHORT).show() + } + } launch { viewModel.toastMessage.collect { messageId -> Toast.makeText(requireContext(), getString(messageId), Toast.LENGTH_SHORT).show() @@ -164,10 +170,20 @@ class ExerciseFormFragment : Fragment() { } launch { viewModel.createResult.collect { result -> + viewModel.consumeCreateResult() when (result) { - is SubmissionResult.Success -> handleSuccessfulCreate(result.earnedPoints) + is SubmissionResult.Success -> { + if (result.refreshError != null) { + Toast.makeText(requireContext(), R.string.exercise_saved_cache_refresh_fail, Toast.LENGTH_LONG).show() + } + notifyOwnWrite() + handleSuccessfulCreate(result.earnedPoints) + } is SubmissionResult.PartialSuccess -> { // 부분 성공 (이미지 업로드 실패) + val message = withCacheWarning(getString(result.messageId), result.refreshError != null) + Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() + notifyOwnWrite() findNavController().navigate( ExerciseFormFragmentDirections .actionExerciseFormFragmentToExerciseDetailFragment(result.recordId), @@ -179,6 +195,7 @@ class ExerciseFormFragment : Fragment() { } launch { viewModel.editResult.collect { result -> + viewModel.consumeEditResult() handleEditResult(result) } } @@ -187,9 +204,13 @@ class ExerciseFormFragment : Fragment() { } private fun handleEditResult(result: ExerciseEditResult) { + if (result !is ExerciseEditResult.Failure) notifyOwnWrite() when (result) { is ExerciseEditResult.Success -> { // 기록 수정, 이미지 삭제/업로드 성공 + if (result.refreshError != null) { + Toast.makeText(requireContext(), R.string.exercise_saved_cache_refresh_fail, Toast.LENGTH_LONG).show() + } findNavController().previousBackStackEntry?.savedStateHandle?.set( ExerciseDetailFragment.KEY_RECORD_UPDATED, true, @@ -198,7 +219,7 @@ class ExerciseFormFragment : Fragment() { } is ExerciseEditResult.ContentFailure -> { // 내용 수정 실패 - Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), withCacheWarning(result.message, result.refreshError != null), Toast.LENGTH_LONG).show() findNavController().previousBackStackEntry?.savedStateHandle?.set( ExerciseDetailFragment.KEY_RECORD_UPDATED, true, @@ -207,7 +228,7 @@ class ExerciseFormFragment : Fragment() { } is ExerciseEditResult.ImageFailure -> { // 이미지 삭제/업로드 실패 - Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), withCacheWarning(result.message, result.refreshError != null), Toast.LENGTH_LONG).show() findNavController().previousBackStackEntry?.savedStateHandle?.set( ExerciseDetailFragment.KEY_RECORD_UPDATED, true, @@ -221,6 +242,15 @@ class ExerciseFormFragment : Fragment() { } } + private fun withCacheWarning( + message: String, + hasCacheWarning: Boolean, + ): String = if (hasCacheWarning) "$message\n${getString(R.string.exercise_saved_cache_refresh_fail)}" else message + + private fun notifyOwnWrite() { + parentFragmentManager.setFragmentResult(ExerciseMainFragment.KEY_EXERCISE_WRITTEN, Bundle()) + } + private fun handleDateSelected(date: LocalDate) { viewModel.updateDate(date.year, date.monthValue, date.dayOfMonth) } @@ -333,8 +363,12 @@ class ExerciseFormFragment : Fragment() { initialRecord: com.project200.domain.model.ExerciseRecord?, onLoaded: (com.project200.domain.model.ExerciseRecord) -> Unit, ) { + var applied by rememberSaveable { mutableStateOf(false) } androidx.compose.runtime.LaunchedEffect(initialRecord) { - initialRecord?.let(onLoaded) + if (!applied && initialRecord != null) { + onLoaded(initialRecord) + applied = true + } } } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt index 2161ec92..0ca37e3f 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt @@ -21,12 +21,15 @@ import com.project200.feature.exercise.utils.ScoreGuidanceState import com.project200.feature.exercise.utils.TimeSelectionState import com.project200.undabang.feature.exercise.R import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import java.time.LocalDate import java.time.LocalDateTime @@ -87,16 +90,31 @@ class ExerciseFormViewModel private val _toastMessage = MutableSharedFlow(replay = 1) val toastMessage: SharedFlow = _toastMessage.asSharedFlow() + private val refreshNotifications = Channel(Channel.BUFFERED) + val refreshMessages = refreshNotifications.receiveAsFlow() private val _scoreGuidanceState = MutableStateFlow(ScoreGuidanceState.Hidden) val scoreGuidanceState: StateFlow = _scoreGuidanceState.asStateFlow() private val _timeSelectionState = MutableStateFlow(TimeSelectionState.NONE) val timeSelectionState: StateFlow = _timeSelectionState.asStateFlow() + private var initializedRecordId: String? = null + + @OptIn(ExperimentalCoroutinesApi::class) + fun consumeCreateResult() { + _createResult.resetReplayCache() + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun consumeEditResult() { + _editResult.resetReplayCache() + } /** 초기 데이터 설정 */ - fun loadInitialRecord(recordId: Long) { - if (recordId == -1L) { + fun loadInitialRecord(recordId: String) { + if (initializedRecordId == recordId) return + initializedRecordId = recordId + if (recordId.isEmpty()) { // 생성 모드 isEditMode = false initialRecord = null @@ -114,9 +132,10 @@ class ExerciseFormViewModel when (val result = getExerciseRecordDetailUseCase(recordId)) { is BaseResult.Success -> { setupEditMode(result.data) + if (result.refreshError != null) refreshNotifications.send(R.string.exercise_load_fail) } is BaseResult.Error -> { - _toastMessage.emit(R.string.exercise_load_fail) + refreshNotifications.send(R.string.exercise_load_fail) } } } @@ -355,7 +374,7 @@ class ExerciseFormViewModel /** 기록 생성 또는 수정 */ fun submitRecord( - recordId: Long, + recordId: String, title: String, type: String, location: String, @@ -410,7 +429,12 @@ class ExerciseFormViewModel when (val createResult = createExerciseRecordUseCase(record)) { is BaseResult.Success -> { // 기록 생성 성공 시, 이미지 업로드 로직 호출 - handleSuccessfulRecordCreation(createResult.data.recordId, newImageUris, createResult.data.earnedPoints) + handleSuccessfulRecordCreation( + createResult.data.recordId, + newImageUris, + createResult.data.earnedPoints, + createResult.refreshError, + ) } is BaseResult.Error -> { // 기록 생성 실패 @@ -424,31 +448,32 @@ class ExerciseFormViewModel /** 기록 생성 후 이미지 업로드 처리 */ private suspend fun handleSuccessfulRecordCreation( - recordId: Long, + recordId: String, newImageUris: List, earnedPoints: Int, + refreshError: BaseResult.Error?, ) { if (newImageUris.isNotEmpty()) { - when (uploadExerciseRecordImagesUseCase(recordId, newImageUris)) { + when (val uploadResult = uploadExerciseRecordImagesUseCase(recordId, newImageUris)) { is BaseResult.Success -> { // 이미지 업로드 성공 -> 최종 성공 - _createResult.emit(SubmissionResult.Success(recordId, earnedPoints)) + _createResult.emit(SubmissionResult.Success(recordId, earnedPoints, refreshError ?: uploadResult.refreshError)) } is BaseResult.Error -> { // 이미지 업로드 실패 -> 부분 성공 - _createResult.emit(SubmissionResult.PartialSuccess(recordId, R.string.exercise_upload_fail)) + _createResult.emit(SubmissionResult.PartialSuccess(recordId, R.string.exercise_upload_fail, refreshError)) } } } else { // 업로드할 이미지가 없음 -> 최종 성공 - _createResult.emit(SubmissionResult.Success(recordId, earnedPoints)) + _createResult.emit(SubmissionResult.Success(recordId, earnedPoints, refreshError)) } _isLoading.value = false // 이미지 업로드까지 완료된 후 로딩 종료 } /** 기록 수정 */ private fun editExerciseRecord( - recordId: Long, + recordId: String, record: ExerciseRecord, newImageUris: List, ) { diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseListAdapter.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseListAdapter.kt index 93e96127..d68ffdc7 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseListAdapter.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseListAdapter.kt @@ -2,6 +2,7 @@ package com.project200.feature.exercise.main import android.view.LayoutInflater import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView @@ -15,7 +16,7 @@ import com.project200.undabang.feature.exercise.databinding.ItemExerciseListBind import java.time.format.DateTimeFormatter class ExerciseListAdapter( - private val onItemClicked: (Long) -> Unit, + private val onItemClicked: (String) -> Unit, ) : ListAdapter(ExerciseDiffCallback()) { private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm") @@ -60,7 +61,7 @@ class ExerciseListAdapter( class ExerciseViewHolder( private val binding: ItemExerciseListBinding, - private val onItemClicked: (Long) -> Unit, + private val onItemClicked: (String) -> Unit, private val formatter: DateTimeFormatter, ) : RecyclerView.ViewHolder(binding.root) { fun bind( @@ -69,6 +70,7 @@ class ExerciseListAdapter( ) { with(binding) { exerciseTitleTv.text = item.title + exerciseSyncPendingTv.isVisible = item.syncPending // 시간 포맷팅 적용 exerciseTimeTv.text = "${item.startTime.format(formatter)} ~ ${item.endTime.format(formatter)}" // 실제 마지막 행의 시간 텍스트를 목록 끝의 접근성 경계로 사용합니다. @@ -86,6 +88,7 @@ class ExerciseListAdapter( .transform(CenterCrop(), RoundedCorners(dpToPx(exerciseIv.context, 8f))) .into(exerciseIv) } else { + Glide.with(exerciseIv).clear(exerciseIv) exerciseIv.setImageResource(R.drawable.ic_empty_img) // 기본 이미지 } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainFragment.kt index cfcfd92f..2dda5434 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainFragment.kt @@ -20,7 +20,7 @@ import com.project200.common.constants.RuleConstants.SCORE_MIDDLE_LEVEL import com.project200.common.utils.CommonDateTimeFormatters.YYYY_M_KR import com.project200.presentation.base.BindingFragment import com.project200.undabang.feature.exercise.R -import com.project200.undabang.feature.exercise.databinding.CalendarDayLayoutBinding +import com.project200.undabang.feature.exercise.databinding.ExerciseCalendarDayLayoutBinding import com.project200.undabang.feature.exercise.databinding.FragmentExerciseMainBinding import dagger.hilt.android.AndroidEntryPoint import java.time.DayOfWeek @@ -32,6 +32,7 @@ import java.time.ZoneId class ExerciseMainFragment : BindingFragment(R.layout.fragment_exercise_main) { private val viewModel: ExerciseMainViewModel by viewModels() private var exerciseCompleteDates: Set = emptySet() + private var pendingDates: Set = emptySet() private var listSubmissionId = 0L private var isListCommitted = false private val exerciseAdapter: ExerciseListAdapter by lazy { @@ -54,8 +55,10 @@ class ExerciseMainFragment : BindingFragment(R.layo setupCalendar() setupRecyclerView() - // 예상 획득 점수 정보 불러오기 - viewModel.loadExpectedScoreInfo() + binding.scrollView.apply { post { scrollTo(0, viewModel.scrollPosition) } } + parentFragmentManager.setFragmentResultListener(KEY_EXERCISE_WRITTEN, viewLifecycleOwner) { _, _ -> + viewModel.onOwnWrite() + } } private fun setupCalendar() { @@ -78,7 +81,7 @@ class ExerciseMainFragment : BindingFragment(R.layo dayBinder = object : MonthDayBinder { // DayViewContainer 인스턴스를 생성 - override fun create(view: View) = DayViewContainer(CalendarDayLayoutBinding.bind(view)) + override fun create(view: View) = DayViewContainer(ExerciseCalendarDayLayoutBinding.bind(view)) // 생성된 View에 데이터를 바인딩 override fun bind( @@ -90,13 +93,21 @@ class ExerciseMainFragment : BindingFragment(R.layo calendarDayTv.text = data.date.dayOfMonth.toString() // 숫자만 같은 다른 달의 날짜와 구분하고 실제 날짜를 접근성에 제공합니다. calendarDayTv.contentDescription = - if (data.position == DayPosition.MonthDate) data.date.toString() else null + if (data.position != DayPosition.MonthDate) { + null + } else if (data.date in pendingDates) { + getString(R.string.exercise_pending_date, data.date) + } else { + data.date.toString() + } // 선택 효과, 오늘 날짜 표시, 운동 완료 점을 모두 초기 상태로 리셋합니다. calendarDayTv.background = null selectedIv.isVisible = false + exercisePendingTv.isVisible = data.position == DayPosition.MonthDate && data.date in pendingDates exerciseCompleteIv.apply { isVisible = false + contentDescription = null animate().cancel() // 진행 중인 애니메이션이 있다면 취소 alpha = 1f } @@ -121,6 +132,8 @@ class ExerciseMainFragment : BindingFragment(R.layo // 운동 기록이 있는 날 표시 if (exerciseCompleteDates.contains(data.date)) { + exerciseCompleteIv.contentDescription = + if (data.date in pendingDates) getString(R.string.exercise_sync_pending) else null exerciseCompleteIv.apply { alpha = 0f isVisible = true @@ -144,6 +157,7 @@ class ExerciseMainFragment : BindingFragment(R.layo } private fun setupBtnListeners() { + binding.exerciseRefreshBtn.setOnClickListener { viewModel.refreshData() } binding.prevMonthBtn.setOnClickListener { binding.exerciseCalendar.findFirstVisibleMonth()?.let { val previousMonth = it.yearMonth.minusMonths(1) @@ -199,9 +213,15 @@ class ExerciseMainFragment : BindingFragment(R.layo exerciseCompleteDates = dates binding.exerciseCalendar.notifyCalendarChanged() } + viewModel.pendingDates.observe(viewLifecycleOwner) { dates -> + pendingDates = dates + binding.exerciseCalendar.notifyCalendarChanged() + } viewModel.toastMessage.observe(viewLifecycleOwner) { message -> - Toast.makeText(requireContext(), message ?: getText(R.string.data_error), Toast.LENGTH_SHORT).show() + if (message == null) return@observe + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() + viewModel.consumeToastMessage() } viewModel.score.observe(viewLifecycleOwner) { score -> @@ -248,8 +268,6 @@ class ExerciseMainFragment : BindingFragment(R.layo oldDate?.let { binding.exerciseCalendar.notifyDateChanged(it) } binding.exerciseCalendar.notifyDateChanged(newDate) oldDate = newDate - // 캘린더를 최상단으로 스크롤 - binding.scrollView.smoothScrollTo(0, binding.exerciseCntScoreCl.height.plus(binding.topBarCl.height)) } // 운동 목록이 변경될 때 RecyclerView 업데이트 @@ -275,6 +293,10 @@ class ExerciseMainFragment : BindingFragment(R.layo viewModel.isExerciseListLoaded.observe(viewLifecycleOwner) { updateListSelectors() } + viewModel.exerciseListError.observe(viewLifecycleOwner) { error -> + binding.exerciseListEmptyTv.text = error?.message ?: getString(R.string.exercise_empty) + binding.exerciseListEmptyTv.isVisible = error != null || exerciseAdapter.currentList.isEmpty() + } } private fun updateListSelectors() { @@ -287,7 +309,7 @@ class ExerciseMainFragment : BindingFragment(R.layo if (isLoaded && exerciseAdapter.currentList.isEmpty()) getString(R.string.exercise_empty_loaded, date) else null } - inner class DayViewContainer(val binding: CalendarDayLayoutBinding) : + inner class DayViewContainer(val binding: ExerciseCalendarDayLayoutBinding) : ViewContainer(binding.root) { lateinit var day: CalendarDay @@ -296,6 +318,12 @@ class ExerciseMainFragment : BindingFragment(R.layo if (day.position == DayPosition.MonthDate && !day.date.isAfter(LocalDate.now(ZoneId.of("Asia/Seoul")))) { if (viewModel.selectedDate.value != day.date) { viewModel.onDateSelected(day.date) + this@ExerciseMainFragment.binding.let { mainBinding -> + mainBinding.scrollView.smoothScrollTo( + 0, + mainBinding.exerciseCntScoreCl.height.plus(mainBinding.topBarCl.height), + ) + } } } } @@ -304,6 +332,21 @@ class ExerciseMainFragment : BindingFragment(R.layo override fun onResume() { super.onResume() - viewModel.refreshData() + viewModel.onScreenStarted() + } + + override fun onStop() { + viewModel.scrollPosition = binding.scrollView.scrollY + viewModel.onScreenStopped() + super.onStop() + } + + override fun onDestroyView() { + viewModel.scrollPosition = binding.scrollView.scrollY + super.onDestroyView() + } + + companion object { + const val KEY_EXERCISE_WRITTEN = "exercise_written" } } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainViewModel.kt index d8f2fe8a..3c47e8d1 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainViewModel.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.common.utils.ClockProvider +import com.project200.common.utils.NetworkMonitor import com.project200.domain.model.BaseResult import com.project200.domain.model.ExerciseListItem import com.project200.domain.model.ExpectedScoreInfo @@ -31,12 +32,22 @@ class ExerciseMainViewModel private val getExerciseRecordListUseCase: GetExerciseRecordListUseCase, private val getExpectedScoreInfoUseCase: GetExpectedScoreInfoUseCase, private val clockProvider: ClockProvider, + private val networkMonitor: NetworkMonitor, ) : ViewModel() { private val _selectedMonth = MutableLiveData() val selectedMonth: LiveData = _selectedMonth private val _exerciseDates = MutableLiveData>(emptySet()) val exerciseDates: LiveData> = _exerciseDates + private val _pendingDates = MutableLiveData>(emptySet()) + val pendingDates: LiveData> = _pendingDates + private val pendingDateCache = mutableMapOf>() + private val monthRequestIds = mutableMapOf() + private var refreshNotificationShown = false + var scrollPosition = 0 + private var hasStarted = false + private var screenStarted = false + private var localReadSinceStop = false private val _toastMessage = MutableLiveData() val toastMessage: LiveData = _toastMessage @@ -63,6 +74,8 @@ class ExerciseMainViewModel // 초기값과 조회 실패가 삭제 검증의 빈 목록 선택자로 노출되지 않게 합니다. private val _isExerciseListLoaded = MutableLiveData(false) val isExerciseListLoaded: LiveData = _isExerciseListLoaded + private val _exerciseListError = MutableLiveData() + val exerciseListError: LiveData = _exerciseListError private var exerciseListRequestId = 0L private val expectedScoreInfo = MutableLiveData() @@ -71,87 +84,96 @@ class ExerciseMainViewModel val earnablePoints: LiveData = _earnablePoints init { - if (_selectedMonth.value == null) { - _selectedMonth.value = clockProvider.yearMonthNow() - } - val today = clockProvider.now() _selectedMonth.value = YearMonth.from(today) - _selectedDate.value = today // 오늘 날짜를 기본 선택 날짜로 설정 - - getExerciseCntThisMonth(clockProvider.yearMonthNow(), clockProvider.now()) + _selectedDate.value = today + getExerciseCounts(YearMonth.from(today), today) loadScorePolicy() - loadExercisesForDate(today) // 앱 시작 시 오늘 날짜의 운동 목록 로드 + getScore() + loadExpectedScoreInfo() + loadExercisesForDate(today) } fun onMonthChanged(newMonth: YearMonth) { - // 중복 호출 방지 if (_selectedMonth.value == newMonth) return - _selectedMonth.value = newMonth - - if (!exerciseCache.containsKey(newMonth)) { // 캐시에 데이터가 있으면 캐시 데이터를 사용하고 api 호출 x - getExerciseCounts(newMonth, clockProvider.now()) - } + refreshNotificationShown = false + if (!exerciseCache.containsKey(newMonth)) getExerciseCounts(newMonth, clockProvider.now()) } fun onDateSelected(date: LocalDate) { + if (_selectedDate.value == date) return + refreshNotificationShown = false _isExerciseListLoaded.value = false _selectedDate.value = date + _exerciseList.value = emptyList() loadExercisesForDate(date) updateEarnablePoints() } - // --- 특정 날짜의 운동 목록을 가져오는 함수 --- - private fun loadExercisesForDate(date: LocalDate) { + private fun loadExercisesForDate( + date: LocalDate, + refresh: Boolean = true, + localOnly: Boolean = false, + ) { val requestId = ++exerciseListRequestId _isExerciseListLoaded.value = false + _exerciseListError.value = null viewModelScope.launch { - // 이전 요청의 완료 상태가 새 응답의 목록 관찰자에 전달되지 않게 먼저 해제합니다. - val result = getExerciseRecordListUseCase(date) - _isExerciseListLoaded.value = false + val result = getExerciseRecordListUseCase(date, refresh, localOnly) + // 이전 날짜 또는 이전 갱신의 응답은 행·오류·완료 상태 어느 것도 바꾸지 않습니다. + if (requestId != exerciseListRequestId || date != _selectedDate.value) return@launch when (result) { is BaseResult.Success -> { _exerciseList.value = result.data - _isExerciseListLoaded.value = requestId == exerciseListRequestId && date == _selectedDate.value + _isExerciseListLoaded.value = true + result.refreshError?.let { notifyRefreshError(it) } } is BaseResult.Error -> { - _exerciseList.value = emptyList() // 실패 시 빈 리스트 - _toastMessage.value = result.message ?: "기록을 불러오는데 실패했습니다." + _exerciseListError.value = result + notifyRefreshError(result) } } } } - // 캘린더 한달 운동 조회 private fun getExerciseCounts( yearMonth: YearMonth, today: LocalDate, + refresh: Boolean = true, + localOnly: Boolean = false, ) { + val requestId = (monthRequestIds[yearMonth] ?: 0) + 1 + monthRequestIds[yearMonth] = requestId viewModelScope.launch { - val startDate = yearMonth.atDay(1) - // 이번 달인 경우, 끝 날짜를 오늘로 설정 val endDate = if (yearMonth == YearMonth.from(today)) today else yearMonth.atEndOfMonth() - - when (val result = getExerciseCountInMonthUseCase(startDate, endDate)) { + val result = getExerciseCountInMonthUseCase(yearMonth.atDay(1), endDate, refresh, localOnly) + if (monthRequestIds[yearMonth] != requestId) return@launch + when (result) { is BaseResult.Success -> { - // 운동 기록 횟수 set 으로 변환 - val datesWithExercise = - result.data - .filter { it.count > 0 } - .map { it.date } - .toSet() - - exerciseCache[yearMonth] = datesWithExercise + exerciseCache[yearMonth] = result.data.filter { it.count > 0 }.map { it.date }.toSet() + pendingDateCache[yearMonth] = result.data.filter { it.syncPending }.map { it.date }.toSet() _exerciseDates.value = exerciseCache.values.flatten().toSet() + _pendingDates.value = pendingDateCache.values.flatten().toSet() + if (yearMonth == clockProvider.yearMonthNow()) _exerciseCount.value = result.data.sumOf { it.count } + result.refreshError?.let { notifyRefreshError(it) } } - is BaseResult.Error -> { - _toastMessage.value = result.message - } + is BaseResult.Error -> notifyRefreshError(result) } } } + private fun notifyRefreshError(error: BaseResult.Error) { + if (error.errorCode == "NETWORK_ERROR" && !networkMonitor.isCurrentlyConnected()) return + if (refreshNotificationShown) return + refreshNotificationShown = true + _toastMessage.value = error.message ?: "기록을 새로고침하지 못했습니다." + } + + fun consumeToastMessage() { + _toastMessage.value = null + } + // 점수 정책 조회 private fun loadScorePolicy() { viewModelScope.launch { @@ -160,26 +182,7 @@ class ExerciseMainViewModel _scorePolicy.value = result.data } is BaseResult.Error -> { - _toastMessage.value = result.message - } - } - } - } - - // 이번 달 운동 횟수 조회 - private fun getExerciseCntThisMonth( - yearMonth: YearMonth, - today: LocalDate, - ) { - viewModelScope.launch { - when (val result = getExerciseCountInMonthUseCase(yearMonth.atDay(1), today)) { - is BaseResult.Success -> { - val totalCount = result.data.sumOf { it.count } - _exerciseCount.value = totalCount - } - is BaseResult.Error -> { - _toastMessage.value = result.message - _exerciseCount.value = 0 // 에러 발생 시 횟수를 0으로 설정 + notifyRefreshError(result) } } } @@ -192,7 +195,7 @@ class ExerciseMainViewModel _score.value = result.data } is BaseResult.Error -> { - _toastMessage.value = result.message + notifyRefreshError(result) } } } @@ -244,15 +247,45 @@ class ExerciseMainViewModel } } + /** 명시적 새로고침만 서버에 요청합니다. 선택 날짜·월·스크롤은 유지합니다. */ fun refreshData() { - exerciseCache.clear() - _selectedMonth.value?.let { - getExerciseCounts(it, clockProvider.now()) - } - _selectedDate.value?.let { - loadExercisesForDate(it) - } - getExerciseCntThisMonth(clockProvider.yearMonthNow(), clockProvider.now()) + refreshNotificationShown = false + reloadExerciseQueries(refresh = true) getScore() } + + /** 자신의 생성·수정·삭제 후에는 캐시 우선으로 읽고, 온라인 미적중만 갱신합니다. */ + fun onOwnWrite() { + localReadSinceStop = true + refreshNotificationShown = false + reloadExerciseQueries(refresh = false) + getScore() + loadExpectedScoreInfo() + } + + /** 첫 시작은 init 조회를 유지하고, 복귀는 네트워크 없이 한 번만 다시 읽습니다. */ + fun onScreenStarted() { + if (screenStarted) return + screenStarted = true + if (hasStarted && !localReadSinceStop) { + reloadExerciseQueries(refresh = false, localOnly = true) + } + hasStarted = true + } + + fun onScreenStopped() { + screenStarted = false + localReadSinceStop = false + } + + private fun reloadExerciseQueries( + refresh: Boolean, + localOnly: Boolean = false, + ) { + val today = clockProvider.now() + val visibleMonths = listOfNotNull(_selectedMonth.value, clockProvider.yearMonthNow()).toSet() + val months = if (refresh) visibleMonths else exerciseCache.keys + visibleMonths + months.forEach { getExerciseCounts(it, today, refresh, localOnly) } + _selectedDate.value?.let { loadExercisesForDate(it, refresh, localOnly) } + } } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/share/ExerciseShareEditFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/share/ExerciseShareEditFragment.kt index 40eda754..99fbf5cc 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/share/ExerciseShareEditFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/share/ExerciseShareEditFragment.kt @@ -2,6 +2,7 @@ package com.project200.feature.exercise.share import android.graphics.Bitmap import android.view.View +import android.widget.Toast import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -54,6 +55,11 @@ class ExerciseShareEditFragment : BindingFragment + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() + } + } launch { viewModel.backgroundImageUrl.collect { url -> url?.let { loadBackgroundImage(it) } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/share/ExerciseShareEditViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/share/ExerciseShareEditViewModel.kt index 53444b01..a882ecb5 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/share/ExerciseShareEditViewModel.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/share/ExerciseShareEditViewModel.kt @@ -9,6 +9,7 @@ import com.project200.feature.exercise.utils.ShareEventData import com.project200.feature.exercise.utils.StickerState import com.project200.feature.exercise.utils.StickerTransformInfo import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -41,21 +43,28 @@ class ExerciseShareEditViewModel private val _shareEvent = MutableSharedFlow() val shareEvent: SharedFlow = _shareEvent.asSharedFlow() + private var initializedRecordId: String? = null + private val notifications = Channel(Channel.BUFFERED) + val refreshMessages = notifications.receiveAsFlow() val stickerState: Flow = _exerciseRecord .filterNotNull() .combine(_selectedTheme) { record, theme -> StickerState(record, theme) } - fun loadExerciseRecord(recordId: Long) { + fun loadExerciseRecord(recordId: String) { + if (initializedRecordId == recordId) return + initializedRecordId = recordId viewModelScope.launch { _isLoading.value = true when (val result = getExerciseRecordDetailUseCase(recordId)) { is BaseResult.Success -> { _exerciseRecord.value = result.data _backgroundImageUrl.value = result.data.pictures?.firstOrNull()?.url + result.refreshError?.let { notifications.send(it.message ?: "기록을 새로고침하지 못했습니다.") } } is BaseResult.Error -> { + notifications.send(result.message ?: "기록을 불러오는데 실패했습니다.") } } _isLoading.value = false diff --git a/feature/exercise/src/main/res/layout/calendar_day_layout.xml b/feature/exercise/src/main/res/layout/exercise_calendar_day_layout.xml similarity index 78% rename from feature/exercise/src/main/res/layout/calendar_day_layout.xml rename to feature/exercise/src/main/res/layout/exercise_calendar_day_layout.xml index 3e302429..57a95fb9 100644 --- a/feature/exercise/src/main/res/layout/calendar_day_layout.xml +++ b/feature/exercise/src/main/res/layout/exercise_calendar_day_layout.xml @@ -36,5 +36,16 @@ app:layout_constraintEnd_toEndOf="@+id/calendar_day_tv" app:layout_constraintStart_toStartOf="@+id/calendar_day_tv" app:layout_constraintTop_toTopOf="@id/calendar_day_tv" /> + \ No newline at end of file diff --git a/feature/exercise/src/main/res/layout/fragment_exercise_main.xml b/feature/exercise/src/main/res/layout/fragment_exercise_main.xml index a8094231..ccbe6a9b 100644 --- a/feature/exercise/src/main/res/layout/fragment_exercise_main.xml +++ b/feature/exercise/src/main/res/layout/fragment_exercise_main.xml @@ -30,6 +30,17 @@ android:src="@drawable/ic_logo_appname" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + diff --git a/feature/exercise/src/main/res/layout/item_exercise_list.xml b/feature/exercise/src/main/res/layout/item_exercise_list.xml index 086d2e33..f777f97a 100644 --- a/feature/exercise/src/main/res/layout/item_exercise_list.xml +++ b/feature/exercise/src/main/res/layout/item_exercise_list.xml @@ -38,6 +38,13 @@ android:layout_height="wrap_content" style="@style/content_bold" android:text="asdasd" /> + + app:argType="string" /> + app:argType="string" /> + app:argType="string" /> + android:defaultValue="" + app:argType="string" /> + app:argType="string" /> + app:argType="string" /> + app:argType="string" /> \ No newline at end of file diff --git a/feature/exercise/src/main/res/values/strings.xml b/feature/exercise/src/main/res/values/strings.xml index 08c9e7de..0b7e08c1 100644 --- a/feature/exercise/src/main/res/values/strings.xml +++ b/feature/exercise/src/main/res/values/strings.xml @@ -29,6 +29,9 @@ %1$s 운동 기록 목록 %1$s에 운동 기록이 없습니다 운동 기록 목록 마지막 항목: %1$s + 동기화 대기 + %1$s 동기화 대기 + 새로고침 운동하는 장소 검색 검색 입력 @@ -79,6 +82,7 @@ 기록을 불러오는데 실패했습니다 + 서버에 반영했지만 기기에 저장된 운동 기록을 새로고침하지 못했습니다. 변경 사항이 없습니다 필수 항목을 입력해주세요. 기록 생성에 실패했습니다 diff --git a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt index f9bf20ef..35a1b6d9 100644 --- a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt +++ b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt @@ -2,6 +2,7 @@ package com.project200.feature.exercise import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.project200.domain.model.BaseResult import com.project200.domain.model.ExerciseRecord @@ -57,7 +58,7 @@ class ExerciseDetailViewModelTest { location = "여의도 공원", pictures = listOf(ExerciseRecordPicture(1L, "http://example.com/img1.jpg")), ) - private val recordId = 123L + private val recordId = "record-123" @Before fun setUp() { @@ -154,4 +155,81 @@ class ExerciseDetailViewModelTest { assertThat(actualResult).isEqualTo(errorResult) assertThat((actualResult as BaseResult.Error).message).isEqualTo("삭제 실패") } + + @Test + fun `회전이나 화면 재진입은 상세 조회를 반복하지 않는다`() = + runTest(testDispatcher) { + coEvery { mockGetExerciseUseCase(recordId) } returns BaseResult.Success(sampleRecord) + viewModel.getExerciseRecord(recordId) + testDispatcher.scheduler.advanceUntilIdle() + viewModel.getExerciseRecord(recordId) + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 1) { mockGetExerciseUseCase(recordId) } + assertThat((viewModel.exerciseRecord.value as UiState.Success).data).isEqualTo(sampleRecord) + } + + @Test + fun `갱신 실패는 캐시 상세를 유지하고 알림을 한 번만 전달한다`() = + runTest(testDispatcher) { + coEvery { mockGetExerciseUseCase(recordId) } returns BaseResult.Success(sampleRecord, BaseResult.Error("401", "인증 실패"), true) + viewModel.getExerciseRecord(recordId) + testDispatcher.scheduler.advanceUntilIdle() + assertThat((viewModel.exerciseRecord.value as UiState.Success).data).isEqualTo(sampleRecord) + viewModel.refreshMessages.test { + assertThat(awaitItem()).isEqualTo("인증 실패") + viewModel.getExerciseRecord(recordId) + testDispatcher.scheduler.advanceUntilIdle() + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `자신의 수정 후 상세는 서버 갱신 없이 로컬 값을 읽는다`() = + runTest(testDispatcher) { + coEvery { mockGetExerciseUseCase(recordId) } returns BaseResult.Success(sampleRecord) + viewModel.getExerciseRecord(recordId) + testDispatcher.scheduler.advanceUntilIdle() + val changed = sampleRecord.copy(title = "수정한 운동") + coEvery { mockGetExerciseUseCase(recordId, false) } returns BaseResult.Success(changed, isCachedSnapshot = true) + viewModel.onOwnWrite(recordId) + testDispatcher.scheduler.advanceUntilIdle() + assertThat((viewModel.exerciseRecord.value as UiState.Success).data).isEqualTo(changed) + coVerify(exactly = 1) { mockGetExerciseUseCase(recordId, false) } + coVerify(exactly = 1) { mockGetExerciseUseCase(recordId, true) } + } + + @Test + fun `첫 시작은 상세를 중복 조회하지 않고 복귀는 로컬 전용으로 한 번 읽는다`() = + runTest(testDispatcher) { + coEvery { mockGetExerciseUseCase(recordId) } returns BaseResult.Success(sampleRecord) + viewModel.getExerciseRecord(recordId) + viewModel.onScreenStarted(recordId) + viewModel.onScreenStarted(recordId) + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 1) { mockGetExerciseUseCase(recordId, true, false) } + val changed = sampleRecord.copy(title = "복귀한 로컬 상세") + coEvery { mockGetExerciseUseCase(recordId, false, true) } returns BaseResult.Success(changed, isCachedSnapshot = true) + viewModel.onScreenStopped() + viewModel.onScreenStarted(recordId) + viewModel.onScreenStarted(recordId) + testDispatcher.scheduler.advanceUntilIdle() + assertThat((viewModel.exerciseRecord.value as UiState.Success).data).isEqualTo(changed) + coVerify(exactly = 1) { mockGetExerciseUseCase(recordId, false, true) } + coVerify(exactly = 1) { mockGetExerciseUseCase(recordId, true, false) } + } + + @Test + fun `삭제 성공의 캐시 경고는 성공 결과에 남고 서버 삭제를 재시도하지 않는다`() = + runTest(testDispatcher) { + val warning = BaseResult.Error("CACHE_ERROR", "캐시 반영 실패") + coEvery { mockDeleteExerciseUseCase(recordId) } returns BaseResult.Success(Unit, warning) + viewModel.deleteExerciseRecord(recordId) + testDispatcher.scheduler.advanceUntilIdle() + val result = viewModel.deleteResult.replayCache.single() as BaseResult.Success + assertThat(result.refreshError).isEqualTo(warning) + viewModel.consumeDeleteResult() + assertThat(viewModel.deleteResult.replayCache).isEmpty() + coVerify(exactly = 1) { mockDeleteExerciseUseCase(recordId) } + } } diff --git a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseFormViewModelTest.kt b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseFormViewModelTest.kt index 86632579..b6c03dd6 100644 --- a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseFormViewModelTest.kt +++ b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseFormViewModelTest.kt @@ -14,13 +14,16 @@ import com.project200.domain.model.ExerciseRecordPicture import com.project200.domain.model.ExpectedScoreInfo import com.project200.domain.model.SubmissionResult import com.project200.domain.model.ValidWindow +import com.project200.domain.repository.ExerciseRecordRepository import com.project200.domain.usecase.CreateExerciseRecordUseCase +import com.project200.domain.usecase.DeleteExerciseRecordImagesUseCase import com.project200.domain.usecase.EditExerciseRecordUseCase import com.project200.domain.usecase.GetExercisePlaceUseCase import com.project200.domain.usecase.GetExerciseRecordDetailUseCase import com.project200.domain.usecase.GetExpectedScoreInfoUseCase import com.project200.domain.usecase.GetPreferredExerciseTypesUseCase import com.project200.domain.usecase.GetPreferredExerciseUseCase +import com.project200.domain.usecase.UpdateExerciseRecordUseCase import com.project200.domain.usecase.UploadExerciseRecordImagesUseCase import com.project200.feature.exercise.form.ExerciseFormViewModel import com.project200.feature.exercise.form.ExerciseImageListItem @@ -95,7 +98,7 @@ class ExerciseFormViewModelTest { location = "테스트 장소", pictures = null, ) - private val recordId = 123L + private val recordId = "record-123" private val imageUriString = "content://image/1" private val sampleExpectedScoreInfo = @@ -129,7 +132,7 @@ class ExerciseFormViewModelTest { } private fun setupViewModelForCreateMode() { - savedStateHandle = SavedStateHandle().apply { set("recordId", -1L) } + savedStateHandle = SavedStateHandle().apply { set("recordId", "") } viewModel = ExerciseFormViewModel( getExerciseRecordDetailUseCase = mockGetDetailUseCase, @@ -144,14 +147,17 @@ class ExerciseFormViewModelTest { ) } - private fun setupViewModelForEditMode() { + private fun setupViewModelForEditMode( + record: ExerciseRecord = sampleRecord, + editUseCase: EditExerciseRecordUseCase = mockEditUseCase, + ) { savedStateHandle = SavedStateHandle().apply { set("recordId", recordId) } viewModel = ExerciseFormViewModel( getExerciseRecordDetailUseCase = mockGetDetailUseCase, createExerciseRecordUseCase = mockCreateUseCase, uploadExerciseRecordImagesUseCase = mockUploadUseCase, - editExerciseRecordUseCase = mockEditUseCase, + editExerciseRecordUseCase = editUseCase, getExpectedScoreInfoUseCase = mockExpectedScoreInfoUseCase, getExercisePlaceUseCase = mockGetExercisePlaceUseCase, getPreferredExerciseUseCase = mockGetPreferredExerciseUseCase, @@ -159,7 +165,7 @@ class ExerciseFormViewModelTest { clockProvider = mockClockProvider, ) // 수정 모드 테스트를 위해 초기 데이터를 미리 로드합니다. - coEvery { mockGetDetailUseCase(recordId) } returns BaseResult.Success(sampleRecord) + coEvery { mockGetDetailUseCase(recordId) } returns BaseResult.Success(record) viewModel.loadInitialRecord(recordId) runTest { testDispatcher.scheduler.advanceUntilIdle() } } @@ -170,15 +176,15 @@ class ExerciseFormViewModelTest { @Test fun `loadInitialRecord - 생성 모드일 때 ViewModel이 올바르게 초기화된다`() { - // Given: recordId가 -1L인 생성 모드 + // Given: 빈 recordId인 생성 모드 setupViewModelForCreateMode() // When: 초기 데이터 로드 실행 - viewModel.loadInitialRecord(recordId) + viewModel.loadInitialRecord("") // Then: 수정과 관련된 데이터는 null 또는 초기 상태여야 함 assertThat(viewModel.initialDataLoaded.value).isNull() - assertThat(viewModel.startTime.value).isNull() + assertThat(viewModel.startTime.value).isEqualTo(now.minusHours(1)) assertThat(viewModel.imageItems.value).hasSize(1) // AddButton만 존재 } @@ -449,10 +455,7 @@ class ExerciseFormViewModelTest { // Given: 수정 모드에서 기존 이미지가 있는 기록을 로드 val picture = ExerciseRecordPicture(id = 99L, url = "http://exist.com/1.png") val recordWithPicture = sampleRecord.copy(pictures = listOf(picture)) - setupViewModelForEditMode() - coEvery { mockGetDetailUseCase(recordId) } returns BaseResult.Success(recordWithPicture) - viewModel.loadInitialRecord(recordId) - testDispatcher.scheduler.advanceUntilIdle() + setupViewModelForEditMode(recordWithPicture) // And: editUseCase가 성공을 반환하도록 설정 coEvery { mockEditUseCase(any(), any(), any(), any(), any()) } returns ExerciseEditResult.Success(recordId) @@ -596,4 +599,68 @@ class ExerciseFormViewModelTest { assertThat(viewModel.toastMessage.replayCache.firstOrNull()).isEqualTo(R.string.exercise_fetch_score_info_fail) assertThat(viewModel.scoreGuidanceState.value).isEqualTo(ScoreGuidanceState.Hidden) } + + @Test + fun `같은 폼을 다시 바인딩해도 기록을 갱신하거나 작성 중 시간을 되돌리지 않는다`() = + runTest(testDispatcher) { + setupViewModelForEditMode() + val changed = sampleRecord.startedAt.plusHours(1) + viewModel.setStartTime(changed) + val images = viewModel.imageItems.value + viewModel.loadInitialRecord(recordId) + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.startTime.value).isEqualTo(changed) + assertThat(viewModel.imageItems.value).isEqualTo(images) + coVerify(exactly = 1) { mockGetDetailUseCase(recordId) } + } + + @Test + fun `실제 수정 유스케이스의 저장 후 경고가 폼 성공 결과에 도달하고 수정은 한 번만 실행된다`() = + runTest(testDispatcher) { + val repository = mockk() + val warning = BaseResult.Error("CACHE_ERROR", "로컬 반영 실패") + coEvery { repository.updateExerciseRecord(any(), recordId) } returns BaseResult.Success(recordId, warning) + val edit = + EditExerciseRecordUseCase( + UpdateExerciseRecordUseCase(repository), + DeleteExerciseRecordImagesUseCase(repository), + UploadExerciseRecordImagesUseCase(repository), + ) + setupViewModelForEditMode(editUseCase = edit) + viewModel.submitRecord(recordId, "수정한 제목", sampleRecord.personalType, sampleRecord.location, sampleRecord.detail) + testDispatcher.scheduler.advanceUntilIdle() + val result = viewModel.editResult.replayCache.single() as ExerciseEditResult.Success + assertThat(result.refreshError).isEqualTo(warning) + assertThat(viewModel.isLoading.value).isFalse() + viewModel.consumeEditResult() + assertThat(viewModel.editResult.replayCache).isEmpty() + coVerify(exactly = 1) { repository.updateExerciseRecord(any(), recordId) } + coVerify(exactly = 0) { repository.deleteExerciseRecordImages(any(), any()) } + coVerify(exactly = 0) { repository.uploadExerciseRecordImages(any(), any()) } + } + + @Test + fun `생성과 이미지 업로드의 캐시 경고를 하나의 생성 성공 결과로 합친다`() = + runTest(testDispatcher) { + setupViewModelForCreateMode() + viewModel.setStartTime(now.minusHours(1)) + viewModel.setEndTime(now) + val image = mockk() + every { image.toString() } returns imageUriString + viewModel.addImage(listOf(image)) + val warning = BaseResult.Error("CACHE_ERROR", "캐시 반영 실패") + coEvery { mockCreateUseCase(any()) } returns BaseResult.Success(ExerciseRecordCreationResult(recordId, 3), warning) + coEvery { mockUploadUseCase(recordId, listOf(imageUriString)) } returns BaseResult.Success(recordId, warning) + viewModel.submitRecord("", "제목", "타입", "장소", "상세") + testDispatcher.scheduler.advanceUntilIdle() + val result = viewModel.createResult.replayCache.single() as SubmissionResult.Success + assertThat(result.refreshError).isEqualTo(warning) + assertThat(result.earnedPoints).isEqualTo(3) + assertThat(viewModel.isLoading.value).isFalse() + viewModel.consumeCreateResult() + assertThat(viewModel.createResult.replayCache).isEmpty() + coVerify(exactly = 1) { mockCreateUseCase(any()) } + coVerify(exactly = 1) { mockUploadUseCase(recordId, listOf(imageUriString)) } + } } diff --git a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt index ceb1ceda..ee06c1f3 100644 --- a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt +++ b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt @@ -4,6 +4,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Observer import com.google.common.truth.Truth.assertThat import com.project200.common.utils.ClockProvider +import com.project200.common.utils.NetworkMonitor import com.project200.domain.model.BaseResult import com.project200.domain.model.ExerciseCount import com.project200.domain.model.ExerciseListItem @@ -18,6 +19,7 @@ import com.project200.domain.usecase.GetExpectedScoreInfoUseCase import com.project200.domain.usecase.GetScorePolicyUseCase import com.project200.domain.usecase.GetScoreUseCase import com.project200.feature.exercise.main.ExerciseMainViewModel +import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify import io.mockk.impl.annotations.MockK @@ -25,6 +27,7 @@ import io.mockk.junit4.MockKRule import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -63,6 +66,9 @@ class ExerciseMainViewModelTest { @MockK private lateinit var mockGetExpectedScoreInfoUseCase: GetExpectedScoreInfoUseCase + @MockK + private lateinit var mockNetworkMonitor: NetworkMonitor + private lateinit var viewModel: ExerciseMainViewModel private val testDispatcher = StandardTestDispatcher() @@ -141,6 +147,7 @@ class ExerciseMainViewModelTest { Dispatchers.setMain(testDispatcher) coEvery { mockClockProvider.yearMonthNow() } returns thisMonth coEvery { mockClockProvider.now() } returns today + coEvery { mockNetworkMonitor.isCurrentlyConnected() } returns true // ViewModel의 init()에서 호출되는 UseCase에 대한 기본 응답 설정 coEvery { mockGetExerciseCountUseCase.invoke(any(), any()) } returns BaseResult.Success(emptyList()) @@ -170,6 +177,7 @@ class ExerciseMainViewModelTest { mockGetExerciseRecordListUseCase, mockGetExpectedScoreInfoUseCase, mockClockProvider, + mockNetworkMonitor, ) } @@ -189,7 +197,7 @@ class ExerciseMainViewModelTest { testDispatcher.scheduler.advanceUntilIdle() // Then - coVerify(exactly = 3) { mockGetExerciseCountUseCase.invoke(thisMonth.atDay(1), today) } + coVerify(exactly = 2) { mockGetExerciseCountUseCase.invoke(thisMonth.atDay(1), today) } assertThat(viewModel.exerciseDates.value).isEqualTo(expectedDates) } @@ -233,10 +241,8 @@ class ExerciseMainViewModelTest { testDispatcher.scheduler.advanceUntilIdle() // Then - // --- 수정된 부분 --- - // init(1) + refreshData(2) = 총 3번 호출된 후, onMonthChanged에서는 추가 호출이 없어야 합니다. - // 따라서 총 호출 횟수는 3회로 유지됩니다. - coVerify(exactly = 3) { mockGetExerciseCountUseCase.invoke(thisMonth.atDay(1), today) } + // 최초 조회와 명시적 갱신만 호출하고 같은 월의 재바인딩은 조회하지 않습니다. + coVerify(exactly = 2) { mockGetExerciseCountUseCase.invoke(thisMonth.atDay(1), today) } } @Test @@ -265,9 +271,9 @@ class ExerciseMainViewModelTest { } @Test - fun `refreshData 호출 시 캐시를 지우고 현재 월 데이터를 다시 로드`() = + fun `refreshData 호출 시 현재 조회를 갱신하고 선택 상태를 유지한다`() = runTest { - // Given 1: 첫 데이터 로드 (init에서 1번, refreshData에서 2번 = 총 3번) + // Given: 최초 조회와 명시적 갱신 coEvery { mockGetExerciseCountUseCase.invoke(thisMonth.atDay(1), today) } returns BaseResult.Success(sampleCounts) coEvery { mockGetScoreUseCase.invoke() } returns BaseResult.Success(Score(100)) viewModel.refreshData() @@ -282,12 +288,9 @@ class ExerciseMainViewModelTest { viewModel.refreshData() testDispatcher.scheduler.advanceUntilIdle() - // Then - // --- 수정된 부분 --- - // init(1) + refreshData(2) + 두 번째 refreshData(2) = 총 5번 - coVerify(exactly = 5) { mockGetExerciseCountUseCase.invoke(thisMonth.atDay(1), today) } - // 첫 번째 refreshData(1) + 두 번째 refreshData(1) = 총 2번 - coVerify(exactly = 2) { mockGetScoreUseCase.invoke() } + // Then: 최초 조회와 두 번의 명시적 새로고침 + coVerify(exactly = 3) { mockGetExerciseCountUseCase.invoke(thisMonth.atDay(1), today) } + coVerify(exactly = 3) { mockGetScoreUseCase.invoke() } val newExpectedDates = setOf(today.withDayOfMonth(25)) assertThat(viewModel.exerciseDates.value).isEqualTo(newExpectedDates) assertThat(viewModel.score.value).isEqualTo(Score(80)) @@ -305,8 +308,8 @@ class ExerciseMainViewModelTest { testDispatcher.scheduler.advanceUntilIdle() // Then - // refreshData()가 한 번만 호출되었으므로 getScoreUseCase도 1번만 호출됩니다. - coVerify(exactly = 1) { mockGetScoreUseCase.invoke() } + // 최초 화면과 명시적 새로고침에서 점수를 조회합니다. + coVerify(exactly = 2) { mockGetScoreUseCase.invoke() } assertThat(viewModel.score.value).isEqualTo(Score(expectedScore)) } @@ -323,9 +326,10 @@ class ExerciseMainViewModelTest { testDispatcher.scheduler.advanceUntilIdle() // Then - coVerify(exactly = 1) { mockGetScoreUseCase.invoke() } + coVerify(exactly = 2) { mockGetScoreUseCase.invoke() } assertThat(viewModel.toastMessage.value).isEqualTo(errorMessage) - assertThat(viewModel.score.value).isNull() + // 첫 화면에서 받은 점수는 새로고침 실패로 지우지 않습니다. + assertThat(viewModel.score.value).isEqualTo(Score(0)) } @Test @@ -523,11 +527,209 @@ class ExerciseMainViewModelTest { previousResponse.complete(BaseResult.Success(emptyList())) testDispatcher.scheduler.runCurrent() - // 데이터 표시 정책은 유지하되 이전 요청의 행을 최신 성공으로 표시하지 않습니다. - assertThat(loadedAtListEmission).containsExactly(false) - assertThat(viewModel.isExerciseListLoaded.value).isFalse() + // 이전 응답은 데이터도 완료 상태도 바꾸지 않습니다. + assertThat(loadedAtListEmission).isEmpty() + assertThat(viewModel.isExerciseListLoaded.value).isTrue() } finally { viewModel.exerciseList.removeObserver(observer) } } + + @Test + fun `이전 날짜의 늦은 응답은 선택한 날짜의 내용이나 완료 상태를 바꾸지 않는다`() = + runTest { + testDispatcher.scheduler.advanceUntilIdle() + val firstDate = today.minusDays(1) + val selected = today.minusDays(2) + val pending = CompletableDeferred>>() + coEvery { mockGetExerciseRecordListUseCase(firstDate) } coAnswers { pending.await() } + val expected = listOf(item("selected-local", selected)) + coEvery { mockGetExerciseRecordListUseCase(selected) } returns BaseResult.Success(expected) + viewModel.onDateSelected(firstDate) + testDispatcher.scheduler.runCurrent() + viewModel.onDateSelected(selected) + testDispatcher.scheduler.runCurrent() + pending.complete(BaseResult.Success(listOf(item("old-local", firstDate)))) + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.selectedDate.value).isEqualTo(selected) + assertThat(viewModel.exerciseList.value).isEqualTo(expected) + assertThat(viewModel.isExerciseListLoaded.value).isTrue() + } + + @Test + fun `자신의 쓰기 후에는 로컬 조회만 하고 선택한 날짜 월 스크롤을 유지한다`() = + runTest { + testDispatcher.scheduler.advanceUntilIdle() + val selected = today.minusDays(1) + viewModel.onDateSelected(selected) + testDispatcher.scheduler.advanceUntilIdle() + viewModel.scrollPosition = 240 + val expected = listOf(item("changed-local", selected)) + coEvery { mockGetExerciseRecordListUseCase(selected, false) } returns BaseResult.Success(expected, isCachedSnapshot = true) + coEvery { mockGetExerciseCountUseCase(any(), any(), false) } returns BaseResult.Success(listOf(ExerciseCount(selected, 1))) + + viewModel.onOwnWrite() + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.selectedDate.value).isEqualTo(selected) + assertThat(viewModel.selectedMonth.value).isEqualTo(thisMonth) + assertThat(viewModel.scrollPosition).isEqualTo(240) + assertThat(viewModel.exerciseList.value).isEqualTo(expected) + assertThat(viewModel.exerciseDates.value).contains(selected) + coVerify(exactly = 1) { mockGetExerciseRecordListUseCase(selected, false) } + coVerify(exactly = 1) { mockGetExerciseCountUseCase(thisMonth.atDay(1), today, false) } + } + + @Test + fun `재연결 재바인딩 시간 경과에도 화면 내용과 선택 스크롤을 바꾸거나 다시 조회하지 않는다`() = + runTest { + // 공통 설정 인스턴스의 초기 조회를 끝낸 뒤 검증 대상 인스턴스를 새로 만듭니다. + testDispatcher.scheduler.advanceUntilIdle() + val connectivity = MutableStateFlow(true) + coEvery { mockNetworkMonitor.networkState } returns connectivity + coEvery { mockNetworkMonitor.isCurrentlyConnected() } answers { connectivity.value } + val expected = listOf(item("visible-local", today)) + coEvery { mockGetExerciseRecordListUseCase(today) } returns BaseResult.Success(expected) + clearMocks( + mockGetExerciseRecordListUseCase, + mockGetExerciseCountUseCase, + mockGetExpectedScoreInfoUseCase, + answers = false, + ) + viewModel = + ExerciseMainViewModel( + mockGetExerciseCountUseCase, + mockGetScoreUseCase, + mockGetScorePolicyUseCase, + mockGetExerciseRecordListUseCase, + mockGetExpectedScoreInfoUseCase, + mockClockProvider, + mockNetworkMonitor, + ) + testDispatcher.scheduler.advanceUntilIdle() + viewModel.scrollPosition = 240 + connectivity.value = false + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.exerciseList.value).isEqualTo(expected) + connectivity.value = true + testDispatcher.scheduler.advanceUntilIdle() + viewModel.onMonthChanged(thisMonth) + viewModel.onDateSelected(today) + testDispatcher.scheduler.advanceTimeBy(60_000) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { mockGetExerciseRecordListUseCase(today) } + coVerify(exactly = 1) { mockGetExerciseCountUseCase(thisMonth.atDay(1), today) } + coVerify(exactly = 1) { mockGetExpectedScoreInfoUseCase() } + assertThat(viewModel.exerciseList.value).isEqualTo(expected) + assertThat(viewModel.scrollPosition).isEqualTo(240) + assertThat(viewModel.selectedMonth.value).isEqualTo(thisMonth) + assertThat(viewModel.selectedDate.value).isEqualTo(today) + } + + @Test + fun `온라인 갱신 실패는 저장된 목록을 유지하고 여러 조회에서 한 번만 알린다`() = + runTest { + testDispatcher.scheduler.advanceUntilIdle() + val error = BaseResult.Error("401", "인증 실패") + val expected = listOf(item("cached-local", today)) + coEvery { mockGetExerciseRecordListUseCase(today) } returns BaseResult.Success(expected, error, true) + coEvery { mockGetExerciseCountUseCase(any(), any()) } returns BaseResult.Success(listOf(ExerciseCount(today, 1)), error, true) + val messages = mutableListOf() + val observer = Observer { message -> if (message != null) messages.add(message) } + viewModel.toastMessage.observeForever(observer) + try { + viewModel.refreshData() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.exerciseList.value).isEqualTo(expected) + assertThat(viewModel.isExerciseListLoaded.value).isTrue() + assertThat(messages).containsExactly("인증 실패") + viewModel.consumeToastMessage() + assertThat(viewModel.toastMessage.value).isNull() + } finally { + viewModel.toastMessage.removeObserver(observer) + } + } + + @Test + fun `생성 대기 날짜는 완료 날짜와 대기 표시 목록에 함께 포함된다`() = + runTest { + testDispatcher.scheduler.advanceUntilIdle() + coEvery { mockGetExerciseCountUseCase(any(), any()) } returns + BaseResult.Success(listOf(ExerciseCount(today, 1, syncPending = true))) + viewModel.refreshData() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.exerciseDates.value).contains(today) + assertThat(viewModel.pendingDates.value).contains(today) + } + + @Test + fun `오프라인 조회 실패는 오류 상태로 구분하되 갱신 실패 토스트를 반복하지 않는다`() = + runTest { + testDispatcher.scheduler.advanceUntilIdle() + coEvery { mockNetworkMonitor.isCurrentlyConnected() } returns false + val offline = BaseResult.Error("NETWORK_ERROR", "네트워크 연결 오류") + coEvery { mockGetExerciseCountUseCase(any(), any()) } returns offline + coEvery { mockGetExerciseRecordListUseCase(any()) } returns offline + coEvery { mockGetScoreUseCase() } returns offline + coEvery { mockGetScorePolicyUseCase() } returns offline + viewModel.refreshData() + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.exerciseListError.value).isEqualTo(offline) + assertThat(viewModel.isExerciseListLoaded.value).isFalse() + assertThat(viewModel.toastMessage.value).isNull() + } + + @Test + fun `첫 시작은 중복 조회하지 않고 복귀마다 로컬 스냅샷만 한 번 읽는다`() = + runTest { + viewModel.onScreenStarted() + viewModel.onScreenStarted() + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 1) { mockGetExerciseRecordListUseCase(today, true, false) } + coVerify(exactly = 1) { mockGetExerciseCountUseCase(thisMonth.atDay(1), today, true, false) } + val selected = today.minusDays(1) + viewModel.onDateSelected(selected) + testDispatcher.scheduler.advanceUntilIdle() + viewModel.scrollPosition = 321 + val changed = listOf(item("returned-local", selected)) + coEvery { mockGetExerciseRecordListUseCase(selected, false, true) } returns BaseResult.Success(changed, isCachedSnapshot = true) + coEvery { mockGetExerciseCountUseCase(any(), any(), false, true) } returns + BaseResult.Success(listOf(ExerciseCount(selected, 1))) + + viewModel.onScreenStopped() + viewModel.onScreenStarted() + viewModel.onScreenStarted() + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.exerciseList.value).isEqualTo(changed) + assertThat(viewModel.selectedDate.value).isEqualTo(selected) + assertThat(viewModel.selectedMonth.value).isEqualTo(thisMonth) + assertThat(viewModel.scrollPosition).isEqualTo(321) + coVerify(exactly = 1) { mockGetExerciseRecordListUseCase(selected, false, true) } + coVerify(exactly = 1) { mockGetExerciseCountUseCase(thisMonth.atDay(1), today, false, true) } + coVerify(exactly = 1) { mockGetExerciseRecordListUseCase(selected, true, false) } + } + + @Test + fun `쓰기 결과를 처리한 복귀는 같은 목록을 다시 읽지 않는다`() = + runTest { + viewModel.onScreenStarted() + testDispatcher.scheduler.advanceUntilIdle() + coEvery { mockGetExerciseRecordListUseCase(today, false, false) } returns BaseResult.Success(emptyList()) + coEvery { mockGetExerciseCountUseCase(any(), any(), false, false) } returns BaseResult.Success(emptyList()) + viewModel.onScreenStopped() + viewModel.onOwnWrite() + viewModel.onScreenStarted() + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 1) { mockGetExerciseRecordListUseCase(today, false, false) } + coVerify(exactly = 0) { mockGetExerciseRecordListUseCase(any(), false, true) } + } + + private fun item( + id: String, + date: LocalDate, + ) = ExerciseListItem(id, "운동", "OTHER", date.atTime(9, 0), date.atTime(10, 0), null) } diff --git a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseShareEditViewModelTest.kt b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseShareEditViewModelTest.kt index 5f18fe0d..dd27eaa2 100644 --- a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseShareEditViewModelTest.kt +++ b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseShareEditViewModelTest.kt @@ -114,7 +114,7 @@ class ExerciseShareEditViewModelTest { viewModel = createViewModel() // When - viewModel.loadExerciseRecord(1L) + viewModel.loadExerciseRecord("local-1") testDispatcher.scheduler.advanceUntilIdle() // Then @@ -131,7 +131,7 @@ class ExerciseShareEditViewModelTest { viewModel = createViewModel() // When - viewModel.loadExerciseRecord(1L) + viewModel.loadExerciseRecord("local-1") testDispatcher.scheduler.advanceUntilIdle() // Then @@ -147,7 +147,7 @@ class ExerciseShareEditViewModelTest { viewModel = createViewModel() // When - viewModel.loadExerciseRecord(1L) + viewModel.loadExerciseRecord("local-1") testDispatcher.scheduler.advanceUntilIdle() // Then @@ -163,7 +163,7 @@ class ExerciseShareEditViewModelTest { viewModel = createViewModel() // When - viewModel.loadExerciseRecord(1L) + viewModel.loadExerciseRecord("local-1") testDispatcher.scheduler.advanceUntilIdle() // Then @@ -180,11 +180,11 @@ class ExerciseShareEditViewModelTest { viewModel = createViewModel() // When - viewModel.loadExerciseRecord(123L) + viewModel.loadExerciseRecord("local-123") testDispatcher.scheduler.advanceUntilIdle() // Then - coVerify { mockGetExerciseRecordDetailUseCase(123L) } + coVerify { mockGetExerciseRecordDetailUseCase("local-123") } } @Test @@ -224,7 +224,7 @@ class ExerciseShareEditViewModelTest { coEvery { mockGetExerciseRecordDetailUseCase(any()) } returns BaseResult.Success(sampleRecord) viewModel = createViewModel() - viewModel.loadExerciseRecord(1L) + viewModel.loadExerciseRecord("local-1") testDispatcher.scheduler.advanceUntilIdle() // When & Then @@ -262,7 +262,7 @@ class ExerciseShareEditViewModelTest { coEvery { mockGetExerciseRecordDetailUseCase(any()) } returns BaseResult.Success(sampleRecord) viewModel = createViewModel() - viewModel.loadExerciseRecord(1L) + viewModel.loadExerciseRecord("local-1") testDispatcher.scheduler.advanceUntilIdle() viewModel.selectTheme(StickerTheme.MINIMAL) @@ -283,7 +283,7 @@ class ExerciseShareEditViewModelTest { coEvery { mockGetExerciseRecordDetailUseCase(any()) } returns BaseResult.Success(sampleRecord) viewModel = createViewModel() - viewModel.loadExerciseRecord(1L) + viewModel.loadExerciseRecord("local-1") testDispatcher.scheduler.advanceUntilIdle() // When & Then @@ -308,7 +308,7 @@ class ExerciseShareEditViewModelTest { coEvery { mockGetExerciseRecordDetailUseCase(any()) } returns BaseResult.Success(sampleRecord) viewModel = createViewModel() - viewModel.loadExerciseRecord(1L) + viewModel.loadExerciseRecord("local-1") testDispatcher.scheduler.advanceUntilIdle() viewModel.requestShare(sampleTransformInfo) @@ -328,7 +328,7 @@ class ExerciseShareEditViewModelTest { viewModel = createViewModel() // When - viewModel.loadExerciseRecord(1L) + viewModel.loadExerciseRecord("local-1") testDispatcher.scheduler.advanceUntilIdle() // Then @@ -346,7 +346,7 @@ class ExerciseShareEditViewModelTest { coEvery { mockGetExerciseRecordDetailUseCase(any()) } returns BaseResult.Success(sampleRecord) viewModel = createViewModel() - viewModel.loadExerciseRecord(1L) + viewModel.loadExerciseRecord("local-1") testDispatcher.scheduler.advanceUntilIdle() // When & Then diff --git a/feature/matching/src/androidTest/java/com/project200/feature/matching/MatchingPlaceLoadEvidenceTest.kt b/feature/matching/src/androidTest/java/com/project200/feature/matching/MatchingPlaceLoadEvidenceTest.kt new file mode 100644 index 00000000..49c2d806 --- /dev/null +++ b/feature/matching/src/androidTest/java/com/project200/feature/matching/MatchingPlaceLoadEvidenceTest.kt @@ -0,0 +1,134 @@ +package com.project200.feature.matching + +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.ApplicationInfo +import android.system.Os +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.project200.domain.model.BaseResult +import com.project200.feature.matching.map.MatchingMapReadiness +import kotlinx.coroutines.CancellationException +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import java.io.File +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class MatchingPlaceLoadEvidenceTest { + @get:Rule + val temporaryFolder = TemporaryFolder(InstrumentationRegistry.getInstrumentation().targetContext.filesDir) + + @Test + fun actualTerminalsAndUnfinishedRequestsUseOnlySafeFieldsAndOriginalSequence() { + val context = debugContext() + val file = armedFile() + val first = checkNotNull(MatchingFixtureEvidence.placeLoadStarted(context)) + val mapAttempt = MatchingFixtureEvidence.mapStarted(context) + MatchingFixtureEvidence.map(context, mapAttempt, MatchingMapReadiness().ready()) + MatchingFixtureEvidence.chat(context, SECRET, 42, 73) + val second = checkNotNull(MatchingFixtureEvidence.placeLoadStarted(context)) + MatchingFixtureEvidence.placeLoadFinished(context, second, BaseResult.Error(SECRET, SECRET, IllegalStateException(SECRET))) + MatchingFixtureEvidence.placeLoadThrown(context, first, CancellationException(SECRET)) + val third = checkNotNull(MatchingFixtureEvidence.placeLoadStarted(context)) + MatchingFixtureEvidence.placeLoadFinished(context, third, BaseResult.Success(listOf(SECRET))) + val fourth = checkNotNull(MatchingFixtureEvidence.placeLoadStarted(context)) + MatchingFixtureEvidence.placeLoadThrown(context, fourth, IOException(SECRET)) + MatchingFixtureEvidence.placeLoadStarted(context) + + val document = JSONObject(file.readText()) + assertEquals("0123456789abcdef", document.getString("runId")) + val events = document.getJSONArray("events") + assertEquals(12, events.length()) + val loads = (0 until events.length()).map { events.getJSONObject(it) }.filter { it.getString("type") == "place_load" } + assertEquals(listOf(1, 5, 6, 7, 8, 9, 10, 11, 12), loads.map { it.getInt("sequence") }) + assertEquals(listOf(1, 5, 5, 1, 8, 8, 10, 10, 12), loads.map { it.getInt("request") }) + assertEquals( + listOf("started", "started", "error", "cancelled", "started", "success", "started", "exception", "started"), + loads.map { it.getString("state") }, + ) + assertEquals("unknown", loads[2].getString("category")) + assertEquals("network", loads[7].getString("category")) + loads.forEach { event -> + val keys = setOf("type", "sequence", "request", "state", "elapsedMs") + val expected = if (event.getString("state") in setOf("error", "exception")) keys + "category" else keys + assertEquals(expected, event.keys().asSequence().toSet()) + assertFalse(event.toString().contains(SECRET)) + assertTrue(event.getLong("elapsedMs") >= 0) + if (event.getString("state") == "started") assertEquals(0L, event.getLong("elapsedMs")) + } + assertEquals("loading", events.getJSONObject(1).getString("state")) + assertEquals("ready", events.getJSONObject(2).getString("state")) + assertEquals(SECRET, events.getJSONObject(3).getString("peerId")) + assertEquals(384, Os.stat(file.path).st_mode and 511) + } + + @Test + fun missingStartAndRemovedArmingNeverCreateTerminalEvidence() { + val context = debugContext() + assertEquals(null, MatchingFixtureEvidence.placeLoadStarted(context)) + assertTrue(temporaryFolder.root.listFiles().isNullOrEmpty()) + val file = armedFile() + val original = file.readText() + + MatchingFixtureEvidence.placeLoadFinished(context, null, BaseResult.Success(Unit)) + MatchingFixtureEvidence.placeLoadThrown(context, null, IllegalStateException(SECRET)) + assertEquals(original, file.readText()) + val request = checkNotNull(MatchingFixtureEvidence.placeLoadStarted(context)) + assertTrue(file.delete()) + MatchingFixtureEvidence.placeLoadFinished(context, request, BaseResult.Success(Unit)) + MatchingFixtureEvidence.placeLoadThrown(context, request, CancellationException(SECRET)) + assertFalse(file.exists()) + } + + @Test + fun brokenEvidenceAndTheExistingEventLimitDoNotReplaceRequestResultsWithDiagnosticFailures() { + val context = debugContext() + val file = armedFile() + val request = checkNotNull(MatchingFixtureEvidence.placeLoadStarted(context)) + file.writeText("{") + + assertEquals(null, MatchingFixtureEvidence.placeLoadStarted(context)) + MatchingFixtureEvidence.placeLoadFinished(context, request, BaseResult.Error(message = SECRET)) + MatchingFixtureEvidence.placeLoadThrown(context, request, IllegalStateException(SECRET)) + assertEquals("{", file.readText()) + + val events = JSONArray() + repeat(1000) { events.put(JSONObject().put("type", "denied")) } + val limited = JSONObject().put("runId", "0123456789abcdef").put("events", events).toString() + file.writeText(limited) + + assertEquals(null, MatchingFixtureEvidence.placeLoadStarted(context)) + MatchingFixtureEvidence.placeLoadFinished(context, request, BaseResult.Success(Unit)) + assertEquals(limited, file.readText()) + } + + private fun armedFile(): File = + temporaryFolder.newFile(MatchingFixtureEvidence.FILE_NAME).apply { + writeText(JSONObject().put("runId", "0123456789abcdef").put("events", JSONArray()).toString()) + } + + private fun debugContext(): Context { + val baseContext = InstrumentationRegistry.getInstrumentation().targetContext + val debugApplicationInfo = + ApplicationInfo(baseContext.applicationInfo).apply { + flags = flags or ApplicationInfo.FLAG_DEBUGGABLE + } + return object : ContextWrapper(baseContext) { + override fun getApplicationInfo(): ApplicationInfo = debugApplicationInfo + + override fun getFilesDir(): File = temporaryFolder.root + } + } + + private companion object { + const val SECRET = "https://private.invalid/request?token=synthetic-secret" + } +} diff --git a/feature/matching/src/main/java/com/project200/feature/matching/MatchingFixtureEvidence.kt b/feature/matching/src/main/java/com/project200/feature/matching/MatchingFixtureEvidence.kt index e77dd0d1..f4e4f85f 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/MatchingFixtureEvidence.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/MatchingFixtureEvidence.kt @@ -4,9 +4,11 @@ import android.content.Context import android.content.pm.ApplicationInfo import android.system.Os import android.util.AtomicFile +import com.project200.domain.model.BaseResult import com.project200.domain.model.ExercisePlace import com.project200.feature.matching.map.MatchingMapLabelKind import com.project200.feature.matching.map.MatchingMapReadiness +import kotlinx.coroutines.CancellationException import org.json.JSONArray import org.json.JSONObject import java.io.File @@ -112,6 +114,72 @@ object MatchingFixtureEvidence { } } + /** 실제 조회 직전의 순번만 요청 식별자로 사용하며 일반 실행에는 타이머도 만들지 않습니다. */ + internal fun placeLoadStarted(context: Context): MatchingPlaceLoadRequest? { + return try { + if (!isArmed(context)) return null + val sequence = + append(context) { sequence -> + put("type", "place_load") + put("sequence", sequence) + put("request", sequence) + put("state", "started") + put("elapsedMs", 0) + } ?: return null + MatchingPlaceLoadRequest(sequence, System.nanoTime()) + } catch (_: Exception) { + // 진단 파일 실패는 실제 조회를 막거나 성공·실패 결과로 바꾸지 않습니다. + null + } + } + + internal fun placeLoadFinished( + context: Context, + request: MatchingPlaceLoadRequest?, + result: BaseResult<*>, + ) { + if (request == null) return + when (result) { + is BaseResult.Success -> placeLoad(context, request, "success") + is BaseResult.Error -> placeLoad(context, request, "error", MatchingPlaceLoadErrorKind.from(result)) + } + } + + internal fun placeLoadThrown( + context: Context, + request: MatchingPlaceLoadRequest?, + failure: Throwable, + ) { + if (request == null) return + if (failure is CancellationException) { + placeLoad(context, request, "cancelled") + } else { + placeLoad(context, request, "exception", MatchingPlaceLoadErrorKind.from(failure)) + } + } + + private fun placeLoad( + context: Context, + request: MatchingPlaceLoadRequest, + state: String, + category: MatchingPlaceLoadErrorKind? = null, + ) { + val elapsedMs = (System.nanoTime() - request.startedAtNanos) / 1_000_000 + try { + if (!isArmed(context)) return + append(context) { sequence -> + put("type", "place_load") + put("sequence", sequence) + put("request", request.sequence) + put("state", state) + put("elapsedMs", elapsedMs) + category?.let { put("category", it.value) } + } + } catch (_: Exception) { + // 끝 기록이 없으면 수집기는 미완료 요청으로 남기며 결과를 추측하지 않습니다. + } + } + /** 일반 UI와 E2E 전용 수집·접근성 표식을 같은 디버그/증거 파일 경계로 구분합니다. */ fun isArmed(context: Context): Boolean = (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 && diff --git a/feature/matching/src/main/java/com/project200/feature/matching/MatchingPlaceLoadDiagnostics.kt b/feature/matching/src/main/java/com/project200/feature/matching/MatchingPlaceLoadDiagnostics.kt new file mode 100644 index 00000000..965ebcc4 --- /dev/null +++ b/feature/matching/src/main/java/com/project200/feature/matching/MatchingPlaceLoadDiagnostics.kt @@ -0,0 +1,40 @@ +package com.project200.feature.matching + +import com.project200.domain.model.BaseResult +import java.io.IOException + +internal data class MatchingPlaceLoadRequest( + val sequence: Int, + val startedAtNanos: Long, +) + +/** 오류 원문은 보관하지 않고 기존 결과 코드와 직접 원인 타입만 고정 분류로 바꿉니다. */ +internal enum class MatchingPlaceLoadErrorKind(val value: String) { + NETWORK("network"), + MAPPING("mapping"), + AUTH("auth"), + SERVER("server"), + UNKNOWN("unknown"), + ; + + companion object { + fun from(error: BaseResult.Error): MatchingPlaceLoadErrorKind = + when (val code = error.errorCode) { + "NETWORK_ERROR" -> NETWORK + "MAPPING_ERROR" -> MAPPING + "401", "403", "AUTHENTICATION_FAILED", "USER_ID_HEADER_MISSING" -> AUTH + else -> + if (code != null && code.length == 3 && code[0] == '5' && code[1] in '0'..'9' && code[2] in '0'..'9') { + SERVER + } else { + from(error.cause) + } + } + + fun from(failure: Throwable?): MatchingPlaceLoadErrorKind = + when (failure) { + is IOException -> NETWORK + else -> UNKNOWN + } + } +} diff --git a/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceViewModel.kt b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceViewModel.kt index 1166fa76..0a66e3d0 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceViewModel.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/place/ExercisePlaceViewModel.kt @@ -1,13 +1,16 @@ package com.project200.feature.matching.place +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.domain.model.BaseResult import com.project200.domain.model.ExercisePlace import com.project200.domain.usecase.DeleteExercisePlaceUseCase import com.project200.domain.usecase.GetExercisePlaceUseCase +import com.project200.feature.matching.MatchingFixtureEvidence import com.project200.feature.matching.utils.ExercisePlaceErrorType import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -29,6 +32,7 @@ class ExercisePlaceViewModel constructor( private val getExercisePlaceUseCase: GetExercisePlaceUseCase, private val deleteExercisePlaceUseCase: DeleteExercisePlaceUseCase, + @ApplicationContext private val context: Context, ) : ViewModel() { private val _places = MutableStateFlow>(emptyList()) val places: StateFlow> = _places.asStateFlow() @@ -42,7 +46,16 @@ class ExercisePlaceViewModel fun getExercisePlaces() { viewModelScope.launch { _loadState.value = ExercisePlaceLoadState.LOADING - when (val result = getExercisePlaceUseCase()) { + val request = MatchingFixtureEvidence.placeLoadStarted(context) + val result = + try { + getExercisePlaceUseCase() + } catch (failure: Throwable) { + MatchingFixtureEvidence.placeLoadThrown(context, request, failure) + throw failure + } + MatchingFixtureEvidence.placeLoadFinished(context, request, result) + when (result) { is BaseResult.Success -> { _places.value = result.data _loadState.value = ExercisePlaceLoadState.READY diff --git a/feature/matching/src/test/java/com/project200/feature/matching/MatchingFixtureEvidenceTest.kt b/feature/matching/src/test/java/com/project200/feature/matching/MatchingFixtureEvidenceTest.kt index bb46c3ab..9aea7006 100644 --- a/feature/matching/src/test/java/com/project200/feature/matching/MatchingFixtureEvidenceTest.kt +++ b/feature/matching/src/test/java/com/project200/feature/matching/MatchingFixtureEvidenceTest.kt @@ -2,6 +2,7 @@ package com.project200.feature.matching import android.content.Context import android.content.pm.ApplicationInfo +import com.project200.domain.model.BaseResult import com.project200.feature.matching.map.MatchingMapLabelKind import com.project200.feature.matching.map.MatchingMapReadiness import io.mockk.every @@ -88,4 +89,32 @@ class MatchingFixtureEvidenceTest { assertFalse(File(temporaryFolder.root, MatchingFixtureEvidence.FILE_NAME).exists()) } + + @Test + fun `release place diagnostics never read or write existing private evidence`() { + applicationInfo.flags = 0 + val file = temporaryFolder.newFile(MatchingFixtureEvidence.FILE_NAME) + file.writeText("unchanged-private-evidence") + val request = MatchingPlaceLoadRequest(sequence = 1, startedAtNanos = System.nanoTime()) + + assertEquals(null, MatchingFixtureEvidence.placeLoadStarted(context)) + MatchingFixtureEvidence.placeLoadFinished(context, request, BaseResult.Success(emptyList())) + MatchingFixtureEvidence.placeLoadFinished(context, request, BaseResult.Error(message = "private-error")) + MatchingFixtureEvidence.placeLoadThrown(context, request, IllegalStateException("private-error")) + + assertEquals("unchanged-private-evidence", file.readText()) + verify(exactly = 0) { context.filesDir } + } + + @Test + fun `unarmed debug place diagnostics never create private evidence`() { + applicationInfo.flags = ApplicationInfo.FLAG_DEBUGGABLE + val request = MatchingFixtureEvidence.placeLoadStarted(context) + + assertEquals(null, request) + MatchingFixtureEvidence.placeLoadFinished(context, request, BaseResult.Success(emptyList())) + MatchingFixtureEvidence.placeLoadThrown(context, request, IllegalStateException("private-error")) + + assertFalse(File(temporaryFolder.root, MatchingFixtureEvidence.FILE_NAME).exists()) + } } diff --git a/feature/matching/src/test/java/com/project200/feature/matching/MatchingPlaceLoadDiagnosticsTest.kt b/feature/matching/src/test/java/com/project200/feature/matching/MatchingPlaceLoadDiagnosticsTest.kt new file mode 100644 index 00000000..3cf8d778 --- /dev/null +++ b/feature/matching/src/test/java/com/project200/feature/matching/MatchingPlaceLoadDiagnosticsTest.kt @@ -0,0 +1,66 @@ +package com.project200.feature.matching + +import com.project200.domain.model.BaseResult +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import java.io.IOException + +class MatchingPlaceLoadDiagnosticsTest { + @Test + fun `only established error codes produce a known category`() { + val expected = + mapOf( + "NETWORK_ERROR" to MatchingPlaceLoadErrorKind.NETWORK, + "MAPPING_ERROR" to MatchingPlaceLoadErrorKind.MAPPING, + "AUTHENTICATION_FAILED" to MatchingPlaceLoadErrorKind.AUTH, + "USER_ID_HEADER_MISSING" to MatchingPlaceLoadErrorKind.AUTH, + "401" to MatchingPlaceLoadErrorKind.AUTH, + "403" to MatchingPlaceLoadErrorKind.AUTH, + "500" to MatchingPlaceLoadErrorKind.SERVER, + "503" to MatchingPlaceLoadErrorKind.SERVER, + "599" to MatchingPlaceLoadErrorKind.SERVER, + "UNKNOWN_ERROR" to MatchingPlaceLoadErrorKind.UNKNOWN, + "404" to MatchingPlaceLoadErrorKind.UNKNOWN, + "5000" to MatchingPlaceLoadErrorKind.UNKNOWN, + "+500" to MatchingPlaceLoadErrorKind.UNKNOWN, + " 503" to MatchingPlaceLoadErrorKind.UNKNOWN, + SECRET to MatchingPlaceLoadErrorKind.UNKNOWN, + ) + + expected.forEach { (code, kind) -> + val category = MatchingPlaceLoadErrorKind.from(BaseResult.Error(code, SECRET)) + + assertEquals(kind, category) + assertFalse(category.value.contains(SECRET)) + } + assertEquals(MatchingPlaceLoadErrorKind.UNKNOWN, MatchingPlaceLoadErrorKind.from(BaseResult.Error(message = SECRET))) + } + + @Test + fun `mapping code takes precedence over an IO cause and unknown codes use only the direct cause type`() { + val cause = IOException(SECRET) + + assertEquals(MatchingPlaceLoadErrorKind.MAPPING, MatchingPlaceLoadErrorKind.from(BaseResult.Error("MAPPING_ERROR", SECRET, cause))) + assertEquals(MatchingPlaceLoadErrorKind.NETWORK, MatchingPlaceLoadErrorKind.from(BaseResult.Error(SECRET, SECRET, cause))) + assertEquals(MatchingPlaceLoadErrorKind.NETWORK, MatchingPlaceLoadErrorKind.from(cause)) + assertEquals(MatchingPlaceLoadErrorKind.UNKNOWN, MatchingPlaceLoadErrorKind.from(IllegalStateException(SECRET, cause))) + } + + @Test + fun `exception classification never reads raw exception details`() { + val failure = mockk() + + assertEquals(MatchingPlaceLoadErrorKind.UNKNOWN, MatchingPlaceLoadErrorKind.from(failure)) + verify(exactly = 0) { failure.message } + verify(exactly = 0) { failure.cause } + verify(exactly = 0) { failure.stackTrace } + verify(exactly = 0) { failure.toString() } + } + + private companion object { + const val SECRET = "https://private.invalid/request?token=synthetic-secret" + } +} diff --git a/feature/matching/src/test/java/com/project200/feature/matching/place/ExercisePlaceViewModelTest.kt b/feature/matching/src/test/java/com/project200/feature/matching/place/ExercisePlaceViewModelTest.kt index 90a6b89f..8535f82b 100644 --- a/feature/matching/src/test/java/com/project200/feature/matching/place/ExercisePlaceViewModelTest.kt +++ b/feature/matching/src/test/java/com/project200/feature/matching/place/ExercisePlaceViewModelTest.kt @@ -1,6 +1,9 @@ package com.project200.feature.matching.place +import android.content.Context +import android.content.pm.ApplicationInfo import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.viewModelScope import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.project200.domain.model.BaseResult @@ -10,11 +13,16 @@ import com.project200.domain.usecase.GetExercisePlaceUseCase import com.project200.feature.matching.utils.ExercisePlaceErrorType import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.junit4.MockKRule +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -39,6 +47,8 @@ class ExercisePlaceViewModelTest { private lateinit var mockDeleteExercisePlaceUseCase: DeleteExercisePlaceUseCase private lateinit var viewModel: ExercisePlaceViewModel + private val context = mockk() + private val applicationInfo = mockk() private val testDispatcher = StandardTestDispatcher() @@ -51,6 +61,8 @@ class ExercisePlaceViewModelTest { @Before fun setUp() { Dispatchers.setMain(testDispatcher) + applicationInfo.flags = 0 + every { context.applicationInfo } returns applicationInfo } @After @@ -62,6 +74,7 @@ class ExercisePlaceViewModelTest { return ExercisePlaceViewModel( getExercisePlaceUseCase = mockGetExercisePlaceUseCase, deleteExercisePlaceUseCase = mockDeleteExercisePlaceUseCase, + context = context, ) } @@ -76,6 +89,7 @@ class ExercisePlaceViewModelTest { assertThat(viewModel.places.value).isEqualTo(samplePlaces) assertThat(viewModel.loadState.value).isEqualTo(ExercisePlaceLoadState.READY) + verify(exactly = 0) { context.filesDir } } @Test @@ -176,4 +190,23 @@ class ExercisePlaceViewModelTest { assertThat(viewModel.loadState.value).isEqualTo(ExercisePlaceLoadState.READY) assertThat(viewModel.places.value.map { it.id }).containsExactly(2L) } + + @Test + fun `대기 중인 조회를 취소하면 완료나 오류 화면으로 바꾸지 않는다`() = + runTest { + val response = CompletableDeferred>>() + val cancellation = CancellationException("request cancelled") + coEvery { mockGetExercisePlaceUseCase() } coAnswers { response.await() } + viewModel = createViewModel() + viewModel.getExercisePlaces() + testDispatcher.scheduler.runCurrent() + val job = viewModel.viewModelScope.coroutineContext[Job]!!.children.single() + + job.cancel(cancellation) + testDispatcher.scheduler.runCurrent() + + assertThat(job.isCancelled).isTrue() + assertThat(viewModel.loadState.value).isEqualTo(ExercisePlaceLoadState.LOADING) + assertThat(viewModel.places.value).isEmpty() + } } diff --git a/presentation/src/main/java/com/project200/presentation/navigator/FragmentNavigator.kt b/presentation/src/main/java/com/project200/presentation/navigator/FragmentNavigator.kt index 5a3ebfae..057e88f3 100644 --- a/presentation/src/main/java/com/project200/presentation/navigator/FragmentNavigator.kt +++ b/presentation/src/main/java/com/project200/presentation/navigator/FragmentNavigator.kt @@ -4,15 +4,15 @@ import java.time.LocalDate interface FragmentNavigator { /** 운동 리스트 */ - fun navigateFromExerciseListToExerciseDetail(recordId: Long) + fun navigateFromExerciseListToExerciseDetail(recordId: String) fun navigateFromExerciseListToExerciseForm() /** 운동 상세 */ - fun navigateFromExerciseDetailToExerciseForm(recordId: Long) + fun navigateFromExerciseDetailToExerciseForm(recordId: String) /** 운동 생성/수정 */ - fun navigateFromExerciseFormToExerciseDetail(recordId: Long) + fun navigateFromExerciseFormToExerciseDetail(recordId: String) /** 운동 메인 */ fun navigateFromExerciseMainToExerciseList(date: LocalDate)