320 lines
12 KiB
Swift
320 lines
12 KiB
Swift
import CoreGraphics
|
|
import Foundation
|
|
import ImageIO
|
|
import Testing
|
|
import ShotdeckCore
|
|
|
|
@Test
|
|
func recordTwelvePDFsKeepsTenNewestAndDeletesOldestCopies() async throws {
|
|
let (root, paths) = try makeHistoryPaths()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
|
|
let store = try HistoryStore(paths: paths)
|
|
let sources = root.appendingPathComponent("user-sources", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: sources, withIntermediateDirectories: true)
|
|
|
|
var recorded: [HistoryEntry] = []
|
|
for i in 0..<12 {
|
|
let source = sources.appendingPathComponent("source-\(i).pdf")
|
|
try writeDummyPDF(to: source, marker: "pdf-\(i)")
|
|
let sentAt = Date(timeIntervalSince1970: 1_800_000_000 + TimeInterval(i))
|
|
let entry = try await store.recordSentPDF(
|
|
sourceURL: source,
|
|
sessionID: UUID(),
|
|
pageCount: i + 1,
|
|
sentAt: sentAt
|
|
)
|
|
recorded.append(entry)
|
|
}
|
|
|
|
let listed = await store.listPDFs()
|
|
#expect(listed.count == 10)
|
|
#expect(listed.map(\.id) == recorded.suffix(10).reversed().map(\.id))
|
|
#expect(listed.map(\.sentAt) == recorded.suffix(10).reversed().map(\.sentAt))
|
|
|
|
let pdfsDir = paths.root.appendingPathComponent("history/pdfs", isDirectory: true)
|
|
for entry in recorded.prefix(2) {
|
|
#expect(!FileManager.default.fileExists(atPath: pdfsDir.appendingPathComponent(entry.fileName).path))
|
|
}
|
|
for entry in recorded.suffix(10) {
|
|
#expect(FileManager.default.fileExists(atPath: pdfsDir.appendingPathComponent(entry.fileName).path))
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func pruneImagesKeepsThirtyNewestAcrossThreeArchivedSessionsAndLeavesOpenSessionAlone() async throws {
|
|
let (root, paths) = try makeHistoryPaths()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
|
|
let spool = try SpoolStore(paths: paths)
|
|
let base = Date(timeIntervalSince1970: 1_800_100_000)
|
|
var captures: [(id: UUID, capturedAt: Date, sessionID: UUID)] = []
|
|
|
|
// 5 oldest + 15 + 15 = 35 archived PNGs. The oldest session is emptied by prune.
|
|
let perSession = [5, 15, 15]
|
|
var index = 0
|
|
for count in perSession {
|
|
let sessionID = try await spool.currentSession().id
|
|
for _ in 0..<count {
|
|
let capturedAt = base.addingTimeInterval(TimeInterval(index))
|
|
let capture = try await spool.append(
|
|
pngData: try makeHistoryPNGData(width: 6, height: 4, red: 0.2, green: 0.3, blue: 0.4),
|
|
pixelWidth: 6,
|
|
pixelHeight: 4,
|
|
scale: 1.0,
|
|
capturedAt: capturedAt
|
|
)
|
|
captures.append((capture.id, capturedAt, sessionID))
|
|
index += 1
|
|
}
|
|
_ = try await spool.archiveCurrent(pdfFileName: "Redline-hist-\(sessionID.uuidString).pdf")
|
|
}
|
|
|
|
let openBefore = try await spool.currentSession()
|
|
let openCapture = try await spool.append(
|
|
pngData: try makeHistoryPNGData(width: 8, height: 6, red: 0.9, green: 0.1, blue: 0.1),
|
|
pixelWidth: 8,
|
|
pixelHeight: 6,
|
|
scale: 1.0,
|
|
capturedAt: Date(timeIntervalSince1970: 1_900_000_000)
|
|
)
|
|
let openSession = try await spool.currentSession()
|
|
#expect(openSession.id == openBefore.id)
|
|
let openDir = paths.sessionDirectory(openSession.id)
|
|
let openPNG = openDir.appendingPathComponent(openCapture.fileName)
|
|
let openPNGBytes = try Data(contentsOf: openPNG)
|
|
let openManifest = try Data(contentsOf: openDir.appendingPathComponent("session.json"))
|
|
|
|
let store = try HistoryStore(paths: paths)
|
|
try await store.pruneImages()
|
|
|
|
let remaining = try await store.listImages(limit: 50)
|
|
#expect(remaining.count == 30)
|
|
|
|
let newestThirty = Array(captures.suffix(30))
|
|
let remainingDates = Set(remaining.map(\.capturedAt))
|
|
#expect(remainingDates == Set(newestThirty.map(\.capturedAt)))
|
|
#expect(remaining.map(\.sessionID).allSatisfy { $0 != openSession.id })
|
|
|
|
let oldestFive = Array(captures.prefix(5))
|
|
for old in oldestFive {
|
|
let archiveDir = paths.archiveDirectory(old.sessionID)
|
|
#expect(!FileManager.default.fileExists(atPath: archiveDir.path))
|
|
}
|
|
|
|
#expect(pngFiles(under: paths.archive).count == 30)
|
|
try assertManifestsMatchDisk(archiveRoot: paths.archive)
|
|
|
|
#expect(FileManager.default.fileExists(atPath: openPNG.path))
|
|
#expect(try Data(contentsOf: openPNG) == openPNGBytes)
|
|
#expect(try Data(contentsOf: openDir.appendingPathComponent("session.json")) == openManifest)
|
|
#expect(try await spool.currentSession().captures.map(\.id) == [openCapture.id])
|
|
}
|
|
|
|
@Test
|
|
func recordSentPDFLeavesTheUserSourceUntouched() async throws {
|
|
let (root, paths) = try makeHistoryPaths()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
|
|
let source = root.appendingPathComponent("outside-appsupport.pdf")
|
|
let payload = Data("%PDF-1.4\n%user-original\n%%EOF\n".utf8)
|
|
try payload.write(to: source)
|
|
|
|
let store = try HistoryStore(paths: paths)
|
|
let entry = try await store.recordSentPDF(
|
|
sourceURL: source,
|
|
sessionID: UUID(),
|
|
pageCount: 2,
|
|
sentAt: Date(timeIntervalSince1970: 1_800_200_000)
|
|
)
|
|
|
|
#expect(FileManager.default.fileExists(atPath: source.path))
|
|
#expect(try Data(contentsOf: source) == payload)
|
|
#expect(entry.fileURL.path != source.path)
|
|
#expect(try Data(contentsOf: entry.fileURL) == payload)
|
|
#expect(entry.fileURL.path.hasPrefix(
|
|
paths.root.appendingPathComponent("history/pdfs", isDirectory: true).path
|
|
))
|
|
}
|
|
|
|
@Test
|
|
func recordingTheSameSourceTwiceMakesTwoDistinctCopies() async throws {
|
|
let (root, paths) = try makeHistoryPaths()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
|
|
let source = root.appendingPathComponent("resend.pdf")
|
|
try writeDummyPDF(to: source, marker: "same-source")
|
|
|
|
let store = try HistoryStore(paths: paths)
|
|
let first = try await store.recordSentPDF(
|
|
sourceURL: source,
|
|
sessionID: nil,
|
|
pageCount: 1,
|
|
sentAt: Date(timeIntervalSince1970: 1_800_300_000)
|
|
)
|
|
let second = try await store.recordSentPDF(
|
|
sourceURL: source,
|
|
sessionID: nil,
|
|
pageCount: 1,
|
|
sentAt: Date(timeIntervalSince1970: 1_800_300_001)
|
|
)
|
|
|
|
#expect(first.id != second.id)
|
|
#expect(first.fileName != second.fileName)
|
|
#expect(first.fileURL.path != second.fileURL.path)
|
|
#expect(FileManager.default.fileExists(atPath: first.fileURL.path))
|
|
#expect(FileManager.default.fileExists(atPath: second.fileURL.path))
|
|
let firstBytes = try Data(contentsOf: first.fileURL)
|
|
let secondBytes = try Data(contentsOf: second.fileURL)
|
|
#expect(firstBytes == secondBytes)
|
|
#expect(FileManager.default.fileExists(atPath: source.path))
|
|
|
|
let listed = await store.listPDFs()
|
|
#expect(listed.count == 2)
|
|
#expect(Set(listed.map(\.id)) == [first.id, second.id])
|
|
}
|
|
|
|
@Test
|
|
func pruneImagesDeletesOlderPNGsInRemovedFolders() async throws {
|
|
let (root, paths) = try makeHistoryPaths()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
|
|
let spool = try SpoolStore(paths: paths)
|
|
let base = Date(timeIntervalSince1970: 1_800_400_000)
|
|
for i in 0..<30 {
|
|
_ = try await spool.append(
|
|
pngData: try makeHistoryPNGData(width: 4, height: 4, red: 0.1, green: 0.2, blue: 0.3),
|
|
pixelWidth: 4,
|
|
pixelHeight: 4,
|
|
scale: 1.0,
|
|
capturedAt: base.addingTimeInterval(TimeInterval(10 + i))
|
|
)
|
|
}
|
|
_ = try await spool.archiveCurrent(pdfFileName: "Redline-keep.pdf")
|
|
|
|
let oldSessionID = UUID()
|
|
let oldDir = paths.archiveDirectory(oldSessionID)
|
|
let removedDir = oldDir.appendingPathComponent("removed", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: removedDir, withIntermediateDirectories: true)
|
|
let oldPNG = removedDir.appendingPathComponent("001-DEADBEEF.png")
|
|
try makeHistoryPNGData(width: 4, height: 4, red: 0.5, green: 0.5, blue: 0.5).write(to: oldPNG)
|
|
try FileManager.default.setAttributes(
|
|
[.creationDate: base],
|
|
ofItemAtPath: oldPNG.path
|
|
)
|
|
let oldSession = CaptureSession(
|
|
id: oldSessionID,
|
|
createdAt: base,
|
|
state: .archived,
|
|
captures: [],
|
|
pdfFileName: "Redline-old.pdf"
|
|
)
|
|
try AtomicFile.writeJSON(oldSession, to: oldDir.appendingPathComponent("session.json"))
|
|
|
|
let store = try HistoryStore(paths: paths)
|
|
try await store.pruneImages()
|
|
|
|
#expect(!FileManager.default.fileExists(atPath: oldPNG.path))
|
|
#expect(pngFiles(under: paths.archive).count == 30)
|
|
}
|
|
|
|
// MARK: - Fixtures
|
|
|
|
private func makeHistoryPaths() throws -> (root: URL, paths: AppSupportPaths) {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("shotdeck-history-\(UUID().uuidString)", isDirectory: true)
|
|
let paths = try AppSupportPaths(
|
|
root: root.appendingPathComponent("root", isDirectory: true),
|
|
outbox: root.appendingPathComponent("outbox", isDirectory: true),
|
|
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
|
|
)
|
|
return (root, paths)
|
|
}
|
|
|
|
private func writeDummyPDF(to url: URL, marker: String) throws {
|
|
try Data("%PDF-1.4\n%\(marker)\n%%EOF\n".utf8).write(to: url)
|
|
}
|
|
|
|
private func makeHistoryPNGData(
|
|
width: Int,
|
|
height: Int,
|
|
red: CGFloat,
|
|
green: CGFloat,
|
|
blue: CGFloat
|
|
) throws -> Data {
|
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
guard let context = CGContext(
|
|
data: nil,
|
|
width: width,
|
|
height: height,
|
|
bitsPerComponent: 8,
|
|
bytesPerRow: width * 4,
|
|
space: colorSpace,
|
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
) else {
|
|
throw HistoryFixtureError.pngGenerationFailed
|
|
}
|
|
context.setFillColor(red: red, green: green, blue: blue, alpha: 1)
|
|
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
|
guard let image = context.makeImage() else {
|
|
throw HistoryFixtureError.pngGenerationFailed
|
|
}
|
|
let buffer = NSMutableData()
|
|
guard let destination = CGImageDestinationCreateWithData(buffer, "public.png" as CFString, 1, nil) else {
|
|
throw HistoryFixtureError.pngGenerationFailed
|
|
}
|
|
CGImageDestinationAddImage(destination, image, nil)
|
|
guard CGImageDestinationFinalize(destination) else {
|
|
throw HistoryFixtureError.pngGenerationFailed
|
|
}
|
|
return buffer as Data
|
|
}
|
|
|
|
private func pngFiles(under root: URL) -> [URL] {
|
|
let fm = FileManager.default
|
|
guard let enumerator = fm.enumerator(
|
|
at: root,
|
|
includingPropertiesForKeys: [.isRegularFileKey],
|
|
options: []
|
|
) else { return [] }
|
|
var urls: [URL] = []
|
|
for case let url as URL in enumerator {
|
|
let isFile = (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) ?? false
|
|
if isFile, url.pathExtension.lowercased() == "png" {
|
|
urls.append(url)
|
|
}
|
|
}
|
|
return urls
|
|
}
|
|
|
|
private func assertManifestsMatchDisk(archiveRoot: URL) throws {
|
|
let fm = FileManager.default
|
|
let sessions = (try fm.contentsOfDirectory(
|
|
at: archiveRoot,
|
|
includingPropertiesForKeys: [.isDirectoryKey],
|
|
options: []
|
|
)).filter {
|
|
((try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false)
|
|
&& UUID(uuidString: $0.lastPathComponent) != nil
|
|
}
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
for dir in sessions {
|
|
let manifestURL = dir.appendingPathComponent("session.json")
|
|
#expect(fm.fileExists(atPath: manifestURL.path))
|
|
let session = try decoder.decode(CaptureSession.self, from: Data(contentsOf: manifestURL))
|
|
for capture in session.captures {
|
|
let url = dir.appendingPathComponent(capture.fileName)
|
|
#expect(fm.fileExists(atPath: url.path))
|
|
}
|
|
let topLevelPNGs = (try fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil, options: []))
|
|
.filter { $0.pathExtension.lowercased() == "png" }
|
|
let manifestNames = Set(session.captures.map(\.fileName))
|
|
#expect(Set(topLevelPNGs.map(\.lastPathComponent)) == manifestNames)
|
|
}
|
|
}
|
|
|
|
private enum HistoryFixtureError: Error {
|
|
case pngGenerationFailed
|
|
}
|