RoomConformanceSuite
Reusable contract test suite for RoomFactory implementations.
Subclass and implement newHarness to bind any RoomFactory under test. Every Test encodes a required invariant of the Room lifecycle state machine.
Lives in commonMain of :kuilt-conformance (not a module's commonTest) so every RoomFactory adapter can subclass it from its own test source set.
Virtual time convention: all partition tests advance in 100 ms steps using fastHeartbeatConfig (interval=100ms, timeout=200ms, reconnectWindow=500ms):
4 × 100 ms → MembershipEvent.Partitioned fires.
9 × 100 ms → past reconnect window → PeerLost / MembershipEvent.HostLost.
Scope contract: newHarness receives the test's CoroutineScope (typically backgroundScope from runTest) so FaultyLoom and SeamRoomFactory are correctly structured under the test's virtual-time scheduler.
Fault injection: tests that require partition behaviour go through RoomHarness.faults, a two-armed FaultInjection fixture. A harness that cannot break its own links declares FaultInjection.Unsupported with a tracking URL — it cannot decline silently, because the arm has nowhere to put a refusal that is not also a declaration (#2306).
Every wait is bounded, and names what it saw. The population running this suite is by definition implementors whose fabric does not work yet, so no obligation here may be guarded by a suspending wait that simply never returns. Waits go through awaitRoster / awaitEvent / awaitFrame, each bounded by awaitBudget in virtual time and each failing with an AssertionError that prints the roster (or the events) actually observed — see #2284, where a fabric that never admitted burned the whole runTest ceiling and then reported UncompletedCoroutinesError, naming neither the room nor its roster.
No obligation here returns silently — #2306
There is no skip API in common kotlin-test, so an obligation that early-returns reports PASS — worse than a JVM-visible @Ignore, because nothing anywhere records that it did not run. This suite had four such returns and, until #2306, exactly one subclass: the reference. Every escape hatch had therefore fired zero times in its life, and nothing had ever checked that a harness taking one survives the suite at all.
They are closed by three different mechanisms, and which one applies is a judgement about whose limitation the missing state is:
resumeToken— a loud precondition. Room.resumeToken is documented non-null on an admitted joiner, so a null there is a contract violation by the room, not a limitation of the harness, and requireResumeToken fails naming the room. The early return was not even protecting anyone: joinerLearnsHostRoomIdOnAdmission already comparesresumeToken?.roomIdagainst a non-null host id, so the same room was already failing one test of this suite while silently skipping another. Two tests, one fabric, opposite verdicts.Fault injection — a two-armed sealed fixture (FaultInjection). Here the missing state genuinely belongs to the harness: a room over a fabric whose links the test cannot reach cannot be partitioned by anyone. A nullable hook was the wrong shape for it —
nullmoves the vacuity one level up, where it is a value nobody has to justify. The sealed arm makes declining representable only together with its declaration, so the pairing is the compiler's job rather than a meta-test's, and the arm that declines still ends in an assertion (injectorOrDeclaredGap) rather than in a barereturn.The refusal branch — a property that needed no hatch at all. aTokenMintedForAnotherRoomIsRefused was simply never written; see its KDoc for what the
Room.resumesurface can and cannot observe.
What the fixture still cannot detect, said plainly. A harness that could fault-inject and declares FaultInjection.Unsupported anyway is invisible here — there is no capability on RoomFactory to check the claim against, and inventing one would put a knob in the contract no consumer asked for. What the arm does buy is that the claim now exists, is attributable, and carries a URL somebody has to keep alive. The residual is narrower, not gone.
Mutation receipt
JVM, --rerun-tasks (27/27 EXECUTED). Subjects: InMemoryRoomConformanceTest (the reference, 14 tests) and RoomConformanceGapDeclarationTest (6). Real = a defect an implementation could plausibly ship; synthetic = a change made purely to reach an assertion no real defect reaches; rig = a mutation of this suite itself. The "before" column is the measurement of the hole — what the pre-#2306 suite did under the same mutation.
| # | Mutation | Kind | after | before |
|---|---|---|---|---|
| M1 | SeamRoom.resumeToken returns null — a room opting out of resume entirely | real | RED: resumeWithinWindowFiresResumed and aTokenMintedForAnotherRoomIsRefused on the loud precondition, naming role=Joiner … roster=1 member(s) | joinerLearnsHostRoomIdOnAdmission RED — but resumeWithinWindowFiresResumed green by absence |
| M2 | injectorOrDeclaredGap drops its assertion — i.e. the pre-#2306 silent ?: return@runTest, exactly | rig | RED: all four blankTrackingUrl* | all green; the four gated obligations passed under a gap declaring nothing |
| M3 | delete the token.roomId != roomId guard in DefaultJoinerReconnectController.tryResume | real | RED: aTokenMintedForAnotherRoomIsRefused, all 4 assertions — got Success, then a refusal for the genuine token | every pre-existing test of this suite green |
| M4 | the host drops a foreign token silently instead of refusing it | synthetic | RED: aTokenMintedForAnotherRoomIsRefused, 2 of 3 — Got TimedOut | all green |
M1 is the argument for requireResumeToken. The same fabric was already failing joinerLearnsHostRoomIdOnAdmission while resumeWithinWindowFiresResumed returned green without asserting anything — two tests of one suite, opposite verdicts on one room. The early return was not protecting a population; it was hiding a contradiction.
M4 is why the "verdict, not silence" assertion is not decoration. Under it the first assertion — "must not be ResumeResult.Success" — stays green, because TimedOut is not Success; only the second reds. A lone refusal check would have passed a host that never answered at all. (Its third assertion also reds under M4, but as blast radius: the window elapses during the resume timeout. Not an independent diagnosis, and not claimed as one.)
M3's claim is narrower than it looks, and the narrowing matters. It also reds two tests in :kuilt-session's own JoinerReconnectControllerTest, so the defect is not invisible to the tree — only to the contract. That is precisely the thing a second RoomFactory inherits nothing of: an implementation's private suite is not a conformance obligation.
One assertion has no red anywhere, and that is correct: RoomConformanceGapDeclarationTest.aDeclaredGapSkipsEveryGatedObligationCleanly. It does not describe behaviour under test — it is the survivability check, and its falsifying input is a future edit that moves work above the gate (a suspending wait, a links[0] access), not any defect present today. Stating it rather than hiding it: an all-red table would mean the table was measuring blast radius instead of diagnoses.
Types
Whether this harness can break and heal the links under the rooms it builds — and, when it cannot, where that is written down.
A harness that bundles host and joiner RoomFactorys plus its FaultInjection fixture.
Properties
How long awaitRoster / awaitEvent / awaitFrame wait before failing with the state they observed — virtual time, null to wait unbounded.
Fast heartbeat config shared by all tests so virtual-time advancement is cheap. Advancing 4 × 100 ms triggers MembershipEvent.Partitioned; advancing 9 × 100 ms exhausts the reconnect window (PeerLost / HostLost).
Functions
The negative half of joinerLearnsHostRoomIdOnAdmission. That test asserts a joiner's token names the host's room, and its own comment reasons about a room that would "refuse its own members' resumes" — but nothing ever presented a room with a token naming a different room and checked that it said no. Only ResumeResult.Success had a property in this suite; every refusal branch was covered by the reference implementation's private tests (RoomResumeTest, JoinerReconnectControllerTest), which a second RoomFactory inherits nothing from.
A host knows which room it is at construction — Room.roomId is non-null the moment RoomFactory.host returns, with no round trip and nothing to wait for.
A joiner has no identity until it is admitted, then reads the host's — one transition, to the value the host already held.
Provide a fresh RoomHarness for one test, using scope as the coroutine scope for background loops (SeamRoomFactory, FaultyLoom).
Faults only the host's FaultySeam (0) with FaultProfile.DropAll in both directions. The joiner's seam (1) stays Healthy, mirroring us.tractat.kuilt.session.PartitionRoleTest's proven partition/recovery pattern.