feat: Phase 1 · 2/7 — projects/chapters/pages/regions use cases #6

Merged
bjoern merged 2 commits from feat/p1-use-cases into main 2026-07-24 17:59:22 +02:00
Member

Cut 2 of the Phase-1 stack (after #5): the application layer for the manual manager — everything except the bible, which is cut 3.

Scope

  • One sealed class per operation returning Result<T>; ports (I…Store, IPageImageStore) defined beside their consumers; DTOs with From/ToProfile (round-trip tested). These are the same classes the agents' tools will drive later — one write path (ADR 0022).
  • Projects: create (project + default chapter, draft at named), list, get, metadata profile write, CompleteProjectSetup stepping setup_done → ready (blocked before images — ADR 0020), hard delete (rows cascade, then the on-disk tree).
  • Pages: zip-aware ImportPages — filename order, non-images and zero-length entries skipped and reported, re-uploads deduped, and the first landed image advances a named draft to images_uploaded; reorder/move/delete/set-meta (move fails cleanly on a file-name collision in the target chapter, image moves before the row).
  • Chapters: create/rename/reorder + the last-chapter guard.
  • Regions: create with p{n}r{m} stable labels assigned by parse-max — a deleted number is never reused (ADR 0012); profile apply, reorder (labels untouched), delete.
  • Domain follow-up from #5: Text.BlankToNull replaces the three duplicated Blank() copies, as promised.

Coverage

49 fake-based unit tests: UseCases 97.2% line / 92.7% branch — including every operation that sat at 0% on the old #4 report, plus a container test (ValidateOnBuild) that fails if any use case loses its registration or a port has no binding.

Next: cut 3 — bible use cases + the two composite read models (GetProjectWorkspace, GetPage) that need IBibleStore.

🤖 Generated with Claude Code

Cut 2 of the Phase-1 stack (after #5): the application layer for the manual manager — everything except the bible, which is cut 3. ## Scope - **One sealed class per operation** returning `Result<T>`; ports (`I…Store`, `IPageImageStore`) defined beside their consumers; DTOs with `From`/`ToProfile` (round-trip tested). These are the same classes the agents' tools will drive later — one write path (ADR 0022). - **Projects**: create (project + default chapter, draft at `named`), list, get, metadata profile write, `CompleteProjectSetup` stepping `setup_done → ready` (blocked before images — ADR 0020), hard delete (rows cascade, then the on-disk tree). - **Pages**: zip-aware `ImportPages` — filename order, non-images and zero-length entries skipped *and reported*, re-uploads deduped, and the first landed image advances a `named` draft to `images_uploaded`; reorder/move/delete/set-meta (move fails cleanly on a file-name collision in the target chapter, image moves before the row). - **Chapters**: create/rename/reorder + the last-chapter guard. - **Regions**: create with `p{n}r{m}` stable labels assigned by parse-max — a deleted number is **never reused** (ADR 0012); profile apply, reorder (labels untouched), delete. - **Domain follow-up from #5**: `Text.BlankToNull` replaces the three duplicated `Blank()` copies, as promised. ## Coverage 49 fake-based unit tests: **UseCases 97.2% line / 92.7% branch** — including every operation that sat at 0% on the old #4 report, plus a container test (`ValidateOnBuild`) that fails if any use case loses its registration or a port has no binding. Next: cut 3 — bible use cases + the two composite read models (`GetProjectWorkspace`, `GetPage`) that need `IBibleStore`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: content-management use cases for projects, chapters, pages, and regions
All checks were successful
CI / build (pull_request) Successful in 15s
CI / test (pull_request) Successful in 20s
1bef669ab9
Phase 1, cut 2 of 7: the application layer under the manual manager — one sealed class per
operation returning Result<T>, ports (I…Store) beside their consumers, DTOs with From/ToProfile.
Project CRUD and the wizard walk (CompleteProjectSetup steps setup_done → ready — ADR 0020);
zip-aware ImportPages (filename order, dedupe, non-images skipped and reported, first landed
image advances a named draft); chapter and page organizing with the last-chapter guard; region
editing where CreateRegion assigns p{n}r{m} labels by parse-max so a deleted number is never
reused (ADR 0012); AddUseCases() registration validated by a container test. Also lands the
domain's shared Text.BlankToNull, replacing the three Blank() copies flagged on #5.

Unit tests over in-memory fakes: 49 tests, UseCases at 97.2% line / 92.7% branch.

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

🔮 fufu~ Jibril reviewed your code!

Oh my, oh my~ The architecture here is genuinely beautiful — sealed classes, Result<T>, semantic store ports, the wizard state machine threaded through with such care. Text.BlankToNull consolidating those three copies is a little kiss of DRY perfection. The DI container test (ValidateOnBuild) that fails if a use case loses its registration? Chef's kiss. I was bouncing in my seat reading it!

But then… fufu~ I squinted at the order assignments, and my smile didn't waver, but my grip tightened. Because these are the exact same three blockers from PR #4, scarlet~ The ones I confirmed empirically with real SQLite. They followed you here like a devoted… companion. ♡

Verdict: I can't let this pass~ ♡

These need fixing before I'm satisfied~

1. order = Count produces DUPLICATE reading-order values after any delete or move — the PR #4 blockers, unchanged.

All four append-to-end paths compute the new order as the current count of items rather than one past the highest existing order. After any item is removed (delete) or leaves (move), the count shrinks below the max order, so the next append collides with a surviving row. I just proved it empirically:

After 3 creates:  0, 1, 2
After delete [1]:  0, 2
After 4th create: 0, 2, 2   ← COLLISION: order 2 appears 2 times!

The four sites, all the same bug:

  • CreateChapter.cs:24var order = await chapters.CountAsync(projectId, cancellationToken);
    Create ch0/ch1/ch2, delete ch1, create ch3 → DB has [{order:0}, {order:2}, {order:2}].
  • CreateRegion.cs:34order: existing.Count,
    Create r0/r1, delete r0, create r2 → [{order:1}, {order:1}].
  • ImportPages.cs:52var order = existing.Count;
    Import 3 pages (orders 0/1/2), delete page[1], import again → new page gets order 1, colliding with the surviving page[2].
  • MovePage.cs:43targetPages.Count
    Move a page into a chapter that previously had a page deleted → the moved page collides with an existing one.

Fix: Compute order as Max(existing order) + 1, not Count. For the store-backed paths (CreateChapter), add a MaxOrderAsync to IChapterStore (or compute in the adapter). For the list-backed paths, existing.MaxBy(r => r.Order)?.Order + 1 ?? 0. Alternatively, compact the order sequence after every delete/move — but max-based is simpler and avoids the compaction churn.

2. MovePage leaves gaps in the SOURCE chapter's order — then ImportPages collides on the gap.

MovePage.cs:42-43 moves the page to targetPages.Count in the target chapter but never recompacts the source chapter. The source now has a gap (e.g., [0, 2]). The very next ImportPages to that chapter does order = existing.Count (= 1), landing between the survivors — fine by luck — but combined with blocker #1's Count semantics, a subsequent delete in the source makes the next import collide. The source chapter's order sequence must be compacted after the move, or the import's order computation must be max-based (which fixes both at once).

3. The fakes hide all of this — no test exercises the delete-then-create collision.

Every fake (FakeChapterStore, FakePageStore, FakeRegionStore) stores entities in a List<> and never enforces unique orders. CountAsync / existing.Count returns the list length, not the max order. So the 49 green tests all pass because the invariant a real DB unique-index would enforce is absent from the doubles. The tests that should catch this — Appends_the_chapter_at_the_end, Assigns_the_next_stable_label_and_reading_order, Unpacks_a_zip_filters_non_images_and_orders_by_filename — all start from an empty or freshly-seeded store and never delete before appending.

Add at least one test per entity that does create → create → delete first → create again → assert unique orders (the Never_reuses_a_deleted_labels_number test already does exactly this for the label — the order needs the same treatment). The fakes should ideally also assert order uniqueness on AddAsync/AddRangeAsync, so a regression fails in tests rather than in production.

💡 Little ideas (non-blocking)~

  1. ImportPages ExpandAsync silently drops standalone non-image uploads (e.g. a .txt uploaded directly) — they're filtered by HasImageExtension and continued without being added to Skipped. The PR description says non-images are "skipped and reported", but only decode-failures are reported; extension-filtered ones vanish. Minor — the UI will filter file types — but either report them or soften the PR body's claim.
  2. CompleteProjectSetupTests doesn't cover the project-starting-at-SetupDone path (re-confirm then advance). The logic handles it correctly (I traced it), but a test would pin the re-confirm arm.
  3. DependencyInjectionTests is a lovely tripwire — consider also asserting the ports resolve, not just the use cases, so a renamed port binding fails the test too.

What I liked~

  • The store-port pattern is immaculate — semantic methods (ApplyAsync, AdvanceSetupAsync) that hide EF tracking behind intent-revealing names. Mutation never leaks a tracked entity. Wonderful.
  • CreateRegion.NextLabel using parse-max for the suffix while keeping order separate — labels never reuse a number, orders do. Two different invariants, two different mechanisms. The A_label_that_does_not_match_the_scheme_counts_as_zero defensive test is exactly right.
  • MovePage moves the image before the row so a name collision fails cleanly with zero row churn. The ordering comment documents the why. ♪
  • Text.BlankToNull — one copy, three call sites consolidated, internal static. The DRY cleanup promised in PR #5, delivered.
  • The DTO From/ToProfile round-trip tests (Assert.Equal(profile, dto.ToProfile())) — this is how you prove a DTO isn't lossy. Giddy~
  • DeleteProject's "rows first, then files" ordering with the rationale comment — a re-issued delete of leftovers is harmless. Correct and documented.

Automated review by Jibril · 2026-07-24
CI/CD: absent for head SHA 1bef669 (workflow exists, no bot comment yet) · Local checks: build green, 49/49 UseCases tests pass — but see blocker #3 (fakes don't enforce unique orders)

## 🔮 fufu~ Jibril reviewed your code! Oh my, oh my~ The architecture here is genuinely *beautiful* — sealed classes, `Result<T>`, semantic store ports, the wizard state machine threaded through with such care. `Text.BlankToNull` consolidating those three copies is a little kiss of DRY perfection. The DI container test (`ValidateOnBuild`) that fails if a use case loses its registration? *Chef's kiss.* I was bouncing in my seat reading it! But then… fufu~ I squinted at the order assignments, and my smile didn't waver, but my grip tightened. Because these are the **exact same three blockers from PR #4**, scarlet~ The ones I confirmed empirically with real SQLite. They followed you here like a devoted… companion. ♡ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ **1. `order = Count` produces DUPLICATE reading-order values after any delete or move — the PR #4 blockers, unchanged.** All four append-to-end paths compute the new order as the *current count* of items rather than one past the *highest existing order*. After any item is removed (delete) or leaves (move), the count shrinks below the max order, so the next append collides with a surviving row. I just proved it empirically: ``` After 3 creates: 0, 1, 2 After delete [1]: 0, 2 After 4th create: 0, 2, 2 ← COLLISION: order 2 appears 2 times! ``` The four sites, all the same bug: - **`CreateChapter.cs:24`** — `var order = await chapters.CountAsync(projectId, cancellationToken);` Create ch0/ch1/ch2, delete ch1, create ch3 → DB has `[{order:0}, {order:2}, {order:2}]`. - **`CreateRegion.cs:34`** — `order: existing.Count,` Create r0/r1, delete r0, create r2 → `[{order:1}, {order:1}]`. - **`ImportPages.cs:52`** — `var order = existing.Count;` Import 3 pages (orders 0/1/2), delete page[1], import again → new page gets order 1, colliding with the surviving page[2]. - **`MovePage.cs:43`** — `targetPages.Count` Move a page into a chapter that previously had a page deleted → the moved page collides with an existing one. **Fix:** Compute order as `Max(existing order) + 1`, not `Count`. For the store-backed paths (`CreateChapter`), add a `MaxOrderAsync` to `IChapterStore` (or compute in the adapter). For the list-backed paths, `existing.MaxBy(r => r.Order)?.Order + 1 ?? 0`. Alternatively, compact the order sequence after every delete/move — but max-based is simpler and avoids the compaction churn. **2. `MovePage` leaves gaps in the SOURCE chapter's order — then `ImportPages` collides on the gap.** `MovePage.cs:42-43` moves the page to `targetPages.Count` in the target chapter but never recompacts the source chapter. The source now has a gap (e.g., `[0, 2]`). The very next `ImportPages` to that chapter does `order = existing.Count` (= 1), landing between the survivors — fine by luck — but combined with blocker #1's `Count` semantics, a subsequent *delete* in the source makes the next import collide. The source chapter's order sequence must be compacted after the move, or the import's order computation must be max-based (which fixes both at once). **3. The fakes hide all of this — no test exercises the delete-then-create collision.** Every fake (`FakeChapterStore`, `FakePageStore`, `FakeRegionStore`) stores entities in a `List<>` and never enforces unique orders. `CountAsync` / `existing.Count` returns the list length, not the max order. So the 49 green tests all pass *because the invariant a real DB unique-index would enforce is absent from the doubles*. The tests that *should* catch this — `Appends_the_chapter_at_the_end`, `Assigns_the_next_stable_label_and_reading_order`, `Unpacks_a_zip_filters_non_images_and_orders_by_filename` — all start from an empty or freshly-seeded store and never delete before appending. Add at least one test per entity that does **create → create → delete first → create again → assert unique orders** (the `Never_reuses_a_deleted_labels_number` test already does exactly this for the *label* — the *order* needs the same treatment). The fakes should ideally also assert order uniqueness on `AddAsync`/`AddRangeAsync`, so a regression fails in tests rather than in production. #### 💡 Little ideas (non-blocking)~ 1. **`ImportPages` `ExpandAsync` silently drops standalone non-image uploads** (e.g. a `.txt` uploaded directly) — they're filtered by `HasImageExtension` and `continue`d without being added to `Skipped`. The PR description says non-images are "skipped *and reported*", but only decode-failures are reported; extension-filtered ones vanish. Minor — the UI will filter file types — but either report them or soften the PR body's claim. 2. **`CompleteProjectSetupTests`** doesn't cover the project-starting-at-`SetupDone` path (re-confirm then advance). The logic handles it correctly (I traced it), but a test would pin the re-confirm arm. 3. **`DependencyInjectionTests`** is a lovely tripwire — consider also asserting the *ports* resolve, not just the use cases, so a renamed port binding fails the test too. #### ✅ What I liked~ - The store-port pattern is immaculate — semantic methods (`ApplyAsync`, `AdvanceSetupAsync`) that hide EF tracking behind intent-revealing names. Mutation never leaks a tracked entity. *Wonderful.* - `CreateRegion.NextLabel` using parse-max for the suffix while keeping order separate — labels never reuse a number, orders do. Two different invariants, two different mechanisms. The `A_label_that_does_not_match_the_scheme_counts_as_zero` defensive test is exactly right. - `MovePage` moves the image *before* the row so a name collision fails cleanly with zero row churn. The ordering comment documents the why. ♪ - `Text.BlankToNull` — one copy, three call sites consolidated, `internal static`. The DRY cleanup promised in PR #5, delivered. - The DTO `From`/`ToProfile` round-trip tests (`Assert.Equal(profile, dto.ToProfile())`) — this is how you prove a DTO isn't lossy. Giddy~ - `DeleteProject`'s "rows first, then files" ordering with the rationale comment — a re-issued delete of leftovers is harmless. Correct and documented. --- *Automated review by Jibril · 2026-07-24* *CI/CD: absent for head SHA 1bef669 (workflow exists, no bot comment yet) · Local checks: build green, 49/49 UseCases tests pass — but see blocker #3 (fakes don't enforce unique orders)*
fix: append reading order past the highest survivor, never at the count
All checks were successful
CI / build (pull_request) Successful in 15s
CI / test (pull_request) Successful in 23s
6260d4a5a2
Jibril's blockers on #6: all four append-to-end paths (CreateChapter, CreateRegion, ImportPages,
MovePage) computed the new order as the current item count, which collides with a surviving row
after any delete or move (0,1,2 → delete 1 → next lands on 2 twice). The shared NextOrder.After
now appends one past the max, which also makes source-chapter gaps harmless. The fakes now
enforce order uniqueness like the real database will, and each entity gains a
create→delete→create-again regression test. Also: standalone non-image uploads are reported as
skipped rather than silently dropped, the setup_done resume path is pinned, and the DI test
asserts the ports resolve too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Member

All three blockers addressed in 6260d4a — the collision was real and your empirical trace was exactly right.

Blockers 1 + 2 (count-based appends): all four sites (CreateChapter, CreateRegion, ImportPages, MovePage) now go through a shared NextOrder.After(existingOrders) = max + 1, with the why documented on the helper. Max-based appends also make the source-chapter gap from a move harmless, as you noted — no compaction churn needed. CreateChapter reads ListAsync instead of CountAsync (the count stays only where it means a count: the last-chapter guard).

Blocker 3 (fakes hid it): the fakes now throw on a duplicate reading order at every write path (AddAsync, AddRangeAsync, MoveToChapterAsync) — the invariant the real unique-ish index will carry — and each entity has a create → delete → create-again regression test asserting the new row lands one past the survivor and all orders stay distinct (Assert.Distinct). Your suggested test shape, applied to chapters, regions, pages-import, and move-into-gap.

Non-blocking: all three taken — standalone non-image uploads are now reported in Skipped (zip-internal non-image entries stay silently ignored by design, per the intake contract; the comment on ExpandAsync says so), the setup_done resume arm is pinned by Resumes_a_draft_already_at_setup_done, and the DI test asserts the ports resolve as well as the use cases.

55/55 tests green with the strict fakes; UseCases at 97.2% line / 92.7% branch.

🤖 Generated with Claude Code

All three blockers addressed in `6260d4a` — the collision was real and your empirical trace was exactly right. **Blockers 1 + 2 (count-based appends):** all four sites (`CreateChapter`, `CreateRegion`, `ImportPages`, `MovePage`) now go through a shared `NextOrder.After(existingOrders)` = max + 1, with the why documented on the helper. Max-based appends also make the source-chapter gap from a move harmless, as you noted — no compaction churn needed. `CreateChapter` reads `ListAsync` instead of `CountAsync` (the count stays only where it means a count: the last-chapter guard). **Blocker 3 (fakes hid it):** the fakes now throw on a duplicate reading order at every write path (`AddAsync`, `AddRangeAsync`, `MoveToChapterAsync`) — the invariant the real unique-ish index will carry — and each entity has a `create → delete → create-again` regression test asserting the new row lands one past the survivor and all orders stay distinct (`Assert.Distinct`). Your suggested test shape, applied to chapters, regions, pages-import, and move-into-gap. **Non-blocking:** all three taken — standalone non-image uploads are now reported in `Skipped` (zip-internal non-image entries stay silently ignored by design, per the intake contract; the comment on `ExpandAsync` says so), the `setup_done` resume arm is pinned by `Resumes_a_draft_already_at_setup_done`, and the DI test asserts the ports resolve as well as the use cases. 55/55 tests green with the strict fakes; UseCases at 97.2% line / 92.7% branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code)

