DiscoverySourceConformanceSuite

Reusable contract test suite for PeerDiscoverySource implementations, aimed squarely at departures() — the half of the contract that used to carry an inherited emptyFlow() default and that four of this repo's implementations silently took.

Subclass and implement newSource, causeArrival and departureFixture to bind any discovery source. Lives in commonMain of :kuilt-conformance (not a module's commonTest) so every discovery backend can subclass it from its own test source set.

The four properties, and where each is discharged

PropertyArmTest
a departure key equals the Tag.peerKey discoveries() emitted for that peerDepartureFixture.EmitsdepartureKeyEqualsThePeerKeyThatWasDiscovered
departures() emits with no concurrent discoveries() collectorDepartureFixture.EmitsdeparturesEmitsWithNoConcurrentDiscoveriesCollector
cancelling the collector's scope completes the collection, and the source stays re-collectablebothcancellingTheCollectorScopeCompletesDepartures
an arrival is never reported as a departure — for a DepartureFixture.NoLeaveSignal source, nothing ever isbothanArrivalIsNeverReportedAsADeparture

Adding a property here is an edit to this file and to two others. Most bindings subclass this suite concretely and pick a new @Test up for free. The two in :kuilt-multipeerkuilt-multipeer/src/jvmTest/…/MultipeerDiscoverySourceConformanceTest.kt and kuilt-multipeer/src/appleTest/…/MultipeerAppleDiscoverySourceConformanceTest.kt — subclass it abstractly and invoke each property by hand, which is what lets them pin the ones they fail (see below). A property added here therefore runs on neither of them, silently, with nothing red. Add the call to both in the same change.

The first two are the ones with teeth, and each was written against a real defect:

  • Emitting something is not enough. discoveryRoster removes by exact key, so a source that emits a display name, a socket address, or another transport's handle leaves the same ghost as a source that emits nothing — while looking, in a log, like it works. Asserting equality against the key discoveries() actually published is the only form of this property that tells the two apart.

  • A leave signal that only runs while somebody is watching arrivals is not a leave signal. A source whose browse session is opened by discoveries(), and whose departure feed is a replay = 0 hot flow fed from that session, delivers nothing to a lone departures() collector. That is not a contrived collector either: discoveryRoster merges the two feeds, and merge subscribes to inner flows in separately-launched coroutines, so even a consumer collecting both can attach to the departure feed a turn late and lose the event.

What this suite does not say

Nothing here asserts latency — only that a departure arrives at all. Nothing observes a transport listener directly, so "does not leak a listener" is checked through the only handle common code has: a cancelled collection must complete, and a fresh collection afterwards must still start. A source that keeps a dead registration alive but tolerates a second one passes.

And the DepartureFixture.NoLeaveSignal arm proves only that the source is honest about having no leave signal. It says nothing whatever about departures the underlying transport could have reported and this implementation throws away — which is the more common and more expensive bug, and the one every silent emptyFlow() in this repo actually is. Read that arm as "declared, and not lying", never as "covered".

Pinning a property a backend genuinely fails

A backend that fails one of these is a finding, and the finding is worth keeping. Record it by pinning the failure — assertFailsWith around the property, asserting the red — rather than @Ignoreing it, so that fixing the backend reds the pin and names itself instead of turning a skip nobody reads into a pass nobody notices. Three things a pin needs, none of them optional:

  • A control source that passes every obligation on the same fixture. Without one a red says only "something failed": it cannot distinguish a broken backend from a harness that could never have satisfied the property, and the pin then freezes the harness's own limitation as if it were the backend's defect. The control is also the smallest sketch of the fix, so it has to be something production could actually be made to do.

  • A red matched on a class-prefixed constant, never a bare phrase. The rig must refuse loudly rather than return quietly, and the pin must match that specific refusal — a phrase like "no browse session" is already raised for unrelated reasons elsewhere in this repo, so matching the words alone lets a pin silently accept a red it was not written for.

  • A tracking issue named in the test's own KDoc, together with the instruction to delete the pin there — the pin is a record of an open defect, and it must say where its own end is decided.

A pin also means the binding enumerates the properties by hand rather than inheriting them, which is the lockstep obligation noted above the table.

Wiring

class MyDiscoverySourceConformanceTest : DiscoverySourceConformanceSuite() {
override fun newSource(): PeerDiscoverySource = MySource()

override suspend fun causeArrival(source: PeerDiscoverySource) {
(source as MySource).advertise("alice")
}

override fun departureFixture(source: PeerDiscoverySource): DepartureFixture =
DepartureFixture.Emits { (source as MySource).withdraw("alice") }
}

Constructors

Link copied to clipboard
constructor()

Properties

Link copied to clipboard

How long the suite waits for an expected event before failing with what it saw — virtual time, null to wait unbounded.

Functions

Link copied to clipboard

A peer arriving must not surface on departures().

Cancelling the collector's scope completes the collection, and the source can then be collected again.

After an arrival and then a departure, departures() emits a key equal to the Tag.peerKey of the Tag discoveries() emitted for that peer.

departures() emits when it is the only thing being collected — no concurrent discoveries() collector anywhere in the test.