diff --git a/Controls/DeveloperPanel.xaml b/Controls/DeveloperPanel.xaml new file mode 100644 index 0000000..64774bf --- /dev/null +++ b/Controls/DeveloperPanel.xaml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/Controls/DeveloperPanel.xaml.cs b/Controls/DeveloperPanel.xaml.cs new file mode 100644 index 0000000..4f98c64 --- /dev/null +++ b/Controls/DeveloperPanel.xaml.cs @@ -0,0 +1,21 @@ +using System.Windows.Controls; + +namespace ytLive.Controls; + +/// +/// The developer affordances that must never exist in a shipped build: today, the +/// all-resolutions override for the output profile. +/// +/// This file is excluded from the Release compile (ytLive.csproj removes +/// the Page and this Compile item when Configuration != Debug), so a Release binary +/// contains no BAML for it and cannot reveal it. That is deliberate over the cheaper +/// "collapse the panel" approach: a collapsed panel still exists in the visual tree of every +/// shipped copy, and "not visible" is a much weaker claim than "not compiled". +/// +public partial class DeveloperPanel : UserControl +{ + public DeveloperPanel() + { + InitializeComponent(); + } +} diff --git a/Controls/OverlayHost.xaml b/Controls/OverlayHost.xaml index 62c4e73..012e1cf 100644 --- a/Controls/OverlayHost.xaml +++ b/Controls/OverlayHost.xaml @@ -111,6 +111,11 @@ + + + diff --git a/Controls/OverlayHost.xaml.cs b/Controls/OverlayHost.xaml.cs index 6752088..2be1a65 100644 --- a/Controls/OverlayHost.xaml.cs +++ b/Controls/OverlayHost.xaml.cs @@ -9,6 +9,12 @@ public partial class OverlayHost : UserControl public OverlayHost() { InitializeComponent(); +#if DEBUG + // Development affordances are added at runtime, never declared in XAML: the panel is + // excluded from the Release compile (ytLive.csproj), so this line is the only + // reference to it and a Release build has none. + DeveloperPanelHost.Children.Add(new DeveloperPanel()); +#endif } /// Is the enlarged brand-logo preview showing? Test seam — a Popup is a separate diff --git a/HANDOFF.md b/HANDOFF.md index 19ed74b..f89d10d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,40 +1,40 @@ -# HANDOFF — 2026-11-01 (the exe is LlamaCasty.exe; the depot folder builds and verifies) +# HANDOFF — 2026-11-01 (exe renamed, depot builds, output rule enforced) ## State -- Branch `main`, level with `origin` through `8f79b20`; this unit (the artifact rename) is local. -- **The shipped exe is `LlamaCasty.exe`.** `AssemblyName` renamed; four pack URIs fixed (the docs - said three — `KeepBroadcastDialog.xaml:5` was the fourth); manifest, MSIX script and - `StorePackagingTests` follow. Guarded by `ytLive.Tests/AssemblyIdentityTests.cs`. +- Branch `main`, level with `origin` through `8f79b20`; this unit is local. +- **The shipped exe is `LlamaCasty.exe`** (`AssemblyName` renamed; four pack URIs fixed — the docs + said three, `KeepBroadcastDialog.xaml:5` was the fourth). Guarded by `AssemblyIdentityTests`. - **`scripts/publish.sh`** stages `artifacts/steam-stage` and refuses to finish if `ffmpeg\`, the licence, privacy or notices file is missing, or test assemblies leaked in. Output 257 MB. -- **Distribution is Steam-exclusive** (creator ruling 2026-10-31): free app + paid edition as a - Steam **DLC**; the **$10/month** plan sold off-Steam by Polar and *stated, never sold*, in-app. - `commercial.md` → Channels is policy; `Distribution.md` is depot mechanics. +- **The output-profile rule is built and enforced**: `Models/OutputProfile.cs` (921,600 px / 30 fps, + fail closed) resolved once in `BuildEncoderOptions`, which feeds the one ffmpeg process writing + both outputs. `MainViewModel.DeveloperMode` bypasses it in Debug only. +- **Distribution is Steam-exclusive** (2026-10-31): free app + paid edition as a Steam **DLC**; the + **$10/month** plan sold off-Steam by Polar, *stated, never sold*, in-app. - **Entitlement is `steamDlcOwned || polarKeyValid`**; `steam_api64.dll` ships beside the exe and the app must start without Steam, falling back to free. -- **Site is live with Steam** (`a9d07ce`). `www.llamacasty.com` 301s to the apex. +- **Site live with Steam** (`a9d07ce`); `www.llamacasty.com` 301s to the apex. ## Next action -1. **OutputProfile gate** — ≤921,600 px / ≤30 fps for the stream **and** the recorder, greyed tier - entries, plus the TASK 53 geometry tests that pin every tier. -2. Then the **entitlement seam** (`IEntitlement`, Steam DLC path behind a fake, Polar path) and the - licence UI. -3. Steamworks: App ID waiting on identity verification; then depot, store page (public 2 weeks - before release) and the DLC app. +1. **Greyed tier entries + entitlement wiring** — the rule is in place and tested + (`Models/OutputProfile.cs`); the dropdown still shows every tier as equally available, and + `HasProOutput` is `IsPremium || DeveloperMode` with no Steam DLC path yet. +2. Then the **entitlement seam** (`IEntitlement`: Steam DLC via `steam_api64.dll`, Polar lease). +3. Steamworks: App ID waits on identity verification; then depot, store page and the DLC app. ## Landmines - **⛔ `ExcludeFromSingleFile="true"` on the ffmpeg content** — without it the publisher bundles libav\*.dll into the exe and the app ships unable to find ffmpeg. Recipe in `MyMistakes.md`. +- **Dev affordances are compiled out, not hidden** — `DeveloperPanel` is removed from the Release + build; `Helpers/DevBuild.cs` is the single source for the flag (never `const`). - **Nothing may gate a feature.** The only paid delta is the output profile; `ShowBrandFlash` stays `false` and entitlement must never enable it. - **No checkout inside a Steam build** — the Wallet rule. The monthly is named, never sold, in-app. - **The org access token never ships.** `organization_id` is public; the token is not. - **The site publishes the model, never the numbers**, and never takes payment or links a checkout. - **No self-updater and no version banner**: Steam updates the app. -- Pre-existing: the **full test suite hangs** (WASAPI teardown) — per-class runs only; and - `ytLive.Tests` carries 3 xUnit analyzer warnings the app-only gate does not see. -- Not every hex is a theme colour — `BorderColorPalette`/chat defaults are USER DATA; on-stream - graphics come from `OutputPalette`. A red settings control is still a bug. +- Pre-existing: the **full test suite hangs** (WASAPI teardown) — per-class runs; `ytLive.Tests` + carries 3 xUnit warnings the app-only gate does not see; a red settings control is still a bug. diff --git a/Helpers/DevBuild.cs b/Helpers/DevBuild.cs new file mode 100644 index 0000000..617ba17 --- /dev/null +++ b/Helpers/DevBuild.cs @@ -0,0 +1,29 @@ +namespace ytLive.Helpers; + +/// +/// Whether this binary is a **development build**. +/// +/// Why static readonly and not const: a const makes +/// if (!DevBuild.IsDevelopment) return; a compile-time branch, so whichever arm is dead +/// raises CS0162 — in Debug the guard is dead, in Release the body is. A readonly field is set +/// once at type init, so both arms compile clean and the JIT folds the branch away. +/// +/// This is not the only defence. The developer panel's XAML and code-behind are +/// removed from the Release compile entirely (ytLive.csproj, Page/Compile Remove), so a +/// shipped binary contains no BAML to reveal. This flag is the second lock: it refuses the +/// value even if something else calls the setter. +/// +/// Single source for the question. It gates the multi-instance profile +/// (), the scoreboard's debug seeding, and the all-resolutions +/// override (MainViewModel.DeveloperMode). +/// +public static class DevBuild +{ + /// True for a Debug compile; false for Release and anything else. + public static readonly bool IsDevelopment = +#if DEBUG + true; +#else + false; +#endif +} diff --git a/Models/OutputProfile.cs b/Models/OutputProfile.cs new file mode 100644 index 0000000..31763a9 --- /dev/null +++ b/Models/OutputProfile.cs @@ -0,0 +1,62 @@ +namespace ytLive.Models; + +/// +/// The one entitlement rule, as pure policy: what a build is allowed to emit. +/// +/// Why pixels and a frame rate rather than "720p". The paid delta is output +/// quality, and a resolution name is a marketing word, not a measurable bound — 720p60 is +/// 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. +/// +/// Fail closed. Callers pass pro: false when entitlement is unknown, +/// unreadable or unresolved; the free profile is the default state, never the fallback +/// after an error. +/// +/// This is not a feature gate. Every feature, scene, overlay and the recording +/// itself stay available; only the emitted frame gets smaller. Never call it for anything +/// else — a cracked build buys almost nothing, which is the entire anti-piracy posture. +/// +public static class OutputProfile +{ + /// Free ceiling: 921,600 px = 1280×720, i.e. one 720p frame. + public const int FreeMaxPixels = 921_600; + + /// Free ceiling: 30 frames per second. + public const int FreeMaxFps = 30; + + /// The named tier a capped build falls back to when nothing else qualifies. + public const string FreeFallbackName = "720p30"; + + /// Is this tier within the free budget? Pro may use anything shipped. + public static bool IsAllowed(QualityOption tier, bool pro) + => pro + || (tier.Width * tier.Height <= FreeMaxPixels && tier.Fps <= FreeMaxFps); + + /// + /// The tier a session will actually encode. A permitted selection passes through + /// untouched (so Pro and the free tier's own 720p30 keep their exact bitrate); a + /// disallowed one resolves to the best tier the budget allows — most pixels first, then + /// most frames, then the cheapest bitrate — because silently encoding something the user + /// did not pick is only acceptable if it is the closest legal thing to it. + /// + /// What the dropdown says. + /// Every tier the build offers. + /// Entitlement, already resolved. Unknown must be passed as false. + public static QualityOption Resolve( + QualityOption requested, IEnumerable shipped, bool pro) + { + if (IsAllowed(requested, pro)) return requested; + + var candidate = shipped + .Where(tier => IsAllowed(tier, pro)) + .OrderByDescending(tier => tier.Width * tier.Height) + .ThenByDescending(tier => tier.Fps) + .ThenBy(tier => tier.Bitrate) + .FirstOrDefault(); + + // 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. + return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 6.0, 1280, 720); + } +} diff --git a/MyMistakes.md b/MyMistakes.md index 51f6cf1..6aff0fe 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -166,6 +166,20 @@ in Studio → Settings → Channel → **Feature eligibility**: *"Advanced YouTube features aren't available for this account"*. If ID verification is ever wanted on a personal channel, do it **before** converting to a brand account. +### THE `const` THAT MAKES A GUARD UNREACHABLE (2026-11-01) + +A dev-build flag declared `const bool` turns `if (!DevBuild.IsDevelopment) return;` into a +compile-time branch: Debug reports **CS0162 unreachable** on the guard, Release reports it on the +body — and the repo builds at 0 warnings, so either config fails the gate. + +**Fix:** `public static readonly bool IsDevelopment =` … set once from `#if DEBUG`. Both arms +compile clean in both configurations, and the JIT still folds the branch. The *real* guarantee for +"this must not ship" is not the flag anyway — it is removing the UI's `Page`/`Compile` items from +the Release build (``). + +⚠️ A Condition on the **ItemGroup** is invisible to a test that reads `item.Attribute("Condition")`. +Read the parent too — that mistake cost one failing test here. + ### THE SINGLE-FILE PUBLISHER EATS ffmpeg (2026-11-01) **Problem:** `dotnet publish` with `PublishSingleFile=true` + diff --git a/TASKS.md b/TASKS.md index e201ccf..1afaa52 100644 --- a/TASKS.md +++ b/TASKS.md @@ -49,7 +49,7 @@ Dog test. | 54 | 🔶 | Freemium: EULA + privacy shipped; **no licence path built** — needs the entitlement seam, the output-profile cap and the licence UI below; site copy still says free forever | [`task-54`](TASKS/task-54-marcom-free-merch.md) | | — | ☐ | **Steamworks entitlement** — `steam_api64.dll` + `SteamAPI.Init`, `BIsDlcInstalled(dlcAppId)` behind `IEntitlement`, free profile when Steam is absent | [`commercial`](docs/ai/commercial.md) | | — | ☐ | **Entitlement seam** — Polar customer-portal path for the monthly (org ID public, org token never in the app), `conditions: {channel_id}`, 7–14 day lease, `--activate` CLI. **No claim page, no server** | [`commercial`](docs/ai/commercial.md) | -| — | ☐ | **OutputProfile gate** — ≤921,600 px / ≤30 fps for stream **and** recording, greyed tier entries, live re-resolve, never mid-stream | [`commercial`](docs/ai/commercial.md) | +| — | 🔶 | **OutputProfile gate** — resolver + `EncoderOptions` wiring **done** (`Models/OutputProfile.cs`, one ffmpeg/two outputs, dev override); remaining: greyed tier entries in the dropdown, entitlement wiring | [`commercial`](docs/ai/commercial.md) | | — | ☐ | **Licence UI** — four states, 7/1/0-day notices, "monthly is on the website, not Steam" line, portal link, licence ID in About | [`commercial`](docs/ai/commercial.md) | | — | ☐ | **System messages** — `SystemMessage` + `MessageCenter`, strip above the dock, dedupe by key, short history, never composited | [`ui-surface`](docs/ai/ui-surface.md) | | — | ☐ | **Steam packaging** — rename, `publish` output + `steam_api64.dll`, depot, `steamcmd` upload, store page + assets, DLC app | [`Distribution.md`](Distribution.md) | diff --git a/ViewModels/MainViewModel.OutputProfile.cs b/ViewModels/MainViewModel.OutputProfile.cs new file mode 100644 index 0000000..b86e031 --- /dev/null +++ b/ViewModels/MainViewModel.OutputProfile.cs @@ -0,0 +1,48 @@ +using ytLive.Helpers; + +namespace ytLive.ViewModels; + +/// +/// The entitlement surface: whether this session may emit output above the free budget. +/// +/// Deliberately a separate partial from License.cs: that file owns *how* a +/// licence is validated (Polar today), this one owns *what the answer means* for output. +/// Steam's DLC check and Polar's lease both collapse into ; nothing +/// downstream of cares which one proved it. +/// +public partial class MainViewModel +{ + /// + /// The single input to . + /// + /// Fail closed: an unresolved or unreadable licence is simply false, so a + /// build with no entitlement emits the free profile. There is no "unknown" branch to get + /// wrong. + /// + /// Developer mode never ships: is only ever + /// settable inside a development build (see ), so this expression + /// reduces to IsPremium in a Release binary. + /// + public bool HasProOutput => IsPremium || DeveloperMode; + + private bool _developerMode; + + /// + /// Development-only override: ignore the output budget and allow every shipped tier. + /// + /// There is no UI to reach this in a Release build — the panel that hosts the + /// checkbox is excluded from the Release compile — and the setter refuses the value + /// unless is true, so even a hand-edited settings + /// file cannot unlock it. + /// + public bool DeveloperMode + { + get => _developerMode; + set + { + if (!DevBuild.IsDevelopment) return; + if (!SetProperty(ref _developerMode, value)) return; + OnPropertyChanged(nameof(HasProOutput)); + } + } +} diff --git a/ViewModels/MainViewModel.Scoreboard.cs b/ViewModels/MainViewModel.Scoreboard.cs index 8c8cedf..4238b34 100644 --- a/ViewModels/MainViewModel.Scoreboard.cs +++ b/ViewModels/MainViewModel.Scoreboard.cs @@ -78,20 +78,15 @@ public partial class MainViewModel } } -#if DEBUG - private const bool DevelopmentBuild = true; -#else - private const bool DevelopmentBuild = false; -#endif - /// Can the scoreboard be configured at all? Signed-in creators yes; in a DEBUG build /// always, so the strip can be previewed and judged before there is an account (creator, /// 2026-10-30). public bool CanUseScoreboard => IsConnected || IsDevelopmentBuild; /// True in a DEBUG build. Used only to expose development-only controls, never to - /// change behaviour — the shipped binary differs by their absence, not by a code path. - public bool IsDevelopmentBuild => DevelopmentBuild; + /// change behaviour — the shipped binary differs by their absence, not by a code path. + /// Single source: . + public bool IsDevelopmentBuild => DevBuild.IsDevelopment; private bool _scoreboardSimulate; diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 70b3d96..ef7807c 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -405,10 +405,14 @@ public partial class MainViewModel : ViewModelBase // Full encoder options for the current tier, or null when no RTMP URL is // available — the pump then skips the encoder entirely (TASK 5 fills the seam). + // + // BOTH outputs (RTMP + local MP4) are encoded from this one option set, so the + // output-profile rule is applied here, once: a capped build cannot stream 1080p and + // record it too. See Models/OutputProfile.cs — pixels, not "720p". private EncoderOptions? BuildEncoderOptions() { var url = _rtmpUrlProvider(); - var quality = SelectedQuality; + var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput); return new EncoderOptions { RtmpUrl = url ?? string.Empty, diff --git a/docs/ai/commercial.md b/docs/ai/commercial.md index 3e6834e..9da098c 100644 --- a/docs/ai/commercial.md +++ b/docs/ai/commercial.md @@ -31,9 +31,15 @@ The cap is a pixel budget plus a frame rate, not the word "720p": | Vertical 9:16 | 720×1280 @30 | 1080×1920 @60 | - **Records too.** The cap governs the file the creator keeps as well as the stream; otherwise - free 1080p recording is a re-upload hole. One `OutputProfile`, resolved once per session and - consumed by **both** the stream encoder and the recorder — never two checks. + free 1080p recording is a re-upload hole. **There is one ffmpeg process with two output blocks** + (FLV to RTMP, MP4 to disk) fed by one `EncoderOptions`, and the profile is resolved once when + those options are built (`MainViewModel.Streaming.Operations.cs` → `Models/OutputProfile.cs`). + There is no second encoder to keep in step and therefore no second check to forget. - **Fail closed.** Unresolved, unreadable or unvalidated entitlement = free tier. Never Pro. +- **The development override exists and never ships.** `MainViewModel.DeveloperMode` bypasses the + budget in a Debug build only; the Settings row that toggles it is **excluded from the Release + compile** (`ytLive.csproj` removes its Page and code-behind), and the setter refuses the value + when `Helpers/DevBuild.IsDevelopment` is false. Not a hidden row — an absent one. - **Never downgrade a running stream.** The gate applies to the next start; pulling quality out of a live broadcast is a worse bug than one free 1080p session. - **The greyed menu entry is the sales surface.** It stays clickable, says what it costs, opens diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md index 6eb3e26..8c3273c 100644 --- a/docs/ai/ui-surface.md +++ b/docs/ai/ui-surface.md @@ -104,6 +104,7 @@ The app is a **director's control surface**, not an editor. This replaces the OB - **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = YouTube chat. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output. - **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand/gear and account surfaces remain at the sides. The gear's left-click menu remains; right-click opens App Settings directly. +- **Developer affordances are compiled out of release, not hidden.** Any control that must never ship (today: the all-resolutions override) lives in its own `UserControl` whose `Page` and `Compile` items are removed by `ytLive.csproj` when `$(Configuration) != 'Debug'`, and is added to its host at runtime inside `#if DEBUG` (`OverlayHost`). A collapsed row still exists in the visual tree of every shipped copy; "not compiled" is the only claim worth making. Label such rows with `Brush.Attention` (orange) while they are visible in Debug. Guard: `ytLive.Tests/OutputProfileTests.cs` asserts the build rule, and `Helpers/DevBuild.cs` is the single source for "is this a dev build". - **Scene strip:** five fixed-size cards above the preview show the scene name and hotkey; the staged scene carries a `PROGRAM` badge, and staged/live borders use orange. Click stages a scene while idle; while streaming or recording it changes program output and starts the selected transition. - **Preview rail:** stream info, YPP, Socials, and TEST are icon/color tabs on the preview's right edge. Socials opens an animated in-preview drawer; it is no longer a standalone modal window. The bottom live-controls row retains TRAX and the game/mic meters and controls. - **Central monitor:** shows the staged scene while editing and the program scene when editing is off. Preview transitions snapshot and animate the outgoing picture in every mode; output transitions run while streaming or recording. Go Live itself remains a Cut. diff --git a/ytLive.Tests/OutputProfileTests.cs b/ytLive.Tests/OutputProfileTests.cs new file mode 100644 index 0000000..d84a257 --- /dev/null +++ b/ytLive.Tests/OutputProfileTests.cs @@ -0,0 +1,112 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Xunit; +using ytLive.Models; + +namespace ytLive.Tests; + +/// +/// 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 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. +/// +public class OutputProfileTests +{ + /// Mirror of MainViewModel.QualityOptions — the shipped tiers. Kept in + /// step deliberately: this is the table the budget has to cover. + 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(), pro: false); + + Assert.Equal(1280, resolved.Width); + Assert.Equal(720, resolved.Height); + Assert.Equal(30, resolved.Fps); + } + + /// + /// 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. + /// + [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); + } + } +} diff --git a/ytLive.csproj b/ytLive.csproj index 6052d53..e2069ad 100644 --- a/ytLive.csproj +++ b/ytLive.csproj @@ -92,6 +92,14 @@ + + + + + +