feat: Phase 1 · 2/7 — projects/chapters/pages/regions use cases #6
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/p1-use-cases"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
Result<T>; ports (I…Store,IPageImageStore) defined beside their consumers; DTOs withFrom/ToProfile(round-trip tested). These are the same classes the agents' tools will drive later — one write path (ADR 0022).named), list, get, metadata profile write,CompleteProjectSetupsteppingsetup_done → ready(blocked before images — ADR 0020), hard delete (rows cascade, then the on-disk tree).ImportPages— filename order, non-images and zero-length entries skipped and reported, re-uploads deduped, and the first landed image advances anameddraft toimages_uploaded; reorder/move/delete/set-meta (move fails cleanly on a file-name collision in the target chapter, image moves before the row).p{n}r{m}stable labels assigned by parse-max — a deleted number is never reused (ADR 0012); profile apply, reorder (labels untouched), delete.Text.BlankToNullreplaces the three duplicatedBlank()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 needIBibleStore.🤖 Generated with Claude Code
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>🔮 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.BlankToNullconsolidating 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 = Countproduces 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:
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.CountMove 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, notCount. For the store-backed paths (CreateChapter), add aMaxOrderAsynctoIChapterStore(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.
MovePageleaves gaps in the SOURCE chapter's order — thenImportPagescollides on the gap.MovePage.cs:42-43moves the page totargetPages.Countin the target chapter but never recompacts the source chapter. The source now has a gap (e.g.,[0, 2]). The very nextImportPagesto that chapter doesorder = existing.Count(= 1), landing between the survivors — fine by luck — but combined with blocker #1'sCountsemantics, 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 aList<>and never enforces unique orders.CountAsync/existing.Countreturns 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_numbertest already does exactly this for the label — the order needs the same treatment). The fakes should ideally also assert order uniqueness onAddAsync/AddRangeAsync, so a regression fails in tests rather than in production.💡 Little ideas (non-blocking)~
ImportPagesExpandAsyncsilently drops standalone non-image uploads (e.g. a.txtuploaded directly) — they're filtered byHasImageExtensionandcontinued without being added toSkipped. 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.CompleteProjectSetupTestsdoesn't cover the project-starting-at-SetupDonepath (re-confirm then advance). The logic handles it correctly (I traced it), but a test would pin the re-confirm arm.DependencyInjectionTestsis 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~
ApplyAsync,AdvanceSetupAsync) that hide EF tracking behind intent-revealing names. Mutation never leaks a tracked entity. Wonderful.CreateRegion.NextLabelusing parse-max for the suffix while keeping order separate — labels never reuse a number, orders do. Two different invariants, two different mechanisms. TheA_label_that_does_not_match_the_scheme_counts_as_zerodefensive test is exactly right.MovePagemoves 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.From/ToProfileround-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)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 sharedNextOrder.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.CreateChapterreadsListAsyncinstead ofCountAsync(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 acreate → delete → create-againregression 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 onExpandAsyncsays so), thesetup_doneresume arm is pinned byResumes_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
Summary
Summary
Coverage
Orihon.BlazorAdapter - 100%
Orihon.Domain - 94.4%
Orihon.Kernel - 90.9%
Orihon.Server - 89.3%
Orihon.UseCases - 97.2%
🔮 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 = Countcollision →NextOrder.After(max + 1). Beautiful fix. A singleinternal statichelper —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 to0without a special case. No copy-paste, no magic — one source of truth. DRY done right. And each entity has its ownAppending_after_a_delete_never_collides_on_orderregression test that does the exact create→create→delete-first→create-again dance I asked for, asserting both the order value andAssert.Distincton 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
ImportPagescomputes fromMax, notCount. A gap[0, 2]+ a new import →NextOrder.After([0, 2])= 3. No collision, ever. TheMoving_into_a_chapter_with_an_order_gap_never_collidestest 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) throwInvalidOperationExceptionon a duplicate order — the exact invariant a real DB unique index would carry.FakePageStore.MoveToChapterAsyncguards 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~
ExpandAsyncnow threads askippedlist and adds the filename (withDisposeAsyncon the stream — no leak). TheReports_a_standalone_non_image_upload_as_skippedtest feedsreadme.txt+p_01.pngand asserts both theAddedandSkippedlists. The PR body's "skipped and reported" claim is now honest.CompleteProjectSetupSetupDone→Ready re-confirm path pinned —Resumes_a_draft_already_at_setup_doneadvances a project toSetupDonemanually, then asserts the use case steps it toReady. Covers the secondCanAdvanceToarm.IProjectStore,IChapterStore,IPageStore,IRegionStore,IPageImageStore,TimeProvider) now checked alongside the use cases. A dropped binding fails the tripwire.✅ What I liked~
NextOrder.Afteras a named concept. Not inlineMax+1scattered across four files — a singleinternal staticwith 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.ImportPagesmulti-pageorder++is correct.NextOrder.After(existing)is computed once at the start, thenorder++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.AddRangeAsyncguards 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.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 fixesforgejo-actions referenced this pull request2026-07-31 07:42:38 +02:00