grade: versioned deterministic JSON snapshots (sorted keys), loud unknown-kind failure — 5 tests
This commit is contained in:
parent
acb538ea0f
commit
6f024607ae
2 changed files with 215 additions and 0 deletions
142
Sources/ForgeGrade/GradeSnapshot.swift
Normal file
142
Sources/ForgeGrade/GradeSnapshot.swift
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import Foundation
|
||||||
|
import ForgeColor
|
||||||
|
|
||||||
|
/// Versioned, deterministic JSON serialization of a GradeStack.
|
||||||
|
/// Sorted keys -> byte-stable re-encoding, snapshots diff cleanly.
|
||||||
|
public enum GradeSnapshot {
|
||||||
|
public static let currentVersion = 1
|
||||||
|
|
||||||
|
public enum SnapshotError: Error, Equatable {
|
||||||
|
case unsupportedVersion(Int)
|
||||||
|
case unknownNodeKind(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func encode(_ stack: GradeStack) throws -> Data {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.sortedKeys]
|
||||||
|
return try encoder.encode(StackDTO(stack))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decode(_ data: Data) throws -> GradeStack {
|
||||||
|
let dto = try JSONDecoder().decode(StackDTO.self, from: data)
|
||||||
|
guard dto.version == currentVersion else {
|
||||||
|
throw SnapshotError.unsupportedVersion(dto.version)
|
||||||
|
}
|
||||||
|
return GradeStack(nodes: try dto.nodes.map { try $0.toNode() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DTOs
|
||||||
|
|
||||||
|
private struct StackDTO: Codable {
|
||||||
|
var version: Int
|
||||||
|
var nodes: [NodeDTO]
|
||||||
|
|
||||||
|
init(_ stack: GradeStack) {
|
||||||
|
version = GradeSnapshot.currentVersion
|
||||||
|
nodes = stack.nodes.map(NodeDTO.init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NodeDTO: Codable {
|
||||||
|
var id: UUID
|
||||||
|
var isEnabled: Bool
|
||||||
|
var kind: KindDTO
|
||||||
|
|
||||||
|
init(_ node: GradeNode) {
|
||||||
|
id = node.id
|
||||||
|
isEnabled = node.isEnabled
|
||||||
|
kind = KindDTO(node.kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toNode() throws -> GradeNode {
|
||||||
|
GradeNode(id: id, kind: try kind.toKind(), isEnabled: isEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct KindDTO: Codable {
|
||||||
|
var type: String
|
||||||
|
// Flat optional payloads — only the one matching `type` is set.
|
||||||
|
var inputTransform: InputTransform?
|
||||||
|
var outputTransform: OutputTransform?
|
||||||
|
var cdl: CDLDTO?
|
||||||
|
var saturation: Float?
|
||||||
|
var curves: CurveSet?
|
||||||
|
var lutSize: Int?
|
||||||
|
var lutTable: [Float]?
|
||||||
|
|
||||||
|
init(_ kind: GradeNode.Kind) {
|
||||||
|
switch kind {
|
||||||
|
case .inputTransform(let t):
|
||||||
|
type = "inputTransform"
|
||||||
|
inputTransform = t
|
||||||
|
case .outputTransform(let t):
|
||||||
|
type = "outputTransform"
|
||||||
|
outputTransform = t
|
||||||
|
case .cdl(let c):
|
||||||
|
type = "cdl"
|
||||||
|
cdl = CDLDTO(c)
|
||||||
|
case .saturation(let s):
|
||||||
|
type = "saturation"
|
||||||
|
saturation = s
|
||||||
|
case .curves(let c):
|
||||||
|
type = "curves"
|
||||||
|
curves = c
|
||||||
|
case .lut3d(let lut):
|
||||||
|
type = "lut3d"
|
||||||
|
lutSize = lut.size
|
||||||
|
lutTable = lut.table
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toKind() throws -> GradeNode.Kind {
|
||||||
|
switch type {
|
||||||
|
case "inputTransform":
|
||||||
|
guard let t = inputTransform else { throw GradeSnapshot.SnapshotError.unknownNodeKind(type) }
|
||||||
|
return .inputTransform(t)
|
||||||
|
case "outputTransform":
|
||||||
|
guard let t = outputTransform else { throw GradeSnapshot.SnapshotError.unknownNodeKind(type) }
|
||||||
|
return .outputTransform(t)
|
||||||
|
case "cdl":
|
||||||
|
guard let c = cdl else { throw GradeSnapshot.SnapshotError.unknownNodeKind(type) }
|
||||||
|
return .cdl(c.toCDL())
|
||||||
|
case "saturation":
|
||||||
|
guard let s = saturation else { throw GradeSnapshot.SnapshotError.unknownNodeKind(type) }
|
||||||
|
return .saturation(s)
|
||||||
|
case "curves":
|
||||||
|
guard let c = curves else { throw GradeSnapshot.SnapshotError.unknownNodeKind(type) }
|
||||||
|
return .curves(c)
|
||||||
|
case "lut3d":
|
||||||
|
guard let size = lutSize, let table = lutTable,
|
||||||
|
table.count == size * size * size * 3 else {
|
||||||
|
throw GradeSnapshot.SnapshotError.unknownNodeKind(type)
|
||||||
|
}
|
||||||
|
return .lut3d(Lut3D(size: size, table: table))
|
||||||
|
default:
|
||||||
|
throw GradeSnapshot.SnapshotError.unknownNodeKind(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SIMD3 is not Codable-stable across platforms; explicit arrays.
|
||||||
|
private struct CDLDTO: Codable {
|
||||||
|
var slope: [Float]
|
||||||
|
var offset: [Float]
|
||||||
|
var power: [Float]
|
||||||
|
var saturation: Float
|
||||||
|
|
||||||
|
init(_ c: CDL) {
|
||||||
|
slope = [c.slope.x, c.slope.y, c.slope.z]
|
||||||
|
offset = [c.offset.x, c.offset.y, c.offset.z]
|
||||||
|
power = [c.power.x, c.power.y, c.power.z]
|
||||||
|
saturation = c.saturation
|
||||||
|
}
|
||||||
|
|
||||||
|
func toCDL() -> CDL {
|
||||||
|
CDL(
|
||||||
|
slope: SIMD3(slope[0], slope[1], slope[2]),
|
||||||
|
offset: SIMD3(offset[0], offset[1], offset[2]),
|
||||||
|
power: SIMD3(power[0], power[1], power[2]),
|
||||||
|
saturation: saturation)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
Tests/ForgeGradeTests/SerializationTests.swift
Normal file
73
Tests/ForgeGradeTests/SerializationTests.swift
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import XCTest
|
||||||
|
import ForgeColor
|
||||||
|
@testable import ForgeGrade
|
||||||
|
|
||||||
|
final class SerializationTests: XCTestCase {
|
||||||
|
|
||||||
|
func makeStack() -> GradeStack {
|
||||||
|
var set = CurveSet.identity
|
||||||
|
set.master = Curve(points: [.init(x: 0, y: 0.05), .init(x: 1, y: 0.95)])
|
||||||
|
return GradeStack(nodes: [
|
||||||
|
GradeNode(kind: .inputTransform(.arriLogC4AWG4)),
|
||||||
|
GradeNode(kind: .cdl(CDL(
|
||||||
|
slope: SIMD3(1.1, 0.95, 1.02),
|
||||||
|
offset: SIMD3(0.01, -0.005, 0),
|
||||||
|
power: SIMD3(0.98, 1.03, 1.0),
|
||||||
|
saturation: 1.15))),
|
||||||
|
GradeNode(kind: .curves(set)),
|
||||||
|
GradeNode(kind: .saturation(0.9), isEnabled: false),
|
||||||
|
GradeNode(kind: .lut3d(Lut3D.build(size: 2) { $0 * 0.9 })),
|
||||||
|
GradeNode(kind: .outputTransform(.rec709Display)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round trip: encode -> decode -> same stack (ids, kinds, params, enabled flags).
|
||||||
|
func testRoundTrip() throws {
|
||||||
|
let stack = makeStack()
|
||||||
|
let data = try GradeSnapshot.encode(stack)
|
||||||
|
let back = try GradeSnapshot.decode(data)
|
||||||
|
XCTAssertEqual(back.nodes.count, stack.nodes.count)
|
||||||
|
for (a, b) in zip(stack.nodes, back.nodes) {
|
||||||
|
XCTAssertEqual(a.id, b.id)
|
||||||
|
XCTAssertEqual(a.isEnabled, b.isEnabled)
|
||||||
|
}
|
||||||
|
// Behavioral equality: same eval on sample pixels.
|
||||||
|
for px in [SIMD3<Float>(0.2, 0.4, 0.6), SIMD3(0.8, 0.1, 0.5)] {
|
||||||
|
let ea = stack.evaluate(px)
|
||||||
|
let eb = back.evaluate(px)
|
||||||
|
XCTAssertEqual(ea.x, eb.x, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(ea.y, eb.y, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(ea.z, eb.z, accuracy: 1e-6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic: re-encoding a decoded stack is byte-identical.
|
||||||
|
func testDeterministicEncoding() throws {
|
||||||
|
let stack = makeStack()
|
||||||
|
let data1 = try GradeSnapshot.encode(stack)
|
||||||
|
let back = try GradeSnapshot.decode(data1)
|
||||||
|
let data2 = try GradeSnapshot.encode(back)
|
||||||
|
XCTAssertEqual(data1, data2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown node kind fails loudly, not silently dropped.
|
||||||
|
func testUnknownNodeKindFails() {
|
||||||
|
let json = """
|
||||||
|
{"nodes":[{"id":"6EC0BD7F-11C0-43DA-975E-2A8AD9EBAE0B","isEnabled":true,"kind":{"type":"warpDrive"}}],"version":1}
|
||||||
|
"""
|
||||||
|
XCTAssertThrowsError(try GradeSnapshot.decode(Data(json.utf8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version field present; future-proofing.
|
||||||
|
func testVersionField() throws {
|
||||||
|
let data = try GradeSnapshot.encode(GradeStack(nodes: []))
|
||||||
|
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
XCTAssertEqual(obj?["version"] as? Int, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrong version rejected.
|
||||||
|
func testWrongVersionRejected() {
|
||||||
|
let json = #"{"nodes":[],"version":99}"#
|
||||||
|
XCTAssertThrowsError(try GradeSnapshot.decode(Data(json.utf8)))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue