Slice 2. 588 green, 0 warnings. - `FramePumpStats` and `FramePump.StatsReported`: the 5 s window the log line already describes, as data. `Dropped` is the ENCODER's counter (frames submitted, not accepted); `Stalls`/`AvgRenderMs` are the pump's. `FramesRendered` is deliberately not used for classification — a static scene reuses the paste cache and renders few frames while completely healthy, so it would read as starvation. - `StepDownPlanner`: one per broadcast, owns the stateful policy, converts stats → sample, exposes the landed tier for the restart. It never acts — the VM owns the effects, which is what keeps the ladder testable without an encoder. - `Describe` / `ReportLine`: the sentences a creator sees. Machine cause says LlamaCasty couldn't keep up and the connection was not the problem; upload cause blames the upload and promises no further thrash. A test asserts the machine copy never leaks "dropped" — mechanism strings stay in the log. Remaining for the task: VM subscription, the encoder restart at the landed tier, the toast, and shedding our own capture cost before stepping fps.
103 lines
4.0 KiB
C#
103 lines
4.0 KiB
C#
using System;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
using ytLive.Services.Encoder;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// TASK 33 slice 2 — the adaptor between the pump's structured stats and the pure policy, plus the two
|
|
/// sentences a creator ever sees. The copy is pinned here because "own the mistake" is a product rule,
|
|
/// not a wording preference: a mechanism string in a toast is the bug.
|
|
/// </summary>
|
|
public sealed class StepDownPlannerTests
|
|
{
|
|
private static FramePumpStats Strained(double renderMs = 45, int stalls = 12, int dropped = 0) =>
|
|
new(WindowSeconds: 5, FramesSubmitted: 150, FramesRendered: 150,
|
|
AvgRenderMs: renderMs, AvgSubmitMs: 1, AvgWaitMs: 14,
|
|
WorstRenderMs: renderMs * 2, WorstSubmitMs: 2, Dropped: dropped, Stalls: stalls);
|
|
|
|
[Fact]
|
|
public void MachineEvidenceAfterThreeWindows_LowersTheFrameRateAndSaysWhoseFaultItWas()
|
|
{
|
|
var now = new DateTime(2026, 11, 9, 12, 0, 0, DateTimeKind.Utc);
|
|
var planner = new StepDownPlanner(
|
|
new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0), now);
|
|
|
|
StepDownDecision decision = default;
|
|
for (var i = 0; i < 4 && !decision.Acted; i++)
|
|
{
|
|
now = now.AddSeconds(5);
|
|
decision = planner.Observe(Strained(), now);
|
|
}
|
|
|
|
Assert.True(decision.Acted);
|
|
Assert.Equal(StepAction.LowerFrameRate, decision.Action);
|
|
|
|
var landed = planner.Current;
|
|
Assert.Equal(30, landed.Fps);
|
|
|
|
var copy = StepDownPlanner.Describe(decision, landed);
|
|
Assert.Contains("LlamaCasty couldn't keep up", copy);
|
|
Assert.Contains("connection wasn't the problem", copy);
|
|
Assert.DoesNotContain("dropped", copy, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void EncoderDrops_LowerTheBitrateAndTheCopyBlamesTheUpload()
|
|
{
|
|
var now = new DateTime(2026, 11, 9, 12, 0, 0, DateTimeKind.Utc);
|
|
var planner = new StepDownPlanner(
|
|
new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0), now);
|
|
|
|
StepDownDecision decision = default;
|
|
for (var i = 0; i < 4 && !decision.Acted; i++)
|
|
{
|
|
now = now.AddSeconds(5);
|
|
decision = planner.Observe(Strained(renderMs: 1, stalls: 0, dropped: 30), now);
|
|
}
|
|
|
|
Assert.Equal(StepAction.LowerBitrate, decision.Action);
|
|
|
|
var landed = planner.Current;
|
|
Assert.Equal(60, landed.Fps);
|
|
Assert.Equal(1920, landed.Width);
|
|
Assert.True(landed.BitrateMbps < 8.0);
|
|
|
|
var copy = StepDownPlanner.Describe(decision, landed);
|
|
Assert.Contains("upload couldn't hold", copy);
|
|
Assert.DoesNotContain("couldn't keep up", copy);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheSessionReportLine_NamesTheCauseInOnePlace()
|
|
{
|
|
var when = new DateTime(2026, 11, 9, 12, 4, 0);
|
|
var machine = new StepDownDecision(StepCause.Machine, StepAction.LowerFrameRate, "frame rate 60 → 30");
|
|
var upload = new StepDownDecision(StepCause.Upload, StepAction.LowerBitrate, "8.0 → 7.2 Mbps on 10% loss");
|
|
|
|
Assert.Equal("12:04 quality stepped down — encoder (frame rate 60 → 30)",
|
|
StepDownPlanner.ReportLine(when, machine));
|
|
Assert.Equal("12:04 quality stepped down — upload (8.0 → 7.2 Mbps on 10% loss)",
|
|
StepDownPlanner.ReportLine(when, upload));
|
|
Assert.Equal(string.Empty,
|
|
StepDownPlanner.ReportLine(when, new StepDownDecision(StepCause.None, StepAction.None, null)));
|
|
}
|
|
|
|
[Fact]
|
|
public void ShortWindows_AreIgnored_SoAStartupBurstCannotTriggerAStep()
|
|
{
|
|
var now = new DateTime(2026, 11, 9, 12, 0, 0, DateTimeKind.Utc);
|
|
var planner = new StepDownPlanner(
|
|
new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0), now);
|
|
|
|
var burst = Strained() with { WindowSeconds = 1 };
|
|
for (var i = 0; i < 20; i++)
|
|
{
|
|
now = now.AddSeconds(1);
|
|
Assert.False(planner.Observe(burst, now).Acted);
|
|
}
|
|
}
|
|
}
|