test+feat: App.Tests project + audio VU scaffold + MF recorder stub
This commit is contained in:
parent
fdd1d1bbfc
commit
46fa0d66a1
9 changed files with 910 additions and 1 deletions
|
|
@ -7,7 +7,8 @@
|
|||
"src/TeamsISO.Console/TeamsISO.Console.csproj",
|
||||
"src/TeamsISO.App/TeamsISO.App.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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.Integration
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src/TeamsISO.Console/TeamsISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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}.Release|Any CPU.ActiveCfg = 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
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{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}
|
||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
74
docs/REAL-TIME-RECORDING.md
Normal file
74
docs/REAL-TIME-RECORDING.md
Normal 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.
|
||||
|
|
@ -18,4 +18,18 @@ public sealed record IsoHealthStats(
|
|||
/// running; otherwise reflects the supervisor's current view.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
|
|
|||
184
src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs
Normal file
184
src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs
Normal 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
|
||||
|
|
@ -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());
|
||||
}
|
||||
225
src/tests/TeamsISO.App.Tests/Services/OscMessageTests.cs
Normal file
225
src/tests/TeamsISO.App.Tests/Services/OscMessageTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
108
src/tests/TeamsISO.App.Tests/Services/OutputNameTemplateTests.cs
Normal file
108
src/tests/TeamsISO.App.Tests/Services/OutputNameTemplateTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
42
src/tests/TeamsISO.App.Tests/TeamsISO.App.Tests.csproj
Normal file
42
src/tests/TeamsISO.App.Tests/TeamsISO.App.Tests.csproj
Normal 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>
|
||||
Loading…
Reference in a new issue