PlaceForm.IsDirty misjudges two cases #125

Merged
rob merged 2 commits from fix/place-form-is-dirty into main 2026-08-11 21:02:37 +00:00
Owner

Task 186. IsDirty decides whether dismissing the add/edit panel confirms before discarding. Wrong in two directions:

  • Add mode ignored the group selector entirely — picking a different group then dismissing lost that choice with no confirmation. Now compared against _seededSelectedGroupId, the auto-selected default, not null — an untouched default still reads clean, only an actual change counts. Reverses ADR-0102's original exclusion; ADR-0110 records why and partially supersedes it.
  • _description starts null; clearing a typed textarea binds "", and "" != null read as dirty for an already-undone change. Both sides now go through a null/empty-normalizing helper.

Decided deliberately, not left to fall out of the implementation: a coordinate reseed on a second map click (PR #120, ADR-0105's own follow-up) stays dirty, the same way the very first seed already does — IsDirty's coordinate comparison is unchanged, and its baseline is always null in the only mode a reseed happens in, so there's nothing for a reseed to reset to. A test locks this in.

Both fixes mutation-tested: reverting each independently reddens its own test, confirmed then restored.

Checked, not fixed, per the ticket's own scope limit: the same null-vs-"" question exists in the save path — SubmitAsync sends _description unnormalized, and nothing between there and description varchar(2000) NULL converts "" to null, despite both contracts documenting null as what clears it. Flagged as a comment on task 186; needs its own ticket.

Full suite (Domain/Contracts/Infrastructure/Api/Architecture/WebUI, 1706 tests) and coverage ratchet green (WebUI 87.9% → 90.1%), dotnet format --verify-no-changes clean, all on the pinned SDK.

Task 186. `IsDirty` decides whether dismissing the add/edit panel confirms before discarding. Wrong in two directions: - Add mode ignored the group selector entirely — picking a different group then dismissing lost that choice with no confirmation. Now compared against `_seededSelectedGroupId`, the auto-selected default, not `null` — an untouched default still reads clean, only an actual change counts. Reverses ADR-0102's original exclusion; ADR-0110 records why and partially supersedes it. - `_description` starts `null`; clearing a typed textarea binds `""`, and `"" != null` read as dirty for an already-undone change. Both sides now go through a null/empty-normalizing helper. Decided deliberately, not left to fall out of the implementation: a coordinate reseed on a second map click (PR #120, ADR-0105's own follow-up) stays dirty, the same way the very first seed already does — `IsDirty`'s coordinate comparison is unchanged, and its baseline is always `null` in the only mode a reseed happens in, so there's nothing for a reseed to reset to. A test locks this in. Both fixes mutation-tested: reverting each independently reddens its own test, confirmed then restored. Checked, not fixed, per the ticket's own scope limit: the same null-vs-`""` question exists in the save path — `SubmitAsync` sends `_description` unnormalized, and nothing between there and `description varchar(2000) NULL` converts `""` to `null`, despite both contracts documenting `null` as what clears it. Flagged as a comment on task 186; needs its own ticket. Full suite (Domain/Contracts/Infrastructure/Api/Architecture/WebUI, 1706 tests) and coverage ratchet green (WebUI 87.9% → 90.1%), `dotnet format --verify-no-changes` clean, all on the pinned SDK.
Count a changed group selection and normalise null/empty in PlaceForm.IsDirty
All checks were successful
CI / build (pull_request) Successful in 3m0s
CI / e2e (pull_request) Successful in 1m48s
45f361d6e1
Merge remote-tracking branch 'origin/main' into HEAD
All checks were successful
CI / build (pull_request) Successful in 3m18s
CI / container-images (pull_request) Has been skipped
CI / e2e (pull_request) Successful in 1m46s
cc396ce82c
# Conflicts:
#	docs/adr/README.md
rob left a comment

Verdict: mergeable

Reviewed substantive commit 45f361d, then re-fetched: head is now cc396ce, mergeable: true. Confirmed the delta myself (git diff 45f361d cc396ce -- src/PlaceMark.WebUI/Places/PlaceForm.razor.cs tests/PlaceMark.WebUI.Tests/Places/PlaceFormTests.cs docs/adr/0102-*.md docs/adr/0110-*.md is empty) — only main's own commits (#121, #123) plus the regenerated index landed. Rebuilt and re-ran the relevant suites at cc396ce itself, not just 45f361d: PlaceFormTests 39/39, AdrIndexTests 119/119, both genuinely green. This review is against cc396ce.

Both fixes mutation-tested independently, not taken on trust. Removed the (Place is null && _selectedGroupId != _seededSelectedGroupId) clause — PlaceForm_CreateModeGroupChanged_IsDirty and PlaceForm_CreateModeGroupChangedThenRestored_IsNotDirtyAgain both reddened, exactly as claimed. Restored, reverted NormalizeDescription back to a raw _description != Place?.Description comparison — PlaceForm_CreateModeDescriptionTypedThenCleared_IsNotDirty and PlaceForm_EditModeDescriptionWasNullTypedThenCleared_IsNotDirty both reddened. Restored. Neither guard is vacuous.

