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 — /// `///attachments/` (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)) } }