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.
258 lines
9.1 KiB
C#
258 lines
9.1 KiB
C#
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);
|
||
}
|
||
}
|