Files
LlamaCasty/ytLive.Tests/FramePumpTests.cs
T
gramps 72cb3ad55a feat(stream): retry a dropped push instead of ending the session (TASK 32·1)
A dead encoder used to run the whole stop path, so a one-second blip ended the broadcast. Now a dropped
push is relaunched against the same ingest while the broadcast is still alive.

- `Services/StreamRetryPolicy.cs` — immediate, +1 s, +2 s, +3 s. Minimum waits on purpose (creator
  ruling): YouTube's grace is short, so every second not pushing is grace spent.
- `Services/StreamRecovery.cs` — the loop, with the audio pipe recreated before every attempt (its
  server is single-instance and unusable after the old client dies, so a naive relaunch fails on a
  missing input). After a push comes up it asks the API whether the broadcast survived: "complete"
  ends recovery as `BroadcastComplete` instead of showing a live sign over a dead stream; an API
  hiccup counts as recovered rather than ending a healthy session.
- `FramePump.EncoderFailed` — a dead push is recoverable; a compositor fault still goes through
  `Failed` and ends the session, because retrying it would loop.
- `MainViewModel.Recovery.cs` — wires the steps, reports each episode into the left panel alert stack
  (one row per episode, updated in place, log range painted), and cancels on Stop.
- Alert stack polish: a rule and a gap now separate the stack from the scene layers (they read as one
  list before), and the licence indicator uses the creator's wording — `STEAM ID NOT PRESENT` /
  `Steam ID Limit: 30fps` / `Click for More Info`, becoming `STEAM ID PRESENT` / `60fps` with a close
  control once owned.

Tests: `StreamRecoveryTests` (schedule, recover on attempt 2 with the pipe restarted each time,
complete-broadcast honesty, unknown liveness, give-up, cancel, audio-restart fault),
`FramePumpTests` now pin that process death raises `EncoderFailed` and not `Failed`.
FAST gate: 538 passed (one documented pre-existing audio failure), 0 warnings.
2026-11-05 12:09:38 -08:00

