What will you build?
A splash screen has one job: play an animation, then hand control to the host. The splash you write here does that with a safety net. When the splash appears it arms a timer; when the animation ends, or when the timer fires, the feature tells its host that the splash completed and by which path. Both paths notify the host every time they fire, because the host treats a repeat as a no-op, and a secondAppeared is inert so the timer is armed once per mount. That is the whole behavior, and it fits in one state field, three actions and two effect payloads.
You will have at the end:
src-kmp/subtrees/splash/logic, a Kotlin Multiplatform module with the feature’s types, its reducer, its serializers, its environment interface and its effect handler.- Four recordings under
parity/fixtures/:splash.ceremony-completes,splash.safety-net-fires,splash.both-paths-notify-twiceandsplash.repeat-appear-inert. - A green
tools/duet verify: the four recordings replay against the reducer, and the safety net’s timing holds on a virtual clock. - A replay runner the
duettool drives over a protocol, which Tutorial 6 puts in CI.
Where do you start?
Clone the duet-tutorials repository and opentutorial1-start. It is a Kotlin Multiplatform project with no feature yet: a Gradle wrapper, a version catalog pinning Duet 0.7.0 and Kotlin 2.4.10, an empty parity manifest, and tools/duet, which fetches the duet command-line tool at version 0.24.0 on first use. Every path on this page is relative to that directory. Expect about an hour; the first Gradle run downloads the wrapper’s distribution and is the slowest step. Confirm the tree is green before you edit anything:
duet verify: PASS with a note that no feature is declared. The finished tree is tutorial1-complete in the same repository; every code block on this page is an excerpt of it, named by its path.
The steps
Declare the feature module
src-kmp/subtrees/<feature>/logic. Its build file declares one JVM target, where the tests run, and three Apple targets, which compile the same sources for the iOS app in Tutorial 2. The module depends on the Duet kernel and on kotlinx.serialization; its tests add the kernel’s test harness and the coroutines test library. Create the file:parity/fixtures as an input of the test task, so that a change to a recording re-runs the replays instead of leaving the task up to date. Then include the module in the settings file:(cd src-kmp && ./gradlew projects -q); the project hierarchy ends with \--- Project ':subtrees:splash:logic'.Write the state, the actions and the effect payloads
path says which of the two ways completed the splash. Every type is @Serializable, because the recordings are JSON.splash.safetyNet is what gives the timer cancel-in-flight semantics: the kernel’s Store cancels a running effect when a new one starts under the same id, and cancels every running effect at teardown.Write the reducer
Appeared arms the net once, and each completion path asks for a notification.CeremonyFinished does not cancel the net and does not set a “completed” flag: if the timer fires after the animation ended, the host is notified a second time, and the host’s job is to ignore it. Keeping the rule in one place, the host, is what lets the reducer stay three arms long, and the recordings in step 7 pin that rule so nobody adds a latch later by accident.Register the serializers
case and, when the case carries data, a value. The kernel’s CanonicalSumSerializer takes one registry line per case; a case you forget fails at the first encode rather than producing a fixture that quietly disagrees with the Swift side later. A case whose single payload is unlabeled, such as NotifyHost(event), is marked inline so the payload encodes bare.SplashCompletionPath and SplashDelegateEvent the same way.Add the environment and the runtime
delay call, so a test can run the wait on virtual time.ArmSafetyNet sleeps and then emits SafetyNetElapsed; NotifyHost calls the environment and emits nothing. Cancellation needs no code of its own: when the store cancels the effect, the sleep throws and the flow ends without emitting. The store factory below it is what both shells call.(cd src-kmp && ./gradlew :subtrees:splash:logic:compileKotlinJvm); the last line reads BUILD SUCCESSFUL.Declare the feature in the manifest and write its spec
parity/manifest.yaml is what the duet tool reads: which file holds the feature, which test is its scenario, and which recordings it owns. Add the feature under features:.parity/feature-specs/splash.md is the one-page description of the feature in prose: identity and config, state, actions, transitions, effects, delegate events, what stays app-side, and the recordings. The tool checks that every recording listed in the manifest is named in that file in backticks, so the prose cannot drift from the fixtures without a red check. Copy the finished tree’s file, or write your own from its headings; its last section is the table below.tools/duet lint. It fails, and the failure names the next step:Describe the behavior as a scenario and record it
whenAction steps, and then and thenEffects checks. Where two endings are mutually exclusive, they are branches over the same given, and each branch leaf becomes one recording. The splash has one given, the appeared splash, and four branches.scenario(feature = "splash", description = …, source = …) call, and the test ends by handing the scenario, the three serializers and the reducer to ScenarioRunner.verifyOrRecord. Run as an ordinary test it verifies; run by the duet tool with regeneration on, it records. Record it:duet record: rewrote 4 fixture(s): followed by the four paths. Open one. Each step holds the action, the expected state and the expected effects as canonical JSON, plus the label and the source line it came from:Replay the recordings
duet tool checks that every recording listed in the manifest reported a replay.duet verify: 4/4 fixture report(s) passed and ends with duet verify: PASS. The verify command runs the manifest checks first, then the Kotlin lane, which is the module’s jvmTest task; the manifest’s Swift lane is skipped because no feature declares a swift: path. The Apple boundary lane, which replays the same recordings across the framework the iOS app consumes, arrives in Tutorial 2.Pin the safety net's timing on a test clock
LiveClock, which under runTest suspends on virtual time.Appeared.TestStore is exhaustive: finish() fails if an action arrived that the test never received, or if an effect is still running. That is how the “exactly once” test works with no counter: a second SafetyNetElapsed would be an unreceived action. Run tools/duet verify again; it ends with duet verify: PASS. Then run the regeneration gate, which CI uses to catch a scenario edited without re-recording:duet record --check: fixtures are up to date with their scenarios.Add the replay runner
duet tool sends each recorded step to it and compares the bytes that come back. It is how the same recordings are checked from outside the test JVM, and Tutorial 6 puts it in the CI workflow. It is a registry with one entry per feature:application plugin, and depends on the kernel and on the feature module:4 leaf + 0 chain fixture(s), 9 step(s) byte-gated CLI-side and ends with protocol-run: PASS.What you now have
- One feature module,
src-kmp/subtrees/splash/logic, with the behavior in fourcommonMainfiles: the types and the reducer, the serializers, the environment interface and the runtime. - Four recordings under
parity/fixtures/, each a JSON file the reducer replays byte for byte. - Nine passing tests on the Kotlin lane: the scenario, four golden replays and four test-clock checks.
- A green
tools/duet verify, a greentools/duet record --check, and a replay runner that passestools/duet protocol-run. - A manifest row and a feature spec that the tool cross-checks against the recordings.
Exercise: break the arming guard
The recordingsplash.repeat-appear-inert pins that a second Appeared does nothing. Delete the guard and watch the recording catch it. In SplashFeature.kt, replace the Appeared arm’s if/else with the else branch’s body alone, so every Appeared arms a net. Run tools/duet verify. The Kotlin lane fails on one recording, and the report names the step, the expected effects and what the reducer emitted instead:
tools/duet verify again; it ends with duet verify: PASS. A recording is a test you did not have to write twice: the same file fails the Apple boundary lane in Tutorial 2 if the framework the iOS app consumes ever disagrees.
Common questions
Why does the reducer copy the duration into the payload instead of the handler knowing it?
Why does the reducer copy the duration into the payload instead of the handler knowing it?
expectedEffects in every fixture carries afterMillis: 3000; a change to SplashConfig.SAFETY_NET_MILLIS shows up as a diff in four JSON files that a reviewer sees. A duration hidden in the handler would be invisible to the recordings and to the Apple boundary lane.Why does CeremonyFinished not cancel the safety net?
Why does CeremonyFinished not cancel the safety net?
splash.both-paths-notify-twice pins the second notification, so a future edit that adds a cancel or a latch fails a check instead of silently changing the contract with the host.What is the difference between the scenario test and the golden test?
What is the difference between the scenario test and the golden test?
tools/duet record, it writes the fixture files; run as a plain test, it checks them. The golden test only replays what is on disk, one method per recording, and is what a second platform runs against the same files. Both stay in the tree so that a recording can be regenerated from its scenario and checked without it.Why is the test double hand-written?
Why is the test double hand-written?
RecordingSplashEnvironment is nine lines and lives in test sources, which is the rule for every test double in a Duet tree. Tutorial 3 replaces hand-written doubles with the family’s generated mocks once there are several environments to double; for one interface with two members, the class is shorter than the generator wiring.Why does the build file declare Apple targets when there is no iOS app yet?
Why does the build file declare Apple targets when there is no iOS app yet?
commonMain sources compile to an Apple framework in Tutorial 2, and declaring the targets now means the module’s shape does not change when the app arrives. Nothing on this page builds them: tools/duet verify runs the JVM target only.Sources and further reading
- The Duet framework repository — the kernel’s
Store,EffectandReducedtypes, thekernel-testharness this page’s tests use, and the contracts for the store kernel and serialization. - The duet-tools repository — the
duetcommand-line tool and the manifest grammar. - The duet-tutorials repository —
tutorial1-startandtutorial1-complete, and the checks CI runs on them. - kotlinx-coroutines-test —
runTest,advanceTimeByand the virtual clock the test-clock suite runs on. - Kotlin serialization — the
@Serializableand@SerialNameannotations on every recorded type. - The Duet glossary — feature, reducer, scenario, behavior recording and the checks.