Files
lanework/KanbanTests/MotionTests.swift
T
rzen 5ebf9fb90c Implement the motion language
One named home for every curve and duration — the two-voice split 03
fixes (snappy structural: drag reflow 0.18, delete 0.25, lane resize
0.2; smooth content-reflow 0.28), the scale-and-fade appear/disappear
transitions (cards 0.8, lanes 0.9), and a Reduce Motion variant on
every accessor (animations go instant, transitions go crossfade). A
post-migration grep holds the invariant: zero motion literals outside
Motion.swift. The animate-vs-snap split lands where Lanework's
one-way flow puts it: the store's reload seam. An app-mediated echo
applies its snapshot inside the structural transaction; foreign and
reconciling reloads — and every bracket-ending wholesale reload —
assign bare, because live-reload is the board becoming what's on
disk, not an event to perform. A window that merged foreign events
into an app-mediated span animates, deliberately: the ratified merge
rule makes it indistinguishable from a pure echo, and a test pins
that reading so a future mixed case fails loudly. The lane-reorder
reflow keys on the drop proposal alone (pointer tracking stays 1:1),
the resize session freezes its Reduce Motion answer at drag start so
the unit tick and the window resize can never disagree, and the m5
drag / m5 search / m7 undo voices are named seams waiting for their
call sites. 13 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 15:59:59 -04:00

143 lines
7.7 KiB
Swift

