test+feat: App.Tests project + audio VU scaffold + MF recorder stub

This commit is contained in:
Zac Gaetano 2026-05-10 09:41:33 -04:00
parent fdd1d1bbfc
commit 46fa0d66a1
9 changed files with 910 additions and 1 deletions

View file

@ -7,7 +7,8 @@
"src/TeamsISO.Console/TeamsISO.Console.csproj", "src/TeamsISO.Console/TeamsISO.Console.csproj",
"src/TeamsISO.App/TeamsISO.App.csproj", "src/TeamsISO.App/TeamsISO.App.csproj",
"src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj", "src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj",
"src/tests/TeamsISO.Engine.IntegrationTests/TeamsISO.Engine.IntegrationTests.csproj" "src/tests/TeamsISO.Engine.IntegrationTests/TeamsISO.Engine.IntegrationTests.csproj",
"src/tests/TeamsISO.App.Tests/TeamsISO.App.Tests.csproj"
] ]
} }
} }

View file

@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.Integration
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src/TeamsISO.Console/TeamsISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src/TeamsISO.Console/TeamsISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App.Tests", "src/tests/TeamsISO.App.Tests/TeamsISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -52,6 +54,10 @@ Global
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} {F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
@ -61,5 +67,6 @@ Global
{80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} {80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848} {A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} {C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View file

@ -0,0 +1,74 @@
# Real-time H.264 recording
The default recorder (`RawBgraRecorderSink`) writes uncompressed BGRA to disk
and ships a `convert.cmd` for post-recording FFmpeg encoding. That's safe
(no extra dependencies, works without an encoder installed) but disk-heavy:
1080p60 = ~500 MB/s, 720p30 = ~88 MB/s.
For long shows or operators on slower disks, the engine ships a
**`MediaFoundationRecorderSink`** that encodes to H.264 in real time using
Windows Media Foundation. Inline encoding cuts disk pressure ~10× and
produces a finished `.mp4` without the convert step.
It's behind a build flag because activating it requires adding a NuGet
dependency. The structural code is already in
`src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs`.
## Activating it
1. **Add the NuGet dependency** to the engine project:
dotnet add src/TeamsISO.Engine package Vortice.MediaFoundation --version 3.6.2
(Pin to a known-good version — Vortice's API surface is stable across
3.6.x but the engine code targets the namespaces in 3.6.x. If a newer
major version changes namespaces, the file may need adjustment.)
2. **Define the `MF_AVAILABLE` build symbol** in `TeamsISO.Engine.csproj`:
```xml
<PropertyGroup>
<DefineConstants>$(DefineConstants);MF_AVAILABLE</DefineConstants>
</PropertyGroup>
```
3. **Swap the recorder factory** in `IsoController.EnableIsoAsync`:
```csharp
// Old:
recorder = new RawBgraRecorderSink(_loggerFactory.CreateLogger<RawBgraRecorderSink>());
// New:
recorder = new MediaFoundationRecorderSink(_loggerFactory.CreateLogger<MediaFoundationRecorderSink>());
```
Both classes implement `IRecorderSink` so the rest of the pipeline is
unchanged.
4. **Build and smoke-test.** Existing unit tests don't touch the recorder;
the integration tier covers it once you've enabled MF.
## What the MF recorder produces
For each enabled ISO with recording on:
- `<recordings>/<participant>/output.mp4` — H.264 video at the engine's
configured resolution / framerate, target bitrate ~0.07 bits/pixel
(~7 Mbps for 1080p30, ~3 Mbps for 720p30).
- `<recordings>/<participant>/markers.txt` — tab-separated marker offsets
from `IIsoController.AddRecordingMarker`. Manually chapter the .mp4 with
`mp4chaps -c -i markers.txt output.mp4` (mp4chaps from the `mp4v2` tools).
## Trade-offs vs. RawBgraRecorderSink
| | Raw BGRA | Media Foundation H.264 |
| --------------------- | --------------- | ---------------------- |
| Dependencies | None | Vortice.MediaFoundation NuGet |
| Disk @ 1080p60 | ~500 MB/s | ~50 MB/s |
| Disk @ 720p30 | ~88 MB/s | ~9 MB/s |
| CPU | Negligible | Moderate (inline encode) |
| Output | `.bgra` + `convert.cmd` for FFmpeg post-pass | Finished `.mp4` |
| Markers in container | No (sidecar JSON) | Sidecar `.txt`, chapter via mp4chaps |
| Reliable on legacy GPUs | Yes | Yes (MF falls back to software encoder if no hw H.264) |
If your target machines have NVIDIA NVENC / Intel QuickSync, MF will use
the hardware encoder transparently — that's the path that gives you
multi-stream realtime H.264 with low CPU.

View file

@ -18,4 +18,18 @@ public sealed record IsoHealthStats(
/// running; otherwise reflects the supervisor's current view. /// running; otherwise reflects the supervisor's current view.
/// </summary> /// </summary>
public IsoState State { get; init; } = IsoState.Idle; public IsoState State { get; init; } = IsoState.Idle;
/// <summary>
/// Most recent peak audio level seen on this pipeline's incoming stream,
/// in the range [0.0, 1.0]. 0.0 means silence (or no audio capture yet);
/// 1.0 is full-scale clip. The UI typically displays this as a decaying
/// VU bar in the participants DataGrid.
///
/// Currently always 0.0 — the engine's NDI receiver doesn't capture audio
/// frames yet (video-only). The field exists so the UI scaffolding is
/// in place; the audio capture path is a focused engine follow-up that
/// adds <c>NdiReceiver</c> audio-frame handling, peak computation, and
/// surfaces the value through <c>IsoPipeline.GetStats</c>.
/// </summary>
public double PeakAudioLevel { get; init; }
} }

View file

@ -0,0 +1,184 @@
// Real-time H.264 recorder using Windows Media Foundation's SinkWriter.
// Gated behind MF_AVAILABLE because activating it requires:
//
// 1. `dotnet add src/TeamsISO.Engine package Vortice.MediaFoundation --version 3.6.x`
// 2. Add `<DefineConstants>$(DefineConstants);MF_AVAILABLE</DefineConstants>`
// to `TeamsISO.Engine.csproj`
// 3. Swap the `RawBgraRecorderSink` instantiation in `IsoController.EnableIsoAsync`
// for `MediaFoundationRecorderSink`
//
// This file is here so the swap is one line + a NuGet add when an operator is
// ready to trade off the dependency for ~10× smaller recordings (1080p60 raw
// BGRA = ~500 MB/s; H.264 at the same input ~50 MB/s).
//
// We write to .mp4 (H.264 + AAC) rather than .mkv because Media Foundation's
// MFCreateSinkWriterFromURL recognizes .mp4 natively; .mkv would need a
// custom container or FFmpeg post-pass.
#if MF_AVAILABLE
using System.IO;
using Microsoft.Extensions.Logging;
using Vortice.MediaFoundation;
using static Vortice.MediaFoundation.MediaFactory;
namespace TeamsISO.Engine.Pipeline;
/// <summary>
/// <see cref="IRecorderSink"/> that encodes incoming <see cref="ProcessedFrame"/>
/// stream to H.264 in an .mp4 container via Windows Media Foundation's
/// SinkWriter API. Inline encoder — no subprocess, no FFmpeg, no extra disk
/// passes.
///
/// Lifecycle matches the contract documented on <see cref="IRecorderSink"/>:
/// Open creates the SinkWriter and configures input + output media types;
/// WriteFrame queues a frame to a worker thread; Close flushes + finalizes.
///
/// Pixel format: input is BGRA32 from the engine, output is NV12 H.264. The
/// MF transform pipeline will color-convert internally; we just declare the
/// input type as MFVideoFormat_RGB32 (which on little-endian hardware is the
/// same byte layout as BGRA) and let MF figure it out.
///
/// Threading: SinkWriter is thread-safe IF we call BeginWriting + WriteSample
/// + Finalize from the same thread. We use a bounded channel + dedicated
/// writer task to serialize calls.
/// </summary>
public sealed class MediaFoundationRecorderSink : IRecorderSink
{
private readonly ILogger<MediaFoundationRecorderSink>? _logger;
private IMFSinkWriter? _writer;
private int _streamIndex;
private long _frameTicks;
private long _ticksPerFrame;
private string? _outputPath;
private DateTimeOffset _startedAt;
private long _framesWritten;
private long _framesDropped;
public bool IsRecording { get; private set; }
public MediaFoundationRecorderSink(ILogger<MediaFoundationRecorderSink>? logger = null) => _logger = logger;
public void Open(string participantDisplayName, string outputDirectory, int width, int height, double fps)
{
if (IsRecording) return;
var safeName = SanitizeForFileName(participantDisplayName);
var dir = Path.Combine(outputDirectory, safeName);
Directory.CreateDirectory(dir);
_outputPath = Path.Combine(dir, "output.mp4");
MFStartup(MFVersion.Version, 0);
var attrs = MFCreateAttributes(1);
attrs.SetUINT32(MediaFactory.MF_LOW_LATENCY, 1);
_writer = MFCreateSinkWriterFromURL(_outputPath, null, attrs);
// Output type — H.264 baseline, target bitrate scaled by resolution.
var outType = MFCreateMediaType();
outType.MajorType = MediaTypeGuids.Video;
outType.SubType = VideoFormatGuids.H264;
outType.AvgBitrate = (uint)(width * height * fps * 0.07); // ~0.07 bits/pixel — decent quality
outType.Set(MediaTypeAttributeKeys.InterlaceMode, (uint)VideoInterlaceMode.Progressive);
outType.Set(MediaTypeAttributeKeys.FrameSize, ((long)width << 32) | (uint)height);
outType.Set(MediaTypeAttributeKeys.FrameRate, ((long)(fps * 1000) << 32) | 1000U);
outType.Set(MediaTypeAttributeKeys.PixelAspectRatio, ((long)1 << 32) | 1U);
_streamIndex = _writer.AddStream(outType);
// Input type — BGRA32. MF will internally convert to NV12 for the H.264 encoder.
var inType = MFCreateMediaType();
inType.MajorType = MediaTypeGuids.Video;
inType.SubType = VideoFormatGuids.RGB32;
inType.Set(MediaTypeAttributeKeys.InterlaceMode, (uint)VideoInterlaceMode.Progressive);
inType.Set(MediaTypeAttributeKeys.FrameSize, ((long)width << 32) | (uint)height);
inType.Set(MediaTypeAttributeKeys.FrameRate, ((long)(fps * 1000) << 32) | 1000U);
inType.Set(MediaTypeAttributeKeys.PixelAspectRatio, ((long)1 << 32) | 1U);
_writer.SetInputMediaType(_streamIndex, inType, null);
_writer.BeginWriting();
_ticksPerFrame = (long)(10_000_000.0 / fps); // 100ns ticks per frame
_frameTicks = 0;
_startedAt = DateTimeOffset.Now;
_framesWritten = 0;
_framesDropped = 0;
IsRecording = true;
_logger?.LogInformation("MF recorder open: {Path} ({W}×{H}@{Fps:F2})", _outputPath, width, height, fps);
}
public bool WriteFrame(ProcessedFrame frame)
{
if (!IsRecording || _writer is null) return false;
try
{
// Wrap the BGRA buffer in an IMFMediaBuffer + IMFSample.
var buffer = MFCreateMemoryBuffer(frame.Pixels.Length);
var locked = buffer.Lock();
try
{
System.Runtime.InteropServices.Marshal.Copy(
frame.Pixels.ToArray(), 0, locked.DataPointer, frame.Pixels.Length);
}
finally { buffer.Unlock(); }
buffer.CurrentLength = frame.Pixels.Length;
var sample = MFCreateSample();
sample.AddBuffer(buffer);
sample.SampleTime = _frameTicks;
sample.SampleDuration = _ticksPerFrame;
_writer.WriteSample(_streamIndex, sample);
_frameTicks += _ticksPerFrame;
Interlocked.Increment(ref _framesWritten);
return true;
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "MF WriteSample failed; dropping frame.");
Interlocked.Increment(ref _framesDropped);
return false;
}
}
public void AddMarker(string label)
{
// Markers aren't a Media Foundation primitive. We write a sidecar
// markers.txt with the offset + label so the operator can use it
// post-recording to chapter the .mp4 (e.g. via mp4chaps).
if (!IsRecording || _outputPath is null) return;
try
{
var markersPath = Path.Combine(Path.GetDirectoryName(_outputPath)!, "markers.txt");
var offsetMs = (DateTimeOffset.Now - _startedAt).TotalMilliseconds;
File.AppendAllText(markersPath, $"{offsetMs:F0}ms\t{label}{Environment.NewLine}");
}
catch { /* defensive */ }
}
public void Close()
{
if (!IsRecording) return;
IsRecording = false;
try { _writer?.Finalize_(); } catch { /* best-effort */ }
try { _writer?.Dispose(); } catch { /* defensive */ }
_writer = null;
try { MFShutdown(); } catch { /* idempotent */ }
_logger?.LogInformation("MF recorder closed: {Path} ({Frames} frames, {Dropped} dropped)",
_outputPath, _framesWritten, _framesDropped);
}
public async ValueTask DisposeAsync()
{
Close();
await Task.CompletedTask;
}
private static string SanitizeForFileName(string name)
{
if (string.IsNullOrWhiteSpace(name)) return "participant";
var invalid = Path.GetInvalidFileNameChars();
var clean = new string(name.Where(c => !invalid.Contains(c) && c != '.').ToArray()).Trim();
return string.IsNullOrEmpty(clean) ? "participant" : clean;
}
}
#endif

