Files
JoshHeaps.Net/JoshHeaps.Net.UiTests/EchoRoomTests.cs
T
Josh-HeapsandClaude Opus 5 5501c84e9b Test the ranging pipeline against a simulated room
The DSP tests drive the same files the page loads, so there is no second
implementation to drift. The room tests put two real browsers through a
real room with a synthetic microphone, which covers everything except the
acoustics: slot rotation, leave and rejoin, and the stalled-device case
where a chirp must be withheld rather than sent late.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 14:42:16 -06:00

245 lines
11 KiB
C#

using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
using NUnit.Framework;
namespace JoshHeaps.Net.UiTests;
/// <summary>
/// Drives two real browsers through a real room: the hub, the round scheduler, slot rotation,
/// detection and the solve all run unchanged. Only the microphone is synthetic, so the answer is
/// known in advance — this is everything except the acoustics.
/// </summary>
[TestFixture]
public class EchoRoomTests : PlaywrightTest
{
private const double TargetMetres = 2.5;
private IPlaywright? _playwright;
private IBrowser? _browser;
private readonly List<IBrowserContext> _contexts = [];
private TestConfiguration Config => TestConfiguration.Instance;
[OneTimeSetUp]
public async Task LaunchBrowser()
{
// Its own Playwright instance and browser: the fake-media launch flags have to be set at
// launch time, and the fixture-managed browser is already running by the time tests start.
_playwright = await Microsoft.Playwright.Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true,
Args =
[
"--use-fake-ui-for-media-stream",
"--use-fake-device-for-media-stream",
"--autoplay-policy=no-user-gesture-required"
]
});
}
[OneTimeTearDown]
public async Task CloseBrowser()
{
if (_browser is not null) await _browser.CloseAsync();
_playwright?.Dispose();
}
[TearDown]
public async Task CloseContexts()
{
foreach (var context in _contexts) await context.CloseAsync();
_contexts.Clear();
}
[Test]
public async Task Two_Devices_Measure_The_Distance_Between_Them()
{
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
var first = await NewDeviceAsync(roomCode, "laptop");
var second = await NewDeviceAsync(roomCode, "phone");
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
var measured = await WaitForMeasurementAsync(first);
var alsoMeasured = await WaitForMeasurementAsync(second);
Assert.That(measured, Is.EqualTo(TargetMetres).Within(0.05), "the first device's range");
Assert.That(alsoMeasured, Is.EqualTo(TargetMetres).Within(0.05), "both devices should agree");
}
[Test]
public async Task A_Device_Leaving_Stops_The_Rounds_And_Rejoining_Resumes_Them()
{
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
var first = await NewDeviceAsync(roomCode, "laptop");
var second = await NewDeviceAsync(roomCode, "phone");
await WaitForMeasurementAsync(first);
await second.ClickAsync("#echoLeave");
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(1);
await Expect(first.Locator("#echoStatus")).ToContainTextAsync("Waiting for a second device");
await second.ClickAsync("#echoJoin");
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
Assert.That(await WaitForMeasurementAsync(first), Is.EqualTo(TargetMetres).Within(0.05));
}
/// <summary>
/// A stalled main thread is the two-tabs-on-one-machine failure: only one tab is visible, so the
/// other gets throttled and its round handling runs late. A late chirp attributed to the wrong
/// slot yields a plausible-looking but completely wrong range, so the requirement is not "always
/// measures" — it is "never reports a wrong answer". A round it cannot hit must be sat out.
/// </summary>
[Test]
public async Task A_Stalled_Device_Sits_Rounds_Out_Instead_Of_Reporting_Nonsense()
{
var roomCode = $"T{Random.Shared.Next(1000, 9999)}";
var first = await NewDeviceAsync(roomCode, "laptop");
var second = await NewDeviceAsync(roomCode, "phone", stallMilliseconds: 900);
await Expect(first.Locator("#echoRoster li")).ToHaveCountAsync(2);
await first.EvaluateAsync("() => { window.__seen = []; }");
await first.EvaluateAsync("""
() => {
const original = EchoPage.renderSolved.bind(EchoPage);
EchoPage.renderSolved = update => {
const range = update.solved?.matrix?.[0]?.[1];
if (range != null) window.__seen.push(range);
return original(update);
};
}
""");
await first.WaitForTimeoutAsync(20000);
var seen = await first.EvaluateAsync<double[]>("() => window.__seen");
var satOut = await second.EvaluateAsync<int>("() => EchoSession.skippedRounds");
TestContext.Out.WriteLine($"reported ranges: {string.Join(", ", seen.Select(r => r.ToString("0.000")))}, sat out: {satOut}");
Assert.That(satOut, Is.GreaterThan(0),
"the stall must actually have cost the device some slots, or this test proves nothing");
Assert.That(seen, Is.Not.Empty, "a stalled peer should still let some rounds through");
Assert.That(seen, Is.All.EqualTo(TargetMetres).Within(0.05),
"every range that gets reported must be right — a stalled device must sit the round out, not chirp late");
}
[Test]
public async Task The_Capture_Worklet_Keeps_A_Continuous_Readable_Stream()
{
var page = await NewDeviceAsync($"T{Random.Shared.Next(1000, 9999)}", "laptop", fakeMicrophone: false);
await page.WaitForFunctionAsync(
"() => EchoAudio.highestFrame > 48000",
null,
new PageWaitForFunctionOptions { Timeout = 15000, PollingInterval = 100 });
var capture = await page.EvaluateAsync<double[]>("""
() => [
EchoAudio.context.sampleRate,
EchoAudio.warnings.length,
EchoAudio.read(EchoAudio.highestFrame - 24000, 24000)?.length ?? 0,
EchoAudio.read(EchoAudio.highestFrame + 1000, 100) === null ? 1 : 0,
Math.abs(EchoAudio.frameAt(EchoAudio.context.currentTime) - EchoAudio.highestFrame)
]
""");
Assert.That(capture[0], Is.EqualTo(48000), "the pipeline assumes it got the rate it asked for");
Assert.That(capture[1], Is.EqualTo(0), "a clean fake device should raise no capture warnings");
Assert.That(capture[2], Is.EqualTo(24000), "recent audio must be readable out of the ring");
Assert.That(capture[3], Is.EqualTo(1), "reads past the captured end must fail rather than return silence");
Assert.That(capture[4], Is.LessThan(48000),
"the frame index and the context clock must stay in the same domain — a scheduled playback time is converted straight into a recording position");
}
/// <summary>
/// A page joined to the room with its microphone replaced by a synthesizer. Every slot's chirp
/// is placed where a room of this geometry would put it, including a different unknown output
/// latency per slot so the cancellation is actually exercised.
/// </summary>
private async Task<IPage> NewDeviceAsync(
string roomCode,
string name,
bool fakeMicrophone = true,
int stallMilliseconds = 0)
{
var context = await _browser!.NewContextAsync(new BrowserNewContextOptions
{
IgnoreHTTPSErrors = true,
Permissions = ["microphone"]
});
_contexts.Add(context);
var page = await context.NewPageAsync();
page.Console += (_, message) =>
{
if (message.Type == "error") TestContext.Out.WriteLine($"[{name} console] {message.Text}");
};
await page.GotoAsync($"{Config.Test.BaseUrl}/echo?room={roomCode}");
await page.FillAsync("#echoName", name);
if (fakeMicrophone) await page.EvaluateAsync(FakeMicrophoneScript, TargetMetres);
if (stallMilliseconds > 0) await page.EvaluateAsync(StallScript, stallMilliseconds);
await page.ClickAsync("#echoJoin");
return page;
}
/// <summary>Blocks the main thread on every round announcement, the way a throttled tab does.</summary>
private const string StallScript = """
stallMs => {
const original = EchoSession.handleRoundStarting.bind(EchoSession);
EchoSession.handleRoundStarting = schedule => {
const until = performance.now() + stallMs;
while (performance.now() < until) { /* hold the thread */ }
return original(schedule);
};
}
""";
private static async Task<double> WaitForMeasurementAsync(IPage page)
{
await page.WaitForFunctionAsync(
"() => EchoPage.lastSolved?.solved?.matrix?.[0]?.[1] != null",
null,
new PageWaitForFunctionOptions { Timeout = 30000, PollingInterval = 250 });
return await page.EvaluateAsync<double>("() => EchoPage.lastSolved.solved.matrix[0][1]");
}
/// <summary>
/// Synthesizes what the microphone would have heard for this round. The device's own chirp is
/// placed at the frame it was actually scheduled for rather than at its nominal slot position,
/// so any drift between "when the round said to play" and "when playback was really booked"
/// reaches the detector instead of being papered over by the harness.
/// </summary>
private const string FakeMicrophoneScript = """
targetMetres => {
const speedOfSound = 343;
const epsilonMetres = 0.08;
const latencyBySlot = [1400, 5200, 2600, 7100, 900, 4300, 3300, 6000];
EchoAudio.read = (startFrame, length) => {
const round = EchoSession.pending ?? EchoSession.lastRound;
if (!round) return null;
const chirp = EchoSession.chirp;
const toSamples = metres => Math.round((metres / speedOfSound) * round.sampleRate);
const recording = new Float32Array(length);
round.schedule.slotOrder.forEach((_, slot) => {
const own = slot === round.ownSlot;
const origin = own
? round.scheduledFrame - round.windowStart
: round.leadInSamples + slot * round.slotSamples;
const at = origin + latencyBySlot[slot] + toSamples(own ? epsilonMetres : targetMetres);
const amplitude = own ? 1.0 : 0.25;
for (let i = 0; i < chirp.length && at + i < length; i++) recording[at + i] += chirp[i] * amplitude;
});
for (let i = 0; i < length; i++) recording[i] += (Math.random() * 2 - 1) * 0.01;
return recording;
};
}
""";
}