Reseed-stays-dirty reasoning verified by tracing the code, not by the test's existence. OnParametersSet only reseeds _latitude/_longitude if (Place is null && ...); IsDirty's coordinate clause compares against Place?.Latitude/Place?.Longitude, which is null throughout add mode. Since a reseed only ever replaces one non-null coordinate with another (never resets to null), and the comparison baseline never moves off null in this mode, there is no code path by which a reseed could flip the clause back to false. The claim holds structurally, not just empirically.

ADR-0110/ADR-0102 supersession mechanism, checked as the first real application of ADR-0107's lesson: both directions present (0102's Partially superseded by names 0110; 0110's Partially supersedes names 0102), Status text matches the established convention exactly (Accepted (partly superseded by 0110), same form as the pre-existing Accepted (partly superseded by 0017)). Didn't just read this — deleted 0102's own Partially superseded by field and Status annotation to simulate a one-sided edit: AdrFiles_Supersession_AgreesWithTheNamedRecordInBothDirections reddened immediately, naming both the missing reverse field and the missing Status text. Restored, green again. CrossRecordSupersessionGaps does what it's for.

Save-path scope boundary confirmed genuinely untouched. Diffed SubmitAsync's body against the pre-PR version directly — only a doc comment was added above the method; no line inside it changed. The unnormalized _description send is real (checked against CreatePlaceRequest/UpdatePlaceRequest's own doc comments) and correctly left alone rather than half-fixed.

Nothing else to flag.

Verdict: mergeable Reviewed substantive commit 45f361d, then re-fetched: head is now cc396ce, `mergeable: true`. Confirmed the delta myself (`git diff 45f361d cc396ce -- src/PlaceMark.WebUI/Places/PlaceForm.razor.cs tests/PlaceMark.WebUI.Tests/Places/PlaceFormTests.cs docs/adr/0102-*.md docs/adr/0110-*.md` is empty) — only main's own commits (#121, #123) plus the regenerated index landed. Rebuilt and re-ran the relevant suites at cc396ce itself, not just 45f361d: `PlaceFormTests` 39/39, `AdrIndexTests` 119/119, both genuinely green. This review is against cc396ce. **Both fixes mutation-tested independently, not taken on trust.** Removed the `(Place is null && _selectedGroupId != _seededSelectedGroupId)` clause — `PlaceForm_CreateModeGroupChanged_IsDirty` and `PlaceForm_CreateModeGroupChangedThenRestored_IsNotDirtyAgain` both reddened, exactly as claimed. Restored, reverted `NormalizeDescription` back to a raw `_description != Place?.Description` comparison — `PlaceForm_CreateModeDescriptionTypedThenCleared_IsNotDirty` and `PlaceForm_EditModeDescriptionWasNullTypedThenCleared_IsNotDirty` both reddened. Restored. Neither guard is vacuous. **Reseed-stays-dirty reasoning verified by tracing the code, not by the test's existence.** `OnParametersSet` only reseeds `_latitude`/`_longitude` `if (Place is null && ...)`; `IsDirty`'s coordinate clause compares against `Place?.Latitude`/`Place?.Longitude`, which is `null` throughout add mode. Since a reseed only ever replaces one non-null coordinate with another (never resets to `null`), and the comparison baseline never moves off `null` in this mode, there is no code path by which a reseed could flip the clause back to false. The claim holds structurally, not just empirically. **ADR-0110/ADR-0102 supersession mechanism, checked as the first real application of ADR-0107's lesson:** both directions present (0102's `Partially superseded by` names 0110; 0110's `Partially supersedes` names 0102), Status text matches the established convention exactly (`Accepted (partly superseded by 0110)`, same form as the pre-existing `Accepted (partly superseded by 0017)`). Didn't just read this — deleted 0102's own `Partially superseded by` field and Status annotation to simulate a one-sided edit: `AdrFiles_Supersession_AgreesWithTheNamedRecordInBothDirections` reddened immediately, naming both the missing reverse field and the missing Status text. Restored, green again. `CrossRecordSupersessionGaps` does what it's for. **Save-path scope boundary confirmed genuinely untouched.** Diffed `SubmitAsync`'s body against the pre-PR version directly — only a doc comment was added above the method; no line inside it changed. The unnormalized `_description` send is real (checked against `CreatePlaceRequest`/`UpdatePlaceRequest`'s own doc comments) and correctly left alone rather than half-fixed. Nothing else to flag.
rob merged commit f6c9c63185 into main 2026-08-11 21:02:37 +00:00
rob deleted branch fix/place-form-is-dirty 2026-08-11 21:02:37 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
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
rob/PlaceMark!125
No description provided.