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
This commit is contained in:
2026-07-28 00:21:13 -04:00
parent 6c490ec71c
commit 2fc4020a2b
9 changed files with 1111 additions and 17 deletions
+356
View File
@@ -0,0 +1,356 @@
import Foundation
import Testing
@testable import Kanban
/// The sole-selection attachment carousel's rules (03-board-ui.md § Card face, § Motion;
/// 10-accessibility.md Reduce Transparency).
///
/// **What is testable here is the whole of what was written to be.** A thumbnail is a round trip to
/// a QuickLook extension in another process, a page slide is a scroll view under a pointer, and a
/// glass underlay is pixels none of that is a unit test. What *is* one is every decision that
/// precedes them: who expands, when the rubber band suppresses it, what the pages are and in what
/// order, what a cached thumbnail is filed under, and which way a wheel tick moves. Those are pure
/// functions precisely so this file can exist.
///
/// The board underneath is a real load off a real temp tree, `NewCardTargetTests`' posture and for
/// its reason: the ordering these assertions make claims about is the *loader's*
/// (`localizedStandardCompare`, 01-storage-format.md § Attachments), and a hand-built `Card` would
/// let this file agree with itself while disagreeing with the app.
// MARK: - Fixtures
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
/// A card with no `title` key at all the untitled placeholder's card, which 03 gives the carousel
/// on exactly the same terms as any other.
private func untitled(order: String) -> String {
"""
---
schema: 1
order: \(order)
---
Body without a title.
"""
}
/// One lane, three cards: one with attachments whose names sort by Finder's rule rather than by
/// ASCII, one with none, and one untitled but attached.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
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: "Attached"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Bare"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", untitled(order: "3072"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
// Finder order is `localizedStandardCompare`, so "shot2" precedes "shot10" the one ordering a
// plain lexicographic sort gets wrong, which is why these are the names.
for name in ["shot10.png", "shot2.png", "notes.txt"] {
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(name)", Data("x".utf8))
}
try fixture.file("\(Ident.lane1)/\(Ident.card3)/attachments/only.pdf", Data("x".utf8))
return fixture
}
private func card(_ id: ItemID, in snapshot: BoardModel) throws -> Card {
try #require(snapshot.lanes.flatMap(\.cards).first { $0.id == id })
}
private func live(_ ids: ItemID...) -> ItemReferenceSet {
ItemReferenceSet(ids: Set(ids), liveness: .live)
}
// MARK: - Who expands
@MainActor
@Suite("CardCarousel ▸ the sole-selection rule")
struct CardCarouselSelectionTests {
/// The positive case, stated on its own: one live card, and it is the key.
@Test("A sole live selection is the key")
func aSoleLiveSelectionIsTheKey() {
#expect(CardCarousel.soleSelection(live(card1)) == card1)
}
/// "Multi-selections and unselected cards stay compact" both halves, and the reason the
/// predicate reads `count == 1` rather than `count >= 1`.
@Test("Nothing selected, and more than one thing selected, expand nothing")
func multiAndEmptySelectionsExpandNothing() {
#expect(CardCarousel.soleSelection(.empty) == nil)
#expect(CardCarousel.soleSelection(live(card1, card2)) == nil)
#expect(CardCarousel.soleSelection(live(card1, card2, card3)) == nil)
}
/// **Side.** The carousel is a *card face* surface, and a tombstoned card has no face it is
/// one row in the trash column (03-board-ui.md § Trash). A sole trashed selection is therefore
/// not a smaller carousel; it is none.
@Test("A sole trashed selection expands nothing")
func aSoleTrashedSelectionExpandsNothing() {
#expect(CardCarousel.soleSelection(ItemReferenceSet(ids: [card1], liveness: .trashed)) == nil)
}
/// **Kind, settled by identity rather than by a snapshot walk.** A sole-selected *lane* is a
/// perfectly good sole selection and this function says so it returns the lane's id, which
/// then matches no card face on the board, so nothing expands. The claim under test is that the
/// two steps compose to the right answer, because that composition is what saved every rendered
/// card a walk of the snapshot.
@Test("A sole-selected lane matches no card face")
func aSoleSelectedLaneMatchesNoCardFace() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let attached = try card(card1, in: snapshot)
let sole = CardCarousel.soleSelection(live(lane1))
#expect(sole == lane1)
#expect(!CardCarousel.expands(attached, expanded: sole))
}
/// The face's own half of the rule: it is the whole selection **and** it has files.
@Test("A card expands only when it is the selection and has attachments")
func aCardExpandsOnlyWhenSelectedAndAttached() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let attached = try card(card1, in: snapshot)
let bare = try card(card2, in: snapshot)
#expect(CardCarousel.expands(attached, expanded: card1))
// "The compact face keeps its quiet paperclip chip unchanged" a card with no files has
// nothing to page through, however it is selected.
#expect(!CardCarousel.expands(bare, expanded: card2))
// Somebody else's selection.
#expect(!CardCarousel.expands(attached, expanded: card2))
#expect(!CardCarousel.expands(attached, expanded: nil))
}
/// **An untitled card gets the carousel on identical terms** 03 makes titles optional at every
/// level and qualifies nothing that reads them, so the placeholder sits above and the pages sit
/// below exactly as they would for a titled card.
@Test("An untitled card expands like any other")
func anUntitledCardExpandsLikeAnyOther() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let untitledCard = try card(card3, in: snapshot)
#expect(untitledCard.title.value == nil)
#expect(CardCarousel.expands(untitledCard, expanded: card3))
}
}
// MARK: - The marquee suppression
@MainActor
@Suite("CardCarousel ▸ the rubber band's suppression")
struct CardCarouselMarqueeTests {
/// The ruling filed for ratification, as a pure function of the two things it reads: a band in
/// flight expands nothing, whatever the selection currently says.
@Test("A band in flight suppresses every expansion")
func aBandInFlightSuppressesEveryExpansion() {
#expect(CardCarousel.expanded(live(card1), marqueeActive: false) == card1)
#expect(CardCarousel.expanded(live(card1), marqueeActive: true) == nil)
#expect(CardCarousel.expanded(.empty, marqueeActive: true) == nil)
#expect(CardCarousel.expanded(live(card1, card2), marqueeActive: true) == nil)
}
/// **The band never enters the animation key** (03-board-ui.md § Motion's
/// animation-free-by-construction list, applied to the one surface a band's churn could
/// otherwise animate). The key is the selection alone, so beginning and ending a band change
/// what renders without changing what the transaction is keyed on no transaction, nothing
/// eases. This is the assertion that would fail if the two answers were ever collapsed into one.
@Test("Suppression changes what renders, never the animation key")
func suppressionNeverMovesTheAnimationKey() {
let selection = live(card1)
#expect(CardCarousel.soleSelection(selection) == card1)
#expect(CardCarousel.expanded(selection, marqueeActive: true) == nil)
// The key is untouched by the band by construction: it does not take the flag at all.
#expect(CardCarousel.soleSelection(selection) == card1)
}
}
// MARK: - The pages
@MainActor
@Suite("CardCarousel ▸ pages")
struct CardCarouselPageTests {
/// One page per attachment, **in the order the loader produced** Finder's, which is why
/// `shot2` precedes `shot10` and why nothing here sorts.
@Test("Pages are one per attachment, in the loaded Finder order")
func pagesFollowTheLoadedOrder() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let attached = try card(card1, in: snapshot)
#expect(attached.attachments == ["notes.txt", "shot2.png", "shot10.png"])
let pages = CardCarousel.pages(of: attached, boardRoot: fixture.root, laneID: lane1)
#expect(pages.count == attached.attachments.count)
#expect(pages.map(\.name) == attached.attachments)
#expect(pages.map(\.id) == attached.attachments)
}
/// Each page points at the file the fractal layout puts it in
/// `<root>/<lane>/<card>/attachments/<name>` (01-storage-format.md § Fractal layout).
@Test("Each page points at the file under the card's attachments folder")
func eachPagePointsAtItsFile() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let attached = try card(card1, in: snapshot)
let pages = CardCarousel.pages(of: attached, boardRoot: fixture.root, laneID: lane1)
let folder = CardCarousel.attachmentsFolder(boardRoot: fixture.root, laneID: lane1, cardID: card1)
#expect(folder.lastPathComponent == "attachments")
for page in pages {
#expect(page.url == folder.appendingPathComponent(page.name))
#expect(FileManager.default.fileExists(atPath: page.url.path))
}
}
/// A card with no files pages through nothing the same answer `expands` gives, reached
/// independently, so a face that somehow rendered a carousel would render an empty one rather
/// than crash on a first page.
@Test("A card with no attachments has no pages")
func aBareCardHasNoPages() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let bare = try card(card2, in: snapshot)
#expect(CardCarousel.pages(of: bare, boardRoot: fixture.root, laneID: lane1).isEmpty)
}
/// The folder is built from the ids, so the same card in a different lane resolves to a
/// different path which is what makes the lane a parameter rather than something this type
/// could guess.
@Test("The attachments folder is the card's own, lane included")
func theAttachmentsFolderIsTheCardsOwn() {
let root = URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true)
let here = CardCarousel.attachmentsFolder(boardRoot: root, laneID: lane1, cardID: card1)
let there = CardCarousel.attachmentsFolder(boardRoot: root, laneID: lane2, cardID: card1)
#expect(here != there)
#expect(here.pathComponents.suffix(3) == [lane1.rawValue, card1.rawValue, "attachments"])
}
}
// MARK: - Paging arithmetic
@Suite("CardCarousel ▸ paging arithmetic")
struct CardCarouselPagingTests {
/// The wheel's sign, which is AppKit's: a scroll view advances by *subtracting* the delta, so a
/// negative delta pages forward.
@Test("A wheel tick pages in the direction the delta names")
func aWheelTickPagesInTheDeltaSDirection() {
#expect(CardCarousel.wheelStep(deltaX: 0, deltaY: -3) == 1)
#expect(CardCarousel.wheelStep(deltaX: 0, deltaY: 3) == -1)
}
/// A tick carrying nothing moves nothing and, in the view, is handed back to the scroll view
/// rather than eaten.
@Test("A tick with no delta pages nothing")
func anEmptyTickPagesNothing() {
#expect(CardCarousel.wheelStep(deltaX: 0, deltaY: 0) == 0)
}
/// A horizontal tick a tilt wheel, or with a plain one wins, because it names this
/// carousel's own axis.
@Test("A horizontal tick outranks a vertical one")
func aHorizontalTickOutranksAVerticalOne() {
#expect(CardCarousel.wheelStep(deltaX: -5, deltaY: 20) == 1)
#expect(CardCarousel.wheelStep(deltaX: 5, deltaY: -20) == -1)
}
/// **Clamped, never wrapped**: a carousel is a short flat list, and paging past its last
/// attachment back to its first would make "how many are there" unanswerable by paging.
@Test("Paging clamps at both ends")
func pagingClampsAtBothEnds() {
#expect(CardCarousel.page(from: 0, step: 1, count: 3) == 1)
#expect(CardCarousel.page(from: 2, step: 1, count: 3) == 2)
#expect(CardCarousel.page(from: 0, step: -1, count: 3) == 0)
#expect(CardCarousel.page(from: 2, step: -1, count: 3) == 1)
}
/// The degenerate inputs a live board can hand it an emptied `attachments/` between a render
/// and a wheel tick answer zero rather than trapping on a negative index.
@Test("An empty carousel pages to nothing")
func anEmptyCarouselPagesToNothing() {
#expect(CardCarousel.page(from: 0, step: 1, count: 0) == 0)
#expect(CardCarousel.page(from: 4, step: -1, count: 0) == 0)
}
}
// MARK: - The thumbnail cache key
@Suite("AttachmentThumbnailKey ▸ what a thumbnail is filed under")
struct AttachmentThumbnailKeyTests {
private let path = "/tmp/Board.kanban/lane/card/attachments/shot.png"
private let stamp = Date(timeIntervalSince1970: 1_700_000_000)
/// **Widths bucket to whole points.** A card face is laid out by a `Layout` and its content
/// width can land on a fraction; a thumbnail regenerated because a card grew by a third of a
/// point would be a cache that never hits.
@Test("A fractional width buckets to a whole point")
func fractionalWidthsBucket() {
let a = AttachmentThumbnailKey.Slot(path: path, width: 220.4)
let b = AttachmentThumbnailKey.Slot(path: path, width: 219.8)
#expect(a == b)
#expect(a.width == 220)
}
/// A real size change is still a different slot the thumbnail is generated *at* a size.
@Test("A different width is a different slot")
func differentWidthsAreDifferentSlots() {
#expect(AttachmentThumbnailKey.Slot(path: path, width: 220)
!= AttachmentThumbnailKey.Slot(path: path, width: 260))
}
/// A zero or negative width the one frame before the carousel has been measured floors at
/// one rather than producing a degenerate key.
@Test("A width of zero floors at one point")
func zeroWidthFloorsAtOnePoint() {
#expect(AttachmentThumbnailKey.Slot(path: path, width: 0).width == 1)
#expect(AttachmentThumbnailKey.Slot(path: path, width: -8).width == 1)
}
/// **The stamp is what makes the cache honest.** A board is a live view over folders anyone may
/// write to, so an entry keyed on the path alone would show a picture of a file that has since
/// been replaced.
@Test("A rewritten file is a different key")
func aRewrittenFileIsADifferentKey() {
let slot = AttachmentThumbnailKey.Slot(path: path, width: 220)
let original = AttachmentThumbnailKey(slot: slot, modified: stamp, size: 1024)
// Same bytes, same everything: the hit a reselection depends on.
#expect(AttachmentThumbnailKey(slot: slot, modified: stamp, size: 1024) == original)
// Edited a second later, and edited in place at the same length within the same second
// either component alone would miss one of these, which is why both are in the key.
#expect(AttachmentThumbnailKey(slot: slot, modified: stamp.addingTimeInterval(1), size: 1024) != original)
#expect(AttachmentThumbnailKey(slot: slot, modified: stamp, size: 2048) != original)
}
/// A file that could not be stat'd keys as `(nil, nil)` a legitimate key that simply misses,
/// which is the ordinary "no thumbnail, show the icon" path rather than a second error surface.
@Test("An unstattable file is a legitimate key")
func anUnstattableFileIsALegitimateKey() {
let slot = AttachmentThumbnailKey.Slot(path: path, width: 220)
let unknown = AttachmentThumbnailKey(slot: slot, modified: nil, size: nil)
#expect(unknown == AttachmentThumbnailKey(slot: slot, modified: nil, size: nil))
#expect(unknown != AttachmentThumbnailKey(slot: slot, modified: stamp, size: 1024))
}
}