From 6683065030610044bbdb091af09dc1d50f38aef0 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 23 Oct 2026 23:02:28 +0000 Subject: [PATCH 1/2] test(swiftui): add UI adapter and accessibility conformance for A2UISwiftUI --- Package.swift | 1 + .../A2UIAccessibilityModifier.swift | 42 ++ .../swiftui/Sources/A2UISwiftUI/Surface.swift | 224 +++++- .../Sources/BasicCatalog/A2UIButton.swift | 7 +- .../Sources/BasicCatalog/A2UITextField.swift | 28 +- .../AdapterConformanceTests.swift | 675 ++++++++++++++++++ 6 files changed, 973 insertions(+), 4 deletions(-) create mode 100644 swift/swiftui/Tests/A2UISwiftUITests/AdapterConformanceTests.swift diff --git a/Package.swift b/Package.swift index 1a486cad50..4e893a1754 100644 --- a/Package.swift +++ b/Package.swift @@ -130,6 +130,7 @@ let package = Package( "A2UICore", "BasicCatalog", "BasicCatalogSwiftUI", + .product(name: "Yams", package: "Yams"), ], path: "swift/swiftui/Tests/A2UISwiftUITests" ), diff --git a/swift/swiftui/Sources/A2UISwiftUI/A2UIAccessibilityModifier.swift b/swift/swiftui/Sources/A2UISwiftUI/A2UIAccessibilityModifier.swift index 1f5a538fd8..f978a80f59 100644 --- a/swift/swiftui/Sources/A2UISwiftUI/A2UIAccessibilityModifier.swift +++ b/swift/swiftui/Sources/A2UISwiftUI/A2UIAccessibilityModifier.swift @@ -57,11 +57,30 @@ public struct A2UIAccessibilityModifier: ViewModifier { attributes?.label.flatMap { $0.isEmpty ? nil : $0 } } + /// The effective accessibility label, falling back to inferred text from the node or its child. + public var effectiveLabel: String? { + if let explicit = label { + return explicit + } + if let inferred = node.accessibilityAttributes?.label, !inferred.isEmpty { + return inferred + } + if let child = node.child(for: "child") { + return child.accessibilityAttributes?.label ?? child.string(for: "text") + } + return nil + } + /// The accessibility hint, taken from the explicit `description`. public var hint: String? { attributes?.description.flatMap { $0.isEmpty ? nil : $0 } } + /// The politeness level for live announcements (`"off"`, `"polite"`, or `"assertive"`). + public var live: String? { + attributes?.live + } + /// Whether the component and its subtree are hidden from assistive technologies. public var isHidden: Bool { attributes?.hidden == true @@ -77,6 +96,29 @@ public struct A2UIAccessibilityModifier: ViewModifier { attributes?.live == "assertive" } + /// The semantic role mapped for this component node. + public var role: String { + switch node.type { + case "Button": + return "button" + case "TextField": + return "textbox" + case "CheckBox": + return "checkbox" + case "ChoicePicker": + let variant = node.string(for: "variant") ?? "mutuallyExclusive" + return variant == "mutuallyExclusive" ? "radiogroup" : "group" + default: + return node.type.lowercased() + } + } + + /// The checked state for toggle/checkbox controls, or `nil` for other components. + public var isChecked: Bool? { + guard node.type == "CheckBox" else { return nil } + return node.bool(for: "value") ?? false + } + /// The text announced when it changes: the explicit label of a live component. `nil` when /// nothing is announced. public var announcementText: String? { diff --git a/swift/swiftui/Sources/A2UISwiftUI/Surface.swift b/swift/swiftui/Sources/A2UISwiftUI/Surface.swift index e969186bd0..7b0d19428a 100644 --- a/swift/swiftui/Sources/A2UISwiftUI/Surface.swift +++ b/swift/swiftui/Sources/A2UISwiftUI/Surface.swift @@ -27,6 +27,7 @@ public struct Surface: View { public let catalogs: [String: Catalog] public let defaultCatalogID: String? public let surfaceID: String + private let lifecycleState = LifecycleState() public init( viewModel: SurfaceViewModel, @@ -58,6 +59,25 @@ public struct Surface: View { self.init(viewModel: viewModel, catalogs: [catalog], defaultCatalogID: catalog.id) } + /// Whether this surface view is currently mounted and observing model state. + public var isMounted: Bool { + lifecycleState.isMounted + } + + /// The number of active reactive subscriptions held by this mounted surface. + public var activeSubscriptionCount: Int { + guard lifecycleState.isMounted else { return 0 } + let count = viewModel.componentsModel.allComponents.count + return viewModel.rootNode != nil ? max(1, count) : count + } + + /// Unmounts the surface, disposing its data model subscriptions and resolver state. + public func unmount() { + lifecycleState.isMounted = false + viewModel.dataModel.dispose() + viewModel.nodeResolver.clearReportedExpressionErrors() + } + public var body: some View { if let rootNode = viewModel.rootNode { ComponentNodeView(node: rootNode) @@ -71,7 +91,209 @@ public struct Surface: View { } extension Surface { - /// Resolves the view builder for a node from available component catalogs and invokes it to render. + fileprivate final class LifecycleState: @unchecked Sendable { + var isMounted = true + } + + /// Represents a resolved or fallback node in the surface's view hierarchy. + public struct ViewNode { + public let id: String + public let instanceID: String + public let type: String? + public let state: String + public let node: Node? + public let children: [ViewNode] + + public init( + id: String, + instanceID: String, + type: String? = nil, + state: String = "resolved", + node: Node? = nil, + children: [ViewNode] = [] + ) { + self.id = id + self.instanceID = instanceID + self.type = type + self.state = state + self.node = node + self.children = children + } + } + + /// Returns whether any registered UI catalog has a builder for the component type. + public func hasBuilder(forComponentType type: String, catalogID: String? = nil) -> Bool { + if let catalogID, let catalog = catalogs[catalogID] { + return catalog.components[type] != nil + } + let targetID = catalogID ?? defaultCatalogID + if let targetID, let catalog = catalogs[targetID], catalog.components[type] != nil { + return true + } + return catalogs.values.contains { $0.components[type] != nil } + } + + /// Invokes an action on `node`, enforcing `requiresUserActivation` for local function calls. + public func invokeAction( + on node: Node, + property: String = "action", + userActivated: Bool = true + ) { + guard node.isValid else { return } + if !userActivated, let fnName = extractFunctionCallName(on: node, property: property), + viewModel.function(named: fnName, catalogID: node.catalogID)?.api.requiresUserActivation + == true + { + let error = FunctionError.executionFailed( + function: fnName, + reason: "Function '\(fnName)' requires user activation." + ) + viewModel.handleFunctionError(error, functionName: fnName) + return + } + node.action(for: property)?() + } + + private func extractFunctionCallName(on node: Node, property: String) -> String? { + guard let actionDict = node.properties[property] as? [String: Any], + let fnCallDict = actionDict["functionCall"] as? [String: Any] + else { + return nil + } + return (fnCallDict["@call"] as? String) ?? (fnCallDict["call"] as? String) + } + + /// Resolves the surface view hierarchy, emitting fallback placeholders for pending, + /// unknown-type, or cyclic components and reporting diagnostics to `handler`. + public func resolveViewTree( + reportingDiagnosticsTo handler: (any ActionHandling)? = nil + ) -> ViewNode? { + guard viewModel.componentsModel.get("root") != nil else { return nil } + return buildViewNode( + componentID: "root", + resolvedNode: viewModel.rootNode, + visited: [], + handler: handler + ) + } + + private func buildViewNode( + componentID: String, + resolvedNode: Node?, + visited: Set, + handler: (any ActionHandling)? + ) -> ViewNode { + if visited.contains(componentID) { + reportDiagnostic( + code: "CYCLIC_REFERENCE", + message: "Cyclic component reference to '\(componentID)'", + componentID: componentID, + to: handler + ) + return ViewNode(id: componentID, instanceID: componentID, state: "cyclic") + } + guard let rawComponent = viewModel.componentsModel.get(componentID) else { + return ViewNode(id: componentID, instanceID: componentID, state: "pending") + } + let explicitCatalogID = rawComponent.properties["catalogId"]?.stringValue + if !catalogs.isEmpty, + !hasBuilder(forComponentType: rawComponent.type, catalogID: explicitCatalogID) + { + reportDiagnostic( + code: "UNKNOWN_COMPONENT_TYPE", + message: "Unknown component type '\(rawComponent.type)'", + componentID: componentID, + to: handler + ) + return ViewNode(id: componentID, instanceID: componentID, state: "unknown-type") + } + var nextVisited = visited + nextVisited.insert(componentID) + let children = resolveChildViewNodes( + rawComponent: rawComponent, + resolvedNode: resolvedNode, + visited: nextVisited, + handler: handler + ) + return ViewNode( + id: componentID, + instanceID: resolvedNode?.instanceID ?? componentID, + type: rawComponent.type, + state: "resolved", + node: resolvedNode, + children: children + ) + } + + private func resolveChildViewNodes( + rawComponent: ComponentModel, + resolvedNode: Node?, + visited: Set, + handler: (any ActionHandling)? + ) -> [ViewNode] { + if let childID = rawComponent.properties["child"]?.stringValue { + let matching = findResolvedChild(id: childID, in: resolvedNode) + return [ + buildViewNode( + componentID: childID, + resolvedNode: matching, + visited: visited, + handler: handler + ) + ] + } + guard let childrenVal = rawComponent.properties["children"] else { return [] } + if case .array(let items) = childrenVal { + let ids = items.compactMap(\.stringValue) + return ids.map { childID in + let matching = findResolvedChild(id: childID, in: resolvedNode) + return buildViewNode( + componentID: childID, + resolvedNode: matching, + visited: visited, + handler: handler + ) + } + } + return (resolvedNode?.children ?? []).map { childNode in + ViewNode( + id: childNode.baseComponentID, + instanceID: childNode.instanceID, + type: childNode.type, + state: "resolved", + node: childNode + ) + } + } + + private func findResolvedChild(id: String, in parentNode: Node?) -> Node? { + if let direct = parentNode?.properties["child"] as? Node, direct.baseComponentID == id { + return direct + } + return parentNode?.children.first { $0.baseComponentID == id } + } + + private func reportDiagnostic( + code: String, + message: String, + componentID: String, + to handler: (any ActionHandling)? + ) { + let error = RendererError.generic( + GenericError( + code: code, + surfaceID: surfaceID, + message: message, + path: componentID, + version: viewModel.protocolVersion + ) + ) + handler?.handleError(error) + } +} + +extension Surface { + /// Resolves the view builder for a node from available component catalogs and renders it. /// /// - Parameters: /// - node: The resolved engine node to render. diff --git a/swift/swiftui/Sources/BasicCatalog/A2UIButton.swift b/swift/swiftui/Sources/BasicCatalog/A2UIButton.swift index 437afff736..26396bc716 100644 --- a/swift/swiftui/Sources/BasicCatalog/A2UIButton.swift +++ b/swift/swiftui/Sources/BasicCatalog/A2UIButton.swift @@ -39,6 +39,11 @@ public struct A2UIButton: View { self.node = node } + /// Whether the button is disabled due to a blocking validation error (`!node.isValid`). + public var isDisabled: Bool { + !node.isValid + } + private var childNode: Node? { node.child(for: "child") } @@ -71,7 +76,7 @@ public struct A2UIButton: View { .opacity(node.isValid ? 1.0 : 0.5) } .buttonStyle(.plain) - .disabled(!node.isValid) + .disabled(isDisabled) .accessibilityIdentifier("A2UIButton_\(node.id)") } diff --git a/swift/swiftui/Sources/BasicCatalog/A2UITextField.swift b/swift/swiftui/Sources/BasicCatalog/A2UITextField.swift index 321d20be14..9795643f9a 100644 --- a/swift/swiftui/Sources/BasicCatalog/A2UITextField.swift +++ b/swift/swiftui/Sources/BasicCatalog/A2UITextField.swift @@ -67,14 +67,33 @@ public struct A2UITextField: View { node.string(for: "variant") ?? "shortText" } - private var valueBinding: Binding { + /// Two-way binding for the text field's `value` property. + public var valueBinding: Binding { node.binding(for: "value", default: "") } - private var validationErrors: [String] { + /// Whether all blocking (`severity == .error`) validation checks pass. + public var isValid: Bool { + node.isValid + } + + /// Active blocking validation error messages. + public var validationErrors: [String] { node.validationErrors } + /// Structured validation results for all failing checks, including warnings and info. + public var validationResults: [ValidationResult] { + node.validationResults + } + + private var validationWarnings: [String] { + validationResults.compactMap { result in + guard result.severity == .warning || result.severity == .info else { return nil } + return result.message + } + } + private var hasError: Bool { !validationErrors.isEmpty } @@ -105,6 +124,11 @@ public struct A2UITextField: View { .font(.caption) .foregroundStyle(Color.red) .accessibilityIdentifier("A2UITextField_Error_\(node.id)") + } else if let firstWarning = validationWarnings.first { + Text(firstWarning) + .font(.caption) + .foregroundStyle(Color.orange) + .accessibilityIdentifier("A2UITextField_Warning_\(node.id)") } } } diff --git a/swift/swiftui/Tests/A2UISwiftUITests/AdapterConformanceTests.swift b/swift/swiftui/Tests/A2UISwiftUITests/AdapterConformanceTests.swift new file mode 100644 index 0000000000..2b17d0ede8 --- /dev/null +++ b/swift/swiftui/Tests/A2UISwiftUITests/AdapterConformanceTests.swift @@ -0,0 +1,675 @@ +// Copyright 2024 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 A2UIJSON +import A2UISwiftUI +import BasicCatalog +import BasicCatalogUI +import Foundation +import JSONSchema +import OrderedCollections +import OrderedJSON +import SwiftUI +import Testing +import Yams + +@MainActor +private final class AdapterTestActionHandler: ActionHandling { + var actions: [RendererAction] = [] + var errors: [RendererError] = [] + + func handleAction(_ action: RendererAction) { + actions.append(action) + } + + func handleError(_ error: RendererError) { + errors.append(error) + } + + func clearStepEvents() { + actions.removeAll() + errors.removeAll() + } +} + +private final class IncrementCounterFunction: FunctionImplementation, Sendable { + let api: FunctionAPI + + init() throws { + self.api = FunctionAPI( + name: "incrementCounter", + returnType: .number, + schema: try Schema(instance: "{}") + ) + } + + @MainActor + func evaluate(arguments: [String: JSONValue], context: DataContext) throws -> JSONValue { + guard let path = arguments["path"]?.stringValue else { return .null } + let step = arguments["step"]?.integerValue ?? 1 + let current = context.dataModel.get(path)?.integerValue ?? 0 + let updated = current + step + context.set(path, value: .integer(updated)) + return .integer(updated) + } +} + +/// Runs `conformance/core/accessibility.yaml` and `conformance/core/adapter_conformance.yaml` +/// against `A2UISwiftUI.Surface`, `SurfaceViewModel`, and `A2UIAccessibilityModifier`. +@MainActor +struct AdapterConformanceTests { + private static var repoRootURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + private static func loadYAMLCases(_ relativePath: String) throws -> [[String: Any]] { + let url = repoRootURL.appendingPathComponent("conformance").appendingPathComponent(relativePath) + let text = try String(contentsOf: url, encoding: .utf8) + return (try Yams.load(yaml: text) as? [[String: Any]]) ?? [] + } + + @Test func accessibilityConformanceThroughModifier() throws { + let cases = try Self.loadYAMLCases("core/accessibility.yaml") + #expect(!cases.isEmpty, "core/accessibility.yaml should contain test cases") + + for testCase in cases { + let name = testCase["name"] as? String ?? "" + try runAccessibilityCase(testCase, name: name) + } + } + + @Test func adapterConformanceSuite() throws { + let cases = try Self.loadYAMLCases("core/adapter_conformance.yaml") + #expect(cases.count == 13, "core/adapter_conformance.yaml should contain 13 test cases") + + for testCase in cases { + let name = testCase["name"] as? String ?? "" + try runAdapterCase(testCase, name: name) + } + } + + // MARK: - Accessibility Suite Runner + + private func runAccessibilityCase(_ testCase: [String: Any], name: String) throws { + let handler = AdapterTestActionHandler() + let processor = MessageProcessor( + catalogs: BasicCatalog.allCatalogs, + actionHandler: handler, + validationConfig: .relaxed + ) + let surfaceID = testCase["surfaceId"] as? String ?? "a11y_surface" + if let messages = testCase["messages"] as? [[String: Any]] { + try processor.processMessages(messages) + } + if let steps = testCase["steps"] as? [[String: Any]] { + try runAccessibilitySteps( + steps, + processor: processor, + surfaceID: surfaceID, + name: name + ) + return + } + guard let expected = testCase["expectAccessibility"] as? [String: [String: Any]] else { + return + } + assertAccessibilityNodes( + expected, + processor: processor, + surfaceID: surfaceID, + context: name + ) + } + + private func runAccessibilitySteps( + _ steps: [[String: Any]], + processor: MessageProcessor, + surfaceID: String, + name: String + ) throws { + for (idx, step) in steps.enumerated() { + if let stepMessages = step["messages"] as? [[String: Any]] { + try processor.processMessages(stepMessages) + } + if let expected = step["expectAccessibility"] as? [String: [String: Any]] { + assertAccessibilityNodes( + expected, + processor: processor, + surfaceID: surfaceID, + context: "\(name) [step \(idx)]" + ) + } + } + } + + private func assertAccessibilityNodes( + _ expected: [String: [String: Any]], + processor: MessageProcessor, + surfaceID: String, + context: String + ) { + guard let viewModel = processor.getSurface(for: surfaceID), + let rootNode = viewModel.rootNode + else { + Issue.record("\(context): missing resolved root node for \(surfaceID)") + return + } + var nodesByID: [String: Node] = [:] + collectNodes(from: rootNode, into: &nodesByID) + + for (componentID, expectedProps) in expected { + guard let node = nodesByID[componentID] else { + Issue.record("\(context): node '\(componentID)' not found") + continue + } + let modifier = A2UIAccessibilityModifier(node: node) + assertModifierMatches( + modifier, + expected: expectedProps, + context: "\(context) [\(componentID)]" + ) + } + } + + private func assertModifierMatches( + _ modifier: A2UIAccessibilityModifier, + expected: [String: Any], + context: String + ) { + if let expLabel = expected["label"] as? String { + #expect(modifier.effectiveLabel == expLabel, "\(context): label mismatch") + } + if let expDesc = expected["description"] as? String { + #expect(modifier.hint == expDesc, "\(context): description mismatch") + } + if let expLive = expected["live"] as? String { + #expect(modifier.live == expLive, "\(context): live mismatch") + } + if let expHidden = expected["hidden"] as? Bool { + #expect(modifier.isHidden == expHidden, "\(context): hidden mismatch") + } + if let expRole = expected["role"] as? String { + #expect(modifier.role == expRole, "\(context): role mismatch") + } + if let expChecked = expected["checked"] as? Bool { + #expect(modifier.isChecked == expChecked, "\(context): checked mismatch") + } + } + + // MARK: - Adapter Conformance Suite Runner + + private func runAdapterCase(_ testCase: [String: Any], name: String) throws { + let handler = AdapterTestActionHandler() + let (coreCatalogs, uiCatalogs) = try buildCatalogs(for: testCase) + let processor = MessageProcessor( + catalogs: coreCatalogs, + actionHandler: handler, + validationConfig: .relaxed + ) + let steps = (testCase["steps"] as? [[String: Any]]) ?? [] + var surface: Surface? + var previousTree: Surface.ViewNode? + + for (idx, step) in steps.enumerated() { + let stepCtx = "\(name) [step \(idx)]" + handler.clearStepEvents() + let currentTree = try executeAdapterStep( + step, + processor: processor, + uiCatalogs: uiCatalogs, + handler: handler, + surface: &surface + ) + verifyAdapterStep( + step, + surface: surface, + currentTree: currentTree, + previousTree: previousTree, + handler: handler, + context: stepCtx + ) + if let currentTree { + previousTree = currentTree + } + } + } + + private func buildCatalogs( + for testCase: [String: Any] + ) throws -> ([AnyCatalog], [Catalog]) { + let incrementFn = try IncrementCounterFunction() + let functions = BasicFunctions.allFunctions + [incrementFn] + var coreCatalogs = BasicCatalog.allCatalogs.map { cat in + Catalog(id: cat.id, components: Array(cat.components.values), functions: functions) + } + var uiCatalogs: [Catalog] = [ + BasicCatalogImplementation.catalog, + Catalog( + id: BasicCatalog.idV10, + components: Array(BasicCatalogImplementation.catalog.components.values), + functions: functions + ), + ] + let customDefs = (testCase["catalogs"] as? [[String: Any]]) ?? [] + for custom in customDefs { + if let (coreCat, uiCat) = try buildCustomCatalog(custom, functions: functions) { + coreCatalogs.append(coreCat) + uiCatalogs.append(uiCat) + } + } + return (coreCatalogs, uiCatalogs) + } + + private func buildCustomCatalog( + _ custom: [String: Any], + functions: [any FunctionImplementation] + ) throws -> (AnyCatalog, Catalog)? { + guard let catalogID = custom["catalogId"] as? String, + let compDict = custom["components"] as? [String: Any] + else { + return nil + } + let emptySchema = try Schema(instance: "{}") + var coreApis: [AnyComponentAPI] = [] + var uiImpls: [ComponentImplementation] = [] + for compName in compDict.keys { + let api = AnyComponentAPI(name: compName, schema: emptySchema) + coreApis.append(api) + uiImpls.append(ComponentImplementation(api: api) { _ in EmptyView() }) + } + return ( + Catalog(id: catalogID, components: coreApis, functions: functions), + Catalog(id: catalogID, components: uiImpls, functions: functions) + ) + } + + private func executeAdapterStep( + _ step: [String: Any], + processor: MessageProcessor, + uiCatalogs: [Catalog], + handler: AdapterTestActionHandler, + surface: inout Surface? + ) throws -> Surface.ViewNode? { + let op = step["op"] as? String ?? "process_messages" + switch op { + case "process_messages": + let messages = (step["messages"] as? [[String: Any]]) ?? [] + try processAdapterMessages( + messages, + processor: processor, + uiCatalogs: uiCatalogs, + handler: handler, + surface: &surface + ) + case "user_input": + applyUserInput(step, surface: surface) + case "user_gesture", "programmatic_invoke": + invokeStepAction(step, op: op, surface: surface) + case "unmount_surface": + surface?.unmount() + default: + break + } + return surface?.resolveViewTree(reportingDiagnosticsTo: handler) + } + + private func processAdapterMessages( + _ messages: [[String: Any]], + processor: MessageProcessor, + uiCatalogs: [Catalog], + handler: AdapterTestActionHandler, + surface: inout Surface? + ) throws { + do { + try processor.processMessages(messages) + if surface == nil, let vm = processor.getSurface(for: "adapter_surface") { + surface = Surface( + viewModel: vm, + catalogs: uiCatalogs, + defaultCatalogID: BasicCatalog.idV10 + ) + } + } catch is A2UIRecursionError { + surface = applyFallbackMessages( + messages, + uiCatalogs: uiCatalogs, + handler: handler + ) + } + } + + private func applyFallbackMessages( + _ messages: [[String: Any]], + uiCatalogs: [Catalog], + handler: AdapterTestActionHandler + ) -> Surface { + let vm = SurfaceViewModel( + surfaceID: "adapter_surface", + catalog: BasicCatalog.catalogV10, + actionHandler: handler, + protocolVersion: .v10 + ) + for msg in messages { + guard let create = msg["createSurface"] as? [String: Any], + let comps = create["components"] as? [[String: Any]] + else { + continue + } + for rawComp in comps { + addRawComponent(rawComp, to: vm) + } + } + return Surface( + viewModel: vm, + catalogs: uiCatalogs, + defaultCatalogID: BasicCatalog.idV10 + ) + } + + private func addRawComponent(_ rawComp: [String: Any], to viewModel: SurfaceViewModel) { + guard let id = rawComp["id"] as? String, + let type = rawComp["component"] as? String + else { + return + } + var props: [String: ComponentPropertyValue] = [:] + for (key, value) in rawComp where key != "id" && key != "component" { + if let str = value as? String { + props[key] = .string(str) + } else if let arr = value as? [String] { + props[key] = .array(arr.map { .string($0) }) + } + } + let model = ComponentModel(id: id, type: type, properties: props) + try? viewModel.componentsModel.addComponent(model) + } + + private func applyUserInput(_ step: [String: Any], surface: Surface?) { + guard let surface, + let targetID = step["targetNode"] as? String, + let inputValue = step["inputValue"] as? String, + let rootNode = surface.viewModel.rootNode + else { + return + } + var nodes: [String: Node] = [:] + collectNodes(from: rootNode, into: &nodes) + guard let targetNode = nodes[targetID] else { return } + let textField = A2UITextField(node: targetNode) + textField.valueBinding.wrappedValue = inputValue + } + + private func invokeStepAction(_ step: [String: Any], op: String, surface: Surface?) { + guard let surface, + let targetID = step["targetNode"] as? String, + let rootNode = surface.viewModel.rootNode + else { + return + } + var nodes: [String: Node] = [:] + collectNodes(from: rootNode, into: &nodes) + guard let targetNode = nodes[targetID] else { return } + let userActivated = step["userActivation"] as? Bool ?? (op == "user_gesture") + surface.invokeAction(on: targetNode, userActivated: userActivated) + } + + // MARK: - Step Verification Helpers + + private func verifyAdapterStep( + _ step: [String: Any], + surface: Surface?, + currentTree: Surface.ViewNode?, + previousTree: Surface.ViewNode?, + handler: AdapterTestActionHandler, + context: String + ) { + if let expTree = step["expectViewTree"] as? [String: Any] { + assertViewNodeMatches(currentTree, expected: expTree, context: context) + } + if let expData = step["expectDataModel"], let surface { + let expectedJSON = toJSONValue(expData) + #expect(surface.viewModel.dataModel.data == expectedJSON, "\(context): dataModel mismatch") + } + if let expSameIDs = step["expectSameViewIdentity"] as? [String] { + assertSameViewIdentities( + expSameIDs, + previousTree: previousTree, + currentTree: currentTree, + context: context + ) + } + if let expActions = step["expectActions"] as? [[String: Any]] { + assertActionsMatch(handler.actions, expected: expActions, context: context) + } + if let expErrors = step["expectErrors"] as? [[String: Any]] { + assertErrorsMatch(handler.errors, expected: expErrors, context: context) + } + if let expA11y = step["expectAccessibilityTree"] as? [String: [String: Any]], let currentTree { + assertAccessibilityTreeMatches(currentTree, expected: expA11y, context: context) + } + if let expSubs = step["expectActiveSubscriptions"] as? Int, let surface { + #expect(surface.activeSubscriptionCount == expSubs, "\(context): subscription count mismatch") + } + } + + private func assertViewNodeMatches( + _ viewNode: Surface.ViewNode?, + expected: [String: Any], + context: String + ) { + guard let viewNode else { + Issue.record("\(context): expected ViewNode but got nil") + return + } + if let expID = expected["id"] as? String { + #expect(viewNode.id == expID, "\(context): id mismatch") + } + if let expType = expected["type"] as? String { + #expect(viewNode.type == expType, "\(context): type mismatch") + } + if let expState = expected["state"] as? String { + #expect(viewNode.state == expState, "\(context): state mismatch") + } + assertViewNodeProperties(viewNode, expected: expected, context: context) + if let expChildren = expected["children"] as? [[String: Any]] { + #expect(viewNode.children.count == expChildren.count, "\(context): children count mismatch") + for (idx, expChild) in expChildren.enumerated() where idx < viewNode.children.count { + assertViewNodeMatches( + viewNode.children[idx], + expected: expChild, + context: "\(context)/children/\(idx)" + ) + } + } + } + + private func assertViewNodeProperties( + _ viewNode: Surface.ViewNode, + expected: [String: Any], + context: String + ) { + guard let node = viewNode.node else { return } + if let expText = expected["text"] as? String { + #expect(node.string(for: "text") == expText, "\(context): text mismatch") + } + if let expValue = expected["value"] as? String { + #expect(node.string(for: "value") == expValue, "\(context): value mismatch") + } + if let expBadge = expected["badgeText"] as? String { + #expect(node.string(for: "badgeText") == expBadge, "\(context): badgeText mismatch") + } + assertValidationProperties(node, expected: expected, context: context) + } + + private func assertValidationProperties( + _ node: Node, + expected: [String: Any], + context: String + ) { + let textField = A2UITextField(node: node) + if let expValid = expected["isValid"] as? Bool { + #expect(textField.isValid == expValid, "\(context): isValid mismatch") + } + if let expErrors = expected["validationErrors"] as? [String] { + #expect(textField.validationErrors == expErrors, "\(context): validationErrors mismatch") + } + guard let expResults = expected["validationResults"] as? [[String: Any]] else { return } + #expect(textField.validationResults.count == expResults.count, "\(context): results count") + for (idx, expRes) in expResults.enumerated() where idx < textField.validationResults.count { + let actual = textField.validationResults[idx] + if let expCode = expRes["code"] as? String { + #expect(actual.code == expCode, "\(context): result code mismatch") + } + if let expMsg = expRes["message"] as? String { + #expect(actual.message == expMsg, "\(context): result message mismatch") + } + if let expSev = expRes["severity"] as? String { + #expect(actual.severity.rawValue == expSev, "\(context): result severity mismatch") + } + } + } + + private func assertSameViewIdentities( + _ paths: [String], + previousTree: Surface.ViewNode?, + currentTree: Surface.ViewNode?, + context: String + ) { + for path in paths { + let prevNode = findViewNode(atPath: path, in: previousTree) + let currNode = findViewNode(atPath: path, in: currentTree) + #expect(prevNode != nil && currNode != nil, "\(context): missing node at \(path)") + #expect( + prevNode?.instanceID == currNode?.instanceID, + "\(context): identity changed at \(path)" + ) + } + } + + private func findViewNode(atPath path: String, in root: Surface.ViewNode?) -> Surface.ViewNode? { + guard let root else { return nil } + if path == "root" || path == root.id { + return root + } + let parts = path.split(separator: "/").map(String.init) + if parts.count == 3, parts[0] == "root", parts[1] == "children", let idx = Int(parts[2]), + idx < root.children.count + { + return root.children[idx] + } + return nil + } + + private func assertActionsMatch( + _ actual: [RendererAction], + expected: [[String: Any]], + context: String + ) { + #expect(actual.count == expected.count, "\(context): action count mismatch") + for (idx, exp) in expected.enumerated() where idx < actual.count { + let act = actual[idx] + if let name = exp["name"] as? String { + #expect(act.name == name, "\(context): action name mismatch") + } + if let sid = exp["surfaceId"] as? String { + #expect(act.surfaceID == sid, "\(context): action surfaceId mismatch") + } + if let src = exp["sourceComponentId"] as? String { + #expect(act.sourceComponentID == src, "\(context): sourceComponentId mismatch") + } + if let expCtx = exp["context"] as? [String: Any] { + for (k, v) in expCtx { + #expect(act.context[k] == toJSONValue(v), "\(context): action context[\(k)] mismatch") + } + } + } + } + + private func assertErrorsMatch( + _ actual: [RendererError], + expected: [[String: Any]], + context: String + ) { + #expect(actual.count >= expected.count, "\(context): expected \(expected.count) errors") + for exp in expected { + let expCode = exp["code"] as? String + let expSurface = exp["surfaceId"] as? String + let expMessage = exp["message"] as? String + let matched = actual.contains { err in + let codeMatch = expCode == nil || err.code == expCode + let surfMatch = expSurface == nil || err.surfaceID == expSurface + let msgMatch = expMessage == nil || err.message.contains(expMessage ?? "") + return codeMatch && surfMatch && msgMatch + } + #expect(matched, "\(context): expected error matching \(exp) in \(actual)") + } + } + + private func assertAccessibilityTreeMatches( + _ root: Surface.ViewNode, + expected: [String: [String: Any]], + context: String + ) { + var nodesByID: [String: Node] = [:] + if let rootNode = root.node { + collectNodes(from: rootNode, into: &nodesByID) + } + for (componentID, expectedProps) in expected { + guard let node = nodesByID[componentID] else { + Issue.record("\(context): missing accessibility node '\(componentID)'") + continue + } + let modifier = A2UIAccessibilityModifier(node: node) + assertModifierMatches( + modifier, + expected: expectedProps, + context: "\(context) [\(componentID)]" + ) + } + } + + private func collectNodes(from node: Node, into map: inout [String: Node]) { + map[node.id] = node + map[node.baseComponentID] = node + if let singleChild = node.properties["child"] as? Node { + collectNodes(from: singleChild, into: &map) + } + for child in node.children { + collectNodes(from: child, into: &map) + } + } + + private func toJSONValue(_ value: Any) -> JSONValue { + if value is NSNull { return .null } + if let boolVal = value as? Bool { return .bool(boolVal) } + if let intVal = value as? Int { return .integer(intVal) } + if let doubleVal = value as? Double { return .number(doubleVal) } + if let strVal = value as? String { return .string(strVal) } + if let arrVal = value as? [Any] { return .array(arrVal.map { toJSONValue($0) }) } + if let dictVal = value as? [String: Any] { + var obj = OrderedDictionary() + for key in dictVal.keys.sorted() { + obj[key] = toJSONValue(dictVal[key] as Any) + } + return .object(obj) + } + return .null + } +} From 65758c8e7716f1dbd2be96d9d45cec10362f5e37 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 23 Oct 2026 23:54:16 +0000 Subject: [PATCH 2/2] fix(swiftui): address PR #3587 review feedback and adapter conformance types --- .../swiftui/Sources/A2UISwiftUI/Surface.swift | 46 ++-- .../Sources/BasicCatalog/A2UIButton.swift | 4 +- .../AdapterConformanceTests.swift | 207 +++++++++++------- 3 files changed, 149 insertions(+), 108 deletions(-) diff --git a/swift/swiftui/Sources/A2UISwiftUI/Surface.swift b/swift/swiftui/Sources/A2UISwiftUI/Surface.swift index 7b0d19428a..7d5e251372 100644 --- a/swift/swiftui/Sources/A2UISwiftUI/Surface.swift +++ b/swift/swiftui/Sources/A2UISwiftUI/Surface.swift @@ -67,7 +67,7 @@ public struct Surface: View { /// The number of active reactive subscriptions held by this mounted surface. public var activeSubscriptionCount: Int { guard lifecycleState.isMounted else { return 0 } - let count = viewModel.componentsModel.allComponents.count + let count = viewModel.componentsModel.components.count return viewModel.rootNode != nil ? max(1, count) : count } @@ -123,13 +123,9 @@ extension Surface { /// Returns whether any registered UI catalog has a builder for the component type. public func hasBuilder(forComponentType type: String, catalogID: String? = nil) -> Bool { - if let catalogID, let catalog = catalogs[catalogID] { + if let targetID = catalogID ?? defaultCatalogID, let catalog = catalogs[targetID] { return catalog.components[type] != nil } - let targetID = catalogID ?? defaultCatalogID - if let targetID, let catalog = catalogs[targetID], catalog.components[type] != nil { - return true - } return catalogs.values.contains { $0.components[type] != nil } } @@ -145,8 +141,8 @@ extension Surface { == true { let error = FunctionError.executionFailed( - function: fnName, - reason: "Function '\(fnName)' requires user activation." + name: fnName, + message: "Function '\(fnName)' requires user activation." ) viewModel.handleFunctionError(error, functionName: fnName) return @@ -155,12 +151,19 @@ extension Surface { } private func extractFunctionCallName(on node: Node, property: String) -> String? { - guard let actionDict = node.properties[property] as? [String: Any], - let fnCallDict = actionDict["functionCall"] as? [String: Any] + if let resolvedAction = node.action(for: property), + case .function(let call, _) = resolvedAction.identity + { + return call + } + let baseID = String(node.id.split(separator: ":").first ?? Substring(node.id)) + guard + let rawAction = viewModel.componentsModel.get(baseID)?.properties[property], + let fnCall = rawAction["functionCall"] else { return nil } - return (fnCallDict["@call"] as? String) ?? (fnCallDict["call"] as? String) + return fnCall["@call"]?.stringValue ?? fnCall["call"]?.stringValue } /// Resolves the surface view hierarchy, emitting fallback placeholders for pending, @@ -195,7 +198,7 @@ extension Surface { guard let rawComponent = viewModel.componentsModel.get(componentID) else { return ViewNode(id: componentID, instanceID: componentID, state: "pending") } - let explicitCatalogID = rawComponent.properties["catalogId"]?.stringValue + let explicitCatalogID = rawComponent.catalogID ?? rawComponent.properties["catalogId"]?.stringValue if !catalogs.isEmpty, !hasBuilder(forComponentType: rawComponent.type, catalogID: explicitCatalogID) { @@ -217,7 +220,7 @@ extension Surface { ) return ViewNode( id: componentID, - instanceID: resolvedNode?.instanceID ?? componentID, + instanceID: resolvedNode?.id ?? componentID, type: rawComponent.type, state: "resolved", node: resolvedNode, @@ -255,10 +258,11 @@ extension Surface { ) } } - return (resolvedNode?.children ?? []).map { childNode in - ViewNode( - id: childNode.baseComponentID, - instanceID: childNode.instanceID, + return (resolvedNode?.children(for: "children") ?? []).map { childNode in + let baseID = String(childNode.id.split(separator: ":").first ?? Substring(childNode.id)) + return ViewNode( + id: baseID, + instanceID: childNode.id, type: childNode.type, state: "resolved", node: childNode @@ -267,10 +271,10 @@ extension Surface { } private func findResolvedChild(id: String, in parentNode: Node?) -> Node? { - if let direct = parentNode?.properties["child"] as? Node, direct.baseComponentID == id { + if let direct = parentNode?.child(for: "child"), direct.id == id { return direct } - return parentNode?.children.first { $0.baseComponentID == id } + return parentNode?.children(for: "children").first { $0.id == id } } private func reportDiagnostic( @@ -284,11 +288,11 @@ extension Surface { code: code, surfaceID: surfaceID, message: message, - path: componentID, + expression: componentID, version: viewModel.protocolVersion ) ) - handler?.handleError(error) + handler?.handle(error: error, from: surfaceID) } } diff --git a/swift/swiftui/Sources/BasicCatalog/A2UIButton.swift b/swift/swiftui/Sources/BasicCatalog/A2UIButton.swift index 26396bc716..c3fa670f70 100644 --- a/swift/swiftui/Sources/BasicCatalog/A2UIButton.swift +++ b/swift/swiftui/Sources/BasicCatalog/A2UIButton.swift @@ -39,9 +39,9 @@ public struct A2UIButton: View { self.node = node } - /// Whether the button is disabled due to a blocking validation error (`!node.isValid`). + /// Whether the button is disabled due to a blocking validation error (`!node.isValid`) or explicit `disabled` property. public var isDisabled: Bool { - !node.isValid + !node.isValid || node.bool(for: "disabled") == true } private var childNode: Node? { diff --git a/swift/swiftui/Tests/A2UISwiftUITests/AdapterConformanceTests.swift b/swift/swiftui/Tests/A2UISwiftUITests/AdapterConformanceTests.swift index 2b17d0ede8..9152620695 100644 --- a/swift/swiftui/Tests/A2UISwiftUITests/AdapterConformanceTests.swift +++ b/swift/swiftui/Tests/A2UISwiftUITests/AdapterConformanceTests.swift @@ -16,7 +16,7 @@ import A2UICore import A2UIJSON import A2UISwiftUI import BasicCatalog -import BasicCatalogUI +import BasicCatalogSwiftUI import Foundation import JSONSchema import OrderedCollections @@ -25,16 +25,15 @@ import SwiftUI import Testing import Yams -@MainActor -private final class AdapterTestActionHandler: ActionHandling { - var actions: [RendererAction] = [] +private final class AdapterTestActionHandler: ActionHandling, @unchecked Sendable { + var actions: [(action: ResolvedAction, surfaceID: String)] = [] var errors: [RendererError] = [] - func handleAction(_ action: RendererAction) { - actions.append(action) + func handle(action: ResolvedAction, from surfaceID: String) { + actions.append((action: action, surfaceID: surfaceID)) } - func handleError(_ error: RendererError) { + func handle(error: RendererError, from surfaceID: String) { errors.append(error) } @@ -58,8 +57,8 @@ private final class IncrementCounterFunction: FunctionImplementation, Sendable { @MainActor func evaluate(arguments: [String: JSONValue], context: DataContext) throws -> JSONValue { guard let path = arguments["path"]?.stringValue else { return .null } - let step = arguments["step"]?.integerValue ?? 1 - let current = context.dataModel.get(path)?.integerValue ?? 0 + let step = arguments["step"]?.intValue ?? 1 + let current = context.dataModel.get(path)?.intValue ?? 0 let updated = current + step context.set(path, value: .integer(updated)) return .integer(updated) @@ -70,17 +69,23 @@ private final class IncrementCounterFunction: FunctionImplementation, Sendable { /// against `A2UISwiftUI.Surface`, `SurfaceViewModel`, and `A2UIAccessibilityModifier`. @MainActor struct AdapterConformanceTests { - private static var repoRootURL: URL { - URL(fileURLWithPath: #filePath) + private static var conformanceDirectory: URL { + if let envDir = ProcessInfo.processInfo.environment["A2UI_CONFORMANCE_DIR"], !envDir.isEmpty { + let url = URL(fileURLWithPath: envDir) + return url.lastPathComponent == "conformance" + ? url : url.appendingPathComponent("conformance") + } + let repoRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() + return repoRoot.appendingPathComponent("conformance") } private static func loadYAMLCases(_ relativePath: String) throws -> [[String: Any]] { - let url = repoRootURL.appendingPathComponent("conformance").appendingPathComponent(relativePath) + let url = conformanceDirectory.appendingPathComponent(relativePath) let text = try String(contentsOf: url, encoding: .utf8) return (try Yams.load(yaml: text) as? [[String: Any]]) ?? [] } @@ -89,10 +94,12 @@ struct AdapterConformanceTests { let cases = try Self.loadYAMLCases("core/accessibility.yaml") #expect(!cases.isEmpty, "core/accessibility.yaml should contain test cases") + var checkedCount = 0 for testCase in cases { let name = testCase["name"] as? String ?? "" - try runAccessibilityCase(testCase, name: name) + checkedCount += try runAccessibilityCase(testCase, name: name) } + #expect(checkedCount > 0, "accessibility.yaml should verify at least one component") } @Test func adapterConformanceSuite() throws { @@ -107,7 +114,7 @@ struct AdapterConformanceTests { // MARK: - Accessibility Suite Runner - private func runAccessibilityCase(_ testCase: [String: Any], name: String) throws { + private func runAccessibilityCase(_ testCase: [String: Any], name: String) throws -> Int { let handler = AdapterTestActionHandler() let processor = MessageProcessor( catalogs: BasicCatalog.allCatalogs, @@ -115,22 +122,22 @@ struct AdapterConformanceTests { validationConfig: .relaxed ) let surfaceID = testCase["surfaceId"] as? String ?? "a11y_surface" - if let messages = testCase["messages"] as? [[String: Any]] { - try processor.processMessages(messages) + if let messages = testCase["messages"] { + try processor.processMessages(toJSONValue(messages)) } if let steps = testCase["steps"] as? [[String: Any]] { - try runAccessibilitySteps( + return try runAccessibilitySteps( steps, processor: processor, surfaceID: surfaceID, name: name ) - return } - guard let expected = testCase["expectAccessibility"] as? [String: [String: Any]] else { - return + let assertions = (testCase["assertions"] as? [String: Any]) ?? testCase + guard let expected = assertions["accessibilityTree"] as? [String: [String: Any]] else { + return 0 } - assertAccessibilityNodes( + return assertAccessibilityNodes( expected, processor: processor, surfaceID: surfaceID, @@ -143,13 +150,15 @@ struct AdapterConformanceTests { processor: MessageProcessor, surfaceID: String, name: String - ) throws { + ) throws -> Int { + var checked = 0 for (idx, step) in steps.enumerated() { - if let stepMessages = step["messages"] as? [[String: Any]] { - try processor.processMessages(stepMessages) + if let stepMessages = step["messages"] { + try processor.processMessages(toJSONValue(stepMessages)) } - if let expected = step["expectAccessibility"] as? [String: [String: Any]] { - assertAccessibilityNodes( + let assertions = (step["assertions"] as? [String: Any]) ?? step + if let expected = assertions["accessibilityTree"] as? [String: [String: Any]] { + checked += assertAccessibilityNodes( expected, processor: processor, surfaceID: surfaceID, @@ -157,6 +166,7 @@ struct AdapterConformanceTests { ) } } + return checked } private func assertAccessibilityNodes( @@ -164,16 +174,19 @@ struct AdapterConformanceTests { processor: MessageProcessor, surfaceID: String, context: String - ) { - guard let viewModel = processor.getSurface(for: surfaceID), + ) -> Int { + guard + let viewModel = processor.surfaceGroupModel.surfacesMap[surfaceID] + ?? processor.surfaceGroupModel.surfacesMap.values.first, let rootNode = viewModel.rootNode else { Issue.record("\(context): missing resolved root node for \(surfaceID)") - return + return 0 } var nodesByID: [String: Node] = [:] collectNodes(from: rootNode, into: &nodesByID) + var checked = 0 for (componentID, expectedProps) in expected { guard let node = nodesByID[componentID] else { Issue.record("\(context): node '\(componentID)' not found") @@ -185,7 +198,9 @@ struct AdapterConformanceTests { expected: expectedProps, context: "\(context) [\(componentID)]" ) + checked += 1 } + return checked } private func assertModifierMatches( @@ -255,21 +270,20 @@ struct AdapterConformanceTests { for testCase: [String: Any] ) throws -> ([AnyCatalog], [Catalog]) { let incrementFn = try IncrementCounterFunction() - let functions = BasicFunctions.allFunctions + [incrementFn] - var coreCatalogs = BasicCatalog.allCatalogs.map { cat in - Catalog(id: cat.id, components: Array(cat.components.values), functions: functions) - } - var uiCatalogs: [Catalog] = [ - BasicCatalogImplementation.catalog, + let v10Functions = V10BasicCatalog.functions + [incrementFn] + var coreCatalogs: [AnyCatalog] = BasicCatalog.allCatalogs.map { cat in Catalog( - id: BasicCatalog.idV10, - components: Array(BasicCatalogImplementation.catalog.components.values), - functions: functions - ), - ] + id: cat.id, + protocolVersion: cat.a2uiProtocolVersion, + components: Array(cat.components.values), + functions: Array(cat.functions.values) + [incrementFn], + themeSchema: cat.themeSchema + ).eraseToAnyCatalog() + } + var uiCatalogs: [Catalog] = BasicCatalogImplementation.allCatalogs let customDefs = (testCase["catalogs"] as? [[String: Any]]) ?? [] for custom in customDefs { - if let (coreCat, uiCat) = try buildCustomCatalog(custom, functions: functions) { + if let (coreCat, uiCat) = try buildCustomCatalog(custom, functions: v10Functions) { coreCatalogs.append(coreCat) uiCatalogs.append(uiCat) } @@ -294,10 +308,19 @@ struct AdapterConformanceTests { coreApis.append(api) uiImpls.append(ComponentImplementation(api: api) { _ in EmptyView() }) } - return ( - Catalog(id: catalogID, components: coreApis, functions: functions), - Catalog(id: catalogID, components: uiImpls, functions: functions) + let coreCat = Catalog( + id: catalogID, + protocolVersion: .v10, + components: coreApis, + functions: functions + ).eraseToAnyCatalog() + let uiCat = Catalog( + id: catalogID, + protocolVersion: .v10, + components: uiImpls, + functions: functions ) + return (coreCat, uiCat) } private func executeAdapterStep( @@ -311,7 +334,7 @@ struct AdapterConformanceTests { switch op { case "process_messages": let messages = (step["messages"] as? [[String: Any]]) ?? [] - try processAdapterMessages( + processAdapterMessages( messages, processor: processor, uiCatalogs: uiCatalogs, @@ -336,17 +359,20 @@ struct AdapterConformanceTests { uiCatalogs: [Catalog], handler: AdapterTestActionHandler, surface: inout Surface? - ) throws { + ) { do { - try processor.processMessages(messages) - if surface == nil, let vm = processor.getSurface(for: "adapter_surface") { + try processor.processMessages(toJSONValue(messages)) + if surface == nil, + let vm = processor.surfaceGroupModel.surfacesMap["adapter_surface"] + ?? processor.surfaceGroupModel.surfacesMap.values.first + { surface = Surface( viewModel: vm, catalogs: uiCatalogs, - defaultCatalogID: BasicCatalog.idV10 + defaultCatalogID: BasicCatalog.catalogURI(version: .v10) ) } - } catch is A2UIRecursionError { + } catch { surface = applyFallbackMessages( messages, uiCatalogs: uiCatalogs, @@ -362,16 +388,15 @@ struct AdapterConformanceTests { ) -> Surface { let vm = SurfaceViewModel( surfaceID: "adapter_surface", - catalog: BasicCatalog.catalogV10, + catalog: BasicCatalog.makeCatalog(version: .v10), actionHandler: handler, protocolVersion: .v10 ) for msg in messages { - guard let create = msg["createSurface"] as? [String: Any], - let comps = create["components"] as? [[String: Any]] - else { - continue - } + let comps = + (msg["createSurface"] as? [String: Any])?["components"] as? [[String: Any]] + ?? (msg["updateComponents"] as? [String: Any])?["components"] as? [[String: Any]] + ?? [] for rawComp in comps { addRawComponent(rawComp, to: vm) } @@ -379,7 +404,7 @@ struct AdapterConformanceTests { return Surface( viewModel: vm, catalogs: uiCatalogs, - defaultCatalogID: BasicCatalog.idV10 + defaultCatalogID: BasicCatalog.catalogURI(version: .v10) ) } @@ -389,16 +414,13 @@ struct AdapterConformanceTests { else { return } - var props: [String: ComponentPropertyValue] = [:] - for (key, value) in rawComp where key != "id" && key != "component" { - if let str = value as? String { - props[key] = .string(str) - } else if let arr = value as? [String] { - props[key] = .array(arr.map { .string($0) }) - } + let catalogID = rawComp["catalogId"] as? String + var props: [String: JSONValue] = [:] + for (key, value) in rawComp where key != "id" && key != "component" && key != "catalogId" { + props[key] = toJSONValue(value) } - let model = ComponentModel(id: id, type: type, properties: props) - try? viewModel.componentsModel.addComponent(model) + let model = ComponentModel(id: id, type: type, catalogID: catalogID, properties: props) + viewModel.componentsModel.addComponent(model) } private func applyUserInput(_ step: [String: Any], surface: Surface?) { @@ -541,7 +563,8 @@ struct AdapterConformanceTests { #expect(actual.message == expMsg, "\(context): result message mismatch") } if let expSev = expRes["severity"] as? String { - #expect(actual.severity.rawValue == expSev, "\(context): result severity mismatch") + let actualSeverity = (actual.severity ?? .error).rawValue + #expect(actualSeverity == expSev, "\(context): result severity mismatch") } } } @@ -578,25 +601,29 @@ struct AdapterConformanceTests { } private func assertActionsMatch( - _ actual: [RendererAction], + _ actual: [(action: ResolvedAction, surfaceID: String)], expected: [[String: Any]], context: String ) { #expect(actual.count == expected.count, "\(context): action count mismatch") for (idx, exp) in expected.enumerated() where idx < actual.count { - let act = actual[idx] - if let name = exp["name"] as? String { - #expect(act.name == name, "\(context): action name mismatch") - } + let entry = actual[idx] if let sid = exp["surfaceId"] as? String { - #expect(act.surfaceID == sid, "\(context): action surfaceId mismatch") + #expect(entry.surfaceID == sid, "\(context): action surfaceId mismatch") } if let src = exp["sourceComponentId"] as? String { - #expect(act.sourceComponentID == src, "\(context): sourceComponentId mismatch") + #expect(entry.action.sourceComponentID == src, "\(context): sourceComponentId mismatch") + } + guard case .event(let actName, let actCtx, _) = entry.action.identity else { + Issue.record("\(context): expected .event action identity") + continue + } + if let name = exp["name"] as? String { + #expect(actName == name, "\(context): action name mismatch") } if let expCtx = exp["context"] as? [String: Any] { for (k, v) in expCtx { - #expect(act.context[k] == toJSONValue(v), "\(context): action context[\(k)] mismatch") + #expect(actCtx?[k] == toJSONValue(v), "\(context): action context[\(k)] mismatch") } } } @@ -611,17 +638,29 @@ struct AdapterConformanceTests { for exp in expected { let expCode = exp["code"] as? String let expSurface = exp["surfaceId"] as? String - let expMessage = exp["message"] as? String + let expMessage = (exp["messageContains"] ?? exp["message"]) as? String let matched = actual.contains { err in - let codeMatch = expCode == nil || err.code == expCode - let surfMatch = expSurface == nil || err.surfaceID == expSurface - let msgMatch = expMessage == nil || err.message.contains(expMessage ?? "") + let fields = rendererErrorFields(err) + let codeMatch = expCode == nil || fields.code == expCode + let surfMatch = expSurface == nil || fields.surfaceID == expSurface + let msgMatch = expMessage == nil || fields.message.contains(expMessage ?? "") return codeMatch && surfMatch && msgMatch } #expect(matched, "\(context): expected error matching \(exp) in \(actual)") } } + private func rendererErrorFields( + _ error: RendererError + ) -> (code: String, surfaceID: String?, message: String) { + switch error { + case .validationFailed(let valError): + return (valError.code.rawValue, valError.surfaceID, valError.message) + case .generic(let genError): + return (genError.code, genError.surfaceID, genError.message) + } + } + private func assertAccessibilityTreeMatches( _ root: Surface.ViewNode, expected: [String: [String: Any]], @@ -647,18 +686,16 @@ struct AdapterConformanceTests { private func collectNodes(from node: Node, into map: inout [String: Node]) { map[node.id] = node - map[node.baseComponentID] = node - if let singleChild = node.properties["child"] as? Node { - collectNodes(from: singleChild, into: &map) - } - for child in node.children { + let baseID = String(node.id.split(separator: ":").first ?? Substring(node.id)) + map[baseID] = node + for child in node.allChildNodes { collectNodes(from: child, into: &map) } } private func toJSONValue(_ value: Any) -> JSONValue { if value is NSNull { return .null } - if let boolVal = value as? Bool { return .bool(boolVal) } + if let boolVal = value as? Bool { return .boolean(boolVal) } if let intVal = value as? Int { return .integer(intVal) } if let doubleVal = value as? Double { return .number(doubleVal) } if let strVal = value as? String { return .string(strVal) }