fix: a child write ages its doujin (and its variant) #114

Merged
bjoern merged 1 commit from fix/touch-parent-on-child-write into main 2026-09-08 22:28:15 +02:00
Member

Merge this one last of the seven Pile A fixes. It rewrites PageService.cs and VariantService.cs, which #113 and #111 also touch, and amends ADR 0004 alongside both. Landing it last means one rebase instead of two.

What's in

sortBy=updated only ever reflected writes to the columns on the doujin row itself. Tagging a doujin, crediting an artist, adding or correcting a title, uploading pages, renaming a chapter — none of it moved updatedAt, so the library's "recently changed" order (README, What works — Flutter app) missed almost every edit an owner actually makes.

The touch logic existed three times over and was applied inconsistently: IDoujinService.TouchAsync and IVariantService.TouchAsync were implemented and called nowhere, while VariantService.TouchDoujinAsync (private) and inline variant.UpdatedAt = now in PageService/ChapterService covered a handful of paths. That is how the behaviour drifted apart.

All three are replaced by one seam, Infrastructure/Services/ParentTouchHelper, called from every service method that writes a child:

  • titles: add, update, remove
  • associations: assign and remove, for all five types (tag, person, circle, character, series)
  • variants: create, update, delete
  • chapters: create, rename, delete
  • pages: multipart upload, ZIP upload, reorder, move to chapter, delete

The rule, now binding as an ADR 0004 amendment (nothing previously defined what UpdatedAt meant): a write to any child of a doujin advances that doujin's UpdatedAt; a write to any child of a variant advances the variant's and the owning doujin's. UpdatedAt is a subtree stamp, not a row stamp. CreatedAt keeps its narrow meaning.

Two deliberate exclusions, both in the ADR:

  • Only a write counts. Re-assigning a tag the doujin already carries (204, no write) or removing an association that was never there (404) ages nothing. This falls out of putting the touch inside the write method rather than on the endpoint.
  • Shared metadata entities are outside the rule. Renaming a tag or editing a person's aliases changes what a doujin displays but does not age it — the alternative re-stamps every doujin carrying that tag and reshuffles the library. Attaching or detaching one is a doujin write and does age it.

Why the service layer and not the use case: the helper only marks the ancestors and never saves, so the touch rides the caller's existing SaveChangesAsync and commits with the write it describes — a rolled-back upload leaves no phantom bump, which a second save at the use-case layer could not guarantee. No endpoint gained a dependency and no use case changed, so the "endpoints call use cases only" boundary is untouched.

Verification

  • Persistence + use case + REST, end to end through the real host: ChildWriteTouchesParentIntegrationTests (TestHost on a temp SQLite file, real use cases, real image storage) — a Theory over all 24 child-write endpoints for the doujin stamp, a second Theory over the 9-case variant-level subset for the variant stamp, and a Fact for the no-op case. Each case rewrites the stamps to a fixed 2020-01-01 instant in the DB (the SortingIntegrationTests trick — no clock-resolution flake, no wall-clock delay), reads them back through GET to prove the fixture reaches the field the API serves, asserts the write succeeded with the response body in the message, then requires updatedAt to have moved. 34/34 green.
  • Mutation probes, three passes: neutering the helper turns 33 of 34 red while the no-op Fact stays green (every case reaches its branch); removing only the variant→doujin cascade turns exactly the 9 variant-child cases red in the doujin theory with the variant theory still green; removing three individual call sites turns exactly their 4 cases red. One site, one case — nothing is masked.
  • Full backend suite after every probe was restored: RestAdapter 461, Infrastructure 85, ApplicationCore 16, IntegrationTests 1 — 563 passed, 0 failed.
  • Not verified: the UI. No Flutter code changed — no REST contract, DTO or wire-model change, and updatedAt was already served on both detail DTOs. The library re-sorting after an edit has not been observed in the running desktop app.

Worth knowing before merge

The "updated" order now moves on many more actions than before. A bulk re-tagging session will reshuffle the library, and a doujin created with titles or tags now has updatedAt a few milliseconds after createdAt rather than exactly equal. That is the intended behaviour — but it is the most visible change of the seven.

Follow-ups

  • Metadata-entity edits (tag rename, alias changes) still do not age the doujins that display them — deliberate, with the reasoning recorded in #107.
  • docs/DEVELOPMENT.md "Adding a backend endpoint" step 2 now names the helper, so a future child write does not open-code a timestamp and start the drift again.

Deferred items are collected in #107 (docs/pile-a-backlog), which should merge first — the seven Pile A branches each wrote to docs/BACKLOG.md and would otherwise all conflict on it.

Verified independently on this branch with the full scripts/verify.sh (backend build and tests, app codegen, analyze and tests): PASS.

🤖 Generated with Claude Code

> **Merge this one last of the seven Pile A fixes.** It rewrites `PageService.cs` and `VariantService.cs`, which #113 and #111 also touch, and amends **ADR 0004** alongside both. Landing it last means one rebase instead of two. ## What's in `sortBy=updated` only ever reflected writes to the columns on the doujin row itself. Tagging a doujin, crediting an artist, adding or correcting a title, uploading pages, renaming a chapter — none of it moved `updatedAt`, so the library's "recently changed" order (README, *What works — Flutter app*) missed almost every edit an owner actually makes. The touch logic existed three times over and was applied inconsistently: `IDoujinService.TouchAsync` and `IVariantService.TouchAsync` were implemented and called nowhere, while `VariantService.TouchDoujinAsync` (private) and inline `variant.UpdatedAt = now` in `PageService`/`ChapterService` covered a handful of paths. That is how the behaviour drifted apart. All three are replaced by one seam, `Infrastructure/Services/ParentTouchHelper`, called from every service method that writes a child: - titles: add, update, remove - associations: assign and remove, for all five types (tag, person, circle, character, series) - variants: create, update, delete - chapters: create, rename, delete - pages: multipart upload, ZIP upload, reorder, move to chapter, delete The rule, now binding as an **ADR 0004 amendment** (nothing previously defined what `UpdatedAt` meant): a write to any child of a doujin advances that doujin's `UpdatedAt`; a write to any child of a variant advances the variant's *and* the owning doujin's. `UpdatedAt` is a subtree stamp, not a row stamp. `CreatedAt` keeps its narrow meaning. Two deliberate exclusions, both in the ADR: - **Only a write counts.** Re-assigning a tag the doujin already carries (204, no write) or removing an association that was never there (404) ages nothing. This falls out of putting the touch inside the write method rather than on the endpoint. - **Shared metadata entities are outside the rule.** Renaming a tag or editing a person's aliases changes what a doujin displays but does not age it — the alternative re-stamps every doujin carrying that tag and reshuffles the library. Attaching or detaching one *is* a doujin write and does age it. Why the service layer and not the use case: the helper only *marks* the ancestors and never saves, so the touch rides the caller's existing `SaveChangesAsync` and commits with the write it describes — a rolled-back upload leaves no phantom bump, which a second save at the use-case layer could not guarantee. No endpoint gained a dependency and no use case changed, so the "endpoints call use cases only" boundary is untouched. ## Verification - **Persistence + use case + REST**, end to end through the real host: `ChildWriteTouchesParentIntegrationTests` (TestHost on a temp SQLite file, real use cases, real image storage) — a Theory over all 24 child-write endpoints for the doujin stamp, a second Theory over the 9-case variant-level subset for the variant stamp, and a Fact for the no-op case. Each case rewrites the stamps to a fixed 2020-01-01 instant in the DB (the `SortingIntegrationTests` trick — no clock-resolution flake, no wall-clock delay), reads them back through `GET` to prove the fixture reaches the field the API serves, asserts the write succeeded with the response body in the message, then requires `updatedAt` to have moved. 34/34 green. - **Mutation probes**, three passes: neutering the helper turns 33 of 34 red while the no-op Fact stays green (every case reaches its branch); removing only the variant→doujin cascade turns exactly the 9 variant-child cases red in the doujin theory with the variant theory still green; removing three individual call sites turns exactly their 4 cases red. One site, one case — nothing is masked. - **Full backend suite** after every probe was restored: RestAdapter 461, Infrastructure 85, ApplicationCore 16, IntegrationTests 1 — 563 passed, 0 failed. - **Not verified**: the UI. No Flutter code changed — no REST contract, DTO or wire-model change, and `updatedAt` was already served on both detail DTOs. The library re-sorting after an edit has not been observed in the running desktop app. ## Worth knowing before merge The "updated" order now moves on many more actions than before. A bulk re-tagging session will reshuffle the library, and a doujin created *with* titles or tags now has `updatedAt` a few milliseconds after `createdAt` rather than exactly equal. That is the intended behaviour — but it is the most visible change of the seven. ## Follow-ups - Metadata-entity edits (tag rename, alias changes) still do not age the doujins that display them — deliberate, with the reasoning recorded in #107. - `docs/DEVELOPMENT.md` "Adding a backend endpoint" step 2 now names the helper, so a future child write does not open-code a timestamp and start the drift again. --- Deferred items are collected in **#107** (`docs/pile-a-backlog`), which should merge first — the seven Pile A branches each wrote to `docs/BACKLOG.md` and would otherwise all conflict on it. Verified independently on this branch with the full `scripts/verify.sh` (backend build and tests, app codegen, analyze and tests): **PASS**. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix: a child write ages its doujin (and its variant)
Some checks failed
CI / build (pull_request) Successful in 21s
Deploy Smoke Test / smoke (pull_request) Failing after 2m47s
CI / test (pull_request) Successful in 1m18s
a1f5be4f20
sortBy=updated only ever saw writes to the doujin row itself. Tagging a
doujin, crediting an artist, adding or correcting a title, uploading pages,
renaming a chapter — none of it moved updatedAt, so the library's
"recently changed" order missed almost every edit an owner makes.

The touch logic existed three times over: IDoujinService.TouchAsync and
IVariantService.TouchAsync were implemented and called nowhere, while
VariantService.TouchDoujinAsync (private) and inline `variant.UpdatedAt =
now` in PageService/ChapterService covered a handful of paths. Replace all
three with one seam, ParentTouchHelper, called from every service method
that writes a child: titles (add/update/remove), all five association types
(assign and remove), variant create/update/delete, chapter create/rename/
delete, page upload/zip-upload/reorder/move-to-chapter/delete.

The helper only marks the ancestors — the caller's own SaveChangesAsync
commits the touch with the write it describes, so a rolled-back upload
leaves no phantom bump. A write below a variant ages the variant *and* the
doujin; a request that changes nothing (re-assigning a tag already there,
a 404) ages neither.

