import CoreGraphics import Foundation import ImageIO import PDFKit import ShotdeckCore import Testing import UniformTypeIdentifiers enum TestFixtureError: Error { case cannotCreateBitmap, cannotCreatePNG, cannotFinalizePNG } func writeSolidPNG( pixelWidth: Int, pixelHeight: Int, red: UInt8, green: UInt8, blue: UInt8, to url: URL ) throws { let colorSpace = CGColorSpaceCreateDeviceRGB() guard let context = CGContext( data: nil, width: pixelWidth, height: pixelHeight, bitsPerComponent: 8, bytesPerRow: pixelWidth * 4, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { throw TestFixtureError.cannotCreateBitmap } context.setFillColor(CGColor( red: CGFloat(red) / 255, green: CGFloat(green) / 255, blue: CGFloat(blue) / 255, alpha: 1 )) context.fill(CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight)) guard let image = context.makeImage(), let destination = CGImageDestinationCreateWithURL( url as CFURL, UTType.png.identifier as CFString, 1, nil ) else { throw TestFixtureError.cannotCreatePNG } CGImageDestinationAddImage(destination, image, nil) guard CGImageDestinationFinalize(destination) else { throw TestFixtureError.cannotFinalizePNG } } private let fixtureDate = Date(timeIntervalSince1970: 1_777_482_180) private func makeCapture( sequence: Int, pixelWidth: Int, pixelHeight: Int, scale: CGFloat = 1, capturedAt: Date = fixtureDate, id: UUID = UUID(), fileName: String? = nil ) -> Capture { Capture( id: id, sequence: sequence, fileName: fileName ?? String(format: "%03d.png", sequence), pixelWidth: pixelWidth, pixelHeight: pixelHeight, scale: scale, capturedAt: capturedAt ) } private func makeSession( captures: [Capture], id: UUID = UUID(), createdAt: Date = fixtureDate ) -> CaptureSession { CaptureSession( id: id, createdAt: createdAt, state: .open, captures: captures, pdfFileName: nil ) } private func makeScratchDirectory() throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("shotdeck-pdf-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url } private func writePNG( for capture: Capture, red: UInt8, green: UInt8, blue: UInt8, in directory: URL ) throws -> URL { let url = directory.appendingPathComponent(capture.fileName) try writeSolidPNG( pixelWidth: capture.pixelWidth, pixelHeight: capture.pixelHeight, red: red, green: green, blue: blue, to: url ) return url } private func imageURLMap(_ urls: [UUID: URL]) -> (Capture) -> URL { { capture in urls[capture.id] ?? URL(fileURLWithPath: "/nonexistent/\(capture.id.uuidString).png") } } private func openDocument(_ url: URL) throws -> PDFDocument { let document = try #require(PDFDocument(url: url)) return document } private func pdftoppm(pdf: URL, prefix: URL) throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/pdftoppm") process.arguments = ["-png", "-r", "110", pdf.path, prefix.path] let err = Pipe() process.standardError = err try process.run() process.waitUntilExit() guard process.terminationStatus == 0 else { let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" throw ShotdeckError.pdfCompositionFailed(reason: "pdftoppm failed: \(message)") } } private struct RGBABitmap { let width: Int let height: Int let bytesPerRow: Int let data: Data func pixel(x: Int, y: Int) -> (UInt8, UInt8, UInt8)? { guard x >= 0, y >= 0, x < width, y < height else { return nil } let offset = y * bytesPerRow + x * 4 guard offset + 2 < data.count else { return nil } return (data[offset], data[offset + 1], data[offset + 2]) } func containsNonWhite(x0: Int, y0: Int, x1: Int, y1: Int) -> Bool { let xStart = max(0, x0) let yStart = max(0, y0) let xEnd = min(width, x1) let yEnd = min(height, y1) guard xStart < xEnd, yStart < yEnd else { return false } for y in yStart.. RGBABitmap { let colorSpace = CGColorSpaceCreateDeviceRGB() let source = try #require(CGImageSourceCreateWithURL(url as CFURL, nil)) let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) let width = image.width let height = image.height let bytesPerRow = width * 4 var data = Data(count: bytesPerRow * height) try data.withUnsafeMutableBytes { raw in guard let base = raw.baseAddress else { throw TestFixtureError.cannotCreateBitmap } guard let context = CGContext( data: base, width: width, height: height, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { throw TestFixtureError.cannotCreateBitmap } context.interpolationQuality = .none context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) } return RGBABitmap(width: width, height: height, bytesPerRow: bytesPerRow, data: data) } } private func pixelWindow( for rect: CGRect, pageHeightPt: CGFloat, scale k: CGFloat ) -> (x0: Int, y0: Int, x1: Int, y1: Int) { let pixelX0 = Int(floor(rect.minX * k)) let pixelX1 = Int(ceil(rect.maxX * k)) let pixelY0 = Int(floor((pageHeightPt - rect.maxY) * k)) let pixelY1 = Int(ceil((pageHeightPt - rect.minY) * k)) return (pixelX0, pixelY0, pixelX1, pixelY1) } @Test("fileName uses compose time, not session.createdAt, and matches Redline-yyyyMMdd-HHmmss.pdf") func fileNameUsesComposeTimeNotSessionCreatedAt() { let old = Date(timeIntervalSince1970: 1_600_000_000) // 2020-09-13 let session = makeSession(captures: [], createdAt: old) let name = PDFComposer.fileName(for: session) #expect(name.wholeMatch(of: /^Redline-\d{8}-\d{6}\.pdf$/) != nil) #expect(!name.contains(DubaiTime.fileStamp(old))) let today = String(DubaiTime.fileStamp(Date()).prefix(8)) #expect(name.contains(today)) } @Test("Three-page basic compose") func threePageBasicCompose() throws { let directory = try makeScratchDirectory() defer { try? FileManager.default.removeItem(at: directory) } let c1 = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) let c2 = makeCapture(sequence: 2, pixelWidth: 900, pixelHeight: 1600) let c3 = makeCapture(sequence: 3, pixelWidth: 1200, pixelHeight: 800) let urls: [UUID: URL] = [ c1.id: try writePNG(for: c1, red: 200, green: 20, blue: 20, in: directory), c2.id: try writePNG(for: c2, red: 20, green: 200, blue: 20, in: directory), c3.id: try writePNG(for: c3, red: 20, green: 20, blue: 200, in: directory), ] let session = makeSession(captures: [c1, c2, c3]) let output = directory.appendingPathComponent("three.pdf") let pageCount = try PDFComposer().compose( session: session, imageURL: imageURLMap(urls), title: "Three page", to: output ) #expect(pageCount == 3) let document = try openDocument(output) #expect(document.pageCount == 3) let p0 = try #require(document.page(at: 0)) let p1 = try #require(document.page(at: 1)) let p2 = try #require(document.page(at: 2)) #expect(p0.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 842, height: 595)) #expect(p1.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 595, height: 842)) #expect(p2.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 842, height: 595)) } @Test("Wide/tall page geometry exact") func wideTallPageGeometryExact() throws { let directory = try makeScratchDirectory() defer { try? FileManager.default.removeItem(at: directory) } let wide = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) let tall = makeCapture(sequence: 1, pixelWidth: 900, pixelHeight: 1600) let wideURL = try writePNG(for: wide, red: 10, green: 10, blue: 10, in: directory) let tallURL = try writePNG(for: tall, red: 10, green: 10, blue: 10, in: directory) let wideOut = directory.appendingPathComponent("wide.pdf") _ = try PDFComposer().compose( session: makeSession(captures: [wide]), imageURL: { _ in wideURL }, title: "Wide", to: wideOut ) let tallOut = directory.appendingPathComponent("tall.pdf") _ = try PDFComposer().compose( session: makeSession(captures: [tall]), imageURL: { _ in tallURL }, title: "Tall", to: tallOut ) let widePage = try #require(openDocument(wideOut).page(at: 0)) let tallPage = try #require(openDocument(tallOut).page(at: 0)) #expect(widePage.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 842, height: 595)) #expect(tallPage.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 595, height: 842)) } @Test("Square resolves to portrait (Capture.swift:39-42 isLandscape strict >)") func squareResolvesToPortrait() throws { let directory = try makeScratchDirectory() defer { try? FileManager.default.removeItem(at: directory) } let square = makeCapture(sequence: 1, pixelWidth: 1000, pixelHeight: 1000) let png = try writePNG(for: square, red: 80, green: 80, blue: 80, in: directory) let output = directory.appendingPathComponent("square.pdf") _ = try PDFComposer().compose( session: makeSession(captures: [square]), imageURL: { _ in png }, title: "Square", to: output ) let page = try #require(openDocument(output).page(at: 0)) #expect(page.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 595, height: 842)) } @Test("Retina capture uses point size, not pixel size") func retinaCaptureUsesPointSize() throws { let capture = makeCapture(sequence: 1, pixelWidth: 2880, pixelHeight: 1620, scale: 2.0) let layout = PageLayout(capture: capture, pageIndex: 1, pageCount: 1) let expectedScale = min(806.0 / 1440.0, 523.0 / 810.0, 1.0) let expected = CGSize(width: 1440.0 * expectedScale, height: 810.0 * expectedScale) #expect(abs(layout.imageRect.width - expected.width) < 0.01) #expect(abs(layout.imageRect.height - expected.height) < 0.01) #expect(layout.imageRect.width < 2000) #expect(layout.isLandscape) } @Test("Zero PDFAnnotation objects on every page") func zeroPDFAnnotationObjects() throws { let directory = try makeScratchDirectory() defer { try? FileManager.default.removeItem(at: directory) } let c1 = makeCapture(sequence: 1, pixelWidth: 800, pixelHeight: 600) let c2 = makeCapture(sequence: 2, pixelWidth: 600, pixelHeight: 800) let c3 = makeCapture(sequence: 3, pixelWidth: 1000, pixelHeight: 1000) let urls: [UUID: URL] = [ c1.id: try writePNG(for: c1, red: 30, green: 30, blue: 30, in: directory), c2.id: try writePNG(for: c2, red: 40, green: 40, blue: 40, in: directory), c3.id: try writePNG(for: c3, red: 50, green: 50, blue: 50, in: directory), ] let output = directory.appendingPathComponent("no-annots.pdf") _ = try PDFComposer().compose( session: makeSession(captures: [c1, c2, c3]), imageURL: imageURLMap(urls), title: "No annotations", to: output ) let document = try openDocument(output) #expect(document.pageCount == 3) for index in 0.. 150 && c1.1 < 80 && c1.2 < 80) #expect(c2.2 > 150 && c2.0 < 80 && c2.1 < 80) } @Test("An unreadable (corrupt) PNG is treated exactly like a missing one (D-A)") func unreadablePNGIsSkippedLikeMissing() throws { let directory = try makeScratchDirectory() defer { try? FileManager.default.removeItem(at: directory) } let good = makeCapture(sequence: 1, pixelWidth: 800, pixelHeight: 500) let bad = makeCapture(sequence: 2, pixelWidth: 800, pixelHeight: 500) let goodURL = try writePNG(for: good, red: 30, green: 140, blue: 30, in: directory) let badURL = directory.appendingPathComponent("corrupt.png") try Data([0x00, 0x01, 0x02, 0xFF, 0xD8, 0x00]).write(to: badURL) let urls: [UUID: URL] = [good.id: goodURL, bad.id: badURL] let output = directory.appendingPathComponent("skip-corrupt.pdf") let pageCount = try PDFComposer().compose( session: makeSession(captures: [good, bad]), imageURL: imageURLMap(urls), title: "Skip corrupt", to: output ) #expect(pageCount == 1) let document = try openDocument(output) #expect(document.pageCount == 1) } @Test("Document attributes round-trip exactly") func documentAttributesRoundTrip() throws { let directory = try makeScratchDirectory() defer { try? FileManager.default.removeItem(at: directory) } let sessionID = try #require(UUID(uuidString: "deadbeef-dead-4eef-8ead-deadbeef0001")) let capture = makeCapture(sequence: 1, pixelWidth: 640, pixelHeight: 480) let png = try writePNG(for: capture, red: 40, green: 40, blue: 40, in: directory) let output = directory.appendingPathComponent("attrs.pdf") _ = try PDFComposer().compose( session: makeSession(captures: [capture], id: sessionID), imageURL: { _ in png }, title: "Ben review — 2026-08-30", to: output ) let document = try openDocument(output) let attributes = try #require(document.documentAttributes) #expect(attributes[PDFDocumentAttribute.creatorAttribute] as? String == "Redline") #expect(attributes[PDFDocumentAttribute.titleAttribute] as? String == "Ben review — 2026-08-30") #expect(attributes[PDFDocumentAttribute.subjectAttribute] as? String == "deadbeef-dead-4eef-8ead-deadbeef0001") #expect(document.pageCount == 1) #expect(try #require(document.page(at: 0)).annotations.isEmpty) } @Test("Rendered page is not blank (pdftoppm, pinned renderer + rounding)") func renderedPageIsNotBlank() throws { let directory = try makeScratchDirectory() defer { try? FileManager.default.removeItem(at: directory) } let capture = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) let png = try writePNG(for: capture, red: 128, green: 128, blue: 128, in: directory) let output = directory.appendingPathComponent("not-blank.pdf") _ = try PDFComposer().compose( session: makeSession(captures: [capture]), imageURL: { _ in png }, title: "Not blank", to: output ) let prefix = directory.appendingPathComponent("not-blank") try pdftoppm(pdf: output, prefix: prefix) let rendered = directory.appendingPathComponent("not-blank-1.png") let bitmap = try RGBABitmap.loadPNG(at: rendered) let layout = PageLayout(capture: capture, pageIndex: 1, pageCount: 1) let k: CGFloat = 110.0 / 72.0 let imageWin = pixelWindow(for: layout.imageRect, pageHeightPt: layout.pageRect.height, scale: k) let headerWin = pixelWindow(for: layout.headerRect, pageHeightPt: layout.pageRect.height, scale: k) #expect(bitmap.containsNonWhite(x0: imageWin.x0, y0: imageWin.y0, x1: imageWin.x1, y1: imageWin.y1)) #expect(bitmap.containsNonWhite(x0: headerWin.x0, y0: headerWin.y0, x1: headerWin.x1, y1: headerWin.y1)) } @Test("Right-edge tick group geometry matches D-12 exactly, both orientations") func rightEdgeTickGroupGeometry() throws { let landscape = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) let portrait = makeCapture(sequence: 1, pixelWidth: 900, pixelHeight: 1600) let landscapeLayout = PageLayout(capture: landscape, pageIndex: 1, pageCount: 1) let portraitLayout = PageLayout(capture: portrait, pageIndex: 1, pageCount: 1) #expect(landscapeLayout.failTickBox.maxX == 824) #expect(landscapeLayout.headerRect.maxX == 824) #expect(portraitLayout.failTickBox.maxX == 577) #expect(portraitLayout.headerRect.maxX == 577) #expect(landscapeLayout.failTickBox.width == 13) #expect(landscapeLayout.failTickBox.height == 13) #expect(portraitLayout.failTickBox.width == 13) #expect(portraitLayout.failTickBox.height == 13) #expect(landscapeLayout.passTickBox.maxX == landscapeLayout.failLabelOrigin.x - 8) #expect(portraitLayout.passTickBox.maxX == portraitLayout.failLabelOrigin.x - 8) } @Test("Writes visual sample for review") func writesVisualSampleForReview() throws { let verifyDir = URL(fileURLWithPath: "/Users/benjaminhippler/mmd-projects/multi-agent-runs/shotdeck-20260830/verify") try FileManager.default.createDirectory(at: verifyDir, withIntermediateDirectories: true) let scratch = try makeScratchDirectory() defer { try? FileManager.default.removeItem(at: scratch) } let wide = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) let tall = makeCapture(sequence: 2, pixelWidth: 900, pixelHeight: 1600) let retina = makeCapture(sequence: 3, pixelWidth: 2880, pixelHeight: 1620, scale: 2.0) let urls: [UUID: URL] = [ wide.id: try writePNG(for: wide, red: 196, green: 42, blue: 42, in: scratch), tall.id: try writePNG(for: tall, red: 32, green: 96, blue: 176, in: scratch), retina.id: try writePNG(for: retina, red: 36, green: 148, blue: 84, in: scratch), ] let output = verifyDir.appendingPathComponent("wp2-sample.pdf") let pageCount = try PDFComposer().compose( session: makeSession(captures: [wide, tall, retina]), imageURL: imageURLMap(urls), title: "WP-2 visual sample", to: output ) #expect(pageCount == 3) let prefix = verifyDir.appendingPathComponent("wp2-sample") try pdftoppm(pdf: output, prefix: prefix) }