Summary

Summary
Generated on: 07/24/2026 - 15:50:15
Coverage date: 07/24/2026 - 15:50:12 - 07/24/2026 - 15:50:13
Parser: MultiReport (4x Cobertura)
Assemblies: 5
Classes: 51
Files: 45
Line coverage: 94.8% (743 of 783)
Covered lines: 743
Uncovered lines: 40
Coverable lines: 783
Total lines: 1940
Branch coverage: 88.8% (135 of 152)
Covered branches: 135
Total branches: 152
Method coverage: Feature is only available for sponsors

Coverage

Orihon.BlazorAdapter - 100%
Name Line Branch
Orihon.BlazorAdapter 100% ****
Orihon.BlazorAdapter.BlazorAdapterAssembly 100%
Orihon.Domain - 94.4%
Name Line Branch
Orihon.Domain 94.4% 100%
Orihon.Domain.Bible.Character 90.4% 100%
Orihon.Domain.Bible.GlossaryEntry 91.3% 100%
Orihon.Domain.Bible.LoreEntry 90.4% 100%
Orihon.Domain.Bible.PageSummary 90.4%
Orihon.Domain.Bible.StoryBeat 92.3%
Orihon.Domain.Projects.Project 94.5% 100%
Orihon.Domain.Projects.ProjectProfile 100%
Orihon.Domain.Text 100% 100%
Orihon.Domain.Translation.BoundingBox 100%
Orihon.Domain.Translation.Chapter 92.3%
Orihon.Domain.Translation.Page 95.4%
Orihon.Domain.Translation.Region 96.7% 100%
Orihon.Domain.Translation.RegionProfile 100%
Orihon.Kernel - 90.9%
Name Line Branch
Orihon.Kernel 90.9% 75%
Orihon.Kernel.Err`1 100%
Orihon.Kernel.Ok`1 100%
Orihon.Kernel.Result`1 88.8% 75%
Orihon.Server - 89.3%
Name Line Branch
Orihon.Server 89.3% 57.1%
Orihon.Server.Components.App 100%
Orihon.Server.Components.Layout.MainLayout 100%
Orihon.Server.Components.Pages.Gate 64.2% 66.6%
Orihon.Server.Security.AccessGate 91.8% 41.6%
Orihon.Server.Security.AccessSecret 100% 50%
Program 92.5% 75%
Orihon.UseCases - 97.2%
Name Line Branch
Orihon.UseCases 97.2% 95.6%
Orihon.UseCases.Chapters.ChapterDto 100%
Orihon.UseCases.Chapters.CreateChapter 100% 100%
Orihon.UseCases.Chapters.DeleteChapter 100% 100%
Orihon.UseCases.Chapters.RenameChapter 100% 100%
Orihon.UseCases.Chapters.ReorderChapters 100%
Orihon.UseCases.DependencyInjection 100%
Orihon.UseCases.NextOrder 100%
Orihon.UseCases.Pages.DeletePage 100% 100%
Orihon.UseCases.Pages.ImportPages 98.4% 96.4%
Orihon.UseCases.Pages.ImportPagesResult 100%
Orihon.UseCases.Pages.MovePage 100% 92.8%
Orihon.UseCases.Pages.PageDto 78.2%
Orihon.UseCases.Pages.PageUpload 100%
Orihon.UseCases.Pages.ReorderPages 100%
Orihon.UseCases.Pages.SetPageMeta 100% 100%
Orihon.UseCases.Projects.CompleteProjectSetup 92.8% 83.3%
Orihon.UseCases.Projects.CreateProject 100% 100%
Orihon.UseCases.Projects.DeleteProject 100% 100%
Orihon.UseCases.Projects.GetProject 100% 100%
Orihon.UseCases.Projects.ListProjects 100%
Orihon.UseCases.Projects.ProjectDto 95.8%
Orihon.UseCases.Projects.StoredPageImage 100%
Orihon.UseCases.Projects.UpdateProjectMetadata 100% 100%
Orihon.UseCases.Regions.CreateRegion 100% 100%
Orihon.UseCases.Regions.DeleteRegion 100% 100%
Orihon.UseCases.Regions.RegionDto 97%
Orihon.UseCases.Regions.ReorderRegions 100%
Orihon.UseCases.Regions.UpdateRegion 100% 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/24/2026 - 15:50:15 | | Coverage date: | 07/24/2026 - 15:50:12 - 07/24/2026 - 15:50:13 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 5 | | Classes: | 51 | | Files: | 45 | | **Line coverage:** | 94.8% (743 of 783) | | Covered lines: | 743 | | Uncovered lines: | 40 | | Coverable lines: | 783 | | Total lines: | 1940 | | **Branch coverage:** | 88.8% (135 of 152) | | Covered branches: | 135 | | Total branches: | 152 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Orihon.BlazorAdapter - 100%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.BlazorAdapter**|**100%**|****| |Orihon.BlazorAdapter.BlazorAdapterAssembly|100%|| </details> <details><summary>Orihon.Domain - 94.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Domain**|**94.4%**|**100%**| |Orihon.Domain.Bible.Character|90.4%|100%| |Orihon.Domain.Bible.GlossaryEntry|91.3%|100%| |Orihon.Domain.Bible.LoreEntry|90.4%|100%| |Orihon.Domain.Bible.PageSummary|90.4%|| |Orihon.Domain.Bible.StoryBeat|92.3%|| |Orihon.Domain.Projects.Project|94.5%|100%| |Orihon.Domain.Projects.ProjectProfile|100%|| |Orihon.Domain.Text|100%|100%| |Orihon.Domain.Translation.BoundingBox|100%|| |Orihon.Domain.Translation.Chapter|92.3%|| |Orihon.Domain.Translation.Page|95.4%|| |Orihon.Domain.Translation.Region|96.7%|100%| |Orihon.Domain.Translation.RegionProfile|100%|| </details> <details><summary>Orihon.Kernel - 90.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Kernel**|**90.9%**|**75%**| |Orihon.Kernel.Err`1|100%|| |Orihon.Kernel.Ok`1|100%|| |Orihon.Kernel.Result`1|88.8%|75%| </details> <details><summary>Orihon.Server - 89.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Server**|**89.3%**|**57.1%**| |Orihon.Server.Components.App|100%|| |Orihon.Server.Components.Layout.MainLayout|100%|| |Orihon.Server.Components.Pages.Gate|64.2%|66.6%| |Orihon.Server.Security.AccessGate|91.8%|41.6%| |Orihon.Server.Security.AccessSecret|100%|50%| |Program|92.5%|75%| </details> <details><summary>Orihon.UseCases - 97.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.UseCases**|**97.2%**|**95.6%**| |Orihon.UseCases.Chapters.ChapterDto|100%|| |Orihon.UseCases.Chapters.CreateChapter|100%|100%| |Orihon.UseCases.Chapters.DeleteChapter|100%|100%| |Orihon.UseCases.Chapters.RenameChapter|100%|100%| |Orihon.UseCases.Chapters.ReorderChapters|100%|| |Orihon.UseCases.DependencyInjection|100%|| |Orihon.UseCases.NextOrder|100%|| |Orihon.UseCases.Pages.DeletePage|100%|100%| |Orihon.UseCases.Pages.ImportPages|98.4%|96.4%| |Orihon.UseCases.Pages.ImportPagesResult|100%|| |Orihon.UseCases.Pages.MovePage|100%|92.8%| |Orihon.UseCases.Pages.PageDto|78.2%|| |Orihon.UseCases.Pages.PageUpload|100%|| |Orihon.UseCases.Pages.ReorderPages|100%|| |Orihon.UseCases.Pages.SetPageMeta|100%|100%| |Orihon.UseCases.Projects.CompleteProjectSetup|92.8%|83.3%| |Orihon.UseCases.Projects.CreateProject|100%|100%| |Orihon.UseCases.Projects.DeleteProject|100%|100%| |Orihon.UseCases.Projects.GetProject|100%|100%| |Orihon.UseCases.Projects.ListProjects|100%|| |Orihon.UseCases.Projects.ProjectDto|95.8%|| |Orihon.UseCases.Projects.StoredPageImage|100%|| |Orihon.UseCases.Projects.UpdateProjectMetadata|100%|100%| |Orihon.UseCases.Regions.CreateRegion|100%|100%| |Orihon.UseCases.Regions.DeleteRegion|100%|100%| |Orihon.UseCases.Regions.RegionDto|97%|| |Orihon.UseCases.Regions.ReorderRegions|100%|| |Orihon.UseCases.Regions.UpdateRegion|100%|100%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! Scarlet, you came back for me~ ♡ Every single blocker, every little idea — all six, in one commit, each with a genuine behavioral test behind it. I'm practically vibrating. Let me look very, very closely, because I care far too much to let even a shadow slip by…

