recording save dialog: Cancel discards the footage instead of saving the default

The creator reported that clicking Cancel in the post-recording save prompt still
saved the file under the default name: "cancel should cancel the save option,
discarding the recording" — Enter is what accepts the default.

The modal was always correct (Enter -> DialogResult=true, Cancel/Escape/X -> false).
The bug was in the caller: FinalizeRecordingAsync only overwrote `stem` when the
dialog returned true and then ran File.Move UNCONDITIONALLY, so a falsy result fell
straight through into the save. The nullable stem made "I don't want this" look
like "I accept the default".

Extracted the decision into internal MainViewModel.CompleteRecordingSave so it is
testable against real files without a frame pump:
  creatorSaved == false (Cancel/Escape/X) -> delete the temp file, leave the
      videos folder empty; a failed delete returns DiscardFailed and the toast
      names the file + folder (a "discarded" recording still on disk is worse
      than no report).
  creatorSaved == true (Save, or Enter on the pre-filled default) -> File.Move.
      A blank box still means "keep the auto name", but only on an explicit Save.

Test: ytLive.Tests/RecordingSaveDialogTests.cs — 5 facts over real temp files. The
headline asserts Cancel leaves the directory EMPTY, not merely "the stem is
unchanged"; a modal's own test cannot cover the decision it feeds.

Full suite 362/362 (the RealMouseDrag flake passed this run).
This commit is contained in:
2026-09-27 09:25:21 -07:00
parent de81fa3c5d
commit e3e69d941d
7 changed files with 324 additions and 19 deletions
+47
View File
@@ -1047,3 +1047,50 @@ Release binary for it — and it was absent from the *Debug* binary too, because
at compile time and never reach metadata**. The control (run it against Debug as well) is what
exposed that. Grep for a **field/type** (`InstanceVariable`) or use reflection; never grep for an
inlined literal, and always run the check against the build you expect it to FAIL in.
## 2026-09-26 — A falsy `ShowDialog()` fell through into the side effect: "Cancel" that saved
The recording save prompt let the creator name the file. It always had a **Cancel** button, and
Cancel always *appeared* to work — the dialog closed, the toast was skipped, nothing visibly went
wrong. The footage was saved anyway, under the default name.
```csharp
// WRONG — and it type-checked, read fine, and looked correct
string stem = autoStem;
var dialog = new RenameRecordingDialog(autoStem);
if (dialog.ShowDialog() == true && !string.IsNullOrEmpty(dialog.ResultFileName))
stem = dialog.ResultFileName;
var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4"));
File.Move(startPath, finalPath); // <-- runs even when the creator hit Cancel
```
**The trap:** `ShowDialog()` returning `false` was collapsed into the *default* branch instead of
being treated as a distinct outcome. "Not what I typed" and "I don't want this" are different
answers, and the nullable stem made the first one look like a legitimate value of the second. The
default value of the accumulator absorbed the cancel.
**The rule, and the grep-able form of it:** when a modal's result gates a side effect, every branch
is explicit and the side effect sits **inside** the accepting branch:
```csharp
if (!dialog.ShowDialog() == true) { DoTheOppositeThing(); return; } // read the FALSE case first
DoTheThing(); // the effect is not a fall-through
```
A `DialogResult`/return value that only ever *modifies* a later value (rather than *permitting* the
effect) is the smell. `null` from a dialog is the same trap wearing a nullable.
**Why it survived so long:** the side effect is in a *different method* from the modal, and the modal
had its own passing test (`RenameRecordingDialogSizingTests`, which only checks layout). The test
suite was green because the bug was never in the tested unit. **A modal's own test proves nothing
about the decision the modal feeds** — cover the commit-or-discard decision itself, against real
files (`ytLive.Tests/RecordingSaveDialogTests.cs` drives the extracted
`MainViewModel.CompleteRecordingSave` and asserts Cancel leaves the directory EMPTY, not just "the
stem is unchanged").
**Creator's actual ruling, worth keeping verbatim:** "if I click cancel, that doesn't mean to accept
the default file name and save the file — that's what pressing enter should do. Cancel should cancel
the save option, discarding the recording." A Cancel button on a destructive-and-final dialog means
*discard*, not *accept default*. When a dialog is the last gate before data is committed, ask what
Cancel should destroy — don't infer "keep the default" just because the default already exists.