669 lines
29 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// The live frame producer (TASK 4 ship step 5): composites the active scene at
/// the tier's FPS and paces frames into the encoder. The integration test drives
/// the full lifecycle against fakes — real SceneCompositor + real FramePump, fake
/// IFfmpegEncoder — proving the composite frame actually reaches the encoder and
/// that stop tears the pump down cleanly. The units pin the failure edges: the
/// no-URL skip (the TASK 5 seam), re-entrancy, and encoder death.
/// </summary>
public class FramePumpTests
{
private sealed class FakeEncoder : IFfmpegEncoder
{
public readonly List<VideoFrame> Frames = new();
public readonly List<byte[]> Backings = new();
public int StartCount;
public int StopCount;
public bool Disposed;
public EncoderOptions? LastOptions;
public Exception? StartError;
public TaskCompletionSource FrameArrived = new(TaskCreationOptions.RunContinuationsAsynchronously);
public event EventHandler<StreamHealth>? HealthUpdated;
public event EventHandler<string>? ProcessFailed;
public Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
{
StartCount++;
LastOptions = options;
if (StartError != null) throw StartError;
return Task.CompletedTask;
}
public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
{
// Snapshot the bytes like the production FfmpegEncoder does (WriteAsync to
// stdin copies before returning). Holding the reference would race the
// pump's scratch pool legitimately reusing the buffer one tick later.
Frames.Add(new VideoFrame(frame.Width, frame.Height, (byte[])frame.BgraPixels.Clone()));
Backings.Add(frame.BgraPixels); // the pre-copy buffer — pool-reuse witness
FrameArrived.TrySetResult();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken = default)
{
StopCount++;
return Task.CompletedTask;
}
public void Dispose() => Disposed = true;
public void RaiseProcessFailed(string message) => ProcessFailed?.Invoke(this, message);
public void RaiseHealth(StreamHealth health) => HealthUpdated?.Invoke(this, health);
public int DroppedFrames { get; set; }
}
private static Scene BackgroundScene()
{
var scene = new Scene { Name = "Live" };
scene.Elements.Add(new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" });
return scene;
}
// The log sink must be thread-safe: FramePump reports stats from its OWN loop thread while
// the test enumerates this collection (a plain List<string> threw "Collection was modified"
// under full-suite load — the pump's extra parts/scene-dump lines made it fire far more often).
private static FramePump NewPump(FakeEncoder encoder, Func<EncoderOptions?>? options = null,
Func<Scene?>? scene = null, Func<SceneElement, VideoFrame?>? resolve = null,
ConcurrentBag<string>? log = null,
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
{
return new FramePump(
sceneProvider: scene ?? (() => BackgroundScene()),
frameResolver: resolve ?? (_ => null),
compositorOptions: () => new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 64, SourceRectHeight = 48,
OutputWidth = 64, OutputHeight = 48,
},
encoderOptions: options ?? (() => new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
Width = 64, Height = 48, Fps = 60,
}),
encoderFactory: () => encoder,
log: log != null ? m => log.Add(m) : null,
pacingDelay: async (_, _) => await Task.Yield(), // deterministic: no real waits
socialBar: socialBar);
}
private static void AssertColor(VideoFrame frame, int x, int y, byte r, byte g, byte b)
{
var i = (y * frame.Width + x) * 4;
var br = frame.BgraPixels[i + 2];
var bg = frame.BgraPixels[i + 1];
var bb = frame.BgraPixels[i];
Assert.True(
Math.Abs(br - r) <= 2 && Math.Abs(bg - g) <= 2 && Math.Abs(bb - b) <= 2,
$"pixel ({x},{y}): expected rgb({r},{g},{b}), got rgb({br},{bg},{bb})");
}
private sealed class FakeHttpHandler : HttpMessageHandler
{
public FakeHttpHandler(string body) => Body = body;
public string Body;
public string? LastUri;
public int RequestCount;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestCount++;
LastUri = request.RequestUri?.ToString();
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(Body, Encoding.UTF8, "application/json"),
});
}
}
/// <summary>The ONE integration test for TASK 5: the real service (hermetic
/// HTTP) returns the reusable stream's RTMP URL, the provider seam hands it
/// to the pump, and the pump starts the encoder with that exact URL — the
/// chain that makes go-live actually push. The stream is reused (listed),
/// never recreated (no POST).</summary>
[Fact]
public async Task ReusableStream_Url_From_Service_Feeds_Encoder_Startup()
{
var handler = new FakeHttpHandler(
"""
{"items":[{"id":"S456","cdn":{"isReusable":true,"ingestionInfo":{"ingestionAddress":"rtmp://a.rtmp.youtube.com/live2","streamName":"KEY123"}}}]}
""");
var auth = new YouTubeAuthService("test-id", "test-secret");
auth.SetSession(new YouTubeChannel
{
AccessToken = "acc-123",
TokenExpiry = DateTime.UtcNow.AddHours(1),
});
var service = new YouTubeStreamService(auth, new HttpClient(handler));
var url = (await service.GetOrCreateReusableStreamAsync())?.RtmpUrl;
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, options: () => url == null
? null
: new EncoderOptions { RtmpUrl = url, Width = 64, Height = 48, Fps = 60 });
await pump.StartAsync();
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY123", url);
Assert.Equal(1, encoder.StartCount);
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY123", encoder.LastOptions?.RtmpUrl);
Assert.Equal(1, handler.RequestCount); // reuse path: list only, no insert
Assert.NotNull(handler.LastUri);
Assert.Contains("liveStreams", handler.LastUri);
Assert.Contains("mine=true", handler.LastUri);
await pump.StopAsync();
}
[Fact]
public async Task Start_CompositesScene_FeedsEncoder_StopsCleanly()
{
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var encoder = new FakeEncoder();
using var pump = NewPump(encoder,
resolve: e => e is Source { IsBackground: true } ? red : null);
await pump.StartAsync();
Assert.True(pump.IsRunning);
Assert.Equal(1, encoder.StartCount);
await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.NotEmpty(encoder.Frames);
var frame = encoder.Frames[0];
Assert.Equal(64, frame.Width);
Assert.Equal(48, frame.Height);
AssertColor(frame, 0, 0, 255, 0, 0); // the background really was composited in
await pump.StopAsync();
Assert.False(pump.IsRunning);
Assert.Equal(1, encoder.StopCount);
Assert.True(encoder.Disposed);
}
[Fact]
public async Task Start_WithoutRtmpUrl_SkipsEncoder()
{
var encoder = new FakeEncoder();
var log = new ConcurrentBag<string>();
using var pump = NewPump(encoder, options: () => null, log: log);
await pump.StartAsync();
Assert.False(pump.IsRunning);
Assert.Equal(0, encoder.StartCount);
Assert.Contains(log, m => m.Contains("no output configured"));
await pump.StopAsync(); // no-op after a skipped start
Assert.Equal(0, encoder.StopCount);
}
[Fact]
public async Task Start_WhileRunning_IsNoop()
{
var encoder = new FakeEncoder();
using var pump = NewPump(encoder);
await pump.StartAsync();
await pump.StartAsync();
Assert.Equal(1, encoder.StartCount);
}
[Fact]
public async Task Stop_WithoutStart_IsNoop()
{
var encoder = new FakeEncoder();
using var pump = NewPump(encoder);
await pump.StopAsync();
Assert.Equal(0, encoder.StopCount);
Assert.False(pump.IsRunning);
}
[Fact]
public async Task Start_WithSocialBarSeam_PlacesBarAtTopThenBottomEdge()
{
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var bar = new VideoFrame(64, 8, new byte[64 * 8 * 4]);
Array.Fill(bar.BgraPixels, (byte)255); // opaque white strip
var encoder = new FakeEncoder();
var position = SocialBarPosition.Top;
using var pump = NewPump(encoder,
resolve: e => e is Source { IsBackground: true } ? red : null,
socialBar: () => (bar, position));
await pump.StartAsync();
await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.NotEmpty(encoder.Frames);
AssertColor(encoder.Frames[0], 0, 0, 255, 255, 255); // bar at the top edge
// Flip to Bottom: the pump re-reads the seam each frame, so a later frame
// lands the bar at the bottom edge (SourceRectHeight - bar height) and the
// top corner clears back to background.
position = SocialBarPosition.Bottom;
VideoFrame? flipped = null;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && flipped == null)
{
for (var i = 1; i < encoder.Frames.Count; i++)
{
var f = encoder.Frames[i];
if (f.BgraPixels[2] == 255 && f.BgraPixels[1] == 0 && f.BgraPixels[0] == 0)
{
flipped = f;
break;
}
}
if (flipped == null) await Task.Delay(10);
}
Assert.NotNull(flipped);
AssertColor(flipped!, 0, 47, 255, 255, 255); // bar sits on the bottom edge
await pump.StopAsync();
}
[Fact]
public async Task Start_EncoderThrows_RaisesFailed_AndDisposes()
{
var encoder = new FakeEncoder { StartError = new InvalidOperationException("access denied") };
var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
using var pump = NewPump(encoder);
pump.Failed += (_, m) => failed.TrySetResult(m);
await pump.StartAsync();
Assert.False(pump.IsRunning);
var message = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Contains("access denied", message);
Assert.True(encoder.Disposed);
}
[Fact]
public async Task ProcessDeath_StopsPump_AndRaisesEncoderFailed()
{
var encoder = new FakeEncoder();
var encoderFailed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var fatal = false;
using var pump = NewPump(encoder);
// TASK 32·1: a dead push is recoverable — the broadcast may still be live — so it is a
// separate signal from a fatal pump fault, and the session gets to relaunch it.
pump.EncoderFailed += (_, m) => encoderFailed.TrySetResult(m);
pump.Failed += (_, _) => fatal = true;
await pump.StartAsync();
encoder.RaiseProcessFailed("FFmpeg exited with code 1");
var message = await encoderFailed.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Contains("1", message);
Assert.False(fatal, "an encoder death must not be reported as a fatal pump failure");
// The pump self-stops (fire-and-forget); poll the definitive teardown
// marker — the encoder's disposal — rather than the IsRunning flag, which
// StopAsync clears before the loop has fully drained.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!encoder.Disposed && DateTime.UtcNow < deadline)
await Task.Delay(10);
Assert.True(encoder.Disposed);
Assert.False(pump.IsRunning);
}
[Fact]
public async Task HealthUpdated_ForwardsEncoderHealth()
{
var encoder = new FakeEncoder();
var health = new TaskCompletionSource<StreamHealth>(TaskCreationOptions.RunContinuationsAsynchronously);
using var pump = NewPump(encoder);
pump.HealthUpdated += (_, h) => health.TrySetResult(h);
await pump.StartAsync();
encoder.RaiseHealth(new StreamHealth { Status = StreamStatus.Streaming, FPS = 59.9 });
var h = await health.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(StreamStatus.Streaming, h.Status);
Assert.Equal(59.9, h.FPS, 1);
}
/// <summary>The ONE integration test for the take-3 starvation fix (2026-09-03):
/// the frame interval is a DEADLINE — render cost eats into it, never piles on
/// top (the OBS libobs video-io.c pacing pattern). The old loop slept the full
/// interval AFTER each render, so 30 wall-seconds of 258ms renders muxed into a
/// 2.1s 60fps time-lapse (rawvideo stamps by arrival). Here the fake render
/// costs ≥60ms of a 200ms interval and the pacing seam records every requested
/// wait: no request may reach the full interval (that was the bug), none may be
/// negative, and a machine slow enough to blow every deadline is still honest
/// (zero waits &lt; interval passes too).</summary>
[Fact]
public async Task Pump_Paces_To_The_Deadline_Compensating_Render_Cost()
{
const int fps = 5; // 200ms interval — generous margin over the fake cost
const int renderCostMs = 60; // Thread.Sleep is a guaranteed lower bound
var interval = TimeSpan.FromSeconds(1d / fps);
var encoder = new FakeEncoder();
var scene = BackgroundScene();
var delays = new List<TimeSpan>();
using var pump = new FramePump(
sceneProvider: () => scene,
frameResolver: _ =>
{
System.Threading.Thread.Sleep(renderCostMs);
return null;
},
compositorOptions: () => new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 64, SourceRectHeight = 48,
OutputWidth = 64, OutputHeight = 48,
},
encoderOptions: () => new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
Width = 64, Height = 48, Fps = fps,
},
encoderFactory: () => encoder,
pacingDelay: async (d, ct) =>
{
lock (delays) delays.Add(d);
await Task.Delay(d, ct); // honors the request, like the real Task.Delay default
});
await pump.StartAsync();
var deadline = DateTime.UtcNow.AddSeconds(10);
while (DateTime.UtcNow < deadline)
{
lock (delays) if (delays.Count >= 3) break;
await Task.Delay(10);
}
await pump.StopAsync();
List<TimeSpan> got;
lock (delays) got = delays.ToList();
Assert.True(got.Count >= 3, $"pump paced only {got.Count} frames in 10s");
foreach (var d in got)
{
Assert.True(d >= TimeSpan.Zero, $"requested wait {d} is negative");
Assert.True(d < interval,
$"requested wait {d} reaches the full {interval} interval — render cost must subtract from the deadline (the take-3 bug)");
}
}
/// <summary>The ONE integration test for the slice-15 pacing fix (2026-09-14):
/// a render that overruns the deadline must NOT skip the missed slots — a skip
/// authors fast playback: ty-1726 rendered one frame per 35ms overrun into a
/// 60fps container (163 frames for 2.93s of audio) and cut the audio tail off.
/// OBS fills an overrun's slots by DUPLICATING the newest frame (libobs
/// media-io/video-io.c; docs.obsproject.com/backend-design: "If the video frame
/// queue is full, it will duplicate the last frame") so recording duration stays
/// equal to wall duration. Here a 60fps pump renders every frame at ~35ms cost
/// (a genuine overrun): the slice-10 keep-fresh loop authored ~1 per 35ms; the
/// slot-filling loop must emit a frame for ~every 16.6ms of wall time.</summary>
[Fact]
public async Task Pump_Overrun_Renders_EmitsEverySlot_NotSkipped()
{
const int fps = 60;
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, resolve: _ =>
{
System.Threading.Thread.Sleep(35);
return null;
});
await pump.StartAsync();
await Task.Delay(7000); // ~420 slots of wall time
await pump.StopAsync();
// Slot-faithful: ~1 frame per 16.6ms -> ~415 in the window. Skipping (the
// slice-10 behavior): ~1 per 35ms -> ~200. 0.65 * fps * (elapsed seconds)
// is a middle line a skipped pump can never cross, a filling one only fails
// on a pathological run.
Assert.True(encoder.Frames.Count >= (int)(7.0 * fps * 0.65),
$"pump emitted {encoder.Frames.Count} frames in ~7s wall at {fps}fps — "
+ $"{7.0 / encoder.Frames.Count * fps:F1}x playback — an overrun must "
+ "DUPLICATE the newest frame per missed slot (OBS), not skip it");
}
/// <summary>The ONE integration test for the take-4 scratch pool: the pump recycles
/// its master buffers across frames (the 8.3MB-per-tick LOH churn that cost GC
/// stalls inside "render") while EVERY frame's content stays correct — stale bytes
/// from an earlier tick would show as a wrong color. The backdrop alternates
/// red/blue per OUTPUT frame (keyed on the burned index, which is stable across the
/// tick's resolver passes) and a repeated backing-array identity proves the pool
/// actually recycles (an inert pool keeps the pixel assertions green while leaving
/// the churn in place). The scene instance is STABLE across ticks, like production
/// (MainViewModel's StagedScene), so the compositor signature doesn't churn on
/// unrelated element identity.</summary>
[Fact]
public async Task Pump_Pools_ScratchBuffers_Across_Frames_Without_Stale_Pixels()
{
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var blue = SceneCompositorTests.Solid(64, 48, 0, 0, 255);
var scene = BackgroundScene();
var encoder = new FakeEncoder();
FramePump? pumpHolder = null;
using var pump = NewPump(encoder, scene: () => scene,
resolve: _ => pumpHolder!.OutputIndex % 2 == 0 ? red : blue);
pumpHolder = pump;
await pump.StartAsync();
var deadline = DateTime.UtcNow.AddSeconds(10);
while (DateTime.UtcNow < deadline && encoder.Frames.Count < 6)
await Task.Delay(10);
await pump.StopAsync();
Assert.True(encoder.Frames.Count >= 6, $"only {encoder.Frames.Count} frames produced");
for (var i = 0; i < encoder.Frames.Count; i++)
{
if (i % 2 == 0) AssertColor(encoder.Frames[i], 0, 0, 255, 0, 0);
else AssertColor(encoder.Frames[i], 0, 0, 0, 0, 255);
}
Assert.True(encoder.Backings.Distinct().Count() < encoder.Backings.Count,
"every frame rode a fresh buffer — the scratch pool is inert");
}
/// <summary>The ONE integration test for the C4 blit-on-change cache (2026-09-15):
/// the ty-1841 take logged a render stall on EVERY iteration (totalMs 21-44, one
/// full composite per ~30ms even though the content moved only ~20 updates/s) — the
/// SceneGraph split can't help because the live backdrop sits at element 0 and cannot
/// be baked (a cached capture goes stale). A static scene must render the composite
/// ONCE then reuse it per tick (re-render == 1, cache hits > 0, byte-identical
/// content above the burn strip); a new backdrop frame (new array + monotonic Epoch)
/// must invalidate the cache and propagate the changed pixels.</summary>
[Fact]
public async Task FullRenderCache_StaticInputs_RenderOnce_Then_Reuse_UntilInputChanges()
{
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var current = red;
// STABLE scene across ticks (production hands StagedScene) — a fresh scene per
// tick would churn the element references inside the render signature and force
// a re-composite every frame, hiding the cache entirely.
var scene = BackgroundScene();
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, scene: () => scene, resolve: _ => current);
await pump.StartAsync();
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && (encoder.Frames.Count < 8 || pump.CacheHits < 6))
await Task.Delay(10);
Assert.True(encoder.Frames.Count >= 8, $"only {encoder.Frames.Count} frames produced");
Assert.Equal(1, pump.CacheRenders); // one composite for the whole static phase
Assert.True(pump.CacheHits >= 6, $"cache reused only {pump.CacheHits} times");
// Every frame's content above the burn strip must be the same — the cache is
// serving the identical pre-burn composite; the burn counter varies only the
// bottom-right strip (rows 39..47, cols 27..64 in a 64×48 frame).
var reference = encoder.Frames[^1].BgraPixels.AsSpan(0, 37 * 64 * 4).ToArray();
Assert.All(encoder.Frames, f => Assert.True(
f.BgraPixels.AsSpan(0, 37 * 64 * 4).SequenceEqual(reference),
"a static input re-composited a different frame — the cache served stale bytes or never engaged"));
// Mutate: new backing array + Epoch guarantees a full signature miss.
var blue = new VideoFrame(64, 48, new byte[64 * 48 * 4]) { Epoch = 42 };
for (var i = 0; i < blue.BgraPixels.Length; i += 4)
{
blue.BgraPixels[i] = 255; // B
blue.BgraPixels[i + 3] = 255; // A
}
current = blue;
VideoFrame? changed = null;
deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && changed == null)
{
foreach (var f in encoder.Frames)
{
if (!f.BgraPixels.AsSpan(0, 37 * 64 * 4).SequenceEqual(reference))
{
changed = f;
break;
}
}
if (changed == null) await Task.Delay(10);
}
Assert.NotNull(changed);
AssertColor(changed!, 0, 0, 0, 0, 255); // the new backdrop propagated
Assert.True(pump.CacheRenders >= 2, "the changed input never re-composited");
await pump.StopAsync();
}
[Fact]
public async Task FullRenderCache_TextOverlayFrameChange_Recomposites()
{
var scene = new Scene { Name = "Text cache" };
scene.Elements.Add(new Source
{
Type = SourceType.TextOverlay,
Width = 64,
Height = 48,
});
var current = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, scene: () => scene, resolve: _ => current);
await pump.StartAsync();
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && (encoder.Frames.Count < 6 || pump.CacheHits < 4))
await Task.Delay(10);
Assert.Equal(1, pump.CacheRenders);
current = SceneCompositorTests.Solid(64, 48, 0, 0, 255);
deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && pump.CacheRenders < 2)
await Task.Delay(10);
Assert.True(pump.CacheRenders >= 2, "the changed text raster did not invalidate the full-render cache");
AssertColor(encoder.Frames[^1], 8, 8, 0, 0, 255);
await pump.StopAsync();
}
/// <summary>One integration test for the take-8 off-UI fix: the pump loop must NOT
/// run on the starting thread's SynchronizationContext (StartAsync fires from a UI
/// command handler; the old loop inherited it, so every frame competed with the
/// live preview for the dispatcher — the "wait 10ms after a 22ms render" that the
/// quantum stats exposed). An inline-pumping sync context makes the OLD code run
/// its resolver on the starting thread; the Task.Run'd loop must never do so.</summary>
[Fact]
public async Task Pump_Produces_OffTheStartingContext()
{
var startThread = Environment.CurrentManagedThreadId;
var resolverThreads = new List<int>();
var firstFrame = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, resolve: _ =>
{
lock (resolverThreads) resolverThreads.Add(Environment.CurrentManagedThreadId);
firstFrame.TrySetResult();
return null;
});
var prev = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new InlineSyncContext());
try
{
await pump.StartAsync();
await firstFrame.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
finally
{
SynchronizationContext.SetSynchronizationContext(prev);
await pump.StopAsync();
}
Assert.NotEmpty(resolverThreads);
Assert.All(resolverThreads, id => Assert.True(id != startThread,
$"resolver ran on the starting thread ({startThread}) — the loop is back on the caller context"));
}
/// <summary>The ONE integration test for UNIT 0's stats correction (2026-10-05).
/// The old line divided every average by the number of frames SUBMITTED — deadline
/// padding always fills that to the full 60fps target, so a pump fed one webcam
/// frame per 200ms still logged "300/300 frames per 5s" and the "avg render" was
/// diluted by an order of magnitude. It also never reported the webcam at all, so
/// a camera delivering 25 of its 30 frames was indistinguishable from one
/// delivering 30. Here a webcam advancing a new Epoch every ~200ms must show up
/// as FEWER distinct frames than submits, with the render count beside them.</summary>
[Fact]
public async Task Stats_ReportRealRendersAndDistinctWebcamFrames_NotJustPaddedSubmits()
{
var webcamPixels = SceneCompositorTests.Solid(64, 48, 0, 255, 0).BgraPixels;
var scene = new Scene { Name = "Live" };
scene.Elements.Add(new WebcamSceneConfig { Name = "Webcam", IsVisible = true });
var encoder = new FakeEncoder();
var log = new ConcurrentBag<string>();
var epoch = 0L;
using var pump = NewPump(encoder, scene: () => scene, log: log, resolve: e =>
{
if (!e.IsWebcam) return null;
// One NEW webcam frame every ~200ms — slower than the 60fps container,
// exactly the case the old stats line hid completely.
Thread.Sleep(200);
epoch++;
return new VideoFrame(64, 48, webcamPixels) { Epoch = epoch };
});
await pump.StartAsync();
var deadline = DateTime.UtcNow.AddSeconds(8);
while (DateTime.UtcNow < deadline && !log.Any(l => l.Contains("webcam ")))
await Task.Delay(50);
await pump.StopAsync();
var stats = log.FirstOrDefault(l => l.StartsWith("FramePump stats:") && l.Contains("webcam "));
Assert.True(stats != null, $"no cadence-bearing stats line in: {string.Join(" | ", log)}");
var submits = int.Parse(Regex.Match(stats!, @"stats: (\d+)/").Groups[1].Value);
var renders = int.Parse(Regex.Match(stats!, @"from (\d+) renders").Groups[1].Value);
var webcamFrames = int.Parse(Regex.Match(stats!, @"webcam (\d+)f/").Groups[1].Value);
Assert.True(submits > 0, stats);
Assert.True(renders > 0 && renders <= submits, $"renders {renders} vs submits {submits}: {stats}");
Assert.True(webcamFrames > 0 && webcamFrames < submits,
$"webcam frames {webcamFrames} should be far fewer than the {submits} padded submits: {stats}");
}
private sealed class InlineSyncContext : SynchronizationContext
{
public override void Post(SendOrPostCallback d, object? state) => d(state);
public override void Send(SendOrPostCallback d, object? state) => d(state);
public override SynchronizationContext CreateCopy() => this;
}
}