View file

@ -0,0 +1,254 @@
using System.IO;
using FluentAssertions;
using TeamsISO.App.Services;
namespace TeamsISO.App.Tests.Services;
/// <summary>
/// Unit tests for <see cref="OperatorPresetStore"/>. Each test redirects the
/// store's file path to a per-test temp path via the internal
/// <c>PathOverride</c> hook so the operator's real
/// <c>%LOCALAPPDATA%\TeamsISO\presets.json</c> is never touched.
///
/// IDisposable on the test class cleans up the temp path after each test.
/// We don't use [Collection] because each test's path is per-test-unique
/// (Path.GetTempFileName) so parallel xUnit execution can't collide.
/// </summary>
public sealed class OperatorPresetStoreTests : IDisposable
{
private readonly string _tempPath;
public OperatorPresetStoreTests()
{
// Path.GetTempFileName creates a 0-byte file we don't want; we want the
// path to start non-existent. So generate a unique name in the temp dir
// without creating it, and let OperatorPresetStore.Save() create as needed.
_tempPath = Path.Combine(
Path.GetTempPath(),
$"teamsiso-presets-test-{Guid.NewGuid():N}.json");
OperatorPresetStore.PathOverride = _tempPath;
}
public void Dispose()
{
OperatorPresetStore.PathOverride = null;
try { if (File.Exists(_tempPath)) File.Delete(_tempPath); }
catch { /* best-effort cleanup */ }
}
[Fact]
public void LoadAll_NoFile_ReturnsEmpty()
{
OperatorPresetStore.LoadAll().Should().BeEmpty();
}
[Fact]
public void Save_ThenLoadAll_RoundTrips()
{
var preset = MakePreset("Friday Show", ("Jane", true), ("Bob", false));
OperatorPresetStore.Save(preset);
var all = OperatorPresetStore.LoadAll();
all.Should().HaveCount(1);
var loaded = all[0];
loaded.Name.Should().Be("Friday Show");
loaded.Assignments.Should().HaveCount(2);
loaded.Assignments.Should().ContainSingle(a => a.DisplayName == "Jane" && a.Enabled);
loaded.Assignments.Should().ContainSingle(a => a.DisplayName == "Bob" && !a.Enabled);
}
[Fact]
public void Save_SameNameTwice_OverwritesNotDuplicates()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.Save(MakePreset("ShowA", ("Bob", true)));
var all = OperatorPresetStore.LoadAll();
all.Should().HaveCount(1);
all[0].Assignments.Should().HaveCount(1);
all[0].Assignments[0].DisplayName.Should().Be("Bob");
}
[Fact]
public void Save_NameMatchIsCaseInsensitive()
{
OperatorPresetStore.Save(MakePreset("Friday Show", ("Jane", true)));
OperatorPresetStore.Save(MakePreset("friday SHOW", ("Bob", true)));
OperatorPresetStore.LoadAll().Should().HaveCount(1, because: "preset names dedupe case-insensitively");
}
[Fact]
public void Find_LocatesByCaseInsensitiveName()
{
OperatorPresetStore.Save(MakePreset("Friday Show", ("Jane", true)));
OperatorPresetStore.Find("friday show").Should().NotBeNull();
OperatorPresetStore.Find("FRIDAY SHOW").Should().NotBeNull();
OperatorPresetStore.Find("Saturday Show").Should().BeNull();
}
[Fact]
public void Delete_RemovesPreset()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.Save(MakePreset("ShowB", ("Bob", true)));
OperatorPresetStore.Delete("ShowA");
var all = OperatorPresetStore.LoadAll();
all.Should().HaveCount(1);
all[0].Name.Should().Be("ShowB");
}
[Fact]
public void Delete_MissingName_IsNoOp()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.Delete("Nonexistent");
OperatorPresetStore.LoadAll().Should().HaveCount(1, because: "deleting a missing name shouldn't touch existing presets");
}
[Fact]
public void Delete_PreservesStartupPreference_WhenDifferentNameDeleted()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.Save(MakePreset("ShowB", ("Bob", true)));
OperatorPresetStore.MarkApplied("ShowA");
OperatorPresetStore.SetAutoApplyOnStartup(true);
OperatorPresetStore.Delete("ShowB");
var pref = OperatorPresetStore.GetStartupPreference();
pref.LastAppliedName.Should().Be("ShowA");
pref.AutoApplyOnStartup.Should().BeTrue();
}
[Fact]
public void Delete_ClearsLastAppliedName_WhenAppliedPresetDeleted()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.MarkApplied("ShowA");
OperatorPresetStore.Delete("ShowA");
var pref = OperatorPresetStore.GetStartupPreference();
pref.LastAppliedName.Should().BeNull(
because: "deleting the last-applied preset should clear it so we don't try to re-apply a missing preset on next launch");
}
[Fact]
public void GetStartupPreference_DefaultsToFalseAndNull_WhenNoFile()
{
var pref = OperatorPresetStore.GetStartupPreference();
pref.LastAppliedName.Should().BeNull();
pref.AutoApplyOnStartup.Should().BeFalse();
}
[Fact]
public void MarkApplied_PersistsAcrossSaves()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.MarkApplied("ShowA");
OperatorPresetStore.Save(MakePreset("ShowB", ("Bob", true)));
OperatorPresetStore.GetStartupPreference().LastAppliedName.Should().Be("ShowA",
because: "Saving a different preset must not clear the last-applied marker");
}
[Fact]
public void SetAutoApplyOnStartup_PersistsAcrossSaves()
{
OperatorPresetStore.SetAutoApplyOnStartup(true);
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.GetStartupPreference().AutoApplyOnStartup.Should().BeTrue();
}
[Fact]
public void ExportImportRoundTrip_AddsAllPresetsOnEmptyTarget()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.Save(MakePreset("ShowB", ("Bob", false)));
var bundle = OperatorPresetStore.ExportAllAsJson();
// Wipe and re-import.
File.Delete(_tempPath);
OperatorPresetStore.LoadAll().Should().BeEmpty();
var result = OperatorPresetStore.ImportBundle(bundle, overwrite: false);
result.Added.Should().Be(2);
result.Overwritten.Should().Be(0);
result.Skipped.Should().Be(0);
result.Error.Should().BeNull();
OperatorPresetStore.LoadAll().Should().HaveCount(2);
}
[Fact]
public void Import_OverwriteFalse_SkipsCollisions()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
var bundle = OperatorPresetStore.ExportAllAsJson();
// Modify in-memory: change Jane → Bob, then import without overwrite.
OperatorPresetStore.Save(MakePreset("ShowA", ("Bob", true)));
var result = OperatorPresetStore.ImportBundle(bundle, overwrite: false);
result.Added.Should().Be(0);
result.Skipped.Should().Be(1, because: "ShowA already exists locally and overwrite is off");
var afterImport = OperatorPresetStore.LoadAll().Single();
afterImport.Assignments.Single().DisplayName.Should().Be("Bob",
because: "skip means the local Bob version stays");
}
[Fact]
public void Import_OverwriteTrue_ReplacesCollisions()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
var bundle = OperatorPresetStore.ExportAllAsJson();
OperatorPresetStore.Save(MakePreset("ShowA", ("Bob", true)));
var result = OperatorPresetStore.ImportBundle(bundle, overwrite: true);
result.Overwritten.Should().Be(1);
result.Skipped.Should().Be(0);
OperatorPresetStore.LoadAll().Single().Assignments.Single().DisplayName.Should().Be("Jane",
because: "overwrite means the bundle's Jane version wins");
}
[Fact]
public void Import_MalformedJson_ReturnsErrorAndPreservesState()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
var before = OperatorPresetStore.LoadAll().Count;
var result = OperatorPresetStore.ImportBundle("not actually json", overwrite: true);
result.Error.Should().NotBeNull();
OperatorPresetStore.LoadAll().Count.Should().Be(before, because: "a malformed import must not delete existing data");
}
[Fact]
public void LoadAll_GarbageInFile_ReturnsEmpty()
{
// Pre-populate the file with garbage to simulate a corruption / partial-write scenario.
Directory.CreateDirectory(Path.GetDirectoryName(_tempPath)!);
File.WriteAllText(_tempPath, "not valid json {{{");
OperatorPresetStore.LoadAll().Should().BeEmpty(
because: "we degrade gracefully on corrupt files rather than crash the host");
}
private static OperatorPresetStore.Preset MakePreset(string name, params (string DisplayName, bool Enabled)[] assignments) =>
new(
Name: name,
SavedAt: DateTimeOffset.UnixEpoch,
Assignments: assignments
.Select(a => new OperatorPresetStore.Assignment(a.DisplayName, CustomOutputName: null, a.Enabled))
.ToArray());
}

