fix(theater): auto-exit Theater Mode when both visualizer subsystems are disabled

Adds CoerceTheaterMode() to WaveformVisualizerControlState; ToggleLava/ToggleWaveform
call it before NotifyChanged so all observers see consistent state in one Changed cycle.
Covers the dead-end escape route bug (Phase 20 review finding).
This commit is contained in:
daniel-c-harvey
2026-06-20 22:03:39 -04:00
parent fa01b9c8e0
commit 14f3af41e4
3 changed files with 106 additions and 0 deletions
@@ -226,12 +226,14 @@
private void ToggleLava() private void ToggleLava()
{ {
ControlState.LavaEnabled = !ControlState.LavaEnabled; ControlState.LavaEnabled = !ControlState.LavaEnabled;
ControlState.CoerceTheaterMode();
ControlState.NotifyChanged(); ControlState.NotifyChanged();
} }
private void ToggleWaveform() private void ToggleWaveform()
{ {
ControlState.WaveformEnabled = !ControlState.WaveformEnabled; ControlState.WaveformEnabled = !ControlState.WaveformEnabled;
ControlState.CoerceTheaterMode();
ControlState.NotifyChanged(); ControlState.NotifyChanged();
} }
@@ -160,6 +160,19 @@ public sealed class WaveformVisualizerControlState
/// </summary> /// </summary>
public event Action? Changed; public event Action? Changed;
/// <summary>
/// Enforces the Theater-Mode invariant: Theater Mode cannot remain on when both visualizer
/// subsystems are off (there is nothing to go to theater FOR). Call this after mutating
/// <see cref="LavaEnabled"/> or <see cref="WaveformEnabled"/> and before
/// <see cref="NotifyChanged"/> so all observers see a consistent, coerced state in the same
/// <see cref="Changed"/> cycle.
/// </summary>
public void CoerceTheaterMode()
{
if (TheaterMode && !LavaEnabled && !WaveformEnabled)
TheaterMode = false;
}
/// <summary>Raise <see cref="Changed"/>. Called by the controls component after mutating a value.</summary> /// <summary>Raise <see cref="Changed"/>. Called by the controls component after mutating a value.</summary>
public void NotifyChanged() => Changed?.Invoke(); public void NotifyChanged() => Changed?.Invoke();
} }
@@ -0,0 +1,91 @@
using DeepDrftPublic.Client.Services;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the Theater-Mode auto-exit invariant on <see cref="WaveformVisualizerControlState"/>
/// (Phase 20 bug fix): when both subsystems are disabled, <see cref="WaveformVisualizerControlState.CoerceTheaterMode"/>
/// must force <c>TheaterMode = false</c> so observers never see a stranded-theater state.
/// </summary>
[TestFixture]
public class WaveformVisualizerControlStateTests
{
private WaveformVisualizerControlState _state = null!;
[SetUp]
public void SetUp() => _state = new WaveformVisualizerControlState();
// ── CoerceTheaterMode guard ──
// Both off + Theater on → coerce exits theater.
[Test]
public void CoerceTheaterMode_BothOff_TheaterBecomesfalse()
{
_state.TheaterMode = true;
_state.LavaEnabled = false;
_state.WaveformEnabled = false;
_state.CoerceTheaterMode();
Assert.That(_state.TheaterMode, Is.False);
}
// Lava still on → theater is left alone even if waveform is off.
[Test]
public void CoerceTheaterMode_LavaOnWaveformOff_TheaterPreserved()
{
_state.TheaterMode = true;
_state.LavaEnabled = true;
_state.WaveformEnabled = false;
_state.CoerceTheaterMode();
Assert.That(_state.TheaterMode, Is.True);
}
// Waveform still on → theater is left alone even if lava is off.
[Test]
public void CoerceTheaterMode_WaveformOnLavaOff_TheaterPreserved()
{
_state.TheaterMode = true;
_state.LavaEnabled = false;
_state.WaveformEnabled = true;
_state.CoerceTheaterMode();
Assert.That(_state.TheaterMode, Is.True);
}
// Theater already false + both off → no change (no false-positive write).
[Test]
public void CoerceTheaterMode_TheaterAlreadyFalse_NoChange()
{
_state.TheaterMode = false;
_state.LavaEnabled = false;
_state.WaveformEnabled = false;
_state.CoerceTheaterMode();
Assert.That(_state.TheaterMode, Is.False);
}
// ── Changed event fires once with coerced state visible ──
// Verify that after coercion, the Changed notification carries the already-corrected TheaterMode
// value — all observers see a consistent state in the single Changed cycle.
[Test]
public void NotifyChanged_AfterCoerce_ObserverSeesTheaterFalse()
{
_state.TheaterMode = true;
_state.LavaEnabled = false;
_state.WaveformEnabled = false;
bool? observedTheaterMode = null;
_state.Changed += () => observedTheaterMode = _state.TheaterMode;
_state.CoerceTheaterMode();
_state.NotifyChanged();
Assert.That(observedTheaterMode, Is.False);
}
}