Compare commits
No commits in common. "bab29b02abe15891ff2d5868d5835c28ae6bc332" and "d2c0c2159f2e143673fd6b4b57def6512c37ded1" have entirely different histories.
bab29b02ab
...
d2c0c2159f
15 changed files with 31 additions and 808 deletions
|
|
@ -59,14 +59,14 @@ jobs:
|
|||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results
|
||||
path: '**/test-results.trx'
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage-report/
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
name: Release
|
||||
|
||||
# Triggered by pushing an annotated tag of the form v1.2.3 (or any v-prefixed
|
||||
# semver). The job runs on a Windows runner because building the WiX MSI
|
||||
# requires the WiX SDK which is supported on Windows; the engine + console
|
||||
# can in principle be built on Linux, but for simplicity we do everything in
|
||||
# one job here so the publish output paths line up for the installer.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
build-msi:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # tags + full history needed to derive the version
|
||||
|
||||
- name: Derive version from tag
|
||||
id: ver
|
||||
shell: pwsh
|
||||
run: |
|
||||
# GITHUB_REF is refs/tags/vX.Y.Z; strip the prefix.
|
||||
$tag = "${env:GITHUB_REF}".Replace('refs/tags/', '')
|
||||
$version = $tag.TrimStart('v')
|
||||
"tag=$tag" >> $env:GITHUB_OUTPUT
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
Write-Host "Building version $version (tag $tag)"
|
||||
|
||||
- name: Setup .NET 8
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Restore (Windows solution filter)
|
||||
run: dotnet restore TeamsISO.Windows.slnf
|
||||
|
||||
- name: Build (Release, treat warnings as errors)
|
||||
run: dotnet build TeamsISO.Windows.slnf --configuration Release --no-restore /p:Version=${{ steps.ver.outputs.version }}
|
||||
|
||||
- name: Run unit tests (excluding requires=ndi)
|
||||
run: >
|
||||
dotnet test TeamsISO.Windows.slnf
|
||||
--configuration Release
|
||||
--no-build
|
||||
--filter "Category!=ndi&requires!=ndi"
|
||||
|
||||
- name: Publish TeamsISO.App (framework-dependent, win-x64)
|
||||
run: >
|
||||
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj
|
||||
--configuration Release
|
||||
--runtime win-x64
|
||||
--self-contained false
|
||||
--output publish/TeamsISO
|
||||
/p:Version=${{ steps.ver.outputs.version }}
|
||||
|
||||
- name: Publish TeamsISO.Console (framework-dependent, win-x64)
|
||||
run: >
|
||||
dotnet publish src/TeamsISO.Console/TeamsISO.Console.csproj
|
||||
--configuration Release
|
||||
--runtime win-x64
|
||||
--self-contained false
|
||||
--output publish/TeamsISO-Console
|
||||
/p:Version=${{ steps.ver.outputs.version }}
|
||||
|
||||
- name: Build MSI installer
|
||||
run: >
|
||||
dotnet build installer/TeamsISO.Installer.wixproj
|
||||
--configuration Release
|
||||
/p:Version=${{ steps.ver.outputs.version }}
|
||||
|
||||
- name: Locate MSI
|
||||
id: msi
|
||||
shell: pwsh
|
||||
run: |
|
||||
$msi = Get-ChildItem -Path installer/bin -Recurse -Filter '*.msi' | Select-Object -First 1
|
||||
if (-not $msi) { throw "No MSI produced under installer/bin." }
|
||||
"path=$($msi.FullName)" >> $env:GITHUB_OUTPUT
|
||||
"name=$($msi.Name)" >> $env:GITHUB_OUTPUT
|
||||
Write-Host "MSI: $($msi.FullName) ($($msi.Length) bytes)"
|
||||
|
||||
- name: Upload MSI as workflow artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ steps.msi.outputs.name }}
|
||||
path: ${{ steps.msi.outputs.path }}
|
||||
|
||||
# Forgejo doesn't ship a stable upload-release-asset action, so we use
|
||||
# the REST API directly. This: (1) finds the release that the tag push
|
||||
# auto-created, (2) uploads the MSI as an asset on it. Requires that
|
||||
# the repo's "Create a release on tag push" setting is on, OR that the
|
||||
# release was created beforehand. If no release exists, we create one.
|
||||
- name: Attach MSI to release
|
||||
shell: pwsh
|
||||
env:
|
||||
FORGE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FORGE_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
MSI_PATH: ${{ steps.msi.outputs.path }}
|
||||
MSI_NAME: ${{ steps.msi.outputs.name }}
|
||||
run: |
|
||||
$headers = @{ Authorization = "token $env:FORGE_TOKEN" }
|
||||
|
||||
# Find the release for this tag.
|
||||
try {
|
||||
$release = Invoke-RestMethod -Method Get `
|
||||
-Uri "$env:FORGE_API/releases/tags/$env:TAG" -Headers $headers
|
||||
} catch {
|
||||
Write-Host "No release found for $env:TAG; creating one."
|
||||
$body = @{
|
||||
tag_name = $env:TAG
|
||||
name = "TeamsISO $env:TAG"
|
||||
body = "Automated build from tag $env:TAG."
|
||||
draft = $false
|
||||
prerelease = $env:TAG -match '-(alpha|beta|rc)'
|
||||
} | ConvertTo-Json
|
||||
$release = Invoke-RestMethod -Method Post `
|
||||
-Uri "$env:FORGE_API/releases" -Headers $headers `
|
||||
-ContentType 'application/json' -Body $body
|
||||
}
|
||||
|
||||
# Upload the MSI as an asset.
|
||||
$uploadUri = "$env:FORGE_API/releases/$($release.id)/assets?name=$env:MSI_NAME"
|
||||
curl.exe -fSL `
|
||||
-H "Authorization: token $env:FORGE_TOKEN" `
|
||||
-H "Content-Type: application/octet-stream" `
|
||||
--upload-file "$env:MSI_PATH" `
|
||||
"$uploadUri"
|
||||
Write-Host "Asset $env:MSI_NAME attached to release $env:TAG."
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
# Releasing TeamsISO
|
||||
|
||||
The release workflow at `.forgejo/workflows/release.yml` runs on **annotated tag pushes
|
||||
matching `v*.*.*`**. It builds, tests, publishes, packages an MSI, and uploads the
|
||||
MSI as a release asset.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A **Windows runner** registered to this Forgejo instance. WiX MSI builds require
|
||||
Windows; the existing CI runs on Linux for unit tests, but releases need a
|
||||
separate Windows runner. Register one with `forgejo-runner register` against a
|
||||
Windows host that has the .NET 8 SDK + WiX SDK access (the WiX SDK pulls itself
|
||||
via NuGet at build time, so no separate install).
|
||||
- The repository's **Create release on tag push** setting on (default), or skip it —
|
||||
the workflow will create the release if one doesn't exist.
|
||||
|
||||
## Cutting a release
|
||||
|
||||
```sh
|
||||
# Bump the version in Directory.Build.props if you haven't already.
|
||||
git tag -a v1.0.0 -m "TeamsISO 1.0.0"
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
The workflow will:
|
||||
|
||||
1. Restore + build `TeamsISO.Windows.slnf` in Release with the tag's version.
|
||||
2. Run unit tests (the `requires=ndi` integration tier is skipped — it needs a
|
||||
real NDI runtime which a CI runner won't have).
|
||||
3. Publish `TeamsISO.App` and `TeamsISO.Console` for `win-x64`,
|
||||
framework-dependent (.NET 8 Desktop runtime is the user's responsibility).
|
||||
4. Build `installer/TeamsISO.Installer.wixproj`, producing
|
||||
`TeamsISO-Setup-<version>.msi`.
|
||||
5. Upload the MSI as a workflow artifact (downloadable from the run page).
|
||||
6. Attach the MSI to the GitHub-style Release for the tag, creating the release
|
||||
first if it doesn't exist. Pre-release flag is set automatically when the
|
||||
tag contains `-alpha`, `-beta`, or `-rc`.
|
||||
|
||||
## Code signing (TODO)
|
||||
|
||||
The `wixproj` has a `SignOutput` property hook but no actual cert wiring. For a
|
||||
v1.0 release, sign the MSI with an EV cert before publishing:
|
||||
|
||||
1. Add a `SIGNING_CERT_BASE64` and `SIGNING_CERT_PASSWORD` to repo Secrets.
|
||||
2. Decode the cert into the runner's cert store at the start of the workflow.
|
||||
3. Set `/p:SignOutput=true` on the `dotnet build` of the wixproj and configure
|
||||
`signtool` invocation (the installer project will need a custom target).
|
||||
|
||||
Until that lands, downstream users will see the standard Windows SmartScreen
|
||||
warning on first launch — annoying but not blocking for early adopters.
|
||||
|
|
@ -75,15 +75,8 @@ public partial class App : Application
|
|||
|
||||
try
|
||||
{
|
||||
// WPF host: write to both console (visible if attached) and a rolling daily
|
||||
// file under %LOCALAPPDATA%\TeamsISO\Logs so users have something to grab when
|
||||
// they file an issue.
|
||||
_loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
|
||||
_loggerFactory = EngineLogging.CreateConsole(LogLevel.Information);
|
||||
var logger = _loggerFactory.CreateLogger<App>();
|
||||
logger.LogInformation(
|
||||
"TeamsISO.App starting up. Build: {Version}. Process: {Pid}.",
|
||||
typeof(App).Assembly.GetName().Version,
|
||||
Environment.ProcessId);
|
||||
|
||||
// ---- Preflight: NDI runtime ----
|
||||
try
|
||||
|
|
|
|||
|
|
@ -3,33 +3,14 @@
|
|||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels"
|
||||
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
Title="TeamsISO"
|
||||
Height="780" Width="1280"
|
||||
MinHeight="640" MinWidth="1080"
|
||||
Background="{DynamicResource Wd.Canvas}"
|
||||
WindowStyle="None"
|
||||
ResizeMode="CanResize"
|
||||
UseLayoutRounding="True"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
TextOptions.TextRenderingMode="ClearType">
|
||||
|
||||
<!--
|
||||
Chromeless window chrome — removes the standard Windows title bar so we can
|
||||
draw our own header that matches the Teams flush look. CaptionHeight=44 makes
|
||||
the top 44px of the window act as a drag region. ResizeBorderThickness=6
|
||||
keeps edge-resize working. UseAeroCaptionButtons=False disables the OS-drawn
|
||||
min/max/close (we draw our own).
|
||||
-->
|
||||
<shell:WindowChrome.WindowChrome>
|
||||
<shell:WindowChrome
|
||||
CaptionHeight="44"
|
||||
ResizeBorderThickness="6"
|
||||
CornerRadius="0"
|
||||
GlassFrameThickness="0"
|
||||
UseAeroCaptionButtons="False"/>
|
||||
</shell:WindowChrome.WindowChrome>
|
||||
|
||||
<Window.Resources>
|
||||
<conv:BoolToVisibilityConverter x:Key="BoolToVis"/>
|
||||
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
|
||||
|
|
@ -187,66 +168,21 @@
|
|||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center">
|
||||
<Border Style="{StaticResource Wd.Pill}"
|
||||
Background="{DynamicResource Wd.Status.LiveBg}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,16,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="7" Height="7"
|
||||
Fill="{DynamicResource Wd.Status.Live}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding StatusText}"
|
||||
Style="{StaticResource Wd.Text.Body}"
|
||||
FontSize="12"
|
||||
Margin="8,0,0,0"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Custom window control buttons. WindowChrome.IsHitTestVisibleInChrome
|
||||
tells the chrome that clicks here are real clicks, not drag-region
|
||||
clicks, so the buttons fire instead of starting a window drag.
|
||||
-->
|
||||
<Button x:Name="MinimizeButton"
|
||||
Style="{StaticResource Wd.Button.Caption}"
|
||||
Click="OnMinimize"
|
||||
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
||||
ToolTip="Minimize">
|
||||
<Path Data="M 0,5 L 10,5"
|
||||
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||
StrokeThickness="1.2"
|
||||
Width="10" Height="10"
|
||||
Stretch="None"/>
|
||||
</Button>
|
||||
<Button x:Name="MaximizeButton"
|
||||
Style="{StaticResource Wd.Button.Caption}"
|
||||
Click="OnMaximizeRestore"
|
||||
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
||||
ToolTip="Maximize">
|
||||
<Path x:Name="MaximizeIcon"
|
||||
Data="M 0,0 L 10,0 L 10,10 L 0,10 Z"
|
||||
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||
StrokeThickness="1.2"
|
||||
Fill="Transparent"
|
||||
Width="10" Height="10"
|
||||
Stretch="None"/>
|
||||
</Button>
|
||||
<Button x:Name="CloseButton"
|
||||
Style="{StaticResource Wd.Button.CaptionClose}"
|
||||
Click="OnClose"
|
||||
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
||||
ToolTip="Close">
|
||||
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
|
||||
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||
StrokeThickness="1.2"
|
||||
Width="10" Height="10"
|
||||
Stretch="None"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<Border Grid.Column="1"
|
||||
Style="{StaticResource Wd.Pill}"
|
||||
Background="{DynamicResource Wd.Status.LiveBg}"
|
||||
VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="7" Height="7"
|
||||
Fill="{DynamicResource Wd.Status.Live}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding StatusText}"
|
||||
Style="{StaticResource Wd.Text.Body}"
|
||||
FontSize="12"
|
||||
Margin="8,0,0,0"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
|
|
@ -340,34 +276,9 @@
|
|||
<DataGridTemplateColumn Header="Source" Width="*">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding SourceMachine}"
|
||||
Style="{StaticResource Wd.Text.Mono}"/>
|
||||
<TextBlock Text="{Binding IncomingResolution}"
|
||||
Style="{StaticResource Wd.Text.Mono}"
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource Wd.Text.Tertiary}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTemplateColumn Header="Live" Width="100">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
||||
FontSize="11">
|
||||
<Run Text="↓ "/>
|
||||
<Run Text="{Binding FramesIn}"/>
|
||||
</TextBlock>
|
||||
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource Wd.Text.Tertiary}">
|
||||
<Run Text="↑ "/>
|
||||
<Run Text="{Binding FramesOut}"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding SourceMachine}"
|
||||
Style="{StaticResource Wd.Text.Mono}"
|
||||
VerticalAlignment="Center"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Shapes;
|
||||
using TeamsISO.App.ViewModels;
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
|
@ -9,37 +8,10 @@ public partial class MainWindow : Window
|
|||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
StateChanged += OnWindowStateChanged;
|
||||
}
|
||||
|
||||
public MainWindow(MainViewModel viewModel) : this()
|
||||
{
|
||||
DataContext = viewModel;
|
||||
}
|
||||
|
||||
/// <summary>Custom min button — chrome'd window has no system caption buttons.</summary>
|
||||
private void OnMinimize(object sender, RoutedEventArgs e) =>
|
||||
WindowState = WindowState.Minimized;
|
||||
|
||||
/// <summary>Toggles maximize/restore. Bound to the maximize button + double-click on the drag region.</summary>
|
||||
private void OnMaximizeRestore(object sender, RoutedEventArgs e) =>
|
||||
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
|
||||
|
||||
/// <summary>Custom close button.</summary>
|
||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
/// <summary>
|
||||
/// Swap the maximize-button glyph between the "single rectangle" (when normal) and the
|
||||
/// "two-overlapping-rectangles" (when maximized) variants, matching the Windows 11
|
||||
/// caption-button conventions.
|
||||
/// </summary>
|
||||
private void OnWindowStateChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (FindName("MaximizeIcon") is not Path icon) return;
|
||||
icon.Data = WindowState == WindowState.Maximized
|
||||
// Two-rectangle "restore" glyph
|
||||
? System.Windows.Media.Geometry.Parse("M 2,0 L 10,0 L 10,8 M 0,2 L 8,2 L 8,10 L 0,10 Z")
|
||||
// Single-rectangle "maximize" glyph
|
||||
: System.Windows.Media.Geometry.Parse("M 0,0 L 10,0 L 10,10 L 0,10 Z");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,58 +186,6 @@
|
|||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Window-caption buttons: minimize / maximize / close. Match the Windows 11
|
||||
Teams look — slim 46x32, hover-tinted, Close turns red on hover.
|
||||
-->
|
||||
<Style x:Key="Wd.Button.Caption" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Width" Value="46"/>
|
||||
<Setter Property="Height" Value="32"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.SurfaceHover}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.SurfaceActive}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="Wd.Button.CaptionClose" TargetType="Button" BasedOn="{StaticResource Wd.Button.Caption}">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#C42B1C"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#A52A1F"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Rail-icon button: square 48x48, used on the Teams-style left rail -->
|
||||
<Style x:Key="Wd.Button.RailIcon" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||
private readonly Dispatcher _dispatcher;
|
||||
private readonly IDisposable _participantsSub;
|
||||
private readonly IDisposable _alertsSub;
|
||||
private readonly DispatcherTimer _statsTimer;
|
||||
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
|
||||
private string _statusText = "Starting…";
|
||||
|
||||
|
|
@ -50,33 +49,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||
{
|
||||
AlertBanner.Current = alert;
|
||||
});
|
||||
|
||||
// 1 Hz stats poll — pull live frame counters from each running pipeline and
|
||||
// push them onto the per-participant view models. Cheap (just reads volatile
|
||||
// fields on the engine side) and runs on the UI dispatcher so SetField is safe.
|
||||
_statsTimer = new DispatcherTimer(DispatcherPriority.Background, _dispatcher)
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1),
|
||||
};
|
||||
_statsTimer.Tick += OnStatsTick;
|
||||
_statsTimer.Start();
|
||||
}
|
||||
|
||||
private void OnStatsTick(object? sender, EventArgs e)
|
||||
{
|
||||
foreach (var vm in Participants)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stats = _controller.GetStats(vm.Id);
|
||||
vm.UpdateStats(stats);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Stats are advisory; never let a transient read failure
|
||||
// tear down the timer or surface an error to the user.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||
|
|
@ -127,8 +99,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
_statsTimer.Stop();
|
||||
_statsTimer.Tick -= OnStatsTick;
|
||||
_participantsSub.Dispose();
|
||||
_alertsSub.Dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Domain;
|
||||
using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats;
|
||||
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
||||
|
|
@ -36,29 +35,6 @@ public sealed class ParticipantViewModel : ObservableObject
|
|||
set => SetField(ref _isEnabled, value);
|
||||
}
|
||||
|
||||
private long _framesIn;
|
||||
private long _framesOut;
|
||||
private string _incomingResolution = "—";
|
||||
|
||||
/// <summary>Number of frames the receiver has captured so far.</summary>
|
||||
public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }
|
||||
|
||||
/// <summary>Number of frames the sender has emitted so far.</summary>
|
||||
public long FramesOut { get => _framesOut; set => SetField(ref _framesOut, value); }
|
||||
|
||||
/// <summary>Source resolution as "WxH", or em-dash when no frames have been seen yet.</summary>
|
||||
public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); }
|
||||
|
||||
/// <summary>Updates the live stats display from a controller-side snapshot.</summary>
|
||||
public void UpdateStats(IsoHealthStats stats)
|
||||
{
|
||||
FramesIn = stats.FramesIn;
|
||||
FramesOut = stats.FramesOut;
|
||||
IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0
|
||||
? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
|
||||
: "—";
|
||||
}
|
||||
|
||||
public bool IsProcessing
|
||||
{
|
||||
get => _isProcessing;
|
||||
|
|
|
|||
|
|
@ -97,15 +97,12 @@ public sealed class IsoController : IIsoController
|
|||
|
||||
public IsoHealthStats GetStats(Guid participantId)
|
||||
{
|
||||
IsoPipeline? pipeline;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_pipelines.TryGetValue(participantId, out pipeline))
|
||||
return IsoHealthStats.Empty;
|
||||
return _pipelines.TryGetValue(participantId, out var pipeline)
|
||||
? IsoHealthStats.Empty // production wires pipeline.Stats; Phase B-1 leaves this stub
|
||||
: IsoHealthStats.Empty;
|
||||
}
|
||||
// GetStats() is thread-safe and fast; pull outside the gate so a slow stats
|
||||
// read doesn't serialize the controller's other operations.
|
||||
return pipeline.GetStats();
|
||||
}
|
||||
|
||||
public async Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -5,35 +5,11 @@ using Serilog.Extensions.Logging;
|
|||
namespace TeamsISO.Engine.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Convenience factory for an <see cref="ILoggerFactory"/> wired to Serilog. Two flavors:
|
||||
/// <see cref="CreateConsole"/> for headless / Console-mode use (writes only to stdout),
|
||||
/// and <see cref="CreateDefault"/> for the WPF host (writes to stdout AND to a rolling
|
||||
/// daily file under <c>%LOCALAPPDATA%\TeamsISO\Logs</c> so support has something to ask for
|
||||
/// when things break).
|
||||
/// Convenience factory for an <see cref="ILoggerFactory"/> wired to Serilog's console sink.
|
||||
/// Phase A wires console-only; Phase C will add the rolling-file sink under %APPDATA%\TeamsISO\logs\.
|
||||
/// </summary>
|
||||
public static class EngineLogging
|
||||
{
|
||||
/// <summary>
|
||||
/// Default filename for the rolling-file sink. Serilog rotates on size and date with
|
||||
/// the suffix it inserts before the extension, so "teamsiso.log" becomes
|
||||
/// "teamsiso20260508.log", "teamsiso20260508_001.log", etc.
|
||||
/// </summary>
|
||||
private const string LogFileName = "teamsiso.log";
|
||||
|
||||
/// <summary>
|
||||
/// Default directory for the rolling-file sink:
|
||||
/// <c>%LOCALAPPDATA%\TeamsISO\Logs\</c>. Created on first write if it doesn't exist.
|
||||
/// </summary>
|
||||
public static string DefaultLogDirectory =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO",
|
||||
"Logs");
|
||||
|
||||
/// <summary>
|
||||
/// Console-only factory. Used by TeamsISO.Console where stdout is the right surface
|
||||
/// and a file sink would be redundant with shell redirection.
|
||||
/// </summary>
|
||||
public static ILoggerFactory CreateConsole(LogLevel minimum = LogLevel.Information)
|
||||
{
|
||||
var serilog = new LoggerConfiguration()
|
||||
|
|
@ -45,50 +21,6 @@ public static class EngineLogging
|
|||
return new SerilogLoggerFactory(serilog, dispose: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default factory for the desktop host. Writes to the console (visible when launched
|
||||
/// from a console attach) and to a rolling daily file at <see cref="DefaultLogDirectory"/>.
|
||||
/// File path is logged at startup to the console sink so users can find it.
|
||||
/// </summary>
|
||||
/// <param name="minimum">Minimum log level for both sinks.</param>
|
||||
/// <param name="logDirectoryOverride">Optional alternate log directory; null uses the default.</param>
|
||||
public static ILoggerFactory CreateDefault(
|
||||
LogLevel minimum = LogLevel.Information,
|
||||
string? logDirectoryOverride = null)
|
||||
{
|
||||
var dir = logDirectoryOverride ?? DefaultLogDirectory;
|
||||
Directory.CreateDirectory(dir); // idempotent; safe under contention
|
||||
var logPath = Path.Combine(dir, LogFileName);
|
||||
|
||||
var serilog = new LoggerConfiguration()
|
||||
.MinimumLevel.Is(MapLevel(minimum))
|
||||
.Enrich.WithProperty("Component", "TeamsISO.Engine")
|
||||
.WriteTo.Console(outputTemplate:
|
||||
"[{Timestamp:HH:mm:ss} {Level:u3}] [{Component}] {Message:lj}{NewLine}{Exception}")
|
||||
.WriteTo.File(
|
||||
path: logPath,
|
||||
outputTemplate:
|
||||
"[{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level:u3}] [{Component}] {Message:lj}{NewLine}{Exception}",
|
||||
rollingInterval: Serilog.RollingInterval.Day,
|
||||
retainedFileCountLimit: 14, // two weeks of history is plenty
|
||||
fileSizeLimitBytes: 10 * 1024 * 1024, // 10 MB per file before roll-over
|
||||
rollOnFileSizeLimit: true,
|
||||
shared: false,
|
||||
flushToDiskInterval: TimeSpan.FromMilliseconds(250)) // flush often so support tools see live tails
|
||||
.CreateLogger();
|
||||
|
||||
// Set the Serilog static singleton too. SerilogLoggerFactory writes through the
|
||||
// explicit logger we pass in, but anything in the engine that reaches for
|
||||
// Serilog.Log.* directly would otherwise miss our sinks. Belt & suspenders.
|
||||
Serilog.Log.Logger = serilog;
|
||||
|
||||
var factory = new SerilogLoggerFactory(serilog, dispose: true);
|
||||
// Surface the path at startup so support has it without digging.
|
||||
factory.CreateLogger("TeamsISO.Engine")
|
||||
.LogInformation("Diagnostic logs writing to: {LogDirectory}", dir);
|
||||
return factory;
|
||||
}
|
||||
|
||||
private static Serilog.Events.LogEventLevel MapLevel(LogLevel level) => level switch
|
||||
{
|
||||
LogLevel.Trace => Serilog.Events.LogEventLevel.Verbose,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ namespace TeamsISO.Engine.Pipeline;
|
|||
/// </summary>
|
||||
public sealed class IsoPipeline : IAsyncDisposable
|
||||
{
|
||||
private Func<CancellationToken, Task> _runInner;
|
||||
private readonly Func<CancellationToken, Task> _runInner;
|
||||
private readonly ExponentialBackoff _backoff;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
|
||||
private readonly ILogger<IsoPipeline> _logger;
|
||||
|
|
@ -20,44 +20,10 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
private Task? _supervisorTask;
|
||||
private int _consecutiveFailures;
|
||||
|
||||
// Refs to the currently-live receiver and sender, set by the inner loop on each
|
||||
// restart. Reads via Volatile.Read are safe from any thread (UI's stats poll).
|
||||
private NdiReceiver? _liveReceiver;
|
||||
private NdiSender? _liveSender;
|
||||
private RawFrame? _lastReceivedFrame;
|
||||
private DateTimeOffset? _lastReceivedAt;
|
||||
|
||||
public Guid ParticipantId { get; }
|
||||
public IsoState State { get; private set; } = IsoState.Idle;
|
||||
public int ConsecutiveFailures => _consecutiveFailures;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the pipeline's current health. Safe to call from any thread; values
|
||||
/// are inherently a moment-in-time view and may change immediately. Returns
|
||||
/// <see cref="Domain.IsoHealthStats.Empty"/> when no inner pipeline is currently
|
||||
/// running (e.g. between supervisor restarts or after final failure).
|
||||
/// </summary>
|
||||
public Domain.IsoHealthStats GetStats()
|
||||
{
|
||||
var receiver = Volatile.Read(ref _liveReceiver);
|
||||
var sender = Volatile.Read(ref _liveSender);
|
||||
var lastFrame = Volatile.Read(ref _lastReceivedFrame);
|
||||
var lastAt = _lastReceivedAt;
|
||||
|
||||
if (receiver is null || sender is null)
|
||||
return Domain.IsoHealthStats.Empty;
|
||||
|
||||
return new Domain.IsoHealthStats(
|
||||
FramesIn: receiver.FramesCaptured,
|
||||
FramesOut: sender.FramesSent,
|
||||
FramesDropped: 0, // FrameProcessor currently doesn't surface drops; wire later
|
||||
FramesDuplicated: 0, // same — last-frame re-emits aren't counted yet
|
||||
LastFrameAt: lastAt,
|
||||
IncomingFps: 0, // running rate computation is a follow-up
|
||||
IncomingWidth: lastFrame?.Width ?? 0,
|
||||
IncomingHeight: lastFrame?.Height ?? 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test ctor. The caller supplies the inner runner directly so failures and lifetimes
|
||||
/// can be controlled from a unit test.
|
||||
|
|
@ -88,32 +54,10 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
Func<TimeSpan, CancellationToken, Task> delay,
|
||||
ILoggerFactory loggerFactory)
|
||||
: this(config.ParticipantId,
|
||||
// The inner-runner closure captures `this` so the receiver/sender
|
||||
// wired by RunInnerPipelineAsync can be published to instance fields
|
||||
// for stats reads.
|
||||
default(Func<CancellationToken, Task>)!,
|
||||
ct => RunInnerPipelineAsync(config, interop, scaler, frameClock, loggerFactory, ct),
|
||||
backoff,
|
||||
delay,
|
||||
loggerFactory)
|
||||
{
|
||||
_runInner = ct => RunInnerPipelineAsync(
|
||||
config, interop, scaler, frameClock, loggerFactory, ct,
|
||||
onLive: (recv, send) =>
|
||||
{
|
||||
Volatile.Write(ref _liveReceiver, recv);
|
||||
Volatile.Write(ref _liveSender, send);
|
||||
},
|
||||
onClear: () =>
|
||||
{
|
||||
Volatile.Write(ref _liveReceiver, null);
|
||||
Volatile.Write(ref _liveSender, null);
|
||||
},
|
||||
onFrame: frame =>
|
||||
{
|
||||
Volatile.Write(ref _lastReceivedFrame, frame);
|
||||
_lastReceivedAt = DateTimeOffset.UtcNow;
|
||||
});
|
||||
}
|
||||
loggerFactory) { }
|
||||
|
||||
/// <summary>Starts the supervisor. Returns immediately; pipeline runs in the background.</summary>
|
||||
public Task StartAsync()
|
||||
|
|
@ -195,12 +139,6 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
/// <summary>
|
||||
/// Default inner pipeline: spins up receiver → processor → sender on bounded channels
|
||||
/// and awaits all three. Throws if any of them throws.
|
||||
///
|
||||
/// The optional <paramref name="onLive"/> / <paramref name="onClear"/> / <paramref name="onFrame"/>
|
||||
/// callbacks let the outer <see cref="IsoPipeline"/> publish references to the live
|
||||
/// receiver and sender (so it can read counters from any thread for health stats)
|
||||
/// and observe the most recent received frame (so source resolution / last-seen-at
|
||||
/// can be surfaced in the UI). All three are no-ops by default.
|
||||
/// </summary>
|
||||
private static async Task RunInnerPipelineAsync(
|
||||
IsoPipelineConfig config,
|
||||
|
|
@ -208,10 +146,7 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
IFrameScaler scaler,
|
||||
IFrameClock frameClock,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken ct,
|
||||
Action<NdiReceiver, NdiSender>? onLive = null,
|
||||
Action? onClear = null,
|
||||
Action<RawFrame>? onFrame = null)
|
||||
CancellationToken ct)
|
||||
{
|
||||
var rawChannel = Channel.CreateBounded<RawFrame>(new BoundedChannelOptions(config.RawChannelCapacity)
|
||||
{
|
||||
|
|
@ -226,20 +161,12 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
SingleWriter = true,
|
||||
});
|
||||
|
||||
// Tap the raw frames as they flow into the channel so the host can show "last
|
||||
// frame at" / source resolution without us re-implementing a probe.
|
||||
var rawWriter = onFrame is null
|
||||
? rawChannel.Writer
|
||||
: new TappedChannelWriter<RawFrame>(rawChannel.Writer, onFrame);
|
||||
|
||||
using var receiver = new NdiReceiver(
|
||||
interop, config.SourceName, rawWriter, loggerFactory.CreateLogger<NdiReceiver>());
|
||||
interop, config.SourceName, rawChannel.Writer, loggerFactory.CreateLogger<NdiReceiver>());
|
||||
using var sender = new NdiSender(
|
||||
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>(),
|
||||
config.OutputGroups);
|
||||
|
||||
onLive?.Invoke(receiver, sender);
|
||||
|
||||
var processor = new FrameProcessor(
|
||||
config.Settings, scaler, new SolidFrameRenderer(),
|
||||
frameClock, rawChannel.Reader, processedChannel.Writer,
|
||||
|
|
@ -257,30 +184,9 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
{
|
||||
rawChannel.Writer.TryComplete();
|
||||
processedChannel.Writer.TryComplete();
|
||||
onClear?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Channel-writer wrapper that fires a callback on every successful write but
|
||||
/// otherwise behaves identically to the inner writer. Used to tap the raw-frame
|
||||
/// stream for stats without entangling the receiver with the stats API.
|
||||
/// </summary>
|
||||
private sealed class TappedChannelWriter<T> : ChannelWriter<T>
|
||||
{
|
||||
private readonly ChannelWriter<T> _inner;
|
||||
private readonly Action<T> _onWrite;
|
||||
public TappedChannelWriter(ChannelWriter<T> inner, Action<T> onWrite) { _inner = inner; _onWrite = onWrite; }
|
||||
public override bool TryWrite(T item)
|
||||
{
|
||||
if (_inner.TryWrite(item)) { _onWrite(item); return true; }
|
||||
return false;
|
||||
}
|
||||
public override ValueTask<bool> WaitToWriteAsync(CancellationToken ct = default)
|
||||
=> _inner.WaitToWriteAsync(ct);
|
||||
public override bool TryComplete(Exception? error = null) => _inner.TryComplete(error);
|
||||
}
|
||||
|
||||
private static async Task ProcessorLoopAsync(FrameProcessor processor, IFrameClock clock, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
<PackageReference Include="Serilog" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="System.Reactive" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,145 +0,0 @@
|
|||
using System.Runtime.Versioning;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TeamsISO.Engine.Domain;
|
||||
using TeamsISO.Engine.NdiInterop;
|
||||
using TeamsISO.Engine.Pipeline;
|
||||
|
||||
namespace TeamsISO.Engine.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end pipeline test: synthesize a fake Teams source, run an
|
||||
/// <see cref="IsoPipeline"/> against it, and assert the resulting normalized
|
||||
/// output stream yields frames at the configured 1080p target size. This is
|
||||
/// the closest unit-test analog to "did the engine actually do its job?"
|
||||
/// — exercises NdiReceiver, FrameProcessor, ManagedNearestNeighborFrameScaler,
|
||||
/// and NdiSender as a single chain through the production P/Invoke shim.
|
||||
///
|
||||
/// Marked [Trait("requires","ndi")] so default CI skips it; run locally with
|
||||
/// dotnet test --filter requires=ndi
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public class PipelineFrameRoundTripTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("requires", "ndi")]
|
||||
public async Task Pipeline_ReceivesFromTeamsLikeSource_AndEmitsAt1080pTarget()
|
||||
{
|
||||
// ─── 1. Source pump ─────────────────────────────────────────────────
|
||||
// Spin up an NDI sender broadcasting an unambiguous, unique source
|
||||
// name. We use the literal "Teams - <token>" form so this works even
|
||||
// if a parallel test happens to be running at the same time.
|
||||
var token = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
||||
var sourceShortName = $"Teams - FrameRT_{token}";
|
||||
var outputName = $"TEAMSISO_RT_{token}";
|
||||
|
||||
using var interop = new NdiInteropPInvoke(NullLogger<NdiInteropPInvoke>.Instance);
|
||||
using var fakeSender = interop.CreateSender(sourceShortName);
|
||||
|
||||
// Synthesize 640x360 BGRA frames in cyan so the receive side has
|
||||
// something obviously-not-zero to pick up. Pump at ~30 fps; the engine
|
||||
// is configured for 59.94 fps target so it'll either re-emit our
|
||||
// frames (slate threshold not yet reached) or interpolate via the
|
||||
// last-frame re-emit path — either is fine for the dimensions check.
|
||||
const int srcW = 640, srcH = 360;
|
||||
var pixelBuffer = new byte[srcW * srcH * 4];
|
||||
for (var i = 0; i < pixelBuffer.Length; i += 4)
|
||||
{
|
||||
pixelBuffer[i + 0] = 0xF0; // B
|
||||
pixelBuffer[i + 1] = 0xED; // G
|
||||
pixelBuffer[i + 2] = 0x97; // R → ~Wild Dragon cyan #97EDF0 in BGRA
|
||||
pixelBuffer[i + 3] = 0xFF; // A
|
||||
}
|
||||
|
||||
using var pumpCts = new CancellationTokenSource();
|
||||
var pumpTask = Task.Run(() =>
|
||||
{
|
||||
var pixelMemory = new ReadOnlyMemory<byte>(pixelBuffer);
|
||||
while (!pumpCts.IsCancellationRequested)
|
||||
{
|
||||
var frame = new ProcessedFrame(srcW, srcH, DateTime.UtcNow.Ticks, pixelMemory, PixelFormat.Bgra);
|
||||
try { interop.SendFrame(fakeSender, frame); }
|
||||
catch { /* sender may be torn down during cancellation */ }
|
||||
Thread.Sleep(33); // ~30 fps source
|
||||
}
|
||||
}, pumpCts.Token);
|
||||
|
||||
try
|
||||
{
|
||||
// ─── 2. Wait for our fake source to be discoverable ─────────────
|
||||
using var finder = interop.CreateFinder();
|
||||
var fullSourceName = await WaitForSourceAsync(interop, finder, sourceShortName, TimeSpan.FromSeconds(5));
|
||||
fullSourceName.Should().NotBeNullOrEmpty(
|
||||
because: "fake Teams sender should be visible to a same-process finder within 5s");
|
||||
|
||||
// ─── 3. Build the production IsoPipeline against it ────────────
|
||||
var settings = FrameProcessingSettings.Default; // 1080p, 59.94 fps, Pillarbox
|
||||
var clock = new PeriodicTimerFrameClock(settings.FramerateHz);
|
||||
var scaler = new ManagedNearestNeighborFrameScaler();
|
||||
var config = new IsoPipelineConfig(Guid.NewGuid(), fullSourceName!, outputName, settings);
|
||||
|
||||
await using var pipeline = new IsoPipeline(
|
||||
config, interop, scaler, clock,
|
||||
ExponentialBackoff.Default,
|
||||
(delay, ct) => Task.Delay(delay, ct),
|
||||
NullLoggerFactory.Instance);
|
||||
await pipeline.StartAsync();
|
||||
|
||||
// ─── 4. Wait for the pipeline's output sender to appear ────────
|
||||
var outputFullName = await WaitForSourceAsync(interop, finder, outputName, TimeSpan.FromSeconds(5));
|
||||
outputFullName.Should().NotBeNullOrEmpty(
|
||||
because: "IsoPipeline must broadcast a TEAMSISO_* sender within 5s of StartAsync");
|
||||
|
||||
// ─── 5. Receive a frame from the normalized output ─────────────
|
||||
using var receiver = interop.CreateReceiver(outputFullName!);
|
||||
RawFrame? captured = null;
|
||||
var captureDeadline = DateTime.UtcNow.AddSeconds(8);
|
||||
while (DateTime.UtcNow < captureDeadline)
|
||||
{
|
||||
captured = interop.CaptureFrame(receiver, 1000);
|
||||
if (captured is not null) break;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
captured.Should().NotBeNull(
|
||||
because: "the pipeline output must yield at least one normalized frame within 8s");
|
||||
|
||||
// ─── 6. Assert the normalized dimensions match the target ──────
|
||||
var (expectedW, expectedH) = settings.ResolutionSize;
|
||||
captured!.Width.Should().Be(expectedW,
|
||||
because: $"the pipeline normalizes to {expectedW}x{expectedH} per FrameProcessingSettings.Default");
|
||||
captured.Height.Should().Be(expectedH);
|
||||
|
||||
// ─── 7. Stop pipeline cleanly ──────────────────────────────────
|
||||
await pipeline.StopAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
pumpCts.Cancel();
|
||||
try { await pumpTask; }
|
||||
catch (OperationCanceledException) { /* expected */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls the finder until a source whose name contains <paramref name="needle"/>
|
||||
/// appears, or the timeout elapses. Returns the full NDI name (machine + paren
|
||||
/// short-name) on success, or null on timeout.
|
||||
/// </summary>
|
||||
private static async Task<string?> WaitForSourceAsync(
|
||||
NdiInteropPInvoke interop,
|
||||
TeamsISO.Engine.Interop.NdiFindHandle finder,
|
||||
string needle,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var sources = interop.GetCurrentSources(finder);
|
||||
var match = sources.FirstOrDefault(s => s.Contains(needle, StringComparison.Ordinal));
|
||||
if (match is not null) return match;
|
||||
await Task.Delay(150);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TeamsISO.Engine.Logging;
|
||||
|
||||
namespace TeamsISO.Engine.Tests.Logging;
|
||||
|
||||
public class EngineLoggingTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public EngineLoggingTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"teamsiso-log-{Guid.NewGuid():N}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDefault_AllLoggers_WriteToFile()
|
||||
{
|
||||
// Multiple ILoggers from the same factory must all land in the file sink —
|
||||
// catches regressions in CreateDefault wiring (e.g. if SerilogLoggerFactory
|
||||
// swaps to Log.Logger silently and our static singleton isn't set).
|
||||
var factory = EngineLogging.CreateDefault(LogLevel.Information, logDirectoryOverride: _dir);
|
||||
factory.CreateLogger<EngineLoggingTests>().LogInformation("typed-logger-line");
|
||||
factory.CreateLogger("Custom.Category").LogInformation("named-logger-line");
|
||||
factory.Dispose(); // disposes the wrapped Serilog logger -> flush + close file
|
||||
|
||||
var logFiles = Directory.GetFiles(_dir, "*.log");
|
||||
logFiles.Should().HaveCount(1);
|
||||
|
||||
var content = File.ReadAllText(logFiles[0]);
|
||||
content.Should().Contain("typed-logger-line",
|
||||
because: "logs from a typed CreateLogger<T> must reach the file sink");
|
||||
content.Should().Contain("named-logger-line",
|
||||
because: "logs from a named CreateLogger(string) must reach the file sink");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDefault_LogsAtBelowMinimumLevel_AreSuppressed()
|
||||
{
|
||||
var factory = EngineLogging.CreateDefault(LogLevel.Warning, logDirectoryOverride: _dir);
|
||||
factory.CreateLogger("X").LogInformation("info-should-be-suppressed");
|
||||
factory.CreateLogger("X").LogWarning("warn-should-appear");
|
||||
factory.Dispose();
|
||||
|
||||
var content = File.ReadAllText(Directory.GetFiles(_dir, "*.log").Single());
|
||||
content.Should().NotContain("info-should-be-suppressed");
|
||||
content.Should().Contain("warn-should-appear");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue