479 lines
17 KiB
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)
|
|
}
|