import CoreGraphics import Foundation import ImageIO import Testing import ShotdeckCore // MARK: - 1. append writes a real, decodable PNG @Test func appendWritesARealDecodablePNGMatchingSourceDimensions() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let width = 12 let height = 7 let png = try makePNGData(width: width, height: height, red: 0.9, green: 0.1, blue: 0.2) let store = try SpoolStore(paths: paths) let capturedAt = Date(timeIntervalSince1970: 1_700_000_000) let capture = try await store.append( pngData: png, pixelWidth: width, pixelHeight: height, scale: 2.0, capturedAt: capturedAt) let session = try await store.currentSession() #expect(capture.pixelWidth == width) #expect(capture.pixelHeight == height) let url = await store.imageURL(for: capture, in: session) let expected = paths.sessionDirectory(session.id).appendingPathComponent(capture.fileName) #expect(url.path == expected.path) #expect(FileManager.default.fileExists(atPath: url.path)) let onDisk = try Data(contentsOf: url) #expect(onDisk == png) let decoded = try #require(pngDimensions(at: url)) #expect(decoded.width == width) #expect(decoded.height == height) } // MARK: - 2. remaining-manifest sequence rule (coordinator correction) @Test func sequencesFollowRemainingManifestMaxAndAreNotRenumbered() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let store = try SpoolStore(paths: paths) let c1 = try await store.append( pngData: try makePNGData(width: 4, height: 4, red: 1, green: 0, blue: 0), pixelWidth: 4, pixelHeight: 4, scale: 1.0, capturedAt: Date()) let c2 = try await store.append( pngData: try makePNGData(width: 6, height: 4, red: 0, green: 1, blue: 0), pixelWidth: 6, pixelHeight: 4, scale: 1.0, capturedAt: Date()) let c3 = try await store.append( pngData: try makePNGData(width: 8, height: 4, red: 0, green: 0, blue: 1), pixelWidth: 8, pixelHeight: 4, scale: 1.0, capturedAt: Date()) #expect(c1.sequence == 1) #expect(c2.sequence == 2) #expect(c3.sequence == 3) _ = try await store.remove(captureID: c2.id) let afterMiddle = try await store.currentSession() #expect(afterMiddle.captures.map(\.sequence) == [1, 3]) #expect(afterMiddle.captures.map(\.id) == [c1.id, c3.id]) let c4 = try await store.append( pngData: try makePNGData(width: 10, height: 4, red: 1, green: 1, blue: 0), pixelWidth: 10, pixelHeight: 4, scale: 1.0, capturedAt: Date()) #expect(c4.sequence == 4) _ = try await store.remove(captureID: c4.id) let afterLast = try await store.currentSession() #expect(afterLast.captures.map(\.sequence) == [1, 3]) let c4b = try await store.append( pngData: try makePNGData(width: 12, height: 4, red: 1, green: 0, blue: 1), pixelWidth: 12, pixelHeight: 4, scale: 1.0, capturedAt: Date()) #expect(c4b.sequence == 4) #expect(c4b.fileName != c4.fileName) #expect(c4b.id != c4.id) let sessionDir = paths.sessionDirectory(afterLast.id) #expect(FileManager.default.fileExists(atPath: sessionDir.appendingPathComponent(c4b.fileName).path)) #expect(FileManager.default.fileExists( atPath: sessionDir.appendingPathComponent("removed", isDirectory: true) .appendingPathComponent(c4.fileName).path)) #expect(!FileManager.default.fileExists(atPath: sessionDir.appendingPathComponent(c4.fileName).path)) } // MARK: - 3. remove moves PNG into removed/ @Test func removeMovesThePNGUnchangedIntoRemovedAndLeavesTheOriginalPathEmpty() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let store = try SpoolStore(paths: paths) let png = try makePNGData(width: 8, height: 5, red: 0.2, green: 0.5, blue: 0.8) let capture = try await store.append( pngData: png, pixelWidth: 8, pixelHeight: 5, scale: 2.0, capturedAt: Date()) let session = try await store.currentSession() let sessionDir = paths.sessionDirectory(session.id) let originalURL = sessionDir.appendingPathComponent(capture.fileName) let removedURL = sessionDir.appendingPathComponent("removed", isDirectory: true) .appendingPathComponent(capture.fileName) let updated = try await store.remove(captureID: capture.id) #expect(updated.captures.contains(where: { $0.id == capture.id }) == false) #expect(!FileManager.default.fileExists(atPath: originalURL.path)) #expect(FileManager.default.fileExists(atPath: removedURL.path)) let moved = try Data(contentsOf: removedURL) #expect(moved == png) } // MARK: - 4. remove of unknown id throws with zero filesystem change @Test func removeOfUnknownIDThrowsAndLeavesDiskByteIdentical() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let store = try SpoolStore(paths: paths) _ = try await store.append( pngData: try makePNGData(width: 4, height: 4, red: 0.1, green: 0.2, blue: 0.3), pixelWidth: 4, pixelHeight: 4, scale: 1.0, capturedAt: Date()) let beforeSession = try await store.currentSession() let beforeFiles = try snapshotFiles(under: root) do { _ = try await store.remove(captureID: UUID()) Issue.record("expected remove of an unknown id to throw") } catch let error as ShotdeckError { guard case .spoolWriteFailed = error else { Issue.record("expected spoolWriteFailed, got \(error)") return } } catch { Issue.record("expected ShotdeckError, got \(error)") return } let afterSession = try await store.currentSession() #expect(afterSession == beforeSession) let afterFiles = try snapshotFiles(under: root) #expect(afterFiles == beforeFiles) } // MARK: - 5. orphan recovery @Test func orphanPNGWrittenBehindTheStoreIsRecoveredOnReopen() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let store = try SpoolStore(paths: paths) let session = try await store.currentSession() let width = 11 let height = 9 let png = try makePNGData(width: width, height: height, red: 0.4, green: 0.7, blue: 0.1) let orphanName = "007-ABCD1234.png" let dest = paths.sessionDirectory(session.id).appendingPathComponent(orphanName) try AtomicFile.write(png, to: dest) let reopened = try SpoolStore(paths: paths) let recovered = try await reopened.currentSession() let match = try #require(recovered.captures.first { $0.fileName == orphanName }) #expect(match.pixelWidth == width) #expect(match.pixelHeight == height) #expect(match.sequence == 7) #expect(match.scale == 1.0) } // MARK: - 6. removed/ files are not resurrected @Test func orphanRecoveryDoesNotResurrectFilesInRemoved() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let store = try SpoolStore(paths: paths) let session = try await store.currentSession() let sessionDir = paths.sessionDirectory(session.id) let removedDir = sessionDir.appendingPathComponent("removed", isDirectory: true) try FileManager.default.createDirectory(at: removedDir, withIntermediateDirectories: true) let hiddenName = "009-FEEDFACE.png" try AtomicFile.write( try makePNGData(width: 5, height: 5, red: 0.9, green: 0.9, blue: 0.1), to: removedDir.appendingPathComponent(hiddenName)) let reopened = try SpoolStore(paths: paths) let reconciled = try await reopened.currentSession() #expect(reconciled.captures.contains(where: { $0.fileName == hiddenName }) == false) #expect(FileManager.default.fileExists(atPath: removedDir.appendingPathComponent(hiddenName).path)) } // MARK: - 7. missing-image drop @Test func missingPNGReferencedByManifestIsDroppedAndOthersSurvive() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let store = try SpoolStore(paths: paths) let keep = try await store.append( pngData: try makePNGData(width: 6, height: 6, red: 0.1, green: 0.8, blue: 0.2), pixelWidth: 6, pixelHeight: 6, scale: 1.0, capturedAt: Date()) let drop = try await store.append( pngData: try makePNGData(width: 7, height: 7, red: 0.8, green: 0.1, blue: 0.2), pixelWidth: 7, pixelHeight: 7, scale: 1.0, capturedAt: Date()) let session = try await store.currentSession() try FileManager.default.removeItem( at: paths.sessionDirectory(session.id).appendingPathComponent(drop.fileName)) let reopened = try SpoolStore(paths: paths) let reconciled = try await reopened.currentSession() #expect(reconciled.captures.map(\.id) == [keep.id]) #expect(reconciled.captures.contains(where: { $0.id == drop.id }) == false) } // MARK: - 8. corrupt manifest rebuild @Test func corruptManifestIsQuarantinedAndPNGsAreRebuiltIntoANewManifest() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let store = try SpoolStore(paths: paths) let first = try await store.append( pngData: try makePNGData(width: 8, height: 6, red: 0.3, green: 0.3, blue: 0.9), pixelWidth: 8, pixelHeight: 6, scale: 2.0, capturedAt: Date()) let second = try await store.append( pngData: try makePNGData(width: 9, height: 6, red: 0.9, green: 0.3, blue: 0.3), pixelWidth: 9, pixelHeight: 6, scale: 2.0, capturedAt: Date()) let session = try await store.currentSession() let sessionDir = paths.sessionDirectory(session.id) let manifestURL = sessionDir.appendingPathComponent("session.json") let garbage = Data("{ not json".utf8) try AtomicFile.write(garbage, to: manifestURL) let reopened = try SpoolStore(paths: paths) let rebuilt = try await reopened.currentSession() #expect(rebuilt.id == session.id) let contents = try FileManager.default.contentsOfDirectory(at: sessionDir, includingPropertiesForKeys: nil) let corruptFiles = contents.filter { $0.lastPathComponent.hasPrefix("session.json.corrupt-") } #expect(corruptFiles.count == 1) let quarantined = try Data(contentsOf: try #require(corruptFiles.first)) #expect(quarantined == garbage) let names = Set(rebuilt.captures.map(\.fileName)) #expect(names.contains(first.fileName)) #expect(names.contains(second.fileName)) let recoveredFirst = try #require(rebuilt.captures.first { $0.fileName == first.fileName }) #expect(recoveredFirst.pixelWidth == 8) #expect(recoveredFirst.pixelHeight == 6) } // MARK: - 9. archiveCurrent moves the directory @Test func archiveCurrentMovesTheSessionUnderArchiveAndOpensAFreshEmptySession() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let store = try SpoolStore(paths: paths) let png = try makePNGData(width: 10, height: 8, red: 0.5, green: 0.1, blue: 0.6) let capture = try await store.append( pngData: png, pixelWidth: 10, pixelHeight: 8, scale: 1.0, capturedAt: Date()) let open = try await store.currentSession() let archived = try await store.archiveCurrent(pdfFileName: "shotdeck-review.pdf") #expect(archived.state == .archived) #expect(archived.pdfFileName == "shotdeck-review.pdf") #expect(archived.id == open.id) #expect(!FileManager.default.fileExists(atPath: paths.sessionDirectory(open.id).path)) let archivedPNG = paths.archiveDirectory(open.id).appendingPathComponent(capture.fileName) #expect(FileManager.default.fileExists(atPath: archivedPNG.path)) #expect(try Data(contentsOf: archivedPNG) == png) let current = try await store.currentSession() #expect(current.id != open.id) #expect(current.isEmpty) #expect(current.state == .open) #expect(FileManager.default.fileExists( atPath: paths.sessionDirectory(current.id).appendingPathComponent("session.json").path)) } // MARK: - 10. archiveCurrent on empty throws @Test func archiveCurrentOnEmptySessionThrowsAndDoesNotMoveTheDirectory() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let store = try SpoolStore(paths: paths) let session = try await store.currentSession() let before = try snapshotFiles(under: root) do { _ = try await store.archiveCurrent(pdfFileName: "never.pdf") Issue.record("expected archiveCurrent on an empty session to throw") } catch let error as ShotdeckError { guard case .spoolWriteFailed = error else { Issue.record("expected spoolWriteFailed, got \(error)") return } } catch { Issue.record("expected ShotdeckError, got \(error)") return } #expect(FileManager.default.fileExists(atPath: paths.sessionDirectory(session.id).path)) #expect(try snapshotFiles(under: root) == before) } // MARK: - 11. interrupted-archive, both exist @Test func interruptedArchiveBothExistSupersedesTheSpoolCopyAndKeepsArchive() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let store = try SpoolStore(paths: paths) let png = try makePNGData(width: 8, height: 8, red: 0.2, green: 0.2, blue: 0.8) let capture = try await store.append( pngData: png, pixelWidth: 8, pixelHeight: 8, scale: 1.0, capturedAt: Date()) let archived = try await store.archiveCurrent(pdfFileName: "sent.pdf") let archiveDir = paths.archiveDirectory(archived.id) let spoolCopy = paths.sessionDirectory(archived.id) try FileManager.default.copyItem(at: archiveDir, to: spoolCopy) let archivePNGBefore = try Data(contentsOf: archiveDir.appendingPathComponent(capture.fileName)) let reopened = try SpoolStore(paths: paths) _ = try await reopened.currentSession() #expect(FileManager.default.fileExists(atPath: archiveDir.path)) #expect(!FileManager.default.fileExists(atPath: spoolCopy.path)) #expect(try Data(contentsOf: archiveDir.appendingPathComponent(capture.fileName)) == archivePNGBefore) let spoolEntries = try FileManager.default.contentsOfDirectory( at: paths.spool, includingPropertiesForKeys: [.isDirectoryKey]) let superseded = spoolEntries.filter { $0.lastPathComponent.hasPrefix("\(archived.id.uuidString).superseded-") } #expect(superseded.count == 1) let supersededPNG = try #require(superseded.first).appendingPathComponent(capture.fileName) #expect(FileManager.default.fileExists(atPath: supersededPNG.path)) #expect(try Data(contentsOf: supersededPNG) == png) } // MARK: - 12. interrupted-archive, manifest-only @Test func interruptedArchiveManifestOnlyCompletesTheMoveIntoArchive() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let store = try SpoolStore(paths: paths) let png = try makePNGData(width: 6, height: 8, red: 0.7, green: 0.4, blue: 0.1) let capture = try await store.append( pngData: png, pixelWidth: 6, pixelHeight: 8, scale: 1.0, capturedAt: Date()) let session = try await store.currentSession() let marked = session.markArchived(pdfFileName: "partial.pdf") try AtomicFile.writeJSON( marked, to: paths.sessionDirectory(session.id).appendingPathComponent("session.json")) let reopened = try SpoolStore(paths: paths) let current = try await reopened.currentSession() #expect(current.id != session.id) #expect(!FileManager.default.fileExists(atPath: paths.sessionDirectory(session.id).path)) let archiveDir = paths.archiveDirectory(session.id) #expect(FileManager.default.fileExists(atPath: archiveDir.path)) #expect(FileManager.default.fileExists(atPath: archiveDir.appendingPathComponent(capture.fileName).path)) #expect(try Data(contentsOf: archiveDir.appendingPathComponent(capture.fileName)) == png) let archived = try await reopened.archivedSessions() #expect(archived.contains(where: { $0.id == session.id })) } // MARK: - 13. archivedSessions newest createdAt first @Test func archivedSessionsReturnsNewestCreatedAtFirst() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let newest = UUID(uuidString: "00000000-0000-4000-8000-000000000003")! let oldest = UUID(uuidString: "00000000-0000-4000-8000-000000000001")! let middle = UUID(uuidString: "00000000-0000-4000-8000-000000000002")! let tNewest = Date(timeIntervalSince1970: 1_700_000_200) let tOldest = Date(timeIntervalSince1970: 1_700_000_000) let tMiddle = Date(timeIntervalSince1970: 1_700_000_100) try seedSession(id: newest, createdAt: tNewest, state: .archived, directory: paths.archiveDirectory(newest)) try seedSession(id: oldest, createdAt: tOldest, state: .archived, directory: paths.archiveDirectory(oldest)) try seedSession(id: middle, createdAt: tMiddle, state: .archived, directory: paths.archiveDirectory(middle)) let store = try SpoolStore(paths: paths) let listed = try await store.archivedSessions() #expect(listed.map(\.id) == [newest, middle, oldest]) } // MARK: - 14. newest-session tie-break by greater UUID string @Test func newestSessionTieBreakPicksTheLexicographicallyGreaterUUID() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let smaller = UUID(uuidString: "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA")! let greater = UUID(uuidString: "BBBBBBBB-BBBB-4BBB-8BBB-BBBBBBBBBBBB")! let createdAt = Date(timeIntervalSince1970: 1_700_000_500) try seedSession(id: smaller, createdAt: createdAt, state: .open, directory: paths.sessionDirectory(smaller)) try seedSession(id: greater, createdAt: createdAt, state: .open, directory: paths.sessionDirectory(greater)) let first = try SpoolStore(paths: paths) let firstID = try await first.currentSession().id let second = try SpoolStore(paths: paths) let secondID = try await second.currentSession().id #expect(firstID == greater) #expect(secondID == greater) } // MARK: - 15. filename shape @Test func appendFilenameMatchesSequenceDashEightHexSuffix() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let store = try SpoolStore(paths: paths) let capture = try await store.append( pngData: try makePNGData(width: 4, height: 3, red: 0.1, green: 0.1, blue: 0.1), pixelWidth: 4, pixelHeight: 3, scale: 1.0, capturedAt: Date()) let hex = String(capture.id.uuidString.replacingOccurrences(of: "-", with: "").prefix(8)).uppercased() #expect(capture.fileName == "001-\(hex).png") let session = try await store.currentSession() let url = await store.imageURL(for: capture, in: session) #expect(url.lastPathComponent == capture.fileName) #expect(FileManager.default.fileExists(atPath: url.path)) } // MARK: - 16. startNewSession @Test func startNewSessionThrowsWhenNonEmptyAndMintsANewIdWhenEmpty() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let store = try SpoolStore(paths: paths) _ = try await store.append( pngData: try makePNGData(width: 5, height: 5, red: 0.4, green: 0.2, blue: 0.6), pixelWidth: 5, pixelHeight: 5, scale: 1.0, capturedAt: Date()) let before = try snapshotFiles(under: root) let occupied = try await store.currentSession() do { _ = try await store.startNewSession() Issue.record("expected startNewSession to throw while captures are present") } catch let error as ShotdeckError { guard case .spoolWriteFailed = error else { Issue.record("expected spoolWriteFailed, got \(error)") return } } catch { Issue.record("expected ShotdeckError, got \(error)") return } #expect(try snapshotFiles(under: root) == before) #expect(try await store.currentSession().id == occupied.id) _ = try await store.remove(captureID: occupied.captures[0].id) let emptyID = try await store.currentSession().id let fresh = try await store.startNewSession() #expect(fresh.id != emptyID) #expect(fresh.isEmpty) #expect(FileManager.default.fileExists( atPath: paths.sessionDirectory(emptyID).appendingPathComponent("session.json").path)) } // MARK: - 17. durability-ordering smoke test @Test func appendReturnsOnlyAfterThePNGBytesAreAlreadyOnDisk() async throws { let (root, paths) = try makeIsolatedPaths() defer { try? FileManager.default.removeItem(at: root) } let store = try SpoolStore(paths: paths) let png = try makePNGData(width: 13, height: 11, red: 0.15, green: 0.55, blue: 0.95) let capture = try await store.append( pngData: png, pixelWidth: 13, pixelHeight: 11, scale: 2.0, capturedAt: Date()) let session = try await store.currentSession() let url = paths.sessionDirectory(session.id).appendingPathComponent(capture.fileName) let onDisk = try Data(contentsOf: url) #expect(onDisk == png) } // MARK: - Fixtures private func makeIsolatedPaths() throws -> (root: URL, paths: AppSupportPaths) { let root = FileManager.default.temporaryDirectory .appendingPathComponent("shotdeck-spool-\(UUID().uuidString)", isDirectory: true) let paths = try AppSupportPaths( root: root, outbox: root.appendingPathComponent("outbox", isDirectory: true), watchFolder: root.appendingPathComponent("watch", isDirectory: true) ) return (root, paths) } private func makePNGData( 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 FixtureError.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 FixtureError.pngGenerationFailed } let buffer = NSMutableData() guard let destination = CGImageDestinationCreateWithData(buffer, "public.png" as CFString, 1, nil) else { throw FixtureError.pngGenerationFailed } CGImageDestinationAddImage(destination, image, nil) guard CGImageDestinationFinalize(destination) else { throw FixtureError.pngGenerationFailed } return buffer as Data } private func pngDimensions(at url: URL) -> (width: Int, height: Int)? { guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as NSDictionary?, let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue, let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue else { return nil } return (width, height) } private func seedSession( id: UUID, createdAt: Date, state: SessionState, directory: URL ) throws { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let session = CaptureSession(id: id, createdAt: createdAt, state: state, captures: [], pdfFileName: nil) try AtomicFile.writeJSON(session, to: directory.appendingPathComponent("session.json")) } private func snapshotFiles(under root: URL) throws -> [String: Data] { let fm = FileManager.default var files: [String: Data] = [:] guard let enumerator = fm.enumerator( at: root, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) else { return files } let rootPath = root.standardizedFileURL.path for case let url as URL in enumerator { let values = try url.resourceValues(forKeys: [.isRegularFileKey]) guard values.isRegularFile == true else { continue } var relative = url.standardizedFileURL.path if relative.hasPrefix(rootPath) { relative = String(relative.dropFirst(rootPath.count)) if relative.hasPrefix("/") { relative = String(relative.dropFirst()) } } files[relative] = try Data(contentsOf: url) } return files } private enum FixtureError: Error { case pngGenerationFailed }