using System.IO; using System.Threading; using System.Threading.Tasks; using Microsoft.Data.Sqlite; using Xunit; using ytLive.Models; using ytLive.Services; using ytLive.ViewModels; namespace ytLive.Tests; public class SocialsDialogViewModelTests { private sealed class FakeValidator : ISocialValidator { public int LookupCount { get; private set; } public Task LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct) { LookupCount++; if (handleOrUrl.StartsWith('@') && handleOrUrl.IndexOf('@', 1) > 0) { var at = handleOrUrl.IndexOf('@', 1); var user = handleOrUrl[1..at]; var domain = handleOrUrl[(at + 1)..]; return Task.FromResult(new SocialLookupResult { Success = true, Handle = handleOrUrl, ProfileUrl = $"https://{domain}/@{user}", FediverseSoftware = "peertube", }); } var handle = handleOrUrl.Trim().TrimStart('@'); if (handle == "doesnotexist") return Task.FromResult(new SocialLookupResult { Error = "404 for 'doesnotexist'." }); return Task.FromResult(new SocialLookupResult { Success = true, Handle = handle, ProfileUrl = SocialServiceIcons.CanonicalUrlFor(service, handle), }); } } /// Holds the lookup open so a test can cancel mid-flight and then /// resolve it — proving a dismissed dialog never applies the result. private sealed class BlockingValidator : ISocialValidator { public TaskCompletionSource Gate { get; } = new(); public Task LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct) => Gate.Task; } private sealed class SignInFake { public int Calls { get; private set; } public Task Next() { Calls++; return Task.FromResult( new YouTubeChannel { ChannelId = "UCabc", DisplayName = "My Channel" }); } } private int _signOutCalls; private Task SignOut() { _signOutCalls++; return Task.CompletedTask; } private SocialsDialogViewModel Create( ISocialValidator? validator = null, SignInFake? signIn = null, YouTubeChannel? account = null, bool isPremium = false, SocialsConfig? current = null) { return new SocialsDialogViewModel( validator ?? new FakeValidator(), signIn != null ? signIn.Next : () => Task.FromResult(null), SignOut, account, isPremium, current); } // ── ONE integration test: the full dialog-VM flow with fakes ── [Fact] public async Task FullFlow_Freemium_SignInAddDeleteSignOutPersistence() { var validator = new FakeValidator(); var signIn = new SignInFake(); var vm = Create(validator, signIn); // Gate: signed out, freemium → row 0 is the sign-in prompt, rows 2-5 locked. Assert.True(vm.ShowSignInBanner); Assert.Equal(6, vm.Slots.Count); Assert.True(vm.Slots[0].IsSignIn); Assert.False(vm.Slots[0].IsLocked); Assert.False(vm.Slots[1].IsLocked); Assert.True(vm.Slots[2].IsLocked); Assert.True(vm.Slots[5].IsLocked); Assert.True(vm.CanSave); // Row 1: add a validated X handle. Save is blocked while input is unvalidated. vm.StartEdit(1); Assert.True(vm.Slots[1].IsEditing); vm.Slots[1].EditText = "x.com/creator"; Assert.False(vm.CanSave); vm.ConfirmEdit(1); Assert.Equal(1, validator.LookupCount); Assert.Equal(SocialService.X, vm.Slots[1].Service); Assert.Equal("creator", vm.Slots[1].Handle); Assert.True(vm.Slots[1].IsFilled); Assert.False(vm.Slots[1].IsEditing); Assert.True(vm.CanSave); // Rows 2-5 are locked on the free tier — starting an edit there is blocked. vm.StartEdit(2); Assert.False(vm.Slots[2].IsEditing); Assert.True(vm.Slots[2].IsLocked); // Delete the X entry: the slot empties and unlocks nothing else. await vm.DeleteSlotAsync(1); Assert.False(vm.Slots[1].IsFilled); Assert.False(vm.Slots[1].IsLocked); Assert.True(vm.Slots[2].IsLocked); // Delete row 0 while signed out is a no-op. await vm.DeleteSlotAsync(0); Assert.Equal(0, _signOutCalls); // Sign in: row 0 fills with the channel; the X slot is still free. vm.SignInCommand.Execute(null); Assert.Equal(1, signIn.Calls); Assert.True(vm.IsSignedIn); Assert.False(vm.Slots[0].IsSignIn); Assert.Equal("My Channel", vm.Slots[0].Handle); Assert.False(vm.ShowSignInBanner); // Fill row 1 again and save: YouTube-first ordering, bar enabled. vm.StartEdit(1); vm.Slots[1].EditText = "twitch.tv/streamer"; vm.ConfirmEdit(1); vm.SaveCommand.Execute(null); Assert.NotNull(vm.CommittedEntries); Assert.Equal(2, vm.CommittedEntries!.Count); Assert.Equal(SocialService.YouTube, vm.CommittedEntries[0].Service); Assert.Equal("My Channel", vm.CommittedEntries[0].Handle); Assert.Equal(SocialService.Twitch, vm.CommittedEntries[1].Service); Assert.Equal("streamer", vm.CommittedEntries[1].Handle); // Sign out via the row-0 delete: action called once, row 0 back to the gate. await vm.DeleteSlotAsync(0); Assert.Equal(1, _signOutCalls); Assert.False(vm.IsSignedIn); Assert.True(vm.Slots[0].IsSignIn); // Full persistence roundtrip through the store. var config = vm.BuildSocialsConfig(); Assert.NotNull(config); Assert.True(config!.BarEnabled); Assert.Equal(2, config.Entries.Count); var path = Path.Combine(Path.GetTempPath(), $"ytLlive-social-flow-{System.Guid.NewGuid():N}.db"); SqliteConnection.ClearAllPools(); try { using (var store = new LayoutStore(path)) store.Save(new[] { new Scene { Name = "Live" } }, null, config); using (var store = new LayoutStore(path)) { store.Load(); Assert.NotNull(store.Socials); Assert.Equal(2, store.Socials!.Entries.Count); Assert.Equal(SocialService.YouTube, store.Socials.Entries[0].Service); Assert.Equal(SocialService.Twitch, store.Socials.Entries[1].Service); Assert.True(store.Socials.BarEnabled); } } finally { SqliteConnection.ClearAllPools(); if (File.Exists(path)) File.Delete(path); } } // ── Unit tests ── [Fact] public void Freemium_LocksSlotsBeyondTwo_PremiumUnlocksAll() { var free = Create(); Assert.True(free.Slots[2].IsLocked); Assert.True(free.Slots[5].IsLocked); Assert.False(free.Slots[1].IsLocked); var premium = Create(isPremium: true); Assert.All(premium.Slots, s => Assert.False(s.IsLocked)); Assert.False(premium.Slots[5].IsLocked); } [Fact] public void ValidationFailure_LeavesSlotEditableAndBlocksSave() { var validator = new FakeValidator(); var vm = Create(validator); vm.StartEdit(1); vm.Slots[1].EditText = "x.com/doesnotexist"; vm.ConfirmEdit(1); Assert.True(vm.Slots[1].IsEditing); Assert.True(vm.Slots[1].HasError); Assert.False(vm.Slots[1].IsFilled); Assert.False(vm.CanSave); vm.Slots[1].EditText = "github.com/dev"; vm.ConfirmEdit(1); Assert.Equal(SocialService.GitHub, vm.Slots[1].Service); Assert.False(vm.Slots[1].HasError); Assert.True(vm.CanSave); } [Fact] public void EmptyEdit_CancelsEditing() { var vm = Create(); vm.StartEdit(1); vm.Slots[1].EditText = "x.com/user"; vm.ConfirmEdit(1); Assert.True(vm.Slots[1].IsFilled); vm.StartEdit(1); vm.Slots[1].EditText = ""; vm.ConfirmEdit(1); Assert.False(vm.Slots[1].IsEditing); Assert.True(vm.Slots[1].IsFilled); } [Fact] public void SeedsFromCurrent_YouTubeBecomesAccountRow() { var current = new SocialsConfig { BarEnabled = false }; current.Entries.Add(new SocialEntry { Service = SocialService.YouTube, Handle = "stale-yt-handle", ProfileUrl = "https://www.youtube.com/@stale-yt-handle", }); current.Entries.Add(new SocialEntry { Service = SocialService.Twitch, Handle = "oldstreamer", ProfileUrl = "https://www.twitch.tv/oldstreamer", }); var vm = Create(account: new YouTubeChannel { ChannelId = "UC123", DisplayName = "Fresh Channel" }, current: current); Assert.False(vm.BarEnabled); Assert.Equal("Fresh Channel", vm.Slots[0].Handle); Assert.Equal(SocialService.Twitch, vm.Slots[1].Service); Assert.Equal("oldstreamer", vm.Slots[1].Handle); Assert.True(vm.Slots[2].IsLocked); vm.SaveCommand.Execute(null); Assert.Equal(2, vm.CommittedEntries!.Count); Assert.Equal("Fresh Channel", vm.CommittedEntries[0].Handle); Assert.Equal(SocialService.Twitch, vm.CommittedEntries[1].Service); Assert.False(vm.BuildSocialsConfig()!.BarEnabled); } [Fact] public void EmptySave_BuildsNullConfig() { var vm = Create(); Assert.True(vm.CanSave); vm.SaveCommand.Execute(null); Assert.NotNull(vm.CommittedEntries); Assert.Empty(vm.CommittedEntries!); Assert.Null(vm.BuildSocialsConfig()); } [Fact] public async Task SignOut_ResetsRowZeroToGate() { var signIn = new SignInFake(); var vm = Create(signIn: signIn, account: new YouTubeChannel { ChannelId = "UC1", DisplayName = "Ch" }); Assert.True(vm.IsSignedIn); await vm.DeleteSlotAsync(0); Assert.Equal(1, _signOutCalls); Assert.False(vm.IsSignedIn); Assert.True(vm.Slots[0].IsSignIn); Assert.True(vm.ShowSignInBanner); } [Fact] public void FediverseHandle_ValidatesAndKeepsFullHandle() { var vm = Create(); vm.StartEdit(1); vm.Slots[1].EditText = "@creator@instance.tube"; vm.ConfirmEdit(1); Assert.Equal(SocialService.Fediverse, vm.Slots[1].Service); Assert.Equal("@creator@instance.tube", vm.Slots[1].Handle); Assert.Equal("https://instance.tube/@creator", vm.Slots[1].ProfileUrl); Assert.Equal("peertube", vm.Slots[1].FediverseSoftware); Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), vm.Slots[1].LogoData); Assert.True(vm.Slots[1].IsFilled); Assert.False(vm.Slots[1].IsEditing); Assert.True(vm.CanSave); } [Fact] public void FediverseEntry_CommitsSoftwareName() { var vm = Create(); vm.StartEdit(1); vm.Slots[1].EditText = "@creator@instance.tube"; vm.ConfirmEdit(1); vm.SaveCommand.Execute(null); Assert.NotNull(vm.CommittedEntries); var entry = Assert.Single(vm.CommittedEntries!); Assert.Equal(SocialService.Fediverse, entry.Service); Assert.Equal("peertube", entry.FediverseSoftware); Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), entry.LogoData); var config = vm.BuildSocialsConfig(); Assert.NotNull(config); Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), Assert.Single(config.Entries).LogoData); } [Fact] public void Cancel_AbortsInFlightValidation_WithoutMutatingSlot() { var validator = new BlockingValidator(); var vm = Create(validator); vm.StartEdit(1); vm.Slots[1].EditText = "x.com/creator"; vm.ConfirmEdit(1); Assert.True(vm.Slots[1].IsValidating); vm.Cancel(); // The lookup resolves with success AFTER dismissal — it must not apply. validator.Gate.TrySetResult(new SocialLookupResult { Success = true, Handle = "creator", ProfileUrl = "https://x.com/creator", }); Assert.False(vm.Slots[1].IsFilled); Assert.False(vm.Slots[1].HasError); Assert.False(vm.Slots[1].IsValidating); Assert.True(vm.Slots[1].IsEditing); } [Fact] public void ReSubmittingIdenticalFailedInput_DoesNotRelookup() { var validator = new FakeValidator(); var vm = Create(validator); vm.StartEdit(1); vm.Slots[1].EditText = "x.com/doesnotexist"; vm.ConfirmEdit(1); Assert.Equal(1, validator.LookupCount); Assert.True(vm.Slots[1].HasError); // LostFocus firing on Cancel re-submits the same failed text — no second lookup. vm.ConfirmEdit(1); Assert.Equal(1, validator.LookupCount); Assert.True(vm.Slots[1].HasError); // Editing the text re-enables validation. vm.Slots[1].EditText = "github.com/dev"; vm.ConfirmEdit(1); Assert.Equal(2, validator.LookupCount); Assert.False(vm.Slots[1].HasError); Assert.True(vm.Slots[1].IsFilled); } [Fact] public void ReEditingUnchangedHandle_ClosesBox_NoRelookup() { var validator = new FakeValidator(); var vm = Create(validator); vm.StartEdit(1); vm.Slots[1].EditText = "x.com/creator"; vm.ConfirmEdit(1); Assert.Equal(1, validator.LookupCount); Assert.True(vm.Slots[1].IsFilled); vm.StartEdit(1); Assert.True(vm.Slots[1].IsEditing); vm.ConfirmEdit(1); Assert.Equal(1, validator.LookupCount); Assert.False(vm.Slots[1].IsEditing); Assert.True(vm.Slots[1].IsFilled); } }