Skip to content
Merged
26 changes: 26 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/smoc/bin/Debug/net9.0/linux-x64/smoc.dll",
"args": [],
"cwd": "${workspaceFolder}",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
"console": "internalConsole",
"stopAtEntry": false
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}
13 changes: 13 additions & 0 deletions smoc.Tests/Fakes/FakePlaybackService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,21 @@ public class FakePlaybackService(Song song) : IPlaybackService {

public PlaybackState PlaybackState { get; private set; } = PlaybackState.Stopped;

/// <summary>
/// Gets the simulated frequency spectrum data.
/// </summary>
public Song Song { get; } = song;

/// <summary>
/// Gets or sets the simulated frequency spectrum data array.
/// </summary>
public float[] SpectrumData { get; set; } = Array.Empty<float>();

/// <summary>
/// Gets or sets a value indicating whether simulated frequency spectrum analysis is active.
/// </summary>
public bool IsSpectrumActive { get; set; } = false;

public event EventHandler? SongEnded;
public event EventHandler<TimeSpan>? PositionChanged;
public event EventHandler<PlaybackState>? PlaybackStateChanged;
Expand Down
35 changes: 35 additions & 0 deletions smoc.Tests/Services/StandardPlaybackQueueServiceTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1059,4 +1059,39 @@ public async Task OnSongEnded_PreloadedTrackPlaysImmediately() {

Assert.Equal(PlaybackState.Playing, fakePlayerService2.PlaybackState);
}

/// <summary>
/// Verifies that the IsSpectrumActive state persists across track transitions.
/// </summary>
[Fact]
public async Task Play_NewSong_PersistsIsSpectrumActiveState() {
var song1 = EntityTestFactory.GenerateSong(id: "1", postfix: "1");
var song2 = EntityTestFactory.GenerateSong(id: "2", postfix: "2");
using var sut = NewStandardPlaybackQueue();
var fakePlayerService1 = new FakePlaybackService(song1);
var fakePlayerService2 = new FakePlaybackService(song2);
_mockAudioService.Setup(a => a.MakePlaybackService(song1, It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(fakePlayerService1);
_mockAudioService.Setup(a => a.MakePlaybackService(song2, It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(fakePlayerService2);
_mockStreamingClient.Setup(c => c.GetSongStreamAsync(song1.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(new SongStream(song1.Id, "m4a", new MemoryStream()));
_mockStreamingClient.Setup(c => c.GetSongStreamAsync(song2.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(new SongStream(song2.Id, "m4a", new MemoryStream()));

sut.QueueNext([song1, song2]);

// Enable visualizer
sut.IsSpectrumActive = true;

await sut.Play();
Assert.True(fakePlayerService1.IsSpectrumActive);

// Transition to the next track
await sut.ChangeTrack(1);
await sut.Play();

// Verify that the second song inherits the IsSpectrumActive state automatically
Assert.True(fakePlayerService2.IsSpectrumActive);
}
}
202 changes: 202 additions & 0 deletions smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
namespace smoc.Tests.Ui.Components;

using System;
using Moq;
using smoc.Tests.TestInfra;
using Smoc.Configuration;
using Smoc.Services;
using Smoc.Ui.Components;
using Terminal.Gui.Views;
using Terminal.Gui.Time;

/// <summary>
/// Unit tests for <see cref="FrequencyHistogramView"/> verifying rendering, mapping, and state changes.
/// </summary>
public class FrequencyHistogramViewTest {
private readonly Mock<IPlaybackQueueService> _mockPlaybackQueue;
private readonly ScreenshotDiffer _screenshotDiffer;

/// <summary>
/// Initializes a new instance of the <see cref="FrequencyHistogramViewTest"/> class.
/// </summary>
/// <param name="output">The test output helper provided by xUnit.</param>
public FrequencyHistogramViewTest(ITestOutputHelper output) {
_mockPlaybackQueue = new Mock<IPlaybackQueueService>();
_screenshotDiffer = new ScreenshotDiffer(output);
}

private static AppTestHelper NewContext(int width = 40, int height = 10) => With.A<Runnable>(width, height, TestDriver.ANSI.ToString());

private FrequencyHistogramView NewVisualizer(ITimeProvider timeProvider) => new FrequencyHistogramView(_mockPlaybackQueue.Object, timeProvider);

private AppTestHelper NewVisualizerContext(int width = 40, int height = 10, int? startEpochOffsetMs = null) {
var context = NewContext(width, height);
if (startEpochOffsetMs.HasValue) {
(context.TimeProvider as VirtualTimeProvider)?.SetTime(DateTime.UnixEpoch.AddMilliseconds(startEpochOffsetMs.Value));
}
var view = NewVisualizer(context.TimeProvider);
view.Visible = false; // Start invisible so that OnVisibleChanged fires when context is active
view.Width = Terminal.Gui.ViewBase.Dim.Fill();
view.Height = Terminal.Gui.ViewBase.Dim.Fill();
context.AddAndLayout(view);

// Set visible inside the running main loop so the timer successfully registers with App
context.Then((_) => {
view.Visible = true;
context.App!.TopRunnableView!.Layout();
});
return context;
}

/// <summary>
/// Verifies that when playback is stopped/empty, the equalizer displays completely flat/empty bars.
/// </summary>
[Fact]
public void InitialState_ShowsEmptyEqualizer() {
_mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Stopped);
_mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns([]);

using var context = NewVisualizerContext();
_screenshotDiffer.AssertEqualsGolden(context);
}

/// <summary>
/// Verifies that when music is playing and no real FFT data is available, the procedural wave fallback renders beautifully and deterministically.
/// </summary>
[Fact]
public void PlayingState_ProceduralFallback_RendersDeterministically() {
_mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Playing);
_mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns([]);

// Use a fixed simulated time point for frozen deterministic screenshot
using var context = NewVisualizerContext(startEpochOffsetMs: 3500);

// Trigger two update ticks to transition attack/decay interpolation towards target values
context.AdvanceTime(TimeSpan.FromMilliseconds(101));
context.AdvanceTime(TimeSpan.FromMilliseconds(101));

_screenshotDiffer.AssertEqualsGolden(context);
}

/// <summary>
/// Verifies that when real-time spectrum data is active, the frequency analyzer logarithmically maps the FFT bands correctly to equalizer columns.
/// </summary>
[Fact]
public void PlayingState_WithRealSpectrum_MapsLogarithmically() {
_mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Playing);

// Simulate 32 frequency bins from SoundFlow with distinct bass (strong) and treble peaks
float[] mockFrequencies = new float[32];
mockFrequencies[1] = 225; // Bass peak
mockFrequencies[2] = 225; // Bass peak
mockFrequencies[10] = 64; // Mid peak
mockFrequencies[28] = 10; // Treble peak
_mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns(mockFrequencies);

using var context = NewVisualizerContext();

context.AdvanceTime(TimeSpan.FromMilliseconds(101));

_screenshotDiffer.AssertEqualsGolden(context);
}

/// <summary>
/// Verifies that when music transitions from playing to paused/stopped, the frequencies decay smoothly towards zero.
/// </summary>
[Fact]
public void StateTransition_ToPaused_DecaysFrequencies() {
// 1. Start as playing with procedural values
_mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Playing);
_mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns([]);

using var context = NewVisualizerContext(startEpochOffsetMs: 2000);

// Pump initial values
context.AdvanceTime(TimeSpan.FromMilliseconds(101));
context.AdvanceTime(TimeSpan.FromMilliseconds(101));

// 2. Pause playback
_mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Paused);

// 3. Pump updates to run the decay logic
context.AdvanceTime(TimeSpan.FromMilliseconds(101));
context.AdvanceTime(TimeSpan.FromMilliseconds(101));

_screenshotDiffer.AssertEqualsGolden(context);
}

/// <summary>
/// Verifies that when <see cref="SmocConfiguration.VisualizerFps"/> changes dynamically,
/// the timer is recreated with the correct new interval.
/// </summary>
[Fact]
public void VisualizerFps_DynamicChange_RecreatesTimerWithNewInterval() {
int originalFps = SmocConfiguration.VisualizerFps;
try {
// 1. Initialize to 10 FPS (100ms interval)
SmocConfiguration.VisualizerFps = 10;
_mockPlaybackQueue.SetupGet(q => q.PlaybackState).Returns(PlaybackState.Playing);
_mockPlaybackQueue.SetupGet(q => q.SpectrumData).Returns([]);

// Create a visualizer context inline
using var context = NewContext(40, 10);
(context.TimeProvider as VirtualTimeProvider)?.SetTime(DateTime.UnixEpoch.AddMilliseconds(1000.0));

var view = NewVisualizer(context.TimeProvider);
view.Visible = false;
view.Width = Terminal.Gui.ViewBase.Dim.Fill();
view.Height = Terminal.Gui.ViewBase.Dim.Fill();
context.AddAndLayout(view);

context.Then((_) => {
view.Visible = true;
context.App!.TopRunnableView!.Layout();
});

var amplitudesField = typeof(FrequencyHistogramView).GetField("_amplitudes", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.NotNull(amplitudesField);

// Initially, amplitudes are uninitialized/all zero
var initialAmplitudes = (float[]?)amplitudesField.GetValue(view);
Assert.NotNull(initialAmplitudes);
Assert.All(initialAmplitudes, amp => Assert.Equal(0f, amp));

// Advance by 100ms: the 10 FPS timer should trigger and procedural values should be populated
context.AdvanceTime(TimeSpan.FromMilliseconds(100));

var amplitudesAfter100ms = (float[]?)amplitudesField.GetValue(view);
Assert.NotNull(amplitudesAfter100ms);
Assert.Contains(amplitudesAfter100ms, amp => amp > 0f);

// Now change the configuration FPS dynamically to 2 FPS (500ms interval)
SmocConfiguration.VisualizerFps = 2;

// Advance by 101ms to reach the scheduled next tick (at t = 200ms)
// When this tick fires, it detects the FPS change (10 -> 2)
context.AdvanceTime(TimeSpan.FromMilliseconds(101));

// Copy the amplitudes to compare later
var amplitudesAfter200ms = (float[]?)amplitudesField.GetValue(view);
Assert.NotNull(amplitudesAfter200ms);
var copyAmplitudes = (float[])amplitudesAfter200ms.Clone();

// Since the new 2 FPS timer has a 500ms interval starting at t = 200ms,
// advancing by another 100ms (to t = 300ms) should NOT trigger any update.
context.AdvanceTime(TimeSpan.FromMilliseconds(100));

var amplitudesAfter300ms = (float[]?)amplitudesField.GetValue(view);
Assert.Equal(copyAmplitudes, amplitudesAfter300ms);

// Advancing by another 400ms (to t = 700ms, which is 500ms after the recreation at t = 200ms)
// should trigger the new 2 FPS timer.
context.AdvanceTime(TimeSpan.FromMilliseconds(400));

var amplitudesAfter700ms = (float[]?)amplitudesField.GetValue(view);
// Frequencies should have moved/updated from the wave procedural generator
Assert.NotEqual(copyAmplitudes, amplitudesAfter700ms);
} finally {
// Restore configuration
SmocConfiguration.VisualizerFps = originalFps;
}
}
}
70 changes: 69 additions & 1 deletion smoc.Tests/Ui/NowPlayingViewTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Smoc.Ui;
using Smoc.Ui.Models;
using Terminal.Gui.Views;
using View = Terminal.Gui.ViewBase.View;

namespace smoc.Tests.Ui;

Expand Down Expand Up @@ -81,7 +82,6 @@ public void OnSongChanged_SongIsNull_UpdatesSongDetails() {
.Then((_) => handler?.Invoke(null, null));
_screenshotDiffer.AssertEqualsGolden(context);
}

[Fact]
public void OnSongChanged_LoadsAlbumArt() {
var song = EntityTestFactory.GenerateSong();
Expand Down Expand Up @@ -144,6 +144,74 @@ public void OnPositionChanged_UpdatesPosition_MultipleTimes() {
_screenshotDiffer.AssertEqualsGolden(context);
}

/// <summary>
/// Verifies that pressing the 'v' hotkey successfully toggles the visualizer component.
/// </summary>
[Fact]
public void ToggleVisualization_Hotkey_TogglesVisibility() {
using var context = NewNowPlayingContext();
var view = (NowPlayingView)context.App!.TopRunnableView!.SubViews.First();

// Find internal fields via reflection to verify visibility
var albumArtField = typeof(NowPlayingView).GetField("_albumArtView", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var histogramField = typeof(NowPlayingView).GetField("_histogramView", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);

var albumArt = (View?)albumArtField?.GetValue(view);
var histogram = (View?)histogramField?.GetValue(view);

Assert.NotNull(albumArt);
Assert.NotNull(histogram);

// Initial state: album art visible, histogram hidden
Assert.True(albumArt.Visible);
Assert.False(histogram.Visible);

// Toggle ON with 'v' hotkey
context.KeyDown(Terminal.Gui.Input.Key.V);
Assert.False(albumArt.Visible);
Assert.True(histogram.Visible);
_mockPlaybackQueue.VerifySet(q => q.IsSpectrumActive = true, Times.Once());

// Toggle OFF
context.KeyDown(Terminal.Gui.Input.Key.V);
Assert.True(albumArt.Visible);
Assert.False(histogram.Visible);
_mockPlaybackQueue.VerifySet(q => q.IsSpectrumActive = false, Times.Exactly(2));
}

/// <summary>
/// Verifies that running the 'np-vis' command successfully toggles the visualizer component.
/// </summary>
[Fact]
public void ToggleVisualization_Command_TogglesVisibility() {
using var context = NewNowPlayingContext();
var view = (NowPlayingView)context.App!.TopRunnableView!.SubViews.First();

var albumArtField = typeof(NowPlayingView).GetField("_albumArtView", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var histogramField = typeof(NowPlayingView).GetField("_histogramView", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);

var albumArt = (View?)albumArtField?.GetValue(view);
var histogram = (View?)histogramField?.GetValue(view);

Assert.NotNull(albumArt);
Assert.NotNull(histogram);

Assert.True(albumArt.Visible);
Assert.False(histogram.Visible);

// Toggle ON with command
context.Then((_) => _commandService.ExecuteCommand("np-vis"));
Assert.False(albumArt.Visible);
Assert.True(histogram.Visible);
_mockPlaybackQueue.VerifySet(q => q.IsSpectrumActive = true, Times.Once());

// Toggle OFF
context.Then((_) => _commandService.ExecuteCommand("np-vis"));
Assert.True(albumArt.Visible);
Assert.False(histogram.Visible);
_mockPlaybackQueue.VerifySet(q => q.IsSpectrumActive = false, Times.Exactly(2));
}

private static Image<Rgba32> GetImage() {
return new Image<Rgba32>(10, 10);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FrequencyHistogramViewTest.InitialState_ShowsEmptyEqualizer_0:











Loading
Loading