Each finished broadcast is filed locally — tier, probed encoder, rated, settled, steps spent — and Models/RateHistory turns that into at most a suggestion. The app never applies one: it asks before the next stream, "Use it" sets the rate, "Keep mine" remembers that exact number so the same question does not return until the evidence moves. Nothing leaves the machine. Two rules keep it from nagging. The high-water mark is the highest rate that *finished* a session with zero steps, not the highest seen, so one easy session (a menu screen, a quiet game) cannot set a target the machine cannot hold. And it is asymmetric: raising needs 10% above the current rate across five clean sessions of the same tier and encoder, lowering needs three step-downs in five — fixing damage should not wait for a percentage, and risking it should. The encoder key is real, not decorative: StreamHealth.VideoEncoder is now filled from the probe (FfmpegEncoder), so history recorded against NVENC is not applied to a machine that later runs QSV. At startup, before anything has streamed, the last session on that tier is the honest guess. 672 pass, 0 warnings.
124 lines
5.3 KiB
C#
124 lines
5.3 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
using ytLive.ViewModels;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// The creator's own video rate: per tier, clamped at the tier's YouTube minimum, persisted across
|
|
/// runs, and substituted into every consumer that builds encoder options.
|
|
///
|
|
/// <para>The rule that must never break: whatever the creator typed is what the encoder is asked to
|
|
/// hold, and no tier's minimum is quietly undercut.</para>
|
|
/// </summary>
|
|
[Collection("MainViewModelLayoutPath")]
|
|
public class RatedBitrateTests
|
|
{
|
|
[Fact]
|
|
public void The_creator_rate_is_per_tier_clamped_to_the_minimum_and_restored()
|
|
{
|
|
var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-rate-{Guid.NewGuid():N}.db");
|
|
MainViewModel.LayoutPathOverride = tempDb;
|
|
try
|
|
{
|
|
var vm = new MainViewModel();
|
|
var tier60 = vm.QualityOptions.Single(tier => tier.Display == "1080p60");
|
|
var tier30 = vm.QualityOptions.Single(tier => tier.Display == "1080p30");
|
|
vm.SelectedQuality = tier60;
|
|
|
|
// Unset means the shipped default, not zero.
|
|
Assert.Equal(tier60.Bitrate, vm.RatedMbps);
|
|
|
|
// A creator who knows their line sets their own number, and the encoder sees it.
|
|
vm.RatedMbps = 15;
|
|
Assert.Equal(15, vm.RatedMbps);
|
|
Assert.Equal(15, vm.EffectiveQuality(tier60).Bitrate);
|
|
Assert.Equal(60, vm.EffectiveQuality(tier60).Fps); // geometry stays the tier's
|
|
|
|
// Below YouTube's minimum for the tier is not a legal target.
|
|
vm.RatedMbps = 2;
|
|
Assert.Equal(RateLadder.For(tier60).Minimum, vm.RatedMbps);
|
|
|
|
// The other tier keeps its own value — one setting does not leak into the ladder.
|
|
vm.SelectedQuality = tier30;
|
|
Assert.Equal(tier30.Bitrate, vm.RatedMbps);
|
|
vm.SelectedQuality = tier60;
|
|
|
|
// And it survives a restart: the same profile, a fresh view model.
|
|
var reloaded = new MainViewModel();
|
|
reloaded.SelectedQuality = reloaded.QualityOptions.Single(tier => tier.Display == "1080p60");
|
|
Assert.Equal(RateLadder.For(tier60).Minimum, reloaded.RatedMbps);
|
|
}
|
|
finally
|
|
{
|
|
MainViewModel.LayoutPathOverride = null;
|
|
try { File.Delete(tempDb); } catch (IOException) { }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The suggestion the creator is asked about before a stream, and the refusal that stops it coming
|
|
/// back: raising is proposed only from five clean sessions above the 10% gap, applied only on a
|
|
/// click, and "keep mine" suppresses that exact number for the tier.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_suggested_rate_is_offered_between_sessions_and_only_a_click_applies_it()
|
|
{
|
|
var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-rate-proposal-{Guid.NewGuid():N}.db");
|
|
MainViewModel.LayoutPathOverride = tempDb;
|
|
try
|
|
{
|
|
using (var seed = new LayoutStore(tempDb))
|
|
{
|
|
for (var i = 0; i < 5; i++)
|
|
seed.AppendRateHistory(new SessionRateResult("1080p60", "h264_nvenc", 15.0, 15.0, 0));
|
|
Assert.Equal(5, seed.LoadRateHistory().Count);
|
|
}
|
|
|
|
var vm = new MainViewModel();
|
|
vm.SelectedQuality = vm.QualityOptions.Single(tier => tier.Display == "1080p60");
|
|
|
|
var historyCount = new LayoutStore(tempDb).LoadRateHistory().Count;
|
|
|
|
// Offered, not applied: the rate is still the shipped default until the creator agrees.
|
|
Assert.True(vm.HasRateProposal, $"history={historyCount}");
|
|
Assert.Contains("15", vm.RateProposalText);
|
|
Assert.Equal(12.0, vm.RatedMbps);
|
|
|
|
vm.AcceptRateProposalCommand.Execute(null);
|
|
Assert.Equal(15.0, vm.RatedMbps);
|
|
Assert.False(vm.HasRateProposal);
|
|
|
|
// Honest evidence now says 15 is what this line holds, so the same question stops being
|
|
// asked: 15 is no longer 10% above the rate in use.
|
|
var second = new MainViewModel();
|
|
second.SelectedQuality = second.QualityOptions.Single(tier => tier.Display == "1080p60");
|
|
Assert.False(second.HasRateProposal);
|
|
|
|
// Back to 12 by hand: the evidence qualifies again, and this time the creator says no.
|
|
second.RatedMbps = 12;
|
|
second.SelectedQuality = second.QualityOptions.Single(tier => tier.Display == "1080p30");
|
|
second.SelectedQuality = second.QualityOptions.Single(tier => tier.Display == "1080p60");
|
|
Assert.True(second.HasRateProposal);
|
|
|
|
second.DismissRateProposalCommand.Execute(null);
|
|
Assert.False(second.HasRateProposal);
|
|
Assert.Equal(12.0, second.RatedMbps);
|
|
|
|
// Refusal is remembered against that number, so a fresh run stays quiet.
|
|
var third = new MainViewModel();
|
|
third.SelectedQuality = third.QualityOptions.Single(tier => tier.Display == "1080p60");
|
|
Assert.False(third.HasRateProposal);
|
|
}
|
|
finally
|
|
{
|
|
MainViewModel.LayoutPathOverride = null;
|
|
try { File.Delete(tempDb); } catch (IOException) { }
|
|
}
|
|
}
|
|
}
|