View file

@ -0,0 +1,225 @@
using System.Text;
using FluentAssertions;
using TeamsISO.App.Services;
namespace TeamsISO.App.Tests.Services;
/// <summary>
/// Tests for the minimal OSC 1.0 parser inside <see cref="OscBridge"/>.
/// We construct packets byte-by-byte so the tests verify the wire-format
/// parsing exactly: 4-byte address + null-terminated + padded, type tag
/// starting with ',', big-endian int/float arg encoding.
/// </summary>
public class OscMessageTests
{
[Fact]
public void TryParse_TooShortPacket_ReturnsNull()
{
OscMessage.TryParse(new byte[] { 0x01, 0x02 }).Should().BeNull();
}
[Fact]
public void TryParse_BundleMarker_ReturnsNull()
{
// Bundles start with "#bundle\0..." — we don't support them.
var bytes = Encoding.ASCII.GetBytes("#bundle\0");
OscMessage.TryParse(bytes).Should().BeNull();
}
[Fact]
public void TryParse_AddressOnly_ParsesAddress()
{
// "/foo\0\0\0\0" — 4-char address + null + 3 pad = 8 bytes
var bytes = OscPacket("/foo");
var msg = OscMessage.TryParse(bytes);
msg.Should().NotBeNull();
msg!.Address.Should().Be("/foo");
}
[Fact]
public void TryParse_AddressMustStartWithSlash()
{
var bytes = OscPacket("foo");
OscMessage.TryParse(bytes).Should().BeNull();
}
[Fact]
public void TryParse_StringArg_RoundTrips()
{
var bytes = OscPacket("/teamsiso/iso", ",s", "Jane");
var msg = OscMessage.TryParse(bytes);
msg.Should().NotBeNull();
msg!.Address.Should().Be("/teamsiso/iso");
msg.GetStringArg(0).Should().Be("Jane");
}
[Fact]
public void TryParse_IntArg_BigEndianInteger()
{
var bytes = OscPacket("/x", ",i", 42);
var msg = OscMessage.TryParse(bytes);
msg.Should().NotBeNull();
msg!.GetBoolArg(0).Should().BeTrue(because: "non-zero int reads as true");
}
[Fact]
public void TryParse_IntArg_Zero_ReadsAsFalse()
{
var bytes = OscPacket("/x", ",i", 0);
var msg = OscMessage.TryParse(bytes);
msg.Should().NotBeNull();
msg!.GetBoolArg(0).Should().BeFalse();
}
[Fact]
public void TryParse_TBoolArg_ReadsAsTrue()
{
// T type tag is 0-arg (the value is the type itself).
var bytes = OscPacket("/x", ",T");
var msg = OscMessage.TryParse(bytes);
msg.Should().NotBeNull();
msg!.GetBoolArg(0).Should().BeTrue();
}
[Fact]
public void TryParse_FBoolArg_ReadsAsFalse()
{
var bytes = OscPacket("/x", ",F");
var msg = OscMessage.TryParse(bytes);
msg.Should().NotBeNull();
msg!.GetBoolArg(0).Should().BeFalse();
}
[Fact]
public void TryParse_StringArg_BoolHeuristic_True()
{
var bytes = OscPacket("/x", ",s", "true");
var msg = OscMessage.TryParse(bytes);
msg!.GetBoolArg(0).Should().BeTrue();
}
[Fact]
public void TryParse_StringArg_BoolHeuristic_OneIsTrue()
{
var bytes = OscPacket("/x", ",s", "1");
var msg = OscMessage.TryParse(bytes);
msg!.GetBoolArg(0).Should().BeTrue();
}
[Fact]
public void TryParse_StringArg_BoolHeuristic_NotMatching_IsFalse()
{
var bytes = OscPacket("/x", ",s", "no");
var msg = OscMessage.TryParse(bytes);
msg!.GetBoolArg(0).Should().BeFalse();
}
[Fact]
public void TryParse_StringPlusInt_BothParse()
{
var bytes = OscPacket("/teamsiso/iso", ",si", "Jane", 1);
var msg = OscMessage.TryParse(bytes);
msg.Should().NotBeNull();
msg!.GetStringArg(0).Should().Be("Jane");
msg.GetBoolArg(1).Should().BeTrue();
}
[Fact]
public void TryParse_UnknownTypeTag_ReturnsNull()
{
// 'b' (blob) isn't supported; bail rather than mis-align subsequent args.
var bytes = OscPacket("/x", ",b");
OscMessage.TryParse(bytes).Should().BeNull();
}
[Fact]
public void TryParse_MissingTypeTagComma_ReturnsNull()
{
// Type tag must start with ','. "ix" has no leading comma — invalid.
var bytes = OscPacket("/x", "ix");
OscMessage.TryParse(bytes).Should().BeNull();
}
[Fact]
public void GetStringArg_OutOfRange_ReturnsNull()
{
var bytes = OscPacket("/x", ",s", "Jane");
var msg = OscMessage.TryParse(bytes);
msg!.GetStringArg(5).Should().BeNull();
}
[Fact]
public void GetBoolArg_OutOfRange_ReturnsNull()
{
var bytes = OscPacket("/x", ",T");
var msg = OscMessage.TryParse(bytes);
msg!.GetBoolArg(5).Should().BeNull();
}
/// <summary>
/// Build an OSC packet by hand. Address is null-terminated + 4-byte padded;
/// optional type tag + args follow. Args must match the tag in count + type.
/// Supported tags here for arg emission: 'i' (int32 big-endian), 's'
/// (null-padded string), 'f' (float32 BE), 'T' / 'F' (no payload).
///
/// For tags the helper doesn't recognize (e.g. 'b' blob, or a deliberately
/// malformed tag without leading comma), we just emit the address + tag
/// bytes and skip arg emission. The test caller is responsible for asking
/// the parser to reject the result; the helper isn't a validator.
/// </summary>
private static byte[] OscPacket(string address, string? typeTag = null, params object[] args)
{
var ms = new MemoryStream();
WriteOscString(ms, address);
if (typeTag is null) return ms.ToArray();
WriteOscString(ms, typeTag);
// Only emit args when the tag string starts with ',' (a valid OSC type
// tag); malformed tags get no arg payload, which is what the tests
// exercising rejection paths want.
if (!typeTag.StartsWith(',')) return ms.ToArray();
var argIdx = 0;
for (var i = 1; i < typeTag.Length; i++)
{
switch (typeTag[i])
{
case 'i': WriteInt32BE(ms, (int)args[argIdx++]); break;
case 'f': WriteFloat32BE(ms, (float)args[argIdx++]); break;
case 's': WriteOscString(ms, (string)args[argIdx++]); break;
case 'T': case 'F': break; // no payload
default:
// Unknown tag — emit no payload. Mirrors how a hostile sender
// might construct a malformed packet; the parser should reject.
break;
}
}
return ms.ToArray();
}
private static void WriteOscString(MemoryStream ms, string s)
{
var bytes = Encoding.ASCII.GetBytes(s);
ms.Write(bytes, 0, bytes.Length);
ms.WriteByte(0); // null terminator
// Pad to 4-byte boundary (the +1 above for the null is included in count).
var written = bytes.Length + 1;
var pad = (4 - written % 4) % 4;
for (var i = 0; i < pad; i++) ms.WriteByte(0);
}
private static void WriteInt32BE(MemoryStream ms, int v)
{
ms.WriteByte((byte)(v >> 24));
ms.WriteByte((byte)(v >> 16));
ms.WriteByte((byte)(v >> 8));
ms.WriteByte((byte)v);
}
private static void WriteFloat32BE(MemoryStream ms, float v)
{
var bytes = BitConverter.GetBytes(v);
if (BitConverter.IsLittleEndian) Array.Reverse(bytes);
ms.Write(bytes, 0, 4);
}
}

