Files
lanework/KanbanTests/MotionTests.swift
T
rzen 2fc4020a2b Build the sole-selection attachment carousel
The card face's in-place expansion (03-board-ui.md § Card face): a card
with attachments, as the sole selection, grows a paged media band below
its title; every other state stays compact behind the quiet paperclip.

- CardCarousel owns the pure rules: the sole-selection predicate decides
  by identity (a sole-selected lane matches no face, no snapshot walk),
  and expansion is suppressed outside the animation key while a rubber
  band is active — a band names a set in progress, so carousels neither
  flicker nor animate under it.
- QuickLook thumbnails generate off-main into a per-window cache keyed to
  survive reselection, with the Finder-icon fallback while loading and
  for non-previewable types; pages ride the platform paging behavior,
  dots (glass underlay, solid under Reduce Transparency) click to page,
  and a local wheel monitor turns a discrete tick into one clamped page —
  precise trackpad pans fall through untouched.
- The expansion animates under Motion's new carouselExpansion transaction
  keyed narrowly on the sole-selected card; Reduce Motion goes instant.
  Drop-slot math and the marquee read the expanded height for free — both
  re-register on every size change.

928 unit tests (24 new). m5-interactions complete.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 00:21:13 -04:00

164 lines
9.2 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))
}
/// **The carousel expansion is the structural voice** (03-board-ui.md § Motion's first named
/// narrow key). What the change *is* is positional — the card grows and its column-mates slide
/// down — so it takes the general snappy spring rather than the filtering one. Pinned as an
/// identity with `structural` rather than as a literal, because if the two ever diverge it
/// should be a deliberate edit to one line.
@Test func theCarouselExpansionIsTheStructuralVoice() {
#expect(Motion.carouselExpansion(reduced: false) == Motion.structural(reduced: false))
#expect(Motion.carouselExpansion(reduced: false) != Motion.contentReflow(reduced: false))
}
}
// 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)
#expect(Motion.carouselExpansion(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)
}
/// **The carousel is the one appearance with no reduced variant, because it is already the
/// reduced form.** 03 fixes an appear/disappear scale for cards and lanes and for nothing else,
/// and the carousel needs none: the movement is the card growing, which `carouselExpansion`
/// animates and Reduce Motion makes instant, so the content only has to fade in over it. This
/// asserts the parameterless accessor is deliberate rather than an omission.
@Test func theCarouselAppearanceIsTheCrossfadeInBothVariants() {
#expect(Motion.carouselAppearance == .crossfade)
#expect(Motion.carouselAppearance != Motion.cardAppearance(reduced: false))
}
}