Social bar v2: fediverse icons resolved via nodeinfo (with redirect-following), persisted software column, icon colors — 112 tests passing, 0 warnings
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
@@ -27,7 +25,7 @@ public class SocialBarTests
|
||||
var socials = new SocialsConfig
|
||||
{
|
||||
BarPosition = SocialBarPosition.Top,
|
||||
BarJustify = SocialBarJustify.Left,
|
||||
BarEnabled = false,
|
||||
};
|
||||
socials.Entries.Add(new SocialEntry
|
||||
{
|
||||
@@ -41,6 +39,13 @@ public class SocialBarTests
|
||||
Handle = "streamer123",
|
||||
ProfileUrl = "https://www.twitch.tv/streamer123",
|
||||
});
|
||||
socials.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = SocialService.Fediverse,
|
||||
Handle = "@gramps@llamachile.tube",
|
||||
ProfileUrl = "https://llamachile.tube/@gramps",
|
||||
FediverseSoftware = "peertube",
|
||||
});
|
||||
|
||||
using (var store = new LayoutStore(path))
|
||||
{
|
||||
@@ -53,13 +58,16 @@ public class SocialBarTests
|
||||
{
|
||||
var scenes = store.Load();
|
||||
Assert.NotNull(store.Socials);
|
||||
Assert.Equal(2, store.Socials!.Entries.Count);
|
||||
Assert.Equal(3, store.Socials!.Entries.Count);
|
||||
Assert.Equal(SocialService.YouTube, store.Socials.Entries[0].Service);
|
||||
Assert.Equal("mychannel", store.Socials.Entries[0].Handle);
|
||||
Assert.Equal("https://www.youtube.com/@mychannel", store.Socials.Entries[0].ProfileUrl);
|
||||
Assert.Equal(SocialService.Twitch, store.Socials.Entries[1].Service);
|
||||
Assert.Equal(SocialService.Fediverse, store.Socials.Entries[2].Service);
|
||||
Assert.Equal("@gramps@llamachile.tube", store.Socials.Entries[2].Handle);
|
||||
Assert.Equal("peertube", store.Socials.Entries[2].FediverseSoftware);
|
||||
Assert.Equal(SocialBarPosition.Top, store.Socials.BarPosition);
|
||||
Assert.Equal(SocialBarJustify.Left, store.Socials.BarJustify);
|
||||
Assert.False(store.Socials.BarEnabled);
|
||||
Assert.True(scenes[0].HasSocialBar);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +81,7 @@ public class SocialBarTests
|
||||
[Fact]
|
||||
public void SocialsConfig_EmptyEntries_NotPersisted()
|
||||
{
|
||||
var path = TempDBPath();
|
||||
var path = TempDbPath();
|
||||
try
|
||||
{
|
||||
var socials = new SocialsConfig();
|
||||
@@ -94,6 +102,12 @@ public class SocialBarTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialsConfig_DefaultBarEnabled_IsOn()
|
||||
{
|
||||
Assert.True(new SocialsConfig().BarEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialServiceIcons_CanonicalUrlFor_AllServices()
|
||||
{
|
||||
@@ -103,17 +117,63 @@ public class SocialBarTests
|
||||
Assert.Equal("https://github.com/dev", SocialServiceIcons.CanonicalUrlFor(SocialService.GitHub, "dev"));
|
||||
Assert.Equal("https://example.com", SocialServiceIcons.CanonicalUrlFor(SocialService.Website, "example.com"));
|
||||
Assert.Equal("https://example.com", SocialServiceIcons.CanonicalUrlFor(SocialService.Website, "https://example.com"));
|
||||
Assert.Equal("https://site.tld/@user", SocialServiceIcons.CanonicalUrlFor(SocialService.Link, "@user@site.tld"));
|
||||
Assert.Equal("https://site.tld/@user", SocialServiceIcons.CanonicalUrlFor(SocialService.Fediverse, "@user@site.tld"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialServiceIcons_InitialsAndColor_AllServices()
|
||||
public void SocialServiceIcons_LogoData_AllServices()
|
||||
{
|
||||
Assert.Equal("YT", SocialServiceIcons.InitialsFor(SocialService.YouTube));
|
||||
Assert.Equal("#FF0000", SocialServiceIcons.ColorFor(SocialService.YouTube));
|
||||
Assert.Equal("TW", SocialServiceIcons.InitialsFor(SocialService.Twitch));
|
||||
Assert.NotEmpty(SocialServiceIcons.InitialsFor(SocialService.Website));
|
||||
Assert.NotEmpty(SocialServiceIcons.ColorFor(SocialService.Website));
|
||||
foreach (var service in Enum.GetValues<SocialService>())
|
||||
Assert.False(string.IsNullOrWhiteSpace(SocialServiceIcons.LogoDataFor(service)), service.ToString());
|
||||
Assert.NotEmpty(SocialServiceIcons.LockedIconData);
|
||||
Assert.NotEmpty(SocialServiceIcons.DoNotIconData);
|
||||
Assert.NotEqual(SocialServiceIcons.LogoDataFor(SocialService.YouTube), SocialServiceIcons.LogoDataFor(SocialService.Twitch));
|
||||
}
|
||||
|
||||
private static string TempDBPath() => TempDbPath();
|
||||
[Fact]
|
||||
public void SocialServiceIcons_LogoDataForFediverse_KnownAndUnknownSoftware()
|
||||
{
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), SocialServiceIcons.LogoDataForFediverse("PeerTube"));
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("mastodon"), SocialServiceIcons.LogoDataForFediverse("MASTODON"));
|
||||
Assert.NotEqual(
|
||||
SocialServiceIcons.LogoDataForFediverse("peertube"),
|
||||
SocialServiceIcons.LogoDataForFediverse("mastodon"));
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse(null), SocialServiceIcons.LogoDataForFediverse("gotosocial"));
|
||||
Assert.NotEqual(SocialServiceIcons.LogoDataForFediverse("peertube"), SocialServiceIcons.LogoDataForFediverse("unknown"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialEntry_FediverseLogo_ComesFromSoftwareName()
|
||||
{
|
||||
var entry = new SocialEntry
|
||||
{
|
||||
Service = SocialService.Fediverse,
|
||||
Handle = "@gramps@llamachile.tube",
|
||||
ProfileUrl = "https://llamachile.tube/@gramps",
|
||||
FediverseSoftware = "peertube",
|
||||
};
|
||||
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("peertube"), entry.LogoData);
|
||||
Assert.NotEqual(SocialServiceIcons.LogoDataFor(SocialService.Link), entry.LogoData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialServiceIcons_DetectService_ByDomain()
|
||||
{
|
||||
Assert.Equal(SocialService.YouTube, SocialServiceIcons.DetectService("youtube.com/@handle").service);
|
||||
Assert.Equal(SocialService.X, SocialServiceIcons.DetectService("https://x.com/user").service);
|
||||
Assert.Equal(SocialService.Instagram, SocialServiceIcons.DetectService("instagram.com/user").service);
|
||||
var fediverse = SocialServiceIcons.DetectService("@user@instance.tube");
|
||||
Assert.Equal(SocialService.Fediverse, fediverse.service);
|
||||
Assert.Equal("@user@instance.tube", fediverse.handle);
|
||||
Assert.Equal("https://instance.tube/@user", fediverse.url);
|
||||
Assert.Equal(SocialService.GitHub, SocialServiceIcons.DetectService("https://github.com/dev").service);
|
||||
Assert.Equal(SocialService.Website, SocialServiceIcons.DetectService("example.com/path").service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocialServiceIcons_DetectService_BareHandle_FallsBackToWebsite()
|
||||
{
|
||||
Assert.Equal(SocialService.Website, SocialServiceIcons.DetectService("justaname").service);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
public class SocialValidatorTests
|
||||
{
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
|
||||
|
||||
public StubHandler(HttpResponseMessage response) : this(_ => response) { }
|
||||
|
||||
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) => _respond = respond;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
return Task.FromCanceled<HttpResponseMessage>(ct);
|
||||
return Task.FromResult(_respond(request));
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpSocialValidator Validator(HttpMessageHandler handler)
|
||||
=> new(new HttpClient(handler));
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_BuildsHostUrl()
|
||||
{
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("https://site.tld/@user", result.ProfileUrl);
|
||||
Assert.Equal("@user@site.tld", result.Handle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_FetchesInstanceSoftware()
|
||||
{
|
||||
var validator = Validator(new StubHandler(RespondFediverse("peertube")));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@gramps@llamachile.tube", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("peertube", result.FediverseSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_RootRedirectToSubdomain_ResolvesSoftware()
|
||||
{
|
||||
var validator = Validator(new StubHandler(request =>
|
||||
{
|
||||
var uri = request.RequestUri!;
|
||||
if (uri.Host == "site.tld")
|
||||
{
|
||||
if (uri.AbsolutePath == "/")
|
||||
{
|
||||
// Identity domain's default app redirects to the real instance.
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
RequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://social.site.tld/"),
|
||||
};
|
||||
}
|
||||
if (uri.AbsolutePath.StartsWith("/@"))
|
||||
return new HttpResponseMessage(HttpStatusCode.OK); // profile validates
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound); // nodeinfo SSO-blocked
|
||||
}
|
||||
if (uri.Host == "social.site.tld")
|
||||
{
|
||||
if (uri.AbsolutePath.StartsWith("/.well-known"))
|
||||
return Json(new { links = new[] { new { rel = "http://nodeinfo.diaspora.software/ns/schema/2.0", href = "https://social.site.tld/nodeinfo/2.0" } } });
|
||||
if (uri.AbsolutePath.StartsWith("/nodeinfo"))
|
||||
return Json(new { software = new { name = "mastodon", version = "4.6.3" } });
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("mastodon", result.FediverseSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_NodeInfoUnavailable_StillSucceeds()
|
||||
{
|
||||
var validator = Validator(new StubHandler(request =>
|
||||
request.RequestUri!.AbsolutePath.StartsWith("/.well-known")
|
||||
? new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
: new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.FediverseSoftware);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FediverseHandle_CanceledDuringNodeInfo_IsCanceled()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var request = 0;
|
||||
var validator = Validator(new StubHandler(_ =>
|
||||
{
|
||||
if (++request >= 2) cts.Cancel();
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.Fediverse, "@user@site.tld", cts.Token);
|
||||
|
||||
Assert.True(result.Canceled);
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
|
||||
private static Func<HttpRequestMessage, HttpResponseMessage> RespondFediverse(string software)
|
||||
{
|
||||
return request =>
|
||||
{
|
||||
if (request.RequestUri!.AbsolutePath.StartsWith("/.well-known"))
|
||||
{
|
||||
return Json(new
|
||||
{
|
||||
links = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
rel = "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
||||
href = $"https://{request.RequestUri.Host}/nodeinfo/2.0",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (request.RequestUri!.AbsolutePath.StartsWith("/nodeinfo"))
|
||||
{
|
||||
return Json(new { software = new { name = software, version = "1.0.0" } });
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
};
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(object payload)
|
||||
=> new(HttpStatusCode.OK) { Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(payload)) };
|
||||
|
||||
[Fact]
|
||||
public async Task KnownServiceHandle_CanonicalUrl()
|
||||
{
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.X, "creator", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("https://x.com/creator", result.ProfileUrl);
|
||||
Assert.Equal("creator", result.Handle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotFound_IsRejected()
|
||||
{
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.NotFound)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.X, "doesnotexist", CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("NotFound", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Redirect_CountsAsExists()
|
||||
{
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.Found)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.GitHub, "dev", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectionFailure_IsFriendlyError()
|
||||
{
|
||||
var validator = Validator(new StubHandler(_ => throw new HttpRequestException("boom")));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.X, "user", CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Couldn't reach", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanceledToken_AbortsRequest()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
|
||||
var result = await validator.LookupAsync(SocialService.X, "creator", cts.Token);
|
||||
|
||||
Assert.True(result.Canceled);
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
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<SocialLookupResult> 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Holds the lookup open so a test can cancel mid-flight and then
|
||||
/// resolve it — proving a dismissed dialog never applies the result.</summary>
|
||||
private sealed class BlockingValidator : ISocialValidator
|
||||
{
|
||||
public TaskCompletionSource<SocialLookupResult> Gate { get; } = new();
|
||||
|
||||
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
|
||||
=> Gate.Task;
|
||||
}
|
||||
|
||||
private sealed class SignInFake
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
public Task<YouTubeChannel?> Next()
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult<YouTubeChannel?>(
|
||||
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<YouTubeChannel?>(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user