Files
lanework/KanbanTests/MotionTests.swift
T
rzen 1020d9fca4 Remove the face carousel — one card presentation
03's resettlement reverses the pathfinder carry-over: the
selection-keyed dual presentation proved undesirable, so a card has one
presentation — selection changes styling, never geometry, and the
masonry never reflows on click. Deleted the carousel view (page dots,
glass underlay, scroll-tick monitor), the QuickLook thumbnail cache
(sole consumer), the pure paging/suppression rules, and the
sole-selected animation key — Motion now keys transactions on the
search query and the drop proposal only. The attachment chip stays as
the face's whole attachment story; viewing media is the card window's
job. No carousel state had leaked beyond the view layer.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 08:11:59 -04:00

233 lines
13 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.
/// 4. **Which changes are not motion at all.** The create handoff has to "read as one arrival"
/// (02-architecture.md ▸ TransientBoardState ▸ overlays), and it achieves that by *removing* an
/// animation rather than adding one — the placeholder adopts the arriving card's `ForEach`
/// identity, so no transition has anything to fire on. The claim is a pure function of a
/// placeholder phase (`LaneSlot.identity(ofPlaceholderIn:)`), so it is pinned here rather than
/// watched for.
// 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)
}
}
// MARK: - The create handoff
/// **"The handoff must read as one arrival"** (02-architecture.md ▸ TransientBoardState ▸ overlays,
/// settled 2026-07-28): the new-card placeholder "is handed off by discarding itself the moment the
/// created card's UUID appears in a snapshot", and "the placeholder renders at the arriving card's
/// exact geometry/chrome".
///
/// The mechanism is `LaneSlot`'s identity function and nothing else — no `matchedGeometryEffect`, no
/// second transaction, no suppression flag. A committed placeholder is keyed by the *arriving card's*
/// slot id, so when the echo reload replaces the pseudo-card with the real one the `ForEach` element
/// is neither inserted nor removed: only its content changes, and `Motion.cardTransition` — attached
/// per slot in the lane's masonry — has nothing to run on. Two scale-and-fades become none.
///
/// That is the whole of what is unit-testable about it. The masonry is a `Layout`, the transition is
/// an opaque `AnyTransition`, and neither renders here — but the key derivation those two depend on
/// is a pure function of a phase, and it is the line that would have to break for the handoff to go
/// back to being two events.
struct CreateHandoffIdentityTests {
private let arriving = ItemID(rawValue: "6C1D4B4E-0000-4000-8000-00000000A001")
/// **The claim, stated as an equality.** The committed placeholder's key *is* the arriving card's
/// key — not a parallel spelling of it, the same function's answer — so nothing can drift the two
/// apart without failing here.
@Test func aCommittedPlaceholderIsKeyedAsTheArrivingCard() {
#expect(
LaneSlot.identity(ofPlaceholderIn: .awaitingArrival(arriving))
== LaneSlot.identity(of: arriving)
)
#expect(LaneSlot.placeholder(.awaitingArrival(arriving)).id == LaneSlot.identity(of: arriving))
}
/// The other half of the handoff, from the slot the real card builds: the two `LaneSlot` cases
/// that stand for one card at two moments answer with one id, which is what makes them one
/// element to `ForEach` rather than two.
@Test func theArrivingCardAndItsPlaceholderAreOneElement() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let card = try #require(snapshot.lanes.flatMap(\.cards).first)
#expect(LaneSlot.card(card).id == LaneSlot.placeholder(.awaitingArrival(card.id)).id)
}
/// **The editing phase keeps the constant key**, and that is not an oversight the handoff missed:
/// an open editor has no identity to adopt yet, and a key that changed while the user typed would
/// tear the field down mid-word and take its keyboard focus with it. The key changes exactly once,
/// at commit.
@Test func anOpenEditorKeepsItsConstantKey() {
#expect(LaneSlot.identity(ofPlaceholderIn: .editing) == LaneSlot.editingPlaceholderIdentity)
#expect(LaneSlot.placeholder(.editing).id == LaneSlot.editingPlaceholderIdentity)
// And it is emphatically *not* any card's key — that is the whole point of the one change.
#expect(LaneSlot.identity(ofPlaceholderIn: .editing) != LaneSlot.identity(of: arriving))
}
/// The keys stay disjoint by construction — a shadow is never mistaken for the card arriving into
/// the slot it opened, and the editing placeholder is never mistaken for either.
@Test func theSlotNamespacesDoNotCollide() {
let keys: Set<String> = [
LaneSlot.identity(of: arriving),
LaneSlot.editingPlaceholderIdentity,
LaneSlot.shadow(index: 0, height: 44).id,
LaneSlot.shadow(index: 1, height: 44).id,
]
#expect(keys.count == 4)
}
/// **Reduce Motion needs no branch of its own here.** Identity continuity introduces no motion to
/// reduce: the key is the same key in both variants, so the reduced path reads as one arrival for
/// exactly the reason the standard path does — a transition that never fires has no variant to
/// choose between. Asserted as the absence of a parameter, which is the shape the claim takes.
@Test func theHandoffIsIdenticalUnderReduceMotion() {
// The derivation takes no `reduced:` flag at all, and the transition it defeats has a reduced
// variant that is equally irrelevant while the element persists.
#expect(LaneSlot.identity(ofPlaceholderIn: .awaitingArrival(arriving)) == LaneSlot.identity(of: arriving))
#expect(Motion.cardAppearance(reduced: true) == .crossfade)
#expect(Motion.cardAppearance(reduced: false) == .scaleAndFade(from: 0.8))
}
}