TRAX: toggle refreshes the status light on play/pause, terse scrolling error on failure, fixed quiet volume (0.5), ITraxPlayer seam. Deadlocked test fixed via RunAsync (was GetAwaiter().GetResult on the UI thread). 416 pass / 1 known env flake. Refs: https://learn.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/async-programming-model-tap#dont-block-on-async-code (Unit: trax toggle)
This commit is contained in:
@@ -899,12 +899,28 @@
|
|||||||
VerticalAlignment="Stretch"/>
|
VerticalAlignment="Stretch"/>
|
||||||
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="230,0,0,0"
|
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="230,0,0,0"
|
||||||
VerticalAlignment="Stretch"/>
|
VerticalAlignment="Stretch"/>
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
<!-- TRAX error: a terse message that scrolls across the bar
|
||||||
Visibility="{Binding TraxError, Converter={StaticResource NotNullToVis}}"
|
(marquee) while the TRAX dot is red. -->
|
||||||
VerticalAlignment="Center" Margin="4,0">
|
<Canvas x:Name="TraxErrorMarquee" ClipToBounds="True"
|
||||||
<TextBlock Text="{Binding TraxError}" Foreground="#e94560" FontSize="9"
|
Visibility="{Binding TraxError, Converter={StaticResource NotNullToVis}}">
|
||||||
FontWeight="SemiBold" VerticalAlignment="Center"/>
|
<TextBlock x:Name="TraxErrorText" Text="{Binding TraxError}" Foreground="#ff6b81" FontSize="10"
|
||||||
</ScrollViewer>
|
FontWeight="SemiBold" Canvas.Top="0">
|
||||||
|
<TextBlock.RenderTransform>
|
||||||
|
<TranslateTransform x:Name="TraxErrorScroll"/>
|
||||||
|
</TextBlock.RenderTransform>
|
||||||
|
<TextBlock.Triggers>
|
||||||
|
<EventTrigger RoutedEvent="FrameworkElement.Loaded">
|
||||||
|
<BeginStoryboard>
|
||||||
|
<Storyboard RepeatBehavior="Forever">
|
||||||
|
<DoubleAnimation Storyboard.TargetName="TraxErrorScroll"
|
||||||
|
Storyboard.TargetProperty="X"
|
||||||
|
From="288" To="-220" Duration="0:0:7"/>
|
||||||
|
</Storyboard>
|
||||||
|
</BeginStoryboard>
|
||||||
|
</EventTrigger>
|
||||||
|
</TextBlock.Triggers>
|
||||||
|
</TextBlock>
|
||||||
|
</Canvas>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
|
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace ytLive.Services.Audio;
|
||||||
|
|
||||||
|
/// <summary>Seam over the TRAX background-music player (<see cref="MusicPlayer"/>) so the
|
||||||
|
/// toggle/status-light logic is testable without a real audio device.</summary>
|
||||||
|
public interface ITraxPlayer : IDisposable
|
||||||
|
{
|
||||||
|
event Action<Exception?>? PlaybackEnded;
|
||||||
|
string? TrackName { get; }
|
||||||
|
bool IsPlaying { get; }
|
||||||
|
float LocalGain { get; set; }
|
||||||
|
void Load(string path);
|
||||||
|
void Play();
|
||||||
|
void Pause();
|
||||||
|
}
|
||||||
@@ -11,12 +11,17 @@ namespace ytLive.Services.Audio;
|
|||||||
/// The desktop/game bar's gain and mute scale <see cref="LocalGain"/>, so the
|
/// The desktop/game bar's gain and mute scale <see cref="LocalGain"/>, so the
|
||||||
/// creator hears the game-audio controls work on the music too.
|
/// creator hears the game-audio controls work on the music too.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MusicPlayer : IDisposable
|
public sealed class MusicPlayer : ITraxPlayer
|
||||||
{
|
{
|
||||||
/// <summary>The one and only music bed — quiet, no slider (the creator's
|
/// <summary>The one and only music bed — no slider (the creator's voice must stay on
|
||||||
/// voice must stay on top; game/music loudness is the ducker's job). The
|
/// top; that's the ducker's job, −12 dB while they talk). The desktop audio bar scales
|
||||||
/// desktop audio bar scales this via <see cref="LocalGain"/>.</summary>
|
/// this via <see cref="LocalGain"/>.
|
||||||
public const float MusicVolume = 0.20f;
|
/// <para>2026-10-09: was 0.20 (−14 dB) — "even at max volume I can barely discern it".
|
||||||
|
/// The creator's track masters at −11.3 LUFS (typical: −9..−14), so 0.20 put the bed at
|
||||||
|
/// ~−25 LUFS, and ducked under speech ~−37 LUFS: inaudible on stream too, because the
|
||||||
|
/// loopback carries exactly what plays locally. 0.5 (−6 dB) lands a typical master at
|
||||||
|
/// ~−15..−20 LUFS — beside the leveled voice (≈ −17 LUFS), and well under it when ducked.</para></summary>
|
||||||
|
public const float MusicVolume = 0.5f;
|
||||||
|
|
||||||
private MediaFoundationReader? _reader;
|
private MediaFoundationReader? _reader;
|
||||||
private VolumeWaveProvider16? _volume;
|
private VolumeWaveProvider16? _volume;
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ namespace ytLive.ViewModels;
|
|||||||
public partial class MainViewModel
|
public partial class MainViewModel
|
||||||
{
|
{
|
||||||
// TRAX (TASK 9): free background music at a fixed quiet volume, playing to
|
// TRAX (TASK 9): free background music at a fixed quiet volume, playing to
|
||||||
// the default device so it rides the loopback into the stream. The dot is
|
// the default device so it rides the loopback into the stream. The button is a
|
||||||
// red (no track) / yellow (loaded, stopped) / green (playing).
|
// toggle; its dot is green (playing) / red (error — a terse message scrolls in the
|
||||||
private readonly MusicPlayer _musicPlayer = new();
|
// desktop-audio bar) / gray (idle or no track).
|
||||||
|
private readonly ITraxPlayer _musicPlayer = TraxPlayerOverride?.Invoke() ?? new MusicPlayer();
|
||||||
|
|
||||||
|
/// <summary>Test seam: a fake TRAX player (no audio device). Set before constructing.</summary>
|
||||||
|
internal static Func<ITraxPlayer>? TraxPlayerOverride { get; set; }
|
||||||
private Music? _music;
|
private Music? _music;
|
||||||
private bool _musicPlaying;
|
private bool _musicPlaying;
|
||||||
private bool _traxLoadInFlight;
|
private bool _traxLoadInFlight;
|
||||||
@@ -46,13 +50,18 @@ public partial class MainViewModel
|
|||||||
public string? TraxError
|
public string? TraxError
|
||||||
{
|
{
|
||||||
get => _traxError;
|
get => _traxError;
|
||||||
private set => SetProperty(ref _traxError, value);
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _traxError, value))
|
||||||
|
OnPropertyChanged(nameof(TraxDotBrush));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>TRAX status dot: green = playing, gray = idle or no track.</summary>
|
/// <summary>TRAX status dot: green = playing, red = the last load/play failed (the
|
||||||
|
/// message scrolls in the desktop-audio bar), gray = idle or no track.</summary>
|
||||||
public SolidColorBrush TraxDotBrush => _musicPlayer.IsPlaying
|
public SolidColorBrush TraxDotBrush => _musicPlayer.IsPlaying
|
||||||
? BarOnBrush
|
? BarOnBrush
|
||||||
: TraxIdleBrush;
|
: TraxError != null ? BarOffBrush : TraxIdleBrush;
|
||||||
|
|
||||||
public string TraxToolTip => _musicPlayer.TrackName != null
|
public string TraxToolTip => _musicPlayer.TrackName != null
|
||||||
? $"TRAX: {_musicPlayer.TrackName} — left-click {(IsMusicPlaying ? "pauses" : "plays")}, right-click chooses a track"
|
? $"TRAX: {_musicPlayer.TrackName} — left-click {(IsMusicPlaying ? "pauses" : "plays")}, right-click chooses a track"
|
||||||
@@ -82,8 +91,15 @@ public partial class MainViewModel
|
|||||||
if (dialog.ShowDialog() != true)
|
if (dialog.ShowDialog() != true)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_music = new Music { TrackPath = dialog.FileName, IsEnabled = true };
|
_ = SetTraxTrackAsync(dialog.FileName, play: true);
|
||||||
LoadTrax(play: true);
|
}
|
||||||
|
|
||||||
|
/// <summary>Points TRAX at a track and loads it (optionally playing). The picker's path;
|
||||||
|
/// internal so tests can load a track without the file dialog.</summary>
|
||||||
|
internal Task SetTraxTrackAsync(string path, bool play)
|
||||||
|
{
|
||||||
|
_music = new Music { TrackPath = path, IsEnabled = true };
|
||||||
|
return LoadTraxAsync(play);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Probes a path off the UI thread and gives up at <paramref name="budget"/>.
|
/// <summary>Probes a path off the UI thread and gives up at <paramref name="budget"/>.
|
||||||
@@ -128,20 +144,20 @@ public partial class MainViewModel
|
|||||||
UpdateTraxLocalGain();
|
UpdateTraxLocalGain();
|
||||||
music.IsEnabled = true;
|
music.IsEnabled = true;
|
||||||
TraxError = null;
|
TraxError = null;
|
||||||
if (play)
|
|
||||||
_musicPlayer.Play();
|
|
||||||
loaded = true;
|
loaded = true;
|
||||||
|
if (play)
|
||||||
|
PlayTrax();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
AppLog.Write($"TRAX: '{path}' unreachable within {TraxProbeBudget.TotalSeconds:0}s — not loading");
|
AppLog.Write($"TRAX: '{path}' unreachable within {TraxProbeBudget.TotalSeconds:0}s — not loading");
|
||||||
TraxError = $"Can't reach {Path.GetFileName(path)} — is the drive on?";
|
TraxError = "TRAX: can't reach the track";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLog.Write($"TRAX: failed to load '{music.TrackPath}': {ex.Message}");
|
AppLog.Write($"TRAX: failed to load '{music.TrackPath}': {ex.Message}");
|
||||||
TraxError = $"Failed to load: {Path.GetFileName(music.TrackPath)} — {ex.Message}";
|
TraxError = "TRAX: can't load the track";
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -152,16 +168,45 @@ public partial class MainViewModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PlayTrax() => _musicPlayer.Play();
|
// Both toggle halves MUST refresh the UI: the dot and IsMusicPlaying used to update only
|
||||||
|
// on load/playback-ended, so pressing TRAX started the music with the light still off —
|
||||||
|
// and because IsMusicPlaying stayed false, the next press tried to play again instead of
|
||||||
|
// pausing (2026-10-09 bug).
|
||||||
|
private void PlayTrax()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_musicPlayer.Play();
|
||||||
|
TraxError = _musicPlayer.IsPlaying ? null : "TRAX: no audio output";
|
||||||
|
if (TraxError != null) AppLog.Write("TRAX: Play() returned but playback did not start (no output device?)");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"TRAX: play failed: {ex.Message}");
|
||||||
|
TraxError = "TRAX: can't play the track";
|
||||||
|
}
|
||||||
|
RefreshTraxUi();
|
||||||
|
}
|
||||||
|
|
||||||
private void PauseTrax() => _musicPlayer.Pause();
|
private void PauseTrax()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_musicPlayer.Pause();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"TRAX: pause failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
RefreshTraxUi();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnTraxPlaybackEnded(Exception? ex)
|
private void OnTraxPlaybackEnded(Exception? ex)
|
||||||
{
|
{
|
||||||
if (ex != null)
|
if (ex != null)
|
||||||
{
|
{
|
||||||
AppLog.Write($"TRAX: playback error: {ex.Message}");
|
AppLog.Write($"TRAX: playback error: {ex.Message}");
|
||||||
TraxError = $"Playback error: {ex.Message}";
|
TraxError = "TRAX: playback stopped";
|
||||||
}
|
}
|
||||||
RefreshTraxUi();
|
RefreshTraxUi();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ C# / WPF (.NET 8) following MVVM:
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, **Socials (`SocialService` enum + `SocialEntry`/`SocialsConfig` + `SocialServiceIcons`) — the social bar** |
|
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, **Socials (`SocialService` enum + `SocialEntry`/`SocialsConfig` + `SocialServiceIcons`) — the social bar** |
|
||||||
| `ViewModels/` | MainViewModel — `public partial class`, one file per functional area (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License, Recording — split complete, see `ViewModels/index.md`); **Chat.cs is a thin delegating facade over `Services/ChatOverlayLayer.cs` (Commit G, first true decomposition), and now also over `Services/AlertOverlayLayer.cs` (TASK 43 — the native alert box, same true-component pattern)**; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** |
|
| `ViewModels/` | MainViewModel — `public partial class`, one file per functional area (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License, Recording — split complete, see `ViewModels/index.md`); **Chat.cs is a thin delegating facade over `Services/ChatOverlayLayer.cs` (Commit G, first true decomposition), and now also over `Services/AlertOverlayLayer.cs` (TASK 43 — the native alert box, same true-component pattern)**; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** |
|
||||||
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **media source: `IMediaFrameSource` + `MediaVideoSource` (spawns ffmpeg `rawvideo` BGRA decode) + `MediaVideoSourceManager` (refcount-by-path session owner) + pure `RawVideoFrameReader` + `IDecodeProcess`/`FfmpegDecodeProcess` process seam (binary-stdout mirror of `IEncoderProcess`; see "Media source")**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **alerts: `ChatOverlayLayer` + `AlertOverlayLayer`/`AlertRenderer` (the native celebration-zone overlay, TASK 43 — see "Native events & alerts")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler`/`AudioSyncDelay` + `MusicPlayer` + `IAudioPipeWriter`/`NamedPipeAudioWriter` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")**, **notifications: `INotificationService` seam (`AppNotificationSeverity` Info/Success/Warning/Error) + `NotificationService` (Notification.Wpf toasts, see "Toast notifications")** |
|
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **media source: `IMediaFrameSource` + `MediaVideoSource` (spawns ffmpeg `rawvideo` BGRA decode) + `MediaVideoSourceManager` (refcount-by-path session owner) + pure `RawVideoFrameReader` + `IDecodeProcess`/`FfmpegDecodeProcess` process seam (binary-stdout mirror of `IEncoderProcess`; see "Media source")**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **alerts: `ChatOverlayLayer` + `AlertOverlayLayer`/`AlertRenderer` (the native celebration-zone overlay, TASK 43 — see "Native events & alerts")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler`/`AudioSyncDelay` + `MusicPlayer` + `ITraxPlayer` (TRAX toggle seam — FakePlayer in tests; the dot is green/red/gray and the error scrolls terse in the desktop-audio bar; MusicVolume fixed 0.5) + `IAudioPipeWriter`/`NamedPipeAudioWriter` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")**, **notifications: `INotificationService` seam (`AppNotificationSeverity` Info/Success/Warning/Error) + `NotificationService` (Notification.Wpf toasts, see "Toast notifications")** |
|
||||||
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, **BuildStamp (wordmark release counter `#N`, +1 per commit, generated by `GenerateBuildStamp` in ytLive.csproj via git rev-list; the per-build GUID now logs to startup.log only — 2026-09-04)**, visibility converters |
|
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, **BuildStamp (wordmark release counter `#N`, +1 per commit, generated by `GenerateBuildStamp` in ytLive.csproj via git rev-list; the per-build GUID now logs to startup.log only — 2026-09-04)**, visibility converters |
|
||||||
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
|
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
|
||||||
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview + live controls below), left (scenes/sources), right (chat), bottom (gear + stream stats + resolution) |
|
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview + live controls below), left (scenes/sources), right (chat), bottom (gear + stream stats + resolution) |
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Xunit;
|
||||||
|
using ytLive.Services.Audio;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
/// <summary>2026-10-09 bug: pressing TRAX started the music but the status light stayed off —
|
||||||
|
/// and the toggle couldn't pause, because the playing state was never refreshed after Play.
|
||||||
|
/// TRAX is a toggle: the light is green while playing, red with a terse scrolling message on
|
||||||
|
/// failure, gray when idle.</summary>
|
||||||
|
[Collection("RealApp")]
|
||||||
|
public sealed class TraxToggleTests
|
||||||
|
{
|
||||||
|
private sealed class FakePlayer : ITraxPlayer
|
||||||
|
{
|
||||||
|
public bool Starts = true;
|
||||||
|
public event Action<Exception?>? PlaybackEnded;
|
||||||
|
public string? TrackName { get; private set; }
|
||||||
|
public bool IsPlaying { get; private set; }
|
||||||
|
public float LocalGain { get; set; } = 1f;
|
||||||
|
public void Load(string path) => TrackName = Path.GetFileNameWithoutExtension(path);
|
||||||
|
public void Play() => IsPlaying = Starts;
|
||||||
|
public void Pause() => IsPlaying = false;
|
||||||
|
public void Dispose() { }
|
||||||
|
public void Fail() { IsPlaying = false; PlaybackEnded?.Invoke(new IOException("device lost")); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly RealAppHost _app;
|
||||||
|
public TraxToggleTests(RealAppHost app) => _app = app;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Trax_Toggle_Lights_Green_While_Playing_And_Red_With_A_Terse_Error()
|
||||||
|
{
|
||||||
|
await _app.RunAsync(async () =>
|
||||||
|
{
|
||||||
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
|
||||||
|
var track = Path.Combine(Path.GetTempPath(), $"trax-{Guid.NewGuid():N}.mp3");
|
||||||
|
File.WriteAllBytes(track, new byte[16]);
|
||||||
|
var player = new FakePlayer();
|
||||||
|
MainViewModel.LayoutPathOverride = tempDb;
|
||||||
|
MainViewModel.TraxPlayerOverride = () => player;
|
||||||
|
var window = new MainWindow();
|
||||||
|
var vm = (MainViewModel)window.DataContext;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await vm.SetTraxTrackAsync(track, play: false);
|
||||||
|
Assert.False(vm.IsMusicPlaying);
|
||||||
|
var idle = vm.TraxDotBrush.Color;
|
||||||
|
|
||||||
|
vm.TraxLeftClick(); // press: plays → light on
|
||||||
|
Assert.True(vm.IsMusicPlaying);
|
||||||
|
var playing = vm.TraxDotBrush.Color;
|
||||||
|
Assert.NotEqual(idle, playing);
|
||||||
|
Assert.Null(vm.TraxError);
|
||||||
|
|
||||||
|
vm.TraxLeftClick(); // press again: pauses → light off
|
||||||
|
Assert.False(vm.IsMusicPlaying);
|
||||||
|
Assert.Equal(idle, vm.TraxDotBrush.Color);
|
||||||
|
|
||||||
|
player.Starts = false; // press: playback can't start
|
||||||
|
vm.TraxLeftClick();
|
||||||
|
Assert.False(vm.IsMusicPlaying);
|
||||||
|
Assert.NotNull(vm.TraxError);
|
||||||
|
Assert.True(vm.TraxError!.Length <= 32, $"error not terse: '{vm.TraxError}'");
|
||||||
|
var red = vm.TraxDotBrush.Color;
|
||||||
|
Assert.NotEqual(idle, red);
|
||||||
|
Assert.NotEqual(playing, red);
|
||||||
|
|
||||||
|
player.Starts = true; // recovery clears the error
|
||||||
|
vm.TraxLeftClick();
|
||||||
|
Assert.True(vm.IsMusicPlaying);
|
||||||
|
Assert.Null(vm.TraxError);
|
||||||
|
Assert.Equal(playing, vm.TraxDotBrush.Color);
|
||||||
|
|
||||||
|
player.Fail(); // device lost mid-play
|
||||||
|
Assert.False(vm.IsMusicPlaying);
|
||||||
|
Assert.Equal(red, vm.TraxDotBrush.Color);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
MainViewModel.LayoutPathOverride = null;
|
||||||
|
MainViewModel.TraxPlayerOverride = null;
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
try { File.Delete(tempDb); File.Delete(track); } catch { /* best-effort cleanup */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user