Merge pull request 'WP-5b: FSEvents ReturnWatcher' (#7) from wp5b/return-watcher-20260831 into feat/shotdeck-20260830
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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-wp5b-\(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)
|
||||
}
|
||||
|
||||
/// Guards `confirmation(expectedCount: 1)` against a create+rename double-event.
|
||||
private final class ConfirmOnce: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var fired = false
|
||||
func run(_ body: () -> Void) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !fired else { return }
|
||||
fired = true
|
||||
body()
|
||||
}
|
||||
}
|
||||
|
||||
@Test("W-25 Duplicate-name suffix is the normal AirDrop return (scanNow)")
|
||||
func w25_duplicateNameSuffixIsRecognizedByScanNow() async throws {
|
||||
let (paths, cleanup) = try makeCasePaths()
|
||||
defer { try? FileManager.default.removeItem(at: cleanup) }
|
||||
|
||||
let ledger = try ReturnLedger(paths: paths)
|
||||
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
|
||||
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-134205 2.pdf")
|
||||
|
||||
try makePDF(
|
||||
at: pdfURL,
|
||||
pageCount: 1,
|
||||
creator: nil,
|
||||
subject: "33333333-3333-3333-3333-333333333333",
|
||||
annotations: [(page: 0, annotation: makeAnnotation(
|
||||
.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)
|
||||
))]
|
||||
)
|
||||
|
||||
let found = try await watcher.scanNow()
|
||||
#expect(found.count == 1)
|
||||
let doc = try #require(found.first)
|
||||
#expect(doc.fileURL.lastPathComponent == "Shotdeck-20260830-134205 2.pdf")
|
||||
#expect(doc.fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path)
|
||||
#expect(doc.pageCount == 1)
|
||||
#expect(doc.annotatedPages == [1])
|
||||
#expect(doc.isCommented == true)
|
||||
|
||||
let commented = try await ledger.commented()
|
||||
#expect(commented.count == 1)
|
||||
#expect(commented[0].fileURL.lastPathComponent == pdfURL.lastPathComponent)
|
||||
#expect(commented[0].fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path)
|
||||
}
|
||||
|
||||
@Test("W-26 Creator provenance negative at the scan level")
|
||||
func w26_presentNonShotdeckCreatorIsIgnoredByScanNow() async throws {
|
||||
let (paths, cleanup) = try makeCasePaths()
|
||||
defer { try? FileManager.default.removeItem(at: cleanup) }
|
||||
|
||||
let ledger = try ReturnLedger(paths: paths)
|
||||
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
|
||||
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 found = try await watcher.scanNow()
|
||||
#expect(found.isEmpty)
|
||||
let all = try await ledger.all()
|
||||
#expect(all.isEmpty)
|
||||
}
|
||||
|
||||
@Test("W-27 FSEvents live callback fires on a real file arrival")
|
||||
func w27_fsEventsCallbackFiresOnRealArrival() async throws {
|
||||
let (paths, cleanup) = try makeCasePaths()
|
||||
defer { try? FileManager.default.removeItem(at: cleanup) }
|
||||
|
||||
let ledger = try ReturnLedger(paths: paths)
|
||||
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
|
||||
let pdfURL = paths.watchFolder.appendingPathComponent("Shotdeck-20260830-140000.pdf")
|
||||
let once = ConfirmOnce()
|
||||
|
||||
try await confirmation("watcher reports the arrived PDF", expectedCount: 1) { confirm in
|
||||
do {
|
||||
try await watcher.start { docs in
|
||||
if docs.contains(where: { $0.fileURL.lastPathComponent == pdfURL.lastPathComponent }) {
|
||||
once.run { confirm() }
|
||||
}
|
||||
}
|
||||
// Simulate AirDrop's own write-then-rename so the watcher must survive that pattern.
|
||||
let tmpURL = pdfURL.appendingPathExtension("inprogress")
|
||||
try makePDF(
|
||||
at: tmpURL, pageCount: 1, creator: "Shotdeck",
|
||||
annotations: [(page: 0, annotation: makeAnnotation(
|
||||
.ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50)
|
||||
))]
|
||||
)
|
||||
try FileManager.default.moveItem(at: tmpURL, to: pdfURL)
|
||||
} catch {
|
||||
print("fsEventsCallbackFiresOnRealArrival error: \(error)")
|
||||
await watcher.stop()
|
||||
throw error
|
||||
}
|
||||
// Budget: 400ms debounce + FS latency + the 250ms stability re-read + PDFKit open.
|
||||
// 5s is a generous, fixed ceiling — never a "some time" wait.
|
||||
try await Task.sleep(for: .seconds(5))
|
||||
await watcher.stop()
|
||||
await watcher.stop()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user