186 lines
7.5 KiB
Swift
186 lines
7.5 KiB
Swift
|
|
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"])
|
||
|
|
}
|
||
|
|
}
|