Files
ytLlive/ytLive.Tests/LayoutStorePersistenceTests.cs
T
gramps 6d71acef8c TASK 9 audio milestone: real stream audio + voice filters + auto-duck + free TRAX music — the mixer now feeds the encoder real audio (ffmpeg reads a Windows named pipe, -f f32le -ar 48000 -ac 2 -i \.\pipe\ytllive_audio, replacing anullsrc silence; explicit -map 0:v -map 1:a via EncoderOptions.AudioPipeName), with honest gains reaching the stream (MicVolume scales mic, GameAudioVolume + mute scale loopback, Func<double> seams on AudioMixer), an always-on pure-C# voice chain on the mic BEFORE the meter and mix (LowShelfFilter 120Hz +4dB → HighShelfFilter 8kHz +3dB → NoiseGate 0.005/hysteresis 0.5 → Compressor 0.5 4:1, all TDF2), AutoDucker (mic RMS>0.02 → loopback ×0.25, attack 0.05/release 0.005), and TRAX free background music (MusicPlayer = MediaFoundationReader → VolumeWaveProvider16 at fixed 0.20 → WaveOutEvent via the new sibling NAudio.WinMM 2.2.1 package; plays to the default device so the existing loopback carries it, ducked with game; footer TRAX button with red/yellow/green status dot, left-click toggles/picks, right-click opens the OpenFileDialog picker, tooltip shows the track name; persisted via schema v9 single-row Music). Build-time deviations from the plan (recorded in ai.md + TASKS.md): WasapiCapture in NAudio 2.2.1 exposes no overridable GetDefaultMixFormat so sources run device mix format and the mixer's TinyResampler normalizes any rate to 48kHz (resampler IS the design); FfmpegEncoder.cs untouched — MainViewModel.StopStream calls _audioMixer.StopLive() (pipe EOF) before the frame pump stops; sound-bar relabelled 'Desktop Audio', IsGameAudioBarVisible = game sound OR music playing. Tests: new AudioPipelineTests (DSP/ring-buffer/ducker/resampler units + the ONE integration test Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe reading real pipe bytes; ring-buffer overwrite bug found + fixed), FfmpegEncoderTests/LayoutStorePersistenceTests updated — 196 tests passing, 0 warnings
2026-08-14 18:09:26 -07:00

285 lines
11 KiB
C#

