2847 lines
85 KiB
Markdown
2847 lines
85 KiB
Markdown
# TeamsISO Phase A — Engine Foundation Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Stand up the TeamsISO solution and ship a fully unit-tested NDI engine library — domain model, source parser, participant tracker with rename heuristic, frame processor with closest-frame timing, config store, and the `INdiInterop` test fake — without a single line of P/Invoke. Phase A ends with green CI on Linux runners and ≥80% coverage on `TeamsISO.Engine`.
|
||
|
||
**Architecture:** Six-project .NET 8 solution per the spec. The engine library targets cross-platform `net8.0` so unit tests run on Linux CI. Production NDI P/Invoke is deferred to Phase B; in Phase A every NDI surface is reached through `INdiInterop`, with a `FakeNdiInterop` exercising the engine's behavior. WPF app and integration tests get scaffold projects that compile but stay empty — they'll fill in later phases.
|
||
|
||
**Tech Stack:** .NET 8, C# 12, xUnit, FluentAssertions, Microsoft.Extensions.Logging, Serilog (console sink for Phase A), `System.Threading.Channels`, `System.Reactive` for `IObservable<T>` plumbing. Build via `dotnet`. CI on Forgejo Actions, Linux runner.
|
||
|
||
**Source spec:** `docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md`
|
||
|
||
**Repo:** `forge.wilddragon.net/zgaetano/teamsiso` (private, default branch `main`)
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
teamsiso/
|
||
├── .editorconfig
|
||
├── .gitignore
|
||
├── .forgejo/workflows/ci.yml
|
||
├── Directory.Build.props
|
||
├── README.md
|
||
├── TeamsISO.sln
|
||
├── docs/
|
||
│ └── superpowers/
|
||
│ ├── specs/2026-05-07-teamsiso-v1-design.md (already committed)
|
||
│ └── plans/2026-05-07-teamsiso-phase-a-engine-foundation.md
|
||
└── src/
|
||
├── TeamsISO.Engine/
|
||
│ ├── TeamsISO.Engine.csproj
|
||
│ ├── Domain/
|
||
│ │ ├── NdiSource.cs (NdiSource record + Kind enum)
|
||
│ │ ├── Participant.cs (Participant record)
|
||
│ │ ├── IsoAssignment.cs (IsoAssignment record)
|
||
│ │ ├── IsoOutput.cs (IsoOutput record + IsoState enum)
|
||
│ │ ├── FrameProcessingSettings.cs (settings record + enums)
|
||
│ │ ├── IsoHealthStats.cs
|
||
│ │ ├── EngineConfig.cs
|
||
│ │ └── EngineAlert.cs
|
||
│ ├── Discovery/
|
||
│ │ ├── DiscoveryEvent.cs (Added/Removed/Renamed)
|
||
│ │ ├── NdiSourceParser.cs
|
||
│ │ ├── ParticipantTracker.cs
|
||
│ │ └── NdiDiscoveryService.cs
|
||
│ ├── Pipeline/
|
||
│ │ ├── RawFrame.cs
|
||
│ │ ├── ProcessedFrame.cs
|
||
│ │ ├── IFrameClock.cs
|
||
│ │ ├── PeriodicTimerFrameClock.cs
|
||
│ │ ├── SolidFrameRenderer.cs
|
||
│ │ └── FrameProcessor.cs
|
||
│ ├── Persistence/
|
||
│ │ └── ConfigStore.cs
|
||
│ └── Interop/
|
||
│ └── INdiInterop.cs (interface lives in Engine; impl in NdiInterop project)
|
||
├── TeamsISO.Engine.NdiInterop/
|
||
│ └── TeamsISO.Engine.NdiInterop.csproj (empty for Phase A)
|
||
├── TeamsISO.App/
|
||
│ ├── TeamsISO.App.csproj
|
||
│ ├── App.xaml / App.xaml.cs
|
||
│ └── MainWindow.xaml / MainWindow.xaml.cs (placeholder)
|
||
└── tests/
|
||
├── TeamsISO.Engine.Tests/
|
||
│ ├── TeamsISO.Engine.Tests.csproj
|
||
│ ├── Fakes/
|
||
│ │ ├── FakeNdiInterop.cs
|
||
│ │ └── FakeFrameClock.cs
|
||
│ ├── Domain/NdiSourceParserTests.cs
|
||
│ ├── Discovery/ParticipantTrackerTests.cs
|
||
│ ├── Discovery/NdiDiscoveryServiceTests.cs
|
||
│ ├── Pipeline/FrameProcessorTests.cs
|
||
│ └── Persistence/ConfigStoreTests.cs
|
||
└── TeamsISO.Engine.IntegrationTests/
|
||
└── TeamsISO.Engine.IntegrationTests.csproj (empty for Phase A)
|
||
```
|
||
|
||
**Decomposition logic:**
|
||
- Domain types live next to each other under `Domain/` because they change together.
|
||
- Discovery (`NdiSourceParser`, `ParticipantTracker`, `NdiDiscoveryService`) is one cohesive subsystem; one folder.
|
||
- Pipeline (`FrameProcessor`, frame types, clock, slate renderer) is its own subsystem.
|
||
- `INdiInterop` lives in `Engine/Interop/` (the engine consumes it). The implementation project `TeamsISO.Engine.NdiInterop` references the engine and provides the production P/Invoke implementation in Phase B.
|
||
- WPF app and IntegrationTests projects exist as scaffolds so the .sln is complete and CI can validate the full graph builds.
|
||
|
||
---
|
||
|
||
## Conventions used by every task
|
||
|
||
- **TDD where applicable:** For any task that adds engine behavior, write the failing test first, run it to confirm failure, implement, run again to confirm pass, then commit. Tasks that scaffold projects skip the TDD loop.
|
||
- **Commits are small.** Each task ends with one commit. The commit message follows Conventional Commits: `feat(scope): subject` / `test(scope): subject` / `chore: subject`.
|
||
- **All code targets `net8.0`** unless explicitly noted (the WPF app needs `net8.0-windows`).
|
||
- **Nullable reference types are on** project-wide (set in `Directory.Build.props`).
|
||
- **TreatWarningsAsErrors is on** (set in `Directory.Build.props`).
|
||
- **Records are immutable.** Use positional records for simple value types and `with` for derived copies.
|
||
|
||
---
|
||
|
||
## Task 1: Initialize repo working directory and global build props
|
||
|
||
**Files:**
|
||
- Create: `.gitignore`
|
||
- Create: `.editorconfig`
|
||
- Create: `Directory.Build.props`
|
||
- Create: `README.md`
|
||
|
||
- [ ] **Step 1: Confirm clone is up to date**
|
||
|
||
Run:
|
||
```
|
||
cd /Users/zacgaetano/Documents/Claude/Projects/Team\ Dragon/teamsiso
|
||
git status
|
||
```
|
||
Expected: clean working tree on `main`, nothing to commit. (If the local clone is missing, clone via `git clone https://forge.wilddragon.net/zgaetano/teamsiso.git` after configuring credentials.)
|
||
|
||
- [ ] **Step 2: Create `.gitignore`**
|
||
|
||
Create `.gitignore` with the standard .NET ignore set:
|
||
|
||
```gitignore
|
||
# .NET
|
||
bin/
|
||
obj/
|
||
*.user
|
||
*.suo
|
||
.vs/
|
||
.vscode/
|
||
*.swp
|
||
*.bak
|
||
*.tmp
|
||
|
||
# Test outputs
|
||
TestResults/
|
||
coverage*.xml
|
||
*.coverage
|
||
*.coveragexml
|
||
|
||
# Tooling
|
||
.idea/
|
||
*.DotSettings.user
|
||
|
||
# Build artifacts
|
||
artifacts/
|
||
publish/
|
||
*.nupkg
|
||
*.snupkg
|
||
|
||
# OS
|
||
.DS_Store
|
||
Thumbs.db
|
||
```
|
||
|
||
- [ ] **Step 3: Create `.editorconfig`**
|
||
|
||
Create `.editorconfig` with .NET defaults:
|
||
|
||
```ini
|
||
root = true
|
||
|
||
[*]
|
||
charset = utf-8
|
||
end_of_line = lf
|
||
indent_style = space
|
||
insert_final_newline = true
|
||
trim_trailing_whitespace = true
|
||
|
||
[*.{cs,csx,vb,vbx}]
|
||
indent_size = 4
|
||
|
||
[*.{xml,csproj,props,targets}]
|
||
indent_size = 2
|
||
|
||
[*.{md,yml,yaml,json}]
|
||
indent_size = 2
|
||
|
||
[*.cs]
|
||
dotnet_sort_system_directives_first = true
|
||
csharp_style_namespace_declarations = file_scoped:warning
|
||
csharp_style_var_when_type_is_apparent = true:suggestion
|
||
dotnet_style_qualification_for_field = false:suggestion
|
||
dotnet_style_qualification_for_property = false:suggestion
|
||
dotnet_style_qualification_for_method = false:suggestion
|
||
dotnet_style_qualification_for_event = false:suggestion
|
||
```
|
||
|
||
- [ ] **Step 4: Create `Directory.Build.props`**
|
||
|
||
Create `Directory.Build.props` so every project inherits the same settings:
|
||
|
||
```xml
|
||
<Project>
|
||
<PropertyGroup>
|
||
<LangVersion>latest</LangVersion>
|
||
<Nullable>enable</Nullable>
|
||
<ImplicitUsings>enable</ImplicitUsings>
|
||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||
<AnalysisLevel>latest</AnalysisLevel>
|
||
<Version>1.0.0-alpha.0</Version>
|
||
<Authors>Wild Dragon LLC</Authors>
|
||
<Company>Wild Dragon LLC</Company>
|
||
<Product>TeamsISO</Product>
|
||
<Copyright>Copyright © Wild Dragon LLC 2026</Copyright>
|
||
</PropertyGroup>
|
||
</Project>
|
||
```
|
||
|
||
- [ ] **Step 5: Create `README.md`**
|
||
|
||
Create `README.md` with a project summary:
|
||
|
||
```markdown
|
||
# TeamsISO
|
||
|
||
Per-Participant NDI ISO Controller for Microsoft Teams.
|
||
|
||
TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a live-production environment. It receives each participant's NDI stream, normalizes framerate and resolution per a configured target, and re-emits clean, individually-addressable NDI sources for ingestion into a switcher (vMix, OBS, Ross, hardware capture).
|
||
|
||
## Status
|
||
|
||
Pre-1.0. See `docs/superpowers/specs/` for the active spec and `docs/superpowers/plans/` for in-flight implementation plans.
|
||
|
||
## Build
|
||
|
||
Requires .NET 8 SDK.
|
||
|
||
dotnet build
|
||
dotnet test
|
||
|
||
## License
|
||
|
||
Proprietary, © Wild Dragon LLC 2026.
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add .gitignore .editorconfig Directory.Build.props README.md
|
||
git commit -m "chore: scaffold repo conventions and global build props"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Create the empty solution and src/tests folders
|
||
|
||
**Files:**
|
||
- Create: `TeamsISO.sln`
|
||
- Create: `src/.gitkeep`, `src/tests/.gitkeep`
|
||
|
||
- [ ] **Step 1: Create the solution file**
|
||
|
||
Run:
|
||
```
|
||
dotnet new sln -n TeamsISO
|
||
mkdir -p src src/tests
|
||
touch src/.gitkeep src/tests/.gitkeep
|
||
```
|
||
|
||
- [ ] **Step 2: Verify solution builds (empty)**
|
||
|
||
Run:
|
||
```
|
||
dotnet build TeamsISO.sln
|
||
```
|
||
Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add TeamsISO.sln src/.gitkeep src/tests/.gitkeep
|
||
git commit -m "chore: add empty TeamsISO solution"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Create `TeamsISO.Engine` class library
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/TeamsISO.Engine.csproj`
|
||
|
||
- [ ] **Step 1: Create the project**
|
||
|
||
Run:
|
||
```
|
||
cd src
|
||
dotnet new classlib -n TeamsISO.Engine -f net8.0
|
||
cd ..
|
||
```
|
||
|
||
- [ ] **Step 2: Add NuGet dependencies**
|
||
|
||
Run:
|
||
```
|
||
cd src/TeamsISO.Engine
|
||
dotnet add package Microsoft.Extensions.Logging.Abstractions --version 8.0.0
|
||
dotnet add package System.Reactive --version 6.0.0
|
||
cd ../..
|
||
```
|
||
|
||
- [ ] **Step 3: Delete the `Class1.cs` placeholder**
|
||
|
||
Run:
|
||
```
|
||
rm src/TeamsISO.Engine/Class1.cs
|
||
```
|
||
|
||
- [ ] **Step 4: Add to solution**
|
||
|
||
Run:
|
||
```
|
||
dotnet sln add src/TeamsISO.Engine/TeamsISO.Engine.csproj
|
||
```
|
||
|
||
- [ ] **Step 5: Build**
|
||
|
||
Run:
|
||
```
|
||
dotnet build TeamsISO.sln
|
||
```
|
||
Expected: success, 0 warnings, 0 errors.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine/ TeamsISO.sln
|
||
git commit -m "feat(engine): scaffold TeamsISO.Engine class library"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Create `TeamsISO.Engine.NdiInterop` class library (empty for Phase A)
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj`
|
||
- Create: `src/TeamsISO.Engine.NdiInterop/Placeholder.cs`
|
||
|
||
- [ ] **Step 1: Create the project**
|
||
|
||
Run:
|
||
```
|
||
cd src
|
||
dotnet new classlib -n TeamsISO.Engine.NdiInterop -f net8.0
|
||
cd ..
|
||
rm src/TeamsISO.Engine.NdiInterop/Class1.cs
|
||
```
|
||
|
||
- [ ] **Step 2: Reference the engine**
|
||
|
||
Run:
|
||
```
|
||
cd src/TeamsISO.Engine.NdiInterop
|
||
dotnet add reference ../TeamsISO.Engine/TeamsISO.Engine.csproj
|
||
cd ../..
|
||
```
|
||
|
||
- [ ] **Step 3: Add a placeholder file so the assembly has at least one type**
|
||
|
||
Create `src/TeamsISO.Engine.NdiInterop/Placeholder.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.NdiInterop;
|
||
|
||
/// <summary>
|
||
/// Phase A placeholder. The production P/Invoke implementations of <c>INdiInterop</c>
|
||
/// will live in this assembly, added in Phase B.
|
||
/// </summary>
|
||
internal static class Placeholder
|
||
{
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Add to solution and build**
|
||
|
||
Run:
|
||
```
|
||
dotnet sln add src/TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj
|
||
dotnet build TeamsISO.sln
|
||
```
|
||
Expected: success.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine.NdiInterop/ TeamsISO.sln
|
||
git commit -m "feat(interop): scaffold TeamsISO.Engine.NdiInterop project"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Create `TeamsISO.Engine.Tests` xUnit project
|
||
|
||
**Files:**
|
||
- Create: `src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj`
|
||
- Create: `src/tests/TeamsISO.Engine.Tests/SmokeTest.cs`
|
||
|
||
- [ ] **Step 1: Create the project**
|
||
|
||
Run:
|
||
```
|
||
cd src/tests
|
||
dotnet new xunit -n TeamsISO.Engine.Tests -f net8.0
|
||
cd ../..
|
||
rm src/tests/TeamsISO.Engine.Tests/UnitTest1.cs
|
||
```
|
||
|
||
- [ ] **Step 2: Add references and packages**
|
||
|
||
Run:
|
||
```
|
||
cd src/tests/TeamsISO.Engine.Tests
|
||
dotnet add reference ../../TeamsISO.Engine/TeamsISO.Engine.csproj
|
||
dotnet add package FluentAssertions --version 6.12.0
|
||
dotnet add package coverlet.collector --version 6.0.0
|
||
cd ../../..
|
||
```
|
||
|
||
- [ ] **Step 3: Add a smoke test**
|
||
|
||
Create `src/tests/TeamsISO.Engine.Tests/SmokeTest.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Tests;
|
||
|
||
public class SmokeTest
|
||
{
|
||
[Fact]
|
||
public void TestProjectIsWired()
|
||
{
|
||
Assert.True(true);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Add to solution and run tests**
|
||
|
||
Run:
|
||
```
|
||
dotnet sln add src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj
|
||
dotnet test TeamsISO.sln
|
||
```
|
||
Expected: `Passed: 1, Failed: 0`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/tests/TeamsISO.Engine.Tests/ TeamsISO.sln
|
||
git commit -m "test(engine): scaffold TeamsISO.Engine.Tests xUnit project"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Scaffold `TeamsISO.App` (WPF) and `TeamsISO.Engine.IntegrationTests`
|
||
|
||
These are stubs for Phase A — they exist so the solution graph is complete and CI can validate it.
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.App/TeamsISO.App.csproj` (and minimal WPF skeleton)
|
||
- Create: `src/tests/TeamsISO.Engine.IntegrationTests/TeamsISO.Engine.IntegrationTests.csproj`
|
||
|
||
- [ ] **Step 1: Create the WPF app project**
|
||
|
||
Run:
|
||
```
|
||
cd src
|
||
dotnet new wpf -n TeamsISO.App -f net8.0
|
||
cd ..
|
||
```
|
||
|
||
- [ ] **Step 2: Reference the engine**
|
||
|
||
Run:
|
||
```
|
||
cd src/TeamsISO.App
|
||
dotnet add reference ../TeamsISO.Engine/TeamsISO.Engine.csproj
|
||
dotnet add reference ../TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj
|
||
cd ../..
|
||
```
|
||
|
||
- [ ] **Step 3: Create the integration tests project**
|
||
|
||
Run:
|
||
```
|
||
cd src/tests
|
||
dotnet new xunit -n TeamsISO.Engine.IntegrationTests -f net8.0
|
||
cd ../..
|
||
rm src/tests/TeamsISO.Engine.IntegrationTests/UnitTest1.cs
|
||
cd src/tests/TeamsISO.Engine.IntegrationTests
|
||
dotnet add reference ../../TeamsISO.Engine/TeamsISO.Engine.csproj
|
||
dotnet add reference ../../TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj
|
||
dotnet add package FluentAssertions --version 6.12.0
|
||
cd ../../..
|
||
```
|
||
|
||
- [ ] **Step 4: Add a placeholder fact in the integration test project so it has at least one test**
|
||
|
||
Create `src/tests/TeamsISO.Engine.IntegrationTests/IntegrationTestsScaffold.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.IntegrationTests;
|
||
|
||
public class IntegrationTestsScaffold
|
||
{
|
||
[Fact(Skip = "Phase A: integration tests require NDI runtime — added in Phase B.")]
|
||
[Trait("requires", "ndi")]
|
||
public void ScaffoldFactSkipsCleanly()
|
||
{
|
||
Assert.True(true);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Add both to the solution and build**
|
||
|
||
Run:
|
||
```
|
||
dotnet sln add src/TeamsISO.App/TeamsISO.App.csproj
|
||
dotnet sln add src/tests/TeamsISO.Engine.IntegrationTests/TeamsISO.Engine.IntegrationTests.csproj
|
||
dotnet build TeamsISO.sln
|
||
dotnet test TeamsISO.sln
|
||
```
|
||
Expected: build succeeds; `Passed: 1, Failed: 0, Skipped: 1` (the skipped one is the integration scaffold).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.App/ src/tests/TeamsISO.Engine.IntegrationTests/ TeamsISO.sln
|
||
git commit -m "chore: scaffold WPF app and integration test projects"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Set up Forgejo Actions CI on Linux
|
||
|
||
**Files:**
|
||
- Create: `.forgejo/workflows/ci.yml`
|
||
|
||
- [ ] **Step 1: Create the CI workflow**
|
||
|
||
Create `.forgejo/workflows/ci.yml`:
|
||
|
||
```yaml
|
||
name: CI
|
||
|
||
on:
|
||
push:
|
||
branches: [main]
|
||
pull_request:
|
||
branches: [main]
|
||
|
||
jobs:
|
||
build-and-test:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v4
|
||
|
||
- name: Setup .NET 8
|
||
uses: actions/setup-dotnet@v4
|
||
with:
|
||
dotnet-version: 8.0.x
|
||
|
||
- name: Restore
|
||
run: dotnet restore TeamsISO.sln
|
||
|
||
- name: Build (Release, treat warnings as errors)
|
||
run: dotnet build TeamsISO.sln --configuration Release --no-restore
|
||
|
||
- name: Test (excluding requires=ndi)
|
||
run: >
|
||
dotnet test TeamsISO.sln
|
||
--configuration Release
|
||
--no-build
|
||
--logger "trx;LogFileName=test-results.trx"
|
||
--collect:"XPlat Code Coverage"
|
||
--filter "Category!=ndi&requires!=ndi"
|
||
|
||
- name: Upload test results
|
||
if: always()
|
||
uses: actions/upload-artifact@v4
|
||
with:
|
||
name: test-results
|
||
path: '**/test-results.trx'
|
||
```
|
||
|
||
- [ ] **Step 2: Commit and push to trigger first CI run**
|
||
|
||
```bash
|
||
mkdir -p .forgejo/workflows
|
||
git add .forgejo/workflows/ci.yml
|
||
git commit -m "ci: add Forgejo Actions build-and-test workflow"
|
||
git push origin main
|
||
```
|
||
|
||
- [ ] **Step 3: Verify the run goes green**
|
||
|
||
Open `https://forge.wilddragon.net/zgaetano/teamsiso/actions` in a browser. Confirm the latest run on `main` succeeded.
|
||
|
||
If `actions/setup-dotnet@v4` is unavailable on the Forgejo Actions runner, swap to a manual setup step:
|
||
|
||
```yaml
|
||
- name: Install .NET 8
|
||
run: |
|
||
curl -sSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh
|
||
chmod +x dotnet-install.sh
|
||
./dotnet-install.sh --channel 8.0 --install-dir $HOME/.dotnet
|
||
echo "$HOME/.dotnet" >> $GITHUB_PATH
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Domain enums
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/Domain/Enums.cs`
|
||
- Create: `src/tests/TeamsISO.Engine.Tests/Domain/EnumSanityTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing sanity test**
|
||
|
||
Create `src/tests/TeamsISO.Engine.Tests/Domain/EnumSanityTests.cs`:
|
||
|
||
```csharp
|
||
using TeamsISO.Engine.Domain;
|
||
|
||
namespace TeamsISO.Engine.Tests.Domain;
|
||
|
||
public class EnumSanityTests
|
||
{
|
||
[Fact]
|
||
public void NdiSourceKind_HasExpectedMembers()
|
||
{
|
||
var values = Enum.GetValues<NdiSourceKind>();
|
||
values.Should().Contain(new[]
|
||
{
|
||
NdiSourceKind.Participant,
|
||
NdiSourceKind.ActiveSpeaker,
|
||
NdiSourceKind.Audio,
|
||
NdiSourceKind.ScreenShare
|
||
});
|
||
}
|
||
|
||
[Fact]
|
||
public void IsoState_HasExpectedMembers()
|
||
{
|
||
var values = Enum.GetValues<IsoState>();
|
||
values.Should().Contain(new[]
|
||
{
|
||
IsoState.Idle,
|
||
IsoState.Receiving,
|
||
IsoState.Sending,
|
||
IsoState.NoSignal,
|
||
IsoState.Error
|
||
});
|
||
}
|
||
|
||
[Fact]
|
||
public void TargetFramerate_HasAllSupportedRates()
|
||
{
|
||
var values = Enum.GetValues<TargetFramerate>();
|
||
values.Should().HaveCount(8); // 23.976, 24, 25, 29.97, 30, 50, 59.94, 60
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, expect failure**
|
||
|
||
Run:
|
||
```
|
||
dotnet test --filter "FullyQualifiedName~EnumSanityTests"
|
||
```
|
||
Expected: compilation errors (`NdiSourceKind` etc. don't exist yet).
|
||
|
||
- [ ] **Step 3: Implement the enums**
|
||
|
||
Create `src/TeamsISO.Engine/Domain/Enums.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Domain;
|
||
|
||
public enum NdiSourceKind
|
||
{
|
||
Participant,
|
||
ActiveSpeaker,
|
||
Audio,
|
||
ScreenShare
|
||
}
|
||
|
||
public enum IsoState
|
||
{
|
||
Idle,
|
||
Receiving,
|
||
Sending,
|
||
NoSignal,
|
||
Error
|
||
}
|
||
|
||
public enum AspectMode
|
||
{
|
||
Pillarbox,
|
||
Letterbox,
|
||
Stretch
|
||
}
|
||
|
||
public enum AudioMode
|
||
{
|
||
Auto,
|
||
Isolated,
|
||
Mixed
|
||
}
|
||
|
||
public enum TargetFramerate
|
||
{
|
||
Fps23_976,
|
||
Fps24,
|
||
Fps25,
|
||
Fps29_97,
|
||
Fps30,
|
||
Fps50,
|
||
Fps59_94,
|
||
Fps60
|
||
}
|
||
|
||
public enum TargetResolution
|
||
{
|
||
R720p,
|
||
R1080p,
|
||
R4K
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, expect pass**
|
||
|
||
Run:
|
||
```
|
||
dotnet test --filter "FullyQualifiedName~EnumSanityTests"
|
||
```
|
||
Expected: 3 pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine/Domain/Enums.cs src/tests/TeamsISO.Engine.Tests/Domain/EnumSanityTests.cs
|
||
git commit -m "feat(domain): add core enums (NdiSourceKind, IsoState, AspectMode, AudioMode, TargetFramerate, TargetResolution)"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: `NdiSource` record + `NdiSourceParser` (TDD)
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/Domain/NdiSource.cs`
|
||
- Create: `src/TeamsISO.Engine/Discovery/NdiSourceParser.cs`
|
||
- Create: `src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing parser tests**
|
||
|
||
Create `src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs`:
|
||
|
||
```csharp
|
||
using TeamsISO.Engine.Discovery;
|
||
using TeamsISO.Engine.Domain;
|
||
|
||
namespace TeamsISO.Engine.Tests.Domain;
|
||
|
||
public class NdiSourceParserTests
|
||
{
|
||
[Theory]
|
||
[InlineData("WORKSTATION-01 (Teams - Jane Doe)", "WORKSTATION-01", NdiSourceKind.Participant, "Jane Doe")]
|
||
[InlineData("PROD-PC (Teams - Élise O'Connor)", "PROD-PC", NdiSourceKind.Participant, "Élise O'Connor")]
|
||
[InlineData("HOST (Teams - Smith, Bob (PM))", "HOST", NdiSourceKind.Participant, "Smith, Bob (PM)")]
|
||
public void Parse_Participant_ExtractsMachineAndDisplayName(
|
||
string fullName, string expectedMachine, NdiSourceKind expectedKind, string expectedDisplay)
|
||
{
|
||
var result = NdiSourceParser.Parse(fullName);
|
||
|
||
result.MachineName.Should().Be(expectedMachine);
|
||
result.Kind.Should().Be(expectedKind);
|
||
result.DisplayName.Should().Be(expectedDisplay);
|
||
result.FullName.Should().Be(fullName);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("HOST (Teams)", NdiSourceKind.ActiveSpeaker)]
|
||
[InlineData("HOST (Teams Audio)", NdiSourceKind.Audio)]
|
||
[InlineData("HOST (Teams Screen Share)", NdiSourceKind.ScreenShare)]
|
||
public void Parse_NonParticipantKinds_ClassifyCorrectly(string fullName, NdiSourceKind expectedKind)
|
||
{
|
||
var result = NdiSourceParser.Parse(fullName);
|
||
|
||
result.Kind.Should().Be(expectedKind);
|
||
result.DisplayName.Should().BeNull();
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("Plain NDI Source")] // not a Teams source at all
|
||
[InlineData("HOST (Some Other Software)")] // wrong app prefix
|
||
[InlineData("(Teams - No Machine)")] // empty machine
|
||
public void Parse_NonTeamsSource_ReturnsNull(string fullName)
|
||
{
|
||
var result = NdiSourceParser.Parse(fullName);
|
||
result.Should().BeNull();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, expect failure**
|
||
|
||
Run:
|
||
```
|
||
dotnet test --filter "FullyQualifiedName~NdiSourceParserTests"
|
||
```
|
||
Expected: compilation errors (types missing).
|
||
|
||
- [ ] **Step 3: Implement `NdiSource`**
|
||
|
||
Create `src/TeamsISO.Engine/Domain/NdiSource.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Domain;
|
||
|
||
/// <summary>
|
||
/// Raw discovery record parsed from an NDI source string emitted by Microsoft Teams.
|
||
/// </summary>
|
||
public sealed record NdiSource(
|
||
string FullName,
|
||
string MachineName,
|
||
NdiSourceKind Kind,
|
||
string? DisplayName);
|
||
```
|
||
|
||
- [ ] **Step 4: Implement `NdiSourceParser`**
|
||
|
||
Create `src/TeamsISO.Engine/Discovery/NdiSourceParser.cs`:
|
||
|
||
```csharp
|
||
using TeamsISO.Engine.Domain;
|
||
|
||
namespace TeamsISO.Engine.Discovery;
|
||
|
||
/// <summary>
|
||
/// Parses NDI source strings emitted by Microsoft Teams.
|
||
///
|
||
/// Examples Teams emits:
|
||
/// "MACHINE (Teams - Display Name)"
|
||
/// "MACHINE (Teams)" — auto-mixed active speaker
|
||
/// "MACHINE (Teams Audio)" — audio-only mix
|
||
/// "MACHINE (Teams Screen Share)" — screen share
|
||
/// </summary>
|
||
public static class NdiSourceParser
|
||
{
|
||
public static NdiSource? Parse(string fullName)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(fullName))
|
||
return null;
|
||
|
||
// Find the last '(' so machine names can themselves contain parens.
|
||
var openParen = fullName.LastIndexOf('(');
|
||
if (openParen <= 0 || !fullName.EndsWith(')'))
|
||
return null;
|
||
|
||
var machine = fullName[..openParen].TrimEnd();
|
||
if (machine.Length == 0)
|
||
return null;
|
||
|
||
var inner = fullName.Substring(openParen + 1, fullName.Length - openParen - 2).Trim();
|
||
|
||
if (!inner.StartsWith("Teams", StringComparison.Ordinal))
|
||
return null;
|
||
|
||
// "Teams" alone → ActiveSpeaker
|
||
if (inner == "Teams")
|
||
return new NdiSource(fullName, machine, NdiSourceKind.ActiveSpeaker, DisplayName: null);
|
||
|
||
if (inner == "Teams Audio")
|
||
return new NdiSource(fullName, machine, NdiSourceKind.Audio, DisplayName: null);
|
||
|
||
if (inner == "Teams Screen Share")
|
||
return new NdiSource(fullName, machine, NdiSourceKind.ScreenShare, DisplayName: null);
|
||
|
||
// "Teams - <display>" → Participant
|
||
const string prefix = "Teams - ";
|
||
if (inner.StartsWith(prefix, StringComparison.Ordinal))
|
||
{
|
||
var display = inner[prefix.Length..].Trim();
|
||
if (display.Length == 0)
|
||
return null;
|
||
return new NdiSource(fullName, machine, NdiSourceKind.Participant, display);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests, expect pass**
|
||
|
||
Run:
|
||
```
|
||
dotnet test --filter "FullyQualifiedName~NdiSourceParserTests"
|
||
```
|
||
Expected: all 9 (3 + 3 + 3) pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine/Domain/NdiSource.cs src/TeamsISO.Engine/Discovery/NdiSourceParser.cs src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs
|
||
git commit -m "feat(discovery): add NdiSource record and Teams source string parser"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: `Participant`, `IsoAssignment`, `IsoOutput`, `IsoHealthStats`
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/Domain/Participant.cs`
|
||
- Create: `src/TeamsISO.Engine/Domain/IsoAssignment.cs`
|
||
- Create: `src/TeamsISO.Engine/Domain/IsoOutput.cs`
|
||
- Create: `src/TeamsISO.Engine/Domain/IsoHealthStats.cs`
|
||
|
||
These are pure data records; no behavior under test. We compile-check via the existing tests.
|
||
|
||
- [ ] **Step 1: Implement `Participant`**
|
||
|
||
Create `src/TeamsISO.Engine/Domain/Participant.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Domain;
|
||
|
||
/// <summary>
|
||
/// Operator-facing identity for a single human in the meeting.
|
||
/// <c>Id</c> is engine-assigned and stable across the rename heuristic.
|
||
/// </summary>
|
||
public sealed record Participant(
|
||
Guid Id,
|
||
string DisplayName,
|
||
NdiSource? CurrentSource,
|
||
DateTimeOffset FirstSeen,
|
||
DateTimeOffset LastSeen);
|
||
```
|
||
|
||
- [ ] **Step 2: Implement `IsoAssignment`**
|
||
|
||
Create `src/TeamsISO.Engine/Domain/IsoAssignment.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Domain;
|
||
|
||
/// <summary>
|
||
/// Operator's intent for an ISO output. Persisted to <c>config.json</c>.
|
||
/// </summary>
|
||
public sealed record IsoAssignment(
|
||
Guid ParticipantId,
|
||
bool IsEnabled,
|
||
string? CustomOutputName);
|
||
```
|
||
|
||
- [ ] **Step 3: Implement `IsoHealthStats`**
|
||
|
||
Create `src/TeamsISO.Engine/Domain/IsoHealthStats.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Domain;
|
||
|
||
public sealed record IsoHealthStats(
|
||
long FramesIn,
|
||
long FramesOut,
|
||
long FramesDropped,
|
||
long FramesDuplicated,
|
||
DateTimeOffset? LastFrameAt,
|
||
double IncomingFps,
|
||
int IncomingWidth,
|
||
int IncomingHeight)
|
||
{
|
||
public static readonly IsoHealthStats Empty =
|
||
new(0, 0, 0, 0, null, 0, 0, 0);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement `IsoOutput`**
|
||
|
||
Create `src/TeamsISO.Engine/Domain/IsoOutput.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Domain;
|
||
|
||
public sealed record IsoOutput(
|
||
Guid ParticipantId,
|
||
string EffectiveOutputName,
|
||
IsoHealthStats Stats,
|
||
IsoState State);
|
||
```
|
||
|
||
- [ ] **Step 5: Verify the solution still builds**
|
||
|
||
Run:
|
||
```
|
||
dotnet build TeamsISO.sln
|
||
```
|
||
Expected: success.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine/Domain/Participant.cs src/TeamsISO.Engine/Domain/IsoAssignment.cs src/TeamsISO.Engine/Domain/IsoOutput.cs src/TeamsISO.Engine/Domain/IsoHealthStats.cs
|
||
git commit -m "feat(domain): add Participant, IsoAssignment, IsoOutput, IsoHealthStats records"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: `FrameProcessingSettings`, `EngineConfig`, `EngineAlert`
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/Domain/FrameProcessingSettings.cs`
|
||
- Create: `src/TeamsISO.Engine/Domain/EngineConfig.cs`
|
||
- Create: `src/TeamsISO.Engine/Domain/EngineAlert.cs`
|
||
|
||
- [ ] **Step 1: Implement `FrameProcessingSettings`**
|
||
|
||
Create `src/TeamsISO.Engine/Domain/FrameProcessingSettings.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Domain;
|
||
|
||
public sealed record FrameProcessingSettings(
|
||
TargetFramerate Framerate,
|
||
TargetResolution Resolution,
|
||
AspectMode Aspect,
|
||
AudioMode Audio)
|
||
{
|
||
public static readonly FrameProcessingSettings Default =
|
||
new(TargetFramerate.Fps30, TargetResolution.R1080p, AspectMode.Pillarbox, AudioMode.Auto);
|
||
|
||
/// <summary>Returns the framerate enum value as a numeric frames-per-second.</summary>
|
||
public double FramerateHz => Framerate switch
|
||
{
|
||
TargetFramerate.Fps23_976 => 24000.0 / 1001.0,
|
||
TargetFramerate.Fps24 => 24.0,
|
||
TargetFramerate.Fps25 => 25.0,
|
||
TargetFramerate.Fps29_97 => 30000.0 / 1001.0,
|
||
TargetFramerate.Fps30 => 30.0,
|
||
TargetFramerate.Fps50 => 50.0,
|
||
TargetFramerate.Fps59_94 => 60000.0 / 1001.0,
|
||
TargetFramerate.Fps60 => 60.0,
|
||
_ => throw new InvalidOperationException($"Unknown framerate: {Framerate}")
|
||
};
|
||
|
||
/// <summary>Returns the resolution as (width, height).</summary>
|
||
public (int Width, int Height) ResolutionSize => Resolution switch
|
||
{
|
||
TargetResolution.R720p => (1280, 720),
|
||
TargetResolution.R1080p => (1920, 1080),
|
||
TargetResolution.R4K => (3840, 2160),
|
||
_ => throw new InvalidOperationException($"Unknown resolution: {Resolution}")
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Implement `EngineConfig`**
|
||
|
||
Create `src/TeamsISO.Engine/Domain/EngineConfig.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Domain;
|
||
|
||
public sealed record EngineConfig(
|
||
FrameProcessingSettings Global,
|
||
IReadOnlyList<IsoAssignment> Assignments)
|
||
{
|
||
public static readonly EngineConfig Default =
|
||
new(FrameProcessingSettings.Default, Array.Empty<IsoAssignment>());
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Implement `EngineAlert`**
|
||
|
||
Create `src/TeamsISO.Engine/Domain/EngineAlert.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Domain;
|
||
|
||
/// <summary>
|
||
/// Structured engine alerts for UI banner display and ops logging.
|
||
/// </summary>
|
||
public abstract record EngineAlert(string Message)
|
||
{
|
||
public sealed record NdiRuntimeMismatch(string DetectedVersion, string ExpectedVersion)
|
||
: EngineAlert($"NDI runtime version mismatch: detected {DetectedVersion}, expected {ExpectedVersion}.");
|
||
|
||
public sealed record OutputNameCollision(string Name)
|
||
: EngineAlert($"Another TeamsISO instance on the LAN is emitting an output named '{Name}'.");
|
||
|
||
public sealed record PipelineError(Guid ParticipantId, string Reason)
|
||
: EngineAlert($"Pipeline {ParticipantId} entered Error: {Reason}");
|
||
|
||
public sealed record ConfigSaveFailed(string Reason)
|
||
: EngineAlert($"Failed to save configuration: {Reason}");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Build the solution**
|
||
|
||
Run:
|
||
```
|
||
dotnet build TeamsISO.sln
|
||
```
|
||
Expected: success.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine/Domain/FrameProcessingSettings.cs src/TeamsISO.Engine/Domain/EngineConfig.cs src/TeamsISO.Engine/Domain/EngineAlert.cs
|
||
git commit -m "feat(domain): add FrameProcessingSettings, EngineConfig, EngineAlert"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: `ConfigStore` — JSON persistence with atomic writes (TDD)
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/Persistence/ConfigStore.cs`
|
||
- Create: `src/tests/TeamsISO.Engine.Tests/Persistence/ConfigStoreTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `src/tests/TeamsISO.Engine.Tests/Persistence/ConfigStoreTests.cs`:
|
||
|
||
```csharp
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using TeamsISO.Engine.Domain;
|
||
using TeamsISO.Engine.Persistence;
|
||
|
||
namespace TeamsISO.Engine.Tests.Persistence;
|
||
|
||
public class ConfigStoreTests : IDisposable
|
||
{
|
||
private readonly string _dir;
|
||
|
||
public ConfigStoreTests()
|
||
{
|
||
_dir = Path.Combine(Path.GetTempPath(), $"teamsiso-tests-{Guid.NewGuid():N}");
|
||
Directory.CreateDirectory(_dir);
|
||
}
|
||
|
||
public void Dispose() => Directory.Delete(_dir, recursive: true);
|
||
|
||
private ConfigStore NewStore() =>
|
||
new(Path.Combine(_dir, "config.json"), NullLogger<ConfigStore>.Instance);
|
||
|
||
[Fact]
|
||
public void Load_WhenFileMissing_ReturnsDefault()
|
||
{
|
||
var store = NewStore();
|
||
|
||
var config = store.Load();
|
||
|
||
config.Should().Be(EngineConfig.Default);
|
||
}
|
||
|
||
[Fact]
|
||
public void SaveThenLoad_RoundTripsExactly()
|
||
{
|
||
var store = NewStore();
|
||
var input = new EngineConfig(
|
||
new FrameProcessingSettings(TargetFramerate.Fps59_94, TargetResolution.R720p,
|
||
AspectMode.Letterbox, AudioMode.Isolated),
|
||
new[]
|
||
{
|
||
new IsoAssignment(Guid.NewGuid(), IsEnabled: true, CustomOutputName: "TEAMSISO_A"),
|
||
new IsoAssignment(Guid.NewGuid(), IsEnabled: false, CustomOutputName: null),
|
||
});
|
||
|
||
store.Save(input);
|
||
var loaded = store.Load();
|
||
|
||
loaded.Should().BeEquivalentTo(input);
|
||
}
|
||
|
||
[Fact]
|
||
public void Load_WhenFileCorrupt_ReturnsDefault()
|
||
{
|
||
var path = Path.Combine(_dir, "config.json");
|
||
File.WriteAllText(path, "not valid json {{{");
|
||
|
||
var store = NewStore();
|
||
|
||
var config = store.Load();
|
||
config.Should().Be(EngineConfig.Default);
|
||
}
|
||
|
||
[Fact]
|
||
public void Save_WritesAtomically_NoPartialFileVisible()
|
||
{
|
||
var store = NewStore();
|
||
var path = Path.Combine(_dir, "config.json");
|
||
|
||
// Pre-existing valid config
|
||
store.Save(EngineConfig.Default);
|
||
var firstWrite = File.ReadAllText(path);
|
||
|
||
// Save a new value; verify the file is replaced atomically (no temp files left behind)
|
||
store.Save(new EngineConfig(
|
||
FrameProcessingSettings.Default with { Framerate = TargetFramerate.Fps60 },
|
||
Array.Empty<IsoAssignment>()));
|
||
|
||
File.Exists(path).Should().BeTrue();
|
||
File.ReadAllText(path).Should().NotBe(firstWrite);
|
||
|
||
// No leftover temp files
|
||
Directory.GetFiles(_dir).Should().HaveCount(1);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, expect failure**
|
||
|
||
Run:
|
||
```
|
||
dotnet test --filter "FullyQualifiedName~ConfigStoreTests"
|
||
```
|
||
Expected: compilation errors (`ConfigStore` doesn't exist).
|
||
|
||
- [ ] **Step 3: Implement `ConfigStore`**
|
||
|
||
Create `src/TeamsISO.Engine/Persistence/ConfigStore.cs`:
|
||
|
||
```csharp
|
||
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
using Microsoft.Extensions.Logging;
|
||
using TeamsISO.Engine.Domain;
|
||
|
||
namespace TeamsISO.Engine.Persistence;
|
||
|
||
/// <summary>
|
||
/// Loads and saves <see cref="EngineConfig"/> as JSON.
|
||
/// Writes are atomic: serialize to a temp file in the same directory, then <c>File.Move</c> with overwrite.
|
||
/// </summary>
|
||
public sealed class ConfigStore
|
||
{
|
||
private static readonly JsonSerializerOptions Options = new()
|
||
{
|
||
WriteIndented = true,
|
||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||
Converters = { new JsonStringEnumConverter() }
|
||
};
|
||
|
||
private readonly string _path;
|
||
private readonly ILogger<ConfigStore> _logger;
|
||
|
||
public ConfigStore(string path, ILogger<ConfigStore> logger)
|
||
{
|
||
_path = path;
|
||
_logger = logger;
|
||
}
|
||
|
||
public EngineConfig Load()
|
||
{
|
||
if (!File.Exists(_path))
|
||
{
|
||
_logger.LogInformation("Config file not found at {Path}; using defaults.", _path);
|
||
return EngineConfig.Default;
|
||
}
|
||
|
||
try
|
||
{
|
||
var json = File.ReadAllText(_path);
|
||
var loaded = JsonSerializer.Deserialize<EngineConfig>(json, Options);
|
||
return loaded ?? EngineConfig.Default;
|
||
}
|
||
catch (JsonException ex)
|
||
{
|
||
_logger.LogWarning(ex, "Config file at {Path} is not valid JSON; using defaults.", _path);
|
||
return EngineConfig.Default;
|
||
}
|
||
}
|
||
|
||
public void Save(EngineConfig config)
|
||
{
|
||
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||
var temp = _path + ".tmp";
|
||
var json = JsonSerializer.Serialize(config, Options);
|
||
File.WriteAllText(temp, json);
|
||
File.Move(temp, _path, overwrite: true);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, expect pass**
|
||
|
||
Run:
|
||
```
|
||
dotnet test --filter "FullyQualifiedName~ConfigStoreTests"
|
||
```
|
||
Expected: all 4 pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine/Persistence/ConfigStore.cs src/tests/TeamsISO.Engine.Tests/Persistence/ConfigStoreTests.cs
|
||
git commit -m "feat(persistence): add ConfigStore with atomic JSON writes and corruption-safe load"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: `INdiInterop` interface
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/Interop/INdiInterop.cs`
|
||
- Create: `src/TeamsISO.Engine/Interop/NdiFindHandle.cs`
|
||
- Create: `src/TeamsISO.Engine/Interop/NdiReceiverHandle.cs`
|
||
- Create: `src/TeamsISO.Engine/Interop/NdiSenderHandle.cs`
|
||
|
||
The interface is the test seam: every NDI SDK call routes through it. In Phase A we have only a fake; in Phase B `TeamsISO.Engine.NdiInterop` ships the real P/Invoke.
|
||
|
||
- [ ] **Step 1: Define the handle marker types**
|
||
|
||
Create `src/TeamsISO.Engine/Interop/NdiFindHandle.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Interop;
|
||
|
||
/// <summary>Opaque handle to an NDI Find instance. Implementation-private.</summary>
|
||
public abstract class NdiFindHandle : IDisposable
|
||
{
|
||
public abstract void Dispose();
|
||
}
|
||
```
|
||
|
||
Create `src/TeamsISO.Engine/Interop/NdiReceiverHandle.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Interop;
|
||
|
||
public abstract class NdiReceiverHandle : IDisposable
|
||
{
|
||
public abstract void Dispose();
|
||
}
|
||
```
|
||
|
||
Create `src/TeamsISO.Engine/Interop/NdiSenderHandle.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Interop;
|
||
|
||
public abstract class NdiSenderHandle : IDisposable
|
||
{
|
||
public abstract void Dispose();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Define the interface**
|
||
|
||
Create `src/TeamsISO.Engine/Interop/INdiInterop.cs`:
|
||
|
||
```csharp
|
||
using TeamsISO.Engine.Domain;
|
||
using TeamsISO.Engine.Pipeline;
|
||
|
||
namespace TeamsISO.Engine.Interop;
|
||
|
||
/// <summary>
|
||
/// Test seam over the NDI SDK. Production: P/Invoke shim. Tests: <c>FakeNdiInterop</c>.
|
||
/// All methods are synchronous; the engine threads are responsible for orchestration.
|
||
/// </summary>
|
||
public interface INdiInterop
|
||
{
|
||
// ----- Discovery -----
|
||
NdiFindHandle CreateFinder();
|
||
|
||
/// <summary>Snapshots the currently-known sources visible to the finder.</summary>
|
||
IReadOnlyList<string> GetCurrentSources(NdiFindHandle finder);
|
||
|
||
// ----- Receive -----
|
||
NdiReceiverHandle CreateReceiver(string sourceFullName);
|
||
|
||
/// <summary>
|
||
/// Blocks for up to <paramref name="timeoutMs"/> waiting for a frame.
|
||
/// Returns null on timeout. Returned <see cref="RawFrame"/> ownership transfers to the caller.
|
||
/// </summary>
|
||
RawFrame? CaptureFrame(NdiReceiverHandle receiver, int timeoutMs);
|
||
|
||
// ----- Send -----
|
||
NdiSenderHandle CreateSender(string outputName);
|
||
void SendFrame(NdiSenderHandle sender, ProcessedFrame frame);
|
||
|
||
// ----- Runtime probe -----
|
||
string GetRuntimeVersion();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Build to confirm types resolve (will fail because RawFrame/ProcessedFrame don't exist yet)**
|
||
|
||
Run:
|
||
```
|
||
dotnet build TeamsISO.sln
|
||
```
|
||
Expected: errors about `RawFrame` and `ProcessedFrame`. Continue to next task without committing — Task 14 introduces those types.
|
||
|
||
---
|
||
|
||
## Task 14: `RawFrame`, `ProcessedFrame`, `IFrameClock`
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/Pipeline/RawFrame.cs`
|
||
- Create: `src/TeamsISO.Engine/Pipeline/ProcessedFrame.cs`
|
||
- Create: `src/TeamsISO.Engine/Pipeline/IFrameClock.cs`
|
||
- Create: `src/TeamsISO.Engine/Pipeline/PeriodicTimerFrameClock.cs`
|
||
|
||
- [ ] **Step 1: Implement `RawFrame`**
|
||
|
||
Create `src/TeamsISO.Engine/Pipeline/RawFrame.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Pipeline;
|
||
|
||
/// <summary>
|
||
/// A frame as captured from an NDI receiver. Pixel buffer is opaque to the engine — its
|
||
/// shape is determined by the NDI receive format. Timestamp is the source's reported time.
|
||
/// </summary>
|
||
public sealed record RawFrame(
|
||
int Width,
|
||
int Height,
|
||
long TimestampTicks,
|
||
ReadOnlyMemory<byte> Pixels,
|
||
PixelFormat Format);
|
||
|
||
public enum PixelFormat
|
||
{
|
||
Bgra,
|
||
Uyvy,
|
||
Rgba
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Implement `ProcessedFrame`**
|
||
|
||
Create `src/TeamsISO.Engine/Pipeline/ProcessedFrame.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Pipeline;
|
||
|
||
/// <summary>
|
||
/// A frame after framerate, resolution, and aspect normalization. Ready to send.
|
||
/// </summary>
|
||
public sealed record ProcessedFrame(
|
||
int Width,
|
||
int Height,
|
||
long TimestampTicks,
|
||
ReadOnlyMemory<byte> Pixels,
|
||
PixelFormat Format);
|
||
```
|
||
|
||
- [ ] **Step 3: Implement `IFrameClock`**
|
||
|
||
Create `src/TeamsISO.Engine/Pipeline/IFrameClock.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Pipeline;
|
||
|
||
/// <summary>
|
||
/// Test seam over the wall clock. Production: <see cref="PeriodicTimerFrameClock"/>.
|
||
/// Tests: <c>FakeFrameClock</c> in TeamsISO.Engine.Tests.
|
||
/// </summary>
|
||
public interface IFrameClock
|
||
{
|
||
/// <summary>Current monotonic time as ticks (100 ns).</summary>
|
||
long NowTicks { get; }
|
||
|
||
/// <summary>Awaits the next tick at the current period.</summary>
|
||
ValueTask<bool> WaitForNextTickAsync(CancellationToken cancellationToken);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement the production clock**
|
||
|
||
Create `src/TeamsISO.Engine/Pipeline/PeriodicTimerFrameClock.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Pipeline;
|
||
|
||
public sealed class PeriodicTimerFrameClock : IFrameClock, IDisposable
|
||
{
|
||
private readonly PeriodicTimer _timer;
|
||
|
||
public PeriodicTimerFrameClock(double framesPerSecond)
|
||
{
|
||
if (framesPerSecond <= 0)
|
||
throw new ArgumentOutOfRangeException(nameof(framesPerSecond));
|
||
var periodMs = 1000.0 / framesPerSecond;
|
||
_timer = new PeriodicTimer(TimeSpan.FromMilliseconds(periodMs));
|
||
}
|
||
|
||
public long NowTicks => DateTime.UtcNow.Ticks;
|
||
|
||
public ValueTask<bool> WaitForNextTickAsync(CancellationToken cancellationToken) =>
|
||
_timer.WaitForNextTickAsync(cancellationToken);
|
||
|
||
public void Dispose() => _timer.Dispose();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Build the solution**
|
||
|
||
Run:
|
||
```
|
||
dotnet build TeamsISO.sln
|
||
```
|
||
Expected: success.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine/Pipeline/RawFrame.cs src/TeamsISO.Engine/Pipeline/ProcessedFrame.cs src/TeamsISO.Engine/Pipeline/IFrameClock.cs src/TeamsISO.Engine/Pipeline/PeriodicTimerFrameClock.cs src/TeamsISO.Engine/Interop/INdiInterop.cs src/TeamsISO.Engine/Interop/NdiFindHandle.cs src/TeamsISO.Engine/Interop/NdiReceiverHandle.cs src/TeamsISO.Engine/Interop/NdiSenderHandle.cs
|
||
git commit -m "feat(pipeline,interop): add RawFrame, ProcessedFrame, IFrameClock and INdiInterop test seam"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: `FakeNdiInterop` and `FakeFrameClock`
|
||
|
||
**Files:**
|
||
- Create: `src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs`
|
||
- Create: `src/tests/TeamsISO.Engine.Tests/Fakes/FakeFrameClock.cs`
|
||
|
||
- [ ] **Step 1: Implement `FakeNdiInterop`**
|
||
|
||
Create `src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs`:
|
||
|
||
```csharp
|
||
using System.Collections.Concurrent;
|
||
using TeamsISO.Engine.Interop;
|
||
using TeamsISO.Engine.Pipeline;
|
||
|
||
namespace TeamsISO.Engine.Tests.Fakes;
|
||
|
||
/// <summary>
|
||
/// In-memory test double for <see cref="INdiInterop"/>. Tests configure source lists and frame
|
||
/// queues; the fake feeds those into engine code as if a real NDI runtime were present.
|
||
/// </summary>
|
||
public sealed class FakeNdiInterop : INdiInterop
|
||
{
|
||
public List<string> Sources { get; } = new();
|
||
public ConcurrentDictionary<string, ConcurrentQueue<RawFrame>> ReceiverFrames { get; } = new();
|
||
public ConcurrentDictionary<string, List<ProcessedFrame>> SentFrames { get; } = new();
|
||
public string RuntimeVersion { get; set; } = "6.0.0";
|
||
public Dictionary<string, int> ReceiverCreatedCount { get; } = new();
|
||
public Dictionary<string, int> SenderCreatedCount { get; } = new();
|
||
|
||
public NdiFindHandle CreateFinder() => new FakeFindHandle();
|
||
public IReadOnlyList<string> GetCurrentSources(NdiFindHandle finder) => Sources.ToArray();
|
||
|
||
public NdiReceiverHandle CreateReceiver(string sourceFullName)
|
||
{
|
||
ReceiverCreatedCount[sourceFullName] = ReceiverCreatedCount.GetValueOrDefault(sourceFullName) + 1;
|
||
ReceiverFrames.GetOrAdd(sourceFullName, _ => new ConcurrentQueue<RawFrame>());
|
||
return new FakeReceiverHandle(sourceFullName);
|
||
}
|
||
|
||
public RawFrame? CaptureFrame(NdiReceiverHandle receiver, int timeoutMs)
|
||
{
|
||
var key = ((FakeReceiverHandle)receiver).Source;
|
||
if (ReceiverFrames.TryGetValue(key, out var q) && q.TryDequeue(out var frame))
|
||
return frame;
|
||
return null; // simulate timeout
|
||
}
|
||
|
||
public NdiSenderHandle CreateSender(string outputName)
|
||
{
|
||
SenderCreatedCount[outputName] = SenderCreatedCount.GetValueOrDefault(outputName) + 1;
|
||
SentFrames.GetOrAdd(outputName, _ => new List<ProcessedFrame>());
|
||
return new FakeSenderHandle(outputName);
|
||
}
|
||
|
||
public void SendFrame(NdiSenderHandle sender, ProcessedFrame frame)
|
||
{
|
||
var key = ((FakeSenderHandle)sender).Output;
|
||
SentFrames[key].Add(frame);
|
||
}
|
||
|
||
public string GetRuntimeVersion() => RuntimeVersion;
|
||
|
||
private sealed class FakeFindHandle : NdiFindHandle
|
||
{
|
||
public override void Dispose() { }
|
||
}
|
||
|
||
private sealed class FakeReceiverHandle : NdiReceiverHandle
|
||
{
|
||
public string Source { get; }
|
||
public FakeReceiverHandle(string source) => Source = source;
|
||
public override void Dispose() { }
|
||
}
|
||
|
||
private sealed class FakeSenderHandle : NdiSenderHandle
|
||
{
|
||
public string Output { get; }
|
||
public FakeSenderHandle(string output) => Output = output;
|
||
public override void Dispose() { }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Implement `FakeFrameClock`**
|
||
|
||
Create `src/tests/TeamsISO.Engine.Tests/Fakes/FakeFrameClock.cs`:
|
||
|
||
```csharp
|
||
using TeamsISO.Engine.Pipeline;
|
||
|
||
namespace TeamsISO.Engine.Tests.Fakes;
|
||
|
||
/// <summary>
|
||
/// Manual-tick clock. Tests advance the clock and trigger the awaiter explicitly.
|
||
/// </summary>
|
||
public sealed class FakeFrameClock : IFrameClock
|
||
{
|
||
private long _nowTicks;
|
||
private TaskCompletionSource<bool>? _pendingTick;
|
||
private readonly object _gate = new();
|
||
|
||
public long NowTicks => Interlocked.Read(ref _nowTicks);
|
||
|
||
public void Advance(TimeSpan by)
|
||
{
|
||
Interlocked.Add(ref _nowTicks, by.Ticks);
|
||
TaskCompletionSource<bool>? toSignal;
|
||
lock (_gate)
|
||
{
|
||
toSignal = _pendingTick;
|
||
_pendingTick = null;
|
||
}
|
||
toSignal?.TrySetResult(true);
|
||
}
|
||
|
||
public ValueTask<bool> WaitForNextTickAsync(CancellationToken cancellationToken)
|
||
{
|
||
TaskCompletionSource<bool> tcs;
|
||
lock (_gate)
|
||
{
|
||
_pendingTick ??= new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
tcs = _pendingTick;
|
||
}
|
||
cancellationToken.Register(() => tcs.TrySetResult(false));
|
||
return new ValueTask<bool>(tcs.Task);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Build to make sure tests compile**
|
||
|
||
Run:
|
||
```
|
||
dotnet build TeamsISO.sln
|
||
```
|
||
Expected: success.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/tests/TeamsISO.Engine.Tests/Fakes/
|
||
git commit -m "test(fakes): add FakeNdiInterop and FakeFrameClock"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: `DiscoveryEvent` type
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/Discovery/DiscoveryEvent.cs`
|
||
|
||
- [ ] **Step 1: Implement the discriminated union**
|
||
|
||
Create `src/TeamsISO.Engine/Discovery/DiscoveryEvent.cs`:
|
||
|
||
```csharp
|
||
using TeamsISO.Engine.Domain;
|
||
|
||
namespace TeamsISO.Engine.Discovery;
|
||
|
||
public abstract record DiscoveryEvent
|
||
{
|
||
public sealed record Added(NdiSource Source) : DiscoveryEvent;
|
||
public sealed record Removed(NdiSource Source) : DiscoveryEvent;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build**
|
||
|
||
Run:
|
||
```
|
||
dotnet build TeamsISO.sln
|
||
```
|
||
Expected: success.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine/Discovery/DiscoveryEvent.cs
|
||
git commit -m "feat(discovery): add DiscoveryEvent (Added/Removed)"
|
||
git push origin main
|
||
```
|
||
|
||
> Note: the spec mentions `Renamed` events. We model rename as Removed-then-Added because Teams emits a fresh source string on rename — the parser sees no link between them, but `ParticipantTracker` (Task 18) reconstitutes identity via the rename heuristic.
|
||
|
||
---
|
||
|
||
## Task 17: `ParticipantTracker` rename heuristic (TDD)
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/Discovery/ParticipantTracker.cs`
|
||
- Create: `src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs`:
|
||
|
||
```csharp
|
||
using TeamsISO.Engine.Discovery;
|
||
using TeamsISO.Engine.Domain;
|
||
|
||
namespace TeamsISO.Engine.Tests.Discovery;
|
||
|
||
public class ParticipantTrackerTests
|
||
{
|
||
private static NdiSource Source(string machine, string display) =>
|
||
new($"{machine} (Teams - {display})", machine, NdiSourceKind.Participant, display);
|
||
|
||
private static DateTimeOffset T0 => DateTimeOffset.UnixEpoch;
|
||
|
||
[Fact]
|
||
public void Added_CreatesParticipant()
|
||
{
|
||
var tracker = new ParticipantTracker(renameWindow: TimeSpan.FromSeconds(5), now: () => T0);
|
||
|
||
tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane")));
|
||
|
||
tracker.Participants.Should().HaveCount(1);
|
||
var p = tracker.Participants[0];
|
||
p.DisplayName.Should().Be("Jane");
|
||
p.CurrentSource!.MachineName.Should().Be("PC1");
|
||
}
|
||
|
||
[Fact]
|
||
public void Removed_NullsCurrentSourceButKeepsParticipant()
|
||
{
|
||
var time = T0;
|
||
var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time);
|
||
tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane")));
|
||
|
||
time = T0.AddSeconds(1);
|
||
tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane")));
|
||
|
||
tracker.Participants.Should().HaveCount(1);
|
||
tracker.Participants[0].CurrentSource.Should().BeNull();
|
||
}
|
||
|
||
[Fact]
|
||
public void RenameWithinWindow_TransfersParticipantId()
|
||
{
|
||
var time = T0;
|
||
var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time);
|
||
tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane")));
|
||
var originalId = tracker.Participants[0].Id;
|
||
|
||
time = T0.AddSeconds(1);
|
||
tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane")));
|
||
|
||
time = T0.AddSeconds(3);
|
||
tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane (PM)")));
|
||
|
||
tracker.Participants.Should().HaveCount(1);
|
||
tracker.Participants[0].Id.Should().Be(originalId);
|
||
tracker.Participants[0].DisplayName.Should().Be("Jane (PM)");
|
||
}
|
||
|
||
[Fact]
|
||
public void RenameAfterWindow_TreatsAsNewParticipant()
|
||
{
|
||
var time = T0;
|
||
var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time);
|
||
tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane")));
|
||
var originalId = tracker.Participants[0].Id;
|
||
|
||
time = T0.AddSeconds(1);
|
||
tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane")));
|
||
|
||
time = T0.AddSeconds(20); // way past the 5s window
|
||
tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Bob")));
|
||
|
||
tracker.Participants.Should().HaveCount(2);
|
||
tracker.Participants.Should().Contain(p => p.Id == originalId);
|
||
tracker.Participants.Should().Contain(p => p.DisplayName == "Bob" && p.Id != originalId);
|
||
}
|
||
|
||
[Fact]
|
||
public void DifferentMachine_DoesNotInheritIdentity()
|
||
{
|
||
var time = T0;
|
||
var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time);
|
||
tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane")));
|
||
|
||
time = T0.AddSeconds(1);
|
||
tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane")));
|
||
|
||
time = T0.AddSeconds(2);
|
||
tracker.Apply(new DiscoveryEvent.Added(Source("PC2", "Jane (PM)")));
|
||
|
||
tracker.Participants.Should().HaveCount(2);
|
||
}
|
||
|
||
[Fact]
|
||
public void NonParticipantSources_AreIgnored()
|
||
{
|
||
var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => T0);
|
||
var screen = new NdiSource("PC1 (Teams Screen Share)", "PC1", NdiSourceKind.ScreenShare, null);
|
||
|
||
tracker.Apply(new DiscoveryEvent.Added(screen));
|
||
|
||
tracker.Participants.Should().BeEmpty();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, expect failure**
|
||
|
||
Run:
|
||
```
|
||
dotnet test --filter "FullyQualifiedName~ParticipantTrackerTests"
|
||
```
|
||
Expected: compilation errors (ParticipantTracker missing).
|
||
|
||
- [ ] **Step 3: Implement `ParticipantTracker`**
|
||
|
||
Create `src/TeamsISO.Engine/Discovery/ParticipantTracker.cs`:
|
||
|
||
```csharp
|
||
using TeamsISO.Engine.Domain;
|
||
|
||
namespace TeamsISO.Engine.Discovery;
|
||
|
||
/// <summary>
|
||
/// Maintains the operator-facing participant list, applying the rename heuristic
|
||
/// from the v1 spec: if a participant source disappears and another participant
|
||
/// source with the same MachineName appears within <see cref="_renameWindow"/>,
|
||
/// the existing <see cref="Participant.Id"/> transfers to the new source.
|
||
/// </summary>
|
||
public sealed class ParticipantTracker
|
||
{
|
||
private readonly TimeSpan _renameWindow;
|
||
private readonly Func<DateTimeOffset> _now;
|
||
private readonly List<MutableParticipant> _participants = new();
|
||
private readonly List<RecentlyRemoved> _recentlyRemoved = new();
|
||
|
||
public ParticipantTracker(TimeSpan renameWindow, Func<DateTimeOffset> now)
|
||
{
|
||
_renameWindow = renameWindow;
|
||
_now = now;
|
||
}
|
||
|
||
public IReadOnlyList<Participant> Participants =>
|
||
_participants.Select(m => m.ToRecord()).ToList();
|
||
|
||
public void Apply(DiscoveryEvent ev)
|
||
{
|
||
switch (ev)
|
||
{
|
||
case DiscoveryEvent.Added a when a.Source.Kind == NdiSourceKind.Participant:
|
||
HandleAdded(a.Source);
|
||
break;
|
||
case DiscoveryEvent.Removed r when r.Source.Kind == NdiSourceKind.Participant:
|
||
HandleRemoved(r.Source);
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void HandleAdded(NdiSource source)
|
||
{
|
||
var now = _now();
|
||
PruneRecentlyRemoved(now);
|
||
|
||
var match = _recentlyRemoved.FirstOrDefault(rr => rr.MachineName == source.MachineName);
|
||
if (match is not null)
|
||
{
|
||
// Transfer identity
|
||
var existing = _participants.First(p => p.Id == match.Id);
|
||
existing.DisplayName = source.DisplayName!;
|
||
existing.CurrentSource = source;
|
||
existing.LastSeen = now;
|
||
_recentlyRemoved.Remove(match);
|
||
return;
|
||
}
|
||
|
||
_participants.Add(new MutableParticipant(
|
||
Id: Guid.NewGuid(),
|
||
DisplayName: source.DisplayName!,
|
||
CurrentSource: source,
|
||
FirstSeen: now,
|
||
LastSeen: now));
|
||
}
|
||
|
||
private void HandleRemoved(NdiSource source)
|
||
{
|
||
var now = _now();
|
||
var existing = _participants.FirstOrDefault(p =>
|
||
p.CurrentSource is not null && p.CurrentSource.FullName == source.FullName);
|
||
if (existing is null)
|
||
return;
|
||
|
||
existing.CurrentSource = null;
|
||
_recentlyRemoved.Add(new RecentlyRemoved(existing.Id, source.MachineName, now));
|
||
}
|
||
|
||
private void PruneRecentlyRemoved(DateTimeOffset now)
|
||
{
|
||
_recentlyRemoved.RemoveAll(rr => now - rr.RemovedAt > _renameWindow);
|
||
}
|
||
|
||
private sealed class MutableParticipant
|
||
{
|
||
public Guid Id { get; init; }
|
||
public string DisplayName { get; set; }
|
||
public NdiSource? CurrentSource { get; set; }
|
||
public DateTimeOffset FirstSeen { get; init; }
|
||
public DateTimeOffset LastSeen { get; set; }
|
||
|
||
public MutableParticipant(Guid Id, string DisplayName, NdiSource? CurrentSource,
|
||
DateTimeOffset FirstSeen, DateTimeOffset LastSeen)
|
||
{
|
||
this.Id = Id;
|
||
this.DisplayName = DisplayName;
|
||
this.CurrentSource = CurrentSource;
|
||
this.FirstSeen = FirstSeen;
|
||
this.LastSeen = LastSeen;
|
||
}
|
||
|
||
public Participant ToRecord() =>
|
||
new(Id, DisplayName, CurrentSource, FirstSeen, LastSeen);
|
||
}
|
||
|
||
private sealed record RecentlyRemoved(Guid Id, string MachineName, DateTimeOffset RemovedAt);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, expect pass**
|
||
|
||
Run:
|
||
```
|
||
dotnet test --filter "FullyQualifiedName~ParticipantTrackerTests"
|
||
```
|
||
Expected: 6 pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine/Discovery/ParticipantTracker.cs src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs
|
||
git commit -m "feat(discovery): add ParticipantTracker with rename heuristic"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: `NdiDiscoveryService` (TDD against `FakeNdiInterop`)
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs`
|
||
- Create: `src/tests/TeamsISO.Engine.Tests/Discovery/NdiDiscoveryServiceTests.cs`
|
||
|
||
The service polls `INdiInterop.GetCurrentSources` on a background loop and emits `DiscoveryEvent`s on a channel. We test by stepping the loop manually rather than running the background thread.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `src/tests/TeamsISO.Engine.Tests/Discovery/NdiDiscoveryServiceTests.cs`:
|
||
|
||
```csharp
|
||
using System.Threading.Channels;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using TeamsISO.Engine.Discovery;
|
||
using TeamsISO.Engine.Domain;
|
||
using TeamsISO.Engine.Tests.Fakes;
|
||
|
||
namespace TeamsISO.Engine.Tests.Discovery;
|
||
|
||
public class NdiDiscoveryServiceTests
|
||
{
|
||
[Fact]
|
||
public void PollOnce_AddsNewParticipantSources_AndIgnoresMalformedStrings()
|
||
{
|
||
var interop = new FakeNdiInterop();
|
||
interop.Sources.Add("PC1 (Teams - Jane)");
|
||
interop.Sources.Add("PC1 (Teams)");
|
||
interop.Sources.Add("Just A Camera"); // not a Teams source
|
||
interop.Sources.Add("BAD (Teams - )"); // empty display name
|
||
var channel = Channel.CreateUnbounded<DiscoveryEvent>();
|
||
|
||
var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger<NdiDiscoveryService>.Instance);
|
||
|
||
svc.PollOnce();
|
||
|
||
var emitted = DrainChannel(channel.Reader);
|
||
emitted.OfType<DiscoveryEvent.Added>().Select(a => a.Source.FullName)
|
||
.Should().BeEquivalentTo(new[] { "PC1 (Teams - Jane)", "PC1 (Teams)" });
|
||
}
|
||
|
||
[Fact]
|
||
public void PollOnce_EmitsRemoved_WhenSourceDisappears()
|
||
{
|
||
var interop = new FakeNdiInterop();
|
||
interop.Sources.Add("PC1 (Teams - Jane)");
|
||
var channel = Channel.CreateUnbounded<DiscoveryEvent>();
|
||
|
||
var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger<NdiDiscoveryService>.Instance);
|
||
svc.PollOnce();
|
||
DrainChannel(channel.Reader); // first poll: Added
|
||
|
||
interop.Sources.Clear();
|
||
svc.PollOnce();
|
||
|
||
var emitted = DrainChannel(channel.Reader);
|
||
emitted.OfType<DiscoveryEvent.Removed>().Select(r => r.Source.FullName)
|
||
.Should().BeEquivalentTo(new[] { "PC1 (Teams - Jane)" });
|
||
}
|
||
|
||
[Fact]
|
||
public void PollOnce_NoChange_EmitsNothing()
|
||
{
|
||
var interop = new FakeNdiInterop();
|
||
interop.Sources.Add("PC1 (Teams - Jane)");
|
||
var channel = Channel.CreateUnbounded<DiscoveryEvent>();
|
||
|
||
var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger<NdiDiscoveryService>.Instance);
|
||
svc.PollOnce(); DrainChannel(channel.Reader);
|
||
|
||
svc.PollOnce();
|
||
|
||
DrainChannel(channel.Reader).Should().BeEmpty();
|
||
}
|
||
|
||
private static List<DiscoveryEvent> DrainChannel(ChannelReader<DiscoveryEvent> reader)
|
||
{
|
||
var list = new List<DiscoveryEvent>();
|
||
while (reader.TryRead(out var ev)) list.Add(ev);
|
||
return list;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, expect failure**
|
||
|
||
Run:
|
||
```
|
||
dotnet test --filter "FullyQualifiedName~NdiDiscoveryServiceTests"
|
||
```
|
||
Expected: compilation errors (`NdiDiscoveryService` missing).
|
||
|
||
- [ ] **Step 3: Implement `NdiDiscoveryService`**
|
||
|
||
Create `src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs`:
|
||
|
||
```csharp
|
||
using System.Threading.Channels;
|
||
using Microsoft.Extensions.Logging;
|
||
using TeamsISO.Engine.Domain;
|
||
using TeamsISO.Engine.Interop;
|
||
|
||
namespace TeamsISO.Engine.Discovery;
|
||
|
||
/// <summary>
|
||
/// Polls <see cref="INdiInterop.GetCurrentSources"/> at a fixed cadence, diffs the
|
||
/// resulting set against the previous poll, and emits <see cref="DiscoveryEvent"/>s
|
||
/// on a channel for downstream consumers.
|
||
/// </summary>
|
||
public sealed class NdiDiscoveryService
|
||
{
|
||
private readonly INdiInterop _interop;
|
||
private readonly ChannelWriter<DiscoveryEvent> _writer;
|
||
private readonly ILogger<NdiDiscoveryService> _logger;
|
||
private readonly NdiFindHandle _finder;
|
||
private readonly HashSet<string> _previous = new();
|
||
|
||
public NdiDiscoveryService(
|
||
INdiInterop interop,
|
||
ChannelWriter<DiscoveryEvent> writer,
|
||
ILogger<NdiDiscoveryService> logger)
|
||
{
|
||
_interop = interop;
|
||
_writer = writer;
|
||
_logger = logger;
|
||
_finder = interop.CreateFinder();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs a single poll cycle. Public for unit testing; production uses <see cref="RunAsync"/>.
|
||
/// </summary>
|
||
public void PollOnce()
|
||
{
|
||
var current = _interop.GetCurrentSources(_finder);
|
||
var currentSet = new HashSet<string>(current);
|
||
|
||
// Added
|
||
foreach (var name in currentSet.Except(_previous))
|
||
{
|
||
var parsed = NdiSourceParser.Parse(name);
|
||
if (parsed is null)
|
||
{
|
||
_logger.LogTrace("Ignoring unrecognized source: {Name}", name);
|
||
continue;
|
||
}
|
||
_writer.TryWrite(new DiscoveryEvent.Added(parsed));
|
||
}
|
||
|
||
// Removed
|
||
foreach (var name in _previous.Except(currentSet))
|
||
{
|
||
var parsed = NdiSourceParser.Parse(name);
|
||
if (parsed is null) continue;
|
||
_writer.TryWrite(new DiscoveryEvent.Removed(parsed));
|
||
}
|
||
|
||
_previous.Clear();
|
||
foreach (var name in currentSet) _previous.Add(name);
|
||
}
|
||
|
||
/// <summary>Long-running poll loop. Cancel the token to stop.</summary>
|
||
public async Task RunAsync(TimeSpan pollInterval, CancellationToken cancellationToken)
|
||
{
|
||
using var timer = new PeriodicTimer(pollInterval);
|
||
try
|
||
{
|
||
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||
{
|
||
try { PollOnce(); }
|
||
catch (Exception ex) { _logger.LogWarning(ex, "Discovery poll failed; will retry on next tick."); }
|
||
}
|
||
}
|
||
catch (OperationCanceledException) { /* expected */ }
|
||
finally
|
||
{
|
||
_finder.Dispose();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, expect pass**
|
||
|
||
Run:
|
||
```
|
||
dotnet test --filter "FullyQualifiedName~NdiDiscoveryServiceTests"
|
||
```
|
||
Expected: 3 pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs src/tests/TeamsISO.Engine.Tests/Discovery/NdiDiscoveryServiceTests.cs
|
||
git commit -m "feat(discovery): add NdiDiscoveryService with diff-based event emission"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: `SolidFrameRenderer` (no-signal slate)
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/Pipeline/SolidFrameRenderer.cs`
|
||
- Create: `src/tests/TeamsISO.Engine.Tests/Pipeline/SolidFrameRendererTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/tests/TeamsISO.Engine.Tests/Pipeline/SolidFrameRendererTests.cs`:
|
||
|
||
```csharp
|
||
using TeamsISO.Engine.Pipeline;
|
||
|
||
namespace TeamsISO.Engine.Tests.Pipeline;
|
||
|
||
public class SolidFrameRendererTests
|
||
{
|
||
[Fact]
|
||
public void Render_ProducesBgraFrameOfTargetSize_FilledWithColor()
|
||
{
|
||
var renderer = new SolidFrameRenderer();
|
||
var frame = renderer.Render(width: 1920, height: 1080, b: 0x80, g: 0x80, r: 0x80, a: 0xFF, timestampTicks: 12345);
|
||
|
||
frame.Width.Should().Be(1920);
|
||
frame.Height.Should().Be(1080);
|
||
frame.Format.Should().Be(PixelFormat.Bgra);
|
||
frame.Pixels.Length.Should().Be(1920 * 1080 * 4);
|
||
frame.TimestampTicks.Should().Be(12345);
|
||
|
||
// Spot-check first and last pixel
|
||
var span = frame.Pixels.Span;
|
||
span[0].Should().Be(0x80); span[1].Should().Be(0x80); span[2].Should().Be(0x80); span[3].Should().Be(0xFF);
|
||
var last = span.Length - 4;
|
||
span[last].Should().Be(0x80); span[last + 3].Should().Be(0xFF);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, expect failure**
|
||
|
||
Run:
|
||
```
|
||
dotnet test --filter "FullyQualifiedName~SolidFrameRendererTests"
|
||
```
|
||
Expected: compilation error.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
Create `src/TeamsISO.Engine/Pipeline/SolidFrameRenderer.cs`:
|
||
|
||
```csharp
|
||
namespace TeamsISO.Engine.Pipeline;
|
||
|
||
/// <summary>
|
||
/// Generates a solid-color BGRA frame for use as a "no signal" slate.
|
||
/// </summary>
|
||
public sealed class SolidFrameRenderer
|
||
{
|
||
public ProcessedFrame Render(int width, int height, byte b, byte g, byte r, byte a, long timestampTicks)
|
||
{
|
||
var pixels = new byte[width * height * 4];
|
||
for (var i = 0; i < pixels.Length; i += 4)
|
||
{
|
||
pixels[i + 0] = b;
|
||
pixels[i + 1] = g;
|
||
pixels[i + 2] = r;
|
||
pixels[i + 3] = a;
|
||
}
|
||
return new ProcessedFrame(width, height, timestampTicks, pixels, PixelFormat.Bgra);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, expect pass**
|
||
|
||
Run:
|
||
```
|
||
dotnet test --filter "FullyQualifiedName~SolidFrameRendererTests"
|
||
```
|
||
Expected: 1 pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine/Pipeline/SolidFrameRenderer.cs src/tests/TeamsISO.Engine.Tests/Pipeline/SolidFrameRendererTests.cs
|
||
git commit -m "feat(pipeline): add SolidFrameRenderer for no-signal slate"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 20: `IFrameScaler` interface and identity scaler
|
||
|
||
We separate the scaling step behind an interface so Phase A doesn't need libyuv. The Phase A scaler is "identity" — copy and emit at the requested size by truncating or padding. This lets us write the FrameProcessor's timing tests without the real libyuv dependency. Phase B replaces the implementation with a real libyuv-backed scaler.
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/Pipeline/IFrameScaler.cs`
|
||
- Create: `src/TeamsISO.Engine/Pipeline/PassthroughFrameScaler.cs`
|
||
|
||
- [ ] **Step 1: Define the interface**
|
||
|
||
Create `src/TeamsISO.Engine/Pipeline/IFrameScaler.cs`:
|
||
|
||
```csharp
|
||
using TeamsISO.Engine.Domain;
|
||
|
||
namespace TeamsISO.Engine.Pipeline;
|
||
|
||
public interface IFrameScaler
|
||
{
|
||
ProcessedFrame Scale(RawFrame source, int targetWidth, int targetHeight, AspectMode aspect, long timestampTicks);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Implement the passthrough scaler (Phase A)**
|
||
|
||
Create `src/TeamsISO.Engine/Pipeline/PassthroughFrameScaler.cs`:
|
||
|
||
```csharp
|
||
using TeamsISO.Engine.Domain;
|
||
|
||
namespace TeamsISO.Engine.Pipeline;
|
||
|
||
/// <summary>
|
||
/// Phase A scaler. Copies the source frame's pixel buffer through unchanged and tags the
|
||
/// output with the requested target dimensions. Real scaling is added in Phase B against libyuv.
|
||
/// </summary>
|
||
public sealed class PassthroughFrameScaler : IFrameScaler
|
||
{
|
||
public ProcessedFrame Scale(RawFrame source, int targetWidth, int targetHeight, AspectMode aspect, long timestampTicks)
|
||
{
|
||
return new ProcessedFrame(
|
||
Width: targetWidth,
|
||
Height: targetHeight,
|
||
TimestampTicks: timestampTicks,
|
||
Pixels: source.Pixels,
|
||
Format: source.Format == PixelFormat.Bgra ? PixelFormat.Bgra : PixelFormat.Bgra);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Build**
|
||
|
||
Run:
|
||
```
|
||
dotnet build TeamsISO.sln
|
||
```
|
||
Expected: success.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine/Pipeline/IFrameScaler.cs src/TeamsISO.Engine/Pipeline/PassthroughFrameScaler.cs
|
||
git commit -m "feat(pipeline): add IFrameScaler interface and PassthroughFrameScaler (Phase A)"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 21: `FrameProcessor` timing logic (TDD against `FakeFrameClock`)
|
||
|
||
This is the heart of Phase A's behavior coverage: closest-frame timing, frame duplication, and the slate fallback.
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/Pipeline/FrameProcessor.cs`
|
||
- Create: `src/tests/TeamsISO.Engine.Tests/Pipeline/FrameProcessorTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `src/tests/TeamsISO.Engine.Tests/Pipeline/FrameProcessorTests.cs`:
|
||
|
||
```csharp
|
||
using System.Threading.Channels;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using TeamsISO.Engine.Domain;
|
||
using TeamsISO.Engine.Pipeline;
|
||
using TeamsISO.Engine.Tests.Fakes;
|
||
|
||
namespace TeamsISO.Engine.Tests.Pipeline;
|
||
|
||
public class FrameProcessorTests
|
||
{
|
||
private static readonly FrameProcessingSettings Settings1080p30 =
|
||
new(TargetFramerate.Fps30, TargetResolution.R1080p, AspectMode.Pillarbox, AudioMode.Auto);
|
||
|
||
private static RawFrame MakeFrame(int width, int height, long ts) =>
|
||
new(width, height, ts, new byte[width * height * 4], PixelFormat.Bgra);
|
||
|
||
private static FrameProcessor NewProcessor(
|
||
FakeFrameClock clock,
|
||
Channel<RawFrame> input,
|
||
Channel<ProcessedFrame> output,
|
||
FrameProcessingSettings? settings = null)
|
||
=> new(
|
||
settings: settings ?? Settings1080p30,
|
||
scaler: new PassthroughFrameScaler(),
|
||
slateRenderer: new SolidFrameRenderer(),
|
||
clock: clock,
|
||
input: input.Reader,
|
||
output: output.Writer,
|
||
slateThreshold: TimeSpan.FromSeconds(2.5),
|
||
logger: NullLogger<FrameProcessor>.Instance);
|
||
|
||
[Fact]
|
||
public async Task ProcessOnce_NewFrameAvailable_EmitsScaledFrame()
|
||
{
|
||
var clock = new FakeFrameClock();
|
||
var input = Channel.CreateBounded<RawFrame>(4);
|
||
var output = Channel.CreateUnbounded<ProcessedFrame>();
|
||
var proc = NewProcessor(clock, input, output);
|
||
|
||
input.Writer.TryWrite(MakeFrame(640, 360, ts: 100));
|
||
clock.Advance(TimeSpan.FromMilliseconds(34)); // 1 tick at ~30fps
|
||
await proc.ProcessOnceAsync(CancellationToken.None);
|
||
|
||
output.Reader.TryRead(out var frame).Should().BeTrue();
|
||
frame!.Width.Should().Be(1920);
|
||
frame.Height.Should().Be(1080);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ProcessOnce_NoNewFrame_ReEmitsLastFrame()
|
||
{
|
||
var clock = new FakeFrameClock();
|
||
var input = Channel.CreateBounded<RawFrame>(4);
|
||
var output = Channel.CreateUnbounded<ProcessedFrame>();
|
||
var proc = NewProcessor(clock, input, output);
|
||
|
||
input.Writer.TryWrite(MakeFrame(640, 360, ts: 100));
|
||
clock.Advance(TimeSpan.FromMilliseconds(34));
|
||
await proc.ProcessOnceAsync(CancellationToken.None);
|
||
output.Reader.TryRead(out _).Should().BeTrue();
|
||
|
||
// Second tick, no new input
|
||
clock.Advance(TimeSpan.FromMilliseconds(34));
|
||
await proc.ProcessOnceAsync(CancellationToken.None);
|
||
|
||
output.Reader.TryRead(out var second).Should().BeTrue();
|
||
second!.Width.Should().Be(1920);
|
||
proc.Stats.FramesDuplicated.Should().Be(1);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ProcessOnce_NoFrameForLongerThanSlateThreshold_EmitsSlate()
|
||
{
|
||
var clock = new FakeFrameClock();
|
||
var input = Channel.CreateBounded<RawFrame>(4);
|
||
var output = Channel.CreateUnbounded<ProcessedFrame>();
|
||
var proc = NewProcessor(clock, input, output);
|
||
|
||
input.Writer.TryWrite(MakeFrame(640, 360, ts: 100));
|
||
clock.Advance(TimeSpan.FromMilliseconds(34));
|
||
await proc.ProcessOnceAsync(CancellationToken.None);
|
||
output.Reader.TryRead(out _);
|
||
|
||
// Advance 3 seconds without input
|
||
clock.Advance(TimeSpan.FromSeconds(3));
|
||
await proc.ProcessOnceAsync(CancellationToken.None);
|
||
|
||
output.Reader.TryRead(out var slate).Should().BeTrue();
|
||
slate!.Width.Should().Be(1920);
|
||
slate.Height.Should().Be(1080);
|
||
// First pixel is mid-grey BGRA
|
||
slate.Pixels.Span[0].Should().Be(0x80);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ProcessOnce_PicksNewestFrame_DropsOlder()
|
||
{
|
||
var clock = new FakeFrameClock();
|
||
var input = Channel.CreateBounded<RawFrame>(4);
|
||
var output = Channel.CreateUnbounded<ProcessedFrame>();
|
||
var proc = NewProcessor(clock, input, output);
|
||
|
||
// Three frames queued before the tick fires
|
||
input.Writer.TryWrite(MakeFrame(640, 360, ts: 100));
|
||
input.Writer.TryWrite(MakeFrame(640, 360, ts: 200));
|
||
input.Writer.TryWrite(MakeFrame(640, 360, ts: 300));
|
||
clock.Advance(TimeSpan.FromMilliseconds(34));
|
||
await proc.ProcessOnceAsync(CancellationToken.None);
|
||
|
||
proc.Stats.FramesIn.Should().Be(3);
|
||
proc.Stats.FramesDropped.Should().Be(2); // we kept ts=300, dropped ts=100 and ts=200
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, expect failure**
|
||
|
||
Run:
|
||
```
|
||
dotnet test --filter "FullyQualifiedName~FrameProcessorTests"
|
||
```
|
||
Expected: compilation errors (`FrameProcessor` missing).
|
||
|
||
- [ ] **Step 3: Implement `FrameProcessor`**
|
||
|
||
Create `src/TeamsISO.Engine/Pipeline/FrameProcessor.cs`:
|
||
|
||
```csharp
|
||
using System.Threading.Channels;
|
||
using Microsoft.Extensions.Logging;
|
||
using TeamsISO.Engine.Domain;
|
||
|
||
namespace TeamsISO.Engine.Pipeline;
|
||
|
||
/// <summary>
|
||
/// Per-ISO frame timing engine. Implements closest-frame strategy: at each tick,
|
||
/// pick the newest available raw frame (dropping older queued frames), scale and emit it.
|
||
/// If no new frame is available, re-emit the last frame. If no frame has arrived for
|
||
/// <see cref="_slateThreshold"/>, emit a no-signal slate instead.
|
||
/// </summary>
|
||
public sealed class FrameProcessor
|
||
{
|
||
private readonly FrameProcessingSettings _settings;
|
||
private readonly IFrameScaler _scaler;
|
||
private readonly SolidFrameRenderer _slateRenderer;
|
||
private readonly IFrameClock _clock;
|
||
private readonly ChannelReader<RawFrame> _input;
|
||
private readonly ChannelWriter<ProcessedFrame> _output;
|
||
private readonly TimeSpan _slateThreshold;
|
||
private readonly ILogger<FrameProcessor> _logger;
|
||
|
||
private RawFrame? _lastRawFrame;
|
||
private long _lastFrameTickTicks;
|
||
private long _framesIn;
|
||
private long _framesOut;
|
||
private long _framesDropped;
|
||
private long _framesDuplicated;
|
||
private long _framesSlated;
|
||
|
||
public FrameProcessor(
|
||
FrameProcessingSettings settings,
|
||
IFrameScaler scaler,
|
||
SolidFrameRenderer slateRenderer,
|
||
IFrameClock clock,
|
||
ChannelReader<RawFrame> input,
|
||
ChannelWriter<ProcessedFrame> output,
|
||
TimeSpan slateThreshold,
|
||
ILogger<FrameProcessor> logger)
|
||
{
|
||
_settings = settings;
|
||
_scaler = scaler;
|
||
_slateRenderer = slateRenderer;
|
||
_clock = clock;
|
||
_input = input;
|
||
_output = output;
|
||
_slateThreshold = slateThreshold;
|
||
_logger = logger;
|
||
}
|
||
|
||
public IsoHealthStats Stats =>
|
||
new(
|
||
FramesIn: Interlocked.Read(ref _framesIn),
|
||
FramesOut: Interlocked.Read(ref _framesOut),
|
||
FramesDropped: Interlocked.Read(ref _framesDropped),
|
||
FramesDuplicated: Interlocked.Read(ref _framesDuplicated),
|
||
LastFrameAt: _lastFrameTickTicks == 0 ? null : new DateTimeOffset(_lastFrameTickTicks, TimeSpan.Zero),
|
||
IncomingFps: 0, // computed downstream from FramesIn delta over time
|
||
IncomingWidth: _lastRawFrame?.Width ?? 0,
|
||
IncomingHeight: _lastRawFrame?.Height ?? 0);
|
||
|
||
public Task ProcessOnceAsync(CancellationToken cancellationToken)
|
||
{
|
||
// Drain the input channel non-blockingly, keeping only the newest frame.
|
||
RawFrame? newest = null;
|
||
while (_input.TryRead(out var frame))
|
||
{
|
||
if (newest is not null)
|
||
Interlocked.Increment(ref _framesDropped);
|
||
newest = frame;
|
||
Interlocked.Increment(ref _framesIn);
|
||
}
|
||
|
||
var (targetW, targetH) = _settings.ResolutionSize;
|
||
var nowTicks = _clock.NowTicks;
|
||
|
||
ProcessedFrame toEmit;
|
||
if (newest is not null)
|
||
{
|
||
_lastRawFrame = newest;
|
||
_lastFrameTickTicks = nowTicks;
|
||
toEmit = _scaler.Scale(newest, targetW, targetH, _settings.Aspect, nowTicks);
|
||
}
|
||
else if (_lastRawFrame is not null && (nowTicks - _lastFrameTickTicks) <= _slateThreshold.Ticks)
|
||
{
|
||
Interlocked.Increment(ref _framesDuplicated);
|
||
toEmit = _scaler.Scale(_lastRawFrame, targetW, targetH, _settings.Aspect, nowTicks);
|
||
}
|
||
else
|
||
{
|
||
Interlocked.Increment(ref _framesSlated);
|
||
toEmit = _slateRenderer.Render(targetW, targetH, b: 0x80, g: 0x80, r: 0x80, a: 0xFF, nowTicks);
|
||
}
|
||
|
||
Interlocked.Increment(ref _framesOut);
|
||
_output.TryWrite(toEmit);
|
||
return Task.CompletedTask;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, expect pass**
|
||
|
||
Run:
|
||
```
|
||
dotnet test --filter "FullyQualifiedName~FrameProcessorTests"
|
||
```
|
||
Expected: 4 pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine/Pipeline/FrameProcessor.cs src/tests/TeamsISO.Engine.Tests/Pipeline/FrameProcessorTests.cs
|
||
git commit -m "feat(pipeline): add FrameProcessor with closest-frame timing and slate fallback"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 22: Coverage threshold gate in CI
|
||
|
||
We promised 80% line coverage on `TeamsISO.Engine`. Wire that into the CI workflow so a regression caught in PR.
|
||
|
||
**Files:**
|
||
- Modify: `.forgejo/workflows/ci.yml`
|
||
- Create: `coverlet.runsettings`
|
||
|
||
- [ ] **Step 1: Add a `coverlet.runsettings`**
|
||
|
||
Create `coverlet.runsettings`:
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="utf-8" ?>
|
||
<RunSettings>
|
||
<DataCollectionRunSettings>
|
||
<DataCollectors>
|
||
<DataCollector friendlyName="XPlat code coverage">
|
||
<Configuration>
|
||
<Format>cobertura,opencover</Format>
|
||
<Exclude>[*.Tests]*,[*.NdiInterop]*,[*.IntegrationTests]*</Exclude>
|
||
<ExcludeByAttribute>GeneratedCodeAttribute,CompilerGeneratedAttribute</ExcludeByAttribute>
|
||
</Configuration>
|
||
</DataCollector>
|
||
</DataCollectors>
|
||
</DataCollectionRunSettings>
|
||
</RunSettings>
|
||
```
|
||
|
||
- [ ] **Step 2: Update `.forgejo/workflows/ci.yml`**
|
||
|
||
Replace the test step in `.forgejo/workflows/ci.yml` with:
|
||
|
||
```yaml
|
||
- name: Test (excluding requires=ndi)
|
||
run: >
|
||
dotnet test TeamsISO.sln
|
||
--configuration Release
|
||
--no-build
|
||
--logger "trx;LogFileName=test-results.trx"
|
||
--collect:"XPlat Code Coverage"
|
||
--settings coverlet.runsettings
|
||
--filter "Category!=ndi&requires!=ndi"
|
||
|
||
- name: Install ReportGenerator
|
||
run: dotnet tool install --global dotnet-reportgenerator-globaltool
|
||
|
||
- name: Generate coverage report
|
||
run: |
|
||
export PATH="$PATH:/root/.dotnet/tools:$HOME/.dotnet/tools"
|
||
reportgenerator \
|
||
-reports:"**/coverage.cobertura.xml" \
|
||
-targetdir:coverage-report \
|
||
-reporttypes:"Cobertura;TextSummary" \
|
||
-assemblyfilters:"+TeamsISO.Engine;-TeamsISO.Engine.NdiInterop"
|
||
|
||
- name: Enforce coverage threshold (80%)
|
||
run: |
|
||
summary=$(cat coverage-report/Summary.txt)
|
||
echo "$summary"
|
||
line_coverage=$(echo "$summary" | awk '/Line coverage/ {print $3}' | tr -d '%')
|
||
echo "Line coverage: $line_coverage%"
|
||
awk -v c="$line_coverage" 'BEGIN { if (c+0 < 80) { exit 1 } }'
|
||
|
||
- name: Upload coverage report
|
||
if: always()
|
||
uses: actions/upload-artifact@v4
|
||
with:
|
||
name: coverage-report
|
||
path: coverage-report/
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add coverlet.runsettings .forgejo/workflows/ci.yml
|
||
git commit -m "ci: enforce 80% line coverage gate on TeamsISO.Engine"
|
||
git push origin main
|
||
```
|
||
|
||
- [ ] **Step 4: Verify CI passes**
|
||
|
||
Open `https://forge.wilddragon.net/zgaetano/teamsiso/actions`. Confirm the latest run is green and the coverage step reports ≥80% on `TeamsISO.Engine`.
|
||
|
||
---
|
||
|
||
## Task 23: Logging bootstrap
|
||
|
||
Provide a tiny, self-contained way for engine consumers to wire `Microsoft.Extensions.Logging` quickly. Phase A ships a console-only configuration so the smoke runner in Phase B has logs out of the box.
|
||
|
||
**Files:**
|
||
- Create: `src/TeamsISO.Engine/Logging/EngineLogging.cs`
|
||
|
||
- [ ] **Step 1: Add Serilog packages**
|
||
|
||
Run:
|
||
```
|
||
cd src/TeamsISO.Engine
|
||
dotnet add package Serilog --version 4.0.0
|
||
dotnet add package Serilog.Extensions.Logging --version 8.0.0
|
||
dotnet add package Serilog.Sinks.Console --version 6.0.0
|
||
cd ../..
|
||
```
|
||
|
||
- [ ] **Step 2: Implement the helper**
|
||
|
||
Create `src/TeamsISO.Engine/Logging/EngineLogging.cs`:
|
||
|
||
```csharp
|
||
using Microsoft.Extensions.Logging;
|
||
using Serilog;
|
||
using Serilog.Extensions.Logging;
|
||
|
||
namespace TeamsISO.Engine.Logging;
|
||
|
||
/// <summary>
|
||
/// 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
|
||
{
|
||
public static ILoggerFactory CreateConsole(LogLevel minimum = LogLevel.Information)
|
||
{
|
||
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}")
|
||
.CreateLogger();
|
||
return new SerilogLoggerFactory(serilog, dispose: true);
|
||
}
|
||
|
||
private static Serilog.Events.LogEventLevel MapLevel(LogLevel level) => level switch
|
||
{
|
||
LogLevel.Trace => Serilog.Events.LogEventLevel.Verbose,
|
||
LogLevel.Debug => Serilog.Events.LogEventLevel.Debug,
|
||
LogLevel.Information => Serilog.Events.LogEventLevel.Information,
|
||
LogLevel.Warning => Serilog.Events.LogEventLevel.Warning,
|
||
LogLevel.Error => Serilog.Events.LogEventLevel.Error,
|
||
LogLevel.Critical => Serilog.Events.LogEventLevel.Fatal,
|
||
_ => Serilog.Events.LogEventLevel.Information
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Build**
|
||
|
||
Run:
|
||
```
|
||
dotnet build TeamsISO.sln
|
||
```
|
||
Expected: success.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/TeamsISO.Engine/Logging/EngineLogging.cs src/TeamsISO.Engine/TeamsISO.Engine.csproj
|
||
git commit -m "feat(logging): add EngineLogging.CreateConsole helper"
|
||
git push origin main
|
||
```
|
||
|
||
---
|
||
|
||
## Task 24: Phase A wrap-up — release tag and follow-up plan stubs
|
||
|
||
**Files:**
|
||
- Create: `docs/test-playbook.md`
|
||
- Create: `docs/superpowers/plans/_NEXT.md`
|
||
|
||
- [ ] **Step 1: Add the (Phase A) test playbook stub**
|
||
|
||
Create `docs/test-playbook.md`:
|
||
|
||
```markdown
|
||
# TeamsISO Manual Test Playbook
|
||
|
||
This doc grows with each phase. Phase A is unit-test only — nothing to verify against live Teams yet. Phase B will fill in NDI runtime checks; Phase C will add the live-meeting end-to-end checklist.
|
||
|
||
## Pre-checks (run before each release branch)
|
||
|
||
- [ ] `dotnet build TeamsISO.sln` succeeds with zero warnings.
|
||
- [ ] `dotnet test TeamsISO.sln` reports all unit tests passing.
|
||
- [ ] CI run on `main` is green.
|
||
|
||
## Live-meeting checklist (Phase C)
|
||
|
||
(To be added.)
|
||
```
|
||
|
||
- [ ] **Step 2: Add a `_NEXT.md` index**
|
||
|
||
Create `docs/superpowers/plans/_NEXT.md`:
|
||
|
||
```markdown
|
||
# Plan Backlog
|
||
|
||
Phase A is implemented (this file's siblings). After Phase A merges and CI is green:
|
||
|
||
1. **Phase B — NDI Interop & Pipeline** — add real P/Invoke shim in `TeamsISO.Engine.NdiInterop`, real `IFrameScaler` against libyuv, `NdiReceiver` and `NdiSender`, `IsoPipeline`, `IsoController`, runtime version probe. Console smoke runner. Integration test suite goes live (Windows + NDI runtime required).
|
||
2. **Phase C — UI & Packaging** — WPF MVVM app on top of the engine. Settings view, participant list, alert banner, system health indicators. WiX MSI installer, release pipeline on tag, About dialog.
|
||
|
||
Each phase gets its own `YYYY-MM-DD-teamsiso-phase-X-<topic>.md` plan written by `superpowers:writing-plans` once the previous phase is shipped.
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add docs/test-playbook.md docs/superpowers/plans/_NEXT.md
|
||
git commit -m "docs: add Phase A test playbook stub and plan backlog"
|
||
git push origin main
|
||
```
|
||
|
||
- [ ] **Step 4: Tag the Phase A milestone**
|
||
|
||
Run:
|
||
```
|
||
git tag -a phase-a-complete -m "Phase A: Engine foundation complete (domain, parser, tracker, processor, config, fakes, CI)"
|
||
git push origin phase-a-complete
|
||
```
|
||
|
||
- [ ] **Step 5: Final verification**
|
||
|
||
Run:
|
||
```
|
||
dotnet build TeamsISO.sln
|
||
dotnet test TeamsISO.sln
|
||
```
|
||
Expected: build succeeds; all unit tests pass; integration test scaffold reports 1 skipped. CI on `main` is green at HEAD with coverage ≥80%.
|
||
|
||
Phase A is done. Phase B starts when you're ready — open a fresh chat and ask for a "phase B plan" referencing the spec and `_NEXT.md`.
|
||
|
||
---
|
||
|
||
## Self-review (run by the author of the plan)
|
||
|
||
**1. Spec coverage** — every spec section maps to at least one task:
|
||
|
||
- Spec §2 Architecture (six-project layout, engine/UI split): Tasks 2–6.
|
||
- Spec §3 Domain model: Tasks 8, 10, 11.
|
||
- Spec §4 Components — `NdiDiscoveryService`, `ParticipantTracker`: Tasks 17, 18. `FrameProcessor`: Task 21. `ConfigStore`: Task 12. `SolidFrameRenderer`: Task 19. Logging: Task 23. The remaining components — `IsoPipeline`, `NdiReceiver`, `NdiSender`, `IsoController` — are in Phase B by design (they require real interop or are pure orchestration over interop-touching components).
|
||
- Spec §5 Data flow & threading: covered for the processor's logic in Task 21; Phase B will assemble the full pipeline with dedicated capture/send threads.
|
||
- Spec §6 Error handling: `ConfigStore` corruption (Task 12) and slate fallback (Task 21). Pipeline isolation, restart backoff, and runtime probe alerts move to Phase B because they're orchestration concerns over real interop.
|
||
- Spec §7 Testing: Tasks 8, 9, 12, 17–19, 21 build the unit test suite; Task 22 enforces coverage gate. Integration test project scaffolded (Task 6); real integration tests added in Phase B alongside the production interop.
|
||
- Spec §8 Build & packaging: solution layout (Tasks 2–6), CI (Tasks 7, 22). MSI installer is Phase C.
|
||
- Spec §9 Open tasks: tracked in `_NEXT.md` (Task 24).
|
||
|
||
**2. Placeholder scan** — searched for "TBD", "TODO", "implement later". None remain in the plan body. Each step has full code or full command.
|
||
|
||
**3. Type consistency** — cross-checked names: `NdiSourceKind` (used in Tasks 8, 9), `IsoState` (Tasks 8, 10), `NdiSource.FullName/MachineName/Kind/DisplayName` (Tasks 9, 17, 18 consistent), `Participant.Id/DisplayName/CurrentSource/FirstSeen/LastSeen` (Tasks 10, 17 consistent), `IsoHealthStats` field set (Tasks 10, 21 consistent — `FramesIn/FramesOut/FramesDropped/FramesDuplicated/LastFrameAt/IncomingFps/IncomingWidth/IncomingHeight`), `INdiInterop` method shape (Tasks 13, 15, 18 consistent), `IFrameClock.NowTicks/WaitForNextTickAsync` (Tasks 14, 15, 21 consistent), `FrameProcessor.ProcessOnceAsync` signature (Task 21 internally consistent and matches test invocations).
|
||
|
||
**4. Ambiguity check** — each task names exact file paths, exact commands, and exact expected output. Tasks declared "Phase A scope" explicitly defer items the spec mentions but that depend on real NDI runtime (interop implementation, IsoPipeline orchestration, runtime probe alerts).
|
||
|
||
No issues to fix. Plan ready for execution.
|