feat: delete a duplicate group's member from the duplicates page #101

Merged
bjoern merged 3 commits from feat/duplicates-delete-member into main 2026-09-07 13:29:01 +02:00
Member

The possible-duplicates page can now act on what it finds: each member has a delete button beside its open-chip, a confirmation names the doujin and says the doujin with all its variants and pages is removed from the server while the group's other members are kept, and on success the list reloads so a group that drops below two members disappears.

What's in

  • State and epic. DeleteDuplicateMemberAction plus per-member in-flight and error fields on DuplicatesState. The reducer is the gate: it accepts one delete at a time and stamps a request sequence; the epic compares that stamp to the last one it ran, so a repeat of the same member is dropped too (the first version only caught a second delete of a different member — the review found that two identical taps deleted twice). On success it dispatches the existing shared DoujinDeletedAction, so the editor slice drops the doujin as well, then reloads page 1.
  • UI. While a delete runs, that member shows a spinner and every other delete is disabled, along with Refresh. Deletes are also disabled during a refresh and a load-more — the load-more case mattered: a stale page-2 append would otherwise land on top of the delete's own reload and release the spinner early over a list that still showed the deleted doujin.
  • Failure keeps the group intact and renders the message inside that group's card only.

Verification

  • scripts/verify.sh app: green, 780 tests.
  • Directional probes, all red: each half of the delete-enabled gate (refresh, load-more), the reducer's one-at-a-time gate, the request stamp, the shared deleted-action dispatch, the reload, and the per-group error placement. The review's reproduction — two identical delete actions producing two server deletes — is now red without the stamp.
  • Green with the rest of the batch on a six-branch integration tree.
  • Not exercised: a real backend (the repository call is stubbed), and the button's layout at very narrow window widths beyond the widget-test surface.

Two things the review corrected in the first commit's claims

The original commit said a double-tap could not delete twice; it could, and the stamp is what makes that true now. It also justified deferring the library-staleness gap by saying the library refetches on entry — it does not: the library fetches on entry only when the list is empty, otherwise it waits out a five-minute staleness window. That makes the gap a real bug rather than a cosmetic one, so it is now a tracked backlog item: the library slice ignores DoujinDeletedAction, so a doujin deleted here or in the editor lingers in an already-loaded grid, and its card 404s when opened.

Decisions to revisit

The epic keeps an id comparison beside the sequence stamp; with the stamp in place the id operand is no longer separately reachable by a test, and it is retained deliberately as a second condition on a destructive call. The page reloads from the server after a delete rather than removing the group locally, which costs one request but avoids reimplementing the server's "a group needs two members" rule in the client.

🤖 Generated with Claude Code

The possible-duplicates page can now act on what it finds: each member has a delete button beside its open-chip, a confirmation names the doujin and says the doujin with all its variants and pages is removed from the server while the group's other members are kept, and on success the list reloads so a group that drops below two members disappears. ## What's in - **State and epic.** `DeleteDuplicateMemberAction` plus per-member in-flight and error fields on `DuplicatesState`. The reducer is the gate: it accepts one delete at a time and stamps a request sequence; the epic compares that stamp to the last one it ran, so a repeat of the *same* member is dropped too (the first version only caught a second delete of a *different* member — the review found that two identical taps deleted twice). On success it dispatches the existing shared `DoujinDeletedAction`, so the editor slice drops the doujin as well, then reloads page 1. - **UI.** While a delete runs, that member shows a spinner and every other delete is disabled, along with Refresh. Deletes are also disabled during a refresh *and* a load-more — the load-more case mattered: a stale page-2 append would otherwise land on top of the delete's own reload and release the spinner early over a list that still showed the deleted doujin. - **Failure** keeps the group intact and renders the message inside that group's card only. ## Verification - `scripts/verify.sh app`: green, 780 tests. - Directional probes, all red: each half of the delete-enabled gate (refresh, load-more), the reducer's one-at-a-time gate, the request stamp, the shared deleted-action dispatch, the reload, and the per-group error placement. The review's reproduction — two identical delete actions producing two server deletes — is now red without the stamp. - Green with the rest of the batch on a six-branch integration tree. - **Not exercised**: a real backend (the repository call is stubbed), and the button's layout at very narrow window widths beyond the widget-test surface. ## Two things the review corrected in the first commit's claims The original commit said a double-tap could not delete twice; it could, and the stamp is what makes that true now. It also justified deferring the library-staleness gap by saying the library refetches on entry — it does not: the library fetches on entry only when the list is empty, otherwise it waits out a five-minute staleness window. That makes the gap a real bug rather than a cosmetic one, so it is now a tracked backlog item: the library slice ignores `DoujinDeletedAction`, so a doujin deleted here or in the editor lingers in an already-loaded grid, and its card 404s when opened. ## Decisions to revisit The epic keeps an id comparison beside the sequence stamp; with the stamp in place the id operand is no longer separately reachable by a test, and it is retained deliberately as a second condition on a destructive call. The page reloads from the server after a delete rather than removing the group locally, which costs one request but avoids reimplementing the server's "a group needs two members" rule in the client. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The possible-duplicates page could only open each member's detail page;
deciding which copy to keep meant leaving the page, deleting in the
editor, and coming back to a stale list.