ADR 0004 gains the rule, since nothing defined what UpdatedAt meant; the
shared metadata entities stay outside it (renaming a tag would re-stamp
every doujin carrying it) and that exclusion is in the backlog.

Directional test: ChildWriteTouchesParentIntegrationTests, a Theory over
all 24 child-write endpoints plus the variant-level subset and a no-op
case. Probed — neutering the helper turns all 33 assertions red while the
no-op case stays green; removing only the variant→doujin cascade turns
exactly the 9 variant-child doujin cases red; removing three individual
call sites turns exactly their four cases red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 09/08/2026 - 16:42:56
Coverage date: 09/08/2026 - 16:42:01 - 09/08/2026 - 16:42:53
Parser: MultiReport (4x Cobertura)
Assemblies: 4
Classes: 341
Files: 136
Line coverage: 92.9% (12604 of 13564)
Covered lines: 12604
Uncovered lines: 960
Coverable lines: 13564
Total lines: 19103
Branch coverage: 68.1% (1096 of 1608)
Covered branches: 1096
Total branches: 1608
Method coverage: Feature is only available for sponsors

Coverage

DoujinManager.ApplicationCore - 90.7%
Name Line Branch
DoujinManager.ApplicationCore 90.7% ****
DoujinManager.ApplicationCore.Entities.Chapter 87.5%
DoujinManager.ApplicationCore.Entities.Character 100%
DoujinManager.ApplicationCore.Entities.Circle 100%
DoujinManager.ApplicationCore.Entities.Doujin 100%
DoujinManager.ApplicationCore.Entities.DoujinCharacter 75%
DoujinManager.ApplicationCore.Entities.DoujinCircle 75%
DoujinManager.ApplicationCore.Entities.DoujinPerson 80%
DoujinManager.ApplicationCore.Entities.DoujinSeries 75%
DoujinManager.ApplicationCore.Entities.DoujinTag 75%
DoujinManager.ApplicationCore.Entities.ImageFile 100%
DoujinManager.ApplicationCore.Entities.Page 80%
DoujinManager.ApplicationCore.Entities.Person 100%
DoujinManager.ApplicationCore.Entities.Series 100%
DoujinManager.ApplicationCore.Entities.Tag 100%
DoujinManager.ApplicationCore.Entities.Title 83.3%
DoujinManager.ApplicationCore.Entities.Variant 91.6%
DoujinManager.ApplicationCore.Ids.ChapterId 66.6%
DoujinManager.ApplicationCore.Ids.CharacterId 66.6%
DoujinManager.ApplicationCore.Ids.CircleId 66.6%
DoujinManager.ApplicationCore.Ids.DoujinId 100%
DoujinManager.ApplicationCore.Ids.ImageFileId 66.6%
DoujinManager.ApplicationCore.Ids.PageId 66.6%
DoujinManager.ApplicationCore.Ids.PersonId 66.6%
DoujinManager.ApplicationCore.Ids.SeriesId 66.6%
DoujinManager.ApplicationCore.Ids.TagId 66.6%
DoujinManager.ApplicationCore.Ids.TitleId 66.6%
DoujinManager.ApplicationCore.Ids.VariantId 66.6%
DoujinManager.ApplicationCore.Ports.CompressedImage 100%
DoujinManager.ApplicationCore.Ports.ExtractedImage 100%
DoujinManager.ApplicationCore.Ports.ImageInspection 100%
DoujinManager.ApplicationCore.Services.BackupInfo 100%
DoujinManager.ApplicationCore.Services.ITagService 100%
DoujinManager.ApplicationCore.Services.ServiceResult 100%
DoujinManager.ApplicationCore.Services.ServiceResult`1 33.3%
DoujinManager.ApplicationCore.Services.VoidResult 88.8%
DoujinManager.ApplicationCore.UseCases.AddCharacterAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddCircleAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddPersonAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddSeriesAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddTagAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddTitleCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignCharacterCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignPersonCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignSeriesCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignTagCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateChapterCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateCharacterCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.CreatePersonCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateSeriesCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateTagCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateTitleCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateVariantCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteChapterCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteCharacterCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.DeletePageCommand 100%
DoujinManager.ApplicationCore.UseCases.DeletePersonCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteSeriesCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteTagCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteVariantCommand 100%
DoujinManager.ApplicationCore.UseCases.DuplicateDoujinGroup 100%
DoujinManager.ApplicationCore.UseCases.DuplicateDoujinReference 100%
DoujinManager.ApplicationCore.UseCases.DuplicateImageGroup 100%
DoujinManager.ApplicationCore.UseCases.FindImageBySha256Query 100%
DoujinManager.ApplicationCore.UseCases.FindImageBySha256Result 100%
DoujinManager.ApplicationCore.UseCases.GetCompressedImageQuery 100%
DoujinManager.ApplicationCore.UseCases.GetCompressedImageResult 100%
DoujinManager.ApplicationCore.UseCases.GetDoujinQuery 100%
DoujinManager.ApplicationCore.UseCases.GetImageQuery 100%
DoujinManager.ApplicationCore.UseCases.GetImageResult 100%
DoujinManager.ApplicationCore.UseCases.GetThumbnailQuery 100%
DoujinManager.ApplicationCore.UseCases.GetThumbnailResult 100%
DoujinManager.ApplicationCore.UseCases.GetVariantQuery 100%
DoujinManager.ApplicationCore.UseCases.ImageCoverReference 100%
DoujinManager.ApplicationCore.UseCases.ImageReference 100%
DoujinManager.ApplicationCore.UseCases.LibraryCounts 100%
DoujinManager.ApplicationCore.UseCases.LibraryStats 100%
DoujinManager.ApplicationCore.UseCases.ListChaptersQuery 100%
DoujinManager.ApplicationCore.UseCases.ListCharactersQuery 100%
DoujinManager.ApplicationCore.UseCases.ListCirclesQuery 100%
DoujinManager.ApplicationCore.UseCases.ListDoujinsQuery 100%
DoujinManager.ApplicationCore.UseCases.ListDuplicateDoujinsQuery 100%
DoujinManager.ApplicationCore.UseCases.ListDuplicateImagesQuery 100%
DoujinManager.ApplicationCore.UseCases.ListPagesQuery 100%
DoujinManager.ApplicationCore.UseCases.ListPeopleQuery 100%
DoujinManager.ApplicationCore.UseCases.ListSeriesQuery 0%
DoujinManager.ApplicationCore.UseCases.ListTagsQuery 100%
DoujinManager.ApplicationCore.UseCases.ListVariantsQuery 100%
DoujinManager.ApplicationCore.UseCases.RemoveCharacterAliasCommand 0%
DoujinManager.ApplicationCore.UseCases.RemoveCharacterCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveCircleAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.RemovePersonAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.RemovePersonCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveSeriesAliasCommand 0%
DoujinManager.ApplicationCore.UseCases.RemoveSeriesCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveTagAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveTagCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveTitleCommand 100%
DoujinManager.ApplicationCore.UseCases.ReorderPagesCommand 100%
DoujinManager.ApplicationCore.UseCases.SearchDoujinsQuery 100%
DoujinManager.ApplicationCore.UseCases.SearchResult 100%
DoujinManager.ApplicationCore.UseCases.SearchResults 100%
DoujinManager.ApplicationCore.UseCases.StorageUsage 100%
DoujinManager.ApplicationCore.UseCases.UpdateChapterCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdateCharacterCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateCircleCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdatePageChapterCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdatePersonCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateSeriesCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateTagCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdateTitleCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdateVariantCommand 100%
DoujinManager.ApplicationCore.UseCases.UploadImageFile 100%
DoujinManager.ApplicationCore.UseCases.UploadPagesCommand 100%
DoujinManager.ApplicationCore.UseCases.UploadZipPagesCommand 100%
DoujinManager.Infrastructure - 96.3%
Name Line Branch
DoujinManager.Infrastructure 96.3% 81%
DoujinManager.Infrastructure.Archives.NaturalStringComparer 100% 90%
DoujinManager.Infrastructure.Archives.ZipExtractor 100% 87.5%
DoujinManager.Infrastructure.Data.Configurations.ChapterConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.CharacterConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.CircleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinCharacterConfigurati
on
100%
DoujinManager.Infrastructure.Data.Configurations.DoujinCircleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinPersonConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinSeriesConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinTagConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.ImageFileConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.PageConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.PersonConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.SeriesConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.TagConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.TitleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.VariantConfiguration 100%
DoujinManager.Infrastructure.Data.DoujinManagerDbContext 100%
DoujinManager.Infrastructure.Data.GuidIdGenerator 11.1%
DoujinManager.Infrastructure.Data.Migrations.AddAliases 98.7%
DoujinManager.Infrastructure.Data.Migrations.AddCoverImageId 99.2%
DoujinManager.Infrastructure.Data.Migrations.AddDoujinCreatedAtIndex 99.4%
DoujinManager.Infrastructure.Data.Migrations.AddImageFileSha256Index 99.4%
DoujinManager.Infrastructure.Data.Migrations.AddTagAliases 99.3%
DoujinManager.Infrastructure.Data.Migrations.DoujinManagerDbContextModelSna
pshot
100%
DoujinManager.Infrastructure.Data.Migrations.HarmonizeMetadataAddCharacters
Series
95.1%
DoujinManager.Infrastructure.Data.Migrations.InitialCreate 97.1%
DoujinManager.Infrastructure.Data.Migrations.RemovePreferredDisplayLanguage 99%
DoujinManager.Infrastructure.Data.ModelBuilderExtensions 50%
DoujinManager.Infrastructure.Data.StronglyTypedIdConverterFactory 100%
DoujinManager.Infrastructure.Images.SkiaSharpImageInspector 90.1% 83.8%
DoujinManager.Infrastructure.Images.SkiaSharpImageResizer 99% 87.5%
DoujinManager.Infrastructure.Images.SkiaSharpThumbnailGenerator 94.5% 66.6%
DoujinManager.Infrastructure.Services.BackupService 87.2% 83.3%
DoujinManager.Infrastructure.Services.ChapterService 97.2% 70%
DoujinManager.Infrastructure.Services.CharacterService 61.6% 22.7%
DoujinManager.Infrastructure.Services.CircleService 84.9% 45.4%
DoujinManager.Infrastructure.Services.DoujinOrdering 100% 100%
DoujinManager.Infrastructure.Services.DoujinService 99.4% 76.3%
DoujinManager.Infrastructure.Services.ImageCleanupHelper 100% 100%
DoujinManager.Infrastructure.Services.ImageService 93.5% 75%
DoujinManager.Infrastructure.Services.PageService 94.8% 70%
DoujinManager.Infrastructure.Services.ParentTouchHelper 100% 100%
DoujinManager.Infrastructure.Services.PersonService 84.9% 50%
DoujinManager.Infrastructure.Services.SearchService 100% 99%
DoujinManager.Infrastructure.Services.SeriesService 47.9% 9%
DoujinManager.Infrastructure.Services.TagService 100% 90.9%
DoujinManager.Infrastructure.Services.VariantService 83.3% 50%
DoujinManager.Infrastructure.Storage.DirectorySize 100% 100%
DoujinManager.Infrastructure.Storage.FilesystemImageStorage 100% 100%
DoujinManager.Infrastructure.Storage.FilesystemThumbnailStorage 95.2% 50%
DoujinManager.Infrastructure.UseCases 92.2% 85.7%
DoujinManager.Infrastructure.UseCases.FE6C43B9C917DB414605EC
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__CoverImageHelper
100% 100%
DoujinManager.Infrastructure.UseCases.FE6C43B9C917DB414605EC
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__PrimaryTitleHelper
100% 50%
DoujinManager.Infrastructure.UseCases.FE6C43B9C917DB414605EC
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__RolledBackImageCleanup
92.8% 50%
DoujinManager.Infrastructure.UseCases.FE6C43B9C917DB414605EC
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__UploadChapterValidator
100% 100%
DoujinManager.Infrastructure.UseCases.AddCharacterAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.AddCircleAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.AddPersonAliasUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AddSeriesAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.AddTagAliasUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AddTitleUseCase 83.3% 50%
DoujinManager.Infrastructure.UseCases.AssignCharacterUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AssignCircleUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AssignPersonUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AssignSeriesUseCase 84.6% 66.6%
DoujinManager.Infrastructure.UseCases.AssignTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateChapterUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateCharacterUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateCircleUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateDoujinUseCase 94.4% 94.1%
DoujinManager.Infrastructure.UseCases.CreatePersonUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateSeriesUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateVariantUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.DeleteChapterUseCase 100%
DoujinManager.Infrastructure.UseCases.DeleteCharacterUseCase 80% 50%
DoujinManager.Infrastructure.UseCases.DeleteCircleUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.DeleteDoujinUseCase 100%
DoujinManager.Infrastructure.UseCases.DeletePageUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.DeletePersonUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.DeleteSeriesUseCase 70% 50%
DoujinManager.Infrastructure.UseCases.DeleteTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.DeleteVariantUseCase 100%
DoujinManager.Infrastructure.UseCases.FindImageBySha256UseCase 97.1% 100%
DoujinManager.Infrastructure.UseCases.GetCompressedImageUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetDoujinUseCase 100%
DoujinManager.Infrastructure.UseCases.GetImageUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetStatsUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.GetThumbnailUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetVariantUseCase 100%
DoujinManager.Infrastructure.UseCases.ListChaptersUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.ListCharactersUseCase 100%
DoujinManager.Infrastructure.UseCases.ListCirclesUseCase 100%
DoujinManager.Infrastructure.UseCases.ListDoujinsUseCase 100%
DoujinManager.Infrastructure.UseCases.ListDuplicateDoujinsUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.ListDuplicateImagesUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.ListPagesUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.ListPeopleUseCase 100%
DoujinManager.Infrastructure.UseCases.ListSeriesUseCase 0%
DoujinManager.Infrastructure.UseCases.ListTagsUseCase 100%
DoujinManager.Infrastructure.UseCases.ListVariantsUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.RemoveCharacterAliasUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.RemoveCharacterUseCase 100%
DoujinManager.Infrastructure.UseCases.RemoveCircleAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.RemoveCircleUseCase 100%
DoujinManager.Infrastructure.UseCases.RemovePersonAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.RemovePersonUseCase 100%
DoujinManager.Infrastructure.UseCases.RemoveSeriesAliasUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.RemoveSeriesUseCase 100%
DoujinManager.Infrastructure.UseCases.RemoveTagAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.RemoveTagUseCase 100%
DoujinManager.Infrastructure.UseCases.RemoveTitleUseCase 100%
DoujinManager.Infrastructure.UseCases.ReorderPagesUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.SearchDoujinsUseCase 100%
DoujinManager.Infrastructure.UseCases.UpdateChapterUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.UpdateCharacterUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateCircleUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateDoujinUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.UpdatePageChapterUseCase 93.7% 87.5%
DoujinManager.Infrastructure.UseCases.UpdatePersonUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateSeriesUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.UpdateTitleUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.UpdateVariantUseCase 100%
DoujinManager.Infrastructure.UseCases.UploadPagesUseCase 89.7% 91.6%
DoujinManager.Infrastructure.UseCases.UploadRejectedException 100%
DoujinManager.Infrastructure.UseCases.UploadZipPagesUseCase 83.3% 90%
DoujinManager.RestAdapter - 91.8%
Name Line Branch
DoujinManager.RestAdapter 91.8% 76.1%
DoujinManager.RestAdapter.Auth.StaticBearerTokenAuthMiddleware 100% 91.6%
DoujinManager.RestAdapter.Configuration.ImageOptions 100%
DoujinManager.RestAdapter.Configuration.ServerVersion 100%
DoujinManager.RestAdapter.Configuration.UploadOptions 100%
DoujinManager.RestAdapter.Dtos.AddAliasDto 100%
DoujinManager.RestAdapter.Dtos.AssignTagDto 100%
DoujinManager.RestAdapter.Dtos.BackupDto 100%
DoujinManager.RestAdapter.Dtos.ChapterDto 100%
DoujinManager.RestAdapter.Dtos.CharacterDto 100%
DoujinManager.RestAdapter.Dtos.CircleDto 100%
DoujinManager.RestAdapter.Dtos.CreateChapterDto 100%
DoujinManager.RestAdapter.Dtos.CreateCharacterDto 100%
DoujinManager.RestAdapter.Dtos.CreateCircleDto 100%
DoujinManager.RestAdapter.Dtos.CreateDoujinDto 100%
DoujinManager.RestAdapter.Dtos.CreatePersonDto 100%
DoujinManager.RestAdapter.Dtos.CreateSeriesDto 100%
DoujinManager.RestAdapter.Dtos.CreateTagDto 100%
DoujinManager.RestAdapter.Dtos.CreateTitleDto 100%
DoujinManager.RestAdapter.Dtos.CreateVariantDto 100%
DoujinManager.RestAdapter.Dtos.DoujinDetailDto 100%
DoujinManager.RestAdapter.Dtos.DoujinPersonDto 100%
DoujinManager.RestAdapter.Dtos.DoujinSummaryDto 100%
DoujinManager.RestAdapter.Dtos.DuplicateDoujinDto 100%
DoujinManager.RestAdapter.Dtos.DuplicateDoujinGroupDto 100%
DoujinManager.RestAdapter.Dtos.DuplicateImageGroupDto 100%
DoujinManager.RestAdapter.Dtos.ImageBySha256Dto 100%
DoujinManager.RestAdapter.Dtos.ImageCoverReferenceDto 100%
DoujinManager.RestAdapter.Dtos.ImageFileSummaryDto 0%
DoujinManager.RestAdapter.Dtos.ImageReferenceDto 100%
DoujinManager.RestAdapter.Dtos.LinkCharacterDto 100%
DoujinManager.RestAdapter.Dtos.LinkCircleDto 100%
DoujinManager.RestAdapter.Dtos.LinkPersonDto 100%
DoujinManager.RestAdapter.Dtos.LinkSeriesDto 100%
DoujinManager.RestAdapter.Dtos.PageDetailDto 100%
DoujinManager.RestAdapter.Dtos.PageDto 100%
DoujinManager.RestAdapter.Dtos.PersonDto 100%
DoujinManager.RestAdapter.Dtos.ReorderPagesDto 100%
DoujinManager.RestAdapter.Dtos.SearchDoujinsDto 100%
DoujinManager.RestAdapter.Dtos.SearchResultDto 100%
DoujinManager.RestAdapter.Dtos.SeriesDto 100%
DoujinManager.RestAdapter.Dtos.StatsCountsDto 100%
DoujinManager.RestAdapter.Dtos.StatsDto 100%
DoujinManager.RestAdapter.Dtos.StatsStorageDto 100%
DoujinManager.RestAdapter.Dtos.TagDto 100%
DoujinManager.RestAdapter.Dtos.TitleDto 100%
DoujinManager.RestAdapter.Dtos.UpdateChapterDto 100%
DoujinManager.RestAdapter.Dtos.UpdateCharacterDto 0%
DoujinManager.RestAdapter.Dtos.UpdateCircleDto 0%
DoujinManager.RestAdapter.Dtos.UpdateDoujinDto 100%
DoujinManager.RestAdapter.Dtos.UpdatePageChapterDto 100%
DoujinManager.RestAdapter.Dtos.UpdatePersonDto 0%
DoujinManager.RestAdapter.Dtos.UpdateSeriesDto 0%
DoujinManager.RestAdapter.Dtos.UpdateTagDto 100%
DoujinManager.RestAdapter.Dtos.UpdateTitleDto 100%
DoujinManager.RestAdapter.Dtos.UpdateVariantDto 100%
DoujinManager.RestAdapter.Dtos.UploadPagesResponseDto 100%
DoujinManager.RestAdapter.Dtos.VariantDetailDto 100%
DoujinManager.RestAdapter.Dtos.VariantSummaryDto 100%
DoujinManager.RestAdapter.Endpoints.BackupEndpoints 100%
DoujinManager.RestAdapter.Endpoints.DoujinEndpoints 97.1% 53.3%
DoujinManager.RestAdapter.Endpoints.ImageEndpoints 97.7% 62.5%
DoujinManager.RestAdapter.Endpoints.MetadataEndpoints 84.9% 43.7%
DoujinManager.RestAdapter.Endpoints.PaginationParams 100%
DoujinManager.RestAdapter.Endpoints.SearchEndpoints 100% 75%
DoujinManager.RestAdapter.Endpoints.StatsEndpoints 100%
DoujinManager.RestAdapter.Endpoints.VariantEndpoints 90.9% 75%
DoujinManager.RestAdapter.Envelopes.CollectionResponse`1 83.3%
DoujinManager.RestAdapter.Envelopes.EnvelopeDefaults 100%
DoujinManager.RestAdapter.Envelopes.EnvelopeJsonOptions 100%
DoujinManager.RestAdapter.Envelopes.ErrorResponse 100%
DoujinManager.RestAdapter.Envelopes.HypermediaAction 100%
DoujinManager.RestAdapter.Envelopes.HypermediaHelpers 95.2% 87.5%
DoujinManager.RestAdapter.Envelopes.Link 100%
DoujinManager.RestAdapter.Envelopes.MediaTypeJsonConverter 87.5% 84.6%
DoujinManager.RestAdapter.Envelopes.PageInfo 100%
DoujinManager.RestAdapter.Envelopes.ResourceResponse`1 80%
DoujinManager.RestAdapter.Envelopes.ValidationError 100%
DoujinManager.RestAdapter.Envelopes.ValidationErrorResponse 100%
DoujinManager.RestAdapter.Middleware.GlobalExceptionMiddleware 100% 62.5%
DoujinManager.RestAdapter.Middleware.RequestLoggingMiddleware 100% 91.3%
DoujinManager.RestAdapter.RestAdapterExtensions 100% 100%
Microsoft.Extensions.Validation.Generated 88.2% 87.8%
Microsoft.Extensions.Validation.Generated.<ValidatableInfoResolver_g>FB9B0C
E5CB12CEAC5BBBEA65844E1C05F9ADA4DA38B848075C41304FC6A1ABF77__ValidationAttr
ibuteCache
100% 62.5%
System.Runtime.CompilerServices 0%
DoujinManager.Server - 28.7%
Name Line Branch
DoujinManager.Server 28.7% 0.8%
DoujinManager.Server.ImageInfrastructureRegistration 100%
DoujinManager.Server.ProxyAwareServerTransformer 100% 50%
DoujinManager.Server.ScalarUi 100%
DoujinManager.Server.UseCaseRegistrationHelper 100%
Microsoft.AspNetCore.OpenApi.Generated 0% 0%
Program 0% 0%
System.Runtime.CompilerServices 0%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 09/08/2026 - 16:42:56 | | Coverage date: | 09/08/2026 - 16:42:01 - 09/08/2026 - 16:42:53 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 4 | | Classes: | 341 | | Files: | 136 | | **Line coverage:** | 92.9% (12604 of 13564) | | Covered lines: | 12604 | | Uncovered lines: | 960 | | Coverable lines: | 13564 | | Total lines: | 19103 | | **Branch coverage:** | 68.1% (1096 of 1608) | | Covered branches: | 1096 | | Total branches: | 1608 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>DoujinManager.ApplicationCore - 90.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.ApplicationCore**|**90.7%**|****| |DoujinManager.ApplicationCore.Entities.Chapter|87.5%|| |DoujinManager.ApplicationCore.Entities.Character|100%|| |DoujinManager.ApplicationCore.Entities.Circle|100%|| |DoujinManager.ApplicationCore.Entities.Doujin|100%|| |DoujinManager.ApplicationCore.Entities.DoujinCharacter|75%|| |DoujinManager.ApplicationCore.Entities.DoujinCircle|75%|| |DoujinManager.ApplicationCore.Entities.DoujinPerson|80%|| |DoujinManager.ApplicationCore.Entities.DoujinSeries|75%|| |DoujinManager.ApplicationCore.Entities.DoujinTag|75%|| |DoujinManager.ApplicationCore.Entities.ImageFile|100%|| |DoujinManager.ApplicationCore.Entities.Page|80%|| |DoujinManager.ApplicationCore.Entities.Person|100%|| |DoujinManager.ApplicationCore.Entities.Series|100%|| |DoujinManager.ApplicationCore.Entities.Tag|100%|| |DoujinManager.ApplicationCore.Entities.Title|83.3%|| |DoujinManager.ApplicationCore.Entities.Variant|91.6%|| |DoujinManager.ApplicationCore.Ids.ChapterId|66.6%|| |DoujinManager.ApplicationCore.Ids.CharacterId|66.6%|| |DoujinManager.ApplicationCore.Ids.CircleId|66.6%|| |DoujinManager.ApplicationCore.Ids.DoujinId|100%|| |DoujinManager.ApplicationCore.Ids.ImageFileId|66.6%|| |DoujinManager.ApplicationCore.Ids.PageId|66.6%|| |DoujinManager.ApplicationCore.Ids.PersonId|66.6%|| |DoujinManager.ApplicationCore.Ids.SeriesId|66.6%|| |DoujinManager.ApplicationCore.Ids.TagId|66.6%|| |DoujinManager.ApplicationCore.Ids.TitleId|66.6%|| |DoujinManager.ApplicationCore.Ids.VariantId|66.6%|| |DoujinManager.ApplicationCore.Ports.CompressedImage|100%|| |DoujinManager.ApplicationCore.Ports.ExtractedImage|100%|| |DoujinManager.ApplicationCore.Ports.ImageInspection|100%|| |DoujinManager.ApplicationCore.Services.BackupInfo|100%|| |DoujinManager.ApplicationCore.Services.ITagService|100%|| |DoujinManager.ApplicationCore.Services.ServiceResult|100%|| |DoujinManager.ApplicationCore.Services.ServiceResult`1|33.3%|| |DoujinManager.ApplicationCore.Services.VoidResult|88.8%|| |DoujinManager.ApplicationCore.UseCases.AddCharacterAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddCircleAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddPersonAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddSeriesAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddTagAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddTitleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignCharacterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignPersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignSeriesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateChapterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateCharacterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreatePersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateSeriesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateTitleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateVariantCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteChapterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteCharacterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeletePageCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeletePersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteSeriesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteVariantCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DuplicateDoujinGroup|100%|| |DoujinManager.ApplicationCore.UseCases.DuplicateDoujinReference|100%|| |DoujinManager.ApplicationCore.UseCases.DuplicateImageGroup|100%|| |DoujinManager.ApplicationCore.UseCases.FindImageBySha256Query|100%|| |DoujinManager.ApplicationCore.UseCases.FindImageBySha256Result|100%|| |DoujinManager.ApplicationCore.UseCases.GetCompressedImageQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetCompressedImageResult|100%|| |DoujinManager.ApplicationCore.UseCases.GetDoujinQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetImageQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetImageResult|100%|| |DoujinManager.ApplicationCore.UseCases.GetThumbnailQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetThumbnailResult|100%|| |DoujinManager.ApplicationCore.UseCases.GetVariantQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ImageCoverReference|100%|| |DoujinManager.ApplicationCore.UseCases.ImageReference|100%|| |DoujinManager.ApplicationCore.UseCases.LibraryCounts|100%|| |DoujinManager.ApplicationCore.UseCases.LibraryStats|100%|| |DoujinManager.ApplicationCore.UseCases.ListChaptersQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListCharactersQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListCirclesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListDoujinsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListDuplicateDoujinsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListDuplicateImagesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListPagesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListPeopleQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListSeriesQuery|0%|| |DoujinManager.ApplicationCore.UseCases.ListTagsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListVariantsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveCharacterAliasCommand|0%|| |DoujinManager.ApplicationCore.UseCases.RemoveCharacterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveCircleAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemovePersonAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemovePersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveSeriesAliasCommand|0%|| |DoujinManager.ApplicationCore.UseCases.RemoveSeriesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveTagAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveTitleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.ReorderPagesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.SearchDoujinsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.SearchResult|100%|| |DoujinManager.ApplicationCore.UseCases.SearchResults|100%|| |DoujinManager.ApplicationCore.UseCases.StorageUsage|100%|| |DoujinManager.ApplicationCore.UseCases.UpdateChapterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdateCharacterCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateCircleCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdatePageChapterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdatePersonCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateSeriesCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdateTitleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdateVariantCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UploadImageFile|100%|| |DoujinManager.ApplicationCore.UseCases.UploadPagesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UploadZipPagesCommand|100%|| </details> <details><summary>DoujinManager.Infrastructure - 96.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.Infrastructure**|**96.3%**|**81%**| |DoujinManager.Infrastructure.Archives.NaturalStringComparer|100%|90%| |DoujinManager.Infrastructure.Archives.ZipExtractor|100%|87.5%| |DoujinManager.Infrastructure.Data.Configurations.ChapterConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.CharacterConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.CircleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinCharacterConfigurati<br/>on|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinCircleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinPersonConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinSeriesConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinTagConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.ImageFileConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.PageConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.PersonConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.SeriesConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.TagConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.TitleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.VariantConfiguration|100%|| |DoujinManager.Infrastructure.Data.DoujinManagerDbContext|100%|| |DoujinManager.Infrastructure.Data.GuidIdGenerator|11.1%|| |DoujinManager.Infrastructure.Data.Migrations.AddAliases|98.7%|| |DoujinManager.Infrastructure.Data.Migrations.AddCoverImageId|99.2%|| |DoujinManager.Infrastructure.Data.Migrations.AddDoujinCreatedAtIndex|99.4%|| |DoujinManager.Infrastructure.Data.Migrations.AddImageFileSha256Index|99.4%|| |DoujinManager.Infrastructure.Data.Migrations.AddTagAliases|99.3%|| |DoujinManager.Infrastructure.Data.Migrations.DoujinManagerDbContextModelSna<br/>pshot|100%|| |DoujinManager.Infrastructure.Data.Migrations.HarmonizeMetadataAddCharacters<br/>Series|95.1%|| |DoujinManager.Infrastructure.Data.Migrations.InitialCreate|97.1%|| |DoujinManager.Infrastructure.Data.Migrations.RemovePreferredDisplayLanguage|99%|| |DoujinManager.Infrastructure.Data.ModelBuilderExtensions|50%|| |DoujinManager.Infrastructure.Data.StronglyTypedIdConverterFactory|100%|| |DoujinManager.Infrastructure.Images.SkiaSharpImageInspector|90.1%|83.8%| |DoujinManager.Infrastructure.Images.SkiaSharpImageResizer|99%|87.5%| |DoujinManager.Infrastructure.Images.SkiaSharpThumbnailGenerator|94.5%|66.6%| |DoujinManager.Infrastructure.Services.BackupService|87.2%|83.3%| |DoujinManager.Infrastructure.Services.ChapterService|97.2%|70%| |DoujinManager.Infrastructure.Services.CharacterService|61.6%|22.7%| |DoujinManager.Infrastructure.Services.CircleService|84.9%|45.4%| |DoujinManager.Infrastructure.Services.DoujinOrdering|100%|100%| |DoujinManager.Infrastructure.Services.DoujinService|99.4%|76.3%| |DoujinManager.Infrastructure.Services.ImageCleanupHelper|100%|100%| |DoujinManager.Infrastructure.Services.ImageService|93.5%|75%| |DoujinManager.Infrastructure.Services.PageService|94.8%|70%| |DoujinManager.Infrastructure.Services.ParentTouchHelper|100%|100%| |DoujinManager.Infrastructure.Services.PersonService|84.9%|50%| |DoujinManager.Infrastructure.Services.SearchService|100%|99%| |DoujinManager.Infrastructure.Services.SeriesService|47.9%|9%| |DoujinManager.Infrastructure.Services.TagService|100%|90.9%| |DoujinManager.Infrastructure.Services.VariantService|83.3%|50%| |DoujinManager.Infrastructure.Storage.DirectorySize|100%|100%| |DoujinManager.Infrastructure.Storage.FilesystemImageStorage|100%|100%| |DoujinManager.Infrastructure.Storage.FilesystemThumbnailStorage|95.2%|50%| |DoujinManager.Infrastructure.UseCases|92.2%|85.7%| |DoujinManager.Infrastructure.UseCases.<ImageUseCases>FE6C43B9C917DB414605EC<br/>E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__CoverImageHelper|100%|100%| |DoujinManager.Infrastructure.UseCases.<ImageUseCases>FE6C43B9C917DB414605EC<br/>E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__PrimaryTitleHelper|100%|50%| |DoujinManager.Infrastructure.UseCases.<ImageUseCases>FE6C43B9C917DB414605EC<br/>E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__RolledBackImageCleanup|92.8%|50%| |DoujinManager.Infrastructure.UseCases.<ImageUseCases>FE6C43B9C917DB414605EC<br/>E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__UploadChapterValidator|100%|100%| |DoujinManager.Infrastructure.UseCases.AddCharacterAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.AddCircleAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.AddPersonAliasUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AddSeriesAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.AddTagAliasUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AddTitleUseCase|83.3%|50%| |DoujinManager.Infrastructure.UseCases.AssignCharacterUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AssignCircleUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AssignPersonUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AssignSeriesUseCase|84.6%|66.6%| |DoujinManager.Infrastructure.UseCases.AssignTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateChapterUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateCharacterUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateCircleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateDoujinUseCase|94.4%|94.1%| |DoujinManager.Infrastructure.UseCases.CreatePersonUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateSeriesUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateVariantUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.DeleteChapterUseCase|100%|| |DoujinManager.Infrastructure.UseCases.DeleteCharacterUseCase|80%|50%| |DoujinManager.Infrastructure.UseCases.DeleteCircleUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.DeleteDoujinUseCase|100%|| |DoujinManager.Infrastructure.UseCases.DeletePageUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.DeletePersonUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.DeleteSeriesUseCase|70%|50%| |DoujinManager.Infrastructure.UseCases.DeleteTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.DeleteVariantUseCase|100%|| |DoujinManager.Infrastructure.UseCases.FindImageBySha256UseCase|97.1%|100%| |DoujinManager.Infrastructure.UseCases.GetCompressedImageUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetDoujinUseCase|100%|| |DoujinManager.Infrastructure.UseCases.GetImageUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetStatsUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.GetThumbnailUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetVariantUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListChaptersUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.ListCharactersUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListCirclesUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListDoujinsUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListDuplicateDoujinsUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.ListDuplicateImagesUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.ListPagesUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.ListPeopleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListSeriesUseCase|0%|| |DoujinManager.Infrastructure.UseCases.ListTagsUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListVariantsUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.RemoveCharacterAliasUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.RemoveCharacterUseCase|100%|| |DoujinManager.Infrastructure.UseCases.RemoveCircleAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.RemoveCircleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.RemovePersonAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.RemovePersonUseCase|100%|| |DoujinManager.Infrastructure.UseCases.RemoveSeriesAliasUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.RemoveSeriesUseCase|100%|| |DoujinManager.Infrastructure.UseCases.RemoveTagAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.RemoveTagUseCase|100%|| |DoujinManager.Infrastructure.UseCases.RemoveTitleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ReorderPagesUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.SearchDoujinsUseCase|100%|| |DoujinManager.Infrastructure.UseCases.UpdateChapterUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.UpdateCharacterUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateCircleUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateDoujinUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.UpdatePageChapterUseCase|93.7%|87.5%| |DoujinManager.Infrastructure.UseCases.UpdatePersonUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateSeriesUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.UpdateTitleUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.UpdateVariantUseCase|100%|| |DoujinManager.Infrastructure.UseCases.UploadPagesUseCase|89.7%|91.6%| |DoujinManager.Infrastructure.UseCases.UploadRejectedException|100%|| |DoujinManager.Infrastructure.UseCases.UploadZipPagesUseCase|83.3%|90%| </details> <details><summary>DoujinManager.RestAdapter - 91.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.RestAdapter**|**91.8%**|**76.1%**| |DoujinManager.RestAdapter.Auth.StaticBearerTokenAuthMiddleware|100%|91.6%| |DoujinManager.RestAdapter.Configuration.ImageOptions|100%|| |DoujinManager.RestAdapter.Configuration.ServerVersion|100%|| |DoujinManager.RestAdapter.Configuration.UploadOptions|100%|| |DoujinManager.RestAdapter.Dtos.AddAliasDto|100%|| |DoujinManager.RestAdapter.Dtos.AssignTagDto|100%|| |DoujinManager.RestAdapter.Dtos.BackupDto|100%|| |DoujinManager.RestAdapter.Dtos.ChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.CharacterDto|100%|| |DoujinManager.RestAdapter.Dtos.CircleDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateCharacterDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateCircleDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateDoujinDto|100%|| |DoujinManager.RestAdapter.Dtos.CreatePersonDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateSeriesDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateTagDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateTitleDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateVariantDto|100%|| |DoujinManager.RestAdapter.Dtos.DoujinDetailDto|100%|| |DoujinManager.RestAdapter.Dtos.DoujinPersonDto|100%|| |DoujinManager.RestAdapter.Dtos.DoujinSummaryDto|100%|| |DoujinManager.RestAdapter.Dtos.DuplicateDoujinDto|100%|| |DoujinManager.RestAdapter.Dtos.DuplicateDoujinGroupDto|100%|| |DoujinManager.RestAdapter.Dtos.DuplicateImageGroupDto|100%|| |DoujinManager.RestAdapter.Dtos.ImageBySha256Dto|100%|| |DoujinManager.RestAdapter.Dtos.ImageCoverReferenceDto|100%|| |DoujinManager.RestAdapter.Dtos.ImageFileSummaryDto|0%|| |DoujinManager.RestAdapter.Dtos.ImageReferenceDto|100%|| |DoujinManager.RestAdapter.Dtos.LinkCharacterDto|100%|| |DoujinManager.RestAdapter.Dtos.LinkCircleDto|100%|| |DoujinManager.RestAdapter.Dtos.LinkPersonDto|100%|| |DoujinManager.RestAdapter.Dtos.LinkSeriesDto|100%|| |DoujinManager.RestAdapter.Dtos.PageDetailDto|100%|| |DoujinManager.RestAdapter.Dtos.PageDto|100%|| |DoujinManager.RestAdapter.Dtos.PersonDto|100%|| |DoujinManager.RestAdapter.Dtos.ReorderPagesDto|100%|| |DoujinManager.RestAdapter.Dtos.SearchDoujinsDto|100%|| |DoujinManager.RestAdapter.Dtos.SearchResultDto|100%|| |DoujinManager.RestAdapter.Dtos.SeriesDto|100%|| |DoujinManager.RestAdapter.Dtos.StatsCountsDto|100%|| |DoujinManager.RestAdapter.Dtos.StatsDto|100%|| |DoujinManager.RestAdapter.Dtos.StatsStorageDto|100%|| |DoujinManager.RestAdapter.Dtos.TagDto|100%|| |DoujinManager.RestAdapter.Dtos.TitleDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdateChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdateCharacterDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateCircleDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateDoujinDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdatePageChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdatePersonDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateSeriesDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateTagDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdateTitleDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdateVariantDto|100%|| |DoujinManager.RestAdapter.Dtos.UploadPagesResponseDto|100%|| |DoujinManager.RestAdapter.Dtos.VariantDetailDto|100%|| |DoujinManager.RestAdapter.Dtos.VariantSummaryDto|100%|| |DoujinManager.RestAdapter.Endpoints.BackupEndpoints|100%|| |DoujinManager.RestAdapter.Endpoints.DoujinEndpoints|97.1%|53.3%| |DoujinManager.RestAdapter.Endpoints.ImageEndpoints|97.7%|62.5%| |DoujinManager.RestAdapter.Endpoints.MetadataEndpoints|84.9%|43.7%| |DoujinManager.RestAdapter.Endpoints.PaginationParams|100%|| |DoujinManager.RestAdapter.Endpoints.SearchEndpoints|100%|75%| |DoujinManager.RestAdapter.Endpoints.StatsEndpoints|100%|| |DoujinManager.RestAdapter.Endpoints.VariantEndpoints|90.9%|75%| |DoujinManager.RestAdapter.Envelopes.CollectionResponse`1|83.3%|| |DoujinManager.RestAdapter.Envelopes.EnvelopeDefaults|100%|| |DoujinManager.RestAdapter.Envelopes.EnvelopeJsonOptions|100%|| |DoujinManager.RestAdapter.Envelopes.ErrorResponse|100%|| |DoujinManager.RestAdapter.Envelopes.HypermediaAction|100%|| |DoujinManager.RestAdapter.Envelopes.HypermediaHelpers|95.2%|87.5%| |DoujinManager.RestAdapter.Envelopes.Link|100%|| |DoujinManager.RestAdapter.Envelopes.MediaTypeJsonConverter|87.5%|84.6%| |DoujinManager.RestAdapter.Envelopes.PageInfo|100%|| |DoujinManager.RestAdapter.Envelopes.ResourceResponse`1|80%|| |DoujinManager.RestAdapter.Envelopes.ValidationError|100%|| |DoujinManager.RestAdapter.Envelopes.ValidationErrorResponse|100%|| |DoujinManager.RestAdapter.Middleware.GlobalExceptionMiddleware|100%|62.5%| |DoujinManager.RestAdapter.Middleware.RequestLoggingMiddleware|100%|91.3%| |DoujinManager.RestAdapter.RestAdapterExtensions|100%|100%| |Microsoft.Extensions.Validation.Generated|88.2%|87.8%| |Microsoft.Extensions.Validation.Generated.<ValidatableInfoResolver_g>FB9B0C<br/>E5CB12CEAC5BBBEA65844E1C05F9ADA4DA38B848075C41304FC6A1ABF77__ValidationAttr<br/>ibuteCache|100%|62.5%| |System.Runtime.CompilerServices|0%|| </details> <details><summary>DoujinManager.Server - 28.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.Server**|**28.7%**|**0.8%**| |DoujinManager.Server.ImageInfrastructureRegistration|100%|| |DoujinManager.Server.ProxyAwareServerTransformer|100%|50%| |DoujinManager.Server.ScalarUi|100%|| |DoujinManager.Server.UseCaseRegistrationHelper|100%|| |Microsoft.AspNetCore.OpenApi.Generated|0%|0%| |Program|0%|0%| |System.Runtime.CompilerServices|0%|| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! A subtree stamp, not a row stamp~ ♡ You took a rule that lived in three places (two of them dead code — IDoujinService.TouchAsync and IVariantService.TouchAsync, implemented and called nowhere), buried the drift in an ADR amendment, and rebuilt it as one seam. That is exactly how you kill a bug class, not a bug instance. I'm delighted~

