library: SQLite store — project/day/shot CRUD, grade snapshot blobs, WAL, cascade delete, serialized writes — 8 tests
This commit is contained in:
parent
642af95d4e
commit
f723ea6177
5 changed files with 547 additions and 6 deletions
|
|
@ -1 +0,0 @@
|
||||||
// ForgeLibrary
|
|
||||||
342
Sources/ForgeLibrary/LibraryStore.swift
Normal file
342
Sources/ForgeLibrary/LibraryStore.swift
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
import Foundation
|
||||||
|
import CSQLite
|
||||||
|
|
||||||
|
public enum LibraryError: Error {
|
||||||
|
case open(String)
|
||||||
|
case sql(String)
|
||||||
|
case notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SQLite-backed project/day/shot store. All access serialized through one queue —
|
||||||
|
/// safe for concurrent callers, single writer under the hood. WAL mode.
|
||||||
|
public final class LibraryStore: @unchecked Sendable {
|
||||||
|
private var db: OpaquePointer?
|
||||||
|
private let queue = DispatchQueue(label: "forge.library")
|
||||||
|
|
||||||
|
public init(path: String) throws {
|
||||||
|
var handle: OpaquePointer?
|
||||||
|
guard sqlite3_open(path, &handle) == SQLITE_OK, let handle else {
|
||||||
|
throw LibraryError.open(String(cString: sqlite3_errmsg(handle)))
|
||||||
|
}
|
||||||
|
db = handle
|
||||||
|
try exec("PRAGMA journal_mode=WAL")
|
||||||
|
try exec("PRAGMA foreign_keys=ON")
|
||||||
|
try migrate()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
sqlite3_close(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Core plumbing
|
||||||
|
|
||||||
|
private func exec(_ sql: String) throws {
|
||||||
|
var err: UnsafeMutablePointer<CChar>?
|
||||||
|
guard sqlite3_exec(db, sql, nil, nil, &err) == SQLITE_OK else {
|
||||||
|
let msg = err.map { String(cString: $0) } ?? "unknown"
|
||||||
|
sqlite3_free(err)
|
||||||
|
throw LibraryError.sql(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prepare(_ sql: String) throws -> OpaquePointer {
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else {
|
||||||
|
throw LibraryError.sql(String(cString: sqlite3_errmsg(db)))
|
||||||
|
}
|
||||||
|
return stmt
|
||||||
|
}
|
||||||
|
|
||||||
|
private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||||
|
|
||||||
|
private func bind(_ stmt: OpaquePointer, _ idx: Int32, _ value: String?) {
|
||||||
|
if let v = value {
|
||||||
|
sqlite3_bind_text(stmt, idx, v, -1, transient)
|
||||||
|
} else {
|
||||||
|
sqlite3_bind_null(stmt, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind(_ stmt: OpaquePointer, _ idx: Int32, _ value: Int?) {
|
||||||
|
if let v = value {
|
||||||
|
sqlite3_bind_int64(stmt, idx, Int64(v))
|
||||||
|
} else {
|
||||||
|
sqlite3_bind_null(stmt, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind(_ stmt: OpaquePointer, _ idx: Int32, _ value: Double?) {
|
||||||
|
if let v = value {
|
||||||
|
sqlite3_bind_double(stmt, idx, v)
|
||||||
|
} else {
|
||||||
|
sqlite3_bind_null(stmt, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind(_ stmt: OpaquePointer, _ idx: Int32, _ value: Data?) {
|
||||||
|
if let v = value {
|
||||||
|
_ = v.withUnsafeBytes { buf in
|
||||||
|
sqlite3_bind_blob(stmt, idx, buf.baseAddress, Int32(buf.count), transient)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sqlite3_bind_null(stmt, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func columnString(_ stmt: OpaquePointer, _ idx: Int32) -> String? {
|
||||||
|
sqlite3_column_text(stmt, idx).map { String(cString: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func columnInt(_ stmt: OpaquePointer, _ idx: Int32) -> Int? {
|
||||||
|
sqlite3_column_type(stmt, idx) == SQLITE_NULL ? nil : Int(sqlite3_column_int64(stmt, idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func columnDouble(_ stmt: OpaquePointer, _ idx: Int32) -> Double? {
|
||||||
|
sqlite3_column_type(stmt, idx) == SQLITE_NULL ? nil : sqlite3_column_double(stmt, idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func columnData(_ stmt: OpaquePointer, _ idx: Int32) -> Data? {
|
||||||
|
guard sqlite3_column_type(stmt, idx) != SQLITE_NULL,
|
||||||
|
let ptr = sqlite3_column_blob(stmt, idx) else { return nil }
|
||||||
|
return Data(bytes: ptr, count: Int(sqlite3_column_bytes(stmt, idx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func step(_ stmt: OpaquePointer) throws {
|
||||||
|
let rc = sqlite3_step(stmt)
|
||||||
|
guard rc == SQLITE_DONE || rc == SQLITE_ROW else {
|
||||||
|
throw LibraryError.sql(String(cString: sqlite3_errmsg(db)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Migration
|
||||||
|
|
||||||
|
private func migrate() throws {
|
||||||
|
try exec("""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);
|
||||||
|
""")
|
||||||
|
let current = try schemaVersion(orZero: true)
|
||||||
|
if current < 1 {
|
||||||
|
try exec("""
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE days (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE shots (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
day_id TEXT NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||||
|
clip_name TEXT NOT NULL,
|
||||||
|
camera_slot TEXT NOT NULL,
|
||||||
|
tc_in TEXT,
|
||||||
|
tc_out TEXT,
|
||||||
|
ei INTEGER,
|
||||||
|
wb INTEGER,
|
||||||
|
tint INTEGER,
|
||||||
|
fps REAL,
|
||||||
|
grade_snapshot BLOB,
|
||||||
|
still_path TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
seq INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_days_project ON days(project_id);
|
||||||
|
CREATE INDEX idx_shots_day ON shots(day_id);
|
||||||
|
DELETE FROM schema_version;
|
||||||
|
INSERT INTO schema_version (version) VALUES (1);
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func schemaVersion() throws -> Int {
|
||||||
|
try schemaVersion(orZero: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func schemaVersion(orZero: Bool) throws -> Int {
|
||||||
|
let stmt = try prepare("SELECT version FROM schema_version LIMIT 1")
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
if sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
return Int(sqlite3_column_int64(stmt, 0))
|
||||||
|
}
|
||||||
|
if orZero { return 0 }
|
||||||
|
throw LibraryError.sql("no schema version")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Projects
|
||||||
|
|
||||||
|
public func createProject(name: String) throws -> Project {
|
||||||
|
try queue.sync {
|
||||||
|
let p = Project(name: name)
|
||||||
|
let stmt = try prepare("INSERT INTO projects (id, name, created_at) VALUES (?,?,?)")
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
bind(stmt, 1, p.id.uuidString)
|
||||||
|
bind(stmt, 2, p.name)
|
||||||
|
bind(stmt, 3, p.createdAt.timeIntervalSince1970)
|
||||||
|
try step(stmt)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listProjects() throws -> [Project] {
|
||||||
|
try queue.sync {
|
||||||
|
let stmt = try prepare("SELECT id, name, created_at FROM projects ORDER BY created_at")
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
var out = [Project]()
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
guard let idStr = columnString(stmt, 0), let id = UUID(uuidString: idStr) else { continue }
|
||||||
|
out.append(Project(
|
||||||
|
id: id,
|
||||||
|
name: columnString(stmt, 1) ?? "",
|
||||||
|
createdAt: Date(timeIntervalSince1970: columnDouble(stmt, 2) ?? 0)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteProject(id: UUID) throws {
|
||||||
|
try queue.sync {
|
||||||
|
let stmt = try prepare("DELETE FROM projects WHERE id = ?")
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
bind(stmt, 1, id.uuidString)
|
||||||
|
try step(stmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Days
|
||||||
|
|
||||||
|
public func createDay(projectID: UUID, label: String, date: String) throws -> Day {
|
||||||
|
try queue.sync {
|
||||||
|
let d = Day(projectID: projectID, label: label, date: date)
|
||||||
|
let stmt = try prepare("INSERT INTO days (id, project_id, label, date) VALUES (?,?,?,?)")
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
bind(stmt, 1, d.id.uuidString)
|
||||||
|
bind(stmt, 2, d.projectID.uuidString)
|
||||||
|
bind(stmt, 3, d.label)
|
||||||
|
bind(stmt, 4, d.date)
|
||||||
|
try step(stmt)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listDays(projectID: UUID) throws -> [Day] {
|
||||||
|
try queue.sync {
|
||||||
|
let stmt = try prepare("SELECT id, project_id, label, date FROM days WHERE project_id = ? ORDER BY date")
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
bind(stmt, 1, projectID.uuidString)
|
||||||
|
var out = [Day]()
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
guard let idStr = columnString(stmt, 0), let id = UUID(uuidString: idStr),
|
||||||
|
let pidStr = columnString(stmt, 1), let pid = UUID(uuidString: pidStr) else { continue }
|
||||||
|
out.append(Day(
|
||||||
|
id: id,
|
||||||
|
projectID: pid,
|
||||||
|
label: columnString(stmt, 2) ?? "",
|
||||||
|
date: columnString(stmt, 3) ?? ""))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Shots
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func createShot(_ shot: Shot) throws -> Shot {
|
||||||
|
try queue.sync {
|
||||||
|
let stmt = try prepare("""
|
||||||
|
INSERT INTO shots (id, day_id, clip_name, camera_slot, tc_in, tc_out, ei, wb, tint, fps,
|
||||||
|
grade_snapshot, still_path, created_at, seq)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,
|
||||||
|
(SELECT COALESCE(MAX(seq),0)+1 FROM shots WHERE day_id = ?))
|
||||||
|
""")
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
bind(stmt, 1, shot.id.uuidString)
|
||||||
|
bind(stmt, 2, shot.dayID.uuidString)
|
||||||
|
bind(stmt, 3, shot.clipName)
|
||||||
|
bind(stmt, 4, shot.cameraSlot)
|
||||||
|
bind(stmt, 5, shot.timecodeIn)
|
||||||
|
bind(stmt, 6, shot.timecodeOut)
|
||||||
|
bind(stmt, 7, shot.exposureIndex)
|
||||||
|
bind(stmt, 8, shot.whiteBalance)
|
||||||
|
bind(stmt, 9, shot.tint)
|
||||||
|
bind(stmt, 10, shot.fps)
|
||||||
|
bind(stmt, 11, shot.gradeSnapshot)
|
||||||
|
bind(stmt, 12, shot.stillPath)
|
||||||
|
bind(stmt, 13, shot.createdAt.timeIntervalSince1970)
|
||||||
|
bind(stmt, 14, shot.dayID.uuidString)
|
||||||
|
try step(stmt)
|
||||||
|
return shot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateShot(_ shot: Shot) throws {
|
||||||
|
try queue.sync {
|
||||||
|
let stmt = try prepare("""
|
||||||
|
UPDATE shots SET clip_name=?, camera_slot=?, tc_in=?, tc_out=?, ei=?, wb=?, tint=?, fps=?,
|
||||||
|
grade_snapshot=?, still_path=?
|
||||||
|
WHERE id=?
|
||||||
|
""")
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
bind(stmt, 1, shot.clipName)
|
||||||
|
bind(stmt, 2, shot.cameraSlot)
|
||||||
|
bind(stmt, 3, shot.timecodeIn)
|
||||||
|
bind(stmt, 4, shot.timecodeOut)
|
||||||
|
bind(stmt, 5, shot.exposureIndex)
|
||||||
|
bind(stmt, 6, shot.whiteBalance)
|
||||||
|
bind(stmt, 7, shot.tint)
|
||||||
|
bind(stmt, 8, shot.fps)
|
||||||
|
bind(stmt, 9, shot.gradeSnapshot)
|
||||||
|
bind(stmt, 10, shot.stillPath)
|
||||||
|
bind(stmt, 11, shot.id.uuidString)
|
||||||
|
try step(stmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func shot(id: UUID) throws -> Shot? {
|
||||||
|
try queue.sync {
|
||||||
|
let stmt = try prepare("SELECT \(Self.shotColumns) FROM shots WHERE id = ?")
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
bind(stmt, 1, id.uuidString)
|
||||||
|
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
|
||||||
|
return readShot(stmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listShots(dayID: UUID) throws -> [Shot] {
|
||||||
|
try queue.sync {
|
||||||
|
let stmt = try prepare("SELECT \(Self.shotColumns) FROM shots WHERE day_id = ? ORDER BY seq")
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
bind(stmt, 1, dayID.uuidString)
|
||||||
|
var out = [Shot]()
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
if let s = readShot(stmt) { out.append(s) }
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let shotColumns =
|
||||||
|
"id, day_id, clip_name, camera_slot, tc_in, tc_out, ei, wb, tint, fps, grade_snapshot, still_path, created_at"
|
||||||
|
|
||||||
|
private func readShot(_ stmt: OpaquePointer) -> Shot? {
|
||||||
|
guard let idStr = columnString(stmt, 0), let id = UUID(uuidString: idStr),
|
||||||
|
let dayStr = columnString(stmt, 1), let dayID = UUID(uuidString: dayStr) else { return nil }
|
||||||
|
return Shot(
|
||||||
|
id: id,
|
||||||
|
dayID: dayID,
|
||||||
|
clipName: columnString(stmt, 2) ?? "",
|
||||||
|
cameraSlot: columnString(stmt, 3) ?? "",
|
||||||
|
timecodeIn: columnString(stmt, 4),
|
||||||
|
timecodeOut: columnString(stmt, 5),
|
||||||
|
exposureIndex: columnInt(stmt, 6),
|
||||||
|
whiteBalance: columnInt(stmt, 7),
|
||||||
|
tint: columnInt(stmt, 8),
|
||||||
|
fps: columnDouble(stmt, 9),
|
||||||
|
gradeSnapshot: columnData(stmt, 10),
|
||||||
|
stillPath: columnString(stmt, 11),
|
||||||
|
createdAt: Date(timeIntervalSince1970: columnDouble(stmt, 12) ?? 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
76
Sources/ForgeLibrary/Models.swift
Normal file
76
Sources/ForgeLibrary/Models.swift
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct Project: Identifiable, Sendable, Equatable {
|
||||||
|
public let id: UUID
|
||||||
|
public var name: String
|
||||||
|
public var createdAt: Date
|
||||||
|
|
||||||
|
public init(id: UUID = UUID(), name: String, createdAt: Date = Date()) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.createdAt = createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Day: Identifiable, Sendable, Equatable {
|
||||||
|
public let id: UUID
|
||||||
|
public var projectID: UUID
|
||||||
|
public var label: String
|
||||||
|
/// Shooting date, ISO yyyy-MM-dd.
|
||||||
|
public var date: String
|
||||||
|
|
||||||
|
public init(id: UUID = UUID(), projectID: UUID, label: String, date: String) {
|
||||||
|
self.id = id
|
||||||
|
self.projectID = projectID
|
||||||
|
self.label = label
|
||||||
|
self.date = date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Shot: Identifiable, Sendable, Equatable {
|
||||||
|
public let id: UUID
|
||||||
|
public var dayID: UUID
|
||||||
|
public var clipName: String
|
||||||
|
public var cameraSlot: String
|
||||||
|
public var timecodeIn: String?
|
||||||
|
public var timecodeOut: String?
|
||||||
|
public var exposureIndex: Int?
|
||||||
|
public var whiteBalance: Int?
|
||||||
|
public var tint: Int?
|
||||||
|
public var fps: Double?
|
||||||
|
/// Serialized GradeSnapshot JSON at rec start.
|
||||||
|
public var gradeSnapshot: Data?
|
||||||
|
/// Path to still grab, if any.
|
||||||
|
public var stillPath: String?
|
||||||
|
public var createdAt: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
dayID: UUID,
|
||||||
|
clipName: String,
|
||||||
|
cameraSlot: String,
|
||||||
|
timecodeIn: String? = nil,
|
||||||
|
timecodeOut: String? = nil,
|
||||||
|
exposureIndex: Int? = nil,
|
||||||
|
whiteBalance: Int? = nil,
|
||||||
|
tint: Int? = nil,
|
||||||
|
fps: Double? = nil,
|
||||||
|
gradeSnapshot: Data? = nil,
|
||||||
|
stillPath: String? = nil,
|
||||||
|
createdAt: Date = Date()
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.dayID = dayID
|
||||||
|
self.clipName = clipName
|
||||||
|
self.cameraSlot = cameraSlot
|
||||||
|
self.timecodeIn = timecodeIn
|
||||||
|
self.timecodeOut = timecodeOut
|
||||||
|
self.exposureIndex = exposureIndex
|
||||||
|
self.whiteBalance = whiteBalance
|
||||||
|
self.tint = tint
|
||||||
|
self.fps = fps
|
||||||
|
self.gradeSnapshot = gradeSnapshot
|
||||||
|
self.stillPath = stillPath
|
||||||
|
self.createdAt = createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
129
Tests/ForgeLibraryTests/LibraryStoreTests.swift
Normal file
129
Tests/ForgeLibraryTests/LibraryStoreTests.swift
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import XCTest
|
||||||
|
import Foundation
|
||||||
|
@testable import ForgeLibrary
|
||||||
|
|
||||||
|
final class LibraryStoreTests: XCTestCase {
|
||||||
|
|
||||||
|
var dbPath: String!
|
||||||
|
var store: LibraryStore!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
dbPath = NSTemporaryDirectory() + "forge-test-\(UUID().uuidString).db"
|
||||||
|
store = try LibraryStore(path: dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
store = nil
|
||||||
|
try? FileManager.default.removeItem(atPath: dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema migration table exists with v1.
|
||||||
|
func testMigrationVersion() throws {
|
||||||
|
XCTAssertEqual(try store.schemaVersion(), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project CRUD.
|
||||||
|
func testProjectCRUD() throws {
|
||||||
|
let p = try store.createProject(name: "Feature X")
|
||||||
|
XCTAssertFalse(p.id.uuidString.isEmpty)
|
||||||
|
XCTAssertEqual(p.name, "Feature X")
|
||||||
|
|
||||||
|
let all = try store.listProjects()
|
||||||
|
XCTAssertEqual(all.count, 1)
|
||||||
|
XCTAssertEqual(all[0].name, "Feature X")
|
||||||
|
|
||||||
|
try store.deleteProject(id: p.id)
|
||||||
|
XCTAssertTrue(try store.listProjects().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day belongs to project.
|
||||||
|
func testDayCRUD() throws {
|
||||||
|
let p = try store.createProject(name: "P")
|
||||||
|
let d = try store.createDay(projectID: p.id, label: "Day 01", date: "2026-07-10")
|
||||||
|
let days = try store.listDays(projectID: p.id)
|
||||||
|
XCTAssertEqual(days.count, 1)
|
||||||
|
XCTAssertEqual(days[0].label, "Day 01")
|
||||||
|
XCTAssertEqual(days[0].date, "2026-07-10")
|
||||||
|
XCTAssertEqual(days[0].id, d.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shot CRUD with metadata + grade snapshot blob round-trip.
|
||||||
|
func testShotRoundTrip() throws {
|
||||||
|
let p = try store.createProject(name: "P")
|
||||||
|
let d = try store.createDay(projectID: p.id, label: "D1", date: "2026-07-10")
|
||||||
|
let snapshot = Data(#"{"nodes":[],"version":1}"#.utf8)
|
||||||
|
|
||||||
|
var shot = Shot(
|
||||||
|
dayID: d.id,
|
||||||
|
clipName: "A001C001_260710_R1CD",
|
||||||
|
cameraSlot: "A-Cam",
|
||||||
|
timecodeIn: "10:00:00:00",
|
||||||
|
timecodeOut: nil,
|
||||||
|
exposureIndex: 800,
|
||||||
|
whiteBalance: 5600,
|
||||||
|
tint: 0,
|
||||||
|
fps: 24,
|
||||||
|
gradeSnapshot: snapshot)
|
||||||
|
shot = try store.createShot(shot)
|
||||||
|
|
||||||
|
var fetched = try XCTUnwrap(store.shot(id: shot.id))
|
||||||
|
XCTAssertEqual(fetched.clipName, "A001C001_260710_R1CD")
|
||||||
|
XCTAssertEqual(fetched.cameraSlot, "A-Cam")
|
||||||
|
XCTAssertEqual(fetched.exposureIndex, 800)
|
||||||
|
XCTAssertEqual(fetched.gradeSnapshot, snapshot)
|
||||||
|
XCTAssertNil(fetched.timecodeOut)
|
||||||
|
|
||||||
|
// Update TC out on rec stop.
|
||||||
|
fetched.timecodeOut = "10:02:30:12"
|
||||||
|
try store.updateShot(fetched)
|
||||||
|
let updated = try XCTUnwrap(store.shot(id: shot.id))
|
||||||
|
XCTAssertEqual(updated.timecodeOut, "10:02:30:12")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shots listed per day in insert order.
|
||||||
|
func testListShotsByDay() throws {
|
||||||
|
let p = try store.createProject(name: "P")
|
||||||
|
let d = try store.createDay(projectID: p.id, label: "D1", date: "2026-07-10")
|
||||||
|
for i in 1...3 {
|
||||||
|
_ = try store.createShot(Shot(dayID: d.id, clipName: "A001C00\(i)", cameraSlot: "A-Cam"))
|
||||||
|
}
|
||||||
|
let shots = try store.listShots(dayID: d.id)
|
||||||
|
XCTAssertEqual(shots.map(\.clipName), ["A001C001", "A001C002", "A001C003"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent writers serialized safely.
|
||||||
|
func testConcurrentWrites() async throws {
|
||||||
|
let p = try store.createProject(name: "P")
|
||||||
|
let d = try store.createDay(projectID: p.id, label: "D1", date: "2026-07-10")
|
||||||
|
let s = store!
|
||||||
|
let dayID = d.id
|
||||||
|
await withTaskGroup(of: Void.self) { group in
|
||||||
|
for i in 0..<50 {
|
||||||
|
group.addTask {
|
||||||
|
_ = try? s.createShot(Shot(dayID: dayID, clipName: "C\(i)", cameraSlot: "A"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertEqual(try store.listShots(dayID: d.id).count, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reopen db: data persists.
|
||||||
|
func testPersistence() throws {
|
||||||
|
let p = try store.createProject(name: "Persist")
|
||||||
|
store = nil
|
||||||
|
store = try LibraryStore(path: dbPath)
|
||||||
|
let all = try store.listProjects()
|
||||||
|
XCTAssertEqual(all.count, 1)
|
||||||
|
XCTAssertEqual(all[0].id, p.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade: deleting project removes days + shots.
|
||||||
|
func testCascadeDelete() throws {
|
||||||
|
let p = try store.createProject(name: "P")
|
||||||
|
let d = try store.createDay(projectID: p.id, label: "D1", date: "2026-07-10")
|
||||||
|
_ = try store.createShot(Shot(dayID: d.id, clipName: "C1", cameraSlot: "A"))
|
||||||
|
try store.deleteProject(id: p.id)
|
||||||
|
XCTAssertTrue(try store.listDays(projectID: p.id).isEmpty)
|
||||||
|
XCTAssertTrue(try store.listShots(dayID: d.id).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import XCTest
|
|
||||||
|
|
||||||
final class ForgeLibraryPlaceholderTests: XCTestCase {
|
|
||||||
func testPlaceholder() { XCTAssertTrue(true) }
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue