import AppKit import Foundation import PDFKit import Testing import ShotdeckCore private let pageBounds = CGRect(x: 0, y: 0, width: 600, height: 800) private func makeAnnotation( _ subtype: PDFAnnotationSubtype, bounds: CGRect = CGRect(x: 100, y: 100, width: 80, height: 40), contents: String? = nil ) -> PDFAnnotation { let annotation = PDFAnnotation( bounds: bounds, forType: subtype, withProperties: nil ) annotation.contents = contents return annotation } private func makePDF( at url: URL, pageCount: Int, creator: String? = "Shotdeck", subject: String? = nil, annotations: [(page: Int, annotation: PDFAnnotation)] = [] ) throws { let document = PDFDocument() for index in 0.. (paths: AppSupportPaths, cleanup: URL) { let cleanup = FileManager.default.temporaryDirectory .appendingPathComponent("shotdeck-wp5a-\(UUID().uuidString)", isDirectory: true) let paths = try AppSupportPaths( root: cleanup.appendingPathComponent("root", isDirectory: true), outbox: cleanup.appendingPathComponent("outbox", isDirectory: true), watchFolder: cleanup.appendingPathComponent("watch", isDirectory: true) ) return (paths, cleanup) } @Test("I-1 Untouched PDF is clean") func i1_untouchedPDFIsClean() async throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134200.pdf") try makePDF( at: pdfURL, pageCount: 3, creator: "Shotdeck", subject: "11111111-1111-1111-1111-111111111111" ) var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(identifier: "Asia/Dubai")) let firstMTime = try #require(calendar.date(from: DateComponents( year: 2026, month: 8, day: 30, hour: 13, minute: 42, second: 0 ))) let secondMTime = try #require(calendar.date(from: DateComponents( year: 2026, month: 8, day: 30, hour: 13, minute: 43, second: 0 ))) try FileManager.default.setAttributes([.modificationDate: firstMTime], ofItemAtPath: pdfURL.path) try FileManager.default.setAttributes([.modificationDate: secondMTime], ofItemAtPath: pdfURL.path) let inspected = try AnnotationInspector.inspect(fileURL: pdfURL) #expect(inspected.fileURL == pdfURL) #expect(inspected.sessionID == UUID(uuidString: "11111111-1111-1111-1111-111111111111")) #expect(inspected.pageCount == 3) #expect(inspected.annotatedPages == []) #expect(inspected.isCommented == false) let ledger = try ReturnLedger(paths: paths) try await ledger.record(inspected) let all = try await ledger.all() #expect(all.count == 1) #expect(all[0].fileURL == pdfURL) #expect(try await ledger.commented() == []) let clipboardError = try await #require(throws: ShotdeckError.self) { try await ledger.clipboardText() } guard case .noCommentedReturns = clipboardError else { Issue.record("expected noCommentedReturns, got \(clipboardError)") return } } @Test("I-2 Ink is a mark") func i2_inkIsAMark() throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134201.pdf") try makePDF( at: pdfURL, pageCount: 3, annotations: [(page: 1, annotation: makeAnnotation( .ink, bounds: CGRect(x: 120, y: 240, width: 140, height: 60) ))] ) let inspected = try AnnotationInspector.inspect(fileURL: pdfURL) #expect(inspected.pageCount == 3) #expect(inspected.annotatedPages == [2]) #expect(inspected.isCommented == true) } @Test("I-3 Highlight is a mark") func i3_highlightIsAMark() throws { try expectSinglePageMark( .highlight, bounds: CGRect(x: 80, y: 500, width: 300, height: 24), fileName: "Shotdeck-20260830-134202.pdf" ) } @Test("I-4 Underline is a mark") func i4_underlineIsAMark() throws { try expectSinglePageMark( .underline, bounds: CGRect(x: 90, y: 460, width: 280, height: 18), fileName: "Shotdeck-20260830-134203.pdf" ) } @Test("I-5 Strike-out is a mark") func i5_strikeOutIsAMark() throws { try expectSinglePageMark( .strikeOut, bounds: CGRect(x: 90, y: 430, width: 280, height: 18), fileName: "Shotdeck-20260830-134204.pdf" ) } @Test("I-6 Squiggly is a mark") func i6_squigglyIsAMark() throws { try expectSinglePageMark( PDFAnnotationSubtype(rawValue: "/Squiggly"), bounds: CGRect(x: 90, y: 400, width: 280, height: 18), fileName: "Shotdeck-20260830-134205.pdf" ) } @Test("I-7 Free-text is a mark") func i7_freeTextIsAMark() throws { try expectSinglePageMark( .freeText, bounds: CGRect(x: 140, y: 300, width: 220, height: 80), contents: "Typed review note", fileName: "Shotdeck-20260830-134206.pdf" ) } @Test("I-8 Square is a mark") func i8_squareIsAMark() throws { try expectSinglePageMark( .square, bounds: CGRect(x: 160, y: 250, width: 100, height: 100), fileName: "Shotdeck-20260830-134207.pdf" ) } @Test("I-9 Circle is a mark") func i9_circleIsAMark() throws { try expectSinglePageMark( .circle, bounds: CGRect(x: 280, y: 250, width: 100, height: 100), fileName: "Shotdeck-20260830-134208.pdf" ) } @Test("I-10 Line is a mark") func i10_lineIsAMark() throws { try expectSinglePageMark( .line, bounds: CGRect(x: 100, y: 180, width: 320, height: 8), fileName: "Shotdeck-20260830-134209.pdf" ) } @Test("I-11 Stamp is a mark") func i11_stampIsAMark() throws { try expectSinglePageMark( .stamp, bounds: CGRect(x: 180, y: 500, width: 120, height: 60), contents: "Approved", fileName: "Shotdeck-20260830-134210.pdf" ) } @Test("I-12 Sticky text is a mark") func i12_stickyTextIsAMark() throws { try expectSinglePageMark( .text, bounds: CGRect(x: 420, y: 620, width: 28, height: 28), contents: "Look here", fileName: "Shotdeck-20260830-134211.pdf" ) } @Test("I-13 Link alone is not a mark") func i13_linkAloneIsNotAMark() throws { try expectSinglePageIgnored( .link, bounds: CGRect(x: 80, y: 700, width: 160, height: 20), fileName: "Shotdeck-20260830-134212.pdf" ) } @Test("I-14 Popup alone is not a mark") func i14_popupAloneIsNotAMark() throws { try expectSinglePageIgnored( .popup, bounds: CGRect(x: 450, y: 600, width: 100, height: 60), fileName: "Shotdeck-20260830-134213.pdf" ) } @Test("I-15 Widget alone is not a mark") func i15_widgetAloneIsNotAMark() throws { try expectSinglePageIgnored( .widget, bounds: CGRect(x: 100, y: 100, width: 140, height: 32), fileName: "Shotdeck-20260830-134214.pdf" ) } @Test("I-16 Zero-area ink is not a mark") func i16_zeroAreaInkIsNotAMark() throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134215.pdf") try makePDF( at: pdfURL, pageCount: 1, annotations: [(page: 0, annotation: makeAnnotation( .ink, bounds: CGRect(x: 240, y: 240, width: 0, height: 0) ))] ) let inspected = try AnnotationInspector.inspect(fileURL: pdfURL) #expect(inspected.annotatedPages == []) #expect(inspected.isCommented == false) } @Test("I-17 Ignored objects plus one real mark count once") func i17_ignoredObjectsPlusOneRealMarkCountOnce() throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134216.pdf") try makePDF( at: pdfURL, pageCount: 3, annotations: [ (page: 1, annotation: makeAnnotation(.link, bounds: CGRect(x: 20, y: 20, width: 40, height: 20))), (page: 1, annotation: makeAnnotation(.popup, bounds: CGRect(x: 70, y: 20, width: 40, height: 20))), (page: 1, annotation: makeAnnotation(.widget, bounds: CGRect(x: 120, y: 20, width: 40, height: 20))), (page: 1, annotation: makeAnnotation(.ink, bounds: CGRect(x: 200, y: 200, width: 120, height: 50))), ] ) let inspected = try AnnotationInspector.inspect(fileURL: pdfURL) #expect(inspected.pageCount == 3) #expect(inspected.annotatedPages == [2]) #expect(inspected.isCommented == true) } @Test("I-18 Page numbers are 1-based and ascending") func i18_pageNumbersAre1BasedAndAscending() throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134217.pdf") try makePDF( at: pdfURL, pageCount: 3, annotations: [ (page: 0, annotation: makeAnnotation(.circle, bounds: CGRect(x: 260, y: 200, width: 70, height: 70))), (page: 1, annotation: makeAnnotation(.link, bounds: CGRect(x: 80, y: 700, width: 160, height: 20))), (page: 2, annotation: makeAnnotation(.ink, bounds: CGRect(x: 100, y: 100, width: 90, height: 40))), ] ) let inspected = try AnnotationInspector.inspect(fileURL: pdfURL) #expect(inspected.pageCount == 3) #expect(inspected.annotatedPages == [1, 3]) #expect(inspected.isCommented == true) } @Test("I-19 Session UUID recovery and invalid-subject tolerance") func i19_sessionUUIDRecoveryAndInvalidSubjectTolerance() throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let validURL = paths.watchFolder.appendingPathComponent("valid-subject.pdf") let invalidURL = paths.watchFolder.appendingPathComponent("invalid-subject.pdf") let missingURL = paths.watchFolder.appendingPathComponent("missing-subject.pdf") try makePDF( at: validURL, pageCount: 1, creator: "Shotdeck", subject: "22222222-2222-2222-2222-222222222222" ) try makePDF( at: invalidURL, pageCount: 1, creator: "Shotdeck", subject: "not-a-uuid" ) try makePDF( at: missingURL, pageCount: 1, creator: "Shotdeck", subject: nil ) let valid = try AnnotationInspector.inspect(fileURL: validURL) #expect(valid.sessionID == UUID(uuidString: "22222222-2222-2222-2222-222222222222")) #expect(valid.pageCount == 1) #expect(valid.annotatedPages == []) #expect(valid.isCommented == false) let invalid = try AnnotationInspector.inspect(fileURL: invalidURL) #expect(invalid.sessionID == nil) #expect(invalid.annotatedPages == []) #expect(invalid.isCommented == false) let missing = try AnnotationInspector.inspect(fileURL: missingURL) #expect(missing.sessionID == nil) #expect(missing.annotatedPages == []) #expect(missing.isCommented == false) } @Test("I-19b Present non-Shotdeck creator beats a matching filename") func i19b_presentNonShotdeckCreatorBeatsMatchingFilename() throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134218.pdf") try makePDF( at: pdfURL, pageCount: 1, creator: "Preview", annotations: [(page: 0, annotation: makeAnnotation( .ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50) ))] ) let reopened = try #require(PDFDocument(url: pdfURL)) #expect(AnnotationInspector.isShotdeckDocument(reopened) == false) } @Test("I-19c Present Redline creator is recognized") func i19c_presentRedlineCreatorIsRecognized() throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134219.pdf") try makePDF(at: pdfURL, pageCount: 1, creator: "Redline") let reopened = try #require(PDFDocument(url: pdfURL)) #expect(AnnotationInspector.isShotdeckDocument(reopened) == true) } @Test("I-19d Present legacy Shotdeck creator is still recognized") func i19d_presentLegacyShotdeckCreatorIsStillRecognized() throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134220.pdf") try makePDF(at: pdfURL, pageCount: 1, creator: "Shotdeck") let reopened = try #require(PDFDocument(url: pdfURL)) #expect(AnnotationInspector.isShotdeckDocument(reopened) == true) } @Test("I-19e Absent creator falls back to Redline filename") func i19e_absentCreatorFallsBackToRedlineFilename() throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134221.pdf") try makePDF(at: pdfURL, pageCount: 1, creator: nil) let reopened = try #require(PDFDocument(url: pdfURL)) #expect(AnnotationInspector.isShotdeckDocument(reopened) == true) } @Test("I-19f Absent creator falls back to legacy Shotdeck filename") func i19f_absentCreatorFallsBackToLegacyShotdeckFilename() throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134222.pdf") try makePDF(at: pdfURL, pageCount: 1, creator: nil) let reopened = try #require(PDFDocument(url: pdfURL)) #expect(AnnotationInspector.isShotdeckDocument(reopened) == true) } @Test("I-19g Absent creator falls back to Redline duplicate-name suffix") func i19g_absentCreatorFallsBackToRedlineDuplicateNameSuffix() throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134223 2.pdf") try makePDF(at: pdfURL, pageCount: 1, creator: nil) let reopened = try #require(PDFDocument(url: pdfURL)) #expect(AnnotationInspector.isShotdeckDocument(reopened) == true) } @Test("I-19h Present non-product creator beats a matching Redline filename") func i19h_presentNonProductCreatorBeatsMatchingRedlineFilename() throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let pdfURL = paths.watchFolder.appendingPathComponent("Redline-20260830-134224.pdf") try makePDF(at: pdfURL, pageCount: 1, creator: "Preview") let reopened = try #require(PDFDocument(url: pdfURL)) #expect(AnnotationInspector.isShotdeckDocument(reopened) == false) } private func expectSinglePageMark( _ subtype: PDFAnnotationSubtype, bounds: CGRect, contents: String? = nil, fileName: String ) throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let pdfURL = paths.watchFolder.appendingPathComponent(fileName) try makePDF( at: pdfURL, pageCount: 1, annotations: [(page: 0, annotation: makeAnnotation(subtype, bounds: bounds, contents: contents))] ) let inspected = try AnnotationInspector.inspect(fileURL: pdfURL) #expect(inspected.pageCount == 1) #expect(inspected.annotatedPages == [1]) #expect(inspected.isCommented == true) } private func expectSinglePageIgnored( _ subtype: PDFAnnotationSubtype, bounds: CGRect, fileName: String ) throws { let (paths, cleanup) = try makeCasePaths() defer { try? FileManager.default.removeItem(at: cleanup) } let pdfURL = paths.watchFolder.appendingPathComponent(fileName) try makePDF( at: pdfURL, pageCount: 1, annotations: [(page: 0, annotation: makeAnnotation(subtype, bounds: bounds))] ) let inspected = try AnnotationInspector.inspect(fileURL: pdfURL) #expect(inspected.annotatedPages == []) #expect(inspected.isCommented == false) }