diff --git a/swift/core/AGENTS.md b/swift/core/AGENTS.md index 43b3738eca..39a7f1f98a 100644 --- a/swift/core/AGENTS.md +++ b/swift/core/AGENTS.md @@ -1,8 +1,8 @@ # A2UI Swift Core Agent Guide (AGENTS.md) This document is the authoritative guide for AI agents operating within the -`swift/` directory tree. It outlines target boundaries, coding conventions, and -verification protocols. +`swift/` directory tree. It outlines target boundaries, coding conventions, spec +compliance rules, and verification protocols. --- @@ -58,7 +58,40 @@ Key rules: --- -## 3. Verification Protocol +## 3. Spec Compliance \u0026 Source-of-Truth Hierarchy + +When implementing or modifying any wire-format types (message envelopes, action +payloads, error types), JSON Pointer semantics, or data model behavior, agents +**MUST** verify consistency against the authoritative sources in this order: + +1. **JSON Schemas** (`specification/v0_9_1/json/`) — the primary authority for + wire-format field names, required properties, and structural shape. +2. **Core SDK Blueprint** (`blueprints/modules/a2ui_core.blueprint.md`) — defines + cross-language behavioral rules (e.g., JSON Pointer Implementation Rules, + auto-vivification, sparse arrays, notification strategy). +3. **`web_core` Reference** (`renderers/web_core/src/v0_9/`) — the canonical + TypeScript implementation to cross-check behavioral semantics (e.g., how + `undefined`/`nil` is handled for arrays vs objects, auto-vivification + conditions, root replacement rules). + +Common pitfalls to avoid: + +- **Wire payload shape**: Do not mirror component property schemas (e.g., + `Action.event`/`Action.functionCall`) in message envelopes. Message payloads + have their own flat schemas (e.g., `action.name`, `action.surfaceId`). +- **Version fields**: All server-to-client and client-to-server messages require + a top-level `version` field. Always encode it and validate it on decode. +- **Array deletion vs sparse**: Setting an array index to `nil`/`undefined` + must preserve array length (set to `.null`), not remove the element. Object + keys are removed entirely. +- **Root replacement**: The root JSON value can be replaced with any valid + `JSONValue` type, not just `.object`. +- **Auto-vivification**: Arrays should be vivified for any numeric path segment + (`Int(key) != nil`), not just index `"0"`. + +--- + +## 4. Verification Protocol Before completing any task, agents **MUST** execute: diff --git a/swift/core/CODING_STANDARDS.md b/swift/core/CODING_STANDARDS.md index ad36a1478b..5af6e1eed0 100644 --- a/swift/core/CODING_STANDARDS.md +++ b/swift/core/CODING_STANDARDS.md @@ -108,6 +108,9 @@ Writing excellent tests ensures high confidence in our SDKs and services. - Deterministic object encoding (e.g., keys sorted alphabetically) - Round-trip string serialization/deserialization - Order-independent equality checks for dictionaries or objects +- **No force unwraps:** Never use force unwraps (`!`) in tests. Use + `try #require(...)` from Swift Testing to safely unwrap optionals, which + produces a clear test failure instead of a crash. - **Running Tests:** Use the `run_tests.sh` script to run tests on the root package: ```bash diff --git a/swift/core/Sources/A2UICore/Errors/ClientServerError.swift b/swift/core/Sources/A2UICore/Errors/ClientServerError.swift new file mode 100644 index 0000000000..7e846776dd --- /dev/null +++ b/swift/core/Sources/A2UICore/Errors/ClientServerError.swift @@ -0,0 +1,43 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Encapsulates all client-to-server error types. +public enum ClientServerError: Equatable, Codable, Sendable { + case validationFailed(ValidationFailedError) + case generic(GenericError) + + private struct Discriminator: Decodable { + let code: String + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let discriminator = try container.decode(Discriminator.self) + if discriminator.code == ValidationFailedError.errorCode { + self = .validationFailed(try container.decode(ValidationFailedError.self)) + } else { + self = .generic(try container.decode(GenericError.self)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .validationFailed(let validation): + try container.encode(validation) + case .generic(let generic): + try container.encode(generic) + } + } +} diff --git a/swift/core/Sources/A2UICore/A2UICore.swift b/swift/core/Sources/A2UICore/Errors/GenericError.swift similarity index 55% rename from swift/core/Sources/A2UICore/A2UICore.swift rename to swift/core/Sources/A2UICore/Errors/GenericError.swift index ed79ef9df3..d5e30ca98d 100644 --- a/swift/core/Sources/A2UICore/A2UICore.swift +++ b/swift/core/Sources/A2UICore/Errors/GenericError.swift @@ -12,12 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -import A2UIJSON -import Foundation -import JSONSchema -import OrderedJSON +/// Represents a generic processing or runtime client error. +public struct GenericError: Error, Equatable, Codable, Sendable { + public let code: String + public let surfaceID: String + public let message: String -// A2UICore — Stateful runtime engine implementing the A2UI state machine, -// validation pipeline, and bidirectional event routing. -// This file is intentionally a placeholder; real types will be added in -// subsequent PRs. + private enum CodingKeys: String, CodingKey { + case code + case surfaceID = "surfaceId" + case message + } + + public init(code: String, surfaceID: String, message: String) { + self.code = code + self.surfaceID = surfaceID + self.message = message + } +} diff --git a/swift/core/Tests/A2UICoreTests/A2UICoreTests.swift b/swift/core/Sources/A2UICore/Errors/LocalFunctionError.swift similarity index 71% rename from swift/core/Tests/A2UICoreTests/A2UICoreTests.swift rename to swift/core/Sources/A2UICore/Errors/LocalFunctionError.swift index 80bf4e1194..c1aae046a4 100644 --- a/swift/core/Tests/A2UICoreTests/A2UICoreTests.swift +++ b/swift/core/Sources/A2UICore/Errors/LocalFunctionError.swift @@ -12,11 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import A2UICore -import Testing - -struct A2UICoreTests { - @Test func placeholderTest() { - #expect(Bool(true)) - } +/// Errors that occur during local function evaluation. +public enum LocalFunctionError: Error, Sendable { + case functionNotFound(String) + case missingArgument(String) + case invalidArgumentType(expected: String, actual: String) } diff --git a/swift/core/Sources/A2UICore/Errors/ValidationFailedError.swift b/swift/core/Sources/A2UICore/Errors/ValidationFailedError.swift new file mode 100644 index 0000000000..5a64fc6efd --- /dev/null +++ b/swift/core/Sources/A2UICore/Errors/ValidationFailedError.swift @@ -0,0 +1,51 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Represents a structured validation failure. +public struct ValidationFailedError: Error, Equatable, Codable, Sendable { + public static let errorCode = "VALIDATION_FAILED" + + public let code: String = errorCode + public let surfaceID: String + public let path: String + public let message: String + + private enum CodingKeys: String, CodingKey { + case code + case surfaceID = "surfaceId" + case path + case message + } + + public init(surfaceID: String, path: String, message: String) { + self.surfaceID = surfaceID + self.path = path + self.message = message + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedCode = try container.decode(String.self, forKey: .code) + guard decodedCode == Self.errorCode else { + throw DecodingError.dataCorruptedError( + forKey: .code, + in: container, + debugDescription: "Invalid error code: \(decodedCode)" + ) + } + surfaceID = try container.decode(String.self, forKey: .surfaceID) + path = try container.decode(String.self, forKey: .path) + message = try container.decode(String.self, forKey: .message) + } +} diff --git a/swift/core/Sources/A2UICore/Extensions/JSONValue+Path.swift b/swift/core/Sources/A2UICore/Extensions/JSONValue+Path.swift new file mode 100644 index 0000000000..2cce0d4f04 --- /dev/null +++ b/swift/core/Sources/A2UICore/Extensions/JSONValue+Path.swift @@ -0,0 +1,272 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import OrderedJSON +import OrderedCollections + +extension JSONValue { + /// Returns the underlying string value if this is a `.string` case. + public var stringValue: String? { + switch self { + case .string(let value): return value + default: return nil + } + } + + /// Returns the underlying double value if this is a `.number` or + /// `.integer` case. + public var doubleValue: Double? { + switch self { + case .number(let value): return value + case .integer(let value): return Double(value) + default: return nil + } + } + + /// Returns the underlying integer value if this is an `.integer` or + /// a whole-number `.number` case. + public var intValue: Int? { + switch self { + case .integer(let value): return value + case .number(let value): + if value >= Double(Int.min) && value <= Double(Int.max) && value == value.rounded() { + return Int(value) + } + return nil + default: return nil + } + } + + /// Returns the underlying boolean value if this is a `.boolean` case. + public var boolValue: Bool? { + switch self { + case .boolean(let value): return value + default: return nil + } + } + + /// Returns the underlying array of JSONValues if this is an `.array`. + public var arrayValue: [JSONValue]? { + switch self { + case .array(let value): return value + default: return nil + } + } + + /// Returns the underlying object as an `OrderedDictionary` if this is + /// an `.object` case. + public var objectValue: OrderedDictionary? { + switch self { + case .object(let value): return value + default: return nil + } + } + + /// Returns the underlying object as a `[String: JSONValue]` dictionary + /// if this is an `.object` case. + public var dictionaryValue: [String: JSONValue]? { + switch self { + case .object(let value): return Dictionary(uniqueKeysWithValues: value.map { ($0.key, $0.value) }) + default: return nil + } + } + + // MARK: - Path Subscripting + + /// Thread-safe getter and setter for deep path-based subscripting. + /// + /// Path components are separated by `/` (e.g., `"/user/name"`). + /// Array indices are numeric strings (e.g., `"/items/0"`). + public subscript(path: String) -> JSONValue? { + get { + let components = Self.parsePath(path) + if components.isEmpty { return self } + var currentValue = self + for component in components { + switch currentValue { + case .object(let dictionary): + guard let value = dictionary[component] else { return nil } + currentValue = value + case .array(let array): + guard let index = Int(component), + index >= 0 && index < array.count + else { return nil } + currentValue = array[index] + default: + return nil + } + } + return currentValue + } + set { + let components = Self.parsePath(path) + guard !components.isEmpty else { + if let newValue { + self = newValue + } + return + } + if let updated = Self.update( + node: self, + components: components[...], + newValue: newValue + ) { + self = updated + } else { + self = .null + } + } + } + + // MARK: - Path Utilities + + /// Parses a JSON Pointer-style path into components. + static func parsePath(_ path: String) -> [String] { + guard !path.isEmpty else { return [] } + let adjustedPath = path.hasPrefix("/") ? String(path.dropFirst()) : path + return adjustedPath.split(separator: "/", omittingEmptySubsequences: false).map { + String($0) + .replacingOccurrences(of: "~1", with: "/") + .replacingOccurrences(of: "~0", with: "~") + } + } + + /// Recursively updates a node at the given path components. + static func update( + node: JSONValue?, + components: ArraySlice, + newValue: JSONValue? + ) -> JSONValue? { + guard let key = components.first else { return newValue } + let isLastComponent = components.count == 1 + let remainingComponents = components.dropFirst() + + switch node { + case .some(.object(var dict)): + if isLastComponent { + if let newValue { + dict[key] = newValue + } else { + dict.removeValue(forKey: key) + } + } else { + let nextNode = dict[key] + dict[key] = update( + node: nextNode, + components: remainingComponents, + newValue: newValue + ) + } + return .object(dict) + + case .some(.array(var array)): + if let index = Int(key), index >= 0 { + if index <= array.count { + if isLastComponent { + if let newValue { + if index == array.count { + array.append(newValue) + } else { + array[index] = newValue + } + } else if index < array.count { + // Setting an array index to nil preserves the array + // length (sparse array), matching the blueprint's + // JSON Pointer Implementation Rules. + array[index] = .null + } + } else { + let nextNode = index < array.count ? array[index] : nil + let updated = update( + node: nextNode, + components: remainingComponents, + newValue: newValue + ) + if let updated { + if index == array.count { + array.append(updated) + } else { + array[index] = updated + } + } else if index < array.count { + // Sparse array: preserve length, set to null. + array[index] = .null + } + } + } + return .array(array) + } else { + if newValue == nil && isLastComponent { return node } + var dict: OrderedDictionary = [:] + if isLastComponent { + if let newValue { dict[key] = newValue } + } else { + dict[key] = update( + node: nil, + components: remainingComponents, + newValue: newValue + ) + } + return .object(dict) + } + + default: + if newValue == nil { return node } + if let index = Int(key), index >= 0 { + // Auto-vivify an array for any numeric key, matching + // web_core's isNumeric() auto-vivification rule. + var array: [JSONValue] = [] + if isLastComponent { + if let newValue { array.append(newValue) } + } else if let updated = update( + node: nil, + components: remainingComponents, + newValue: newValue + ) { + array.append(updated) + } + return .array(array) + } else { + var dict: OrderedDictionary = [:] + if isLastComponent { + if let newValue { dict[key] = newValue } + } else { + dict[key] = update( + node: nil, + components: remainingComponents, + newValue: newValue + ) + } + return .object(dict) + } + } + } + + /// Resolves a relative or absolute path against a base path context. + /// + /// - Parameters: + /// - path: The path to resolve. If it starts with `/`, it is absolute. + /// - basePath: The base path to resolve against (if `path` is relative). + /// - Returns: The resolved absolute path. + public static func absolutePath( + for path: String, + in basePath: String? + ) -> String { + if path.hasPrefix("/") { return path } + let base = basePath ?? "" + let trimmedBase = base.hasSuffix("/") ? String(base.dropLast()) : base + if trimmedBase.isEmpty { return "/\(path)" } + return "\(trimmedBase)/\(path)" + } +} diff --git a/swift/core/Sources/A2UICore/Messages/ClientToServerMessage.swift b/swift/core/Sources/A2UICore/Messages/ClientToServerMessage.swift new file mode 100644 index 0000000000..04ac104ea2 --- /dev/null +++ b/swift/core/Sources/A2UICore/Messages/ClientToServerMessage.swift @@ -0,0 +1,71 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Top-level envelope for client-to-server messages (actions or errors). +/// +/// Matches `specification/v0_9_1/json/client_to_server.json`. +public enum ClientToServerMessage: Equatable, Codable, Sendable { + case action(ClientAction) + case error(ClientServerError) + + private enum CodingKeys: String, CodingKey { + case version + case action + case error + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(String.self, forKey: .version) + guard version == "v0.9" || version == "v0.9.1" else { + throw DecodingError.dataCorruptedError( + forKey: .version, + in: container, + debugDescription: "Unsupported version: \(version)" + ) + } + + if let action = try container.decodeIfPresent( + ClientAction.self, + forKey: .action + ) { + self = .action(action) + } else if let error = try container.decodeIfPresent( + ClientServerError.self, + forKey: .error + ) { + self = .error(error) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Message must contain either 'action' or 'error'" + ) + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("v0.9.1", forKey: .version) + + switch self { + case .action(let action): + try container.encode(action, forKey: .action) + case .error(let error): + try container.encode(error, forKey: .error) + } + } +} + diff --git a/swift/core/Sources/A2UICore/Messages/CreateSurfaceMessage.swift b/swift/core/Sources/A2UICore/Messages/CreateSurfaceMessage.swift new file mode 100644 index 0000000000..98af7da2af --- /dev/null +++ b/swift/core/Sources/A2UICore/Messages/CreateSurfaceMessage.swift @@ -0,0 +1,54 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import OrderedJSON + +/// A message commanding the client to initialize a new active surface. +public struct CreateSurfaceMessage: Codable, Sendable, Equatable { + public let surfaceID: String + public let catalogID: String + public let theme: [String: JSONValue]? + public let shouldSendDataModel: Bool + + private enum CodingKeys: String, CodingKey { + case surfaceID = "surfaceId" + case catalogID = "catalogId" + case theme + case shouldSendDataModel = "sendDataModel" + } + + public init( + surfaceID: String, + catalogID: String, + theme: [String: JSONValue]? = nil, + shouldSendDataModel: Bool? = nil + ) { + self.surfaceID = surfaceID + self.catalogID = catalogID + self.theme = theme + self.shouldSendDataModel = shouldSendDataModel ?? false + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + surfaceID = try container.decode(String.self, forKey: .surfaceID) + catalogID = try container.decode(String.self, forKey: .catalogID) + theme = try container.decodeIfPresent([String: JSONValue].self, forKey: .theme) + shouldSendDataModel = + try container.decodeIfPresent( + Bool.self, + forKey: .shouldSendDataModel + ) ?? false + } +} diff --git a/swift/core/Sources/A2UICore/Messages/DeleteSurfaceMessage.swift b/swift/core/Sources/A2UICore/Messages/DeleteSurfaceMessage.swift new file mode 100644 index 0000000000..1f2d731012 --- /dev/null +++ b/swift/core/Sources/A2UICore/Messages/DeleteSurfaceMessage.swift @@ -0,0 +1,32 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// A message commanding the client to dismantle and delete an active +/// surface. +public struct DeleteSurfaceMessage: Codable, Sendable, Equatable { + public let surfaceID: String + + private enum CodingKeys: String, CodingKey { + case surfaceID = "surfaceId" + } + + public init(surfaceID: String) { + self.surfaceID = surfaceID + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + surfaceID = try container.decode(String.self, forKey: .surfaceID) + } +} diff --git a/swift/core/Sources/A2UICore/Messages/ServerToClientMessage.swift b/swift/core/Sources/A2UICore/Messages/ServerToClientMessage.swift new file mode 100644 index 0000000000..01c8cb58d5 --- /dev/null +++ b/swift/core/Sources/A2UICore/Messages/ServerToClientMessage.swift @@ -0,0 +1,93 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// A container message enclosing one of the supported incoming +/// server-to-client commands. +/// +/// Matches `specification/v0_9_1/json/server_to_client.json`. +public enum ServerToClientMessage: Codable, Sendable, Equatable { + case createSurface(CreateSurfaceMessage) + case updateComponents(UpdateComponentsMessage) + case updateDataModel(UpdateDataModelMessage) + case deleteSurface(DeleteSurfaceMessage) + + private enum CodingKeys: String, CodingKey { + case version + case createSurface + case updateComponents + case updateDataModel + case deleteSurface + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(String.self, forKey: .version) + guard version == "v0.9" || version == "v0.9.1" else { + throw DecodingError.dataCorruptedError( + forKey: .version, + in: container, + debugDescription: "Unsupported version: \(version)" + ) + } + + if let createSurface = try container.decodeIfPresent( + CreateSurfaceMessage.self, + forKey: .createSurface + ) { + self = .createSurface(createSurface) + } else if let updateComponents = try container.decodeIfPresent( + UpdateComponentsMessage.self, + forKey: .updateComponents + ) { + self = .updateComponents(updateComponents) + } else if let updateDataModel = try container.decodeIfPresent( + UpdateDataModelMessage.self, + forKey: .updateDataModel + ) { + self = .updateDataModel(updateDataModel) + } else if let deleteSurface = try container.decodeIfPresent( + DeleteSurfaceMessage.self, + forKey: .deleteSurface + ) { + self = .deleteSurface(deleteSurface) + } else { + let context = DecodingError.Context( + codingPath: container.codingPath, + debugDescription: """ + ServerToClientMessage must contain one of: 'createSurface', \ + 'updateComponents', 'updateDataModel', or 'deleteSurface' + """ + ) + throw DecodingError.dataCorrupted(context) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("v0.9.1", forKey: .version) + switch self { + case .createSurface(let message): + try container.encode(message, forKey: .createSurface) + case .updateComponents(let message): + try container.encode(message, forKey: .updateComponents) + case .updateDataModel(let message): + try container.encode(message, forKey: .updateDataModel) + case .deleteSurface(let message): + try container.encode(message, forKey: .deleteSurface) + } + } +} + diff --git a/swift/core/Sources/A2UICore/Messages/UpdateComponentsMessage.swift b/swift/core/Sources/A2UICore/Messages/UpdateComponentsMessage.swift new file mode 100644 index 0000000000..92c543a67c --- /dev/null +++ b/swift/core/Sources/A2UICore/Messages/UpdateComponentsMessage.swift @@ -0,0 +1,41 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import OrderedJSON + +/// A message containing the updated set of component declarations for an +/// active surface. +public struct UpdateComponentsMessage: Codable, Sendable, Equatable { + public let surfaceID: String + public let components: [[String: JSONValue]] + + private enum CodingKeys: String, CodingKey { + case surfaceID = "surfaceId" + case components + } + + public init(surfaceID: String, components: [[String: JSONValue]]) { + self.surfaceID = surfaceID + self.components = components + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + surfaceID = try container.decode(String.self, forKey: .surfaceID) + components = try container.decode( + [[String: JSONValue]].self, + forKey: .components + ) + } +} diff --git a/swift/core/Sources/A2UICore/Messages/UpdateDataModelMessage.swift b/swift/core/Sources/A2UICore/Messages/UpdateDataModelMessage.swift new file mode 100644 index 0000000000..463c8ac587 --- /dev/null +++ b/swift/core/Sources/A2UICore/Messages/UpdateDataModelMessage.swift @@ -0,0 +1,50 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import OrderedJSON + +/// A message instructing the client to update a path in the active +/// surface's data model. +public struct UpdateDataModelMessage: Codable, Sendable, Equatable { + public let surfaceID: String + public let path: String + public let value: JSONValue? + + private enum CodingKeys: String, CodingKey { + case surfaceID = "surfaceId" + case path + case value + } + + public init( + surfaceID: String, + path: String? = nil, + value: JSONValue? = nil + ) { + self.surfaceID = surfaceID + self.path = path ?? "/" + self.value = value + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + surfaceID = try container.decode(String.self, forKey: .surfaceID) + path = try container.decodeIfPresent(String.self, forKey: .path) ?? "/" + if container.contains(.value) { + value = try container.decode(JSONValue.self, forKey: .value) + } else { + value = nil + } + } +} diff --git a/swift/core/Sources/A2UICore/Models/ClientAction.swift b/swift/core/Sources/A2UICore/Models/ClientAction.swift new file mode 100644 index 0000000000..d18835e359 --- /dev/null +++ b/swift/core/Sources/A2UICore/Models/ClientAction.swift @@ -0,0 +1,61 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import OrderedJSON + +/// Reports a user-initiated action from a component. +/// +/// Matches the `action` property in +/// `specification/v0_9_1/json/client_to_server.json`. +public struct ClientAction: Equatable, Codable, Sendable { + /// The name of the action, taken from the component's + /// `action.event.name` property. + public let name: String + + /// The id of the surface where the event originated. + public let surfaceID: String + + /// The id of the component that triggered the event. + public let sourceComponentID: String + + /// An ISO 8601 timestamp of when the event occurred. + public let timestamp: String + + /// A JSON object containing the key-value pairs from the component's + /// `action.event.context`, after resolving all data bindings. + public let context: [String: JSONValue] + + private enum CodingKeys: String, CodingKey { + case name + case surfaceID = "surfaceId" + case sourceComponentID = "sourceComponentId" + case timestamp + case context + } + + /// Creates a new client action. + public init( + name: String, + surfaceID: String, + sourceComponentID: String, + timestamp: String, + context: [String: JSONValue] + ) { + self.name = name + self.surfaceID = surfaceID + self.sourceComponentID = sourceComponentID + self.timestamp = timestamp + self.context = context + } +} diff --git a/swift/core/Sources/A2UICore/Models/Resolved.swift b/swift/core/Sources/A2UICore/Models/Resolved.swift new file mode 100644 index 0000000000..06f8850394 --- /dev/null +++ b/swift/core/Sources/A2UICore/Models/Resolved.swift @@ -0,0 +1,31 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// A marker protocol for resolved A2UI types that are safe to pass +/// across threads. +public protocol Resolved: Sendable { + /// Compares this resolved value with another for structural equality. + /// - Parameter other: Another resolved value to compare. + /// - Returns: True if they are structurally equal, false otherwise. + func isEqual(to other: any Resolved) -> Bool +} + +extension Resolved where Self: Equatable { + /// Default implementation of isEqual(to:) using the type's Equatable + /// conformance. + public func isEqual(to other: any Resolved) -> Bool { + guard let otherResolved = other as? Self else { return false } + return self == otherResolved + } +} diff --git a/swift/core/Sources/A2UICore/Models/ResolvedAction.swift b/swift/core/Sources/A2UICore/Models/ResolvedAction.swift new file mode 100644 index 0000000000..759ae1bf90 --- /dev/null +++ b/swift/core/Sources/A2UICore/Models/ResolvedAction.swift @@ -0,0 +1,52 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import OrderedJSON + +/// An action trigger wrapper that exposes a parameter-free trigger method. +public struct ResolvedAction: Sendable { + /// Defines the identity of the action for structural equality. + public enum Identity: Equatable, Sendable { + case event(name: String, context: [String: JSONValue]?) + case function(call: String, args: [String: JSONValue]?) + } + + /// The identity of this resolved action. + public let identity: Identity + + private let triggerClosure: @Sendable () -> Void + + /// Creates a new resolved action with the specified identity and + /// trigger closure. + public init( + identity: Identity, + trigger: @escaping @Sendable () -> Void + ) { + self.identity = identity + self.triggerClosure = trigger + } + + /// Triggers the action using function-call syntax. + public func callAsFunction() { + triggerClosure() + } +} + +extension ResolvedAction: Equatable { + public static func == (lhs: ResolvedAction, rhs: ResolvedAction) -> Bool { + lhs.identity == rhs.identity + } +} + +extension ResolvedAction: Resolved {} diff --git a/swift/core/Tests/A2UICoreTests/ClientToServerMessageTests.swift b/swift/core/Tests/A2UICoreTests/ClientToServerMessageTests.swift new file mode 100644 index 0000000000..6722d84a25 --- /dev/null +++ b/swift/core/Tests/A2UICoreTests/ClientToServerMessageTests.swift @@ -0,0 +1,262 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import A2UICore +import Foundation +import OrderedJSON +import Testing + +struct ClientToServerMessageTests { + + // MARK: - Decoding + + @Test func decodeValidAction() throws { + let json = try #require(""" + { + "version": "v0.9.1", + "action": { + "name": "submit", + "surfaceId": "main", + "sourceComponentId": "btn_submit", + "timestamp": "2023-10-27T10:00:00Z", + "context": {"foo": "bar"} + } + } + """.data(using: .utf8)) + let message = try JSONDecoder().decode( + ClientToServerMessage.self, from: json + ) + if case .action(let action) = message { + #expect(action.name == "submit") + #expect(action.surfaceID == "main") + #expect(action.sourceComponentID == "btn_submit") + #expect(action.timestamp == "2023-10-27T10:00:00Z") + #expect(action.context["foo"]?.stringValue == "bar") + } else { + Issue.record("Expected .action message") + } + } + + @Test func decodeValidActionWithEmptyContext() throws { + let json = try #require(""" + { + "version": "v0.9.1", + "action": { + "name": "click", + "surfaceId": "main", + "sourceComponentId": "btn", + "timestamp": "2024-01-15T12:30:00Z", + "context": {} + } + } + """.data(using: .utf8)) + let message = try JSONDecoder().decode( + ClientToServerMessage.self, from: json + ) + if case .action(let action) = message { + #expect(action.name == "click") + #expect(action.context.isEmpty) + } else { + Issue.record("Expected .action message") + } + } + + @Test func decodeValidError() throws { + let json = try #require(""" + { + "version": "v0.9.1", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "surface-1", + "path": "/components/0", + "message": "Missing required property" + } + } + """.data(using: .utf8)) + let message = try JSONDecoder().decode( + ClientToServerMessage.self, from: json + ) + if case .error(let error) = message { + if case .validationFailed(let validation) = error { + #expect(validation.surfaceID == "surface-1") + #expect(validation.path == "/components/0") + #expect(validation.message == "Missing required property") + } else { + Issue.record("Expected .validationFailed error") + } + } else { + Issue.record("Expected .error message") + } + } + + @Test func decodeRejectsUnsupportedVersion() throws { + let json = try #require( + """ + {"version": "v2.0", "action": {"name": "x", "surfaceId": "s", \ + "sourceComponentId": "c", "timestamp": "t", "context": {}}} + """.data(using: .utf8) + ) + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(ClientToServerMessage.self, from: json) + } + } + + @Test func decodeRejectsMissingActionAndError() throws { + let json = try #require( + "{\"version\": \"v0.9.1\"}".data(using: .utf8) + ) + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(ClientToServerMessage.self, from: json) + } + } + + @Test func decodeRejectsActionMissingRequiredField() throws { + let json = try #require(""" + { + "version": "v0.9.1", + "action": { + "name": "submit", + "surfaceId": "main" + } + } + """.data(using: .utf8)) + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(ClientToServerMessage.self, from: json) + } + } + + @Test func decodeAcceptsVersion09() throws { + let json = try #require( + """ + {"version": "v0.9", "action": {"name": "click", "surfaceId": "s", \ + "sourceComponentId": "c", "timestamp": "t", "context": {}}} + """.data(using: .utf8) + ) + let message = try JSONDecoder().decode( + ClientToServerMessage.self, from: json + ) + if case .action(let action) = message { + #expect(action.name == "click") + } + } + + // MARK: - Encoding + + @Test func encodeActionRoundTrip() throws { + let action = ClientAction( + name: "submit", + surfaceID: "main", + sourceComponentID: "btn_submit", + timestamp: "2023-10-27T10:00:00Z", + context: ["foo": .string("bar")] + ) + let message = ClientToServerMessage.action(action) + let data = try JSONEncoder().encode(message) + let decoded = try JSONDecoder().decode( + ClientToServerMessage.self, from: data + ) + #expect(decoded == message) + } + + @Test func encodeValidationError() throws { + let error = ClientServerError.validationFailed( + ValidationFailedError( + surfaceID: "surface-1", + path: "/components/0", + message: "Missing required property" + ) + ) + let message = ClientToServerMessage.error(error) + let data = try JSONEncoder().encode(message) + let decoded = try JSONDecoder().decode( + ClientToServerMessage.self, from: data + ) + #expect(decoded == message) + } + + @Test func encodeAlwaysUsesV091Version() throws { + let action = ClientAction( + name: "click", + surfaceID: "main", + sourceComponentID: "btn", + timestamp: "2024-01-01T00:00:00Z", + context: [:] + ) + let message = ClientToServerMessage.action(action) + let data = try JSONEncoder().encode(message) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(json.contains("\"version\":\"v0.9.1\"")) + } + + @Test func encodeProducesFlatActionPayload() throws { + let action = ClientAction( + name: "submit", + surfaceID: "main", + sourceComponentID: "btn_submit", + timestamp: "2023-10-27T10:00:00Z", + context: ["foo": .string("bar")] + ) + let message = ClientToServerMessage.action(action) + let data = try JSONEncoder().encode(message) + let json = try #require(String(data: data, encoding: .utf8)) + // Verify flat keys are present + #expect(json.contains("\"name\":\"submit\"")) + #expect(json.contains("\"surfaceId\":\"main\"")) + #expect(json.contains("\"sourceComponentId\":\"btn_submit\"")) + #expect(json.contains("\"timestamp\":\"2023-10-27T10:00:00Z\"")) + #expect(json.contains("\"context\"")) + // Verify nested event/call keys are absent + #expect(!json.contains("\"event\"")) + #expect(!json.contains("\"call\"")) + #expect(!json.contains("\"args\"")) + } + + // MARK: - ClientAction Equality + + @Test func clientActionsEqualByAllFields() { + let a = ClientAction( + name: "click", + surfaceID: "main", + sourceComponentID: "btn", + timestamp: "2024-01-01T00:00:00Z", + context: ["key": .string("val")] + ) + let b = ClientAction( + name: "click", + surfaceID: "main", + sourceComponentID: "btn", + timestamp: "2024-01-01T00:00:00Z", + context: ["key": .string("val")] + ) + #expect(a == b) + } + + @Test func clientActionsNotEqualByDifferentName() { + let a = ClientAction( + name: "click", + surfaceID: "main", + sourceComponentID: "btn", + timestamp: "2024-01-01T00:00:00Z", + context: [:] + ) + let b = ClientAction( + name: "submit", + surfaceID: "main", + sourceComponentID: "btn", + timestamp: "2024-01-01T00:00:00Z", + context: [:] + ) + #expect(a != b) + } +} diff --git a/swift/core/Tests/A2UICoreTests/JSONValuePathTests.swift b/swift/core/Tests/A2UICoreTests/JSONValuePathTests.swift new file mode 100644 index 0000000000..3c01d81d97 --- /dev/null +++ b/swift/core/Tests/A2UICoreTests/JSONValuePathTests.swift @@ -0,0 +1,225 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import A2UICore +import OrderedJSON +import Testing + +struct JSONValuePathTests { + + // MARK: - Type Accessors + + @Test func stringValueReturnsString() { + let value: JSONValue = "hello" + #expect(value.stringValue == "hello") + } + + @Test func stringValueReturnsNilForNonString() { + let value: JSONValue = 42 + #expect(value.stringValue == nil) + } + + @Test func doubleValueReturnsDoubleFromNumber() { + let value: JSONValue = .number(3.14) + #expect(value.doubleValue == 3.14) + } + + @Test func doubleValueReturnsDoubleFromInteger() { + let value: JSONValue = .integer(42) + #expect(value.doubleValue == 42.0) + } + + @Test func intValueReturnsFromInteger() { + let value: JSONValue = .integer(42) + #expect(value.intValue == 42) + } + + @Test func intValueReturnsFromWholeNumber() { + let value: JSONValue = .number(42.0) + #expect(value.intValue == 42) + } + + @Test func intValueReturnsNilForFractional() { + let value: JSONValue = .number(3.14) + #expect(value.intValue == nil) + } + + @Test func boolValueReturnsBool() { + let value: JSONValue = true + #expect(value.boolValue == true) + } + + @Test func boolValueReturnsNilForNonBool() { + let value: JSONValue = "true" + #expect(value.boolValue == nil) + } + + @Test func arrayValueReturnsArray() { + let value: JSONValue = [1, 2, 3] + #expect(value.arrayValue?.count == 3) + } + + @Test func dictionaryValueReturnsDict() { + let value: JSONValue = ["name": "Alice", "age": 30] + let dict = value.dictionaryValue + #expect(dict != nil) + #expect(dict?["name"]?.stringValue == "Alice") + } + + // MARK: - Path Subscript: Get + + @Test func subcriptGetReturnsRootForEmptyPath() { + let value: JSONValue = ["name": "Alice"] + #expect(value[""] != nil) + } + + @Test func subscriptGetReturnsNestedValue() { + let value: JSONValue = [ + "user": ["name": "Alice"], + ] + #expect(value["user/name"]?.stringValue == "Alice") + } + + @Test func subscriptGetReturnsNilForMissingKey() { + let value: JSONValue = ["name": "Alice"] + #expect(value["age"] == nil) + } + + @Test func subscriptGetReturnsArrayElement() { + let value: JSONValue = [ + "items": ["a", "b", "c"], + ] + #expect(value["items/1"]?.stringValue == "b") + } + + @Test func subscriptGetReturnsNilForInvalidArrayIndex() { + let value: JSONValue = [ + "items": ["a", "b"], + ] + #expect(value["items/5"] == nil) + } + + @Test func subscriptGetHandlesDeepNesting() { + let value: JSONValue = [ + "a": ["b": ["c": ["d": "deep"]]], + ] + #expect(value["a/b/c/d"]?.stringValue == "deep") + } + + // MARK: - Path Subscript: Set + + @Test func subscriptSetSetsNestedValue() { + var value: JSONValue = ["name": "Alice"] + value["name"] = "Bob" + #expect(value["name"]?.stringValue == "Bob") + } + + @Test func subscriptSetCreatesNestedPath() { + var value: JSONValue = JSONValue.object([:]) + value["user/name"] = "Alice" + #expect(value["user/name"]?.stringValue == "Alice") + } + + @Test func subscriptSetRootToArray() { + var value: JSONValue = ["name": "Alice"] + value[""] = [1, 2, 3] + #expect(value.arrayValue?.count == 3) + #expect(value["0"]?.intValue == 1) + } + + @Test func subscriptSetRootToPrimitive() { + var value: JSONValue = ["name": "Alice"] + value[""] = JSONValue.string("hello") + #expect(value == .string("hello")) + } + + @Test func subscriptSetAppendsToArray() { + var value: JSONValue = [ + "items": [1, 2, 3], + ] + value["items/3"] = 4 + #expect(value["items"]?.arrayValue?.count == 4) + #expect(value["items/3"]?.intValue == 4) + } + + @Test func subscriptSetReplacesArrayElement() { + var value: JSONValue = [ + "items": [1, 2, 3], + ] + value["items/1"] = 99 + #expect(value["items/1"]?.intValue == 99) + } + + @Test func subscriptSetDeletesByKey() { + var value: JSONValue = ["name": "Alice", "age": 30] + value["name"] = nil + #expect(value["name"] == nil) + #expect(value["age"]?.intValue == 30) + } + + @Test func subscriptSetDeletesByArrayIndex() { + var value: JSONValue = [ + "items": [1, 2, 3], + ] + value["items/1"] = nil + // Setting an array index to nil preserves length (sparse array) + #expect(value["items"]?.arrayValue?.count == 3) + #expect(value["items/0"]?.intValue == 1) + #expect(value["items/1"] == .null) + #expect(value["items/2"]?.intValue == 3) + } + + @Test func subscriptSetAutoVivifiesObjectFromArray() { + var value: JSONValue = [ + "items": [["name": "Alice"]], + ] + value["items/0/age"] = 30 + #expect(value["items/0/age"]?.intValue == 30) + #expect(value["items/0/name"]?.stringValue == "Alice") + } + + @Test func subscriptSetAutoVivifiesArrayFromPrimitive() { + var value: JSONValue = "primitive" + value["0/name"] = "Alice" + #expect(value.arrayValue?.count == 1) + #expect(value["0/name"]?.stringValue == "Alice") + } + + // MARK: - absolutePath + + @Test func absolutePathResolvesAbsolutePath() { + let result = JSONValue.absolutePath(for: "/user/name", in: "/user") + #expect(result == "/user/name") + } + + @Test func absolutePathResolvesRelativePath() { + let result = JSONValue.absolutePath(for: "name", in: "/user") + #expect(result == "/user/name") + } + + @Test func absolutePathResolvesRelativePathWithNilBase() { + let result = JSONValue.absolutePath(for: "name", in: nil) + #expect(result == "/name") + } + + @Test func absolutePathResolvesRelativePathWithEmptyBase() { + let result = JSONValue.absolutePath(for: "name", in: "") + #expect(result == "/name") + } + + @Test func absolutePathHandlesTrailingSlashInBase() { + let result = JSONValue.absolutePath(for: "name", in: "/user/") + #expect(result == "/user/name") + } +} diff --git a/swift/core/Tests/A2UICoreTests/ServerToClientMessageTests.swift b/swift/core/Tests/A2UICoreTests/ServerToClientMessageTests.swift new file mode 100644 index 0000000000..c8cc5abb54 --- /dev/null +++ b/swift/core/Tests/A2UICoreTests/ServerToClientMessageTests.swift @@ -0,0 +1,248 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import A2UICore +import Foundation +import OrderedJSON +import Testing + +struct ServerToClientMessageTests { + + // MARK: - Decoding + + @Test func decodeCreateSurface() throws { + let json = try #require(""" + { + "version": "v0.9.1", + "createSurface": { + "surfaceId": "s1", + "catalogId": "default" + } + } + """.data(using: .utf8)) + let msg = try JSONDecoder().decode( + ServerToClientMessage.self, from: json + ) + if case .createSurface(let create) = msg { + #expect(create.surfaceID == "s1") + #expect(create.catalogID == "default") + #expect(create.shouldSendDataModel == false) + } else { + Issue.record("Expected .createSurface") + } + } + + @Test func decodeCreateSurfaceWithSendDataModel() throws { + let json = try #require(""" + { + "version": "v0.9.1", + "createSurface": { + "surfaceId": "s1", + "catalogId": "default", + "sendDataModel": true + } + } + """.data(using: .utf8)) + let msg = try JSONDecoder().decode( + ServerToClientMessage.self, from: json + ) + if case .createSurface(let create) = msg { + #expect(create.shouldSendDataModel == true) + } + } + + @Test func decodeUpdateComponents() throws { + let json = try #require(""" + { + "version": "v0.9.1", + "updateComponents": { + "surfaceId": "s1", + "components": [ + {"id": "btn1", "type": "button"} + ] + } + } + """.data(using: .utf8)) + let msg = try JSONDecoder().decode( + ServerToClientMessage.self, from: json + ) + if case .updateComponents(let update) = msg { + #expect(update.surfaceID == "s1") + #expect(update.components.count == 1) + #expect(update.components[0]["id"]?.stringValue == "btn1") + } else { + Issue.record("Expected .updateComponents") + } + } + + @Test func decodeUpdateDataModel() throws { + let json = try #require(""" + { + "version": "v0.9.1", + "updateDataModel": { + "surfaceId": "s1", + "path": "/user/name", + "value": "Alice" + } + } + """.data(using: .utf8)) + let msg = try JSONDecoder().decode( + ServerToClientMessage.self, from: json + ) + if case .updateDataModel(let update) = msg { + #expect(update.surfaceID == "s1") + #expect(update.path == "/user/name") + #expect(update.value?.stringValue == "Alice") + } else { + Issue.record("Expected .updateDataModel") + } + } + + @Test func decodeUpdateDataModelDefaultsToRootPath() throws { + let json = try #require(""" + { + "version": "v0.9.1", + "updateDataModel": { + "surfaceId": "s1", + "value": {"name": "Alice"} + } + } + """.data(using: .utf8)) + let msg = try JSONDecoder().decode( + ServerToClientMessage.self, from: json + ) + if case .updateDataModel(let update) = msg { + #expect(update.path == "/") + } + } + + @Test func decodeDeleteSurface() throws { + let json = try #require(""" + { + "version": "v0.9.1", + "deleteSurface": { + "surfaceId": "s1" + } + } + """.data(using: .utf8)) + let msg = try JSONDecoder().decode( + ServerToClientMessage.self, from: json + ) + if case .deleteSurface(let delete) = msg { + #expect(delete.surfaceID == "s1") + } else { + Issue.record("Expected .deleteSurface") + } + } + + @Test func decodeRejectsEmptyMessage() throws { + let json = try #require("{}".data(using: .utf8)) + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(ServerToClientMessage.self, from: json) + } + } + + @Test func decodeRejectsMissingVersion() throws { + let json = try #require( + """ + {"createSurface": {"surfaceId": "s1", "catalogId": "default"}} + """.data(using: .utf8) + ) + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(ServerToClientMessage.self, from: json) + } + } + + @Test func decodeRejectsUnsupportedVersion() throws { + let json = try #require(""" + { + "version": "v2.0", + "createSurface": {"surfaceId": "s1", "catalogId": "default"} + } + """.data(using: .utf8)) + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(ServerToClientMessage.self, from: json) + } + } + + @Test func decodeAcceptsVersion09() throws { + let json = try #require(""" + { + "version": "v0.9", + "createSurface": {"surfaceId": "s1", "catalogId": "default"} + } + """.data(using: .utf8)) + let msg = try JSONDecoder().decode( + ServerToClientMessage.self, from: json + ) + if case .createSurface(let create) = msg { + #expect(create.surfaceID == "s1") + } + } + + // MARK: - Encoding Round-Trip + + @Test func encodeDecodeRoundTripCreateSurface() throws { + let original = ServerToClientMessage.createSurface( + CreateSurfaceMessage( + surfaceID: "s1", + catalogID: "default", + theme: ["primary": "blue"], + shouldSendDataModel: true + ) + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + ServerToClientMessage.self, from: data + ) + #expect(decoded == original) + } + + @Test func encodeDecodeRoundTripUpdateComponents() throws { + let original = ServerToClientMessage.updateComponents( + UpdateComponentsMessage( + surfaceID: "s1", + components: [ + ["id": "btn1", "type": "button"], + ["id": "txt1", "type": "text"], + ] + ) + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + ServerToClientMessage.self, from: data + ) + #expect(decoded == original) + } + + @Test func encodeDecodeRoundTripDeleteSurface() throws { + let original = ServerToClientMessage.deleteSurface( + DeleteSurfaceMessage(surfaceID: "s1") + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + ServerToClientMessage.self, from: data + ) + #expect(decoded == original) + } + + @Test func encodeAlwaysIncludesVersionV091() throws { + let original = ServerToClientMessage.deleteSurface( + DeleteSurfaceMessage(surfaceID: "s1") + ) + let data = try JSONEncoder().encode(original) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(json.contains("\"version\":\"v0.9.1\"")) + } +}