grade: versioned deterministic JSON snapshots (sorted keys), loud unknown-kind failure — 5 tests

This commit is contained in:
Forge Dev 2026-07-10 18:58:25 +00:00
parent acb538ea0f
commit 6f024607ae
2 changed files with 215 additions and 0 deletions

View 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)
}
}

View 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)))
}
}