import CoreGraphics import Foundation import ImageIO import Testing @testable import Kanban /// **The card face's hero image** (03-board-ui.md § Card face ▸ Hero image; the card's `hero` key, /// 01-storage-format.md § Frontmatter). /// /// The *reading* lives with its siblings in `FrontmatterTests` and the *metric* with the rest of the /// board's geometry in `VisualAccommodationsTests`/`BoardZoomTests`; what is here is everything new: /// where a name resolves (`CardHero`), what the band can actually draw (the decode), and the shared /// cache the drag replica depends on being able to read synchronously (`CardHeroCache`). // MARK: - Fixtures /// A card folder with an `attachments/` inside it, under a temp root. private struct CardFixture { let root: URL let lane = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" let card = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" var cardFolder: URL { root .appendingPathComponent(lane, isDirectory: true) .appendingPathComponent(card, isDirectory: true) } var attachments: URL { cardFolder.appendingPathComponent("attachments", isDirectory: true) } init() throws { root = FileManager.default.temporaryDirectory .appendingPathComponent("CardHeroTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: attachments, withIntermediateDirectories: true) } func tearDown() { try? FileManager.default.removeItem(at: root) } } /// A real PNG of the given pixel size — the only way to test a decode honestly, since the whole /// question is what ImageIO makes of actual bytes. @discardableResult private func writePNG(at url: URL, width: Int, height: Int) throws -> URL { let context = try #require(CGContext( data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue )) context.setFillColor(CGColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1)) context.fill(CGRect(x: 0, y: 0, width: width, height: height)) let image = try #require(context.makeImage()) let destination = try #require( CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil)) CGImageDestinationAddImage(destination, image, nil) #expect(CGImageDestinationFinalize(destination)) return url } /// The card as the loader reads it — the model value the face is handed, rather than one assembled /// by hand, so the carry-through and the resolution are exercised against the same thing. private func loadedCard(_ fixture: CardFixture, frontmatter: String) throws -> Card { func index(_ folder: URL, _ text: String) throws { try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) try "---\n\(text)---\n" .write(to: folder.appendingPathComponent("index.md"), atomically: true, encoding: .utf8) } try index(fixture.root, "schema: 1\n") try index(fixture.root.appendingPathComponent(fixture.lane, isDirectory: true), "schema: 1\norder: 1024\n") try index(fixture.cardFolder, frontmatter) let model = try BoardLoader.load(boardRoot: fixture.root).model return try #require(model.lanes.first?.cards.first) } // MARK: - Where a name resolves @Suite("Card hero ▸ the name resolves inside the card's own attachments") struct CardHeroResolutionTests { /// The whole grammar: a bare filename lands directly in this card's `attachments/`, which is what /// makes a hero survive every move, copy, trash and restore the card takes — the folder travels /// with it and nothing has to be rewritten. @Test("A bare filename lands in this card's attachments folder") func aBareNameResolves() throws { let fixture = try CardFixture() defer { fixture.tearDown() } #expect(CardHero.imageURL(named: "sketch.png", inCardFolder: fixture.cardFolder)?.path == fixture.attachments.appendingPathComponent("sketch.png").path) // Nothing about the name is policed here beyond the shape: a dot file, a tilde and spaces are // all filenames, and one naming nothing simply decodes to nothing later. #expect(CardHero.imageURL(named: ".hidden.png", inCardFolder: fixture.cardFolder) != nil) #expect(CardHero.imageURL(named: "~a shot 2.png", inCardFolder: fixture.cardFolder) != nil) } /// **Belt over the reading's braces.** `FrontmatterDocument.hero` already refuses a path, so /// nothing pathy should ever reach here — but a lenient reading must never be the only thing /// standing between a value and the filesystem, and this is the layer that builds the URL. /// /// The last two are what makes this stricter than `BoardBackdrop`'s check: "inside the folder" /// and "directly inside the folder" are different promises, and an attachment is the second. @Test("Anything that is not a name resolves nowhere") func pathsResolveNowhere() throws { let fixture = try CardFixture() defer { fixture.tearDown() } #expect(CardHero.imageURL(named: "", inCardFolder: fixture.cardFolder) == nil) #expect(CardHero.imageURL(named: "art/sketch.png", inCardFolder: fixture.cardFolder) == nil) #expect(CardHero.imageURL(named: "../sketch.png", inCardFolder: fixture.cardFolder) == nil) #expect(CardHero.imageURL(named: "/etc/passwd", inCardFolder: fixture.cardFolder) == nil) #expect(CardHero.imageURL(named: "attachments/sketch.png", inCardFolder: fixture.cardFolder) == nil) #expect(CardHero.imageURL(named: ".", inCardFolder: fixture.cardFolder) == nil) #expect(CardHero.imageURL(named: "..", inCardFolder: fixture.cardFolder) == nil) } /// The card-level entry point, on the board side: the container is the lane folder, and the card /// folder is appended here so the face never has to know its own path. @Test("A card's hero resolves under its container") func aCardResolvesUnderItsContainer() throws { let fixture = try CardFixture() defer { fixture.tearDown() } let card = try loadedCard(fixture, frontmatter: "schema: 1\nkind: card\norder: 1024\nhero: sketch.png\n") let lane = fixture.root.appendingPathComponent(fixture.lane, isDirectory: true) #expect(CardHero.imageURL(for: card, inContainer: lane)?.path == fixture.attachments.appendingPathComponent("sketch.png").path) } /// **The no-key-no-change identity** — the card every board is made of. No key means no reading, /// no URL, no band, no trace, and bytes that come back exactly as they went in. @Test("A card with no hero key resolves to nothing and changes nothing") func noKeyIsNoChange() throws { let fixture = try CardFixture() defer { fixture.tearDown() } let text = "schema: 1\nkind: card\norder: 1024\ntitle: Plain\n" let card = try loadedCard(fixture, frontmatter: text) let lane = fixture.root.appendingPathComponent(fixture.lane, isDirectory: true) #expect(card.hero == .missing) #expect(CardHero.imageURL(for: card, inContainer: lane) == nil) #expect(card.document.coercedFields.isEmpty) #expect(card.document.serialized() == "---\n\(text)---\n") } /// A malformed key is the same absence with a trace behind it: the face draws no band, and the /// value the author wrote is still on disk for them to fix. @Test("A pathy hero resolves to nothing, and the bytes stay as written") func aMalformedKeyResolvesToNothing() throws { let fixture = try CardFixture() defer { fixture.tearDown() } let text = "schema: 1\nkind: card\norder: 1024\nhero: art/sketch.png\n" let card = try loadedCard(fixture, frontmatter: text) let lane = fixture.root.appendingPathComponent(fixture.lane, isDirectory: true) #expect(card.hero == .malformed(raw: "art/sketch.png")) #expect(CardHero.imageURL(for: card, inContainer: lane) == nil) #expect(card.document.serialized() == "---\n\(text)---\n") } } // MARK: - What the band can draw @Suite("Card hero ▸ what the band can draw") struct CardHeroDecodeTests { /// A hero is decoded **downsampled**, at the card's own limit rather than the backdrop's: a board /// has one backdrop and may have hundreds of hero cards, so the figure is a per-card memory cost /// as much as a decode cost. @Test("An image decodes downsampled to the hero's own limit") func anImageDecodesDownsampled() throws { let fixture = try CardFixture() defer { fixture.tearDown() } let url = try writePNG( at: fixture.attachments.appendingPathComponent("sketch.png"), width: 2400, height: 1200) let decoded = try #require(BoardBackdrop.decode(url, limit: CardHero.maximumPixelSize)) #expect(decoded.width == CardHero.maximumPixelSize) #expect(decoded.height == CardHero.maximumPixelSize / 2) // The parameter is the whole reason the backdrop's decode was widened rather than copied. #expect(CardHero.maximumPixelSize < BoardBackdrop.maximumPixelSize) } /// **Missing, unreadable, and not-an-image all end the same way** — no picture, so no band, so a /// face that renders exactly as one with no key (the ruling). No defect, no badge, and nothing /// written. @Test("A missing or non-image file decodes to nothing") func nonImagesDecodeToNothing() throws { let fixture = try CardFixture() defer { fixture.tearDown() } let missing = fixture.attachments.appendingPathComponent("gone.png") #expect(BoardBackdrop.decode(missing, limit: CardHero.maximumPixelSize) == nil) let notes = fixture.attachments.appendingPathComponent("notes.txt") try "not a picture".write(to: notes, atomically: true, encoding: .utf8) #expect(BoardBackdrop.decode(notes, limit: CardHero.maximumPixelSize) == nil) // The liar: an image extension over text. The extension is not what is read. let liar = fixture.attachments.appendingPathComponent("fake.png") try "not a picture either".write(to: liar, atomically: true, encoding: .utf8) #expect(BoardBackdrop.decode(liar, limit: CardHero.maximumPixelSize) == nil) } } // MARK: - The shared cache /// Serialized because the cache is app-wide by design (see `CardHeroCache`) — one suite's fixtures /// must not decide another's hits. @MainActor @Suite("Card hero ▸ the shared cache", .serialized) struct CardHeroCacheTests { /// **The claim the drag replica rests on**: a picture already on screen can be read back /// *synchronously*, with no `stat` and no decode. A preview builder is non-escaping — it runs /// while the body does — so a replica that had to await anything would lift a card with a hole /// where its banner is. @Test("A loaded hero reads back synchronously") func aLoadedHeroReadsBackSynchronously() async throws { let fixture = try CardFixture() defer { fixture.tearDown() } CardHeroCache.removeAll() let url = try writePNG( at: fixture.attachments.appendingPathComponent("sketch.png"), width: 800, height: 400) #expect(CardHeroCache.image(forFileAt: url) == nil) let loaded = await CardHeroCache.load(url) #expect(loaded != nil) #expect(CardHeroCache.image(forFileAt: url) != nil) } /// A file that is not an image caches nothing — and, crucially, is not *remembered* as an image /// either: the synchronous read stays empty, so the replica draws the same hero-less face the /// board does. @Test("A non-image caches nothing") func aNonImageCachesNothing() async throws { let fixture = try CardFixture() defer { fixture.tearDown() } CardHeroCache.removeAll() let notes = fixture.attachments.appendingPathComponent("notes.txt") try "not a picture".write(to: notes, atomically: true, encoding: .utf8) #expect(await CardHeroCache.load(notes) == nil) #expect(CardHeroCache.image(forFileAt: notes) == nil) } /// **Replaced bytes key differently** — the freshness posture `AttachmentThumbnailCache` already /// takes, and the reason there is no invalidation path to keep honest: the stamp is part of the /// key, so a rewritten file cannot be answered with the old picture. @Test("A replaced file keys differently") func replacedBytesKeyDifferently() async throws { let fixture = try CardFixture() defer { fixture.tearDown() } CardHeroCache.removeAll() // Both are wider than the decode limit, so both come back at it — the *shape* is what tells // them apart, which is exactly what a stale entry could not fake. let url = fixture.attachments.appendingPathComponent("sketch.png") try writePNG(at: url, width: 2048, height: 1024) let first = await CardHeroCache.load(url) #expect(first?.height == CardHero.maximumPixelSize / 2) try FileManager.default.removeItem(at: url) try writePNG(at: url, width: 2048, height: 512) let second = await CardHeroCache.load(url) #expect(second?.height == CardHero.maximumPixelSize / 4) } }