teamsiso/docs/superpowers/plans/2026-05-07-teamsiso-phase-a-engine-foundation.md

85 KiB
Raw Blame History

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:

# .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:

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:

<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:

# 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
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
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
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:

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
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:

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
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:

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
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:

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
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:

      - 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:

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:

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
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:

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:

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:

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
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:

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:

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:

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:

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
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:

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:

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:

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
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:

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:

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
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:

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:

namespace TeamsISO.Engine.Interop;

public abstract class NdiReceiverHandle : IDisposable
{
    public abstract void Dispose();
}

Create src/TeamsISO.Engine/Interop/NdiSenderHandle.cs:

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:

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:

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:

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:

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:

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
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:

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:

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
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:

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
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:

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:

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
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 DiscoveryEvents 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:

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:

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
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:

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:

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
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:

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:

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
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:

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:

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
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 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:

      - 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
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:

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
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:

# 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:

# 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
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 26.
  • 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, 1719, 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 26), 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.