using System; using System.IO; using Microsoft.Data.Sqlite; using Xunit; using ytLive.Services; using ytLive.ViewModels; namespace ytLive.Tests; /// /// Record-OR-live (creator ruling 2026-09-01): the REC and ON-AIR pills are /// radio-exclusive — arming one clears the other, so a session can never dual-encode /// to both the YouTube ingest and a local file at once (a concurrent disk write /// streamed from the same sustained Kbps cripples the stream on mid-range chassis). /// The dual-block encoder machinery in FfmpegArgs stays generic; the constraint /// lives at the pills. /// [Collection("RealApp")] public sealed class PillRadioTests { private readonly RealAppHost _app; public PillRadioTests(RealAppHost app) => _app = app; [Fact] public void Arming_One_Output_Pill_Clears_The_Other() { _app.Run(Run); } private void Run() { var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-pillradio-{Guid.NewGuid():N}.db"); MainViewModel.LayoutPathOverride = tempDb; try { using (var schema = new LayoutStore(tempDb)) { } SqliteConnection.ClearAllPools(); var vm = new MainViewModel(); vm.IsConnected = true; // the ON-AIR setter requires a sign-in // Arm REC first, then ON-AIR → REC must clear. vm.RecordPillOn = true; vm.OnAirPillOn = true; Assert.False(vm.RecordPillOn, "arming ON-AIR must clear the REC pill"); Assert.True(vm.OnAirPillOn); // Arm REC while ON-AIR is lit → ON-AIR must clear. vm.RecordPillOn = true; Assert.False(vm.OnAirPillOn, "arming REC must clear the ON-AIR pill"); Assert.True(vm.RecordPillOn); // A single armed pill still makes the Start button actionable. Assert.True(vm.CanStartSession); } finally { MainViewModel.LayoutPathOverride = null; SqliteConnection.ClearAllPools(); try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } } /// Good Dog (one integration test, creator ruling 2026-09-20): the ON-AIR /// pill is armable signed-out — intent may light while the account is offline (a /// greyed pill was the dead end the guard shipped to forbid). A front-branch on /// the ON-AIR setter that dropped the pill headlessly is what the pill is FOR. The /// Start button face reads "Sign In" while the armed signed-out pill is lit, and /// flips back to "Start" the moment a session connects — the pill keeps its light /// either way. Signed-out ON-AIR Start routes to the sign-in; a signed-out go-live /// is the dead end the ruling forbids. [Fact] public void SignedOut_OnAir_Pill_Stays_Lit_And_Start_Face_Is_SignIn() { _app.Run(RunSignedOutOnAir); } private void RunSignedOutOnAir() { var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-pillradio-signin-{Guid.NewGuid():N}.db"); MainViewModel.LayoutPathOverride = tempDb; try { using (var schema = new LayoutStore(tempDb)) { } SqliteConnection.ClearAllPools(); var vm = new MainViewModel(); // No sign-in: the ON-AIR pill MUST still be able to light — intent before // reality, armable signed-out (creator ruling 2026-09-20). vm.OnAirPillOn = true; Assert.True(vm.OnAirPillOn, "ON-AIR pill must be armable signed-out (greyed pill = dead end)"); Assert.True(vm.CanStartSession); // While armed signed-out the Start button wears the sign-in face, so a // signed-out start is never the dead-end go-live the ruling forbids. Assert.Equal("Sign In", vm.PrimaryStartButtonLabel); Assert.False(vm.ShowPrimaryStartButton && vm.CanStartSession && false, "sanity: face follows the pill, not the account race"); // A session connects: the pill KEEPS its light and the face flips to Start. vm.IsConnected = true; Assert.True(vm.OnAirPillOn, "the armed signed-out pill must survive the sign-in"); Assert.Equal("Start", vm.PrimaryStartButtonLabel soil); } finally { MainViewModel.LayoutPathOverride = null; SqliteConnection.ClearAllPools(); try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } } }