FIX 1 — UDP 9100 binding race: SprtDiscoveryServiceTests and
CompositeDiscoveryServiceTests both use exclusive UDP 9100 binds. xunit
runs test classes concurrently by default, causing the tests to fail
intermittently with AddressAlreadyInUse. Add [Collection("udp-9100")]
attribute to both classes so they run serially instead.
FIX 2 — Design doc: CompositeDiscoveryService does NOT forward the callback
down to children; it raises the caller's callback itself after all children
finish, once per unique IP. Rewrite the relevant sentence to describe the
real behavior.
Verify: 30 tests pass (x2), build clean, no production code touched.
Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref
219 lines
7.7 KiB
C#
219 lines
7.7 KiB
C#
using Inspectron.Epson;
|
|
|
|
namespace Inspectron.Epson.Tests;
|
|
|
|
[Collection("udp-9100")]
|
|
public class CompositeDiscoveryServiceTests
|
|
{
|
|
private sealed class StubDiscovery : IDiscoveryService
|
|
{
|
|
private readonly List<DiscoveredPrinter> _printers;
|
|
private readonly Exception? _throws;
|
|
|
|
public StubDiscovery(params string[] ips)
|
|
{
|
|
_printers = ips.Select(ip => new DiscoveredPrinter { IPAddress = ip }).ToList();
|
|
}
|
|
|
|
public StubDiscovery(Exception throws)
|
|
{
|
|
_printers = new List<DiscoveredPrinter>();
|
|
_throws = throws;
|
|
}
|
|
|
|
public StubDiscovery(List<DiscoveredPrinter> printers)
|
|
{
|
|
_printers = printers;
|
|
}
|
|
|
|
public Task<List<DiscoveredPrinter>> DiscoverPrintersAsync(
|
|
TimeSpan? timeout = null,
|
|
Action<DiscoveredPrinter>? onPrinterDiscovered = null)
|
|
{
|
|
if (_throws is not null)
|
|
{
|
|
throw _throws;
|
|
}
|
|
|
|
foreach (var p in _printers)
|
|
{
|
|
onPrinterDiscovered?.Invoke(p);
|
|
}
|
|
|
|
return Task.FromResult(_printers);
|
|
}
|
|
}
|
|
|
|
private sealed class SlowStubDiscovery : IDiscoveryService
|
|
{
|
|
private readonly TimeSpan _delay;
|
|
private readonly string _ip;
|
|
|
|
public SlowStubDiscovery(TimeSpan delay, string ip)
|
|
{
|
|
_delay = delay;
|
|
_ip = ip;
|
|
}
|
|
|
|
public async Task<List<DiscoveredPrinter>> DiscoverPrintersAsync(
|
|
TimeSpan? timeout = null,
|
|
Action<DiscoveredPrinter>? onPrinterDiscovered = null)
|
|
{
|
|
await Task.Delay(_delay);
|
|
return new List<DiscoveredPrinter> { new DiscoveredPrinter { IPAddress = _ip } };
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Merges_results_from_every_child()
|
|
{
|
|
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
|
|
{
|
|
new StubDiscovery("10.0.0.1"),
|
|
new StubDiscovery("10.0.0.2", "10.0.0.3")
|
|
});
|
|
|
|
var result = await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50));
|
|
|
|
Assert.Equal(3, result.Count);
|
|
Assert.Contains(result, p => p.IPAddress == "10.0.0.1");
|
|
Assert.Contains(result, p => p.IPAddress == "10.0.0.2");
|
|
Assert.Contains(result, p => p.IPAddress == "10.0.0.3");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deduplicates_by_ip_keeping_the_first_child_that_reported_it()
|
|
{
|
|
// The two stubs report the same IP but are distinguishable, so this
|
|
// proves first-wins rather than merely proving that one of them survived.
|
|
var first = new StubDiscovery(new List<DiscoveredPrinter>
|
|
{
|
|
new DiscoveredPrinter { IPAddress = "10.0.0.1", ModelName = "TM-T30III", ServiceType = "ENPC" }
|
|
});
|
|
var second = new StubDiscovery(new List<DiscoveredPrinter>
|
|
{
|
|
new DiscoveredPrinter { IPAddress = "10.0.0.1", ModelName = "POS895", ServiceType = "SPRT" }
|
|
});
|
|
|
|
var composite = new CompositeDiscoveryService(new IDiscoveryService[] { first, second });
|
|
|
|
var result = await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50));
|
|
|
|
var only = Assert.Single(result);
|
|
Assert.Equal("TM-T30III", only.ModelName);
|
|
Assert.Equal("ENPC", only.ServiceType);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Runs_children_concurrently_not_sequentially()
|
|
{
|
|
// Three children that each take ~300ms. Sequential execution needs >=900ms;
|
|
// concurrent should land near 300ms. The 700ms bound leaves generous headroom
|
|
// for a loaded machine while still failing a sequential regression widely.
|
|
var children = new IDiscoveryService[]
|
|
{
|
|
new SlowStubDiscovery(TimeSpan.FromMilliseconds(300), "10.0.0.1"),
|
|
new SlowStubDiscovery(TimeSpan.FromMilliseconds(300), "10.0.0.2"),
|
|
new SlowStubDiscovery(TimeSpan.FromMilliseconds(300), "10.0.0.3")
|
|
};
|
|
|
|
var composite = new CompositeDiscoveryService(children);
|
|
|
|
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
|
var result = await composite.DiscoverPrintersAsync(TimeSpan.FromSeconds(5));
|
|
stopwatch.Stop();
|
|
|
|
Assert.Equal(3, result.Count);
|
|
Assert.True(stopwatch.ElapsedMilliseconds < 700,
|
|
$"expected concurrent execution near 300ms but took {stopwatch.ElapsedMilliseconds}ms, " +
|
|
"which indicates the children ran sequentially");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task One_child_throwing_does_not_suppress_the_others()
|
|
{
|
|
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
|
|
{
|
|
new StubDiscovery(new InvalidOperationException("boom")),
|
|
new StubDiscovery("10.0.0.7")
|
|
});
|
|
|
|
var result = await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50));
|
|
|
|
Assert.Single(result);
|
|
Assert.Equal("10.0.0.7", result[0].IPAddress);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Every_child_throwing_propagates_instead_of_reporting_no_printers()
|
|
{
|
|
// If every discovery protocol failed (e.g. a routeless interface), that is
|
|
// "discovery is broken", not "no printers on the network". The caller
|
|
// (PrinterDiscoveryBackgroundTask) relies on this to skip the pass instead
|
|
// of expiring and unregistering every printer it already knows about.
|
|
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
|
|
{
|
|
new StubDiscovery(new InvalidOperationException("boom 1")),
|
|
new StubDiscovery(new InvalidOperationException("boom 2"))
|
|
});
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Forwards_the_discovered_callback()
|
|
{
|
|
var seen = new List<string>();
|
|
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
|
|
{
|
|
new StubDiscovery("10.0.0.1", "10.0.0.2")
|
|
});
|
|
|
|
await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50),
|
|
p => seen.Add(p.IPAddress));
|
|
|
|
Assert.Equal(2, seen.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Callback_fires_once_per_ip_even_when_two_children_report_it()
|
|
{
|
|
var seen = new List<string>();
|
|
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
|
|
{
|
|
new StubDiscovery("10.0.0.1"),
|
|
new StubDiscovery("10.0.0.1")
|
|
});
|
|
|
|
await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50),
|
|
p => seen.Add(p.IPAddress));
|
|
|
|
Assert.Single(seen);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_real_sprt_bind_failure_degrades_instead_of_losing_the_other_child()
|
|
{
|
|
// End-to-end proof of the fix: SprtDiscoveryService now throws when it
|
|
// cannot bind UDP 9100 (instead of swallowing the fault and returning
|
|
// empty), but the composite still absorbs that single child's failure and
|
|
// returns whatever the other child found - it does not propagate. Total
|
|
// failure only happens when every child fails, which is asserted
|
|
// separately by Every_child_throwing_propagates_instead_of_reporting_no_printers.
|
|
using var blocker = new System.Net.Sockets.UdpClient();
|
|
blocker.Client.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Any, 9100));
|
|
|
|
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
|
|
{
|
|
new SprtDiscoveryService(),
|
|
new StubDiscovery("10.0.0.9")
|
|
});
|
|
|
|
var result = await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(200));
|
|
|
|
var only = Assert.Single(result);
|
|
Assert.Equal("10.0.0.9", only.IPAddress);
|
|
}
|
|
}
|