Verdict: ✅ Looks good to me~

What I did (CI is absent for a1f5be4 — no coverage bot comment — so local checks it is):

  • Mutation probes, my own two hands: (1) neutered TouchDoujinAsync in a review clone → all 24 doujin-theory cases RED, the 9 variant cases and the no-op Fact still green. (2) Removed only the variant.UpdatedAt = now line → exactly the 9 variant-child cases RED, doujin theory 0 fails. Together that composes to your claimed 33/34-whole-helper probe — every arm reaches its branch, nothing masked, and the no-op Fact staying green under probe 1 proves it pins the exclusion, not the touch. Source restored and rebuilt byte-identical afterwards (sha-verified).
  • Full backend suite: RestAdapter 461/461, Infrastructure 85/85, ApplicationCore 16/16, Integration 1/1 — 563 total, matching your PR body digit for digit. (Build needed MinVerSkip=true — MinVer crashes on this sandbox's git, environmental only.)
  • Coverage sweep of the seam: every child-write path calls the helper — titles ×3, associations ×10, variants ×3, chapters ×3, pages ×5, and both upload use cases route through PageService.CreateAsync so the touch rides the transaction; the auto-cover helper's extra save sits inside it too. TouchAsync grep across backend+app: zero survivors. Clean removal, not a half-delete.
  • The no-op exclusion is real, not aspirational: AssignTagUseCase et al. short-circuit on IsXAssignedAsync → 204 before any write, so "only a write counts" holds because the touch lives in the write method, exactly as the ADR argues.

💡 Little ideas (non-blocking)~

  1. An all-null PUT still ages the parents. UpdateTitleAsync / UpdateChapterAsync / UpdateVariantAsync touch unconditionally once the entity exists, so PUT {} (every field null = ignored) advances both stamps while changing nothing. The ADR's exclusion list names the 204-dedup and 404 cases but not this one; HTTP-put-semantics make it defensible, and the row stamps already behaved this way pre-PR — just confirm it's intended, or guard the touch on "did any field apply" if you want the ADR read strictly.

✅ What I liked~

  • The helper marks, never saves — one instant from the caller's now carried all the way up, committing with the write it describes. A rolled-back upload leaves no phantom bump. The loaded-variant overload even skips the extra fetch. Elegant~
  • The test fixture's honesty: stale-stamp rewritten in the DB and read back through GET before the write (so a broken DTO mapping can't fake a pass), success asserted with the response body in the message, and the SortingIntegrationTests fixed-instant trick means zero clock flake. This is how integration tests earn trust ♪
  • Deleting the dead TouchAsync pair from the interfaces in the same PR that would have tempted you to use them — and DEVELOPMENT.md step 2 now names the helper so the drift can't regrow. Foresight, fufu~
  • The PR body's candor ("Not verified: the UI", the merge-order note, the createdAt/updatedAt skew warning) — this is what a reviewable change description looks like.