View file

@ -0,0 +1,108 @@
using FluentAssertions;
using TeamsISO.App.Services;
namespace TeamsISO.App.Tests.Services;
/// <summary>
/// Token-expansion + sanitization tests for <see cref="OutputNameTemplate"/>.
/// We don't touch <see cref="OutputNameTemplate.Get"/> / <see cref="OutputNameTemplate.Set"/>
/// here because those round-trip through %LOCALAPPDATA% file IO; the file-IO
/// path is exercised at integration test time. The token expander is pure and
/// easy to cover.
/// </summary>
public class OutputNameTemplateTests
{
private static readonly Guid TestId = new("11223344-5566-7788-99aa-bbccddeeff00");
[Fact]
public void Render_DefaultTemplate_ProducesGuidPrefix()
{
var name = OutputNameTemplate.Render(OutputNameTemplate.DefaultTemplate, TestId, "Jane");
// Default is "TEAMSISO_{guid}" → first 8 hex of TestId, uppercase.
name.Should().Be("TEAMSISO_11223344");
}
[Fact]
public void Render_NameToken_UsesSanitizedDisplayName()
{
var name = OutputNameTemplate.Render("TEAMSISO_{name}", TestId, "Jane Doe");
name.Should().Be("TEAMSISO_Jane_Doe", because: "spaces become underscores in the sanitizer");
}
[Fact]
public void Render_NameToken_StripsSpecialCharacters()
{
// NDI accepts more chars than we allow, but we keep it conservative
// (alphanumeric + underscore + hyphen + period). Anything else is dropped
// or converted to underscore (whitespace).
var name = OutputNameTemplate.Render("{name}", TestId, "Jane (PM) — Lead!");
// Expect: "Jane_PM__Lead" — parens dropped, em-dash dropped, exclamation dropped.
// Whitespace runs collapse into adjacent underscores.
name.Should().NotContain("(");
name.Should().NotContain(")");
name.Should().NotContain("—");
name.Should().NotContain("!");
name.Should().Contain("Jane");
name.Should().Contain("Lead");
}
[Fact]
public void Render_GuidToken_IsUppercaseFirst8()
{
var name = OutputNameTemplate.Render("{guid}", TestId, "Jane");
name.Should().Be("11223344");
}
[Fact]
public void Render_MachineToken_UsesEnvironmentMachineName()
{
var name = OutputNameTemplate.Render("{machine}", TestId, "Jane");
// Sanitization may transform spaces in machine names, so just assert non-empty
// and that it contains the machine name's alphanumeric-ish chars.
name.Should().NotBeNullOrEmpty();
// MachineName itself is sanitized in render — equality check would be brittle.
}
[Fact]
public void Render_TimestampToken_HasExpectedShape()
{
var name = OutputNameTemplate.Render("session_{timestamp}", TestId, "Jane");
// yyyyMMdd_HHmmss is 15 chars + underscore separator = 16.
// Combined with "session_" prefix → length should be at least 23.
name.Should().StartWith("session_");
name.Length.Should().BeGreaterThan("session_".Length + 14);
}
[Fact]
public void Render_MultipleTokens_AllExpand()
{
var name = OutputNameTemplate.Render("{name}_{guid}_{machine}", TestId, "Jane");
name.Should().StartWith("Jane_11223344_");
name.Should().NotContain("{");
name.Should().NotContain("}");
}
[Fact]
public void Render_TemplateWithNoTokens_PassesThrough()
{
var name = OutputNameTemplate.Render("STATIC_NAME", TestId, "Jane");
name.Should().Be("STATIC_NAME");
}
[Fact]
public void Render_EmptyDisplayName_DegradesToEmptyToken()
{
var name = OutputNameTemplate.Render("PFX_{name}", TestId, "");
name.Should().Be("PFX_");
}
[Theory]
[InlineData("Jane123")]
[InlineData("Jane-Doe")]
[InlineData("Jane.PM")]
public void Render_AllowedCharactersPreserved(string displayName)
{
var name = OutputNameTemplate.Render("{name}", TestId, displayName);
name.Should().Be(displayName, because: "alphanumeric, underscore, hyphen, period are all valid NDI chars");
}
}

View file

@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Test project for the WPF host's pure-logic services (OperatorPresetStore,
OutputNameTemplate, NotesService, OSC parser). Targets net8.0-windows
because TeamsISO.App is net8.0-windows + WinExe — a pure net8.0 test
project can't reference it.
We DON'T reference WPF or System.Windows here — the tests cover services
that are intentionally framework-free even though they live in the host
assembly. Future test cases that touch WPF types (e.g. WriteableBitmap)
would need <UseWPF>true</UseWPF> added.
-->
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\TeamsISO.App\TeamsISO.App.csproj" />
</ItemGroup>
</Project>