diff --git a/Sources/ShotdeckCore/Returns/AnnotationInspector.swift b/Sources/ShotdeckCore/Returns/AnnotationInspector.swift new file mode 100644 index 0000000..7df2922 --- /dev/null +++ b/Sources/ShotdeckCore/Returns/AnnotationInspector.swift @@ -0,0 +1,110 @@ +import Foundation +import PDFKit + +public struct ReturnedDocument: Codable, Sendable, Identifiable, Equatable { + public var id: URL { fileURL } + public let fileURL: URL + /// Session UUID recovered from the PDF's subject attribute, when present and valid. + public let sessionID: UUID? + public let pageCount: Int + /// 1-based page numbers that carry at least one human mark, ascending, no duplicates. + public let annotatedPages: [Int] + public let detectedAt: Date + public var isCommented: Bool { !annotatedPages.isEmpty } + + public init( + fileURL: URL, + sessionID: UUID?, + pageCount: Int, + annotatedPages: [Int], + detectedAt: Date + ) { + self.fileURL = fileURL + self.sessionID = sessionID + self.pageCount = pageCount + self.annotatedPages = annotatedPages + self.detectedAt = detectedAt + } +} + +public enum AnnotationInspector { + private static let humanMarkTypes: Set = [ + PDFAnnotationSubtype.ink.rawValue, + PDFAnnotationSubtype.highlight.rawValue, + PDFAnnotationSubtype.underline.rawValue, + PDFAnnotationSubtype.strikeOut.rawValue, + // PDFKit has no PDFAnnotationSubtype.squiggly member (unsupported renderer), + // but Apple Markup still writes Adobe /Squiggly objects that we must count. + PDFAnnotationSubtype(rawValue: "/Squiggly").rawValue, + PDFAnnotationSubtype.freeText.rawValue, + PDFAnnotationSubtype.square.rawValue, + PDFAnnotationSubtype.circle.rawValue, + PDFAnnotationSubtype.line.rawValue, + PDFAnnotationSubtype.stamp.rawValue, + PDFAnnotationSubtype.text.rawValue, + ] + + // .link ignored: a PDF hyperlink is structural, not a human mark. + // .popup ignored: it is always the companion of another annotation; counting it + // would double-count a single human mark as two. + // .widget ignored: a form field. Shotdeck's own PASS/FAIL boxes are page content + // (drawn by WP-2), never PDFAnnotation objects — a widget seen here can only be + // introduced by a third-party tool flattening/reopening the file, and is not a + // human mark either way. + + /// PDFKit's `PDFAnnotation.type` may omit the leading slash that + /// `PDFAnnotationSubtype.rawValue` includes; compare against the slash form. + private static func pdfTypeName(_ type: String) -> String { + type.hasPrefix("/") ? type : "/" + type + } + + private static func isHumanMark(_ annotation: PDFAnnotation) -> Bool { + guard let raw = annotation.type else { return false } + let type = pdfTypeName(raw) + guard humanMarkTypes.contains(type) else { return false } + if type == PDFAnnotationSubtype.ink.rawValue { + let b = annotation.bounds + return b.width > 0 && b.height > 0 // zero-area ink = an undone stroke, not a mark + } + return true + } + + /// Opens the PDF at fileURL and reports which pages carry a genuine human mark. + /// Throws ShotdeckError.manifestCorrupt(path: fileURL.path) if PDFDocument cannot open it. + /// File modification date is never consulted; only persisted PDFAnnotation objects count. + public static func inspect(fileURL: URL) throws -> ReturnedDocument { + guard let document = PDFDocument(url: fileURL) else { + throw ShotdeckError.manifestCorrupt(path: fileURL.path) + } + var annotatedPages: [Int] = [] + for index in 0.. Bool { + if let creator = document.documentAttributes?[PDFDocumentAttribute.creatorAttribute] as? String { + return creator == "Shotdeck" // present creator is authoritative, full stop + } + // Creator ABSENT (some apps rewrite metadata on save) -> filename fallback only here. + guard let name = document.documentURL?.lastPathComponent else { return false } + // .lastPathComponent on a file URL is already percent-decoded; do not use .absoluteString. + return name.wholeMatch(of: /^Shotdeck-\d{8}-\d{6}( \d+)?\.pdf$/) != nil + // Case-sensitive by construction (Swift Regex literals are case-sensitive by default). + // The optional "( \d+)?" is macOS's duplicate-name suffix AirDrop adds when a file of + // the same name already exists in the watch folder — the normal case for a return. + } +} diff --git a/Sources/ShotdeckCore/Returns/ReturnLedger.swift b/Sources/ShotdeckCore/Returns/ReturnLedger.swift new file mode 100644 index 0000000..a1c8fa9 --- /dev/null +++ b/Sources/ShotdeckCore/Returns/ReturnLedger.swift @@ -0,0 +1,60 @@ +import Foundation + +public actor ReturnLedger { + private let fileURL: URL + private var entries: [URL: ReturnedDocument] + + /// Loads `returns.json` under paths.root if it exists; starts empty otherwise. + /// A file that cannot be decoded is renamed (never deleted) and the ledger starts empty. + public init(paths: AppSupportPaths) throws { + self.fileURL = paths.root.appendingPathComponent("returns.json") + if FileManager.default.fileExists(atPath: fileURL.path) { + let data = try Data(contentsOf: fileURL) + do { + let decoded = try JSONDecoder().decode([ReturnedDocument].self, from: data) + entries = Dictionary(decoded.map { ($0.fileURL, $0) }, uniquingKeysWith: { _, new in new }) + } catch { + let stamp = DubaiTime.fileStamp(Date()) + let corruptURL = fileURL.deletingLastPathComponent() + .appendingPathComponent("returns.json.corrupt-\(stamp)") + try FileManager.default.moveItem(at: fileURL, to: corruptURL) + Log.returns.error( + "returns.json could not be decoded; moved to \(corruptURL.path, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + entries = [:] + } + } else { + entries = [:] + } + } + + /// Upserts by fileURL — recording the same URL again replaces the prior entry + /// (the most recently recorded call wins, regardless of its detectedAt value). + public func record(_ document: ReturnedDocument) throws { + entries[document.fileURL] = document + try persist() + } + + /// Every recorded return, newest detectedAt first. + public func all() throws -> [ReturnedDocument] { + entries.values.sorted { $0.detectedAt > $1.detectedAt } + } + + /// Commented returns (isCommented == true), newest detectedAt first. + public func commented() throws -> [ReturnedDocument] { + try all().filter(\.isCommented) + } + + /// Absolute POSIX paths of commented returns, newest first, one per line, no + /// trailing newline. Throws ShotdeckError.noCommentedReturns when commented() is empty. + public func clipboardText() throws -> String { + let paths = try commented().map { $0.fileURL.path } + guard !paths.isEmpty else { throw ShotdeckError.noCommentedReturns } + return paths.joined(separator: "\n") + } + + private func persist() throws { + let data = try JSONEncoder().encode(Array(entries.values)) + try AtomicFile.write(data, to: fileURL) + } +} diff --git a/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift b/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift new file mode 100644 index 0000000..a7f84c0 --- /dev/null +++ b/Tests/ShotdeckCoreTests/AnnotationInspectorTests.swift @@ -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.. (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) +} diff --git a/Tests/ShotdeckCoreTests/ReturnLedgerTests.swift b/Tests/ShotdeckCoreTests/ReturnLedgerTests.swift new file mode 100644 index 0000000..fc9b4dc --- /dev/null +++ b/Tests/ShotdeckCoreTests/ReturnLedgerTests.swift @@ -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))) +}