using System;
using System.IO;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// The layout DB is a full rewrite on every save (DELETE all scenes/sources,
/// re-insert from memory). This guards the round trip: webcam configs the user
/// removes in the UI must not come back after a save + reload.
/// </summary>
public class LayoutStorePersistenceTests
{
[Fact]
public void Deleted_Webcam_Config_Does_Not_Return_After_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var webcam = new Webcam { DeviceId = "real-device", Name = "Logitech" };
var scene = new Scene { Name = "Starting" };
scene.Elements.Add(new WebcamSceneConfig
{
WebcamId = webcam.Id,
Name = webcam.Name,
Width = 480,
Height = 270,
});
store.Save(new[] { scene }, webcam, null);
var reloaded = store.Load();
var config = Assert.Single(reloaded[0].Elements);
Assert.IsType<WebcamSceneConfig>(config);
reloaded[0].Elements.RemoveAt(0);
store.Save(reloaded, store.Webcam, null);
var afterDelete = store.Load();
Assert.Empty(afterDelete[0].Elements);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// The backdrop (schema v5): permanent bottom layer holding live desktop/game
// capture. IsBackdrop + CaptureKey must survive a save + reload so a
// re-designated target isn't lost on restart.
[Fact]
public void Backdrop_IsBackdrop_And_CaptureKey_Survive_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var backdrop = new Source
{
Name = "Backdrop",
Type = SourceType.DisplayCapture,
IsBackdrop = true,
IsEnabled = true,
X = 0,
Y = 0,
Width = 1920,
Height = 1080,
MonitorIndex = 1,
CaptureKey = "monitor:1",
};
var scene = new Scene { Name = "Starting" };
scene.Elements.Add(backdrop);
store.Save(new[] { scene }, null, null);
var reloaded = store.Load();
var restored = Assert.IsType<Source>(Assert.Single(reloaded[0].Elements));
Assert.True(restored.IsBackdrop);
Assert.Equal("monitor:1", restored.CaptureKey);
Assert.Equal(1, restored.MonitorIndex);
Assert.Equal(SourceType.DisplayCapture, restored.Type);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// Per-scene backdrop switch (schema v6): a scene whose backdrop was turned
// off must stay off across a save + reload.
[Fact]
public void Scene_HasBackdrop_Survives_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var on = new Scene { Name = "Live", HasBackdrop = true };
var off = new Scene { Name = "Ending", HasBackdrop = false };
store.Save(new[] { on, off }, null, null);
var reloaded = store.Load();
Assert.True(reloaded.First(s => s.Name == "Live").HasBackdrop);
Assert.False(reloaded.First(s => s.Name == "Ending").HasBackdrop);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// v5 → v6 one-time backfill: pre-v6 DBs already have backdrops sitting in the
// non-Live canonical scenes (Starting/BRB/Chat/Ending). Adding the HasBackdrop
// column turns those scenes off and drops their backdrop sources; Live keeps
// its backdrop.
[Fact]
public void V5_Database_Backfill_Removes_NonLive_Backdrops()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using (var conn = new SqliteConnection($"Data Source={path}"))
{
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
CREATE TABLE Scene (
Id TEXT PRIMARY KEY,
Name TEXT NOT NULL,
IsHidden INTEGER NOT NULL DEFAULT 0,
IsChatScene INTEGER NOT NULL DEFAULT 0,
SortOrder INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE Source (
Id TEXT PRIMARY KEY,
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
AssetId TEXT,
Type TEXT NOT NULL,
Name TEXT NOT NULL,
IsEnabled INTEGER NOT NULL DEFAULT 1,
X REAL NOT NULL DEFAULT 0,
Y REAL NOT NULL DEFAULT 0,
Width REAL NOT NULL DEFAULT 0,
Height REAL NOT NULL DEFAULT 0,
Opacity REAL NOT NULL DEFAULT 1,
MonitorIndex INTEGER,
DeviceId TEXT,
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0,
IsBackdrop INTEGER NOT NULL DEFAULT 0,
CaptureKey TEXT,
SortOrder INTEGER NOT NULL DEFAULT 0
);
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-starting', 'Starting', 0);
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-live', 'Live', 1);
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-chat', 'Chat', 2);
INSERT INTO Source (Id, SceneId, Type, Name, IsBackdrop, CaptureKey, SortOrder)
VALUES ('b-starting', 's-starting', 'DisplayCapture', 'Backdrop', 1, 'monitor:0', 0);
INSERT INTO Source (Id, SceneId, Type, Name, IsBackdrop, CaptureKey, SortOrder)
VALUES ('b-live', 's-live', 'DisplayCapture', 'Backdrop', 1, 'monitor:0', 0);
INSERT INTO Source (Id, SceneId, Type, Name, IsBackdrop, CaptureKey, SortOrder)
VALUES ('b-chat', 's-chat', 'DisplayCapture', 'Backdrop', 1, 'monitor:0', 0);
PRAGMA user_version = 5;
""";
cmd.ExecuteNonQuery();
}
using var store = new LayoutStore(path);
var scenes = store.Load();
var starting = Assert.Single(scenes, s => s.Name == "Starting");
Assert.False(starting.HasBackdrop);
Assert.DoesNotContain(starting.Elements, e => e is Source { IsBackdrop: true });
var chat = Assert.Single(scenes, s => s.Name == "Chat");
Assert.False(chat.HasBackdrop);
Assert.DoesNotContain(chat.Elements, e => e is Source { IsBackdrop: true });
var live = Assert.Single(scenes, s => s.Name == "Live");
Assert.True(live.HasBackdrop);
Assert.Single(live.Elements, e => e is Source { IsBackdrop: true });
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// Round-to-rect restore is persisted (schema v4): a Round webcam resized to a
// square saves its pre-Round rect dims, and a reloaded config restores them on
// toggle-back instead of staying square.
[Fact]
public void Pre_Round_Rect_Dims_Survive_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var webcam = new Webcam { DeviceId = "real-device", Name = "Logitech" };
var scene = new Scene { Name = "Starting" };
scene.Elements.Add(new WebcamSceneConfig
{
WebcamId = webcam.Id,
Name = webcam.Name,
Width = 400,
Height = 400,
ClipShape = ClipShape.Round,
RectWidth = 480,
RectHeight = 270,
});
store.Save(new[] { scene }, webcam, null);
var reloaded = store.Load();
var config = Assert.IsType<WebcamSceneConfig>(Assert.Single(reloaded[0].Elements));
config.ToggleClipShape();
Assert.Equal(ClipShape.Traditional, config.ClipShape);
Assert.Equal(480, config.Width);
Assert.Equal(270, config.Height);
Assert.Null(config.RectWidth);
Assert.Null(config.RectHeight);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// TRAX (schema v9): the app-wide background track is one Music row; it must
// survive a save + reload so the creator's chosen track is restored.
[Fact]
public void Music_Track_Survives_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var scene = new Scene { Name = "Starting" };
var music = new Music { TrackPath = @"C:\music\my-banger.mp3", IsEnabled = true };
store.Save(new[] { scene }, null, null, music);
store.Load();
Assert.NotNull(store.Music);
Assert.Equal(@"C:\music\my-banger.mp3", store.Music!.TrackPath);
Assert.True(store.Music.IsEnabled);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
[Fact]
public void No_Music_Row_Loads_Null()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var scene = new Scene { Name = "Starting" };
store.Save(new[] { scene }, null, null);
store.Load();
Assert.Null(store.Music);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
}