Merge pull request 'WP-5a: AnnotationInspector + ReturnLedger' (#5) from wp5a/inspector-ledger-20260831 into feat/shotdeck-20260830

This commit was merged in pull request #5.
This commit is contained in:
2026-08-31 11:16:52 +00:00
4 changed files with 782 additions and 0 deletions
@@ -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)
}
@@ -0,0 +1,200 @@
import Foundation
import Testing
import ShotdeckCore
private func makeCasePaths() throws -> (paths: AppSupportPaths, cleanup: URL) {
let cleanup = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-wp5a-ledger-\(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)
}
private func document(
path: String,
annotatedPages: [Int],
detectedAt: Date,
sessionID: UUID? = nil,
pageCount: Int = 1
) -> ReturnedDocument {
ReturnedDocument(
fileURL: URL(fileURLWithPath: path),
sessionID: sessionID,
pageCount: pageCount,
annotatedPages: annotatedPages,
detectedAt: detectedAt
)
}
@Test("L-1 commented() returns only commented entries, newest first")
func l1_commentedReturnsOnlyCommentedNewestFirst() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let newer = document(
path: "/tmp/shotdeck-returns/newer.pdf",
annotatedPages: [1, 2],
detectedAt: Date(timeIntervalSince1970: 1_777_000_200)
)
let older = document(
path: "/tmp/shotdeck-returns/older.pdf",
annotatedPages: [3],
detectedAt: Date(timeIntervalSince1970: 1_777_000_100)
)
let clean = document(
path: "/tmp/shotdeck-returns/clean.pdf",
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_300)
)
let ledger = try ReturnLedger(paths: paths)
try await ledger.record(older)
try await ledger.record(newer)
try await ledger.record(clean)
let commented = try await ledger.commented()
#expect(commented.map(\.fileURL) == [newer.fileURL, older.fileURL])
#expect(commented.map(\.annotatedPages) == [[1, 2], [3]])
}
@Test("L-2 clipboardText() exact format")
func l2_clipboardTextExactFormat() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let newer = document(
path: "/tmp/shotdeck-returns/newer.pdf",
annotatedPages: [1, 2],
detectedAt: Date(timeIntervalSince1970: 1_777_000_200)
)
let older = document(
path: "/tmp/shotdeck-returns/older.pdf",
annotatedPages: [3],
detectedAt: Date(timeIntervalSince1970: 1_777_000_100)
)
let clean = document(
path: "/tmp/shotdeck-returns/clean.pdf",
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_300)
)
let ledger = try ReturnLedger(paths: paths)
try await ledger.record(older)
try await ledger.record(newer)
try await ledger.record(clean)
let text = try await ledger.clipboardText()
#expect(text == newer.fileURL.path + "\n" + older.fileURL.path)
#expect(text.hasSuffix("\n") == false)
}
@Test("L-3 Re-recording the same URL upserts")
func l3_rerecordingTheSameURLUpserts() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let url = URL(fileURLWithPath: "/tmp/shotdeck-returns/same.pdf")
let first = ReturnedDocument(
fileURL: url,
sessionID: nil,
pageCount: 1,
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_400)
)
let second = ReturnedDocument(
fileURL: url,
sessionID: UUID(uuidString: "11111111-1111-1111-1111-111111111111"),
pageCount: 1,
annotatedPages: [1],
detectedAt: Date(timeIntervalSince1970: 1_777_000_100)
)
let ledger = try ReturnLedger(paths: paths)
try await ledger.record(first)
try await ledger.record(second)
let all = try await ledger.all()
#expect(all.count == 1)
#expect(all[0].fileURL == url)
#expect(all[0].annotatedPages == [1])
#expect(all[0].isCommented == true)
#expect(all[0].sessionID == UUID(uuidString: "11111111-1111-1111-1111-111111111111"))
}
@Test("L-4 clipboardText() throws when nothing is commented")
func l4_clipboardTextThrowsWhenNothingIsCommented() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let ledger = try ReturnLedger(paths: paths)
let emptyError = try await #require(throws: ShotdeckError.self) {
try await ledger.clipboardText()
}
guard case .noCommentedReturns = emptyError else {
Issue.record("expected noCommentedReturns on an empty ledger, got \(emptyError)")
return
}
try await ledger.record(document(
path: "/tmp/shotdeck-returns/clean-only.pdf",
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_500)
))
let cleanError = try await #require(throws: ShotdeckError.self) {
try await ledger.clipboardText()
}
guard case .noCommentedReturns = cleanError else {
Issue.record("expected noCommentedReturns with only clean returns, got \(cleanError)")
return
}
}
@Test("L-5 Ledger survives reopening from disk")
func l5_ledgerSurvivesReopeningFromDisk() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let first = document(
path: "/tmp/shotdeck-returns/a.pdf",
annotatedPages: [1],
detectedAt: Date(timeIntervalSince1970: 1_777_000_700),
sessionID: UUID(uuidString: "11111111-1111-1111-1111-111111111111")
)
let second = document(
path: "/tmp/shotdeck-returns/b.pdf",
annotatedPages: [],
detectedAt: Date(timeIntervalSince1970: 1_777_000_600)
)
do {
let ledger = try ReturnLedger(paths: paths)
try await ledger.record(first)
try await ledger.record(second)
}
let reopened = try ReturnLedger(paths: paths)
let all = try await reopened.all()
#expect(all == [first, second])
}
@Test("L-6 Corrupt returns.json is renamed and the ledger starts empty")
func l6_corruptReturnsJSONIsRenamedAndStartsEmpty() async throws {
let (paths, cleanup) = try makeCasePaths()
defer { try? FileManager.default.removeItem(at: cleanup) }
let returnsURL = paths.root.appendingPathComponent("returns.json")
try Data("{not-json".utf8).write(to: returnsURL)
#expect(FileManager.default.fileExists(atPath: returnsURL.path))
let ledger = try ReturnLedger(paths: paths)
#expect(try await ledger.all() == [])
#expect(FileManager.default.fileExists(atPath: returnsURL.path) == false)
let names = try FileManager.default.contentsOfDirectory(atPath: paths.root.path)
let corrupt = names.filter { $0.hasPrefix("returns.json.corrupt-") }
#expect(corrupt.count == 1)
#expect(corrupt[0].contains(DubaiTime.fileStamp(Date()).prefix(8)))
}