feat(quality): learn the starting rate from real sessions, on the creator's permission

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.
This commit is contained in:
2026-11-12 08:24:12 -08:00
parent f2cab5ca0f
commit b0f8263414
12 changed files with 407 additions and 1 deletions
+25
View File
@@ -159,6 +159,31 @@
<TextBlock Grid.Column="1" Text="Mbps" Margin="6,0,0,0" VerticalAlignment="Center" <TextBlock Grid.Column="1" Text="Mbps" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"/> Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"/>
</Grid> </Grid>
<!-- Between sessions only, never mid-show, never applied without a click: what
the last few sessions actually held (Models/RateHistory). -->
<Border Margin="0,6,0,0" Padding="8,6" CornerRadius="4"
Background="{StaticResource Brush.SurfaceContainerHigh}">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasRateProposal}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel>
<TextBlock Text="{Binding RateProposalText}" TextWrapping="Wrap" FontSize="11"
Foreground="{StaticResource Brush.OnSurface}"/>
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<Button Content="Use it" Command="{Binding AcceptRateProposalCommand}"
Style="{StaticResource YtButton}"/>
<Button Content="Keep mine" Command="{Binding DismissRateProposalCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="6,0,0,0"/>
</StackPanel>
</StackPanel>
</Border>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
+92
View File
@@ -0,0 +1,92 @@
namespace ytLive.Models;
/// <summary>One finished broadcast, as evidence about a tier's rate.</summary>
/// <param name="Tier">The quality tier's display name, e.g. "1080p60".</param>
/// <param name="Encoder">The probed encoder, e.g. "h264_nvenc" — history against one encoder is not
/// evidence about another, so a GPU or driver change cannot import stale conclusions.</param>
/// <param name="RatedMbps">The rate the session started at (the creator's or the tier default).</param>
/// <param name="SettledMbps">The rate the session ended at after any step-down.</param>
/// <param name="Steps">How many quality steps the session spent. Zero means the rate held.</param>
public readonly record struct SessionRateResult(
string Tier, string Encoder, double RatedMbps, double SettledMbps, int Steps)
{
public bool Stepped => Steps > 0;
}
/// <summary>
/// What the app has learned about a connection, as pure policy — the only thing allowed to suggest a
/// different starting rate, and it never applies one itself.
///
/// <para><b>High-water mark</b> is the highest rate that <i>finished</i> a session with zero steps, not
/// the highest number ever seen: one easy session (a menu screen, a quiet game) must not set a target
/// the machine cannot hold, which would make every later session look like a regression.</para>
///
/// <para><b>Asymmetric on purpose.</b> Raising needs a 10% improvement across five clean sessions, so
/// fixing nothing is slow; lowering needs only repeated step-downs, because fixing damage should not
/// wait. A creator may always override either way — see <c>MainViewModel.RatedMbps</c>.</para>
/// </summary>
public static class RateHistory
{
/// <summary>Sessions considered. Below this there is no evidence, only noise.</summary>
public const int Window = 5;
/// <summary>How far above the current rate an upward proposal must sit to be worth asking.</summary>
public const double RaiseThreshold = 0.10;
/// <summary>Step-downs in the window that make a downward proposal worth asking.</summary>
public const int LowerAfterSteps = 3;
/// <summary>A suggested starting rate, and which way it moves.</summary>
public readonly record struct Proposal(double Mbps, bool Raise);
/// <summary>
/// The suggestion for one tier, or null when the evidence does not warrant one. <paramref name="encoder"/>
/// null considers every encoder; callers pass the current one so a hardware change starts clean.
/// </summary>
public static Proposal? Suggest(
IEnumerable<SessionRateResult> history, string tier, string? encoder, double currentMbps)
{
var window = history
.Where(entry => entry.Tier == tier && (encoder is null || entry.Encoder == encoder))
.TakeLast(Window)
.ToList();
if (window.Count < Window) return null;
if (window.Count(entry => entry.Stepped) >= LowerAfterSteps)
return new Proposal(Math.Round(Median(window.Select(entry => entry.SettledMbps)), 1), Raise: false);
var highWater = window.Max(entry => entry.Stepped ? 0 : entry.RatedMbps);
return highWater >= currentMbps * (1 + RaiseThreshold)
? new Proposal(Math.Round(highWater, 0), Raise: true)
: null;
}
private static double Median(IEnumerable<double> values)
{
var sorted = values.OrderBy(value => value).ToArray();
if (sorted.Length == 0) return 0;
var middle = sorted.Length / 2;
return sorted.Length % 2 == 1
? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
}
// ─── Storage form: one line per session, unit-separated fields ───
private const char Field = '\u001F';
public static string Format(SessionRateResult entry)
=> string.Join(Field, entry.Tier, entry.Encoder,
entry.RatedMbps.ToString("0.#"), entry.SettledMbps.ToString("0.#"), entry.Steps);
public static SessionRateResult? Parse(string? line)
{
if (string.IsNullOrWhiteSpace(line)) return null;
var parts = line.Split(Field);
if (parts.Length != 5 || !double.TryParse(parts[2], out var rated)
|| !double.TryParse(parts[3], out var settled) || !int.TryParse(parts[4], out var steps))
return null;
return new SessionRateResult(parts[0], parts[1], rated, settled, steps);
}
}
+5
View File
@@ -29,6 +29,11 @@ public class StreamHealth
public TimeSpan StreamDuration { get; set; } public TimeSpan StreamDuration { get; set; }
public string? LastError { get; set; } public string? LastError { get; set; }
/// <summary>The encoder the probe actually chose ("h264_nvenc", "libopenh264", …). Filled once the
/// encoder starts, and the key the rate history is filed under — evidence about NVENC says nothing
/// about QSV on another machine.</summary>
public string? VideoEncoder { get; set; }
// YouTube-specific (liveStreams.status) // YouTube-specific (liveStreams.status)
public string? HealthStatus { get; set; } // "good", "ok", "bad", "noData" public string? HealthStatus { get; set; } // "good", "ok", "bad", "noData"
public string? HealthMessage { get; set; } public string? HealthMessage { get; set; }
+1
View File
@@ -82,6 +82,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false); var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
var encoder = options.VideoEncoder ?? await ProbeEncoderAsync(ffmpegPath, cancellationToken).ConfigureAwait(false); var encoder = options.VideoEncoder ?? await ProbeEncoderAsync(ffmpegPath, cancellationToken).ConfigureAwait(false);
_health.VideoEncoder = encoder;
var args = FfmpegArgs.Build(options, encoder); var args = FfmpegArgs.Build(options, encoder);
var startInfo = new ProcessStartInfo var startInfo = new ProcessStartInfo
+32 -1
View File
@@ -2,7 +2,6 @@ using Microsoft.Data.Sqlite;
using Notification.Core; using Notification.Core;
using ytLive.Services.Compositor; using ytLive.Services.Compositor;
using ytLive.Models; using ytLive.Models;
namespace ytLive.Services; namespace ytLive.Services;
public partial class LayoutStore : IDisposable public partial class LayoutStore : IDisposable
@@ -33,6 +32,38 @@ public partial class LayoutStore : IDisposable
public double? LoadRatedMbps(string tierDisplay) public double? LoadRatedMbps(string tierDisplay)
=> double.TryParse(GetSetting($"Video.Rate.{tierDisplay}"), out var mbps) && mbps > 0 ? mbps : null; => double.TryParse(GetSetting($"Video.Rate.{tierDisplay}"), out var mbps) && mbps > 0 ? mbps : null;
/// <summary>Finished sessions, oldest first — the evidence behind any rate suggestion. Capped so the
/// row never grows without bound: 40 sessions is several weeks of streaming for anyone.</summary>
public List<SessionRateResult> LoadRateHistory()
{
var raw = GetSetting("Video.RateHistory");
if (string.IsNullOrWhiteSpace(raw)) return new List<SessionRateResult>();
return raw.Split('\n')
.Select(RateHistory.Parse)
.Where(entry => entry is not null)
.Select(entry => entry!.Value)
.ToList();
}
public void AppendRateHistory(SessionRateResult entry)
{
var history = LoadRateHistory();
history.Add(entry);
if (history.Count > RateHistoryCap)
history.RemoveRange(0, history.Count - RateHistoryCap);
UpsertSetting("Video.RateHistory", string.Join('\n', history.Select(RateHistory.Format)));
}
private const int RateHistoryCap = 40;
/// <summary>The proposed rate the creator already refused for this tier, or zero for none. Stored as the
/// number rather than a flag, so a materially different suggestion may still be asked.</summary>
public double LoadDismissedRateProposal(string tierDisplay)
=> double.TryParse(GetSetting($"Video.RateProposalDismissed.{tierDisplay}"), out var mbps) ? mbps : 0;
public void SaveDismissedRateProposal(string tierDisplay, double mbps)
=> UpsertSetting($"Video.RateProposalDismissed.{tierDisplay}", mbps.ToString("0.#"));
public void SaveRatedMbps(string tierDisplay, double? mbps) public void SaveRatedMbps(string tierDisplay, double? mbps)
{ {
if (mbps is not > 0) if (mbps is not > 0)
+80
View File
@@ -1,3 +1,5 @@
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models; using ytLive.Models;
namespace ytLive.ViewModels; namespace ytLive.ViewModels;
@@ -86,4 +88,82 @@ public partial class MainViewModel
OnPropertyChanged(nameof(RatedMbps)); OnPropertyChanged(nameof(RatedMbps));
OnPropertyChanged(nameof(RateHint)); OnPropertyChanged(nameof(RateHint));
} }
// ─── Between-session suggestion (Models/RateHistory) ───
//
// Never applied on its own. The creator is asked once per materially different answer and may say
// "keep mine"; that refusal is remembered against the proposed value, so the same question is not
// asked again until the evidence actually moves.
private RateHistory.Proposal? _rateProposal;
public bool HasRateProposal => _rateProposal is not null;
public string? RateProposalText
{
get
{
if (_rateProposal is not { } proposal) return null;
var tier = SelectedQuality.Display;
return proposal.Raise
? $"Your last {RateHistory.Window} sessions on {tier} held {proposal.Mbps:0.#} Mbps. " +
$"Start the next stream there instead of {RatedMbps:0.#}?"
: $"Your last {RateHistory.Window} sessions on {tier} had to drop quality. " +
$"Start the next stream at {proposal.Mbps:0.#} Mbps instead of {RatedMbps:0.#}?";
}
}
public ICommand AcceptRateProposalCommand { get; private set; } = null!;
public ICommand DismissRateProposalCommand { get; private set; } = null!;
private void InitializeRateProposalCommands()
{
AcceptRateProposalCommand = new RelayCommand(_ =>
{
if (_rateProposal is not { } proposal) return;
RatedMbps = proposal.Mbps; // explicit consent is the only path that changes it
ClearRateProposal();
});
DismissRateProposalCommand = new RelayCommand(_ =>
{
if (_rateProposal is { } proposal)
_layoutStore.SaveDismissedRateProposal(SelectedQuality.Display, proposal.Mbps);
ClearRateProposal();
});
}
/// <summary>Recomputes the suggestion for the selected tier. Called at startup and whenever the tier
/// or the rate changes; silent unless the evidence warrants a change and the creator has not already
/// refused this exact number.</summary>
internal void RefreshRateProposal()
{
_rateProposal = null;
if (_layoutStore is not null)
{
var tier = SelectedQuality;
var history = _layoutStore.LoadRateHistory();
// Before this run's first stream the encoder is unknown; the last session on this tier is
// the honest guess, and evidence about a different encoder stays out either way.
var lastForTier = history.LastOrDefault(entry => entry.Tier == tier.Display);
var encoder = CurrentHealth.VideoEncoder
?? (string.IsNullOrEmpty(lastForTier.Encoder) ? null : lastForTier.Encoder);
var suggestion = RateHistory.Suggest(history, tier.Display, encoder, RatedMbps);
if (suggestion is { } candidate
&& _layoutStore.LoadDismissedRateProposal(tier.Display) != candidate.Mbps)
{
_rateProposal = candidate;
}
}
OnPropertyChanged(nameof(HasRateProposal));
OnPropertyChanged(nameof(RateProposalText));
}
private void ClearRateProposal()
{
_rateProposal = null;
OnPropertyChanged(nameof(HasRateProposal));
OnPropertyChanged(nameof(RateProposalText));
}
} }
+25
View File
@@ -19,12 +19,14 @@ public partial class MainViewModel
private StepDownPlanner? _stepDownPlanner; private StepDownPlanner? _stepDownPlanner;
private (int Width, int Height, int Fps, double BitrateMbps)? _encoderTierOverride; private (int Width, int Height, int Fps, double BitrateMbps)? _encoderTierOverride;
private bool _stepDownBusy; private bool _stepDownBusy;
private double _sessionRatedMbps;
/// <summary>Starts the watch for this broadcast. Called once per session, before the pump starts.</summary> /// <summary>Starts the watch for this broadcast. Called once per session, before the pump starts.</summary>
private void StartStepDownWatch() private void StartStepDownWatch()
{ {
var quality = EffectiveQuality(OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput)); var quality = EffectiveQuality(OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput));
_encoderTierOverride = null; _encoderTierOverride = null;
_sessionRatedMbps = quality.Bitrate;
_stepDownPlanner = new StepDownPlanner( _stepDownPlanner = new StepDownPlanner(
new StepDownPolicy(quality.Width, quality.Height, quality.Fps, quality.Bitrate, new StepDownPolicy(quality.Width, quality.Height, quality.Fps, quality.Bitrate,
floorBitrateMbps: RateLadder.For(quality).Minimum), floorBitrateMbps: RateLadder.For(quality).Minimum),
@@ -37,12 +39,35 @@ public partial class MainViewModel
private void StopStepDownWatch() private void StopStepDownWatch()
{ {
_framePump.StatsReported -= OnPumpStatsReported; _framePump.StatsReported -= OnPumpStatsReported;
RecordSessionRate();
_stepDownPlanner = null; _stepDownPlanner = null;
_encoderTierOverride = null; _encoderTierOverride = null;
_stepDownBusy = false; _stepDownBusy = false;
_screenCaptureManager.ShedLoad(false); // never carry a shed cadence into the next show _screenCaptureManager.ShedLoad(false); // never carry a shed cadence into the next show
} }
/// <summary>
/// One finished session, filed as evidence for the rate-history policy (Models/RateHistory).
/// Best effort: a store failure must never be the reason a stop fails.
/// </summary>
private void RecordSessionRate()
{
if (_stepDownPlanner is not { } planner) return;
try
{
_layoutStore.AppendRateHistory(new SessionRateResult(
SelectedQuality.Display,
CurrentHealth.VideoEncoder ?? "unknown",
_sessionRatedMbps,
planner.Current.BitrateMbps,
planner.Policy.StepsUsed));
}
catch (Exception ex)
{
AppLog.Write($"Rate history not recorded: {ex.Message}", AppLogMessageType.Warning, AppLogErrorClass.Class2);
}
}
/// <summary>Runs on the PUMP thread, so it must never block: the restart it may trigger stops the /// <summary>Runs on the PUMP thread, so it must never block: the restart it may trigger stops the
/// very loop that raised this event. Decide here (pure and fast), act on the dispatcher.</summary> /// very loop that raised this event. Decide here (pure and fast), act on the dispatcher.</summary>
private void OnPumpStatsReported(object? sender, FramePumpStats stats) private void OnPumpStatsReported(object? sender, FramePumpStats stats)
+1
View File
@@ -492,6 +492,7 @@ public partial class MainViewModel : ViewModelBase
ApplyStreamQuality(value); ApplyStreamQuality(value);
OnPropertyChanged(nameof(RatedMbps)); OnPropertyChanged(nameof(RatedMbps));
OnPropertyChanged(nameof(RateHint)); OnPropertyChanged(nameof(RateHint));
RefreshRateProposal();
} }
} }
} }
+4
View File
@@ -349,6 +349,10 @@ public partial class MainViewModel : ViewModelBase
_stickyAlertsUntilDismissed = _layoutStore.LoadStickyAlertsUntilDismissed(); _stickyAlertsUntilDismissed = _layoutStore.LoadStickyAlertsUntilDismissed();
_recordFolder = _layoutStore.LoadRecordFolder(); _recordFolder = _layoutStore.LoadRecordFolder();
// What the last few sessions on this tier actually held, if it is worth asking about.
InitializeRateProposalCommands();
RefreshRateProposal();
// Load saved hotkey bindings (filled with defaults for any unbound actions). // Load saved hotkey bindings (filled with defaults for any unbound actions).
_hotkeyBindings = _layoutStore.LoadHotkeyBindings(); _hotkeyBindings = _layoutStore.LoadHotkeyBindings();
+9
View File
@@ -98,6 +98,15 @@ triad is **capped VBR, not CBR**: measured 2026-11-12 on the bundled BtbN build,
itself when `bitrate == maxrate` (`libavcodec/qsvenc.c`, `select_rc_mode`), so the triad is its whole ask; itself when `bitrate == maxrate` (`libavcodec/qsvenc.c`, `select_rc_mode`), so the triad is its whole ask;
OpenH264's `-rc_mode bitrate` changed nothing measurable here and is not passed. YouTube's encoder spec OpenH264's `-rc_mode bitrate` changed nothing measurable here and is not passed. YouTube's encoder spec
requires CBR. Never assume `-maxrate` equals CBR — it does not. requires CBR. Never assume `-maxrate` equals CBR — it does not.
**The starting rate is learned, never imposed** (`Models/RateHistory`, `ViewModels/MainViewModel.Rate.cs`).
Each finished broadcast is filed locally — tier, probed encoder, rated, settled, steps spent — and the
only thing that may change a starting rate is a proposal the creator clicks. A **high-water mark** is the
highest rate that *finished* a session with zero steps, never the highest number seen, so one easy session
cannot set a target the machine cannot hold. Raising needs 10% above the current rate across five clean
sessions of the same tier **and encoder** (history against NVENC is not evidence about QSV); lowering needs
three step-downs in five, because fixing damage should not wait. "Keep mine" is remembered against that
specific number, so the same question is not asked again until the evidence moves. Nothing is uploaded.
**Record output (TASK 18):** `FfmpegArgs.Build` emits one self-contained block per output, each its own **Record output (TASK 18):** `FfmpegArgs.Build` emits one self-contained block per output, each its own
`-map 0:v -map 1:a` + codec tags (`AddVideoTags` helper). Stream block = `-f flv <rtmpUrl>`; record block = `-map 0:v -map 1:a` + codec tags (`AddVideoTags` helper). Stream block = `-f flv <rtmpUrl>`; record block =
`-f mp4 <RecordPath>`. `EncoderOptions.StreamEnabled`/`RecordEnabled`/`RecordPath` gate each block, so `-f mp4 <RecordPath>`. `EncoderOptions.StreamEnabled`/`RecordEnabled`/`RecordPath` gate each block, so
+71
View File
@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// The rate the app suggests next session, from real sessions — and the two rules that keep it from
/// becoming a nuisance: five sessions of evidence per tier and encoder, a 10% gap before asking for
/// more, and a lower bar for backing off.
/// </summary>
public class RateHistoryTests
{
private static SessionRateResult Session(
double rated, double settled = -1, int steps = 0, string tier = "1080p60", string encoder = "h264_nvenc")
=> new(tier, encoder, rated, settled < 0 ? rated : settled, steps);
[Fact]
public void Suggestions_need_five_sessions_of_the_same_tier_and_encoder_and_persist_with_them()
{
// Four sessions is not evidence.
Assert.Null(RateHistory.Suggest(
Enumerable.Range(0, 4).Select(_ => Session(15)), "1080p60", "h264_nvenc", 12));
// Five clean sessions holding 15 against a 12 target: worth asking, and the ask is 10% up.
var raised = RateHistory.Suggest(
Enumerable.Range(0, 5).Select(_ => Session(15)), "1080p60", "h264_nvenc", 12);
Assert.NotNull(raised);
Assert.True(raised!.Value.Raise);
Assert.Equal(15, raised.Value.Mbps);
// The same evidence means nothing below the threshold (12 → 12.6 is under 10%).
Assert.Null(RateHistory.Suggest(
Enumerable.Range(0, 5).Select(_ => Session(13)), "1080p60", "h264_nvenc", 12));
// A different encoder is a different machine: no suggestion.
Assert.Null(RateHistory.Suggest(
Enumerable.Range(0, 5).Select(_ => Session(15)), "1080p60", "h264_qsv", 12));
// Three step-downs out of five asks to back off to the settled median, faster than it asks to
// climb — fixing damage should not wait for a 10% gap.
var lowered = RateHistory.Suggest(
new[]
{
Session(12, 9.6, 1), Session(12, 8.0, 2), Session(12), Session(12, 7.2, 1), Session(12),
},
"1080p60", "h264_nvenc", 12);
Assert.NotNull(lowered);
Assert.False(lowered!.Value.Raise);
Assert.Equal(9.6, lowered.Value.Mbps); // median of 9.6, 8.0, 12, 7.2, 12
// And the storage form survives a round trip through the settings row, which is where the
// evidence actually lives between runs.
var db = Path.Combine(Path.GetTempPath(), $"llamacasty-rate-history-{Guid.NewGuid():N}.db");
using (var store = new LayoutStore(db))
{
foreach (var entry in Enumerable.Range(0, 5).Select(_ => Session(15)))
store.AppendRateHistory(entry);
var reloaded = store.LoadRateHistory();
Assert.Equal(5, reloaded.Count);
Assert.Equal(Session(15), reloaded[0]);
Assert.Equal(15, RateHistory.Suggest(reloaded, "1080p60", "h264_nvenc", 12)!.Value.Mbps);
}
try { File.Delete(db); } catch (IOException) { }
}
}
+62
View File
@@ -3,6 +3,7 @@ using System.IO;
using System.Linq; using System.Linq;
using Xunit; using Xunit;
using ytLive.Models; using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels; using ytLive.ViewModels;
namespace ytLive.Tests; namespace ytLive.Tests;
@@ -58,4 +59,65 @@ public class RatedBitrateTests
try { File.Delete(tempDb); } catch (IOException) { } 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) { }
}
}
} }