Files
LlamaCasty/ytLive.Tests/EncoderRestartTests.cs
T
gramps e654a16b60 fix(history): file each session under the real encoder, not "unknown"
FfmpegEncoder resolved the encoder name into its OWN StreamHealth instance, while the view
model read CurrentHealth — a different object. So every recorded session was filed as
"unknown", and the rate history's encoder key (evidence about NVENC says nothing about QSV)
had nothing to key on. The name now travels on the encoder interface and the pump forwards
it: IFfmpegEncoder.VideoEncoderName -> FramePump.EncoderName.

Caught while adding it: reading _framePump.EncoderName from RefreshRateProposal throws,
because that call runs at MainViewModel ctor line 354 and the pump is built at line 468.
Eight tests failed with the same NullReferenceException, which took a while to see because
the harness's test output is buffered — the first honest look at per-test logging named it
in one line. Guarded; the history falls back to the last session's encoder for that tier,
which is exactly the pre-first-session case the fallback already existed for.

Tests: FramePumpTests gains the encoder-name assertion (null before start, h264_nvenc
after), and the three fake encoders implement the new member.

675 pass, 0 warnings.
2026-11-12 13:57:03 -08:00

121 lines
4.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// TASK 33 slice 3 — the step-down's one dangerous act: relaunching the encoder mid-broadcast.
///
/// <para>The restart is a full pump stop/start rather than an encoder swap, and this test is why: a swap
/// leaves an in-flight submit pointing at a disposed process, which faults the loop and ends the
/// broadcast. Here the pump must come back and produce a frame with a <b>new</b> encoder instance and
/// the tier the policy landed on.</para>
/// </summary>
public sealed class EncoderRestartTests
{
[Fact]
public async Task ChangeEncoder_RelauchesWithANewEncoderAtTheNewTier()
{
var tier = (Width: 1920, Height: 1080, Fps: 60, Bitrate: 8.0);
var started = new List<EncoderOptions>();
var frames = new List<CapturingEncoder>();
var order = new List<string>();
var scene = new Scene { Name = "Live" };
scene.Elements.Add(new Source { Name = "Background", Type = SourceType.Background, IsBackground = true });
var black = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
using var pump = new FramePump(
sceneProvider: () => scene,
frameResolver: _ => black,
compositorOptions: () => new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0,
SourceRectWidth = 1920, SourceRectHeight = 1080,
OutputWidth = 1920, OutputHeight = 1080,
},
encoderOptions: () => new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
Width = tier.Width, Height = tier.Height, Fps = tier.Fps,
BitrateKbps = (int)(tier.Bitrate * 1000),
},
encoderFactory: () =>
{
var encoder = new CapturingEncoder();
encoder.Started += options =>
{
started.Add(options);
order.Add($"encoder-start-{options.Fps}");
};
encoder.Stopped = () => order.Add("encoder-stop");
frames.Add(encoder);
return encoder;
},
log: _ => { },
pacingDelay: (_, _) => Task.Delay(5));
await pump.StartAsync();
Assert.True(await frames[0].FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(5)), "first session never produced a frame");
// The policy lands on 1080p30 @ 6.4 Mbps; the VM has already written the override, so the
// options func now reports the new tier — which the restart must re-read.
tier = (1920, 1080, 30, 6.4);
Assert.True(await pump.ChangeEncoderAsync(
stopAudio: () => order.Add("audio-stop"),
startAudio: () => order.Add("audio-start")));
Assert.True(await frames[1].FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(5)), "restarted session never produced a frame");
Assert.Equal(2, started.Count);
Assert.Equal(60, started[0].Fps);
Assert.Equal(30, started[1].Fps);
Assert.Equal(6400, started[1].BitrateKbps);
Assert.NotSame(frames[0], frames[1]);
Assert.Equal(new[] { "encoder-start-60", "audio-stop", "encoder-stop", "audio-start", "encoder-start-30" }, order);
await pump.StopAsync();
}
private sealed class CapturingEncoder : IFfmpegEncoder
{
public string? VideoEncoderName => "h264_nvenc";
public readonly TaskCompletionSource<bool> FirstFrame = new(TaskCreationOptions.RunContinuationsAsynchronously);
public event Action<EncoderOptions>? Started;
public Action? Stopped { get; set; }
public int DroppedFrames => 0;
public event EventHandler<StreamHealth>? HealthUpdated { add { } remove { } }
public event EventHandler<string>? ProcessFailed { add { } remove { } }
public Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
{
Started?.Invoke(options);
return Task.CompletedTask;
}
public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
{
FirstFrame.TrySetResult(true);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken = default)
{
Stopped?.Invoke();
return Task.CompletedTask;
}
public void Dispose() { }
}
}