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
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user