Files
LlamaCasty/ytLive.Tests/StepDownPolicyTests.cs
T
gramps 0fa3006da7 feat(33): shed our own capture cost first; the task is done
Last slice. 590 green, 0 warnings.

- **Shed before you charge the viewer.** On machine evidence the policy's first act is
  `ShedCaptureCadence`: `ScreenCaptureFrameSource` widens its conversion interval 10 ms → 33 ms (≈100
  → 30 conversions/s) through a new `ScreenCaptureManager.ShedLoad`. The output tier, the declared fps
  and the viewer's picture are untouched — we stop paying for readbacks we were throwing away. It
  costs no step budget; if the starvation persists, the fps ladder runs on the next clean window.
- The shed is reset on every stop path, so a cheap session cannot leave a later one capped.
- **The session line is a Logs-tab line, not a session-report line.** The session report is the money
  summary (Super Chats, per currency); quality chatter there would bury the numbers it exists for. The
  cause-tagged line already lands in the log at the moment of the step.

TASK 33 is complete: two ladders, budgets, floors, ambiguity, one-way with recovery behind a flag, the
relaunch, the copy, and the cost shed. 18 tests pin the ladders, the copy and the restart.
2026-11-09 13:05:44 -08:00

277 lines
9.8 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 changes QUALITY; the capture-load shed is a
/// pre-step (no budget, nothing the viewer sees) so it is skipped here, and tested on its own.</summary>
private static StepDownDecision Run(StepDownPolicy policy, StepDownSample sample, ref double t, int maxWindows = 24)
{
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 && last.Action != StepAction.ShedCaptureCadence) return last;
}
return last;
}
[Fact]
public void MachineStarvation_ShedsOurOwnCaptureCostBeforeLoweringQuality()
{
var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
var t = 0.0;
StepDownDecision first = default;
for (var i = 0; i < 6 && !first.Acted; i++)
{
t += 5;
first = policy.Observe(Machine(), TimeSpan.FromSeconds(t));
}
Assert.Equal(StepAction.ShedCaptureCadence, first.Action);
Assert.Equal(60, policy.Fps);
Assert.Equal(0, policy.StepsUsed); // shedding is not a quality step
Assert.Equal(1920, policy.Width);
}
[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);
}
}