What's in:
- Every member row now carries a delete button next to its open-chip. It
  confirms with the doujin's title and what goes ("the doujin with all
  its variants and pages"), and the other members of the group are named
  as kept.
- DeleteDuplicateMemberAction + deletingDoujinId / deleteErrorDoujinId /
  deleteError on DuplicatesState. One delete at a time: the reducer drops
  a second request and the epic re-reads that flag, so a double-tap
  cannot delete twice. The page disables every other delete and Refresh
  while one runs and turns that member's button into a spinner.
- The epic calls DoujinRepository.deleteDoujin, then dispatches the
  shared DoujinDeletedAction (the editor slice's own delete signal, so
  the editor list drops the doujin too) and reloads page 1 — the server
  decides which groups remain, so a group that falls to one member
  disappears. A failure keeps the group and shows the message under that
  group's card, with the member intact.

Verified: scripts/verify.sh app (775 tests, analyze clean, no codegen
drift). New tests: reducer (7 cases through appReducer), epics through
createStore (success reloads + editor slice sees the removal; failure
keeps the group and names the member; a gated second delete never
reaches the repo), page widget tests over the real store and router
(confirm cancel deletes nothing; confirm removes the group that drops to
one member; in-flight spinner with every other delete and Refresh
disabled; failure inline under the owning card only). Mutation-probed:
both in-flight guards, the DoujinDeletedAction dispatch, the reload, the
per-group error scoping, the Refresh gate, and the spinner release on a
failed reload each go red when removed. Not verified: against a real
backend (the app half has no live server in CI).

Follow-ups: merging metadata across a group stays in docs/BACKLOG.md.
The library slice still ignores DoujinDeletedAction (pre-existing — the
library list refetches on entry); noted for the owner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix: the duplicates delete gate matches Refresh, and a repeated delete of the same member runs once
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 3m49s
b0e46b85d5
Review follow-ups on the member-delete work, all in the duplicates slice.

What's in:
- `deletesEnabled` now also requires `!isLoadingMore`, so the delete gate
  is exactly the Refresh gate. Before, a delete started during a load-more
  dispatched its own page-1 reload, and the stale page-2 append landing
  afterwards cleared `deletingDoujinId` — releasing the member's spinner
  before the doujin was actually gone.
- The `!isLoading` half of that same gate had no test: removing it stayed
  green. A widget test now holds the repo gate over a Refresh and asserts
  every delete button refuses a press until the fresh page lands; a second
  one does the same for a load-more.
- The epic's one-delete-at-a-time gate was `deletingDoujinId != doujinId`,
  which only catches a second delete of a *different* member. `asyncExpand`
  serialises the handlers, so the second of two identical
  `DeleteDuplicateMemberAction('d2')` reaches the body after the first
  delete finished but before its reload clears the flag — the flag still
  says 'd2', and the doujin was deleted twice (a second DELETE and a second
  reload). The reducer now stamps a monotonic `deleteRequestSeq` when it
  accepts a request, and the epic compares it against the last stamp it
  ran, so a request the reducer dropped cannot slip through on a matching
  id. The id comparison stays beside the stamp: the operation is
  destructive, and it also asserts the flagged member is the one the action
  names.
- docs/BACKLOG.md gets the library-staleness item this branch deferred.

Correcting two claims of the previous commit on this branch: the reducer's
drop plus the epic's id check did *not* make a double-tap safe for the same
member (only for a different one), and LibraryPage does not refetch on
entry — it loads only when the list is empty and otherwise re-runs the
query only once the result set is older than `kLibraryAutoRefreshAfter`,
which is why the ignored `DoujinDeletedAction` is a real staleness bug and
now sits in the backlog.

Verified: scripts/verify.sh app (780 tests, analyze clean, no codegen
drift). Mutation probes, each red on the named test and green again after
restoring: dropping `!isLoading` from `deletesEnabled` fails "a refresh
disables every delete until its page lands"; dropping `!isLoadingMore`
fails "a load-more disables every delete until its page lands"; dropping
the stamp comparison from the epic (back to the id-only gate) fails "a
repeated delete of the same member deletes it once" with deleted =
['d2', 'd2']; dropping the whole epic gate fails that one and "a second
delete while one is in flight never reaches the server"; dropping the
reducer's stamp fails "an accepted delete stamps a fresh request, a
dropped one does not" and every delete epic test. "A delete of the same
member after a failed one still runs" pins that the stamp does not block a
legitimate retry. Not verified: against a real backend (the app half has
no live server in CI).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Flutter Coverage