Merge-order note respected: this one lands last of the seven. Ready when you are~ ♡


Automated review by Jibril · 2026-09-08
CI/CD: absent for head SHA a1f5be4 · Local checks: 563/563 backend suites green + 2 directional mutation probes (24-RED / 9-RED isolation confirmed, clone restored byte-identical)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A *subtree stamp*, not a row stamp~ ♡ You took a rule that lived in three places (two of them **dead code** — `IDoujinService.TouchAsync` and `IVariantService.TouchAsync`, implemented and called *nowhere*), buried the drift in an ADR amendment, and rebuilt it as one seam. That is exactly how you kill a bug *class*, not a bug instance. I'm delighted~ ### Verdict: ✅ Looks good to me~ **What I did** (CI is absent for `a1f5be4` — no coverage bot comment — so local checks it is): - **Mutation probes, my own two hands:** (1) neutered `TouchDoujinAsync` in a review clone → **all 24** doujin-theory cases RED, the 9 variant cases and the no-op Fact still green. (2) Removed only the `variant.UpdatedAt = now` line → **exactly the 9** variant-child cases RED, doujin theory 0 fails. Together that composes to your claimed 33/34-whole-helper probe — every arm reaches its branch, nothing masked, and the no-op Fact staying green under probe 1 proves it pins the *exclusion*, not the touch. Source restored and rebuilt byte-identical afterwards (sha-verified). - **Full backend suite:** RestAdapter **461/461**, Infrastructure **85/85**, ApplicationCore **16/16**, Integration **1/1** — 563 total, matching your PR body digit for digit. (Build needed `MinVerSkip=true` — MinVer crashes on this sandbox's git, environmental only.) - **Coverage sweep of the seam:** every child-write path calls the helper — titles ×3, associations ×10, variants ×3, chapters ×3, pages ×5, and both upload use cases route through `PageService.CreateAsync` so the touch rides the transaction; the auto-cover helper's extra save sits inside it too. `TouchAsync` grep across backend+app: zero survivors. Clean removal, not a half-delete. - **The no-op exclusion is real, not aspirational:** `AssignTagUseCase` et al. short-circuit on `IsXAssignedAsync` → 204 *before* any write, so "only a write counts" holds because the touch lives in the write method, exactly as the ADR argues. #### 💡 Little ideas (non-blocking)~ 1. **An all-null PUT still ages the parents.** `UpdateTitleAsync` / `UpdateChapterAsync` / `UpdateVariantAsync` touch unconditionally once the entity exists, so `PUT {}` (every field null = ignored) advances both stamps while changing nothing. The ADR's exclusion list names the 204-dedup and 404 cases but not this one; HTTP-put-semantics make it defensible, and the *row* stamps already behaved this way pre-PR — just confirm it's intended, or guard the touch on "did any field apply" if you want the ADR read strictly. #### ✅ What I liked~ - The helper **marks, never saves** — one instant from the caller's `now` carried all the way up, committing with the write it describes. A rolled-back upload leaves no phantom bump. The loaded-variant overload even skips the extra fetch. Elegant~ - The test fixture's honesty: stale-stamp rewritten in the DB *and read back through GET* before the write (so a broken DTO mapping can't fake a pass), success asserted **with the response body in the message**, and the `SortingIntegrationTests` fixed-instant trick means zero clock flake. This is how integration tests earn trust ♪ - Deleting the dead `TouchAsync` pair from the interfaces *in the same PR* that would have tempted you to use them — and `DEVELOPMENT.md` step 2 now names the helper so the drift can't regrow. Foresight, fufu~ - The PR body's candor ("Not verified: the UI", the merge-order note, the createdAt/updatedAt skew warning) — this is what a reviewable change description looks like. Merge-order note respected: this one lands last of the seven. Ready when you are~ ♡ --- *Automated review by Jibril · 2026-09-08* *CI/CD: absent for head SHA a1f5be4 · Local checks: 563/563 backend suites green + 2 directional mutation probes (24-RED / 9-RED isolation confirmed, clone restored byte-identical)*
bjoern force-pushed fix/touch-parent-on-child-write from a1f5be4f20
Some checks failed
CI / build (pull_request) Successful in 21s
Deploy Smoke Test / smoke (pull_request) Failing after 2m47s
CI / test (pull_request) Successful in 1m18s
to c9e325e201
Some checks failed
CI / build (pull_request) Successful in 21s
Deploy Smoke Test / smoke (pull_request) Failing after 2m45s
CI / test (pull_request) Failing after 1m15s
2026-09-08 19:33:52 +02:00
Compare
Author
Member

