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)) } /// **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)) } } // 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 = [ 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)) } }