From 6f024607aede2964e3af40c25676969ff374d5ce Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Fri, 10 Jul 2026 18:58:25 +0000 Subject: [PATCH] =?UTF-8?q?grade:=20versioned=20deterministic=20JSON=20sna?= =?UTF-8?q?pshots=20(sorted=20keys),=20loud=20unknown-kind=20failure=20?= =?UTF-8?q?=E2=80=94=205=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ForgeGrade/GradeSnapshot.swift | 142 ++++++++++++++++++ .../ForgeGradeTests/SerializationTests.swift | 73 +++++++++ 2 files changed, 215 insertions(+) create mode 100644 Sources/ForgeGrade/GradeSnapshot.swift create mode 100644 Tests/ForgeGradeTests/SerializationTests.swift diff --git a/Sources/ForgeGrade/GradeSnapshot.swift b/Sources/ForgeGrade/GradeSnapshot.swift new file mode 100644 index 0000000..37bffec --- /dev/null +++ b/Sources/ForgeGrade/GradeSnapshot.swift @@ -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) + } +} diff --git a/Tests/ForgeGradeTests/SerializationTests.swift b/Tests/ForgeGradeTests/SerializationTests.swift new file mode 100644 index 0000000..06dced1 --- /dev/null +++ b/Tests/ForgeGradeTests/SerializationTests.swift @@ -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(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))) + } +}