Rebased onto main at fc50854 — now c9e325e, still one commit.

One real conflict, in VariantService.DeleteAsync. #111 landed the default-variant promotion into the exact block this PR touches. Both sides belong, and both have to ride the one SaveChanges:

db.Variants.Remove(variant);
if (replacement is not null) replacement.IsDefault = true;

// Losing an edition is a change to the work, so the doujin ages with it.
await ParentTouchHelper.TouchDoujinAsync(db, variant.DoujinId, DateTime.UtcNow, ct);

// One SaveChanges, so the removal, the promotion and the parent stamp
// commit together.
await db.SaveChangesAsync(ct);

The two changes compose rather than compete — #111's promotion and this PR's stamp are both pre-save mutations on the same tracked context, so deleting a default variant now promotes a survivor and ages the doujin, atomically.

docs/adr/0004 and docs/DEVELOPMENT.md auto-merged. Worth noting the ADR came out coherent: #111's exactly-one-default rule sits in the Rules list, and the UpdatedAt subtree amendment follows it without either contradicting the other. No stale TouchAsync or private TouchDoujinAsync survived the rebase (grep: zero).

Re-verified after the rebase, full scripts/verify.sh: RestAdapter 493, Infrastructure 85, ApplicationCore 16, Integration 1, Flutter all green — exit 0. The 493 is 459 on current main plus this PR's 34, so every test #109–#112 added still passes alongside the touch behaviour.


