Files
LlamaCasty/ytLive.Tests/StepDownPolicyTests.cs
T
gramps bc62bd81ec feat(33): StepDownPolicy — two ladders, one budget, recovery behind a flag
Pure policy, no wiring yet. 12 unit tests, 584 green overall, 0 warnings.

- **Machine starvation** buys less work: 60 → 30 fps first, then 1080 → 720. The vertical tier has no
  resolution step — its floor *is* its only geometry.
- **Upload congestion** buys fewer bits: `current − max(0.5, current × lossFraction)`, floored at
  3.0 Mbps (16:9) / 4.0 (vertical). Resolution is deliberately not a bandwidth lever: 1080p30 and
  720p60 cost the same, so dropping pixels at an unchanged bitrate relieves nothing.
- **Budgets** as ruled: three steps for a 1920-wide start, one for 720 and vertical. 15 s of evidence,
  30 s cooldown, ambiguous evidence does nothing at all.
- **Recovery** is `allowClimb`, default off (the one-way ruling): the machine carries its ceiling from
  construction, climbs one step after 180 s of clean windows, and a climb followed by a relapse inside
  120 s freezes the session at the low value — one flap pair is proof enough.

Inputs are the poster's own counters (`PumpStalls`, `PumpDrops`, `AvgRenderMs`, `EncoderDrops`), not a
throughput reading: `StreamHealth.CurrentBitrate` is ffmpeg's *encoded output* rate and sits at target
even while the socket is gagging, so it cannot size a step.
2026-11-09 09:30:28 -08:00

