Files
LlamaCasty/ytLive.Tests/OutputProfileTests.cs
T
gramps 27fc6fb507 feat(profile): the free budget is pixels and fps, enforced once — with a dev-only release hatch
The paid delta is output quality, so the rule needs to be measurable rather than a word:
921,600 pixels and 30 fps, fail-closed. Models/OutputProfile.cs is that rule as pure
policy — a permitted tier passes through untouched (Pro, or the free tier's own 720p30),
a disallowed one resolves to the best tier the budget allows and carries ITS bitrate, so
a capped user is never offered an 8 Mbps budget for a 720p frame.

Applied once, in BuildEncoderOptions. That is genuinely one place: the stream and the
local recording are two output blocks of ONE ffmpeg process fed by one EncoderOptions,
so there is no second encoder to forget. (commercial.md claimed "both encoders — never
two checks"; the code is stronger than the doc, and the doc is corrected.)

Developer override, as asked: a "Developer mode — all resolutions" checkbox in App
Settings, bound to MainViewModel.DeveloperMode, which feeds HasProOutput. It is not
hidden in a release build — Controls/DeveloperPanel's Page and Compile items are removed
when Configuration != Debug, and OverlayHost adds it at runtime inside #if DEBUG, so a
shipped exe has no BAML for it at all. The setter also refuses the value when
DevBuild.IsDevelopment is false, which is now the single source for that question (the
scoreboard partial had its own duplicate const).

Two traps worth their lines: a `const bool` dev flag makes `if (!Flag) return;` an
unreachable-code warning in one configuration or the other (CS0162), so DevBuild uses
static readonly; and a Condition on an ItemGroup is invisible to a test reading
item.Attribute("Condition") — read the parent.

Tests: 14 pass across OutputProfileTests, AssemblyIdentityTests and StorePackagingTests.
The invariant test is the one that matters: iterate every shipped tier under a free
entitlement and assert none emits more than the budget, so a future 4K tier fails here
instead of shipping to everyone. App builds Debug and Release at 0 warnings.
2026-11-01 07:11:49 -08:00

113 lines
4.3 KiB
C#

using System;
using System.IO;
using System.Linq;
using System.Xml.Linq;
using Xunit;
using ytLive.Models;
namespace ytLive.Tests;
/// <summary>
/// The output-profile rule (2026-11-01): a build without Pro may emit at most 921,600 pixels
/// at 30 fps, and this is the only place that decision is made.
///
/// <para>The one thing that must never break: a cracked or unlicensed build silently gets a
/// *smaller frame*, never a smaller feature set. If a future tier is added above the budget,
/// the last test here fails instead of shipping it to everyone.</para>
/// </summary>
public class OutputProfileTests
{
/// <summary>Mirror of <c>MainViewModel.QualityOptions</c> — the shipped tiers. Kept in
/// step deliberately: this is the table the budget has to cover.</summary>
private static readonly QualityOption[] Shipped =
{
new("1080p60", 60, 8.0, 1920, 1080),
new("1080p30", 30, 8.0, 1920, 1080),
new("720p60", 60, 6.0, 1280, 720),
new("720p30", 30, 6.0, 1280, 720),
new("Vertical 1080p30", 30, 8.0, 1080, 1920),
new("Vertical 1080p60", 60, 8.0, 1080, 1920),
};
private static QualityOption Tier(string name) => Shipped.Single(t => t.Display == name);
[Fact]
public void A_free_build_resolves_1080p60_down_to_720p30_with_its_own_bitrate()
{
var resolved = OutputProfile.Resolve(Tier("1080p60"), Shipped, pro: false);
Assert.Equal(1280, resolved.Width);
Assert.Equal(720, resolved.Height);
Assert.Equal(30, resolved.Fps);
// The bitrate follows the profile: a capped user is not offered an 8 Mbps budget.
Assert.Equal(6.0, resolved.Bitrate);
}
[Fact]
public void A_free_build_leaves_its_own_tier_untouched()
{
var requested = Tier("720p30");
Assert.Same(requested, OutputProfile.Resolve(requested, Shipped, pro: false));
}
[Fact]
public void Pro_passes_every_shipped_tier_through()
{
foreach (var tier in Shipped)
Assert.Same(tier, OutputProfile.Resolve(tier, Shipped, pro: true));
}
[Theory]
[InlineData("1080p60")]
[InlineData("1080p30")]
[InlineData("720p60")]
[InlineData("720p30")]
[InlineData("Vertical 1080p30")]
[InlineData("Vertical 1080p60")]
public void No_free_selection_can_emit_more_than_the_budget(string name)
{
var resolved = OutputProfile.Resolve(Tier(name), Shipped, pro: false);
Assert.True(resolved.Width * resolved.Height <= OutputProfile.FreeMaxPixels,
$"{resolved.Display} resolves to {resolved.Width * resolved.Height} px on a free build");
Assert.True(resolved.Fps <= OutputProfile.FreeMaxFps,
$"{resolved.Display} resolves to {resolved.Fps} fps on a free build");
}
[Fact]
public void An_empty_tier_table_still_yields_a_legal_frame()
{
var resolved = OutputProfile.Resolve(Tier("1080p60"), Array.Empty<QualityOption>(), pro: false);
Assert.Equal(1280, resolved.Width);
Assert.Equal(720, resolved.Height);
Assert.Equal(30, resolved.Fps);
}
/// <summary>
/// The developer override must not exist in a shipped binary. A runtime test cannot prove
/// that (the test host is a Debug build), so this asserts the build rule itself: the
/// panel's XAML and code-behind are removed whenever the configuration is not Debug.
/// </summary>
[Fact]
public void The_developer_panel_is_excluded_from_the_release_compile()
{
var project = XDocument.Load(SourceAssert.RepoPath("ytLive.csproj"));
var guards = project.Descendants()
.Where(e => e.Name.LocalName is "Page" or "Compile")
.Where(e => (string?)e.Attribute("Remove") is string remove
&& remove.Contains("DeveloperPanel", StringComparison.OrdinalIgnoreCase))
.ToArray();
Assert.Equal(2, guards.Length);
foreach (var guard in guards)
{
// The condition may sit on the item or on its ItemGroup — either excludes it.
var condition = (string?)guard.Attribute("Condition")
?? (string?)guard.Parent?.Attribute("Condition");
Assert.NotNull(condition);
Assert.Contains("'$(Configuration)' != 'Debug'", condition, StringComparison.Ordinal);
}
}
}