rainbow-dragon/Tests/ForgeLoggerTests/ClipLoggerTests.swift

186 lines
7.5 KiB
Swift
Raw Permalink Normal View History

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<CameraState>.Continuation] = [:]
}
private nonisolated let box = Box()
nonisolated var state: AsyncStream<CameraState> {
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"])
}
}