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)) } }