feature: sent-PDF history (10) + image retention (30) with pruning per Ben's 2026-09-02 ruling
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user