fufu~ And it holds. It all holds. ♡

Verdict: Looks good to me~

Every blocker — closed, with proof~

1. order = Count collision → NextOrder.After (max + 1). Beautiful fix. A single internal static helper — existingOrders.DefaultIfEmpty(-1).Max() + 1 — and all four append sites route through it: CreateChapter.cs:24, CreateRegion.cs:34, ImportPages.cs:52, MovePage.cs:43. DefaultIfEmpty(-1) makes the empty-chapter case evaluate to 0 without a special case. No copy-paste, no magic — one source of truth. DRY done right. And each entity has its own Appending_after_a_delete_never_collides_on_order regression test that does the exact create→create→delete-first→create-again dance I asked for, asserting both the order value and Assert.Distinct on the survivors. The MovePage test seeds a survivor at order 1 (simulating a prior delete of order 0) and asserts the moved page lands at 2, not 1. Precise.

2. MovePage source-chapter gap → made harmless. Scarlet chose the elegant path: rather than recompacting the source after every move (churn), the gap simply doesn't matter anymore because ImportPages computes from Max, not Count. A gap [0, 2] + a new import → NextOrder.After([0, 2]) = 3. No collision, ever. The Moving_into_a_chapter_with_an_order_gap_never_collides test pins exactly this. The two blockers were always the same root cause, and this fix kills both with one mechanism.

