feat(entitlement): gate 60 fps output through Steam

This commit is contained in:
2026-11-04 12:14:46 -08:00
parent 1993e7cc8b
commit 7216c95449
12 changed files with 188 additions and 72 deletions
+12 -4
View File
@@ -137,14 +137,22 @@
<TextBlock Grid.Row="4" Grid.ColumnSpan="2" Text="{Binding CurrentHealth.HealthMessage}" <TextBlock Grid.Row="4" Grid.ColumnSpan="2" Text="{Binding CurrentHealth.HealthMessage}"
Foreground="{StaticResource Brush.Attention}" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,6"/> Foreground="{StaticResource Brush.Attention}" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,6"/>
<ComboBox Grid.Row="5" Grid.ColumnSpan="2" VerticalAlignment="Bottom" Style="{StaticResource YtComboBox}" <StackPanel Grid.Row="5" Grid.ColumnSpan="2" VerticalAlignment="Bottom">
x:Name="QualityPicker" <ComboBox x:Name="QualityPicker" Style="{StaticResource YtComboBox}"
ItemsSource="{Binding QualityOptions}" ItemsSource="{Binding QualityOptions}"
SelectedItem="{Binding SelectedQuality}" SelectedItem="{Binding SelectedQuality}"
ItemTemplate="{StaticResource YtQualityComboBoxItemTemplate}"
ItemContainerStyle="{StaticResource YtQualityComboBoxItem}" ItemContainerStyle="{StaticResource YtQualityComboBoxItem}"
DisplayMemberPath="Label"
ToolTip="{Binding ResolutionHelp}" ToolTip="{Binding ResolutionHelp}"
IsEnabled="{Binding IsOffline}"/> IsEnabled="{Binding CanChangeQuality}"/>
<Border x:Name="EntitlementErrorPanel" Margin="0,6,0,0" Padding="8,6"
Background="{StaticResource Brush.ErrorContainer}"
BorderBrush="{StaticResource Brush.Error}" BorderThickness="1" CornerRadius="4"
Visibility="{Binding EntitlementErrorMessage, Converter={StaticResource NotNullToVis}}">
<TextBlock x:Name="EntitlementErrorText" Text="{Binding EntitlementErrorMessage}"
Foreground="{StaticResource Brush.Error}" FontSize="11" TextWrapping="Wrap"/>
</Border>
</StackPanel>
</Grid> </Grid>
</Border> </Border>
+6 -12
View File
@@ -3,11 +3,8 @@ namespace ytLive.Models;
/// <summary> /// <summary>
/// The one entitlement rule, as pure policy: what a build is allowed to emit. /// The one entitlement rule, as pure policy: what a build is allowed to emit.
/// ///
/// <para><b>Why pixels and a frame rate rather than "720p".</b> The paid delta is output /// <para><b>Frame rate is the paid boundary.</b> Every supported resolution at 30 fps is free;
/// quality, and a resolution name is a marketing word, not a measurable bound — 720p60 is /// 60 fps requires paid ownership, regardless of frame dimensions.</para>
/// 921,600 pixels at twice the frame rate, and vertical 720×1280 is the same pixel count
/// with a different shape. A pixel budget plus a frame-rate ceiling expresses the same
/// promise without a lookup table that a new tier can quietly escape.</para>
/// ///
/// <para><b>Fail closed.</b> Callers pass <c>pro: false</c> when entitlement is unknown, /// <para><b>Fail closed.</b> Callers pass <c>pro: false</c> when entitlement is unknown,
/// unreadable or unresolved; the free profile is the default state, never the fallback /// unreadable or unresolved; the free profile is the default state, never the fallback
@@ -19,19 +16,16 @@ namespace ytLive.Models;
/// </summary> /// </summary>
public static class OutputProfile public static class OutputProfile
{ {
/// <summary>Free ceiling: 921,600 px = 1280×720, i.e. one 720p frame.</summary>
public const int FreeMaxPixels = 921_600;
/// <summary>Free ceiling: 30 frames per second.</summary> /// <summary>Free ceiling: 30 frames per second.</summary>
public const int FreeMaxFps = 30; public const int FreeMaxFps = 30;
/// <summary>The named tier a capped build falls back to when nothing else qualifies.</summary> /// <summary>The default free tier and fallback when no shipped tier is available.</summary>
public const string FreeFallbackName = "720p30"; public const string FreeFallbackName = "1080p30";
/// <summary>Is this tier within the free budget? Pro may use anything shipped.</summary> /// <summary>Is this tier within the free budget? Pro may use anything shipped.</summary>
public static bool IsAllowed(QualityOption tier, bool pro) public static bool IsAllowed(QualityOption tier, bool pro)
=> pro => pro
|| (tier.Width * tier.Height <= FreeMaxPixels && tier.Fps <= FreeMaxFps); || tier.Fps <= FreeMaxFps;
/// <summary> /// <summary>
/// The tier a session will actually encode. A permitted selection passes through /// The tier a session will actually encode. A permitted selection passes through
@@ -57,6 +51,6 @@ public static class OutputProfile
// No shipped tier fits the budget: build the floor explicitly rather than trusting // No shipped tier fits the budget: build the floor explicitly rather than trusting
// the table. A build whose tiers were all redefined must still emit a legal frame. // the table. A build whose tiers were all redefined must still emit a legal frame.
return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 6.0, 1280, 720); return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 8.0, 1920, 1080);
} }
} }
+6 -1
View File
@@ -5,7 +5,12 @@ namespace ytLive.Services.Entitlement;
/// </summary> /// </summary>
public sealed class FakeEntitlement : IEntitlement public sealed class FakeEntitlement : IEntitlement
{ {
public FakeEntitlement(bool isAppOwned = false) => IsAppOwned = isAppOwned; public FakeEntitlement(bool isAppOwned = false, string? errorMessage = null)
{
IsAppOwned = isAppOwned;
ErrorMessage = errorMessage;
}
public bool IsAppOwned { get; set; } public bool IsAppOwned { get; set; }
public string? ErrorMessage { get; set; }
} }
+2
View File
@@ -9,4 +9,6 @@ namespace ytLive.Services.Entitlement;
public interface IEntitlement public interface IEntitlement
{ {
bool IsAppOwned { get; } bool IsAppOwned { get; }
string? ErrorMessage { get; }
} }
+22 -4
View File
@@ -17,6 +17,7 @@ public sealed class SteamEntitlement : IEntitlement
private bool _initAttempted; private bool _initAttempted;
private bool _available; private bool _available;
private bool? _cached; private bool? _cached;
private string? _errorMessage;
public SteamEntitlement(uint paidAppId) public SteamEntitlement(uint paidAppId)
: this( : this(
@@ -42,11 +43,18 @@ public sealed class SteamEntitlement : IEntitlement
/// <summary>Configured with no paid app ID; ownership fails closed without touching Steam.</summary> /// <summary>Configured with no paid app ID; ownership fails closed without touching Steam.</summary>
public static SteamEntitlement NotConfigured() => new(0); public static SteamEntitlement NotConfigured() => new(0);
public string? ErrorMessage => _errorMessage;
public bool IsAppOwned public bool IsAppOwned
{ {
get get
{ {
if (_paidAppId == 0) return false; if (_paidAppId == 0)
{
_cached = false;
_errorMessage = "The paid Steam app ID is not configured for this build. Contact the creator or support.";
return false;
}
if (_cached is { } known) return known; if (_cached is { } known) return known;
if (!_initAttempted) if (!_initAttempted)
@@ -56,22 +64,31 @@ public sealed class SteamEntitlement : IEntitlement
{ {
_available = _initializeSteam(); _available = _initializeSteam();
} }
catch (Exception) catch (Exception ex)
{ {
_available = false; _available = false;
_errorMessage = $"Steam could not start ({ex.Message}). Open Steam, sign in, then restart LlamaCasty.";
} }
} }
if (!_available) { _cached = false; return false; } if (!_available)
{
_cached = false;
_errorMessage ??= "Steam could not verify ownership. Open Steam, sign in, then restart LlamaCasty.";
return false;
}
try try
{ {
var owned = _isAppSubscribed(_paidAppId); var owned = _isAppSubscribed(_paidAppId);
_cached = owned; _cached = owned;
_errorMessage = null;
return owned; return owned;
} }
catch (Exception) catch (Exception ex)
{ {
_cached = false;
_errorMessage = $"Steam could not check ownership ({ex.Message}). Open Steam, sign in, then restart LlamaCasty.";
return false; return false;
} }
} }
@@ -83,5 +100,6 @@ public sealed class SteamEntitlement : IEntitlement
_available = false; _available = false;
_cached = null; _cached = null;
_initAttempted = false; _initAttempted = false;
_errorMessage = null;
} }
} }
+21 -2
View File
@@ -380,6 +380,24 @@
</Setter> </Setter>
</Style> </Style>
<DataTemplate x:Key="YtQualityComboBoxItemTemplate">
<TextBlock Text="{Binding Label}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding RequiresPro}" Value="True"/>
<Condition Binding="{Binding DataContext.HasProOutput, RelativeSource={RelativeSource AncestorType={x:Type ComboBox}}}" Value="False"/>
</MultiDataTrigger.Conditions>
<Setter Property="TextDecorations" Value="{x:Static TextDecorations.Strikethrough}"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
<Style x:Key="YtQualityComboBoxItem" TargetType="ComboBoxItem" BasedOn="{StaticResource YtComboBoxItem}"> <Style x:Key="YtQualityComboBoxItem" TargetType="ComboBoxItem" BasedOn="{StaticResource YtComboBoxItem}">
<Style.Triggers> <Style.Triggers>
<MultiDataTrigger> <MultiDataTrigger>
@@ -387,8 +405,9 @@
<Condition Binding="{Binding RequiresPro}" Value="True"/> <Condition Binding="{Binding RequiresPro}" Value="True"/>
<Condition Binding="{Binding DataContext.HasProOutput, RelativeSource={RelativeSource AncestorType={x:Type ComboBox}}}" Value="False"/> <Condition Binding="{Binding DataContext.HasProOutput, RelativeSource={RelativeSource AncestorType={x:Type ComboBox}}}" Value="False"/>
</MultiDataTrigger.Conditions> </MultiDataTrigger.Conditions>
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurfaceDisabled}"/> <Setter Property="IsEnabled" Value="False"/>
<Setter Property="Opacity" Value="0.55"/> <Setter Property="Foreground" Value="{StaticResource Brush.OnSurface}"/>
<Setter Property="Opacity" Value="1"/>
</MultiDataTrigger> </MultiDataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
+14 -1
View File
@@ -1,4 +1,5 @@
using ytLive.Helpers; using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services.Entitlement; using ytLive.Services.Entitlement;
namespace ytLive.ViewModels; namespace ytLive.ViewModels;
@@ -13,17 +14,28 @@ public partial class MainViewModel
{ {
private IEntitlement _entitlement = CreateEntitlement(); private IEntitlement _entitlement = CreateEntitlement();
private bool _isAppOwned; private bool _isAppOwned;
private string? _entitlementErrorMessage;
public bool IsAppOwned public bool IsAppOwned
{ {
get => _isAppOwned; get => _isAppOwned;
private set private set
{ {
if (!SetProperty(ref _isAppOwned, value)) return; var changed = SetProperty(ref _isAppOwned, value);
EntitlementErrorMessage = _entitlement.ErrorMessage;
if (!changed) return;
OnPropertyChanged(nameof(HasProOutput)); OnPropertyChanged(nameof(HasProOutput));
if (!HasProOutput && _selectedQuality is { RequiresPro: true } && CanChangeQuality)
SelectedQuality = QualityOptions.Single(option => option.Display == OutputProfile.FreeFallbackName);
} }
} }
public string? EntitlementErrorMessage
{
get => _entitlementErrorMessage;
private set => SetProperty(ref _entitlementErrorMessage, value);
}
public bool HasProOutput => DeveloperMode || IsAppOwned; public bool HasProOutput => DeveloperMode || IsAppOwned;
private static IEntitlement CreateEntitlement() private static IEntitlement CreateEntitlement()
@@ -43,6 +55,7 @@ public partial class MainViewModel
{ {
_entitlement = value; _entitlement = value;
IsAppOwned = value.IsAppOwned; IsAppOwned = value.IsAppOwned;
EntitlementErrorMessage = value.ErrorMessage;
} }
} }
+9 -1
View File
@@ -66,6 +66,7 @@ public partial class MainViewModel : ViewModelBase
{ {
_youtubeBroadcastLive = false; _youtubeBroadcastLive = false;
OnPropertyChanged(nameof(IsOffline)); OnPropertyChanged(nameof(IsOffline));
OnPropertyChanged(nameof(CanChangeQuality));
OnPropertyChanged(nameof(IsLive)); OnPropertyChanged(nameof(IsLive));
OnPropertyChanged(nameof(IsEditMode)); OnPropertyChanged(nameof(IsEditMode));
OnPropertyChanged(nameof(RecDotBrush)); OnPropertyChanged(nameof(RecDotBrush));
@@ -91,6 +92,7 @@ public partial class MainViewModel : ViewModelBase
public bool IsOffline => StreamStatus == StreamStatus.Offline; public bool IsOffline => StreamStatus == StreamStatus.Offline;
public bool IsLive => StreamStatus == StreamStatus.Streaming; public bool IsLive => StreamStatus == StreamStatus.Streaming;
public bool CanChangeQuality => IsOffline && !IsRecording;
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase); public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
public string RecDotBrush => !IsRecording public string RecDotBrush => !IsRecording
? ThemeBrush.Hex("Brush.OnSurfaceDisabled") ? ThemeBrush.Hex("Brush.OnSurfaceDisabled")
@@ -151,6 +153,7 @@ public partial class MainViewModel : ViewModelBase
internal set internal set
{ {
if (!SetProperty(ref _isRecording, value)) return; if (!SetProperty(ref _isRecording, value)) return;
OnPropertyChanged(nameof(CanChangeQuality));
OnPropertyChanged(nameof(RecDotBrush)); OnPropertyChanged(nameof(RecDotBrush));
OnPropertyChanged(nameof(RecTextBrush)); OnPropertyChanged(nameof(RecTextBrush));
OnPropertyChanged(nameof(RecDotOpacity)); OnPropertyChanged(nameof(RecDotOpacity));
@@ -450,10 +453,15 @@ public partial class MainViewModel : ViewModelBase
get => _selectedQuality; get => _selectedQuality;
set set
{ {
if (!CanChangeQuality)
{
OnPropertyChanged(nameof(SelectedQuality));
return;
}
if (!HasProOutput && value.RequiresPro) if (!HasProOutput && value.RequiresPro)
{ {
OnPropertyChanged(nameof(SelectedQuality)); OnPropertyChanged(nameof(SelectedQuality));
OpenAboutCommand.Execute(null);
return; return;
} }
+4 -1
View File
@@ -200,7 +200,10 @@ public partial class MainViewModel : ViewModelBase
_youtubeChat = new YouTubeChatService(_youtubeAuth); _youtubeChat = new YouTubeChatService(_youtubeAuth);
AppLog.Write("MainViewModel ctor: services created"); AppLog.Write("MainViewModel ctor: services created");
_selectedQuality = OutputProfile.Resolve(QualityOptions[0], QualityOptions, HasProOutput); var defaultQuality = HasProOutput
? QualityOptions[0]
: QualityOptions.Single(option => option.Display == OutputProfile.FreeFallbackName);
_selectedQuality = OutputProfile.Resolve(defaultQuality, QualityOptions, HasProOutput);
ApplyStreamQuality(_selectedQuality); ApplyStreamQuality(_selectedQuality);
_youtubeChat.MessageReceived += OnChatMessageReceived; _youtubeChat.MessageReceived += OnChatMessageReceived;
+54 -17
View File
@@ -2,9 +2,11 @@ using System;
using System.Windows; using System.Windows;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Threading; using System.Windows.Threading;
using Xunit; using Xunit;
using ytLive.Controls; using ytLive.Controls;
using ytLive.Models;
using ytLive.Services.Entitlement; using ytLive.Services.Entitlement;
using ytLive.ViewModels; using ytLive.ViewModels;
@@ -74,7 +76,7 @@ public sealed class AboutHubTests
} }
[Fact] [Fact]
public void Selecting_a_locked_quality_opens_about_and_keeps_the_free_tier_selected() public void Free_user_gets_1080p30_and_cannot_select_or_change_to_60fps()
{ {
_app.Run(() => _app.Run(() =>
{ {
@@ -85,36 +87,59 @@ public sealed class AboutHubTests
{ {
window = new MainWindow(); window = new MainWindow();
var vm = (MainViewModel)window.DataContext; var vm = (MainViewModel)window.DataContext;
vm.EntitlementOverride = new FakeEntitlement(false); vm.EntitlementOverride = new FakeEntitlement(false,
var freeTier = vm.QualityOptions.Single(option => !option.RequiresPro); "Steam could not verify ownership. Open Steam, sign in, then restart LlamaCasty.");
var lockedTier = vm.QualityOptions.First(option => option.RequiresPro);
vm.SelectedQuality = freeTier;
window.Show(); window.Show();
window.UpdateLayout();
var freeTier = vm.QualityOptions.Single(option => option.Display == "1080p30");
var lockedTier = vm.QualityOptions.First(option => option.Fps == 60);
Assert.Same(freeTier, vm.SelectedQuality);
var errorPanel = (Border)window.FindName("EntitlementErrorPanel");
var errorText = (TextBlock)window.FindName("EntitlementErrorText");
Assert.Equal(Visibility.Visible, errorPanel.Visibility);
Assert.Contains("Open Steam, sign in", errorText.Text);
var picker = (ComboBox)window.FindName("QualityPicker"); var picker = (ComboBox)window.FindName("QualityPicker");
Assert.True(vm.CanChangeQuality);
Assert.True(picker.IsEnabled);
picker.IsDropDownOpen = true; picker.IsDropDownOpen = true;
picker.UpdateLayout(); picker.UpdateLayout();
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded); Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
var lockedItem = (ComboBoxItem)picker.ItemContainerGenerator.ContainerFromItem(lockedTier)!; var lockedItem = (ComboBoxItem)picker.ItemContainerGenerator.ContainerFromItem(lockedTier)!;
Assert.True(lockedItem.IsEnabled); Assert.False(lockedItem.IsEnabled);
Assert.Equal(0.55, lockedItem.Opacity); var struckText = FindVisualChild<TextBlock>(lockedItem);
Assert.Equal(Application.Current.Resources["Brush.OnSurfaceDisabled"], lockedItem.Foreground); Assert.NotNull(struckText);
Assert.Contains(struckText!.TextDecorations, decoration =>
decoration.Location == TextDecorationLocation.Strikethrough);
picker.SelectedItem = lockedTier; picker.SelectedItem = lockedTier;
picker.UpdateLayout(); picker.UpdateLayout();
Assert.Same(freeTier, vm.SelectedQuality); Assert.Same(freeTier, vm.SelectedQuality);
Assert.True(vm.IsAboutOpen); Assert.False(vm.IsAboutOpen);
var about = (OverlayHost)window.FindName("AboutOverlay"); picker.IsDropDownOpen = false;
var copy = (TextBlock)about.FindName("UpgradeCopy"); vm.IsRecording = true;
var link = (Button)about.FindName("UpgradeLinkButton"); window.UpdateLayout();
Assert.Contains("720p30", copy.Text); Assert.False(vm.CanChangeQuality);
Assert.Contains("one-time purchase", copy.Text, StringComparison.OrdinalIgnoreCase); Assert.False(picker.IsEnabled);
Assert.Contains("no subscription", copy.Text, StringComparison.OrdinalIgnoreCase); vm.SelectedQuality = vm.QualityOptions.Single(option => option.Display == "720p30");
Assert.Contains("llamacasty.com", link.Content!.ToString()); Assert.Same(freeTier, vm.SelectedQuality);
Assert.Same(vm.OpenPremiumCommand, link.Command);
vm.IsRecording = false;
vm.StreamStatus = StreamStatus.Streaming;
window.UpdateLayout();
Assert.False(vm.CanChangeQuality);
Assert.False(picker.IsEnabled);
vm.SelectedQuality = vm.QualityOptions.Single(option => option.Display == "720p30");
Assert.Same(freeTier, vm.SelectedQuality);
vm.StreamStatus = StreamStatus.Offline;
Assert.True(vm.CanChangeQuality);
Assert.True(picker.IsEnabled);
} }
finally finally
{ {
@@ -125,4 +150,16 @@ public sealed class AboutHubTests
} }
}); });
} }
private static T? FindVisualChild<T>(DependencyObject parent) where T : DependencyObject
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is T match) return match;
var nested = FindVisualChild<T>(child);
if (nested != null) return nested;
}
return null;
}
} }
+14 -1
View File
@@ -20,7 +20,9 @@ public class EntitlementTests
[Fact] [Fact]
public void No_configured_paid_app_id_reads_as_not_owned() public void No_configured_paid_app_id_reads_as_not_owned()
{ {
Assert.False(SteamEntitlement.NotConfigured().IsAppOwned); var entitlement = SteamEntitlement.NotConfigured();
Assert.False(entitlement.IsAppOwned);
Assert.Contains("paid Steam app ID is not configured", entitlement.ErrorMessage);
} }
[Fact] [Fact]
@@ -40,6 +42,7 @@ public class EntitlementTests
Assert.True(entitlement.IsAppOwned); Assert.True(entitlement.IsAppOwned);
Assert.Equal(paidAppId, queriedAppId); Assert.Equal(paidAppId, queriedAppId);
Assert.Null(entitlement.ErrorMessage);
} }
[Fact] [Fact]
@@ -47,6 +50,16 @@ public class EntitlementTests
{ {
var entitlement = new SteamEntitlement(1234567u, () => false, _ => true, () => { }); var entitlement = new SteamEntitlement(1234567u, () => false, _ => true, () => { });
Assert.False(entitlement.IsAppOwned); Assert.False(entitlement.IsAppOwned);
Assert.Contains("Open Steam", entitlement.ErrorMessage);
}
[Fact]
public void A_valid_steam_check_for_an_unowned_account_is_not_an_authentication_error()
{
var entitlement = new SteamEntitlement(1234567u, () => true, _ => false, () => { });
Assert.False(entitlement.IsAppOwned);
Assert.Null(entitlement.ErrorMessage);
} }
[Fact] [Fact]
+20 -24
View File
@@ -8,8 +8,8 @@ using ytLive.Models;
namespace ytLive.Tests; namespace ytLive.Tests;
/// <summary> /// <summary>
/// The output-profile rule (2026-11-01): a build without Pro may emit at most 921,600 pixels /// The output-profile rule: a free build may emit every shipped resolution at 30 fps;
/// at 30 fps, and this is the only place that decision is made. /// 60 fps requires Pro, independent of resolution.
/// ///
/// <para>The one thing that must never break: a cracked or unlicensed build silently gets a /// <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, /// *smaller frame*, never a smaller feature set. If a future tier is added above the budget,
@@ -32,21 +32,21 @@ public class OutputProfileTests
private static QualityOption Tier(string name) => Shipped.Single(t => t.Display == name); private static QualityOption Tier(string name) => Shipped.Single(t => t.Display == name);
[Fact] [Fact]
public void A_free_build_resolves_1080p60_down_to_720p30_with_its_own_bitrate() public void A_free_build_resolves_1080p60_to_1080p30()
{ {
var resolved = OutputProfile.Resolve(Tier("1080p60"), Shipped, pro: false); var resolved = OutputProfile.Resolve(Tier("1080p60"), Shipped, pro: false);
Assert.Equal(1280, resolved.Width); Assert.Equal("1080p30", resolved.Display);
Assert.Equal(720, resolved.Height); Assert.Equal(1920, resolved.Width);
Assert.Equal(1080, resolved.Height);
Assert.Equal(30, resolved.Fps); Assert.Equal(30, resolved.Fps);
// The bitrate follows the profile: a capped user is not offered an 8 Mbps budget. Assert.Equal(8.0, resolved.Bitrate);
Assert.Equal(6.0, resolved.Bitrate);
} }
[Fact] [Fact]
public void A_free_build_leaves_its_own_tier_untouched() public void A_free_build_leaves_its_own_tier_untouched()
{ {
var requested = Tier("720p30"); var requested = Tier("1080p30");
Assert.Same(requested, OutputProfile.Resolve(requested, Shipped, pro: false)); Assert.Same(requested, OutputProfile.Resolve(requested, Shipped, pro: false));
} }
@@ -57,21 +57,15 @@ public class OutputProfileTests
Assert.Same(tier, OutputProfile.Resolve(tier, Shipped, pro: true)); Assert.Same(tier, OutputProfile.Resolve(tier, Shipped, pro: true));
} }
[Theory] [Fact]
[InlineData("1080p60")] public void Free_access_depends_on_frame_rate_not_resolution()
[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); foreach (var tier in Shipped)
{
Assert.True(resolved.Width * resolved.Height <= OutputProfile.FreeMaxPixels, var freeAllowed = tier.Fps <= OutputProfile.FreeMaxFps;
$"{resolved.Display} resolves to {resolved.Width * resolved.Height} px on a free build"); Assert.Equal(freeAllowed, OutputProfile.IsAllowed(tier, pro: false));
Assert.True(resolved.Fps <= OutputProfile.FreeMaxFps, Assert.Equal(!freeAllowed, tier.RequiresPro);
$"{resolved.Display} resolves to {resolved.Fps} fps on a free build"); }
} }
[Fact] [Fact]
@@ -79,9 +73,11 @@ public class OutputProfileTests
{ {
var resolved = OutputProfile.Resolve(Tier("1080p60"), Array.Empty<QualityOption>(), pro: false); var resolved = OutputProfile.Resolve(Tier("1080p60"), Array.Empty<QualityOption>(), pro: false);
Assert.Equal(1280, resolved.Width); Assert.Equal("1080p30", resolved.Display);
Assert.Equal(720, resolved.Height); Assert.Equal(1920, resolved.Width);
Assert.Equal(1080, resolved.Height);
Assert.Equal(30, resolved.Fps); Assert.Equal(30, resolved.Fps);
Assert.Equal(8.0, resolved.Bitrate);
} }
/// <summary> /// <summary>