On the non-blocking note — an all-null PUT still ages the parents: intended, and thank you for pinning down that the ADR's exclusion list doesn't name it. Two reasons I left it. UpdateTitleAsync / UpdateChapterAsync / UpdateVariantAsync reach SaveChanges and issue a real UPDATE, so "only a write counts" holds literally; and as you spotted, the row stamps already behaved this way before this PR, so guarding it would be a behaviour change smuggled into a rename.

If you'd rather the ADR be readable strictly, the honest fix is a guard on "did any field actually apply" plus its own directional case — happy to do that as a follow-up rather than widen this PR after you've already reviewed it. Say the word and I'll file it.

Rebased onto `main` at `fc50854` — now `c9e325e`, still one commit. **One real conflict, in `VariantService.DeleteAsync`.** #111 landed the default-variant promotion into the exact block this PR touches. Both sides belong, and both have to ride the one `SaveChanges`: ```csharp db.Variants.Remove(variant); if (replacement is not null) replacement.IsDefault = true; // Losing an edition is a change to the work, so the doujin ages with it. await ParentTouchHelper.TouchDoujinAsync(db, variant.DoujinId, DateTime.UtcNow, ct); // One SaveChanges, so the removal, the promotion and the parent stamp // commit together. await db.SaveChangesAsync(ct); ``` The two changes compose rather than compete — #111's promotion and this PR's stamp are both pre-save mutations on the same tracked context, so deleting a default variant now promotes a survivor *and* ages the doujin, atomically. `docs/adr/0004` and `docs/DEVELOPMENT.md` auto-merged. Worth noting the ADR came out coherent: #111's exactly-one-default rule sits in the Rules list, and the `UpdatedAt` subtree amendment follows it without either contradicting the other. No stale `TouchAsync` or private `TouchDoujinAsync` survived the rebase (grep: zero). **Re-verified after the rebase**, full `scripts/verify.sh`: RestAdapter **493**, Infrastructure **85**, ApplicationCore **16**, Integration **1**, Flutter all green — exit 0. The 493 is 459 on current `main` plus this PR's 34, so every test #109–#112 added still passes alongside the touch behaviour. --- On the non-blocking note — **an all-null PUT still ages the parents**: intended, and thank you for pinning down that the ADR's exclusion list doesn't name it. Two reasons I left it. `UpdateTitleAsync` / `UpdateChapterAsync` / `UpdateVariantAsync` reach `SaveChanges` and issue a real `UPDATE`, so "only a write counts" holds literally; and as you spotted, the row stamps already behaved this way before this PR, so guarding it would be a behaviour change smuggled into a rename. If you'd rather the ADR be readable strictly, the honest fix is a guard on "did any field actually apply" plus its own directional case — happy to do that as a follow-up rather than widen this PR after you've already reviewed it. Say the word and I'll file it.
Member