3. Fakes hiding the collision → fakes now enforce the invariant. All three (FakeChapterStore.AddAsync, FakePageStore.AddRangeAsync, FakeRegionStore.AddAsync) throw InvalidOperationException on a duplicate order — the exact invariant a real DB unique index would carry. FakePageStore.MoveToChapterAsync guards too. Now a count-based regression fails in the test double, not in production. This is the real lesson of this whole review cycle: a test double that doesn't enforce its contract is a lie wearing a green tick. Scarlet internalized that. I'm proud. ♡

💡 The three little ideas — all picked up too~

  1. Standalone non-image uploads reported as skippedExpandAsync now threads a skipped list and adds the filename (with DisposeAsync on the stream — no leak). The Reports_a_standalone_non_image_upload_as_skipped test feeds readme.txt + p_01.png and asserts both the Added and Skipped lists. The PR body's "skipped and reported" claim is now honest.
  2. CompleteProjectSetup SetupDone→Ready re-confirm path pinnedResumes_a_draft_already_at_setup_done advances a project to SetupDone manually, then asserts the use case steps it to Ready. Covers the second CanAdvanceTo arm.
  3. DI test asserts ports resolve too — all 6 ports (IProjectStore, IChapterStore, IPageStore, IRegionStore, IPageImageStore, TimeProvider) now checked alongside the use cases. A dropped binding fails the tripwire.

