import XCTest import Foundation import ForgeCamera import ForgeGrade import ForgeColor import ForgeLibrary @testable import ForgeLogger /// Minimal scriptable driver for logger tests. /// Continuations registered synchronously under a lock — emits immediately /// after `state` subscription are never lost. actor ScriptedDriver: CameraDriver { nonisolated let capabilities = CameraCapabilities( vendor: .simulated, supportsNativeCDL: true, lut3dSizes: [33], metadataFields: MetadataField.allCases) private final class Box: @unchecked Sendable { let lock = NSLock() var continuations: [UUID: AsyncStream.Continuation] = [:] } private nonisolated let box = Box() nonisolated var state: AsyncStream { AsyncStream { continuation in let id = UUID() box.lock.lock() box.continuations[id] = continuation box.lock.unlock() continuation.onTermination = { [box] _ in box.lock.lock() box.continuations.removeValue(forKey: id) box.lock.unlock() } } } func connect() async throws {} func disconnect() async {} func push(look: FlattenedLook) async throws {} nonisolated func emit(_ s: CameraState) { box.lock.lock() let conts = Array(box.continuations.values) box.lock.unlock() for c in conts { c.yield(s) } } } final class ClipLoggerTests: XCTestCase { var dbPath: String! var store: LibraryStore! var day: ForgeLibrary.Day! var driver: ScriptedDriver! var logger: ClipLogger! override func setUp() async throws { dbPath = NSTemporaryDirectory() + "forge-logger-\(UUID().uuidString).db" store = try LibraryStore(path: dbPath) let p = try store.createProject(name: "P") day = try store.createDay(projectID: p.id, label: "D1", date: "2026-07-10") driver = ScriptedDriver() logger = ClipLogger(store: store, dayID: day.id) } override func tearDown() async throws { await logger.stop() logger = nil store = nil try? FileManager.default.removeItem(atPath: dbPath) } static func currentGrade() -> GradeStack { GradeStack(nodes: [GradeNode(kind: .cdl(CDL( slope: SIMD3(1.2, 1, 1), offset: .zero, power: .one, saturation: 1)))]) } func currentGrade() -> GradeStack { Self.currentGrade() } // Rec start creates shot with clip name, TC in, metadata, grade snapshot. func testRecStartCreatesShot() async throws { await logger.attach(slotName: "A-Cam", driver: driver) { Self.currentGrade() } await driver.emit(CameraState( connection: .connected, isRecording: true, clipName: "A001C001", metadata: CameraMetadata(exposureIndex: 800, whiteBalance: 5600, tint: 0, fps: 24, timecode: "10:00:00:00"))) try await Task.sleep(for: .milliseconds(100)) let shots = try store.listShots(dayID: day.id) XCTAssertEqual(shots.count, 1) let s = shots[0] XCTAssertEqual(s.clipName, "A001C001") XCTAssertEqual(s.cameraSlot, "A-Cam") XCTAssertEqual(s.timecodeIn, "10:00:00:00") XCTAssertEqual(s.exposureIndex, 800) XCTAssertNotNil(s.gradeSnapshot) // Snapshot decodes to the grade we supplied. let stack = try GradeSnapshot.decode(s.gradeSnapshot!) XCTAssertEqual(stack.nodes.count, 1) XCTAssertNil(s.timecodeOut) } // Rec stop stamps TC out. func testRecStopStampsTCOut() async throws { await logger.attach(slotName: "A-Cam", driver: driver) { Self.currentGrade() } await driver.emit(CameraState( connection: .connected, isRecording: true, clipName: "A001C002", metadata: CameraMetadata(timecode: "11:00:00:00"))) try await Task.sleep(for: .milliseconds(80)) await driver.emit(CameraState( connection: .connected, isRecording: false, clipName: "A001C002", metadata: CameraMetadata(timecode: "11:00:45:10"))) try await Task.sleep(for: .milliseconds(80)) let shots = try store.listShots(dayID: day.id) XCTAssertEqual(shots.count, 1) XCTAssertEqual(shots[0].timecodeOut, "11:00:45:10") } // Repeated recording-true states (poll noise) don't duplicate shots. func testDuplicateRecStartDebounced() async throws { await logger.attach(slotName: "A-Cam", driver: driver) { Self.currentGrade() } for _ in 0..<5 { await driver.emit(CameraState( connection: .connected, isRecording: true, clipName: "A001C003", metadata: CameraMetadata(timecode: "12:00:00:00"))) } try await Task.sleep(for: .milliseconds(100)) let shots = try store.listShots(dayID: day.id) XCTAssertEqual(shots.count, 1) } // Two takes = two shots. func testTwoTakesTwoShots() async throws { await logger.attach(slotName: "A-Cam", driver: driver) { Self.currentGrade() } await driver.emit(CameraState(connection: .connected, isRecording: true, clipName: "A001C004", metadata: CameraMetadata(timecode: "13:00:00:00"))) try await Task.sleep(for: .milliseconds(50)) await driver.emit(CameraState(connection: .connected, isRecording: false, clipName: "A001C004", metadata: CameraMetadata(timecode: "13:00:30:00"))) try await Task.sleep(for: .milliseconds(50)) await driver.emit(CameraState(connection: .connected, isRecording: true, clipName: "A001C005", metadata: CameraMetadata(timecode: "13:05:00:00"))) try await Task.sleep(for: .milliseconds(50)) await driver.emit(CameraState(connection: .connected, isRecording: false, clipName: "A001C005", metadata: CameraMetadata(timecode: "13:05:20:00"))) try await Task.sleep(for: .milliseconds(80)) let shots = try store.listShots(dayID: day.id) XCTAssertEqual(shots.map(\.clipName), ["A001C004", "A001C005"]) XCTAssertEqual(shots[0].timecodeOut, "13:00:30:00") XCTAssertEqual(shots[1].timecodeOut, "13:05:20:00") } // Manual shot when no camera attached. func testManualShot() async throws { let shot = try await logger.logManualShot( slotName: "B-Cam", clipName: "B001C001", grade: currentGrade()) XCTAssertEqual(shot.clipName, "B001C001") let shots = try store.listShots(dayID: day.id) XCTAssertEqual(shots.count, 1) XCTAssertNotNil(shots[0].gradeSnapshot) } // Multi-slot: A and B log independently. func testMultiSlot() async throws { let driverB = ScriptedDriver() await logger.attach(slotName: "A-Cam", driver: driver) { Self.currentGrade() } await logger.attach(slotName: "B-Cam", driver: driverB) { Self.currentGrade() } await driver.emit(CameraState(connection: .connected, isRecording: true, clipName: "A001C006", metadata: CameraMetadata(timecode: "14:00:00:00"))) await driverB.emit(CameraState(connection: .connected, isRecording: true, clipName: "B001C002", metadata: CameraMetadata(timecode: "14:00:00:00"))) try await Task.sleep(for: .milliseconds(120)) let shots = try store.listShots(dayID: day.id) XCTAssertEqual(shots.count, 2) XCTAssertEqual(Set(shots.map(\.cameraSlot)), ["A-Cam", "B-Cam"]) } }