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:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user