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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user