feat(entitlement): gate 60 fps output through Steam
This commit is contained in:
@@ -2,9 +2,11 @@ using System;
|
||||
using System.Windows;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using Xunit;
|
||||
using ytLive.Controls;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services.Entitlement;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
@@ -74,7 +76,7 @@ public sealed class AboutHubTests
|
||||
}
|
||||
|
||||
[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(() =>
|
||||
{
|
||||
@@ -85,36 +87,59 @@ public sealed class AboutHubTests
|
||||
{
|
||||
window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
vm.EntitlementOverride = new FakeEntitlement(false);
|
||||
var freeTier = vm.QualityOptions.Single(option => !option.RequiresPro);
|
||||
var lockedTier = vm.QualityOptions.First(option => option.RequiresPro);
|
||||
vm.SelectedQuality = freeTier;
|
||||
vm.EntitlementOverride = new FakeEntitlement(false,
|
||||
"Steam could not verify ownership. Open Steam, sign in, then restart LlamaCasty.");
|
||||
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");
|
||||
Assert.True(vm.CanChangeQuality);
|
||||
Assert.True(picker.IsEnabled);
|
||||
picker.IsDropDownOpen = true;
|
||||
picker.UpdateLayout();
|
||||
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
|
||||
|
||||
var lockedItem = (ComboBoxItem)picker.ItemContainerGenerator.ContainerFromItem(lockedTier)!;
|
||||
Assert.True(lockedItem.IsEnabled);
|
||||
Assert.Equal(0.55, lockedItem.Opacity);
|
||||
Assert.Equal(Application.Current.Resources["Brush.OnSurfaceDisabled"], lockedItem.Foreground);
|
||||
Assert.False(lockedItem.IsEnabled);
|
||||
var struckText = FindVisualChild<TextBlock>(lockedItem);
|
||||
Assert.NotNull(struckText);
|
||||
Assert.Contains(struckText!.TextDecorations, decoration =>
|
||||
decoration.Location == TextDecorationLocation.Strikethrough);
|
||||
|
||||
picker.SelectedItem = lockedTier;
|
||||
picker.UpdateLayout();
|
||||
|
||||
Assert.Same(freeTier, vm.SelectedQuality);
|
||||
Assert.True(vm.IsAboutOpen);
|
||||
Assert.False(vm.IsAboutOpen);
|
||||
|
||||
var about = (OverlayHost)window.FindName("AboutOverlay");
|
||||
var copy = (TextBlock)about.FindName("UpgradeCopy");
|
||||
var link = (Button)about.FindName("UpgradeLinkButton");
|
||||
Assert.Contains("720p30", copy.Text);
|
||||
Assert.Contains("one-time purchase", copy.Text, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("no subscription", copy.Text, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("llamacasty.com", link.Content!.ToString());
|
||||
Assert.Same(vm.OpenPremiumCommand, link.Command);
|
||||
picker.IsDropDownOpen = false;
|
||||
vm.IsRecording = true;
|
||||
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.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
|
||||
{
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ public class EntitlementTests
|
||||
[Fact]
|
||||
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]
|
||||
@@ -40,6 +42,7 @@ public class EntitlementTests
|
||||
|
||||
Assert.True(entitlement.IsAppOwned);
|
||||
Assert.Equal(paidAppId, queriedAppId);
|
||||
Assert.Null(entitlement.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -47,6 +50,16 @@ public class EntitlementTests
|
||||
{
|
||||
var entitlement = new SteamEntitlement(1234567u, () => false, _ => true, () => { });
|
||||
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]
|
||||
|
||||
@@ -8,8 +8,8 @@ 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.
|
||||
/// The output-profile rule: a free build may emit every shipped resolution at 30 fps;
|
||||
/// 60 fps requires Pro, independent of resolution.
|
||||
///
|
||||
/// <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,
|
||||
@@ -32,21 +32,21 @@ public class OutputProfileTests
|
||||
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()
|
||||
public void A_free_build_resolves_1080p60_to_1080p30()
|
||||
{
|
||||
var resolved = OutputProfile.Resolve(Tier("1080p60"), Shipped, pro: false);
|
||||
|
||||
Assert.Equal(1280, resolved.Width);
|
||||
Assert.Equal(720, resolved.Height);
|
||||
Assert.Equal("1080p30", resolved.Display);
|
||||
Assert.Equal(1920, resolved.Width);
|
||||
Assert.Equal(1080, 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);
|
||||
Assert.Equal(8.0, resolved.Bitrate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -57,21 +57,15 @@ public class OutputProfileTests
|
||||
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)
|
||||
[Fact]
|
||||
public void Free_access_depends_on_frame_rate_not_resolution()
|
||||
{
|
||||
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");
|
||||
foreach (var tier in Shipped)
|
||||
{
|
||||
var freeAllowed = tier.Fps <= OutputProfile.FreeMaxFps;
|
||||
Assert.Equal(freeAllowed, OutputProfile.IsAllowed(tier, pro: false));
|
||||
Assert.Equal(!freeAllowed, tier.RequiresPro);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -79,9 +73,11 @@ public class OutputProfileTests
|
||||
{
|
||||
var resolved = OutputProfile.Resolve(Tier("1080p60"), Array.Empty<QualityOption>(), pro: false);
|
||||
|
||||
Assert.Equal(1280, resolved.Width);
|
||||
Assert.Equal(720, resolved.Height);
|
||||
Assert.Equal("1080p30", resolved.Display);
|
||||
Assert.Equal(1920, resolved.Width);
|
||||
Assert.Equal(1080, resolved.Height);
|
||||
Assert.Equal(30, resolved.Fps);
|
||||
Assert.Equal(8.0, resolved.Bitrate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user