WP-5a: AnnotationInspector + ReturnLedger per SPEC-A1c with review corrections
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user