The committed placeholder's lane slot now takes the arriving card's identity — .awaitingArrival keys as card:<id>, so when the echo reload lands the ForEach sees one persisting element whose content swaps from stub to card face instead of a removal and an insertion with two scale+fade transitions (DESIGN/02 > overlays: the handoff must read as one arrival). The awaiting face renders the card's exact chrome from shared CardFaceMetrics, so any residual branch crossfade is between pixel-identical renderings; the editing phase keeps its constant key so typing identity holds. Slot position math is untouched. CreateHandoffIdentityTests pins the key derivation. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
254 lines
14 KiB
Swift
254 lines
14 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))
|
|
}
|
|
|
|
/// **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<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))
|
|
}
|
|
}
|