RoomConformanceSuite

abstract class 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):

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 compares resumeToken?.roomId against 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 — null moves 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 bare return.

  • The refusal branch — a property that needed no hatch at all. aTokenMintedForAnotherRoomIsRefused was simply never written; see its KDoc for what the Room.resume surface 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.

#MutationKindafterbefore
M1SeamRoom.resumeToken returns null — a room opting out of resume entirelyrealRED: resumeWithinWindowFiresResumed and aTokenMintedForAnotherRoomIsRefused on the loud precondition, naming role=Joiner … roster=1 member(s)joinerLearnsHostRoomIdOnAdmission RED — but resumeWithinWindowFiresResumed green by absence
M2injectorOrDeclaredGap drops its assertion — i.e. the pre-#2306 silent ?: return@runTest, exactlyrigRED: all four blankTrackingUrl*all green; the four gated obligations passed under a gap declaring nothing
M3delete the token.roomId != roomId guard in DefaultJoinerReconnectController.tryResumerealRED: aTokenMintedForAnotherRoomIsRefused, all 4 assertions — got Success, then a refusal for the genuine tokenevery pre-existing test of this suite green
M4the host drops a foreign token silently instead of refusing itsyntheticRED: aTokenMintedForAnotherRoomIsRefused, 2 of 3 — Got TimedOutall 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.

Constructors

Link copied to clipboard
constructor()

Types

Link copied to clipboard
sealed interface FaultInjection

Whether this harness can break and heal the links under the rooms it builds — and, when it cannot, where that is written down.

Link copied to clipboard
data class RoomHarness(val hostFactory: RoomFactory, val joinerFactory: RoomFactory, val faults: RoomConformanceSuite.FaultInjection, val clock: () -> Instant, val advanceClock: (Long) -> Unit)

A harness that bundles host and joiner RoomFactorys plus its FaultInjection fixture.

Properties

Link copied to clipboard

How long awaitRoster / awaitEvent / awaitFrame wait before failing with the state they observed — virtual time, null to wait unbounded.

Link copied to clipboard

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

Link copied to clipboard

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.

Link copied to clipboard
fun hostFactoryAssignsHostRole(): TestResult
Link copied to clipboard
Link copied to clipboard

A host knows which room it is at constructionRoom.roomId is non-null the moment RoomFactory.host returns, with no round trip and nothing to wait for.

Link copied to clipboard

A joiner has no identity until it is admitted, then reads the host's — one transition, to the value the host already held.

Link copied to clipboard
Link copied to clipboard
open fun newHarness(scope: CoroutineScope): RoomConformanceSuite.RoomHarness

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.

Link copied to clipboard
fun rejoinAfterLeaveWorks(): TestResult
Link copied to clipboard
Link copied to clipboard