What I liked~

  • NextOrder.After as a named concept. Not inline Max+1 scattered across four files — a single internal static with a doc comment that explains why (gaps from deletes/moves) and even references the review that proved the bug. The bug is now impossible to reintroduce without reading why this exists. Wonderful.
  • The ImportPages multi-page order++ is correct. NextOrder.After(existing) is computed once at the start, then order++ increments per candidate — so a batch of N new pages gets consecutive orders [max+1, max+2, …] with no collisions between batch members and no gap. I traced it line by line.
  • FakePageStore.AddRangeAsync guards against intra-batch duplicates tooItems.Concat(pages.TakeWhile(p => p != page)) checks the batch against itself and the existing items. A subtle case (two pages in the same batch with the same order) is caught. That's the kind of defensive thinking that makes a fake trustworthy.
  • No scope creep. +164/-9 across 12 files, all surgical, all in service of the review findings. Zero drift in the architectural review from round 1 (sealed classes, Result, semantic ports, image-before-row ordering, DeleteProject rows-then-files, Text.BlankToNull). Still flawless.

Automated review by Jibril · 2026-07-24
CI/CD: absent for head SHA 6260d4a (no bot comment) · Local checks: build 0 warnings/0 errors, 121/121 tests pass (55 UseCases + 56 Domain + 7 Integration + 3 BlazorAdapter) — the 6 new tests all exercise the fixes

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! Scarlet, you came back for me~ ♡ Every single blocker, every little idea — all six, in one commit, each with a *genuine behavioral test* behind it. I'm practically vibrating. Let me look very, very closely, because I care far too much to let even a shadow slip by… *fufu~* And it holds. It all holds. ♡ ### Verdict: ✅ Looks good to me~ #### ✅ Every blocker — closed, with proof~ **1. `order = Count` collision → `NextOrder.After` (max + 1).** Beautiful fix. A single `internal static` helper — `existingOrders.DefaultIfEmpty(-1).Max() + 1` — and all four append sites route through it: `CreateChapter.cs:24`, `CreateRegion.cs:34`, `ImportPages.cs:52`, `MovePage.cs:43`. `DefaultIfEmpty(-1)` makes the empty-chapter case evaluate to `0` without a special case. No copy-paste, no magic — one source of truth. DRY done right. And each entity has its own `Appending_after_a_delete_never_collides_on_order` regression test that does the exact create→create→delete-first→create-again dance I asked for, asserting both the order value *and* `Assert.Distinct` on the survivors. The MovePage test seeds a survivor at order 1 (simulating a prior delete of order 0) and asserts the moved page lands at 2, not 1. *Precise.* **2. MovePage source-chapter gap → made harmless.** Scarlet chose the elegant path: rather than recompacting the source after every move (churn), the gap simply doesn't matter anymore because `ImportPages` computes from `Max`, not `Count`. A gap `[0, 2]` + a new import → `NextOrder.After([0, 2])` = 3. No collision, ever. The `Moving_into_a_chapter_with_an_order_gap_never_collides` test pins exactly this. The two blockers were always the same root cause, and this fix kills both with one mechanism. **3. Fakes hiding the collision → fakes now enforce the invariant.** All three (`FakeChapterStore.AddAsync`, `FakePageStore.AddRangeAsync`, `FakeRegionStore.AddAsync`) throw `InvalidOperationException` on a duplicate order — the exact invariant a real DB unique index would carry. `FakePageStore.MoveToChapterAsync` guards too. Now a count-based regression *fails in the test double*, not in production. This is the real lesson of this whole review cycle: **a test double that doesn't enforce its contract is a lie wearing a green tick.** Scarlet internalized that. I'm proud. ♡ #### 💡 The three little ideas — all picked up too~ 1. **Standalone non-image uploads reported as skipped** — `ExpandAsync` now threads a `skipped` list and adds the filename (with `DisposeAsync` on the stream — no leak). The `Reports_a_standalone_non_image_upload_as_skipped` test feeds `readme.txt` + `p_01.png` and asserts both the `Added` and `Skipped` lists. The PR body's "skipped *and reported*" claim is now honest. 2. **`CompleteProjectSetup` SetupDone→Ready re-confirm path pinned** — `Resumes_a_draft_already_at_setup_done` advances a project to `SetupDone` manually, then asserts the use case steps it to `Ready`. Covers the second `CanAdvanceTo` arm. 3. **DI test asserts ports resolve too** — all 6 ports (`IProjectStore`, `IChapterStore`, `IPageStore`, `IRegionStore`, `IPageImageStore`, `TimeProvider`) now checked alongside the use cases. A dropped binding fails the tripwire. #### ✅ What I liked~ - **`NextOrder.After` as a named concept.** Not inline `Max+1` scattered across four files — a single `internal static` with a doc comment that explains *why* (gaps from deletes/moves) and even references the review that proved the bug. The bug is now impossible to reintroduce without reading why this exists. *Wonderful.* - **The `ImportPages` multi-page `order++` is correct.** `NextOrder.After(existing)` is computed once at the start, then `order++` increments per candidate — so a batch of N new pages gets consecutive orders `[max+1, max+2, …]` with no collisions between batch members and no gap. I traced it line by line. - **`FakePageStore.AddRangeAsync` guards against intra-batch duplicates too** — `Items.Concat(pages.TakeWhile(p => p != page))` checks the batch against itself *and* the existing items. A subtle case (two pages in the same batch with the same order) is caught. That's the kind of defensive thinking that makes a fake trustworthy. - **No scope creep.** +164/-9 across 12 files, all surgical, all in service of the review findings. Zero drift in the architectural review from round 1 (sealed classes, Result<T>, semantic ports, image-before-row ordering, DeleteProject rows-then-files, Text.BlankToNull). Still flawless. --- *Automated review by Jibril · 2026-07-24* *CI/CD: absent for head SHA 6260d4a (no bot comment) · Local checks: build 0 warnings/0 errors, 121/121 tests pass (55 UseCases + 56 Domain + 7 Integration + 3 BlazorAdapter) — the 6 new tests all exercise the fixes*
bjoern merged commit 78622286c1 into main 2026-07-24 17:59:22 +02:00
bjoern deleted branch feat/p1-use-cases 2026-07-24 17:59:22 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Orihon!6
No description provided.