Files
LlamaCasty/ytLive.Tests/AboutHubTests.cs
T
gramps 1e3a4f7fe0 feat(entitlement): pre-release switch so Release builds can test 60 fps
The frame-rate boundary is correct as a product rule, but it only bites in a
Release build, where DevBuild.IsDevelopment is false and DeveloperMode cannot
unlock anything — exactly the build whose performance numbers are worth reading
(see docs/ai/environment.md: Debug pixel paths are several times slower). 60 fps
could not be tested at all.

OutputProfile.EntitlementEnforced is false: every shipped tier is selectable by
any build, and HasProOutput — the one property the tier setter, the dropdown's
strikethrough and the locked-item style all read — answers yes regardless of
ownership. static readonly rather than const, so the consumed `||` does not fold
into unreachable operand code (same reason as DevBuild.IsDevelopment).

The licence indicator row is suppressed while the switch is off: it exists to
explain what the boundary costs, and claiming "Steam ID Limit: 60fps" to an
unowned build would be a false statement. Docs corrected in place — the enforced
rule is unchanged, only its state is recorded.

Tests: one integration case covers both switch states (unowned build reaches
1080p60 with the switch off, 1080p30 with it on), so flipping at launch leaves no
red test behind. The two suites that anchor on the pinned licence row early-return
while it is absent; EntitlementTests covers that state meanwhile.

667 pass, build 0 warnings.
2026-11-12 07:50:59 -08:00

261 lines
11 KiB
C#

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;
namespace ytLive.Tests;
/// <summary>
/// The About overlay is the in-app creator hub: it opens from the real window,
/// and its licensing sub-panel surfaces the full THIRD-PARTY-NOTICES.txt
/// in-app (scrolling text, never the OS viewer / Notepad). Drives the real VM
/// through the real window — same real-App + temp-DB pattern as the round-clip
/// and source-naming tests.
/// </summary>
[Collection("RealApp")]
public sealed class AboutHubTests
{
private readonly RealAppHost _app;
public AboutHubTests(RealAppHost app)
{
_app = app;
}
[Fact]
public void About_Opens_InApp_Licensing_Loads_Shipped_Notices()
{
_app.Run(Run);
}
private void Run()
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-about-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
var window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
try
{
// About hub opens (the wordmark / gear menu entry point).
vm.OpenAboutCommand.Execute(null);
Assert.True(vm.IsAboutOpen);
Assert.False(vm.IsLicensesOpen);
// The hub links resolve to real destinations.
Assert.Equal("https://youtube.com/@llamachileshop", MainViewModel.ChannelUrl);
Assert.Equal("https://mastodon.llamachile.tube/@gramps", MainViewModel.MastodonUrl);
Assert.Equal("https://buymeacoffee.com/llamachiley", MainViewModel.CoffeeUrl);
Assert.Equal("https://llamacasty.com", MainViewModel.PremiumUrl);
// The licensing panel loads the full shipped notices text in-app.
vm.OpenLicensesCommand.Execute(null);
Assert.True(vm.IsLicensesOpen);
Assert.False(string.IsNullOrWhiteSpace(vm.LicensesText));
Assert.Contains("Third-Party Notices", vm.LicensesText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("LGPL", vm.LicensesText, StringComparison.OrdinalIgnoreCase);
// Back returns to the hub view (code-behind click handler path is
// equivalent; here we assert the VM contract the handler uses).
vm.IsLicensesOpen = false;
Assert.True(vm.IsAboutOpen);
}
finally
{
window.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
/// <summary>
/// The About box is a conversion surface (creator, 2026-11-05): a large logo, the 1-2-3 tagline,
/// the offer, one primary CTA with the price on it — and a paying customer sees none of the price.
/// </summary>
[Fact]
public void About_ShowsTheOfferToFreeUsers_AndNothingToSellToOwners()
{
_app.Run(() =>
{
var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-about-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
MainWindow? window = null;
try
{
window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
vm.EntitlementOverride = new FakeEntitlement(false, "Steam licensing is not configured in this build.");
window.Show();
vm.OpenAboutCommand.Execute(null);
window.UpdateLayout();
var badge = FindVisualChildByName<Button>(window, "ReleaseBadgeButton");
Assert.NotNull(badge);
Assert.Equal("Pre-Release", badge!.Content);
Assert.NotNull(badge.Command); // the badge links out until the Steam page exists
var tagline = FindVisualChildByName<TextBlock>(window, "AboutTagline");
Assert.NotNull(tagline);
Assert.Equal("1-2-3, and go live to the world on YouTube!", tagline!.Text);
// The old line insulted the creator and is gone for good.
Assert.DoesNotContain("right-brained", TextOf(window));
var price = FindVisualChildByName<TextBlock>(window, "AboutPriceLine");
Assert.Equal("Launch price $79", price!.Text);
var cta = FindVisualChildByName<Button>(window, "UpgradeCtaButton");
Assert.NotNull(cta);
Assert.Equal("Unlock 60 fps — $79", cta!.Content);
var upgradeBlock = FindVisualChildByName<StackPanel>(window, "UpgradeBlock");
Assert.Equal(Visibility.Visible, upgradeBlock!.Visibility);
var copyright = FindVisualChildByName<TextBlock>(window, "AboutCopyright");
Assert.Equal($"© {DateTime.Now.Year} Llama Chile Shop", copyright!.Text);
Assert.DoesNotContain("Made by gramps", TextOf(window));
// A paying customer is never sold to.
vm.EntitlementOverride = new FakeEntitlement(true);
window.UpdateLayout();
Assert.Equal(Visibility.Collapsed, FindVisualChildByName<StackPanel>(window, "UpgradeBlock")!.Visibility);
var owned = FindVisualChildByName<TextBlock>(window, "OwnedStateText");
Assert.Equal(Visibility.Visible, owned!.Visibility);
Assert.Contains("owned", owned.Text, StringComparison.OrdinalIgnoreCase);
}
finally
{
window?.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
});
}
[Fact]
public void Free_user_gets_1080p30_and_cannot_select_or_change_to_60fps()
{
// Coverage of the boundary once it is enforced. While the pre-release switch is off
// (OutputProfile.EntitlementEnforced == false) no build is limited, so there is nothing here
// to assert; that state is covered by EntitlementTests.
if (!OutputProfile.EntitlementEnforced) return;
_app.Run(() =>
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"llamacasty-upgrade-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
MainWindow? window = null;
try
{
window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
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);
// The old red panel under the quality picker is gone (2026-11-05): the licence state
// lives in the left panel's alert stack now, as a pinned status indicator.
Assert.Null(window.FindName("EntitlementErrorPanel"));
var licenceRow = vm.AttentionAlerts.Single(alert => alert.Id == "steam-licence");
Assert.True(licenceRow.IsPinned);
Assert.Contains("Steam ID Limit: 30fps", licenceRow.Text);
Assert.Contains("Click for More Info", licenceRow.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.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.False(vm.IsAboutOpen);
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
{
window?.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
});
}
private static T? FindVisualChildByName<T>(DependencyObject parent, string name) where T : DependencyObject
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is FrameworkElement element && element.Name == name && child is T typed) return typed;
if (FindVisualChildByName<T>(child, name) is { } found) return found;
}
return null;
}
/// <summary>Every TextBlock string in the tree — for asserting that removed copy is really gone.</summary>
private static string TextOf(DependencyObject parent)
{
var text = new System.Text.StringBuilder();
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is TextBlock block) text.Append(block.Text).Append('\n');
text.Append(TextOf(child));
}
return text.ToString();
}
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;
}
}