258 lines
9.1 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;
using Xunit;
using ytLive.Models;
namespace ytLive.Tests;
/// <summary>
/// TASK 33 — the step-down ladders as pure policy. The two rules that shaped the design and are easy
/// to undo by "tidying" later:
///
/// <list type="number">
/// <item><b>Two levers, never mixed.</b> Machine starvation buys less work (fps, then resolution);
/// upload congestion buys fewer bits. Resolution at an unchanged bitrate relieves no congestion, and
/// an fps drop at an unchanged bitrate relieves no upload.</item>
/// <item><b>One-way by default.</b> Climbing is a constructor flag; the ceiling is carried either way
/// so recovery is a policy change, not a rewrite.</item>
/// </list>
/// </summary>
public sealed class StepDownPolicyTests
{
private static StepDownSample Machine(double renderMs = 40, int stalls = 10) =>
new(WindowSeconds: 5, PumpStalls: stalls, PumpDrops: 0, EncoderDrops: 0,
AvgRenderMs: renderMs, AvgSubmitWaitMs: 1);
private static StepDownSample Upload(int encoderDrops = 30) =>
new(WindowSeconds: 5, PumpStalls: 0, PumpDrops: 0, EncoderDrops: encoderDrops,
AvgRenderMs: 1, AvgSubmitWaitMs: 2);
private static StepDownSample Clean() =>
new(WindowSeconds: 5, PumpStalls: 0, PumpDrops: 0, EncoderDrops: 0,
AvgRenderMs: 1, AvgSubmitWaitMs: 1);
/// <summary>Feeds windows every 5 s until the policy acts; returns that decision, or the last
/// decision it reached when it never acted (so the "holding" reason is visible to the test).</summary>
private static StepDownDecision Run(StepDownPolicy policy, StepDownSample sample, ref double t, int maxWindows = 12)
{
var last = new StepDownDecision(StepCause.None, StepAction.None, null);
for (var i = 0; i < maxWindows; i++)
{
t += 5;
last = policy.Observe(sample, TimeSpan.FromSeconds(t));
if (last.Acted) return last;
}
return last;
}
[Fact]
public void MachineStarvation_LowersFrameRateBeforeAnythingElse()
{
var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
var t = 0.0;
var decision = Run(policy, Machine(), ref t);
Assert.Equal(StepCause.Machine, decision.Cause);
Assert.Equal(StepAction.LowerFrameRate, decision.Action);
Assert.Equal(30, policy.Fps);
Assert.Equal(8.0, policy.BitrateMbps); // bits were never the problem
Assert.Equal(1920, policy.Width);
}
[Fact]
public void MachineStarvationAtThirty_DropsResolutionNext()
{
var policy = new StepDownPolicy(vertical: false, startFps: 30, startBitrateMbps: 8.0);
var t = 0.0;
var decision = Run(policy, Machine(renderMs: 40), ref t);
Assert.Equal(StepAction.LowerResolution, decision.Action);
Assert.Equal(1280, policy.Width);
Assert.Equal(720, policy.Height);
Assert.Equal(30, policy.Fps);
}
[Fact]
public void MachineStarvationAtTheFloor_HoldsAndSaysSo()
{
var policy = new StepDownPolicy(vertical: false, startFps: 30, startBitrateMbps: 3.0);
var t = 0.0;
Run(policy, Machine(), ref t); // 1080p30 → 720p30
var t2 = t;
var next = Run(policy, Machine(renderMs: 90), ref t2);
Assert.False(next.Acted);
Assert.Contains("floor", next.Why ?? string.Empty);
Assert.Equal(1280, policy.Width);
}
[Fact]
public void UploadCongestion_StepsBitrateOnly_NeverGeometry()
{
var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
var t = 0.0;
var decision = Run(policy, Upload(encoderDrops: 30), ref t); // 30 / (60×5) = 10% loss
Assert.Equal(StepCause.Upload, decision.Cause);
Assert.Equal(StepAction.LowerBitrate, decision.Action);
Assert.Equal(7.2, policy.BitrateMbps); // 8.0 − max(0.5, 0.8)
Assert.Equal(60, policy.Fps);
Assert.Equal(1920, policy.Width);
}
[Fact]
public void UploadStep_IsSizedByLossAndStopsAtTheFloor()
{
var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 6.0);
var t = 0.0;
for (var i = 0; i < 8; i++)
{
t += 40; // clear the cooldown
policy.Observe(Upload(encoderDrops: 60), TimeSpan.FromSeconds(t)); // 20% loss
}
Assert.True(policy.BitrateMbps >= 3.0);
Assert.Equal(60, policy.Fps); // congested upload never buys an fps drop
Assert.Equal(1920, policy.Width);
}
[Fact]
public void BothKindsOfEvidenceInOneWindow_DoesNothing()
{
var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
var t = 0.0;
var mixed = new StepDownSample(5, PumpStalls: 20, PumpDrops: 3, EncoderDrops: 40,
AvgRenderMs: 50, AvgSubmitWaitMs: 30);
var decision = Run(policy, mixed, ref t, maxWindows: 6);
Assert.False(decision.Acted);
Assert.Equal(StepCause.Ambiguous, decision.Cause);
Assert.Equal(60, policy.Fps);
Assert.Equal(8.0, policy.BitrateMbps);
}
[Fact]
public void StepBudget_IsThreeForAFullHdStart_AndOneForSevenTwenty()
{
var full = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
var hd = new StepDownPolicy(startWidth: 1280, startHeight: 720, startFps: 60, startBitrateMbps: 6.0);
var vertical = new StepDownPolicy(vertical: true, startFps: 60, startBitrateMbps: 8.0);
Assert.Equal(3, full.StepsAllowed);
Assert.Equal(1, hd.StepsAllowed);
Assert.Equal(1, vertical.StepsAllowed);
}
[Fact]
public void VerticalTier_HasNoResolutionStepOnlyFrameRateAndBits()
{
var policy = new StepDownPolicy(vertical: true, startFps: 60, startBitrateMbps: 8.0);
var t = 0.0;
var first = Run(policy, Machine(), ref t);
Assert.Equal(StepAction.LowerFrameRate, first.Action);
Assert.Equal(1080, policy.Width);
Assert.Equal(1920, policy.Height);
var t2 = t;
var second = Run(policy, Machine(), ref t2);
Assert.False(second.Acted); // one step on a vertical start, then done
}
[Fact]
public void Cooldown_StopsASecondStepImmediatelyAfterTheFirst()
{
var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
var t = 0.0;
Run(policy, Upload(encoderDrops: 30), ref t);
// Three more qualifying windows, still inside the 30 s cooldown.
var acted = false;
for (var i = 0; i < 3; i++)
{
t += 5;
if (policy.Observe(Upload(encoderDrops: 30), TimeSpan.FromSeconds(t)).Acted) acted = true;
}
Assert.False(acted);
Assert.Equal(7.2, policy.BitrateMbps);
}
[Fact]
public void Climbing_IsOffByDefault_EvenAfterMinutesOfCleanWindows()
{
var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
var t = 0.0;
Run(policy, Upload(encoderDrops: 30), ref t);
var lowered = policy.BitrateMbps;
for (var i = 0; i < 60; i++)
{
t += 5;
policy.Observe(Clean(), TimeSpan.FromSeconds(t));
}
Assert.Equal(lowered, policy.BitrateMbps);
Assert.Equal(1, policy.StepsUsed);
}
[Fact]
public void WithRecoveryEnabled_ALongCleanStretchClimbsOneStep()
{
var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0, allowClimb: true);
var t = 0.0;
Run(policy, Upload(encoderDrops: 30), ref t);
StepDownDecision raised = default;
for (var i = 0; i < 60 && !raised.Acted; i++)
{
t += 5;
raised = policy.Observe(Clean(), TimeSpan.FromSeconds(t));
}
Assert.Equal(StepAction.RaiseBitrate, raised.Action);
Assert.True(policy.BitrateMbps > 7.2);
Assert.True(policy.BitrateMbps <= 8.0);
Assert.Equal(0, policy.StepsUsed);
}
[Fact]
public void AClimbFollowedByARelapse_FreezesTheSessionAtTheLowValue()
{
var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0, allowClimb: true);
var t = 0.0;
Run(policy, Upload(encoderDrops: 30), ref t);
for (var i = 0; i < 60; i++)
{
t += 5;
if (policy.Observe(Clean(), TimeSpan.FromSeconds(t)).Acted) break;
}
var raisedTo = policy.BitrateMbps;
// The link gives way again shortly after the climb.
for (var i = 0; i < 60; i++)
{
t += 5;
if (policy.Observe(Upload(encoderDrops: 60), TimeSpan.FromSeconds(t)).Acted) break;
}
Assert.True(policy.Frozen);
// Frozen: a much longer clean stretch must not climb again.
var afterRelapse = policy.BitrateMbps;
for (var i = 0; i < 120; i++)
{
t += 5;
policy.Observe(Clean(), TimeSpan.FromSeconds(t));
}
Assert.Equal(afterRelapse, policy.BitrateMbps);
Assert.True(afterRelapse <= raisedTo);
}
}