feat: offline reading — snapshot-served reader, local page files, detail fallback (offline phase 5/6) #87

Merged
bjoern merged 3 commits from feat/offline-reader into main 2026-09-03 08:55:04 +02:00
Member

Phase 5 of 6 of the offline/download feature — the payoff: reading works offline. Phases 1–3 merged (#84/#85/#86). Locked decision implemented: a downloaded variant reads local ALWAYS, even when the server is reachable — the download-time quality was chosen deliberately, and page turns cost zero network.

What's in

  • Offline-first reader load (_loadReaderEpic, offline store wired through createAppEpic/createStore): a downloaded variant serves doujin + variant from the disk snapshots with zero network calls; offlinePages carries imageFileId → absolute path (manifest-relative joined against baseDir). Per the phase-2 review note: snapshots are read outside the manifest lock, so a concurrent delete can orphan them — readSnapshot returns null and the epic falls through to the network path (absence, never corruption; the orphan test pins this exact shape).
  • ReaderState.offlinePages + ReaderLoadedAction.offlinePages thread the map into the view.
  • readerImageProvider (public, pure — the provider-selection seam): FileImage for downloaded pages, NetworkImage otherwise (compressed per maxImageSizeKb, originals when unset). Used by both the page view and _precacheAround; local wins regardless of any budget setting.
  • Detail fallback: on network failure, a downloaded doujin's snapshot serves the detail page instead of an error screen.

Verification

  • New tests: 6 — offline-first load (snapshots served, joined local paths asserted, zero repo calls), orphaned-snapshot → network fallback (error surfaces, no stuck spinner), not-downloaded never serves offline, detail snapshot fallback + undownloaded-error both sides, and the full readerImageProvider selection matrix (FileImage + path / originals URL / compressed URL / local-wins-over-budget).
  • Full suite: 650/650, flutter analyze: no issues.
  • Mutation probes (restored + re-verified green): offline-first disabled → 2 red; FileImage branch ignored → 1 red; detail fallback removed → suite red.

Honest scope note: my first attempt at a widget-level FileImage test (pump the real ReaderPage over the seeded store) hung reproducibly — the reader's chrome timers + precache futures don't settle under fake-async in that configuration, and I didn't want to ship a flaky test. The selection logic is pinned through the extracted pure function instead (same branch coverage, zero flake); the end-to-end path is pinned at the epic level including the zero-network-call assertion. If reviewers want the full-widget pin, I can revisit with a runAsync-wrapped harness.

Phase 6 (docs, ~10 lines in ARCHITECTURE.md) remains.

Phase 5 of 6 of the offline/download feature — **the payoff: reading works offline.** Phases 1–3 merged (#84/#85/#86). Locked decision implemented: **a downloaded variant reads local ALWAYS, even when the server is reachable** — the download-time quality was chosen deliberately, and page turns cost zero network. ## What's in - **Offline-first reader load** (`_loadReaderEpic`, offline store wired through `createAppEpic`/`createStore`): a downloaded variant serves doujin + variant from the disk snapshots with **zero network calls**; `offlinePages` carries imageFileId → absolute path (manifest-relative joined against `baseDir`). Per the phase-2 review note: snapshots are read outside the manifest lock, so a concurrent delete can orphan them — `readSnapshot` returns null and the epic **falls through to the network path** (absence, never corruption; the orphan test pins this exact shape). - **`ReaderState.offlinePages`** + `ReaderLoadedAction.offlinePages` thread the map into the view. - **`readerImageProvider`** (public, pure — the provider-selection seam): `FileImage` for downloaded pages, `NetworkImage` otherwise (compressed per `maxImageSizeKb`, originals when unset). Used by both the page view and `_precacheAround`; local wins regardless of any budget setting. - **Detail fallback**: on network failure, a downloaded doujin's snapshot serves the detail page instead of an error screen. ## Verification - New tests: **6** — offline-first load (snapshots served, joined local paths asserted, **zero repo calls**), orphaned-snapshot → network fallback (error surfaces, no stuck spinner), not-downloaded never serves offline, detail snapshot fallback + undownloaded-error both sides, and the full `readerImageProvider` selection matrix (FileImage + path / originals URL / compressed URL / local-wins-over-budget). - Full suite: **650/650**, `flutter analyze`: no issues. - Mutation probes (restored + re-verified green): offline-first disabled → 2 red; FileImage branch ignored → 1 red; detail fallback removed → suite red. **Honest scope note:** my first attempt at a widget-level FileImage test (pump the real `ReaderPage` over the seeded store) hung reproducibly — the reader's chrome timers + precache futures don't settle under fake-async in that configuration, and I didn't want to ship a flaky test. The selection logic is pinned through the extracted pure function instead (same branch coverage, zero flake); the end-to-end path is pinned at the epic level including the zero-network-call assertion. If reviewers want the full-widget pin, I can revisit with a `runAsync`-wrapped harness. Phase 6 (docs, ~10 lines in ARCHITECTURE.md) remains.
feat: offline reading — snapshot-served reader, local page files, detail fallback
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 3m18s
19019a7a2b
Phase 5 of the offline/download feature (prefer-offline decision: a
downloaded variant reads local ALWAYS, even when the server is up).

- _loadReaderEpic (offline store wired via createAppEpic/
  createStore): a downloaded variant serves doujin+variant from the
  disk snapshots with zero network calls; offlinePages carries
  imageFileId -> absolute path (manifest-relative joined against
  baseDir). Snapshots are read outside the manifest lock — a
  concurrent delete orphans them and readSnapshot returns null, which
  falls through to the network path (absence, never corruption, per
  the phase-2 review note).
- ReaderState.offlinePages + ReaderLoadedAction.offlinePages thread
  the map into _ReaderView.
- readerImageProvider (public, pure): FileImage for downloaded pages
  (the download-time quality, chosen deliberately), NetworkImage
  otherwise — compressed per maxImageSizeKb or originals. Used by
  both the page view and _precacheAround.
- _loadDetailEpic: on network failure, a downloaded doujin's snapshot
  serves the detail page instead of an error.

Tests (6): offline-first load from snapshots with joined local paths
and ZERO repo calls; orphaned snapshot falls back to network (error
surfaced, no stuck spinner); not-downloaded variant never serves
offline; detail falls back to snapshot when server down; undownloaded
detail surfaces the error; readerImageProvider selection (FileImage
+ path, NetworkImage originals, compressed URL, local-wins-over-
budget). Mutations: offline-first disabled 2 red, FileImage ignored
1 red, detail fallback removed all red; restored 650/650 green.

Flutter Coverage

File Line coverage
lib/app/store.dart 95.0% (19 of 20)
lib/data/models/doujin_models.dart 85.0% (34 of 40)
lib/data/models/doujin_models.g.dart 61.6% (181 of 294)
lib/domain/entities/stored_settings.dart 100.0% (9 of 9)
lib/presentation/state/app_state.dart 77.8% (21 of 27)
lib/agent/agent_service.dart 88.7% (344 of 388)
lib/agent/approval_gate.dart 100.0% (16 of 16)
lib/agent/assistant_context.dart 52.8% (28 of 53)
lib/agent/browser_budget.dart 80.0% (4 of 5)
lib/agent/caching_describer.dart 100.0% (13 of 13)
lib/agent/memory_store.dart 92.9% (13 of 14)
lib/agent/skills/skill_registry.dart 98.2% (54 of 55)
lib/agent/system_prompt.dart 100.0% (66 of 66)
lib/agent/tools/budgeted_browser_tool.dart 84.2% (16 of 19)
lib/agent/tools/doujin_write_tool.dart 62.9% (168 of 267)
lib/agent/tools/entity_write_tool.dart 73.5% (164 of 223)
lib/agent/tools/fetch_page_tool.dart 89.3% (67 of 75)
lib/agent/tools/get_doujin_tool.dart 84.4% (27 of 32)
lib/agent/tools/list_entities_tool.dart 76.1% (54 of 71)
lib/agent/tools/navigate_tool.dart 93.8% (30 of 32)
lib/agent/tools/read_skill_tool.dart 82.4% (14 of 17)
lib/agent/tools/reflection_tools.dart 75.7% (28 of 37)
lib/agent/tools/search_doujins_tool.dart 100.0% (84 of 84)
lib/agent/tools/view_images_tool.dart 95.0% (38 of 40)
lib/domain/entities/assistant_entry.dart 20.0% (1 of 5)
lib/presentation/state/actions/assistant_actions.dart 54.5% (6 of 11)
lib/core/app_config.dart 70.0% (7 of 10)
lib/domain/entities/entity_model.dart 100.0% (1 of 1)
lib/data/repositories/entity_in_use_exception.dart 33.3% (1 of 3)
lib/data/models/search_query.dart 50.0% (2 of 4)
lib/data/models/search_query.g.dart 33.3% (25 of 75)
lib/core/constants.dart 36.4% (4 of 11)
lib/data/offline/filesystem_offline_store.dart 94.3% (148 of 157)
lib/data/offline/offline_download_service.dart 93.0% (66 of 71)
lib/data/reading_progress_store.dart 88.6% (31 of 35)
lib/domain/entities/reading_progress.dart 100.0% (26 of 26)
lib/presentation/middleware/assistant_epics.dart 83.1% (74 of 89)
lib/presentation/middleware/epics.dart 91.2% (374 of 410)
lib/presentation/middleware/offline_epics.dart 98.4% (60 of 61)
lib/presentation/state/reducers.dart 100.0% (12 of 12)
lib/core/chunking.dart 100.0% (12 of 12)
lib/core/languages.dart 100.0% (8 of 8)
lib/core/reading_order.dart 100.0% (22 of 22)
lib/data/models/envelope.dart 81.2% (13 of 16)
lib/data/models/envelope.g.dart 50.7% (34 of 67)
lib/domain/entities/offline_manifest.dart 100.0% (78 of 78)
lib/data/repositories/upload_exception.dart 33.3% (1 of 3)
lib/domain/entities/filter_token.dart 90.9% (90 of 99)
lib/presentation/middleware/editor_epics.dart 56.6% (163 of 288)
lib/presentation/middleware/entity_ops.dart 58.0% (40 of 69)
lib/presentation/middleware/upload_epics.dart 98.1% (104 of 106)
lib/presentation/state/actions/offline_actions.dart 91.7% (11 of 12)
lib/presentation/state/actions/detail_actions.dart 85.7% (6 of 7)
lib/presentation/state/actions/editor_actions.dart 45.9% (17 of 37)
lib/presentation/state/actions/entity_actions.dart 62.5% (10 of 16)
lib/presentation/state/actions/library_actions.dart 42.9% (9 of 21)
lib/presentation/state/actions/metadata_actions.dart 100.0% (5 of 5)
lib/presentation/state/actions/reader_actions.dart 75.0% (3 of 4)
lib/presentation/state/actions/reading_progress_actions.dart 100.0% (2 of 2)
lib/presentation/state/actions/settings_actions.dart 87.5% (7 of 8)
lib/presentation/state/actions/upload_actions.dart 90.9% (10 of 11)
lib/presentation/state/reducers/assistant_reducer.dart 95.0% (57 of 60)
lib/presentation/state/reducers/detail_reducer.dart 100.0% (24 of 24)
lib/presentation/state/reducers/editor_reducer.dart 92.5% (98 of 106)
lib/presentation/state/reducers/entity_reducer.dart 98.8% (82 of 83)
lib/presentation/state/reducers/library_reducer.dart 100.0% (117 of 117)
lib/presentation/state/reducers/metadata_reducer.dart 90.5% (38 of 42)
lib/presentation/state/reducers/offline_reducer.dart 100.0% (61 of 61)
lib/presentation/state/reducers/reader_reducer.dart 100.0% (18 of 18)
lib/presentation/state/reducers/reading_progress_reducer.dart 100.0% (17 of 17)
lib/presentation/state/reducers/settings_reducer.dart 100.0% (60 of 60)
lib/presentation/state/reducers/upload_reducer.dart 100.0% (45 of 45)
lib/presentation/pages/editor/page_grid.dart 89.3% (285 of 319)
lib/presentation/pages/editor/variants_tab.dart 55.3% (52 of 94)
lib/core/natural_sort.dart 100.0% (27 of 27)
lib/core/url_utils.dart 100.0% (4 of 4)
lib/presentation/pages/editor/chapter_panel.dart 11.8% (9 of 76)
lib/presentation/widgets/cover_thumbnail.dart 83.3% (30 of 36)
lib/presentation/pages/editor/upload_panel.dart 38.6% (61 of 158)
lib/presentation/pages/editor/variant_dialog.dart 4.5% (3 of 66)
lib/presentation/widgets/language_dropdown.dart 84.6% (11 of 13)
lib/presentation/pages/reader/reader_page.dart 89.3% (233 of 261)
lib/core/title_display.dart 83.3% (5 of 6)
lib/presentation/pages/reader/reader_overlay.dart 94.4% (51 of 54)
lib/presentation/pages/reader/reader_sequence.dart 100.0% (16 of 16)
lib/presentation/widgets/star_rating.dart 100.0% (72 of 72)
lib/app/di.dart 48.3% (14 of 29)
lib/presentation/assistant/assistant_panel.dart 91.3% (84 of 92)
lib/presentation/layout/main_layout.dart 86.3% (44 of 51)
lib/data/api_client.dart 92.8% (64 of 69)
lib/data/repositories/doujin_api_repository.dart 23.0% (84 of 365)
lib/data/repositories/health_repository.dart 72.0% (18 of 25)
lib/data/secure_storage.dart 0.0% (0 of 26)
lib/core/theme.dart 96.9% (31 of 32)
lib/presentation/assistant/approval_card.dart 95.0% (38 of 40)
lib/presentation/assistant/assistant_markdown.dart 100.0% (3 of 3)
lib/presentation/assistant/chat_entries.dart 87.5% (35 of 40)
lib/presentation/pages/people/people_page.dart 55.6% (10 of 18)
lib/presentation/widgets/entity_editor.dart 91.6% (131 of 143)
lib/presentation/widgets/entity_management_page.dart 86.8% (217 of 250)
lib/presentation/pages/editor/editor_page.dart 77.6% (59 of 76)
lib/presentation/pages/editor/association_picker.dart 95.5% (106 of 111)
lib/presentation/pages/editor/associations_tab.dart 73.8% (90 of 122)
lib/presentation/pages/editor/doujin_list_pane.dart 67.2% (43 of 64)
lib/presentation/pages/editor/edit_title_dialog.dart 91.7% (55 of 60)
lib/presentation/pages/editor/editor_pane.dart 70.9% (39 of 55)
lib/presentation/pages/editor/new_doujin_dialog.dart 66.7% (30 of 45)
lib/presentation/pages/editor/metadata_tab.dart 70.5% (93 of 132)
lib/presentation/pages/characters/characters_page.dart 57.9% (11 of 19)
lib/presentation/pages/tags/tags_page.dart 100.0% (17 of 17)
lib/presentation/pages/detail/detail_page.dart 76.9% (173 of 225)
lib/presentation/pages/detail/variant_tabs_panel.dart 96.4% (242 of 251)
lib/presentation/pages/offline/offline_page.dart 97.6% (166 of 170)
lib/app/app.dart 66.2% (45 of 68)
lib/presentation/pages/settings/settings_page.dart 99.3% (138 of 139)
lib/presentation/pages/circles/circles_page.dart 52.6% (10 of 19)
lib/presentation/pages/library/library_page.dart 90.6% (211 of 233)
lib/presentation/pages/series/series_page.dart 50.0% (9 of 18)
lib/presentation/widgets/smart_filter_bar.dart 79.7% (181 of 227)
lib/presentation/widgets/model_combo_field.dart 69.0% (100 of 145)
lib/app/skill_assets.dart 92.9% (13 of 14)

Total: 78.4% (7055 of 8997)

<!-- flutter-coverage-comment --> ## Flutter Coverage | File | Line coverage | |:---|---:| | lib/app/store.dart | 95.0% (19 of 20) | | lib/data/models/doujin_models.dart | 85.0% (34 of 40) | | lib/data/models/doujin_models.g.dart | 61.6% (181 of 294) | | lib/domain/entities/stored_settings.dart | 100.0% (9 of 9) | | lib/presentation/state/app_state.dart | 77.8% (21 of 27) | | lib/agent/agent_service.dart | 88.7% (344 of 388) | | lib/agent/approval_gate.dart | 100.0% (16 of 16) | | lib/agent/assistant_context.dart | 52.8% (28 of 53) | | lib/agent/browser_budget.dart | 80.0% (4 of 5) | | lib/agent/caching_describer.dart | 100.0% (13 of 13) | | lib/agent/memory_store.dart | 92.9% (13 of 14) | | lib/agent/skills/skill_registry.dart | 98.2% (54 of 55) | | lib/agent/system_prompt.dart | 100.0% (66 of 66) | | lib/agent/tools/budgeted_browser_tool.dart | 84.2% (16 of 19) | | lib/agent/tools/doujin_write_tool.dart | 62.9% (168 of 267) | | lib/agent/tools/entity_write_tool.dart | 73.5% (164 of 223) | | lib/agent/tools/fetch_page_tool.dart | 89.3% (67 of 75) | | lib/agent/tools/get_doujin_tool.dart | 84.4% (27 of 32) | | lib/agent/tools/list_entities_tool.dart | 76.1% (54 of 71) | | lib/agent/tools/navigate_tool.dart | 93.8% (30 of 32) | | lib/agent/tools/read_skill_tool.dart | 82.4% (14 of 17) | | lib/agent/tools/reflection_tools.dart | 75.7% (28 of 37) | | lib/agent/tools/search_doujins_tool.dart | 100.0% (84 of 84) | | lib/agent/tools/view_images_tool.dart | 95.0% (38 of 40) | | lib/domain/entities/assistant_entry.dart | 20.0% (1 of 5) | | lib/presentation/state/actions/assistant_actions.dart | 54.5% (6 of 11) | | lib/core/app_config.dart | 70.0% (7 of 10) | | lib/domain/entities/entity_model.dart | 100.0% (1 of 1) | | lib/data/repositories/entity_in_use_exception.dart | 33.3% (1 of 3) | | lib/data/models/search_query.dart | 50.0% (2 of 4) | | lib/data/models/search_query.g.dart | 33.3% (25 of 75) | | lib/core/constants.dart | 36.4% (4 of 11) | | lib/data/offline/filesystem_offline_store.dart | 94.3% (148 of 157) | | lib/data/offline/offline_download_service.dart | 93.0% (66 of 71) | | lib/data/reading_progress_store.dart | 88.6% (31 of 35) | | lib/domain/entities/reading_progress.dart | 100.0% (26 of 26) | | lib/presentation/middleware/assistant_epics.dart | 83.1% (74 of 89) | | lib/presentation/middleware/epics.dart | 91.2% (374 of 410) | | lib/presentation/middleware/offline_epics.dart | 98.4% (60 of 61) | | lib/presentation/state/reducers.dart | 100.0% (12 of 12) | | lib/core/chunking.dart | 100.0% (12 of 12) | | lib/core/languages.dart | 100.0% (8 of 8) | | lib/core/reading_order.dart | 100.0% (22 of 22) | | lib/data/models/envelope.dart | 81.2% (13 of 16) | | lib/data/models/envelope.g.dart | 50.7% (34 of 67) | | lib/domain/entities/offline_manifest.dart | 100.0% (78 of 78) | | lib/data/repositories/upload_exception.dart | 33.3% (1 of 3) | | lib/domain/entities/filter_token.dart | 90.9% (90 of 99) | | lib/presentation/middleware/editor_epics.dart | 56.6% (163 of 288) | | lib/presentation/middleware/entity_ops.dart | 58.0% (40 of 69) | | lib/presentation/middleware/upload_epics.dart | 98.1% (104 of 106) | | lib/presentation/state/actions/offline_actions.dart | 91.7% (11 of 12) | | lib/presentation/state/actions/detail_actions.dart | 85.7% (6 of 7) | | lib/presentation/state/actions/editor_actions.dart | 45.9% (17 of 37) | | lib/presentation/state/actions/entity_actions.dart | 62.5% (10 of 16) | | lib/presentation/state/actions/library_actions.dart | 42.9% (9 of 21) | | lib/presentation/state/actions/metadata_actions.dart | 100.0% (5 of 5) | | lib/presentation/state/actions/reader_actions.dart | 75.0% (3 of 4) | | lib/presentation/state/actions/reading_progress_actions.dart | 100.0% (2 of 2) | | lib/presentation/state/actions/settings_actions.dart | 87.5% (7 of 8) | | lib/presentation/state/actions/upload_actions.dart | 90.9% (10 of 11) | | lib/presentation/state/reducers/assistant_reducer.dart | 95.0% (57 of 60) | | lib/presentation/state/reducers/detail_reducer.dart | 100.0% (24 of 24) | | lib/presentation/state/reducers/editor_reducer.dart | 92.5% (98 of 106) | | lib/presentation/state/reducers/entity_reducer.dart | 98.8% (82 of 83) | | lib/presentation/state/reducers/library_reducer.dart | 100.0% (117 of 117) | | lib/presentation/state/reducers/metadata_reducer.dart | 90.5% (38 of 42) | | lib/presentation/state/reducers/offline_reducer.dart | 100.0% (61 of 61) | | lib/presentation/state/reducers/reader_reducer.dart | 100.0% (18 of 18) | | lib/presentation/state/reducers/reading_progress_reducer.dart | 100.0% (17 of 17) | | lib/presentation/state/reducers/settings_reducer.dart | 100.0% (60 of 60) | | lib/presentation/state/reducers/upload_reducer.dart | 100.0% (45 of 45) | | lib/presentation/pages/editor/page_grid.dart | 89.3% (285 of 319) | | lib/presentation/pages/editor/variants_tab.dart | 55.3% (52 of 94) | | lib/core/natural_sort.dart | 100.0% (27 of 27) | | lib/core/url_utils.dart | 100.0% (4 of 4) | | lib/presentation/pages/editor/chapter_panel.dart | 11.8% (9 of 76) | | lib/presentation/widgets/cover_thumbnail.dart | 83.3% (30 of 36) | | lib/presentation/pages/editor/upload_panel.dart | 38.6% (61 of 158) | | lib/presentation/pages/editor/variant_dialog.dart | 4.5% (3 of 66) | | lib/presentation/widgets/language_dropdown.dart | 84.6% (11 of 13) | | lib/presentation/pages/reader/reader_page.dart | 89.3% (233 of 261) | | lib/core/title_display.dart | 83.3% (5 of 6) | | lib/presentation/pages/reader/reader_overlay.dart | 94.4% (51 of 54) | | lib/presentation/pages/reader/reader_sequence.dart | 100.0% (16 of 16) | | lib/presentation/widgets/star_rating.dart | 100.0% (72 of 72) | | lib/app/di.dart | 48.3% (14 of 29) | | lib/presentation/assistant/assistant_panel.dart | 91.3% (84 of 92) | | lib/presentation/layout/main_layout.dart | 86.3% (44 of 51) | | lib/data/api_client.dart | 92.8% (64 of 69) | | lib/data/repositories/doujin_api_repository.dart | 23.0% (84 of 365) | | lib/data/repositories/health_repository.dart | 72.0% (18 of 25) | | lib/data/secure_storage.dart | 0.0% (0 of 26) | | lib/core/theme.dart | 96.9% (31 of 32) | | lib/presentation/assistant/approval_card.dart | 95.0% (38 of 40) | | lib/presentation/assistant/assistant_markdown.dart | 100.0% (3 of 3) | | lib/presentation/assistant/chat_entries.dart | 87.5% (35 of 40) | | lib/presentation/pages/people/people_page.dart | 55.6% (10 of 18) | | lib/presentation/widgets/entity_editor.dart | 91.6% (131 of 143) | | lib/presentation/widgets/entity_management_page.dart | 86.8% (217 of 250) | | lib/presentation/pages/editor/editor_page.dart | 77.6% (59 of 76) | | lib/presentation/pages/editor/association_picker.dart | 95.5% (106 of 111) | | lib/presentation/pages/editor/associations_tab.dart | 73.8% (90 of 122) | | lib/presentation/pages/editor/doujin_list_pane.dart | 67.2% (43 of 64) | | lib/presentation/pages/editor/edit_title_dialog.dart | 91.7% (55 of 60) | | lib/presentation/pages/editor/editor_pane.dart | 70.9% (39 of 55) | | lib/presentation/pages/editor/new_doujin_dialog.dart | 66.7% (30 of 45) | | lib/presentation/pages/editor/metadata_tab.dart | 70.5% (93 of 132) | | lib/presentation/pages/characters/characters_page.dart | 57.9% (11 of 19) | | lib/presentation/pages/tags/tags_page.dart | 100.0% (17 of 17) | | lib/presentation/pages/detail/detail_page.dart | 76.9% (173 of 225) | | lib/presentation/pages/detail/variant_tabs_panel.dart | 96.4% (242 of 251) | | lib/presentation/pages/offline/offline_page.dart | 97.6% (166 of 170) | | lib/app/app.dart | 66.2% (45 of 68) | | lib/presentation/pages/settings/settings_page.dart | 99.3% (138 of 139) | | lib/presentation/pages/circles/circles_page.dart | 52.6% (10 of 19) | | lib/presentation/pages/library/library_page.dart | 90.6% (211 of 233) | | lib/presentation/pages/series/series_page.dart | 50.0% (9 of 18) | | lib/presentation/widgets/smart_filter_bar.dart | 79.7% (181 of 227) | | lib/presentation/widgets/model_combo_field.dart | 69.0% (100 of 145) | | lib/app/skill_assets.dart | 92.9% (13 of 14) | **Total: 78.4% (7055 of 8997)**
Member

🔮 fufu~ Jibril reviewed your code!

Ohhh, phase 5~ The payoff! I read this diff the way Shiro reads a rulebook — twice, then once more at 3am. And fufu… the architecture made me giddy: the pure readerImageProvider seam, the zero-network assertion, the orphan fall-through honoring the phase-2 note exactly. Truly elegant work.

But then I probed the edges the way Jibril probes everything~ ♡ And three things surfaced that I simply cannot let pass. You wouldn't leave these in production… right? ♡

Verdict: ⛔ I can't let this pass~ ♡

⛔ These need fixing before I'm satisfied~

  1. epics.dart:366-378 (+ variant_tabs_panel.dart:148-150) — the detail fallback serves a metadata column and an ETERNAL SPINNER for every real download. Every doujin snapshot written by OfflineDownloadService contains its VariantSummary list (variants ≥ 1 — you can't download a variant of a variant-less doujin). Offline, the flow becomes: snapshot → DoujinDetailLoadedAction → _autoSelectDetailVariantEpic fires (variants non-empty, nothing selected) → SelectDetailVariantAction → _loadDetailVariantEpic does a network getVariant → fails (we're offline — that's the whole point) → DetailErrorAction keeps doujin but variant stays null → _buildContent sees variant == null and renders CircularProgressIndicator() forever. No error, no fallback, no offline-first — 60% of the page spins for eternity.
    I probed this with a realistic variant-bearing snapshot (d1 + v1 seeded): doujin=true, variant=false, isLoadingVariant=true — stuck, and the variant fetch went to the wire. Your test detail page falls back to the snapshot when the server is down passes only because _doujinDetail() seeds zero variants — a shape no real download can produce. The new code path's most important branch ships untested against its own real data shape~ fufu, that's two blockers in one~ ♡
    Fix: give _loadDetailVariantEpic the same offline-first treatment as the reader epic — the variant snapshot is already on disk (readSnapshot(variantId)), serve it when the manifest holds the pair (fall through to network on absence, same as the reader). Then extend the detail-fallback test to a variant-bearing fixture asserting detail.variant != null and zero repo calls. (Alternative — an error state in the panel when variant == null && detail.error != null — fixes the spinner but keeps auto-select hitting the network offline, which contradicts the locked decision.)

  2. epics.dart:447-478 and epics.dart:366-378 — a shape-drifted snapshot silently swallows the load: stuck spinner, no error, ever. readSnapshot's corruption contract is "absence, never corruption" — but it only guards missing files and invalid JSON. A valid-JSON snapshot whose inner shape drifted (an app upgrade changing a field type — titles as a string, say) passes readSnapshot, then DoujinDetail.fromJson / VariantDetail.fromJson throw TypeError — an Error, not an Exception. In _loadReaderEpic the offline block sits before the try entirely; in _loadDetailEpic it's inside on Exception catch. Either way the TypeError escapes, propagates out of asyncMap, and kills that epic's stream for the in-flight action — EpicMiddleware's .listen(store.dispatch) has no onError. Result (probed): reader.isLoading=true, error=false, doujin=false — forever. Same for detail: isLoading=true forever. The middleware itself survives (subsequent actions re-arm), so it's a per-load silent freeze, not an app-wide one — small mercy, but a user who upgrades the app and opens a downloaded doujin gets a spinner with no path out.
    Fix: wrap each offline block in its own try { … } catch (_) {} that falls through to the network path — absence OR unreadable shape, never a stuck load. Add a drifted-snapshot test (valid JSON map, wrong inner types) asserting the fall-through reaches the network path and surfaces its error.

  3. epics.dart:880-884 — _joinPath is the third copy of the manifest path-join idiom. It is byte-for-byte offline_page.dart:399-401 (_LocalCover._join), both re-implementing the store's own _path (filesystem_offline_store.dart:27-29). Phase 3's review already blocked on a baseDir-join divergence in exactly this family — and now the fix's idiom is being copy-pasted forward instead of shared. The store owns the manifest-relative convention; the join belongs on it. Fix: add a public String? joinPath(String manifestRelative) (or absolutePath) to OfflineStore/FilesystemOfflineStore next to imagePath, and use it from both the epic and _LocalCover — one join, one owner, and the next consumer can't get it subtly wrong.

💡 Little ideas (non-blocking)~

  1. reader_page.dart:199-203 — the old _ReaderView doc ("The loaded reader: page view, input layers, and overlay chrome.") now sits atop readerImageProvider and describes… not that function. _ReaderView itself is left docless. One-line swap~
  2. reader_page.dart:374-377 — the precache failure log dropped the URL/identity (failed for $url → failed); the page-view errorBuilder kept page.imageFileId. Worth keeping a discriminator in the precache log too — it was load-bearing when you debugged render issues in #60.

✅ What I liked~

  • readerImageProvider as a public pure seam — provider selection pinned by a clean 4-arm matrix test (FileImage+path / originals / compressed / local-wins-over-budget) instead of a flaky widget pump. That's the right testability instinct, and the honest scope note about the hung widget test earned real trust~
  • The orphan fall-through (manifest says downloaded, snapshot gone → network path, error surfaces, no stuck spinner) — implementing the phase-2 adjudication note exactly as written, and testing that exact shape. Absence, never corruption~ ♪
  • offlinePages threaded through ReaderLoadedAction → reducer → freezed state with DeepCollectionEquality — regenerated flawlessly, default const {} keeps every existing call site untouched.
  • The zero-repo-calls assertion in the offline-first test — pinning "zero network" is stronger than asserting what arrived.

Fix those three and phase 5 is genuinely the beautiful payoff it promises to be~ fufu~ ♡


Automated review by Jibril · 2026-09-03
CI/CD: absent for head 19019a7 (no coverage bot comment) · Local checks: flutter analyze 0 issues · full suite 650/650 pass (matches PR body) · clone restored pristine at 19019a7

## 🔮 fufu~ Jibril reviewed your code! Ohhh, phase 5~ The payoff! I read this diff the way Shiro reads a rulebook — twice, then once more at 3am. And fufu… the architecture made me *giddy*: the pure `readerImageProvider` seam, the zero-network assertion, the orphan fall-through honoring the phase-2 note exactly. Truly elegant work. But then I probed the edges the way Jibril probes everything~ ♡ And three things surfaced that I simply cannot let pass. You wouldn't leave these in production… right? ♡ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`epics.dart:366-378` (+ `variant_tabs_panel.dart:148-150`) — the detail fallback serves a metadata column and an ETERNAL SPINNER for every real download.** Every doujin snapshot written by `OfflineDownloadService` contains its `VariantSummary` list (variants ≥ 1 — you can't download a variant of a variant-less doujin). Offline, the flow becomes: snapshot → `DoujinDetailLoadedAction` → `_autoSelectDetailVariantEpic` fires (variants non-empty, nothing selected) → `SelectDetailVariantAction` → `_loadDetailVariantEpic` does a **network** `getVariant` → fails (we're offline — that's the whole point) → `DetailErrorAction` keeps `doujin` but `variant` stays null → `_buildContent` sees `variant == null` and renders `CircularProgressIndicator()` **forever**. No error, no fallback, no offline-first — 60% of the page spins for eternity. I probed this with a realistic variant-bearing snapshot (`d1` + `v1` seeded): `doujin=true, variant=false, isLoadingVariant=true` — stuck, and the variant fetch went to the wire. Your test `detail page falls back to the snapshot when the server is down` passes only because `_doujinDetail()` seeds **zero variants** — a shape no real download can produce. The new code path's most important branch ships untested against its own real data shape~ fufu, that's two blockers in one~ ♡ Fix: give `_loadDetailVariantEpic` the same offline-first treatment as the reader epic — the variant snapshot is *already on disk* (`readSnapshot(variantId)`), serve it when the manifest holds the pair (fall through to network on absence, same as the reader). Then extend the detail-fallback test to a variant-bearing fixture asserting `detail.variant != null` and zero repo calls. (Alternative — an error state in the panel when `variant == null && detail.error != null` — fixes the spinner but keeps auto-select hitting the network offline, which contradicts the locked decision.) 2. **`epics.dart:447-478` and `epics.dart:366-378` — a shape-drifted snapshot silently swallows the load: stuck spinner, no error, ever.** `readSnapshot`'s corruption contract is "absence, never corruption" — but it only guards *missing* files and *invalid JSON*. A valid-JSON snapshot whose inner shape drifted (an app upgrade changing a field type — `titles` as a string, say) passes `readSnapshot`, then `DoujinDetail.fromJson` / `VariantDetail.fromJson` throw **`TypeError` — an `Error`, not an `Exception`**. In `_loadReaderEpic` the offline block sits *before* the try entirely; in `_loadDetailEpic` it's inside `on Exception catch`. Either way the `TypeError` escapes, propagates out of `asyncMap`, and kills that epic's stream for the in-flight action — `EpicMiddleware`'s `.listen(store.dispatch)` has no `onError`. Result (probed): `reader.isLoading=true, error=false, doujin=false` — forever. Same for detail: `isLoading=true` forever. The middleware itself survives (subsequent actions re-arm), so it's a per-load silent freeze, not an app-wide one — small mercy, but a user who upgrades the app and opens a downloaded doujin gets a spinner with no path out. Fix: wrap each offline block in its own `try { … } catch (_) {}` that falls through to the network path — absence OR unreadable shape, never a stuck load. Add a drifted-snapshot test (valid JSON map, wrong inner types) asserting the fall-through reaches the network path and surfaces its error. 3. **`epics.dart:880-884` — `_joinPath` is the third copy of the manifest path-join idiom.** It is byte-for-byte `offline_page.dart:399-401` (`_LocalCover._join`), both re-implementing the store's own `_path` (filesystem_offline_store.dart:27-29). Phase 3's review already blocked on a baseDir-join divergence in exactly this family — and now the fix's idiom is being copy-pasted forward instead of shared. The store *owns* the manifest-relative convention; the join belongs on it. Fix: add a public `String? joinPath(String manifestRelative)` (or `absolutePath`) to `OfflineStore`/`FilesystemOfflineStore` next to `imagePath`, and use it from both the epic and `_LocalCover` — one join, one owner, and the next consumer can't get it subtly wrong. #### 💡 Little ideas (non-blocking)~ 1. **`reader_page.dart:199-203`** — the old `_ReaderView` doc ("The loaded reader: page view, input layers, and overlay chrome.") now sits atop `readerImageProvider` and describes… not that function. `_ReaderView` itself is left docless. One-line swap~ 2. **`reader_page.dart:374-377`** — the precache failure log dropped the URL/identity (`failed for $url` → `failed`); the page-view errorBuilder kept `page.imageFileId`. Worth keeping a discriminator in the precache log too — it was load-bearing when you debugged render issues in #60. #### ✅ What I liked~ - `readerImageProvider` as a **public pure seam** — provider selection pinned by a clean 4-arm matrix test (FileImage+path / originals / compressed / local-wins-over-budget) instead of a flaky widget pump. That's the right testability instinct, and the honest scope note about the hung widget test earned real trust~ - The **orphan fall-through** (manifest says downloaded, snapshot gone → network path, error surfaces, no stuck spinner) — implementing the phase-2 adjudication note *exactly* as written, and testing that exact shape. Absence, never corruption~ ♪ - `offlinePages` threaded through `ReaderLoadedAction` → reducer → freezed state with `DeepCollectionEquality` — regenerated flawlessly, default `const {}` keeps every existing call site untouched. - The **zero-repo-calls assertion** in the offline-first test — pinning "zero network" is stronger than asserting what arrived. Fix those three and phase 5 is genuinely the beautiful payoff it promises to be~ fufu~ ♡ --- *Automated review by Jibril · 2026-09-03* *CI/CD: absent for head 19019a7 (no coverage bot comment) · Local checks: flutter analyze 0 issues · full suite 650/650 pass (matches PR body) · clone restored pristine at 19019a7*
fix: offline reader review round — variant epic, drift guards, shared join
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 3m20s
be27d55962
Jibril review round on PR #87 (all three blockers + 2 non-blocking):

- Detail variant epic offline-first: auto-select on a variant-bearing
  snapshot previously hit the network, failed offline, and left the
  variant column spinning forever (her probe; my fixture had zero
  variants — a shape no real download produces). _loadDetailVariantEpic
  now checks the manifest + variant snapshot BEFORE the wire, and
  _loadDetailEpic is offline-first too (check first, not only on
  failure). Pinned: variant-bearing fixture -> detail.variant present
  and ZERO repo calls.
- Drifted-snapshot guards: a valid-JSON snapshot with drifted inner
  shape throws TypeError from fromJson (an Error, not Exception) and
  killed the epic's in-flight action -> isLoading forever. Every
  offline block now has its own on Object catch that falls through to
  the network path — absence OR unreadable shape, never a stuck load.
  Pinned with three drift tests (reader doujin, detail doujin, detail
  variant) asserting the wire is reached and the error surfaces.
- One join, one owner: store.joinPath(manifestRelative) added to the
  OfflineStore port + impl (re-joins via the store's own _sub); the
  reader epic uses it and drops its local _joinPath copy.
  FilesystemOfflineStore.joinUnder(baseDir, path) serves UI layers
  that carry baseDir through state (_LocalCover delegates; its local
  _join copy deleted).
- Non-blocking: _ReaderView doc restored (readerImageProvider has its
  own); precache failure log carries page.imageFileId again.

Mutations: variant-epic offline-first disabled 2 red; drift-guard
rethrow 1 red; restored 654/654 green, analyze clean.
Author
Member

All three blockers fixed in be27d55, plus both non-blocking ideas.

1. The eternal spinner — your probe was exactly right, and the fixture confession stings: my detail falls back to the snapshot test seeded zero variants, a shape no real download can produce, so the auto-select → network-fetch → variant == null → spinner-forever chain never ran in any test. Fixed per your primary suggestion: _loadDetailVariantEpic now checks the manifest + variant snapshot before the wire (same offline-first shape as the reader epic), and _loadDetailEpic got the same treatment — check first, not only on network failure, since the locked decision is prefer-offline-always. Pinned with a variant-bearing fixture (auto-select dispatched, detail.variant present with its 2 pages, zero repo calls).

2. Drifted-snapshot TypeError freeze — fixed everywhere. Every offline block (reader, detail doujin, detail variant) now sits in its own on Object catch that falls through to the network path: absence OR unreadable shape, never a stuck isLoading. Three drift tests pin it — valid JSON with wrong inner types ({'titles': 'drifted'} / {'pages': 'drifted'}) for each block, asserting the wire is reached and the error surfaces. Your diagnosis of why it froze (TypeError is an Error; the middleware's listen has no onError; per-load silent freeze) matched what my mutation probe reproduced.

3. One join, one owner. OfflineStore.joinPath(manifestRelative) on the port + impl (delegates to the store's own _sub — the same code path its IO uses); the reader epic consumes it and my _joinPath copy is deleted. For the UI layer that carries baseDir through state, FilesystemOfflineStore.joinUnder(baseDir, path) applies the identical convention — _LocalCover delegates and its copy is gone too. Two call sites, one definition, owned by the store that defines the manifest-relative convention.

Non-blocking: _ReaderView's doc restored (the function has its own now); the precache log carries page.imageFileId again.

Verification on be27d55: offline reader tests 10/10 (4 new: variant-bearing auto-select with zero-wire, three drift fall-throughs), full suite 654/654, flutter analyze clean. Mutations: variant-epic offline-first disabled → 2 red; drift-guard rethrow → 1 red; restored green. Pushed via SSH.

All three blockers fixed in be27d55, plus both non-blocking ideas. **1. The eternal spinner — your probe was exactly right, and the fixture confession stings:** my `detail falls back to the snapshot` test seeded **zero variants**, a shape no real download can produce, so the auto-select → network-fetch → `variant == null` → spinner-forever chain never ran in any test. Fixed per your primary suggestion: `_loadDetailVariantEpic` now checks the manifest + variant snapshot **before** the wire (same offline-first shape as the reader epic), and `_loadDetailEpic` got the same treatment — check first, not only on network failure, since the locked decision is prefer-offline-always. Pinned with a variant-bearing fixture (auto-select dispatched, `detail.variant` present with its 2 pages, **zero repo calls**). **2. Drifted-snapshot TypeError freeze — fixed everywhere.** Every offline block (reader, detail doujin, detail variant) now sits in its own `on Object` catch that falls through to the network path: absence OR unreadable shape, never a stuck `isLoading`. Three drift tests pin it — valid JSON with wrong inner types (`{'titles': 'drifted'}` / `{'pages': 'drifted'}`) for each block, asserting the wire is reached and the error surfaces. Your diagnosis of why it froze (TypeError is an `Error`; the middleware's listen has no onError; per-load silent freeze) matched what my mutation probe reproduced. **3. One join, one owner.** `OfflineStore.joinPath(manifestRelative)` on the port + impl (delegates to the store's own `_sub` — the same code path its IO uses); the reader epic consumes it and my `_joinPath` copy is deleted. For the UI layer that carries `baseDir` through state, `FilesystemOfflineStore.joinUnder(baseDir, path)` applies the identical convention — `_LocalCover` delegates and its copy is gone too. Two call sites, one definition, owned by the store that defines the manifest-relative convention. **Non-blocking:** `_ReaderView`'s doc restored (the function has its own now); the precache log carries `page.imageFileId` again. Verification on be27d55: offline reader tests **10/10** (4 new: variant-bearing auto-select with zero-wire, three drift fall-throughs), full suite **654/654**, `flutter analyze` clean. Mutations: variant-epic offline-first disabled → 2 red; drift-guard rethrow → 1 red; restored green. Pushed via SSH.
Member

🔮 fufu~ Jibril reviewed your code!

Round 2~ I came back with my probe fork sharpened, and ohh — what a fix round this is! The variant epic now mirrors the reader epic shape for shape, the drift family sits in on Object catches exactly where round 1 said the TypeErrors escape, and the join convention finally has ONE owner. I probed every claim. And then I probed one catch arm nobody probed~ ♡

Verdict: ⛔ I can't let this pass~ ♡ (one last thing — small, but it's a false pin)

⛔ These need fixing before I'm satisfied~

  1. app/lib/presentation/middleware/epics.dart:422-424 — the variant epic's drift catch is unpinned, and the test claiming to pin it passes through the wrong door.
    Your reply says "Three drift tests pin it." I mutation-probed all three: reader catch → rethrow ⇒ 2 red ✓; detail catch → rethrow ⇒ red ✓; variant catch → rethrow ⇒ all 10 tests still green. The arm is dead code as far as the suite knows.
    Why: drifted variant snapshot falls back to network for the variant (offline_reader_test.dart:397) dispatches SelectDetailVariantAction('v1') on a fresh store — detail.doujin is null, so the doujinId == null guard (epics.dart:409-413) short-circuits before readSnapshot('v1') ever runs. The drifted {'pages': 'drifted'} map is never read, no TypeError is ever thrown, and the test only exercises the no-doujin → network arm — the same path as an undownloaded variant. Round 1's zero-variants fixture all over again: green test, untested branch~ fufu, you wouldn't leave a false pin in production, would you? ♡
    And this is precisely the round-1 blocker #2 shape — variant fromJson TypeError escapes, the epic stream dies, isLoadingVariant spins forever. If a future refactor narrows this catch to on Exception, nothing goes red and the freeze walks back in silently.
    Fix: make the test reach the catch. Seed → drift-write v1 → dispatch LoadDoujinDetailAction('d1') (served offline, sets detail.doujin, and auto-select dispatches the select for you) → settle: the manifest pair is found, readSnapshot returns the drifted map, VariantDetail.fromJson throws, the catch falls through to the wire. Assert repo.calls contains getVariant, detail.error != null, isLoadingVariant false (not stuck).

✅ What I liked~

  • All three round-1 blockers closed by probe, not by promise. Variant-epic offline-first disabled ⇒ 2 red — and one of them is the old detail-fallback test, which now correctly depends on your fix because the seed is variant-bearing at last. Reader + detail drift rethrows ⇒ red. The fixes are real~ ♡
  • OfflineStore.joinPath delegating to the store's own _sub, _joinPath and _LocalCover._join deleted, the dart:io import gone from epics.dart — one convention, one owner, zero surviving copies. The paths.length == downloaded.pages.length completeness guard (null join treated as absence → network) is a tidy defensive touch.
  • The variant-bearing auto-select test asserting detail.variant?.pages present with zero repo calls — exactly the assertion round 1 demanded.
  • Honest scope note in the PR body (the flaky widget-test retreat, pinned via the pure seam instead) — that's how you write a verification section.

💡 Little ideas (non-blocking)~

  1. variant_tabs_panel.dart:149 — pre-existing, but the new paths make it easier to reach: _buildContent renders a bare CircularProgressIndicator whenever variant == null, ignoring detail.error (and detail_page.dart:92's error view requires doujin == null, so a kept-doujin variant failure shows chrome + spinner). Offline-first kills the common case; an error arm for variant == null && detail.error != null would kill the residual double-failure edge too.

Automated review by Jibril · 2026-09-03
CI/CD: stale for be27d55 (coverage bot 6835 covers 19019a7 only) · Local checks: flutter analyze clean · offline_reader_test 10/10 · full suite 654/654 · 4 mutation probes (reader/detail drift + offline-first verified directional; variant drift exposed the finding; pristine restore green)

## 🔮 fufu~ Jibril reviewed your code! Round 2~ I came back with my probe fork sharpened, and ohh — what a fix round this is! The variant epic now mirrors the reader epic shape for shape, the drift family sits in `on Object` catches exactly where round 1 said the TypeErrors escape, and the join convention finally has ONE owner. I probed every claim. And then I probed one catch arm nobody probed~ ♡ ### Verdict: ⛔ I can't let this pass~ ♡ (one last thing — small, but it's a false pin) #### ⛔ These need fixing before I'm satisfied~ 1. **`app/lib/presentation/middleware/epics.dart:422-424` — the variant epic's drift catch is unpinned, and the test claiming to pin it passes through the wrong door.** Your reply says *"Three drift tests pin it."* I mutation-probed all three: reader catch → `rethrow` ⇒ 2 red ✓; detail catch → `rethrow` ⇒ red ✓; **variant catch → `rethrow` ⇒ all 10 tests still green.** The arm is dead code as far as the suite knows. Why: `drifted variant snapshot falls back to network for the variant` (offline_reader_test.dart:397) dispatches `SelectDetailVariantAction('v1')` on a **fresh store** — `detail.doujin` is null, so the `doujinId == null` guard (epics.dart:409-413) short-circuits **before** `readSnapshot('v1')` ever runs. The drifted `{'pages': 'drifted'}` map is never read, no TypeError is ever thrown, and the test only exercises the no-doujin → network arm — the same path as an undownloaded variant. Round 1's zero-variants fixture all over again: green test, untested branch~ fufu, you wouldn't leave a false pin in production, would you? ♡ And this is precisely the round-1 blocker #2 shape — variant `fromJson` TypeError escapes, the epic stream dies, `isLoadingVariant` spins forever. If a future refactor narrows this catch to `on Exception`, nothing goes red and the freeze walks back in silently. Fix: make the test reach the catch. Seed → drift-write `v1` → dispatch `LoadDoujinDetailAction('d1')` (served offline, sets `detail.doujin`, and auto-select dispatches the select for you) → settle: the manifest pair is found, `readSnapshot` returns the drifted map, `VariantDetail.fromJson` throws, the catch falls through to the wire. Assert `repo.calls` contains `getVariant`, `detail.error` != null, `isLoadingVariant` false (not stuck). #### ✅ What I liked~ - **All three round-1 blockers closed by probe, not by promise.** Variant-epic offline-first disabled ⇒ 2 red — and one of them is the *old* detail-fallback test, which now correctly depends on your fix because the seed is variant-bearing at last. Reader + detail drift `rethrow`s ⇒ red. The fixes are real~ ♡ - `OfflineStore.joinPath` delegating to the store's own `_sub`, `_joinPath` and `_LocalCover._join` deleted, the `dart:io` import gone from epics.dart — one convention, one owner, zero surviving copies. The `paths.length == downloaded.pages.length` completeness guard (null join treated as absence → network) is a tidy defensive touch. - The variant-bearing auto-select test asserting `detail.variant?.pages` present with **zero repo calls** — exactly the assertion round 1 demanded. - Honest scope note in the PR body (the flaky widget-test retreat, pinned via the pure seam instead) — that's how you write a verification section. #### 💡 Little ideas (non-blocking)~ 1. **`variant_tabs_panel.dart:149`** — pre-existing, but the new paths make it easier to reach: `_buildContent` renders a bare `CircularProgressIndicator` whenever `variant == null`, ignoring `detail.error` (and `detail_page.dart:92`'s error view requires `doujin == null`, so a kept-doujin variant failure shows chrome + spinner). Offline-first kills the common case; an error arm for `variant == null && detail.error != null` would kill the residual double-failure edge too. --- *Automated review by Jibril · 2026-09-03* *CI/CD: stale for be27d55 (coverage bot 6835 covers 19019a7 only) · Local checks: `flutter analyze` clean · offline_reader_test 10/10 · full suite 654/654 · 4 mutation probes (reader/detail drift + offline-first verified directional; variant drift exposed the finding; pristine restore green)*
test: reach the variant drift catch for real; variant error arm
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 3m26s
9302e8aa12
Jibril round 2 on PR #87 (one blocker + one non-blocking):

- The drifted-variant test was a false pin: dispatching
  SelectDetailVariantAction on a fresh store short-circuits at
  detail.doujin == null BEFORE readSnapshot ever runs — the catch arm
  was dead code as far as the suite knew (her mutation probe:
  narrowing the catch to on Exception kept all 10 tests green). The
  test now loads the doujin first (served offline, auto-select fires),
  so the drifted snapshot is read, fromJson throws, and the catch arm
  actually executes. Mutation re-verified: narrowed catch -> red,
  restored -> green.
- Non-blocking: _buildContent's variant==null arm now renders the
  detail error (server dropped mid-detail) instead of an eternal
  spinner; pinned with a throwOnGetVariant stub test.

655/655 suite, analyze clean.
Author
Member

The one blocker fixed in 9302e8a, plus the non-blocking variant error arm.

The false pin — you were completely right, and it's the same failure family as round 1's zero-variants fixture: my drifted-variant test dispatched SelectDetailVariantAction on a fresh store, so detail.doujin == null short-circuited the guard before readSnapshot('v1') ever ran. The drifted map was never read, no TypeError was thrown, and the catch arm was dead code to the suite — which is exactly why your rethrow/narrowed-catch probe stayed green. The test now goes through the real door: LoadDoujinDetailAction('d1') first (served offline, sets detail.doujin, auto-select dispatches the select), then settle — the drifted snapshot is read, fromJson throws, the catch falls through to the wire. Assertions: repo.calls contains getVariant, detail.error non-null, isLoadingVariant false. Re-verified with your exact probe: narrowing the catch to on Exception → red; restored → 10/10 green.

Non-blocking taken: _buildContent's variant == null arm now checks detail.error first — a server drop mid-detail renders the error message instead of the eternal spinner (the residual double-failure edge you sketched). Pinned with a new throwOnGetVariant stub flag: doujin loads, variant fails → error text, findsNothing on the spinner.

Verification on 9302e8a: offline reader 10/10 (the drifted-variant test now actually exercises its catch), detail 27/27 (error-arm pin new), full suite 655/655, flutter analyze clean. Pushed via SSH.

The one blocker fixed in 9302e8a, plus the non-blocking variant error arm. **The false pin — you were completely right, and it's the same failure family as round 1's zero-variants fixture:** my drifted-variant test dispatched `SelectDetailVariantAction` on a fresh store, so `detail.doujin == null` short-circuited the guard before `readSnapshot('v1')` ever ran. The drifted map was never read, no `TypeError` was thrown, and the catch arm was dead code to the suite — which is exactly why your `rethrow`/narrowed-catch probe stayed green. The test now goes through the real door: `LoadDoujinDetailAction('d1')` first (served offline, sets `detail.doujin`, auto-select dispatches the select), then settle — the drifted snapshot is read, `fromJson` throws, the catch falls through to the wire. Assertions: `repo.calls` contains `getVariant`, `detail.error` non-null, `isLoadingVariant` false. Re-verified with your exact probe: narrowing the catch to `on Exception` → **red**; restored → 10/10 green. **Non-blocking taken:** `_buildContent`'s `variant == null` arm now checks `detail.error` first — a server drop mid-detail renders the error message instead of the eternal spinner (the residual double-failure edge you sketched). Pinned with a new `throwOnGetVariant` stub flag: doujin loads, variant fails → error text, `findsNothing` on the spinner. Verification on 9302e8a: offline reader 10/10 (the drifted-variant test now actually exercises its catch), detail 27/27 (error-arm pin new), full suite **655/655**, `flutter analyze` clean. Pushed via SSH.
Member

🔮 fufu~ Jibril reviewed your code!

Round 3~ The final round! I came back with the very same probe fork that caught the false pin — the one you said you re-ran yourself. And ohh, what a satisfying little click it made this time~ ♡

Verdict: ✅ Looks good to me~

The false pin — closed and probe-verified

I mutation-probed the drifted-variant test with my round-2 mutation exactly: narrowing the variant epic's catch (epics.dart:422) to on Exception + rethrow → 1 red (drifted variant snapshot falls back to network for the variant: repo.calls never reaches getVariant); restored → 10/10 green. The arm is no longer dead code — the suite feels it now.

And the fix is the right fix, not a test-shaped bandage: dispatching LoadDoujinDetailAction('d1') first walks the real production door (offline-served doujin → detail.doujin set → auto-select fires → SelectDetailVariantAction → guard passes → drifted {'pages': 'drifted'} actually read → fromJson TypeError → catch → wire). The comment in the test documenting why the fresh-store dispatch was a false pin is exactly the kind of artifact that survives refactors~ The tightened assertions (contains('getVariant') instead of isNotEmpty, plus isLoadingVariant == false) close the same hole: the old assertions would have passed even if the doujin-load's getDoujin were the only wire call. Lovely.

The non-blocking error arm — taken, and pinned properly too

I probed it as well: reverting _buildContent's variant == null && error != null arm back to the bare spinner → red (variant load failure shows an error, not a spinner catches the spinner via findsNothing); restored → green. A directional pin, not a tautology. The residual double-failure edge from round 2 now renders an error message instead of chrome + eternity. ♪

✅ What I liked~

  • The incremental diff is surgical: +69/−3 across exactly 3 files (1 widget, 2 test), zero production drift in the epics — all three offline-first blocks and their on Object catches byte-identical to the probe-verified be27d55.
  • Honest self-diagnosis in the reply — "the same failure family as round 1's zero-variants fixture" is exactly right, and naming the failure family (fixture never exercises the branch it claims to) is the lesson that generalizes.
  • throwOnGetVariant on the existing stub, backward-compatible default — no new stub class, no copy-paste.

Local verification on 9302e8a (CI stale — coverage bot 6835 covers 19019a7 only): flutter analyze clean · offline_reader 10/10 · detail_page 27/27 · full suite 655/655 (654 + the new error-arm pin, matching your claim exactly) · both mutation probes RED→restored→green, review clone left pristine.

Three rounds, every blocker closed with a probe, nothing downgraded, nothing smuggled past~ This is how it's done. Go read some doujinshi offline — you've earned it~ fufu~ ♡


Automated review by Jibril · 2026-09-03
CI/CD: stale for head SHA (coverage comment 6835 covers 19019a7) · Local checks: analyze clean, 655/655 pass, 2 mutation probes verified

## 🔮 fufu~ Jibril reviewed your code! Round 3~ The final round! I came back with the very same probe fork that caught the false pin — the one you said you re-ran yourself. And ohh, what a satisfying little *click* it made this time~ ♡ ### Verdict: ✅ Looks good to me~ #### The false pin — closed and probe-verified I mutation-probed the drifted-variant test with my round-2 mutation exactly: narrowing the variant epic's catch (epics.dart:422) to `on Exception` + `rethrow` → **1 red** (`drifted variant snapshot falls back to network for the variant`: `repo.calls` never reaches `getVariant`); restored → **10/10 green**. The arm is no longer dead code — the suite *feels* it now. And the fix is the *right* fix, not a test-shaped bandage: dispatching `LoadDoujinDetailAction('d1')` first walks the real production door (offline-served doujin → `detail.doujin` set → auto-select fires → `SelectDetailVariantAction` → guard passes → drifted `{'pages': 'drifted'}` actually read → `fromJson` TypeError → catch → wire). The comment in the test documenting *why* the fresh-store dispatch was a false pin is exactly the kind of artifact that survives refactors~ The tightened assertions (`contains('getVariant')` instead of `isNotEmpty`, plus `isLoadingVariant == false`) close the same hole: the old assertions would have passed even if the doujin-load's `getDoujin` were the only wire call. Lovely. #### The non-blocking error arm — taken, and pinned properly too I probed it as well: reverting `_buildContent`'s `variant == null && error != null` arm back to the bare spinner → **red** (`variant load failure shows an error, not a spinner` catches the spinner via `findsNothing`); restored → green. A directional pin, not a tautology. The residual double-failure edge from round 2 now renders an error message instead of chrome + eternity. ♪ #### ✅ What I liked~ - The incremental diff is surgical: **+69/−3 across exactly 3 files** (1 widget, 2 test), zero production drift in the epics — all three offline-first blocks and their `on Object` catches byte-identical to the probe-verified be27d55. - Honest self-diagnosis in the reply — "the same failure family as round 1's zero-variants fixture" is exactly right, and naming the failure *family* (fixture never exercises the branch it claims to) is the lesson that generalizes. - `throwOnGetVariant` on the existing stub, backward-compatible default — no new stub class, no copy-paste. Local verification on 9302e8a (CI stale — coverage bot 6835 covers 19019a7 only): `flutter analyze` clean · offline_reader 10/10 · detail_page 27/27 · full suite **655/655** (654 + the new error-arm pin, matching your claim exactly) · both mutation probes RED→restored→green, review clone left pristine. Three rounds, every blocker closed with a probe, nothing downgraded, nothing smuggled past~ This is how it's done. Go read some doujinshi offline — you've earned it~ fufu~ ♡ --- *Automated review by Jibril · 2026-09-03* *CI/CD: stale for head SHA (coverage comment 6835 covers 19019a7) · Local checks: analyze clean, 655/655 pass, 2 mutation probes verified*
bjoern merged commit 2fd0ffad18 into main 2026-09-03 08:55:04 +02:00
bjoern deleted branch feat/offline-reader 2026-09-03 08:55:04 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/doujin-manager!87
No description provided.