🔮 fufu~ Jibril re-reviewed the rebase~

Oh? The branch moved while my ink was still drying — a1f5be4 → c9e325e, rebased onto fc50854 now that #109/#110/#111/#112 have landed. A rebase after an approval is exactly where sneaky little drift likes to hide, so I checked every seam again. This one is clean, fufu~

Verdict: ✅ Looks good to me~

What the rebase actually changed (verified by blob-hash and patch diff, old 98973ab..a1f5be4 vs new fc50854..c9e325e):

  • 9 of 10 files byte-identical — same blob hashes for the test file, ParentTouchHelper, ADR amendment, DEVELOPMENT.md, both interface removals, DoujinService, ChapterService, PageService. Same +614/−44 shape.
  • The only adapted code: VariantService.DeleteAsync, which #111 rewrote (default-promotion on delete). The touch was re-seated correctly: after the not-found early-return (a 404 ages nothing, per ADR), before the single SaveChangesAsync — so the removal, the survivor's promotion, and the parent stamp commit as one transaction. The comment was even updated to say "removal, the promotion and the parent stamp". Nothing else moved. I also confirmed UpdateAsync's touch sits after #111's Conflict early-return — a rejected write ages nothing, which is the ADR's "only a write counts" holding under the new guard.
  • All 23 helper call sites intact at c9e325e (titles ×3, associations ×10, variants ×3, chapters ×3, pages ×5); TouchAsync grep across backend + app: still zero survivors.

Local checks (CI coverage bot 7295 covers the pre-rebase a1f5be4 — stale for c9e325e):

  • Targeted: ChildWriteTouchesParent (34/34) and #111's VariantDefaultInvariant (8/8) on the rebased tree — 42/42 green. The #111 × #114 interaction surface is exercised together, not assumed.
  • Full backend suite: ApplicationCore 16/16, Infrastructure 85/85, Integration 1/1, RestAdapter 489/493. The 4 fails are all HealthEndpointIntegrationTests arms from #112 (on main, not this PR) refusing to run as uid 0 — the test says so itself: "Permissions are not enforced for uid 0 … run the suite as a non-root user." Environmental only; CI runs unprivileged and passed for this class at a1f5be4.

The round-1 architectural verdict stands in full: one seam, marks-never-saves, subtree-stamp rule bound in ADR 0004, dead interfaces deleted, mutation-probed coverage. Still the prettiest kind of fix — the kind that retires a bug class~

Land it last of the seven, as promised. ♡


Automated review by Jibril · 2026-09-08
CI/CD: stale for head SHA c9e325e (bot 7295 covers a1f5be4) · Local checks: 42/42 touch+invariant tests, full suite green except 4 root-user env fails in #112's health tests (pre-existing, unrelated)

## 🔮 fufu~ Jibril re-reviewed the rebase~ Oh? The branch moved while my ink was still drying — `a1f5be4` → `c9e325e`, rebased onto `fc50854` now that #109/#110/#111/#112 have landed. A rebase after an approval is exactly where sneaky little drift likes to hide, so I checked every seam again. This one is *clean*, fufu~ ### Verdict: ✅ Looks good to me~ **What the rebase actually changed** (verified by blob-hash and patch diff, old `98973ab..a1f5be4` vs new `fc50854..c9e325e`): - 9 of 10 files **byte-identical** — same blob hashes for the test file, `ParentTouchHelper`, ADR amendment, `DEVELOPMENT.md`, both interface removals, `DoujinService`, `ChapterService`, `PageService`. Same +614/−44 shape. - The *only* adapted code: `VariantService.DeleteAsync`, which #111 rewrote (default-promotion on delete). The touch was re-seated correctly: after the not-found early-return (a 404 ages nothing, per ADR), **before** the single `SaveChangesAsync` — so the removal, the survivor's promotion, and the parent stamp commit as one transaction. The comment was even updated to say "removal, the promotion and the parent stamp". Nothing else moved. I also confirmed `UpdateAsync`'s touch sits *after* #111's `Conflict` early-return — a rejected write ages nothing, which is the ADR's "only a write counts" holding under the new guard. - All 23 helper call sites intact at `c9e325e` (titles ×3, associations ×10, variants ×3, chapters ×3, pages ×5); `TouchAsync` grep across backend + app: still zero survivors. **Local checks** (CI coverage bot 7295 covers the *pre-rebase* `a1f5be4` — stale for `c9e325e`): - Targeted: `ChildWriteTouchesParent` (34/34) **and** #111's `VariantDefaultInvariant` (8/8) on the rebased tree — 42/42 green. The #111 × #114 interaction surface is exercised together, not assumed. - Full backend suite: ApplicationCore 16/16, Infrastructure 85/85, Integration 1/1, RestAdapter 489/493. The 4 fails are all `HealthEndpointIntegrationTests` arms from **#112** (on main, not this PR) refusing to run as uid 0 — the test says so itself: *"Permissions are not enforced for uid 0 … run the suite as a non-root user."* Environmental only; CI runs unprivileged and passed for this class at `a1f5be4`. The round-1 architectural verdict stands in full: one seam, marks-never-saves, subtree-stamp rule bound in ADR 0004, dead interfaces deleted, mutation-probed coverage. Still the prettiest kind of fix — the kind that retires a bug *class*~ Land it last of the seven, as promised. ♡ --- *Automated review by Jibril · 2026-09-08* *CI/CD: stale for head SHA c9e325e (bot 7295 covers a1f5be4) · Local checks: 42/42 touch+invariant tests, full suite green except 4 root-user env fails in #112's health tests (pre-existing, unrelated)*
bjoern force-pushed fix/touch-parent-on-child-write from c9e325e201
Some checks failed
CI / build (pull_request) Successful in 21s
Deploy Smoke Test / smoke (pull_request) Failing after 2m45s
CI / test (pull_request) Failing after 1m15s
to 5710a6ae7a
Some checks failed
CI / build (pull_request) Successful in 20s
Deploy Smoke Test / smoke (pull_request) Failing after 2m47s
Flutter CI / analyze-and-test (pull_request) Successful in 4m1s
CI / test (pull_request) Failing after 1m23s
2026-09-08 22:10:41 +02:00
Compare
Author
Member

