TASK 4 ship step 5.5: social bar bug fixes + bar on the live output — direction-snap drag (SocialBarSnap.Decide + ClearValue, local Canvas.Top was overriding the binding), fediverse nodeinfo subdomain probing + load-time heal (settable FediverseSoftware), SocialBarRenderer strip blitted above the flash via a re-read FramePump socialBar seam — 155 tests passing, 0 warnings

This commit is contained in:
2026-08-13 09:29:58 -07:00
parent e72ba71165
commit ac60a26d82
16 changed files with 666 additions and 52 deletions
+47 -2
View File
@@ -66,7 +66,8 @@ public class FramePumpTests
private static FramePump NewPump(FakeEncoder encoder, Func<EncoderOptions?>? options = null,
Func<Scene?>? scene = null, Func<SceneElement, VideoFrame?>? resolve = null,
List<string>? log = null)
List<string>? log = null,
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
{
return new FramePump(
sceneProvider: scene ?? (() => BackdropScene()),
@@ -83,7 +84,8 @@ public class FramePumpTests
}),
encoderFactory: () => encoder,
log: log != null ? m => log.Add(m) : null,
pacingDelay: async (_, _) => await Task.Yield()); // deterministic: no real waits
pacingDelay: async (_, _) => await Task.Yield(), // deterministic: no real waits
socialBar: socialBar);
}
private static void AssertColor(VideoFrame frame, int x, int y, byte r, byte g, byte b)
@@ -163,6 +165,49 @@ public class FramePumpTests
Assert.False(pump.IsRunning);
}
[Fact]
public async Task Start_WithSocialBarSeam_PlacesBarAtTopThenBottomEdge()
{
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var bar = new VideoFrame(64, 8, new byte[64 * 8 * 4]);
Array.Fill(bar.BgraPixels, (byte)255); // opaque white strip
var encoder = new FakeEncoder();
var position = SocialBarPosition.Top;
using var pump = NewPump(encoder,
resolve: e => e is Source { IsBackdrop: true } ? red : null,
socialBar: () => (bar, position));
await pump.StartAsync();
await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.NotEmpty(encoder.Frames);
AssertColor(encoder.Frames[0], 0, 0, 255, 255, 255); // bar at the top edge
// Flip to Bottom: the pump re-reads the seam each frame, so a later frame
// lands the bar at the bottom edge (SourceRectHeight - bar height) and the
// top corner clears back to backdrop.
position = SocialBarPosition.Bottom;
VideoFrame? flipped = null;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && flipped == null)
{
for (var i = 1; i < encoder.Frames.Count; i++)
{
var f = encoder.Frames[i];
if (f.BgraPixels[2] == 255 && f.BgraPixels[1] == 0 && f.BgraPixels[0] == 0)
{
flipped = f;
break;
}
}
if (flipped == null) await Task.Delay(10);
}
Assert.NotNull(flipped);
AssertColor(flipped!, 0, 47, 255, 255, 255); // bar sits on the bottom edge
await pump.StopAsync();
}
[Fact]
public async Task Start_EncoderThrows_RaisesFailed_AndDisposes()
{
+37
View File
@@ -180,6 +180,43 @@ public class SceneCompositorTests
// untouched corner stays pure red
AssertColor(output, 0, 0, 255, 0, 0);
}
[Fact]
public void Composite_WithSocialBar_OverlaysAboveFlash_AtTopOrBottom()
{
var red = Solid(1920, 1080, 255, 0, 0);
var backdrop = new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" };
var scene = new Scene { Name = "Live" };
scene.Elements.Add(backdrop);
var options = new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
OutputWidth = 1920, OutputHeight = 1080,
};
var compositor = new SceneCompositor();
// master-sized overlay: opaque green at (0,0), 50%-white at the center
var bar = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
var g = 0;
bar.BgraPixels[g] = 0; bar.BgraPixels[g + 1] = 255; bar.BgraPixels[g + 2] = 0; bar.BgraPixels[g + 3] = 255;
var w = (540 * 1920 + 960) * 4;
bar.BgraPixels[w] = 255; bar.BgraPixels[w + 1] = 255; bar.BgraPixels[w + 2] = 255; bar.BgraPixels[w + 3] = 128;
// flash: opaque magenta at (0,0) — the bar must cover it
var flash = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
flash.BgraPixels[0] = 255; flash.BgraPixels[1] = 0; flash.BgraPixels[2] = 255; flash.BgraPixels[3] = 255;
var top = compositor.Render(scene, _ => red, flash, options, bar, 0);
AssertColor(top, 0, 0, 0, 255, 0); // bar above flash at the top-left
AssertColor(top, 960, 540, 255, 127, 127); // 50% white over red
AssertColor(top, 100, 100, 255, 0, 0); // empty overlay area: backdrop
var bottom = compositor.Render(scene, _ => red, flash, options, bar, 1040);
AssertColor(bottom, 0, 1040, 0, 255, 0); // bar drawn at the bottom edge
AssertColor(bottom, 0, 1039, 255, 0, 0); // backdrop just above the bar
AssertColor(bottom, 960, 540, 255, 0, 0); // bar region moved away from center
}
}
public class StretchMathTests
+99
View File
@@ -1,8 +1,11 @@
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;
@@ -16,6 +19,102 @@ public class SocialBarTests
return path;
}
/// <summary>Resolves every fediverse domain as Mastodon — the heal test only
/// cares that a missing stored software name gets filled in and persisted.</summary>
private sealed class MastodonValidator : ISocialValidator
{
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
=> Task.FromResult<string?>("mastodon");
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
=> throw new System.NotSupportedException();
}
[Fact]
public void SocialBarSnap_Decide_DirectionAndDeadzone()
{
Assert.Equal(SocialBarPosition.Top, SocialBarSnap.Decide(-10));
Assert.Equal(SocialBarPosition.Top, SocialBarSnap.Decide(-6));
Assert.Equal(SocialBarPosition.Bottom, SocialBarSnap.Decide(10));
Assert.Equal(SocialBarPosition.Bottom, SocialBarSnap.Decide(6));
Assert.Null(SocialBarSnap.Decide(5));
Assert.Null(SocialBarSnap.Decide(0));
Assert.Null(SocialBarSnap.Decide(-5));
}
[Fact]
public void SocialBarSnap_Decide_HonorsCustomDeadzone()
{
Assert.Null(SocialBarSnap.Decide(10, 20));
Assert.Equal(SocialBarPosition.Top, SocialBarSnap.Decide(-21, 20));
}
[Fact]
public void SocialEntry_FediverseSoftware_SettableUpdatesLogo()
{
var entry = new SocialEntry
{
Service = SocialService.Fediverse,
Handle = "@gramps@llamachile.tube",
ProfileUrl = "https://llamachile.tube/@gramps",
};
var honeycomb = entry.LogoData;
entry.FediverseSoftware = "mastodon";
Assert.Equal(SocialServiceIcons.LogoDataForFediverse("mastodon"), entry.LogoData);
Assert.NotEqual(honeycomb, entry.LogoData);
}
/// <summary>The single integration test for this branch: a fediverse entry
/// persisted with a NULL software name is loaded, healed via the real validator
/// seam, and the resolved name is persisted back — surviving a second load.</summary>
[Fact]
public async Task Socials_HealMissingFediverseSoftware_RoundTripsThroughDb()
{
var path = TempDbPath();
try
{
var socials = new SocialsConfig();
socials.Entries.Add(new SocialEntry
{
Service = SocialService.Fediverse,
Handle = "@gramps@llamachile.tube",
ProfileUrl = "https://llamachile.tube/@gramps",
});
using (var store = new LayoutStore(path))
store.Save(new[] { new Scene { Name = "Live" } }, null, socials);
SocialsConfig? loaded;
using (var store = new LayoutStore(path))
{
store.Load();
loaded = store.Socials;
Assert.NotNull(loaded);
Assert.Null(loaded!.Entries[0].FediverseSoftware);
}
var healed = await MainViewModel.HealFediverseSoftwareAsync(loaded!, new MastodonValidator());
Assert.Single(healed);
Assert.Equal("mastodon", healed["@gramps@llamachile.tube"]);
loaded.Entries[0].FediverseSoftware = healed["@gramps@llamachile.tube"];
using (var store = new LayoutStore(path))
store.Save(new[] { new Scene { Name = "Live" } }, null, loaded);
using (var store = new LayoutStore(path))
{
store.Load();
var again = store.Socials;
Assert.NotNull(again);
Assert.Equal("mastodon", again!.Entries[0].FediverseSoftware);
}
}
finally
{
SqliteConnection.ClearAllPools();
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void SocialsConfig_RoundTrip_PersistsEntriesAndBarSettings()
{
+33
View File
@@ -103,6 +103,39 @@ public class SocialValidatorTests
Assert.Null(result.FediverseSoftware);
}
[Fact]
public async Task FediverseSoftware_IdentityDomainSilent_ProbesWellKnownSubdomains()
{
// The identity domain is a silent landing page (no nodeinfo, no redirect);
// the real instance lives on mastodon.<domain> — the probe must find it.
var validator = Validator(new StubHandler(request =>
{
var host = request.RequestUri!.Host;
if (host == "mastodon.llamachile.tube")
{
if (request.RequestUri.AbsolutePath.StartsWith("/.well-known"))
return Json(new { links = new[] { new { rel = "http://nodeinfo.diaspora.software/ns/schema/2.0", href = "https://mastodon.llamachile.tube/nodeinfo/2.0" } } });
if (request.RequestUri.AbsolutePath.StartsWith("/nodeinfo"))
return Json(new { software = new { name = "mastodon", version = "4.6.3" } });
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
}));
var software = await validator.ResolveFediverseSoftwareAsync("llamachile.tube", CancellationToken.None);
Assert.Equal("mastodon", software);
}
[Fact]
public async Task FediverseSoftware_NoSubdomainAnswers_ReturnsNull()
{
var validator = Validator(new StubHandler(new HttpResponseMessage(HttpStatusCode.NotFound)));
var software = await validator.ResolveFediverseSoftwareAsync("silent.example", CancellationToken.None);
Assert.Null(software);
}
[Fact]
public async Task FediverseHandle_CanceledDuringNodeInfo_IsCanceled()
{
@@ -15,6 +15,9 @@ public class SocialsDialogViewModelTests
{
public int LookupCount { get; private set; }
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
=> Task.FromResult<string?>("mastodon");
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
{
LookupCount++;
@@ -51,6 +54,9 @@ public class SocialsDialogViewModelTests
public Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
=> Gate.Task;
public Task<string?> ResolveFediverseSoftwareAsync(string domain, CancellationToken ct)
=> Task.FromResult<string?>(null);
}
private sealed class SignInFake