File Line coverage
lib/app/store.dart 95.0% (19 of 20)
lib/data/models/doujin_models.dart 86.4% (38 of 44)
lib/data/models/doujin_models.g.dart 61.7% (195 of 316)
lib/domain/entities/stored_settings.dart 100.0% (9 of 9)
lib/presentation/state/app_state.dart 80.0% (24 of 30)
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% (69 of 69)
lib/agent/tools/budgeted_browser_tool.dart 84.2% (16 of 19)
lib/agent/tools/doujin_write_tool.dart 86.4% (319 of 369)
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 95.1% (39 of 41)
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% (99 of 99)
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 40.0% (4 of 10)
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.3% (380 of 416)
lib/presentation/middleware/offline_epics.dart 98.4% (60 of 61)
lib/presentation/state/reducers.dart 100.0% (14 of 14)
lib/core/chunking.dart 100.0% (12 of 12)
lib/core/code_point_compare.dart 100.0% (8 of 8)
lib/core/languages.dart 100.0% (8 of 8)
lib/core/reading_order.dart 100.0% (22 of 22)
lib/core/title_display.dart 90.0% (9 of 10)
lib/core/variant_display.dart 100.0% (22 of 22)
lib/data/models/envelope.dart 81.2% (13 of 16)
lib/data/models/envelope.g.dart 53.7% (36 of 67)
lib/data/models/server_models.dart 100.0% (8 of 8)
lib/data/models/server_models.g.dart 47.5% (19 of 40)
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 92.2% (107 of 116)
lib/presentation/middleware/duplicates_epics.dart 100.0% (39 of 39)
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/server_epics.dart 100.0% (33 of 33)
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/duplicates_actions.dart 83.3% (5 of 6)
lib/presentation/state/actions/editor_actions.dart 48.6% (18 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/server_actions.dart 72.7% (8 of 11)
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/duplicates_reducer.dart 100.0% (38 of 38)
lib/presentation/state/reducers/editor_reducer.dart 97.1% (99 of 102)
lib/presentation/state/reducers/entity_reducer.dart 98.8% (82 of 83)
lib/presentation/state/reducers/library_reducer.dart 100.0% (122 of 122)
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% (20 of 20)
lib/presentation/state/reducers/server_reducer.dart 100.0% (47 of 47)
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 0.0% (0 of 61)
lib/presentation/widgets/language_dropdown.dart 84.6% (11 of 13)
lib/presentation/pages/reader/reader_page.dart 89.4% (236 of 264)
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 29.0% (111 of 383)
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/editor_pane.dart 69.8% (37 of 53)
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/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 80.1% (173 of 216)
lib/presentation/pages/detail/variant_tabs_panel.dart 97.2% (239 of 246)
lib/data/offline/filesystem_offline_store.dart 94.1% (144 of 153)
lib/core/offline_paths.dart 100.0% (3 of 3)
lib/presentation/pages/offline/offline_page.dart 98.2% (164 of 167)
lib/app/app.dart 67.1% (47 of 70)
lib/presentation/pages/settings/settings_page.dart 99.4% (156 of 157)
lib/presentation/pages/circles/circles_page.dart 52.6% (10 of 19)
lib/presentation/pages/duplicates/duplicates_page.dart 100.0% (157 of 157)
lib/presentation/pages/library/library_page.dart 93.1% (215 of 231)
lib/presentation/pages/series/series_page.dart 50.0% (9 of 18)
lib/presentation/widgets/smart_filter_bar.dart 84.1% (207 of 246)
lib/presentation/pages/settings/server_sections.dart 100.0% (154 of 154)
lib/presentation/widgets/model_combo_field.dart 69.0% (100 of 145)
lib/app/skill_assets.dart 92.9% (13 of 14)

Total: 80.8% (7902 of 9783)

<!-- flutter-coverage-comment --> ## Flutter Coverage | File | Line coverage | |:---|---:| | lib/app/store.dart | 95.0% (19 of 20) | | lib/data/models/doujin_models.dart | 86.4% (38 of 44) | | lib/data/models/doujin_models.g.dart | 61.7% (195 of 316) | | lib/domain/entities/stored_settings.dart | 100.0% (9 of 9) | | lib/presentation/state/app_state.dart | 80.0% (24 of 30) | | 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% (69 of 69) | | lib/agent/tools/budgeted_browser_tool.dart | 84.2% (16 of 19) | | lib/agent/tools/doujin_write_tool.dart | 86.4% (319 of 369) | | 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 | 95.1% (39 of 41) | | 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% (99 of 99) | | 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 | 40.0% (4 of 10) | | 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.3% (380 of 416) | | lib/presentation/middleware/offline_epics.dart | 98.4% (60 of 61) | | lib/presentation/state/reducers.dart | 100.0% (14 of 14) | | lib/core/chunking.dart | 100.0% (12 of 12) | | lib/core/code_point_compare.dart | 100.0% (8 of 8) | | lib/core/languages.dart | 100.0% (8 of 8) | | lib/core/reading_order.dart | 100.0% (22 of 22) | | lib/core/title_display.dart | 90.0% (9 of 10) | | lib/core/variant_display.dart | 100.0% (22 of 22) | | lib/data/models/envelope.dart | 81.2% (13 of 16) | | lib/data/models/envelope.g.dart | 53.7% (36 of 67) | | lib/data/models/server_models.dart | 100.0% (8 of 8) | | lib/data/models/server_models.g.dart | 47.5% (19 of 40) | | 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 | 92.2% (107 of 116) | | lib/presentation/middleware/duplicates_epics.dart | 100.0% (39 of 39) | | 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/server_epics.dart | 100.0% (33 of 33) | | 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/duplicates_actions.dart | 83.3% (5 of 6) | | lib/presentation/state/actions/editor_actions.dart | 48.6% (18 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/server_actions.dart | 72.7% (8 of 11) | | 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/duplicates_reducer.dart | 100.0% (38 of 38) | | lib/presentation/state/reducers/editor_reducer.dart | 97.1% (99 of 102) | | lib/presentation/state/reducers/entity_reducer.dart | 98.8% (82 of 83) | | lib/presentation/state/reducers/library_reducer.dart | 100.0% (122 of 122) | | 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% (20 of 20) | | lib/presentation/state/reducers/server_reducer.dart | 100.0% (47 of 47) | | 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 | 0.0% (0 of 61) | | lib/presentation/widgets/language_dropdown.dart | 84.6% (11 of 13) | | lib/presentation/pages/reader/reader_page.dart | 89.4% (236 of 264) | | 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 | 29.0% (111 of 383) | | 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/editor_pane.dart | 69.8% (37 of 53) | | 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/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 | 80.1% (173 of 216) | | lib/presentation/pages/detail/variant_tabs_panel.dart | 97.2% (239 of 246) | | lib/data/offline/filesystem_offline_store.dart | 94.1% (144 of 153) | | lib/core/offline_paths.dart | 100.0% (3 of 3) | | lib/presentation/pages/offline/offline_page.dart | 98.2% (164 of 167) | | lib/app/app.dart | 67.1% (47 of 70) | | lib/presentation/pages/settings/settings_page.dart | 99.4% (156 of 157) | | lib/presentation/pages/circles/circles_page.dart | 52.6% (10 of 19) | | lib/presentation/pages/duplicates/duplicates_page.dart | 100.0% (157 of 157) | | lib/presentation/pages/library/library_page.dart | 93.1% (215 of 231) | | lib/presentation/pages/series/series_page.dart | 50.0% (9 of 18) | | lib/presentation/widgets/smart_filter_bar.dart | 84.1% (207 of 246) | | lib/presentation/pages/settings/server_sections.dart | 100.0% (154 of 154) | | lib/presentation/widgets/model_combo_field.dart | 69.0% (100 of 145) | | lib/app/skill_assets.dart | 92.9% (13 of 14) | **Total: 80.8% (7902 of 9783)**
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! A delete button on the duplicates page — the page finally gets teeth~ ♡ And that request-stamp design... reducer-as-gate with a monotonic sequence the epic reads back? I got genuinely giddy tracing it. You even caught your own first commit lying about the double-tap. That's the kind of self-correction I want to frame and hang on my wall~

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

⛔ These need fixing before I'm satisfied~

  1. duplicates_reducer.dart (LoadMoreDuplicatesAction guard) + duplicates_page.dart (Load-more onPressed) — the delete/load-more gate is asymmetric, and the open half re-opens the very window the stamp closed.
    The PR guards one direction meticulously: deletes are disabled while a load-more runs (deletesEnabled checks isLoadingMore), because "a stale page-2 append would otherwise land on top of the delete's own reload and release its spinner early." But the reverse direction is unguarded: Load more is gated on isLoading only (not deletingDoujinId), and the reducer's LoadMoreDuplicatesAction guard checks hasMorePages || isLoading || isLoadingMore — never deletingDoujinId. The two epics run concurrently (asyncMap/asyncExpand on separate streams), so: delete in flight → Load more tapped → reducer accepts it → the page-2 append lands while repo.deleteDoujin is still on the wire → the append arm of DuplicatesLoadedAction sets deletingDoujinId: null → spinner gone, every delete re-enabled, over a list that still shows the doujin being deleted. The user sees "done", taps delete again, gets a 404 dressed up as an error. Your own invariant — no delete affordance is live while the list is stale about a delete — violated through the back door.
    I verified this isn't theoretical, silly little gap~ ♡ Reducer probe: append-during-delete clears deletingDoujinId (Expected 'd2', Actual <null>); load-more-during-delete is accepted (Expected false, Actual true). Widget probe: with the delete spinner up and Refresh properly dead, Load more's onPressed is non-null — the button is clickable.
    Fix: add dup.deletingDoujinId != null to the LoadMoreDuplicatesAction guard in the reducer, and dup.deletingDoujinId != null to the Load-more onPressed gate in the page — mirroring exactly what Refresh already does. (The LoadDuplicatesAction arm of the reducer needs no change: the delete's own reload must pass through.)

  2. Missing tests for precisely those arms — fufu~ you added code paths but left the gate's fourth wall unpinned? ♡ Once the two guards above exist they need directional pins: (a) a reducer test that LoadMoreDuplicatesAction during deletingDoujinId != null is dropped, and (b) a reducer or epic test that an append arriving during a delete does not clear deletingDoujinId (your existing the delete flags survive the load the epic dispatches after it is the perfect sibling to copy). A widget assertion that Load-more is disabled while the spinner runs would complete the set.

💡 Little ideas (non-blocking)~

  1. duplicates_page.dart:306 — the dialog title Delete this doujin? vs the editor sibling's Delete doujin? (editor_pane.dart). Both clear, but the family wears matching clothes in this codebase; consider unifying the phrasing someday.

✅ What I liked~

  • The request stamp itself. deleteRequestSeq rising only on reducer-accepted requests, read back by the epic against handledSeq — that's the correct Redux-idiomatic answer to "two identical taps both pass an id-only check," and the comment explaining why asyncExpand serialization makes the id insufficient is exactly the kind of why-comment that survives refactors. I mutation-probed it three ways: removing the stamp check turns exactly the repeated-delete test red; removing the reducer gate turns three tests red; removing the id operand leaves everything green — which confirms your PR body's candid claim that it's unreachable defense-in-depth rather than tested behavior. Honest engineering~ ♪
  • The test discipline. a second delete while one is in flight never reaches the server with the gated Completer is a genuine race pin, not a tautology. The failure-path test asserting no reload (so the error stays visible) shows real care about the UX consequence.
  • Cross-slice reuse, not duplication. Dispatching the shared DoujinDeletedAction so the editor slice updates for free, instead of copy-pasting editor logic — exactly right. And the reducer's library-comment explaining why this slice never sees that action (editor claims it earlier in the chain) prevented me from filing a phantom bug~
  • The BACKLOG honesty. I verified both claims live: library_reducer.dart truly has no DoujinDeletedAction handler, and library_page.dart:109-124 truly fetches on entry only when empty with a 5-minute staleness window — so promoting the gap from "cosmetic" to a tracked real bug was the right call, documented with the right evidence.
  • Local verification, independently reproduced: flutter analyze 0 issues; duplicates suites 40/40; full suite 780/780 (matches your PR body exactly); build_runner regenerates app_state.freezed.dart with zero drift.

The architecture is lovely and 95% of the gate is airtight — which is exactly why the one open seam hurts so much, fufu~ ♡ Close the reverse direction and pin it, and this merges beautifully.


Automated review by Jibril · 2026-09-06
CI/CD: absent for head SHA b0e46b8 · Local checks: analyze clean · 40/40 duplicates tests · 780/780 full suite · codegen drift none · 3 mutation probes + 3 gate probes

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A delete button on the duplicates page — the page finally gets *teeth*~ ♡ And that request-stamp design... reducer-as-gate with a monotonic sequence the epic reads back? I got genuinely giddy tracing it. You even caught your own first commit lying about the double-tap. That's the kind of self-correction I want to frame and hang on my wall~ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`duplicates_reducer.dart` (`LoadMoreDuplicatesAction` guard) + `duplicates_page.dart` (Load-more `onPressed`)** — the delete/load-more gate is asymmetric, and the open half re-opens the very window the stamp closed. The PR guards one direction meticulously: deletes are disabled while a load-more runs (`deletesEnabled` checks `isLoadingMore`), because "a stale page-2 append would otherwise land on top of the delete's own reload and release its spinner early." But the **reverse direction is unguarded**: Load more is gated on `isLoading` only (not `deletingDoujinId`), and the reducer's `LoadMoreDuplicatesAction` guard checks `hasMorePages || isLoading || isLoadingMore` — never `deletingDoujinId`. The two epics run concurrently (`asyncMap`/`asyncExpand` on separate streams), so: delete in flight → Load more tapped → reducer accepts it → the page-2 append lands **while `repo.deleteDoujin` is still on the wire** → the append arm of `DuplicatesLoadedAction` sets `deletingDoujinId: null` → spinner gone, every delete re-enabled, over a list that still *shows* the doujin being deleted. The user sees "done", taps delete again, gets a 404 dressed up as an error. Your own invariant — no delete affordance is live while the list is stale about a delete — violated through the back door. I verified this isn't theoretical, silly little gap~ ♡ Reducer probe: append-during-delete clears `deletingDoujinId` (Expected `'d2'`, Actual `<null>`); load-more-during-delete is accepted (Expected `false`, Actual `true`). Widget probe: with the delete spinner up and Refresh properly dead, **Load more's `onPressed` is non-null** — the button is clickable. Fix: add `dup.deletingDoujinId != null` to the `LoadMoreDuplicatesAction` guard in the reducer, and `dup.deletingDoujinId != null` to the Load-more `onPressed` gate in the page — mirroring exactly what Refresh already does. (The `LoadDuplicatesAction` arm of the reducer needs no change: the delete's own reload must pass through.) 2. **Missing tests for precisely those arms** — fufu~ you added code paths but left the gate's fourth wall unpinned? ♡ Once the two guards above exist they need directional pins: (a) a reducer test that `LoadMoreDuplicatesAction` during `deletingDoujinId != null` is dropped, and (b) a reducer or epic test that an append arriving during a delete does not clear `deletingDoujinId` (your existing `the delete flags survive the load the epic dispatches after it` is the perfect sibling to copy). A widget assertion that Load-more is disabled while the spinner runs would complete the set. #### 💡 Little ideas (non-blocking)~ 1. **`duplicates_page.dart:306`** — the dialog title `Delete this doujin?` vs the editor sibling's `Delete doujin?` (`editor_pane.dart`). Both clear, but the family wears matching clothes in this codebase; consider unifying the phrasing someday. #### ✅ What I liked~ - **The request stamp itself.** `deleteRequestSeq` rising only on reducer-accepted requests, read back by the epic against `handledSeq` — that's the *correct* Redux-idiomatic answer to "two identical taps both pass an id-only check," and the comment explaining why `asyncExpand` serialization makes the id insufficient is exactly the kind of why-comment that survives refactors. I mutation-probed it three ways: removing the stamp check turns exactly the repeated-delete test red; removing the reducer gate turns three tests red; removing the id operand leaves everything green — which **confirms your PR body's candid claim** that it's unreachable defense-in-depth rather than tested behavior. Honest engineering~ ♪ - **The test discipline.** `a second delete while one is in flight never reaches the server` with the gated `Completer` is a genuine race pin, not a tautology. The failure-path test asserting *no reload* (so the error stays visible) shows real care about the UX consequence. - **Cross-slice reuse, not duplication.** Dispatching the shared `DoujinDeletedAction` so the editor slice updates for free, instead of copy-pasting editor logic — exactly right. And the reducer's library-comment explaining *why* this slice never sees that action (editor claims it earlier in the chain) prevented me from filing a phantom bug~ - **The BACKLOG honesty.** I verified both claims live: `library_reducer.dart` truly has no `DoujinDeletedAction` handler, and `library_page.dart:109-124` truly fetches on entry only when empty with a 5-minute staleness window — so promoting the gap from "cosmetic" to a tracked real bug was the right call, documented with the right evidence. - **Local verification, independently reproduced:** `flutter analyze` 0 issues; duplicates suites 40/40; full suite **780/780** (matches your PR body exactly); `build_runner` regenerates `app_state.freezed.dart` with **zero drift**. The architecture is lovely and 95% of the gate is airtight — which is exactly why the one open seam hurts so much, fufu~ ♡ Close the reverse direction and pin it, and this merges beautifully. --- *Automated review by Jibril · 2026-09-06* *CI/CD: absent for head SHA b0e46b8 · Local checks: analyze clean · 40/40 duplicates tests · 780/780 full suite · codegen drift none · 3 mutation probes + 3 gate probes*
bjoern force-pushed feat/duplicates-delete-member from b0e46b85d5
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 3m49s
to 17501875e3
Some checks failed
Deploy Smoke Test / smoke (pull_request) Failing after 2m45s
Flutter CI / analyze-and-test (pull_request) Successful in 15m7s
2026-09-07 12:39:01 +02:00
Compare
Author
Member

All three parts of the blocker fixed in 1750187, plus the dialog wording. Rebased onto main first (head 1750187); the README was the only conflict, and both bullets survive — main's assistant bullet with the one-card approval, and this branch's duplicates bullet ending in the per-member delete.

The gate is now symmetric. The reducer's LoadMoreDuplicatesAction guard gained deletingDoujinId != null, and the page's Load-more button carries the same condition, so it is dead while the spinner runs — exactly what Refresh already did. LoadDuplicatesAction is untouched, so the delete's own reload still passes through and still holds the spinner until the fresh page 1 lands.

The belt-and-braces suggestion turned out to be load-bearing, so it is in too. Those two gates close the UI and reducer path, but nothing stops a delete being accepted while a load-more is already in flight: the reducer's delete guard checks only deletingDoujinId, not isLoadingMore — only the page's deletesEnabled does. So a delete dispatched from anywhere but that button, the assistant for instance, can still overlap an in-flight append. The append arm therefore no longer clears any delete flag; an append only extends the list, so nothing on screen goes away and the spinner survives until the delete's own reload replaces page 1.

Probes, each applied alone and restored:

  • reducer guard reverted → red on "LoadMoreDuplicatesAction is ignored while a delete is in flight";
  • append arm reverted to clearing the flags → red on "an append landing during a delete keeps the delete flags", while both existing flag tests stayed green, so the change breaks neither;
  • page gate reverted → red on "Load more is disabled while a delete runs", which is your third reproduction, now pinned;
  • dialog title reverted → red.

Non-blocking taken: the title is now Delete doujin?, matching editor_pane.dart exactly.

Verification on 1750187: scripts/verify.sh app green, 820 tests, analyze clean, no codegen drift. Verified again together with #99 on one tree: backend 529, app 820.

All three parts of the blocker fixed in 1750187, plus the dialog wording. Rebased onto `main` first (head 1750187); the README was the only conflict, and both bullets survive — main's assistant bullet with the one-card approval, and this branch's duplicates bullet ending in the per-member delete. **The gate is now symmetric.** The reducer's `LoadMoreDuplicatesAction` guard gained `deletingDoujinId != null`, and the page's Load-more button carries the same condition, so it is dead while the spinner runs — exactly what Refresh already did. `LoadDuplicatesAction` is untouched, so the delete's own reload still passes through and still holds the spinner until the fresh page 1 lands. **The belt-and-braces suggestion turned out to be load-bearing, so it is in too.** Those two gates close the UI and reducer path, but nothing stops a delete being *accepted* while a load-more is already in flight: the reducer's delete guard checks only `deletingDoujinId`, not `isLoadingMore` — only the page's `deletesEnabled` does. So a delete dispatched from anywhere but that button, the assistant for instance, can still overlap an in-flight append. The append arm therefore no longer clears any delete flag; an append only extends the list, so nothing on screen goes away and the spinner survives until the delete's own reload replaces page 1. Probes, each applied alone and restored: - reducer guard reverted → red on "LoadMoreDuplicatesAction is ignored while a delete is in flight"; - append arm reverted to clearing the flags → red on "an append landing during a delete keeps the delete flags", while both existing flag tests stayed green, so the change breaks neither; - page gate reverted → red on "Load more is disabled while a delete runs", which is your third reproduction, now pinned; - dialog title reverted → red. **Non-blocking taken:** the title is now `Delete doujin?`, matching `editor_pane.dart` exactly. Verification on 1750187: `scripts/verify.sh app` green, 820 tests, analyze clean, no codegen drift. Verified again together with #99 on one tree: backend 529, app 820.
Member

🔮 fufu~ Jibril reviewed your code!

You came back and made the gate symmetric — and then, oh? Oh! You noticed the belt-and-braces I didn't even ask for was actually load-bearing: the reducer's delete guard checks only deletingDoujinId, so a delete dispatched from anywhere but that button could still overlap an in-flight append, and you closed it at the state machine instead of the UI. Defense-in-depth on a destructive call, motivated correctly. I got genuinely giddy tracing the interleavings~ ♡

Verdict: ✅ Looks good to me~

Both round-1 blockers are closed, pinned, and independently verified. I ran my own probes this time — not yours, mine:

  • Reducer guard — dropped deletingDoujinId != null from LoadMoreDuplicatesAction's guard in my clone → red on exactly LoadMoreDuplicatesAction is ignored while a delete is in flight. Restored → green.
  • Append arm — reverted to the unconditional deletingDoujinId: null → red on exactly an append landing during a delete keeps the delete flags. Restored → green.
  • Page gate — dropped the condition from Load-more's onPressed → red on exactly Load more is disabled while a delete runs (spinner up, onPressed a closure instead of null). Restored → green.

Three probes, three independent pins, zero collateral red. That's the whole fourth wall of the gate, bricked and mortared~

✅ What I liked~

  • The append-arm fix is the right shape, not just the easy one. I traced every interleaving: an append only extends the list (nothing on screen goes away, so spinner and per-group error stay truthful), and only a fresh page 1 — the delete's own reload — clears the flags. The failure variant converges too: DuplicateMemberDeleteFailedAction rewrites all three fields atomically. And the one arm that still clears deletingDoujinId on error is the documented release valve for the delete's own failed reload, with its why-comment already in place from round 1~
  • The three new tests are directional, not decorative. The reducer test asserts identical(result, deleting) — a true drop, not a copy. The append test checks the flags and that the append itself still landed ([g1, g2], isLoadingMore: false) — it pins both halves of the conditional. The widget test is my round-1 reproduction turned into a regression pin, gated Completer and all, and it even asserts Load-more comes back after the reload lands. ♪
  • The rebase was honest. Old head b0e46b8 vs rewritten 763f4ba: every duplicates_* file byte-identical, the only drift being main's four incoming commits plus the README/BACKLOG conflict resolution — and I read that resolution: main's assistant bullet with the one-card approval survives intact, your duplicates bullet keeps its per-member delete ending. Both bullets live. Nothing smuggled in~
  • Dialog unified. Delete doujin? now matches editor_pane.dart exactly — and it's asserted, so it can't silently drift back.
  • Local verification, independently reproduced on 1750187: flutter analyze 0 issues; duplicates suites 43/43 (+3 new); full suite 820/820 (matches your comment); build_runner regenerates with zero drift; clone left pristine at head.

💡 Little ideas (non-blocking)~

  1. duplicates_reducer.dart:79 — the DuplicatesErrorAction arm releases deletingDoujinId unconditionally. Today that's exactly right (it's the delete's-own-reload-failed release, and the page gates make any other mid-delete fetch unreachable — I checked, the page is the only dispatch site for this action family). If a second dispatch site ever appears, this arm becomes the next back door — the in-code comment already carries the intent, so just keep it in mind if that day comes.

The gate is symmetric, the wall is pinned, and the spinner cannot lie anymore. This merges beautifully, fufu~ ♡


Automated review by Jibril · 2026-09-07
CI/CD: stale for head SHA 1750187 (coverage bot 7195 covers the original push) · Local checks: analyze clean · 43/43 duplicates tests · 820/820 full suite · codegen drift none · 3 independent mutation probes

## 🔮 fufu~ Jibril reviewed your code! You came back and made the gate *symmetric* — and then, oh? Oh! You noticed the belt-and-braces I didn't even ask for was actually **load-bearing**: the reducer's delete guard checks only `deletingDoujinId`, so a delete dispatched from anywhere but that button could still overlap an in-flight append, and you closed it at the state machine instead of the UI. Defense-in-depth on a destructive call, motivated *correctly*. I got genuinely giddy tracing the interleavings~ ♡ ### Verdict: ✅ Looks good to me~ Both round-1 blockers are closed, pinned, and independently verified. I ran my own probes this time — not yours, mine: - **Reducer guard** — dropped `deletingDoujinId != null` from `LoadMoreDuplicatesAction`'s guard in my clone → *red* on exactly `LoadMoreDuplicatesAction is ignored while a delete is in flight`. Restored → green. - **Append arm** — reverted to the unconditional `deletingDoujinId: null` → *red* on exactly `an append landing during a delete keeps the delete flags`. Restored → green. - **Page gate** — dropped the condition from Load-more's `onPressed` → *red* on exactly `Load more is disabled while a delete runs` (spinner up, `onPressed` a closure instead of null). Restored → green. Three probes, three independent pins, zero collateral red. That's the whole fourth wall of the gate, bricked and mortared~ #### ✅ What I liked~ - **The append-arm fix is the right shape, not just the easy one.** I traced every interleaving: an append only extends the list (nothing on screen goes away, so spinner and per-group error stay truthful), and only a fresh page 1 — the delete's own reload — clears the flags. The failure variant converges too: `DuplicateMemberDeleteFailedAction` rewrites all three fields atomically. And the one arm that still clears `deletingDoujinId` on error is the *documented release valve* for the delete's own failed reload, with its why-comment already in place from round 1~ - **The three new tests are directional, not decorative.** The reducer test asserts `identical(result, deleting)` — a true drop, not a copy. The append test checks the flags *and* that the append itself still landed (`[g1, g2]`, `isLoadingMore: false`) — it pins both halves of the conditional. The widget test is my round-1 reproduction turned into a regression pin, gated `Completer` and all, and it even asserts Load-more comes *back* after the reload lands. ♪ - **The rebase was honest.** Old head `b0e46b8` vs rewritten `763f4ba`: every `duplicates_*` file byte-identical, the only drift being main's four incoming commits plus the README/BACKLOG conflict resolution — and I read that resolution: main's assistant bullet with the one-card approval survives intact, your duplicates bullet keeps its per-member delete ending. Both bullets live. Nothing smuggled in~ - **Dialog unified.** `Delete doujin?` now matches `editor_pane.dart` exactly — and it's *asserted*, so it can't silently drift back. - **Local verification, independently reproduced on `1750187`:** `flutter analyze` 0 issues; duplicates suites 43/43 (+3 new); full suite **820/820** (matches your comment); `build_runner` regenerates with zero drift; clone left pristine at head. #### 💡 Little ideas (non-blocking)~ 1. **`duplicates_reducer.dart:79`** — the `DuplicatesErrorAction` arm releases `deletingDoujinId` unconditionally. Today that's exactly right (it's the delete's-own-reload-failed release, and the page gates make any other mid-delete fetch unreachable — I checked, the page is the *only* dispatch site for this action family). If a second dispatch site ever appears, this arm becomes the next back door — the in-code comment already carries the intent, so just keep it in mind if that day comes. The gate is symmetric, the wall is pinned, and the spinner cannot lie anymore. This merges *beautifully*, fufu~ ♡ --- *Automated review by Jibril · 2026-09-07* *CI/CD: stale for head SHA 1750187 (coverage bot 7195 covers the original push) · Local checks: analyze clean · 43/43 duplicates tests · 820/820 full suite · codegen drift none · 3 independent mutation probes*
bjoern merged commit 0d5b103c45 into main 2026-09-07 13:29:01 +02:00
bjoern deleted branch feat/duplicates-delete-member 2026-09-07 13:29:01 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 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!101
No description provided.