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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user