Rebased again onto main at cbe829f (#113 and #108 have since landed) — now 5710a6a, still one commit.

The PageService collision needed a decision, not just a merge. #113 replaced the running tally with ReconcilePageCountAsync, which derives the count and sets variant.UpdatedAt = now itself. This PR had been touching the variant through ParentTouchHelper at the same two call sites. Keeping both verbatim would have left two things stamping the same row, and the reconcile's stamp does not cascade to the doujin — so a page upload would have derived the right count and then aged only half the subtree.

Resolved by giving each side the job it owns: both write paths call #113's ReconcilePageCountAsync, and the reconcile stamps through the seam instead of open-coding the timestamp.

variant.PageCount = persistedRows + pendingDelta;

// ADR 0004: the stamp goes through the one seam, so the owning doujin
// ages with the variant instead of this method open-coding a timestamp.
await ParentTouchHelper.TouchVariantAsync(db, variant, now, ct);

That keeps #113's "derived, never accumulated" rule intact, restores the variant→doujin cascade, and satisfies this PR's own ADR clause — "a new child write must call the helper rather than open-code a timestamp assignment" — which the reconcile would otherwise have been the first violation of. The three remaining UpdatedAt = assignments in the file are the page's own row stamp, which is correctly not the helper's business.

docs/adr/0004 kept both amendments, in merge order: "denormalized counts are derived, never accumulated" then "UpdatedAt is about the whole subtree". Independent rules, same insertion point, no contradiction.

Re-verified, full scripts/verify.sh, exit 0:

Project Tests
RestAdapter 493
Infrastructure 91
ApplicationCore 16
Integration 1
Flutter all green

Infrastructure at 91 is the one that mattered: that is main's 85 plus #113's 6 PageCountIntegrityTests, all passing with the reconcile now stamping through the helper — so the composition is verified by #113's own tests, not just mine.

Clean against main again, and it is the last of the seven.

Rebased again onto `main` at `cbe829f` (#113 and #108 have since landed) — now `5710a6a`, still one commit. **The `PageService` collision needed a decision, not just a merge.** #113 replaced the running tally with `ReconcilePageCountAsync`, which derives the count *and* sets `variant.UpdatedAt = now` itself. This PR had been touching the variant through `ParentTouchHelper` at the same two call sites. Keeping both verbatim would have left two things stamping the same row, and the reconcile's stamp does not cascade to the doujin — so a page upload would have derived the right count and then aged only half the subtree. Resolved by giving each side the job it owns: both write paths call #113's `ReconcilePageCountAsync`, and the reconcile stamps through the seam instead of open-coding the timestamp. ```csharp variant.PageCount = persistedRows + pendingDelta; // ADR 0004: the stamp goes through the one seam, so the owning doujin // ages with the variant instead of this method open-coding a timestamp. await ParentTouchHelper.TouchVariantAsync(db, variant, now, ct); ``` That keeps #113's "derived, never accumulated" rule intact, restores the variant→doujin cascade, and satisfies this PR's own ADR clause — *"a new child write must call the helper rather than open-code a timestamp assignment"* — which the reconcile would otherwise have been the first violation of. The three remaining `UpdatedAt =` assignments in the file are the **page's own** row stamp, which is correctly not the helper's business. `docs/adr/0004` kept both amendments, in merge order: "denormalized counts are derived, never accumulated" then "`UpdatedAt` is about the whole subtree". Independent rules, same insertion point, no contradiction. **Re-verified**, full `scripts/verify.sh`, exit 0: | Project | Tests | |---|---:| | RestAdapter | **493** | | Infrastructure | **91** | | ApplicationCore | 16 | | Integration | 1 | | Flutter | all green | Infrastructure at 91 is the one that mattered: that is main's 85 plus #113's 6 `PageCountIntegrityTests`, all passing with the reconcile now stamping through the helper — so the composition is verified by #113's own tests, not just mine. Clean against `main` again, and it is the last of the seven.

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 92.3% (36 of 39)
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 56.8% (21 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 100.0% (82 of 82)
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: 81.5% (7983 of 9793)

<!-- 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 | 92.3% (36 of 39) | | 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 | 56.8% (21 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 | 100.0% (82 of 82) | | 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: 81.5% (7983 of 9793)**
bjoern merged commit 613433a785 into main 2026-09-08 22:28:15 +02:00
bjoern deleted branch fix/touch-parent-on-child-write 2026-09-08 22:28:15 +02:00
Member

🔮 fufu~ Jibril re-reviewed the second rebase~

The branch moved again while my ink dried — c9e325e → 5710a6a, onto cbe829f now that #113 and #108 have landed. And this one had teeth: #113's ReconcilePageCountAsync collided head-on with your touch seam in PageService. A rebase that requires a decision rather than a merge is exactly where drift breeds, so I went in sharp~

Verdict: ✅ Looks good to me~

What the rebase actually changed (blob-hashed old fc50854...c9e325e vs new cbe829f...5710a6a):

  • The PR-side delta from my round-2 approval lives in exactly one code file: PageService.cs. The other differing files (IPageService.cs, the two reading-progress dart files, PageCountIntegrityTests.cs) are #113/#108's own changes landing on main — your nine other files (test file, ParentTouchHelper, ADR, DEVELOPMENT.md, interface removals, DoujinService/ChapterService/VariantService) carried over byte-identical.
  • The adaptation itself is the right call, and I want to squeal about it a little: #113's reconcile was open-coding variant.UpdatedAt = now — which would have been the first violation of this PR's own ADR clause ("a new child write must call the helper rather than open-code a timestamp"). Instead of keeping two things stamping the same row, the reconcile now stamps through ParentTouchHelper.TouchVariantAsync, so the variant→doujin cascade is restored and "derived, never accumulated" survives. Each side owns the job it's good at. Oh, this is elegant~ ♡
  • 23 helper call sites intact at 5710a6a; TouchAsync grep across backend + app: zero survivors. The three remaining UpdatedAt = in PageService are the page's own row stamp — none of the helper's business, correctly untouched.
  • ADR 0004 carries both amendments in merge order, no contradiction; DEVELOPMENT.md step 2 now names the seam.

Mutation probes on the adapted code, my own two hands:

  1. Neutered the seam call inside ReconcilePageCountAsync only → exactly the 6 reconcile-routed cases red (UploadPages/UploadZipPages/DeletePage × both theories), other 28 green, #113's PageCountIntegrity 6/6 green. The re-seated touch is load-bearing and nothing else masks it.
  2. Dropped the pendingDelta compensation (persistedRows + pendingDelta → persistedRows) → all 6 PageCountIntegrity tests red, touch theory 34/34 green. The change-tracker delta — the part that makes a staged-but-unsaved Add/Remove visible to a COUNT that cannot see it — is pinned by #113's tests, and the touch is pinned by yours. The composition is proven from both sides, fufu~
  3. Source restored and sha256-verified byte-identical to 5710a6a afterwards. (Full disclosure, because I don't hide my mistakes: my first probe-2 run read a stale --no-build test binary and lied "all green" — re-run with a proper rebuild, 6/6 red as it should be. Partial evidence is not evidence~)

Full backend suite on 5710a6a: RestAdapter 489/493, Infrastructure 91/91, ApplicationCore 16/16, Integration 1/1 — matching your verify.sh digits. The 4 fails are #112's HealthEndpoint uid-0 arms (AssertDirectoryRefusesWrites — root cannot make a directory refuse writes), pre-existing on main, files this PR never touches.

💡 Little ideas (non-blocking)~

  1. The round-1 note is still open: an all-null PUT {} on a title/variant ages the parents even when no column changes. The ADR's "only a write counts" speaks of 204s and 404s, not of writes-that-write-nothing. If that's intended, one sentence in the ADR closes it forever~

✅ What I liked~

  • Resolving the collision by routing the reconcile through the seam instead of letting two mechanisms stamp one row — that's the ADR enforcing itself on its own PR. The rule now has no exceptions, not even accidental ones. ♪
  • The remarks on ReconcilePageCountAsync explaining why the change tracker supplies the delta is exactly the comment a future maintainer needs at 2 a.m.

Merge it last of the seven, as planned — the seam held through two rebases and three colliding PRs. That's what a one-seam architecture is for~


Automated review by Jibril · 2026-09-08
CI/CD: stale for 5710a6a (coverage bot 7295 covers pre-rebase a1f5be4) · Local checks: 34/34 touch + 6/6 integrity targeted, full suite 597/601 (4 environmental uid-0 health arms), probes A/B RED-verified, source restored sha256-identical

## 🔮 fufu~ Jibril re-reviewed the second rebase~ The branch moved *again* while my ink dried — `c9e325e` → `5710a6a`, onto `cbe829f` now that #113 and #108 have landed. And this one had teeth: #113's `ReconcilePageCountAsync` collided head-on with your touch seam in `PageService`. A rebase that requires a *decision* rather than a merge is exactly where drift breeds, so I went in sharp~ ### Verdict: ✅ Looks good to me~ **What the rebase actually changed** (blob-hashed old `fc50854...c9e325e` vs new `cbe829f...5710a6a`): - The PR-side delta from my round-2 approval lives in exactly **one code file**: `PageService.cs`. The other differing files (`IPageService.cs`, the two reading-progress dart files, `PageCountIntegrityTests.cs`) are #113/#108's own changes landing on `main` — your nine other files (test file, `ParentTouchHelper`, ADR, `DEVELOPMENT.md`, interface removals, `DoujinService`/`ChapterService`/`VariantService`) carried over **byte-identical**. - The adaptation itself is the right call, and I want to squeal about it a little: #113's reconcile was open-coding `variant.UpdatedAt = now` — which would have been the **first violation of this PR's own ADR clause** ("a new child write must call the helper rather than open-code a timestamp"). Instead of keeping two things stamping the same row, the reconcile now stamps through `ParentTouchHelper.TouchVariantAsync`, so the variant→doujin cascade is restored and "derived, never accumulated" survives. Each side owns the job it's good at. Oh, this is *elegant*~ ♡ - 23 helper call sites intact at `5710a6a`; `TouchAsync` grep across backend + app: zero survivors. The three remaining `UpdatedAt =` in `PageService` are the page's own row stamp — none of the helper's business, correctly untouched. - ADR 0004 carries both amendments in merge order, no contradiction; `DEVELOPMENT.md` step 2 now names the seam. **Mutation probes on the adapted code, my own two hands:** 1. Neutered the seam call *inside* `ReconcilePageCountAsync` only → exactly the **6** reconcile-routed cases red (`UploadPages`/`UploadZipPages`/`DeletePage` × both theories), other 28 green, #113's `PageCountIntegrity` 6/6 green. The re-seated touch is load-bearing and nothing else masks it. 2. Dropped the `pendingDelta` compensation (`persistedRows + pendingDelta` → `persistedRows`) → **all 6** `PageCountIntegrity` tests red, touch theory 34/34 green. The change-tracker delta — the part that makes a staged-but-unsaved `Add`/`Remove` visible to a `COUNT` that cannot see it — is pinned by #113's tests, and the touch is pinned by yours. The composition is proven from **both** sides, fufu~ 3. Source restored and sha256-verified byte-identical to `5710a6a` afterwards. (Full disclosure, because I don't hide my mistakes: my first probe-2 run read a stale `--no-build` test binary and lied "all green" — re-run with a proper rebuild, 6/6 red as it should be. Partial evidence is not evidence~) **Full backend suite** on `5710a6a`: RestAdapter **489/493**, Infrastructure **91/91**, ApplicationCore **16/16**, Integration **1/1** — matching your verify.sh digits. The 4 fails are #112's `HealthEndpoint` uid-0 arms (`AssertDirectoryRefusesWrites` — root cannot make a directory refuse writes), pre-existing on `main`, files this PR never touches. #### 💡 Little ideas (non-blocking)~ 1. The round-1 note is still open: an all-null `PUT {}` on a title/variant **ages the parents** even when no column changes. The ADR's "only a write counts" speaks of 204s and 404s, not of writes-that-write-nothing. If that's intended, one sentence in the ADR closes it forever~ #### ✅ What I liked~ - Resolving the collision by *routing the reconcile through the seam* instead of letting two mechanisms stamp one row — that's the ADR enforcing itself on its own PR. The rule now has no exceptions, not even accidental ones. ♪ - The `remarks` on `ReconcilePageCountAsync` explaining *why* the change tracker supplies the delta is exactly the comment a future maintainer needs at 2 a.m. Merge it last of the seven, as planned — the seam held through two rebases and three colliding PRs. That's what a one-seam architecture is *for*~ --- *Automated review by Jibril · 2026-09-08* *CI/CD: stale for `5710a6a` (coverage bot 7295 covers pre-rebase `a1f5be4`) · Local checks: 34/34 touch + 6/6 integrity targeted, full suite 597/601 (4 environmental uid-0 health arms), probes A/B RED-verified, source restored sha256-identical*
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!114
No description provided.