Files
shotdeck/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift

479 lines
17 KiB
Swift

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..<pageCount {
let page = PDFPage()
page.setBounds(pageBounds, for: .mediaBox)
document.insert(page, at: index)
}
var attributes = [PDFDocumentAttribute: Any]()
if let creator { attributes[.creatorAttribute] = creator }
if let subject { attributes[.subjectAttribute] = subject }
document.documentAttributes = attributes
for item in annotations {
guard let page = document.page(at: item.page) else {
throw NSError(domain: "WP5Test", code: 1)
}
page.addAnnotation(item.annotation)
}
guard document.write(to: url) else {
throw NSError(domain: "WP5Test", code: 2)
}
}
private func makeCasePaths() throws -> (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)
}