import SwiftUI
import Testing
@testable import Kanban
/// The motion language's testable half (03-board-ui.md § Motion, 10-accessibility.md ▸ Reduce
/// Motion). Animation itself is not unit-testable — nothing here renders — so what these pin is the
/// *contract* the surface makes to its call sites:
///
/// 1. **Which reloads perform.** `Motion.reloadAnimates` is the whole of "user-initiated structural
/// changes animate; foreign changes snap" in Lanework's one-way flow, and it is a pure function
/// of an origin and a flag precisely so it can be pinned here rather than watched for.
/// 2. **Which variant Reduce Motion gets.** 10-accessibility.md's commitment is "crossfade or
/// instant", and both halves are assertable: an animation's reduced variant is `nil`, a
/// transition's is the opacity-only crossfade (`Motion.Appearance`, which exists so this claim is
/// testable at all — `AnyTransition` is opaque).
/// 3. **The named durations.** 03 fixes five figures by name; a silent drift in one of them would be
/// invisible in every other test in the suite.
// MARK: - Which reloads perform
/// The store seam's decision, over every origin the watcher can deliver.
struct ReloadVoiceTests {
/// The rule's positive half: an app-mediated reload is the tail of something the user did, and
/// it is the *only* origin that performs.
@Test func anAppMediatedReloadAnimates() {
#expect(Motion.reloadAnimates(origin: .appMediated, endsBracketedOperation: false))
}
/// "Changes arriving through the watcher — agent edits, hand edits, sync, external git — apply
/// instantly with no transition: live-reload is the board becoming what's on disk, not an event
/// to perform."
@Test func aForeignReloadSnaps() {
#expect(!Motion.reloadAnimates(origin: .foreign, endsBracketedOperation: false))
}
/// A reconciling sweep — wake, activation, a missed-events flag — makes no claim to be anyone's
/// gesture. Usually it lands a value-equal snapshot and nothing moves at all; when it does not,
/// what it found is a change that already happened, not one to perform.
@Test func aReconcilingReloadSnaps() {
#expect(!Motion.reloadAnimates(origin: .reconciling, endsBracketedOperation: false))
}
/// A bracketed wholesale operation's closing reload snaps **whatever its origin** — a pull, a
/// branch switch, a wholesale rewrite can leave the board a different tree, and 10 gives those
/// one announcement at completion rather than a performance of their churn.
@Test func aBracketedWholesaleReloadSnapsWhateverItsOrigin() {
for origin in [WatchOrigin.appMediated, .foreign, .reconciling] {
#expect(
!Motion.reloadAnimates(origin: origin, endsBracketedOperation: true),
"a bracketed wholesale reload from \(origin.rawValue) should snap"
)
}
}
/// **The merged-origin case, pinned because it is not obvious.** Origins coalesce by precedence
/// and the merge is lossy: a foreign edit folding into an app-mediated span yields
/// `.appMediated`, so a window that mixes both is indistinguishable from a pure echo and
/// animates. This test exists to make that a decision rather than a surprise — if the merge rule
/// ever grows a "mixed" case, this is what should fail.
@Test func aWindowMixingForeignIntoAnAppMediatedSpanStillAnimates() {
let merged = WatchOrigin.merged(.appMediated, .foreign)
#expect(merged == .appMediated)
#expect(Motion.reloadAnimates(origin: merged, endsBracketedOperation: false))
}
/// The other merge, the other way: reconciling outranks app-mediated, so a sweep folding over an
/// echo snaps. The stronger claim wins, and the stronger claim here is "assume nothing".
@Test func aReconcilingSweepFoldingOverAnEchoSnaps() {
let merged = WatchOrigin.merged(.appMediated, .reconciling)
#expect(merged == .reconciling)
#expect(!Motion.reloadAnimates(origin: merged, endsBracketedOperation: false))
}
/// The wrapper the store actually calls: the same decision, plus the voice and the Reduce Motion
/// variant. Every snapping path is `nil`, and so is the reduced form of the animating one — which
/// is what makes `withAnimation` at the seam need no branch of its own.
@Test func theStoreSeamsWrapperYieldsTheStructuralVoiceOnlyWhereItPerforms() {
#expect(Motion.reloadAnimation(origin: .appMediated, endsBracketedOperation: false, reduced: false)
== Motion.structural(reduced: false))
#expect(Motion.reloadAnimation(origin: .appMediated, endsBracketedOperation: false, reduced: true) == nil)
#expect(Motion.reloadAnimation(origin: .foreign, endsBracketedOperation: false, reduced: false) == nil)
#expect(Motion.reloadAnimation(origin: .reconciling, endsBracketedOperation: false, reduced: false) == nil)
#expect(Motion.reloadAnimation(origin: .appMediated, endsBracketedOperation: true, reduced: false) == nil)
}
}
// MARK: - The named curves
/// The five durations 03-board-ui.md § Motion fixes, and the two voices they are spoken in.
struct MotionCurveTests {
@Test func theStructuralVoiceIsTheSnappySpringAtItsNamedDurations() {
#expect(Motion.structural(reduced: false) == .snappy(duration: 0.2))
#expect(Motion.dragReflow(reduced: false) == .snappy(duration: 0.18))
#expect(Motion.delete(reduced: false) == .snappy(duration: 0.25))
#expect(Motion.laneResize(reduced: false) == .snappy(duration: 0.2))
}
/// The content-reflow voice is a *different preset*, not the structural one slowed down — that
/// is the whole of "two curves, semantically split", and it is why search filtering and an undo
/// restore read alike and neither reads like a drop.
@Test func theContentReflowVoiceIsTheSmoothSpring() {
#expect(Motion.contentReflow(reduced: false) == .smooth(duration: 0.28))
#expect(Motion.contentReflow(reduced: false) != Motion.structural(reduced: false))
}
/// The window's half of the lane resize has to be the same figure as the units' half, or the
/// window edge and the lanes inside it stop travelling as one (`LaneResizeSession`).
@Test func theWindowResizeMatchesTheLaneResizeDuration() {
#expect(Motion.laneResizeWindowDuration == 0.2)
#expect(Motion.laneResize(reduced: false) == .snappy(duration: Motion.laneResizeWindowDuration))
}
}
// MARK: - Reduce Motion
/// 10-accessibility.md's "crossfade or instant", by kind: an animation has no crossfade available,
/// so its reduced variant is instant; a transition does, so its reduced variant is the crossfade.
struct ReduceMotionVariantTests {
@Test func everyAnimationsReducedVariantIsInstant() {
#expect(Motion.structural(reduced: true) == nil)
#expect(Motion.dragReflow(reduced: true) == nil)
#expect(Motion.delete(reduced: true) == nil)
#expect(Motion.laneResize(reduced: true) == nil)
#expect(Motion.contentReflow(reduced: true) == nil)
}
/// "Appear/disappear is scale + fade (cards scale from ~0.8, lanes ~0.9, combined with opacity)."
@Test func appearanceIsScaleAndFadeAtTheTwoNamedScales() {
#expect(Motion.cardAppearance(reduced: false) == .scaleAndFade(from: 0.8))
#expect(Motion.laneAppearance(reduced: false) == .scaleAndFade(from: 0.9))
}
/// The scale is what drops under Reduce Motion; the fade is what stays. A reduced user still
/// sees the item leave — it just does not travel to do it.
@Test func everyTransitionsReducedVariantIsTheCrossfade() {
#expect(Motion.cardAppearance(reduced: true) == .crossfade)
#expect(Motion.laneAppearance(reduced: true) == .crossfade)
}
}