From 1646d1b62c673eaa66c4e57264d1baf96fedb69a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 07:02:34 +0700 Subject: [PATCH 1/2] feat(swift-sdk): typed arrays in the Swift SDK and example app (PV14) The contract parser no longer refuses a typed array (an array property declared by an items schema); it persists it as an ordinary array property. DocumentTypedArray reads the element kind, bounds and enum off the stored schemaJSON, so no SwiftData field or schema version is added, and converts form text into JSON values of the element's own kind. The example app's document form edits a typed array one row per element and refuses an invalid list before broadcast, since a refused transition is still paid for. The state-transition builder now encodes its document fields through the same conversion, which also stops the Objective-C exception it raised on identifier and byte array values. Co-Authored-By: Claude Opus 5.5 --- .../Core/Utils/DataContractParser.swift | 29 +- .../Core/Utils/DocumentTypedArray.swift | 621 ++++++++++++++++++ .../Models/PersistentDocumentType.swift | 24 + .../Views/DocumentFieldsView.swift | 368 +++++++++++ .../Views/DocumentTypeDetailsView.swift | 8 + .../SwiftExampleApp/Views/DocumentsView.swift | 40 +- .../Views/StorageRecordDetailViews.swift | 6 + .../Views/TransitionDetailView.swift | 41 +- .../DataContractParserTypedArrayTests.swift | 406 +++++++++--- .../DocumentTypedArrayElementInputTests.swift | 431 ++++++++++++ 10 files changed, 1871 insertions(+), 103 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentTypedArray.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentTypedArrayElementInputTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift index d21c6d5cf63..45043890e7c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift @@ -3,22 +3,6 @@ import SwiftData public struct DataContractParser { - // MARK: - Errors - public enum ParseError: LocalizedError, Equatable { - /// A protocol-version-14 typed array: a `type: "array"` property - /// declared by an `items` schema instead of `byteArray: true`. - /// `PersistentProperty` has no element type, so the parser refuses the - /// contract rather than persist the property as a bare array. - case unsupportedTypedArray(documentType: String, property: String) - - public var errorDescription: String? { - switch self { - case let .unsupportedTypedArray(documentType, property): - return "typed arrays (an array property declared by an items schema) are not supported by the Swift SDK yet: document type \(documentType), property \(property)" - } - } - } - // MARK: - Parse Data Contract public static func parseDataContract(contractData: [String: Any], contractId: Data, modelContext: ModelContext) throws { print("๐Ÿ”ต Parsing data contract with ID: \(contractId.toBase58String())") @@ -387,12 +371,13 @@ public struct DataContractParser { // Extract type let type = propertyDict["type"] as? String ?? "unknown" - // An array that declares `items` instead of `byteArray` is a - // protocol-version-14 typed array. Refuse it until the Swift SDK - // supports typed arrays, rather than persist it as a bare array. - if type == "array", propertyDict["byteArray"] == nil, propertyDict["items"] != nil { - throw ParseError.unsupportedTypedArray(documentType: documentTypeName, property: propertyName) - } + // A protocol-version-14 typed array (an array declaring `items` + // instead of `byteArray`) is persisted like any array: `type` + // "array", `byteArray` false, `minItems` / `maxItems` counting + // elements. Its element schema needs no column of its own: + // `schemaJSON` is the whole type dictionary, and + // `PersistentDocumentType.typedArrays` reads it back. Keep that + // true when touching the schema stored there. // Create persistent property let property = PersistentProperty( diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentTypedArray.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentTypedArray.swift new file mode 100644 index 00000000000..98658662a6e --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentTypedArray.swift @@ -0,0 +1,621 @@ +import CoreFoundation +import Foundation + +/// A typed array property of a document type (meta-schema v3, protocol +/// version 14): a list of scalars, declared as `type: "array"` with an +/// `items` schema naming what every element is and a required `maxItems`. +/// +/// ```json +/// "scores": { +/// "type": "array", +/// "items": { "type": "integer", "minimum": 0, "maximum": 100 }, +/// "minItems": 1, +/// "maxItems": 8, +/// "uniqueItems": true, +/// "position": 0 +/// } +/// ``` +/// +/// A property that declares `byteArray` itself is a byte array (or an +/// identifier), never a typed array, whatever else it declares. +/// +/// The fields mirror wasm-dpp2's `DocumentTypedArrayProperty` key for key. +/// DPP validated the declaration when the contract was registered; this type +/// only reads it back off the schema as authored and does not re-validate it. +/// The element checks of `Element.value(fromInput:)` are a client-side +/// courtesy: consensus is the authority on what a document may hold. +public struct DocumentTypedArray: Equatable, Sendable { + /// Dotted path of the property within the document type: `"reasons"`, or + /// `"team.leads"` for one nested in an object property. + public let path: String + + /// What every element is. + public let element: Element + + /// The fewest elements a document may hold; `nil` when not declared. + public let minItems: Int? + + /// The most elements a document may hold. Every typed array declares it. + public let maxItems: Int + + /// Whether a document repeating an element is refused. `false` when the + /// schema does not declare `uniqueItems`. + public let uniqueItems: Bool + + /// A declaration from its parts, as `init?(path:propertySchema:)` reads + /// them. + public init(path: String, element: Element, minItems: Int?, maxItems: Int, uniqueItems: Bool) { + self.path = path + self.element = element + self.minItems = minItems + self.maxItems = maxItems + self.uniqueItems = uniqueItems + } + + /// Read one property schema dictionary, as authored in the contract. + /// + /// `nil` when the property is not a typed array: not `type: "array"`, a + /// `byteArray` key on the property, no `items` object, an `items` schema + /// that is not one scalar DPP admits as an element, or no integer + /// `maxItems`. DPP refuses a contract declaring the last three, so they + /// can only reach a client through hand-edited JSON. + public init?(path: String, propertySchema: [String: Any]) { + guard propertySchema["type"] as? String == "array", + propertySchema["byteArray"] == nil, + let items = propertySchema["items"] as? [String: Any], + let element = Element(itemsSchema: items), + let maxItems = Self.jsonInteger(propertySchema["maxItems"]) + else { + return nil + } + self.init( + path: path, + element: element, + minItems: Self.jsonInteger(propertySchema["minItems"]), + maxItems: maxItems, + uniqueItems: Self.jsonBool(propertySchema["uniqueItems"]) ?? false + ) + } + + /// Every typed array a document type declares, sorted by path. Walks into + /// `object` properties, naming a nested typed array by its dotted path. + /// + /// `documentTypeSchema` is the whole document type dictionary as authored + /// in the contract (`PersistentDocumentType.schema`). + public static func all(inDocumentTypeSchema documentTypeSchema: [String: Any]?) -> [DocumentTypedArray] { + var found: [DocumentTypedArray] = [] + collect(properties: documentTypeSchema?["properties"], prefix: nil, into: &found) + return found.sorted { $0.path < $1.path } + } + + /// The typed array a document type declares as its top-level property + /// `name`, or `nil` when that property is absent or not a typed array. + /// A nested typed array is not found by its dotted path here: use + /// `all(inDocumentTypeSchema:)`. + public static func named( + _ name: String, + inDocumentTypeSchema documentTypeSchema: [String: Any]? + ) -> DocumentTypedArray? { + guard let properties = documentTypeSchema?["properties"] as? [String: Any], + let propertySchema = properties[name] as? [String: Any] + else { + return nil + } + return DocumentTypedArray(path: name, propertySchema: propertySchema) + } + + private static func collect( + properties: Any?, + prefix: String?, + into found: inout [DocumentTypedArray] + ) { + guard let properties = properties as? [String: Any] else { return } + for (name, value) in properties { + guard let propertySchema = value as? [String: Any] else { continue } + let path = prefix.map { "\($0).\(name)" } ?? name + if let typedArray = DocumentTypedArray(path: path, propertySchema: propertySchema) { + found.append(typedArray) + } else if propertySchema["type"] as? String == "object" { + collect(properties: propertySchema["properties"], prefix: path, into: &found) + } + } + } +} + +// MARK: - Element + +extension DocumentTypedArray { + /// What every element of a typed array is, read from its `items` schema. + /// + /// The cases mirror wasm-dpp2's `DocumentTypedArrayItem`. The bounds are + /// the schema keywords': `minLength` / `maxLength` count a string + /// element's characters, `minSize` / `maxSize` (the items' `minItems` / + /// `maxItems`) a byte array element's bytes, and `minimum` / `maximum` an + /// integer or number element's range. `allowedValues` is the items' + /// `enum`, in declared order. Each is `nil` when the schema omits it. + public enum Element: Equatable, Sendable { + case integer(minimum: Int?, maximum: Int?, allowedValues: [Int]?) + case number(minimum: Double?, maximum: Double?, allowedValues: [Double]?) + case boolean(allowedValues: [Bool]?) + case string(minLength: Int?, maxLength: Int?, allowedValues: [String]?) + case byteArray(minSize: Int?, maxSize: Int?) + /// A 32-byte identifier: a byte array element carrying the identifier + /// `contentMediaType`. + case identifier + } +} + +extension DocumentTypedArray.Element { + /// The `contentMediaType` that makes a byte array element an identifier. + static let identifierMediaType = "application/x.dash.dpp.identifier" + + /// Parse an `items` schema the way DPP types an element: an integer, + /// number, boolean or string schema, or a `byteArray: true` array, which + /// the identifier media type makes an identifier. Anything else (an + /// object, an array of arrays) is not an element DPP admits. + /// + /// An `enum` member or bound that no Swift value of the element type can + /// hold (an integer beyond `Int`) is left out, and an `enum` left empty + /// that way reads as `nil`: the client then checks less, and consensus + /// still checks everything. + init?(itemsSchema items: [String: Any]) { + typealias Reader = DocumentTypedArray + switch items["type"] as? String { + case "integer": + self = .integer( + minimum: Reader.jsonInteger(items["minimum"]), + maximum: Reader.jsonInteger(items["maximum"]), + allowedValues: Reader.members(items["enum"], Reader.jsonInteger) + ) + case "number": + self = .number( + minimum: Reader.jsonDouble(items["minimum"]), + maximum: Reader.jsonDouble(items["maximum"]), + allowedValues: Reader.members(items["enum"], Reader.jsonDouble) + ) + case "boolean": + self = .boolean(allowedValues: Reader.members(items["enum"], Reader.jsonBool)) + case "string": + self = .string( + minLength: Reader.jsonInteger(items["minLength"]), + maxLength: Reader.jsonInteger(items["maxLength"]), + allowedValues: Reader.members(items["enum"]) { $0 as? String } + ) + case "array": + guard Reader.jsonBool(items["byteArray"]) == true else { return nil } + if items["contentMediaType"] as? String == Self.identifierMediaType { + self = .identifier + } else { + self = .byteArray( + minSize: Reader.jsonInteger(items["minItems"]), + maxSize: Reader.jsonInteger(items["maxItems"]) + ) + } + default: + return nil + } + } +} + +// MARK: - JSON readers + +extension DocumentTypedArray { + /// A JSON number, but never a JSON boolean: `JSONSerialization` hands + /// both back as `NSNumber`, and an `NSNumber` boolean casts to `1` / `0`. + static func jsonNumber(_ value: Any?) -> NSNumber? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() + else { + return nil + } + return number + } + + /// A JSON integer that fits `Int`; `nil` for a fraction or a boolean. + static func jsonInteger(_ value: Any?) -> Int? { + jsonNumber(value).flatMap { Int(exactly: $0) } + } + + /// A JSON number read as `Double`; `nil` for a boolean. + static func jsonDouble(_ value: Any?) -> Double? { + jsonNumber(value)?.doubleValue + } + + /// A JSON boolean; `nil` for a number, which an `NSNumber` cast to `Bool` + /// would otherwise accept. + static func jsonBool(_ value: Any?) -> Bool? { + guard let number = value as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() + else { + return nil + } + return number.boolValue + } + + /// The members of an `enum` list that `read` accepts, or `nil` when there + /// is no list or none of its members is readable. + static func members(_ value: Any?, _ read: (Any?) -> T?) -> [T]? { + guard let list = value as? [Any] else { return nil } + let members = list.compactMap { read($0) } + return members.isEmpty ? nil : members + } +} + +// MARK: - Element input + +extension DocumentTypedArray { + /// One element in the JSON form the platform wallet's schema sanitizer + /// takes (`DocumentType::sanitize_document_properties`): integers, numbers + /// and booleans as JSON numbers and booleans, which the sanitizer narrows + /// to the element's width but never parses out of a string; a string + /// element, an identifier as base58, and a byte array as hex, all as JSON + /// strings the sanitizer decodes. Never `Data`: `JSONSerialization` + /// cannot encode `Data` inside an array. + public enum ElementValue: Hashable, Sendable { + /// An integer element, sent as a JSON integer. + case integer(Int) + /// A number element, sent as a JSON number (always finite). + case number(Double) + /// A boolean element, sent as a JSON boolean. + case boolean(Bool) + /// A string element, an identifier as base58, or a byte array as + /// lowercase hex. + case string(String) + + /// The value as a `JSONSerialization`-encodable object: an `Int`, + /// `Double`, `Bool` or `String`. + public var jsonValue: Any { + switch self { + case let .integer(value): return value + case let .number(value): return value + case let .boolean(value): return value + case let .string(value): return value + } + } + + /// Text that `Element.value(fromInput:)` reads back as this value. A + /// whole number is written without a fraction (`2`, not `2.0`). + public var inputText: String { + switch self { + case let .integer(value): + return String(value) + case let .number(value): + if value.rounded() == value, abs(value) < 1e15 { + return String(Int64(value)) + } + return String(value) + case let .boolean(value): + return value ? "true" : "false" + case let .string(value): + return value + } + } + } + + /// Why the text entered for one element is not a value of its element. + public enum ElementInputError: Error, Equatable, Sendable, LocalizedError { + /// Nothing entered for an element that is not a string. + case empty + /// Not a whole number `Int` can hold. + case notAnInteger(String) + /// Not a finite decimal number. + case notANumber(String) + /// Neither `true` nor `false`. + case notABoolean(String) + /// Below `minimum` or above `maximum`; the bounds are given as input + /// text, `nil` when not declared. + case outOfRange(value: String, minimum: String?, maximum: String?) + /// Not a member of the element's `enum`, given as input text. + case notAllowed(value: String, allowed: [String]) + /// A string element's length in characters outside + /// `minLength` / `maxLength`. + case wrongLength(length: Int, minimum: Int?, maximum: Int?) + /// Not base58 text. + case invalidBase58(String) + /// Not an even number of hex digits. + case invalidHex(String) + /// A byte array's size, or an identifier's (which must be exactly 32), + /// outside the declared bounds. + case wrongByteCount(count: Int, minimum: Int?, maximum: Int?) + + public var errorDescription: String? { + switch self { + case .empty: + return "Enter a value." + case let .notAnInteger(text): + return "\"\(text)\" is not a whole number." + case let .notANumber(text): + return "\"\(text)\" is not a number." + case let .notABoolean(text): + return "\"\(text)\" is not true or false." + case let .outOfRange(value, minimum, maximum): + return "\(value) is outside the allowed range (\(Self.bounds(minimum, maximum)))." + case let .notAllowed(value, allowed): + return "\"\(value)\" is not one of the allowed values: \(allowed.joined(separator: ", "))." + case let .wrongLength(length, minimum, maximum): + return "\(length) characters; the element takes \(Self.bounds(minimum.map(String.init), maximum.map(String.init)))." + case .invalidBase58: + return "Not a valid base58 identifier." + case .invalidHex: + return "Not valid hex: use an even number of 0-9 and a-f digits." + case let .wrongByteCount(count, minimum, maximum): + return "\(count) bytes; the element takes \(Self.bounds(minimum.map(String.init), maximum.map(String.init)))." + } + } + + private static func bounds(_ minimum: String?, _ maximum: String?) -> String { + switch (minimum, maximum) { + case let (minimum?, maximum?) where minimum == maximum: + return "exactly \(minimum)" + case let (minimum?, maximum?): + return "\(minimum) to \(maximum)" + case let (minimum?, nil): + return "at least \(minimum)" + case let (nil, maximum?): + return "at most \(maximum)" + case (nil, nil): + return "any" + } + } + } +} + +extension DocumentTypedArray { + /// Why the rows entered for a typed array cannot be sent as its value. + /// The message names the property by its path, and a row by its + /// zero-based index in the path syntax (`scores[1]`). + public enum InputError: Error, Equatable, Sendable, LocalizedError { + /// Fewer rows than `minItems`. + case tooFewElements(path: String, count: Int, minimum: Int) + /// More rows than `maxItems`. + case tooManyElements(path: String, count: Int, maximum: Int) + /// The row at `index` is not a value of the element. + case invalidElement(path: String, index: Int, reason: ElementInputError) + /// Under `uniqueItems`, the row at `index` converts to the same value + /// as the row at `firstIndex`. + case repeatedElement(path: String, index: Int, firstIndex: Int) + + public var errorDescription: String? { + switch self { + case let .tooFewElements(path, count, minimum): + return "\(path): \(Self.elements(count)); the list takes at least \(minimum)." + case let .tooManyElements(path, count, maximum): + return "\(path): \(Self.elements(count)); the list takes at most \(maximum)." + case let .invalidElement(path, index, reason): + return "\(path)[\(index)]: \(reason.localizedDescription)" + case let .repeatedElement(path, index, firstIndex): + return "\(path)[\(index)]: repeats \(path)[\(firstIndex)], and the elements must be unique." + } + } + + private static func elements(_ count: Int) -> String { + count == 1 ? "1 element" : "\(count) elements" + } + } + + /// Convert every row entered for this typed array, in order, into the + /// list to send, or say why the list cannot be sent. + /// + /// Checks, in order: the row count against `minItems` / `maxItems`, each + /// row with `Element.value(fromInput:)` (the first bad row is reported), + /// and, under `uniqueItems`, that no two rows convert to the same value. + /// Uniqueness compares the converted values, so `1` and `1.0` in a number + /// list, or one identifier typed twice with different spacing, repeat. + /// A caller that leaves an empty optional list out of the document skips + /// this check for it; an empty list that is sent must pass `minItems`. + /// + /// Like the element check, this is a client-side courtesy that spares a + /// transition consensus would refuse and still charge for. + public func values( + fromInputs inputs: [String] + ) -> Result<[ElementValue], InputError> { + if let minItems, inputs.count < minItems { + return .failure(.tooFewElements(path: path, count: inputs.count, minimum: minItems)) + } + if inputs.count > maxItems { + return .failure(.tooManyElements(path: path, count: inputs.count, maximum: maxItems)) + } + + var values: [ElementValue] = [] + values.reserveCapacity(inputs.count) + for (index, input) in inputs.enumerated() { + switch element.value(fromInput: input) { + case let .success(value): + values.append(value) + case let .failure(reason): + return .failure(.invalidElement(path: path, index: index, reason: reason)) + } + } + + if uniqueItems { + var firstIndexOf: [ElementValue: Int] = [:] + for (index, value) in values.enumerated() { + if let firstIndex = firstIndexOf[value] { + return .failure(.repeatedElement(path: path, index: index, firstIndex: firstIndex)) + } + firstIndexOf[value] = index + } + } + + return .success(values) + } + + /// `values(fromInputs:)` as `JSONSerialization`-encodable elements: the + /// array to put in a document's properties JSON under this path. + public func jsonArray(fromInputs inputs: [String]) -> Result<[Any], InputError> { + values(fromInputs: inputs).map { $0.map(\.jsonValue) } + } +} + +extension DocumentTypedArray.Element { + /// The input text of each value the element's `enum` allows, in declared + /// order: what a picker offers. `value(fromInput:)` accepts every entry. + /// `nil` when the element declares no `enum`; byte array and identifier + /// elements never do. + public var allowedInputs: [String]? { + typealias Value = DocumentTypedArray.ElementValue + switch self { + case let .integer(_, _, allowedValues): + return allowedValues?.map { Value.integer($0).inputText } + case let .number(_, _, allowedValues): + return allowedValues?.map { Value.number($0).inputText } + case let .boolean(allowedValues): + return allowedValues?.map { Value.boolean($0).inputText } + case let .string(_, _, allowedValues): + return allowedValues + case .byteArray, .identifier: + return nil + } + } + + /// Convert the text entered for one element into the value to send, or + /// say why it is not one. + /// + /// - Integer: a whole number within `minimum` / `maximum`. + /// - Number: a finite decimal within `minimum` / `maximum`. A lone comma + /// reads as the decimal separator, which the decimal pad shows in some + /// locales. + /// - Boolean: `true` or `false`. + /// - String: the text exactly as entered, spaces and commas included, + /// with its length counted in Unicode scalars as JSON Schema counts + /// characters. An empty string is a value. + /// - Identifier: base58 text decoding to 32 bytes, sent as base58. + /// - Byte array: hex digits (an optional `0x` prefix is dropped) within + /// the declared size, sent as lowercase hex. + /// + /// Surrounding whitespace is ignored for every kind but string. A value + /// outside the element's `enum` fails. These checks spare the user a + /// transition consensus would refuse; they decide nothing consensus + /// does not decide again. + public func value( + fromInput text: String + ) -> Result { + typealias Value = DocumentTypedArray.ElementValue + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if case .string = self { + // A string element keeps its text exactly, and may be empty + } else if trimmed.isEmpty { + return .failure(.empty) + } + + switch self { + case let .string(minLength, maxLength, allowedValues): + return Self.stringValue( + text, minLength: minLength, maxLength: maxLength, allowedValues: allowedValues) + + case let .integer(minimum, maximum, allowedValues): + guard let value = Int(trimmed) else { return .failure(.notAnInteger(trimmed)) } + return Self.checked( + Value.integer(value), + isAllowed: allowedValues.map { $0.contains(value) }, + allowed: allowedInputs, + isBelowMinimum: minimum.map { value < $0 } ?? false, + isAboveMaximum: maximum.map { value > $0 } ?? false, + minimum: minimum.map { Value.integer($0).inputText }, + maximum: maximum.map { Value.integer($0).inputText } + ) + + case let .number(minimum, maximum, allowedValues): + let commaCount = trimmed.filter { $0 == "," }.count + let normalized = commaCount == 1 && !trimmed.contains(".") + ? trimmed.replacingOccurrences(of: ",", with: ".") + : trimmed + guard let value = Double(normalized), value.isFinite else { + return .failure(.notANumber(trimmed)) + } + return Self.checked( + Value.number(value), + isAllowed: allowedValues.map { $0.contains(value) }, + allowed: allowedInputs, + isBelowMinimum: minimum.map { value < $0 } ?? false, + isAboveMaximum: maximum.map { value > $0 } ?? false, + minimum: minimum.map { Value.number($0).inputText }, + maximum: maximum.map { Value.number($0).inputText } + ) + + case let .boolean(allowedValues): + let value: Bool + switch trimmed.lowercased() { + case "true": value = true + case "false": value = false + default: return .failure(.notABoolean(trimmed)) + } + return Self.checked( + Value.boolean(value), + isAllowed: allowedValues.map { $0.contains(value) }, + allowed: allowedInputs, + isBelowMinimum: false, + isAboveMaximum: false, + minimum: nil, + maximum: nil + ) + + case .identifier: + guard let bytes = Data.identifier(fromBase58: trimmed) else { + return .failure(.invalidBase58(trimmed)) + } + guard bytes.count == 32 else { + return .failure(.wrongByteCount(count: bytes.count, minimum: 32, maximum: 32)) + } + return .success(.string(bytes.toBase58String())) + + case let .byteArray(minSize, maxSize): + let digits = trimmed.hasPrefix("0x") || trimmed.hasPrefix("0X") + ? String(trimmed.dropFirst(2)) + : trimmed + guard !digits.isEmpty else { return .failure(.empty) } + // `isASCII` too: `isHexDigit` also admits the fullwidth digits + guard digits.count.isMultiple(of: 2), + digits.allSatisfy({ $0.isASCII && $0.isHexDigit }) + else { + return .failure(.invalidHex(trimmed)) + } + let count = digits.count / 2 + let tooSmall = minSize.map { count < $0 } ?? false + let tooLarge = maxSize.map { count > $0 } ?? false + if tooSmall || tooLarge { + return .failure(.wrongByteCount(count: count, minimum: minSize, maximum: maxSize)) + } + return .success(.string(digits.lowercased())) + } + } + + private static func stringValue( + _ text: String, + minLength: Int?, + maxLength: Int?, + allowedValues: [String]? + ) -> Result { + if let allowedValues, !allowedValues.contains(text) { + return .failure(.notAllowed(value: text, allowed: allowedValues)) + } + let length = text.unicodeScalars.count + if let minLength, length < minLength { + return .failure(.wrongLength(length: length, minimum: minLength, maximum: maxLength)) + } + if let maxLength, length > maxLength { + return .failure(.wrongLength(length: length, minimum: minLength, maximum: maxLength)) + } + return .success(.string(text)) + } + + /// The `enum` is checked first: when a value fails both, naming the + /// allowed values says more than naming the range. + private static func checked( + _ value: DocumentTypedArray.ElementValue, + isAllowed: Bool?, + allowed: [String]?, + isBelowMinimum: Bool, + isAboveMaximum: Bool, + minimum: String?, + maximum: String? + ) -> Result { + if isAllowed == false { + return .failure(.notAllowed(value: value.inputText, allowed: allowed ?? [])) + } + if isBelowMinimum || isAboveMaximum { + return .failure(.outOfRange(value: value.inputText, minimum: minimum, maximum: maximum)) + } + return .success(value) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift index 08277a8bb37..2761654c802 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift @@ -128,6 +128,30 @@ extension PersistentDocumentType { immutability.immutableAllowSetting } + /// Every typed array property the type declares (protocol version 14), + /// those nested in object properties included under their dotted path + /// (`"team.leads"`), sorted by path. Empty when it declares none. + /// + /// Derived from the persisted schema rather than stored, for the same + /// reason as `immutability`: `schemaJSON` holds the whole document type + /// dictionary as authored, element schemas included, and a new stored + /// property on this model or on `PersistentProperty` would move an entity + /// hash, which costs a schema version and a fixture store (see + /// `DashModelContainer.modelTypes` and `DashModelMigrationTests`). + /// `PersistentProperty` keeps a typed array as an ordinary `"array"` row + /// with `byteArray` false and its element counts in `minItems` / + /// `maxItems`. + public var typedArrays: [DocumentTypedArray] { + DocumentTypedArray.all(inDocumentTypeSchema: schema) + } + + /// The typed array declared as the top-level property `name`, or `nil` + /// when that property is absent or is not a typed array (a byte array + /// among them). Read off the persisted schema; see `typedArrays`. + public func typedArray(named name: String) -> DocumentTypedArray? { + DocumentTypedArray.named(name, inDocumentTypeSchema: schema) + } + public var documentCount: Int { documents?.count ?? 0 } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift index 59f4c1f899a..2721b0f00ab 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift @@ -18,15 +18,43 @@ struct DocumentFieldsView: View { /// caption says so. var storedPropertyNames: Set = [] + /// Top-level typed array properties (protocol version 14) by name, read + /// off the persisted schema once per view value. A typed array gets the + /// list editor; any other `"array"` row keeps its old editor. + private let typedArrays: [String: DocumentTypedArray] + @State private var textFields: [String: String] = [:] @State private var numberFields: [String: String] = [:] @State private var boolFields: [String: Bool] = [:] @State private var arrayFields: [String: String] = [:] + /// One entry per element of each typed array, in list order, holding the + /// text entered for it (`"true"` / `"false"` for a boolean toggle, the + /// chosen member's input text for an `enum` picker). + @State private var typedArrayRows: [String: [TypedArrayRow]] = [:] /// Boolean fields the user has actually toggled. Untouched optional /// booleans are omitted from the payload (absence โ‰  `false` for some /// schemas) rather than broadcast as the seeded `false` default. @State private var touchedBoolFields: Set = [] + init( + documentType: PersistentDocumentType, + fieldValues: Binding<[String: Any]>, + immutability: DocumentTypeImmutability = .none, + storedPropertyNames: Set = [] + ) { + self.documentType = documentType + self._fieldValues = fieldValues + self.immutability = immutability + self.storedPropertyNames = storedPropertyNames + + var typedArrays: [String: DocumentTypedArray] = [:] + for property in documentType.propertiesList ?? [] + where property.type == "array" && !property.byteArray { + typedArrays[property.name] = documentType.typedArray(named: property.name) + } + self.typedArrays = typedArrays + } + var body: some View { VStack(alignment: .leading, spacing: 16) { if let properties = documentType.propertiesList, !properties.isEmpty { @@ -121,6 +149,9 @@ struct DocumentFieldsView: View { if property.byteArray { // Byte arrays should be entered as hex strings byteArrayField(for: property) + } else if let typedArray = typedArrays[property.name] { + // Typed arrays: one typed input per element + typedArrayField(for: property, typedArray: typedArray) } else { // Regular arrays with comma-separated values VStack(alignment: .leading, spacing: 4) { @@ -229,6 +260,8 @@ struct DocumentFieldsView: View { case "array": if property.byteArray { textFields[property.name] = "" // Use text field for hex input + } else if typedArrays[property.name] != nil { + typedArrayRows[property.name] = [] // One row per element } else { arrayFields[property.name] = "" // Use array field for comma-separated } @@ -314,8 +347,32 @@ struct DocumentFieldsView: View { } } + // Add typed arrays: one JSON value per row, in row order. An empty + // optional list is left out; every other list goes through the + // whole-list check, `minItems` included. A list that fails it is + // represented by the `DocumentTypedArray.InputError` itself, which + // `CreateDocumentView.propertiesJSON` throws: a refused document + // transition is still paid for, so an invalid list must never be + // broadcast. + for (key, rows) in typedArrayRows { + guard let typedArray = typedArrays[key] else { continue } + if rows.isEmpty && !isRequired(key) { + continue + } + switch typedArray.jsonArray(fromInputs: rows.map(\.text)) { + case .success(let array): + values[key] = array + case .failure(let error): + values[key] = error + } + } + fieldValues = values } + + private func isRequired(_ propertyName: String) -> Bool { + documentType.propertiesList?.first(where: { $0.name == propertyName })?.isRequired ?? false + } } @@ -397,3 +454,314 @@ extension DocumentFieldsView { return stringCharacterSet.isSubset(of: hexCharacterSet) && string.count == expectedLength } } + +// MARK: - Typed Array Field Helper + +/// One element row of a typed array editor. Rows are identified by `id`, not +/// by position, so a binding captured before a removal cannot write into the +/// wrong row. +struct TypedArrayRow: Identifiable, Equatable { + let id = UUID() + var text: String +} + +extension DocumentFieldsView { + /// The list editor for a typed array (protocol version 14): one row per + /// element with an input suited to the element, an add button that stops + /// at `maxItems`, a remove button per row, and a caption stating what an + /// element is and how many the list takes. Each row is marked with the + /// verdict of `DocumentTypedArray.Element.value(fromInput:)`, and a list + /// failing `DocumentTypedArray.values(fromInputs:)` is never sent: the + /// submit refuses it (see `updateFieldValues`). + @ViewBuilder + private func typedArrayField(for property: PersistentProperty, typedArray: DocumentTypedArray) -> some View { + let name = property.name + let element = typedArray.element + let rows = typedArrayRows[name] ?? [] + let results = rows.map { element.value(fromInput: $0.text) } + // The whole-list refusal that `updateFieldValues` sends in place of + // the list. A bad row is already marked on its row, so only the count + // and repeat refusals are shown here. An empty optional list is left + // out of the document and is never checked. + let listError: DocumentTypedArray.InputError? = { + guard !rows.isEmpty || property.isRequired, + case let .failure(error) = typedArray.values(fromInputs: rows.map(\.text)) + else { + return nil + } + if case .invalidElement = error { return nil } + return error + }() + + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + // The index a refusal names (`tags[1]`) + Text("[\(index)]") + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.secondary) + + typedArrayElementInput(name: name, index: index, rowId: row.id, element: element) + + if case .failure = results[index] { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + .accessibilityLabel("Invalid \(name)[\(index)]") + .accessibilityIdentifier("createDocument.field.\(name).\(index).invalid") + } + + Button { + removeTypedArrayRow(name, id: row.id) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + // Borderless: the whole editor sits in one Form row, + // where a default button would claim every tap in it + .buttonStyle(.borderless) + .accessibilityLabel("Remove \(name)[\(index)]") + .accessibilityIdentifier("createDocument.field.\(name).\(index).remove") + } + + if case let .failure(error) = results[index] { + Text(error.localizedDescription) + .font(.caption2) + .foregroundColor(.red) + } + } + } + + Button { + addTypedArrayRow(name, typedArray: typedArray) + } label: { + Label("Add element", systemImage: "plus.circle") + .font(.subheadline) + } + .buttonStyle(.borderless) + .disabled(rows.count >= typedArray.maxItems) + .accessibilityIdentifier("createDocument.field.\(name).add") + + Text("Each element: \(element.summary)") + .font(.caption2) + .foregroundColor(.secondary) + + Text(typedArrayCountCaption(typedArray, count: rows.count)) + .font(.caption2) + .foregroundColor(.secondary) + .accessibilityIdentifier("createDocument.field.\(name).count") + + if let listError { + Text(listError.localizedDescription) + .font(.caption2) + .foregroundColor(.orange) + .accessibilityIdentifier("createDocument.field.\(name).error") + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("createDocument.field.\(name)") + } + + /// The input for one element, chosen by the element kind: a picker when + /// an `enum` is declared, a toggle for a boolean, and otherwise a text + /// field with the keyboard the kind needs. + @ViewBuilder + private func typedArrayElementInput( + name: String, + index: Int, + rowId: UUID, + element: DocumentTypedArray.Element + ) -> some View { + let identifier = "createDocument.field.\(name).\(index)" + let text = typedArrayTextBinding(name: name, rowId: rowId) + + if let options = element.allowedInputs { + Picker("\(name)[\(index)]", selection: text) { + ForEach(Array(options.enumerated()), id: \.offset) { _, option in + Text(option).tag(option) + } + } + .pickerStyle(.menu) + .labelsHidden() + .accessibilityIdentifier(identifier) + Spacer() + } else { + switch element { + case .boolean: + Toggle(isOn: Binding( + get: { text.wrappedValue == "true" }, + set: { text.wrappedValue = $0 ? "true" : "false" } + )) { + Text(text.wrappedValue) + .font(.subheadline) + } + .accessibilityLabel("\(name)[\(index)]") + .accessibilityIdentifier(identifier) + + case let .integer(minimum, _, _): + TextField("Whole number", text: text) + // The number pad has no minus key: offer it only when + // the element cannot be negative + .keyboardType((minimum ?? -1) >= 0 ? .numberPad : .numbersAndPunctuation) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .accessibilityIdentifier(identifier) + + case let .number(minimum, _, _): + TextField("Number", text: text) + .keyboardType((minimum ?? -1) >= 0 ? .decimalPad : .numbersAndPunctuation) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .accessibilityIdentifier(identifier) + + case .string: + TextField("Text", text: text) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .accessibilityIdentifier(identifier) + + case .byteArray: + TextField("Hex bytes", text: text) + .font(.system(.body, design: .monospaced)) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + .accessibilityIdentifier(identifier) + + case .identifier: + TextField("Base58 identifier", text: text) + .font(.system(.body, design: .monospaced)) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + .accessibilityIdentifier(identifier) + } + } + } + + /// "2 of 1 to 5 elements", plus the uniqueness rule when declared. + private func typedArrayCountCaption(_ typedArray: DocumentTypedArray, count: Int) -> String { + var caption: String + if let minItems = typedArray.minItems, minItems > 0 { + caption = minItems == typedArray.maxItems + ? "\(count) of exactly \(minItems) elements" + : "\(count) of \(minItems) to \(typedArray.maxItems) elements" + } else { + caption = "\(count) of at most \(typedArray.maxItems) elements" + } + if typedArray.uniqueItems { + caption += " ยท elements must be unique" + } + return caption + } + + /// Binds one row's text by row id, so a stale binding after a removal + /// writes nothing rather than into another row. + private func typedArrayTextBinding(name: String, rowId: UUID) -> Binding { + Binding( + get: { typedArrayRows[name]?.first(where: { $0.id == rowId })?.text ?? "" }, + set: { newValue in + guard let index = typedArrayRows[name]?.firstIndex(where: { $0.id == rowId }) else { + return + } + typedArrayRows[name]?[index].text = newValue + updateFieldValues() + } + ) + } + + /// A new row starts on the first allowed value when an `enum` is declared + /// (a picker must select one of its tags), `false` for a boolean, and + /// empty otherwise. + private func addTypedArrayRow(_ name: String, typedArray: DocumentTypedArray) { + var rows = typedArrayRows[name] ?? [] + guard rows.count < typedArray.maxItems else { return } + let initialText: String + if let first = typedArray.element.allowedInputs?.first { + initialText = first + } else if case .boolean = typedArray.element { + initialText = "false" + } else { + initialText = "" + } + rows.append(TypedArrayRow(text: initialText)) + typedArrayRows[name] = rows + updateFieldValues() + } + + private func removeTypedArrayRow(_ name: String, id: UUID) { + typedArrayRows[name]?.removeAll { $0.id == id } + updateFieldValues() + } +} + +// MARK: - Typed Array Descriptions + +extension DocumentTypedArray.Element { + /// The element kind with its declared bounds and allowed values, for + /// captions and schema detail rows: "integer (1 to 10), one of 1, 5, 10". + var summary: String { + typealias Value = DocumentTypedArray.ElementValue + var text: String + switch self { + case let .integer(minimum, maximum, _): + text = "integer" + Self.bounds( + minimum.map { Value.integer($0).inputText }, + maximum.map { Value.integer($0).inputText }, + unit: nil) + case let .number(minimum, maximum, _): + text = "number" + Self.bounds( + minimum.map { Value.number($0).inputText }, + maximum.map { Value.number($0).inputText }, + unit: nil) + case .boolean: + text = "boolean" + case let .string(minLength, maxLength, _): + text = "string" + Self.bounds( + minLength.map(String.init), maxLength.map(String.init), unit: "characters") + case let .byteArray(minSize, maxSize): + text = "byte array, hex" + Self.bounds( + minSize.map(String.init), maxSize.map(String.init), unit: "bytes") + case .identifier: + text = "identifier, base58" + } + if let allowed = allowedInputs { + text += ", one of " + allowed.map { $0.isEmpty ? "\"\"" : $0 }.joined(separator: ", ") + } + return text + } + + private static func bounds(_ minimum: String?, _ maximum: String?, unit: String?) -> String { + let suffix = unit.map { " \($0)" } ?? "" + switch (minimum, maximum) { + case let (minimum?, maximum?) where minimum == maximum: + return " (exactly \(minimum)\(suffix))" + case let (minimum?, maximum?): + return " (\(minimum) to \(maximum)\(suffix))" + case let (minimum?, nil): + return " (at least \(minimum)\(suffix))" + case let (nil, maximum?): + return " (at most \(maximum)\(suffix))" + case (nil, nil): + return "" + } + } +} + +extension DocumentTypedArray { + /// "list of integer (1 to 10), 1 to 5 elements, unique": the whole + /// declaration in one line, for schema detail views. + var summary: String { + var text = "list of \(element.summary)" + if let minItems, minItems > 0 { + text += minItems == maxItems + ? "; exactly \(maxItems) elements" + : "; \(minItems) to \(maxItems) elements" + } else { + text += "; at most \(maxItems) elements" + } + if uniqueItems { + text += ", unique" + } + return text + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift index 84e104ec214..731d741e047 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift @@ -424,6 +424,14 @@ struct PropertyRowView: View { // Property attributes propertyAttributesView + // A typed array (protocol version 14): what its elements are + if let dict = propertyDict, + let typedArray = DocumentTypedArray(path: propertyName, propertySchema: dict) { + Label(typedArray.summary, systemImage: "list.bullet") + .font(.caption2) + .foregroundColor(.purple) + } + // Sub-properties for objects if propertyType == "object", let dict = propertyDict { subPropertiesView(dict: dict) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift index c38bfbfd51d..c157d226459 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift @@ -1478,7 +1478,10 @@ struct CreateDocumentView: View { /// Field values produced by `DocumentFieldsView`. Byte-array fields /// arrive as `Data`, identifier fields as `Data`, scalars as - /// `Int`/`Double`/`Bool`/`String`, arrays as `[String]`. + /// `Int`/`Double`/`Bool`/`String`, plain arrays as `[String]`, and typed + /// arrays as `[Any]` of JSON-native elements (identifier elements as + /// base58 and byte array elements as hex strings, never `Data`), or as + /// the `DocumentTypedArray.InputError` refusing the list. @State private var fieldValues: [String: Any] = [:] @State private var isSubmitting = false @@ -1865,7 +1868,18 @@ struct CreateDocumentView: View { /// hex/base58 identifiers back to native values. `object`-typed /// fields arrive as the editor's raw JSON `String`; they are parsed /// back into a nested object so they serialize as objects, not as a - /// JSON string. Other values are JSON-native and pass through. + /// JSON string. Other values are JSON-native and pass through, + /// typed arrays included: their elements are already `Int` / `Double` / + /// `Bool` / `String`, which the same sanitize step narrows or decodes + /// per element. + /// + /// Throws, so that nothing is broadcast, when a typed array failed its + /// client-side check (the field value is then the + /// `DocumentTypedArray.InputError` itself: a refused transition is still + /// paid for) and when any value is one JSON cannot carry, such as NaN + /// from a number field, which `JSONSerialization` would otherwise raise + /// on as an Objective-C exception. The state-transition builder + /// (`TransitionDetailView`) encodes its document fields here too. static func propertiesJSON( from fieldValues: [String: Any], documentType: PersistentDocumentType @@ -1876,7 +1890,12 @@ struct CreateDocumentView: View { .map(\.name) ?? [] ) var jsonObject: [String: Any] = [:] - for (key, value) in fieldValues { + // Sorted, so the reported refusal does not depend on hash order + for key in fieldValues.keys.sorted() { + guard let value = fieldValues[key] else { continue } + if let refusal = value as? DocumentTypedArray.InputError { + throw refusal + } if let data = value as? Data { jsonObject[key] = data.toHexString() } else if objectFields.contains(key), let text = value as? String { @@ -1887,12 +1906,27 @@ struct CreateDocumentView: View { } else { jsonObject[key] = value } + if let encoded = jsonObject[key], !JSONSerialization.isValidJSONObject([encoded]) { + throw DocumentPropertiesEncodingError.notJSON(property: key) + } } let data = try JSONSerialization.data(withJSONObject: jsonObject, options: []) return String(data: data, encoding: .utf8) ?? "{}" } } +/// A document field value `JSONSerialization` cannot encode. +enum DocumentPropertiesEncodingError: LocalizedError { + case notJSON(property: String) + + var errorDescription: String? { + switch self { + case let .notJSON(property): + return "\(property) holds a value JSON cannot carry (a number must be finite)." + } + } +} + struct DetailRow: View { let label: String let value: String diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 6bbceba4243..7fe6ed1cd28 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1392,6 +1392,12 @@ struct PropertyStorageDetailView: View { label: "Max Items", value: record.maxItems.map { "\($0)" } ?? "โ€”" ) + // A typed array's element schema has no column: it is read + // off the document type's persisted schema + if let typedArray = record.documentType?.typedArray(named: record.name) { + FieldRow(label: "Items", value: typedArray.element.summary) + FieldRow(label: "Unique Items", value: typedArray.uniqueItems ? "Yes" : "No") + } FieldRow( label: "Min Value", value: record.minValue.map { "\($0)" } ?? "โ€”" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift index 8cc48e4cd02..af716cacf88 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift @@ -39,6 +39,12 @@ struct TransitionDetailView: View { @State private var selectedContractId: String = "" @State private var selectedDocumentType: String = "" @State private var documentFieldValues: [String: Any] = [:] + /// Why the document fields could not be encoded (a typed array refused by + /// its client-side check, a malformed object field, a non-finite number). + /// While set, `formInputs["documentFields"]` is cleared, so the submit stays + /// disabled and cannot send stale fields, and the reason is shown below the + /// fields. + @State private var documentFieldsError: String? /// Guards the one-time form setup in `.onAppear`. Contract / document-type /// selection now pushes a child list onto the navigation stack; popping it /// re-fires this view's `.onAppear`, and re-running `clearForm()` there @@ -303,15 +309,34 @@ struct TransitionDetailView: View { get: { documentFieldValues }, set: { newValues in documentFieldValues = newValues - // Convert to JSON string for the form - if let jsonData = try? JSONSerialization.data(withJSONObject: newValues, options: [.prettyPrinted]), - let jsonString = String(data: jsonData, encoding: .utf8) { - formInputs["documentFields"] = jsonString + // The Create Document screen's encoding: `Data` as hex (which + // the Rust sanitizer decodes), object text as objects, and a + // refusal for a typed array that failed its check or a value + // JSON cannot carry. `JSONSerialization.data` raises an + // Objective-C exception on such a value, which `try?` cannot + // catch. + do { + formInputs["documentFields"] = try CreateDocumentView.propertiesJSON( + from: newValues, documentType: documentType) + documentFieldsError = nil + } catch { + formInputs["documentFields"] = nil + documentFieldsError = error.localizedDescription } } ), immutability: immutability ) + // A new editor per document type, so switching type re-encodes the + // fields rather than keeping the previous type's + .id(documentType.id) + + if let documentFieldsError { + Text("Could not encode document fields: \(documentFieldsError)") + .font(.caption) + .foregroundColor(.red) + .accessibilityIdentifier("transition.documentFields.error") + } } else { Text("Document type '\(documentTypeName)' not found in contract") .font(.caption) @@ -1079,6 +1104,10 @@ struct TransitionDetailView: View { throw SDKError.invalidParameter("Document type is required") } + if let documentFieldsError { + throw SDKError.invalidParameter("Could not encode document fields: \(documentFieldsError)") + } + guard let propertiesJson = formInputs["documentFields"], !propertiesJson.isEmpty else { throw SDKError.invalidParameter("Document properties are required") } @@ -1381,6 +1410,10 @@ struct TransitionDetailView: View { throw SDKError.invalidParameter("Document ID is required") } + if let documentFieldsError { + throw SDKError.invalidParameter("Could not encode document fields: \(documentFieldsError)") + } + guard let propertiesJson = formInputs["documentFields"], !propertiesJson.isEmpty else { throw SDKError.invalidParameter("Document properties are required") } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTypedArrayTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTypedArrayTests.swift index 9024761f7ea..a49f1a5f7ba 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTypedArrayTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTypedArrayTests.swift @@ -4,110 +4,361 @@ import XCTest @testable import SwiftDashSDK /// Protocol version 14 adds typed arrays to document schemas: a -/// `type: "array"` property declared by an `items` schema instead of -/// `byteArray: true`. The Swift SDK does not support them yet, and -/// `PersistentProperty` has no element type, so `DataContractParser` must -/// refuse such a contract with `ParseError.unsupportedTypedArray` instead of -/// persisting the property as a bare array. Byte arrays parse as before. +/// `type: "array"` property declared by an `items` schema (one scalar +/// element) instead of `byteArray: true`. `DataContractParser` persists one as +/// an ordinary `"array"` `PersistentProperty` row (`byteArray` false, element +/// counts in `minItems` / `maxItems`), and the element schema is read back off +/// the persisted document type schema by `PersistentDocumentType.typedArrays` +/// and `typedArray(named:)`, with no stored column of its own. +/// +/// These tests run the real parser over an in-memory store, so every +/// declaration below goes through the `JSONSerialization` round trip the +/// persisted `schemaJSON` takes: booleans and numbers come back as +/// `NSNumber`s, which the readers must not confuse. @MainActor final class DataContractParserTypedArrayTests: XCTestCase { private let contractId = Data(repeating: 0xC2, count: 32) - func testTypedArrayOfScalarsIsRefused() throws { - try assertRefused(property: "tags", schema: [ - "type": "array", - "items": ["type": "string", "maxLength": 32], - "maxItems": 8, - "position": 0 + private let identifierItems: [String: Any] = [ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + ] + + // MARK: - Persisted property rows + + func testTypedArrayPropertiesArePersistedAsArrayRowsWithoutThrowing() throws { + let (context, _) = try parse(properties: [ + "tags": [ + "type": "array", + "items": ["type": "string", "maxLength": 32], + "minItems": 1, + "maxItems": 8, + "position": 0 + ], + "reasons": [ + "type": "array", + "items": identifierItems, + "maxItems": 64, + "uniqueItems": true, + "position": 1 + ] ]) + + let properties = try fetchProperties(in: context) + let tags = try XCTUnwrap(properties.first { $0.name == "tags" }) + XCTAssertEqual(tags.type, "array") + XCTAssertFalse(tags.byteArray) + XCTAssertEqual(tags.minItems, 1) + XCTAssertEqual(tags.maxItems, 8) + // The element's own bounds stay on the element, not on the row + XCTAssertNil(tags.maxLength) + + // An identifier element does not make the property an identifier + let reasons = try XCTUnwrap(properties.first { $0.name == "reasons" }) + XCTAssertEqual(reasons.type, "array") + XCTAssertFalse(reasons.byteArray) + XCTAssertNil(reasons.minItems) + XCTAssertEqual(reasons.maxItems, 64) + XCTAssertNil(reasons.contentMediaType) } - /// An element that is itself a byte array (here an identifier) does not - /// make the property a byte array: the property declares `items`, and only - /// its elements declare `byteArray`. - func testTypedArrayOfIdentifiersIsRefused() throws { - try assertRefused(property: "reasons", schema: [ - "type": "array", - "items": [ + func testByteArrayPropertyStillParsesAndIsNotATypedArray() throws { + let (context, docType) = try parse(properties: [ + "owner": [ "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, - "contentMediaType": "application/x.dash.dpp.identifier" + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 ], - "maxItems": 64, - "uniqueItems": true, - "position": 0 + "payload": [ + "type": "array", + "byteArray": true, + "maxItems": 64, + "position": 1 + ] ]) + + let owner = try XCTUnwrap(try fetchProperties(in: context).first { $0.name == "owner" }) + XCTAssertEqual(owner.type, "array") + XCTAssertTrue(owner.byteArray) + XCTAssertEqual(owner.minItems, 32) + XCTAssertEqual(owner.maxItems, 32) + XCTAssertEqual(owner.contentMediaType, "application/x.dash.dpp.identifier") + + XCTAssertNil(docType.typedArray(named: "owner")) + XCTAssertNil(docType.typedArray(named: "payload")) + XCTAssertEqual(docType.typedArrays, []) } - func testByteArrayStillParses() throws { - let context = try makeContext() + // MARK: - Element kinds - try parse(properties: [ - "owner": [ + func testIntegerElementsReportTheirBoundsAndAllowedValues() throws { + let (_, docType) = try parse(properties: [ + "scores": [ "type": "array", - "byteArray": true, - "minItems": 32, - "maxItems": 32, - "contentMediaType": "application/x.dash.dpp.identifier", + "items": ["type": "integer", "minimum": 1, "maximum": 10, "enum": [1, 5, 10]], + "minItems": 1, + "maxItems": 5, "position": 0 ] - ], in: context) + ]) - let property = try XCTUnwrap( - try fetchProperties(in: context).first { $0.name == "owner" }, - "parser should have persisted the byte array property" + XCTAssertEqual( + docType.typedArray(named: "scores"), + DocumentTypedArray( + path: "scores", + element: .integer(minimum: 1, maximum: 10, allowedValues: [1, 5, 10]), + minItems: 1, + maxItems: 5, + uniqueItems: false + ) ) - XCTAssertEqual(property.type, "array") - XCTAssertTrue(property.byteArray) - XCTAssertEqual(property.minItems, 32) - XCTAssertEqual(property.maxItems, 32) - XCTAssertEqual(property.contentMediaType, "application/x.dash.dpp.identifier") } - // MARK: - Helpers + func testIntegerElementsWithoutBoundsReportNone() throws { + let (_, docType) = try parse(properties: [ + "counts": [ + "type": "array", + "items": ["type": "integer"], + "maxItems": 4, + "position": 0 + ] + ]) - private func assertRefused( - property name: String, - schema: [String: Any], - file: StaticString = #filePath, - line: UInt = #line - ) throws { - let context = try makeContext() - - XCTAssertThrowsError( - try parse(properties: [name: schema], in: context), - file: file, - line: line - ) { error in - XCTAssertEqual( - error as? DataContractParser.ParseError, - .unsupportedTypedArray(documentType: "post", property: name), - file: file, - line: line + XCTAssertEqual( + docType.typedArray(named: "counts")?.element, + .integer(minimum: nil, maximum: nil, allowedValues: nil) + ) + } + + func testNumberElementsReportTheirBoundsAndAllowedValues() throws { + let (_, docType) = try parse(properties: [ + "weights": [ + "type": "array", + "items": ["type": "number", "minimum": -1.5, "maximum": 2, "enum": [-1.5, 0, 2]], + "maxItems": 3, + "uniqueItems": true, + "position": 0 + ] + ]) + + XCTAssertEqual( + docType.typedArray(named: "weights"), + DocumentTypedArray( + path: "weights", + element: .number(minimum: -1.5, maximum: 2, allowedValues: [-1.5, 0, 2]), + minItems: nil, + maxItems: 3, + uniqueItems: true ) - let message = error.localizedDescription - XCTAssertTrue(message.contains("typed arrays"), message, file: file, line: line) - XCTAssertTrue(message.contains("document type post"), message, file: file, line: line) - XCTAssertTrue(message.contains("property \(name)"), message, file: file, line: line) - } + ) + } + + /// `JSONSerialization` hands a JSON boolean back as an `NSNumber`, which + /// casts to an integer: the element must still read as booleans. + func testBooleanElementsReportTheirAllowedValues() throws { + let (_, docType) = try parse(properties: [ + "flags": [ + "type": "array", + "items": ["type": "boolean"], + "maxItems": 2, + "position": 0 + ], + "confirmations": [ + "type": "array", + "items": ["type": "boolean", "enum": [true]], + "maxItems": 2, + "position": 1 + ] + ]) + + XCTAssertEqual(docType.typedArray(named: "flags")?.element, .boolean(allowedValues: nil)) + XCTAssertEqual( + docType.typedArray(named: "confirmations")?.element, .boolean(allowedValues: [true])) + } + + func testStringElementsReportTheirLengthsAndAllowedValues() throws { + let (_, docType) = try parse(properties: [ + "moods": [ + "type": "array", + "items": [ + "type": "string", + "minLength": 2, + "maxLength": 5, + "enum": ["happy", "sad", "ok"] + ], + "minItems": 0, + "maxItems": 3, + "uniqueItems": false, + "position": 0 + ] + ]) + + XCTAssertEqual( + docType.typedArray(named: "moods"), + DocumentTypedArray( + path: "moods", + element: .string(minLength: 2, maxLength: 5, allowedValues: ["happy", "sad", "ok"]), + minItems: 0, + maxItems: 3, + uniqueItems: false + ) + ) + } + + /// An element's `minItems` / `maxItems` count its bytes; the array's own + /// count its elements. + func testByteArrayElementsReportTheirSize() throws { + let (_, docType) = try parse(properties: [ + "hashes": [ + "type": "array", + "items": ["type": "array", "byteArray": true, "minItems": 20, "maxItems": 32], + "minItems": 2, + "maxItems": 16, + "position": 0 + ] + ]) + + XCTAssertEqual( + docType.typedArray(named: "hashes"), + DocumentTypedArray( + path: "hashes", + element: .byteArray(minSize: 20, maxSize: 32), + minItems: 2, + maxItems: 16, + uniqueItems: false + ) + ) + } + + /// `distinctFrom` (protocol version 14) may ride on identifier elements; + /// it changes nothing about what an element is. + func testIdentifierElementsReportAsIdentifiers() throws { + var items = identifierItems + items["distinctFrom"] = ["$ownerId"] + let (_, docType) = try parse(properties: [ + "reasons": [ + "type": "array", + "items": items, + "maxItems": 64, + "uniqueItems": true, + "position": 0 + ] + ]) + + XCTAssertEqual( + docType.typedArray(named: "reasons"), + DocumentTypedArray( + path: "reasons", + element: .identifier, + minItems: nil, + maxItems: 64, + uniqueItems: true + ) + ) + } + + // MARK: - Nesting and lookup + + func testTypedArraysAreListedByPathIncludingThoseNestedInObjects() throws { + let (_, docType) = try parse(properties: [ + "tags": [ + "type": "array", + "items": ["type": "string"], + "maxItems": 8, + "position": 0 + ], + "team": [ + "type": "object", + "properties": [ + "leads": [ + "type": "array", + "items": identifierItems, + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "position": 0 + ], + "name": ["type": "string", "maxLength": 63, "position": 1] + ], + "additionalProperties": false, + "position": 1 + ], + "title": ["type": "string", "maxLength": 63, "position": 2] + ]) - // Refused, not mis-parsed: no row stands in for the typed array - XCTAssertFalse( - try fetchProperties(in: context).contains { $0.name == name }, - "a typed array must not be persisted as a property", - file: file, - line: line + XCTAssertEqual(docType.typedArrays.map(\.path), ["tags", "team.leads"]) + XCTAssertEqual( + docType.typedArrays.last, + DocumentTypedArray( + path: "team.leads", + element: .identifier, + minItems: 1, + maxItems: 3, + uniqueItems: true + ) ) + + // The named lookup reads top-level properties only + XCTAssertNotNil(docType.typedArray(named: "tags")) + XCTAssertNil(docType.typedArray(named: "team")) + XCTAssertNil(docType.typedArray(named: "leads")) + XCTAssertNil(docType.typedArray(named: "team.leads")) + XCTAssertNil(docType.typedArray(named: "title")) + XCTAssertNil(docType.typedArray(named: "missing")) + } + + func testDocumentTypeWithoutTypedArraysReportsNone() throws { + let (_, docType) = try parse(properties: [ + "title": ["type": "string", "maxLength": 63, "position": 0] + ]) + + XCTAssertEqual(docType.typedArrays, []) + } + + // MARK: - Declarations DPP refuses + + /// DPP refuses each of these at registration, so they reach a client only + /// through hand-edited JSON. None may be mistaken for a typed array. + func testDeclarationsThatAreNotTypedArraysReadAsNone() { + let notTypedArrays: [String: [String: Any]] = [ + "byteArray false": [ + "type": "array", "byteArray": false, + "items": ["type": "string"], "maxItems": 4 + ], + "no items": ["type": "array", "maxItems": 4], + "tuple items": ["type": "array", "items": [["type": "string"]], "maxItems": 4], + "object items": ["type": "array", "items": ["type": "object"], "maxItems": 4], + "array of arrays": [ + "type": "array", "items": ["type": "array", "items": ["type": "string"]], + "maxItems": 4 + ], + "no maxItems": ["type": "array", "items": ["type": "string"]], + "not an array": ["type": "string", "items": ["type": "string"], "maxItems": 4] + ] + + for (label, schema) in notTypedArrays { + XCTAssertNil(DocumentTypedArray(path: "p", propertySchema: schema), label) + } } - private func makeContext() throws -> ModelContext { + // MARK: - Helpers + + /// Run the real parser over a one-document-type contract and hand back + /// the context and the persisted document type row. `parseDocumentTypes` + /// needs the `PersistentDataContract` row to exist first. + private func parse( + properties: [String: Any] + ) throws -> (ModelContext, PersistentDocumentType) { let container = try DashModelContainer.createInMemory() let context = ModelContext(container) - // Document types hang off the contract row, so it must exist first let contract = PersistentDataContract( id: contractId, name: "Fixture", @@ -116,10 +367,7 @@ final class DataContractParserTypedArrayTests: XCTestCase { ) context.insert(contract) try context.save() - return context - } - private func parse(properties: [String: Any], in context: ModelContext) throws { try DataContractParser.parseDataContract( contractData: [ "documents": [ @@ -133,6 +381,16 @@ final class DataContractParserTypedArrayTests: XCTestCase { contractId: contractId, modelContext: context ) + + let id = contractId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.contractId == id } + ) + let docType = try XCTUnwrap( + try context.fetch(descriptor).first, + "parser should have persisted one document type" + ) + return (context, docType) } private func fetchProperties(in context: ModelContext) throws -> [PersistentProperty] { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentTypedArrayElementInputTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentTypedArrayElementInputTests.swift new file mode 100644 index 00000000000..bbb31f6662d --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentTypedArrayElementInputTests.swift @@ -0,0 +1,431 @@ +import Foundation +import XCTest + +@testable import SwiftDashSDK + +/// Coverage for `DocumentTypedArray.Element.value(fromInput:)`, which turns +/// the text a user entered for one typed array element into the JSON value +/// the platform wallet's schema sanitizer takes. +/// +/// The sanitizer narrows JSON numbers to the element's width, decodes base58 +/// identifiers and hex byte arrays, but never parses a number or a boolean out +/// of a string. So integers, numbers and booleans must leave as JSON numbers +/// and booleans, identifiers and byte arrays as strings (never `Data`, which +/// `JSONSerialization` cannot encode inside an array), and a string element +/// exactly as typed. The range, length and `enum` checks are a courtesy that +/// spares a transition consensus would refuse. +/// +/// `DocumentTypedArray.values(fromInputs:)` / `jsonArray(fromInputs:)` judge a +/// whole list the same way (count, every row, repeats under `uniqueItems`); +/// the example app refuses to encode a list that fails, so an invalid list is +/// never broadcast and paid for. +final class DocumentTypedArrayElementInputTests: XCTestCase { + + private typealias Element = DocumentTypedArray.Element + private typealias Value = DocumentTypedArray.ElementValue + private typealias ListError = DocumentTypedArray.InputError + + // MARK: - Integer + + func testIntegerInputBecomesAJSONInteger() { + let element = Element.integer(minimum: nil, maximum: nil, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "42"), .success(.integer(42))) + XCTAssertEqual(element.value(fromInput: " -7 "), .success(.integer(-7))) + } + + func testIntegerInputThatIsNotAWholeNumberIsRefused() { + let element = Element.integer(minimum: nil, maximum: nil, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "4.2"), .failure(.notAnInteger("4.2"))) + XCTAssertEqual(element.value(fromInput: "abc"), .failure(.notAnInteger("abc"))) + XCTAssertEqual(element.value(fromInput: " "), .failure(.empty)) + // Beyond Int64 is not a whole number Swift can send + XCTAssertEqual( + element.value(fromInput: "99999999999999999999"), + .failure(.notAnInteger("99999999999999999999"))) + } + + func testIntegerInputOutsideTheDeclaredRangeIsRefused() { + let element = Element.integer(minimum: 1, maximum: 10, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "1"), .success(.integer(1))) + XCTAssertEqual(element.value(fromInput: "10"), .success(.integer(10))) + XCTAssertEqual( + element.value(fromInput: "0"), + .failure(.outOfRange(value: "0", minimum: "1", maximum: "10"))) + XCTAssertEqual( + element.value(fromInput: "11"), + .failure(.outOfRange(value: "11", minimum: "1", maximum: "10"))) + } + + func testIntegerInputOutsideTheEnumIsRefusedNamingTheAllowedValues() { + let element = Element.integer(minimum: 1, maximum: 10, allowedValues: [1, 5, 10]) + + XCTAssertEqual(element.value(fromInput: "5"), .success(.integer(5))) + XCTAssertEqual( + element.value(fromInput: "4"), + .failure(.notAllowed(value: "4", allowed: ["1", "5", "10"]))) + // Failing both, the enum is what the user is told about + XCTAssertEqual( + element.value(fromInput: "11"), + .failure(.notAllowed(value: "11", allowed: ["1", "5", "10"]))) + } + + // MARK: - Number + + func testNumberInputBecomesAJSONNumber() { + let element = Element.number(minimum: nil, maximum: nil, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "2.5"), .success(.number(2.5))) + XCTAssertEqual(element.value(fromInput: "-3"), .success(.number(-3))) + XCTAssertEqual(element.value(fromInput: "1e3"), .success(.number(1000))) + } + + /// The decimal pad shows a comma as the decimal separator in some locales. + func testNumberInputReadsALoneCommaAsTheDecimalSeparator() { + let element = Element.number(minimum: nil, maximum: nil, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "2,5"), .success(.number(2.5))) + XCTAssertEqual(element.value(fromInput: "1,000,5"), .failure(.notANumber("1,000,5"))) + } + + /// JSON cannot carry NaN or infinity, and `JSONSerialization` raises on + /// them rather than throwing. + func testNumberInputThatIsNotAFiniteNumberIsRefused() { + let element = Element.number(minimum: nil, maximum: nil, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "nan"), .failure(.notANumber("nan"))) + XCTAssertEqual(element.value(fromInput: "inf"), .failure(.notANumber("inf"))) + XCTAssertEqual(element.value(fromInput: "two"), .failure(.notANumber("two"))) + XCTAssertEqual(element.value(fromInput: ""), .failure(.empty)) + } + + func testNumberInputOutsideTheDeclaredRangeOrEnumIsRefused() { + let bounded = Element.number(minimum: -1.5, maximum: 2, allowedValues: nil) + XCTAssertEqual(bounded.value(fromInput: "-1.5"), .success(.number(-1.5))) + XCTAssertEqual( + bounded.value(fromInput: "2.25"), + .failure(.outOfRange(value: "2.25", minimum: "-1.5", maximum: "2"))) + + // An integral input matches an integral member, as 1 equals 1.0 in JSON + let listed = Element.number(minimum: nil, maximum: nil, allowedValues: [1, 2.5]) + XCTAssertEqual(listed.value(fromInput: "1"), .success(.number(1))) + XCTAssertEqual( + listed.value(fromInput: "2"), + .failure(.notAllowed(value: "2", allowed: ["1", "2.5"]))) + } + + // MARK: - Boolean + + func testBooleanInputBecomesAJSONBoolean() { + let element = Element.boolean(allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "true"), .success(.boolean(true))) + XCTAssertEqual(element.value(fromInput: "False"), .success(.boolean(false))) + XCTAssertEqual(element.value(fromInput: "yes"), .failure(.notABoolean("yes"))) + XCTAssertEqual(element.value(fromInput: "1"), .failure(.notABoolean("1"))) + } + + func testBooleanInputOutsideTheEnumIsRefused() { + let element = Element.boolean(allowedValues: [true]) + + XCTAssertEqual(element.value(fromInput: "true"), .success(.boolean(true))) + XCTAssertEqual( + element.value(fromInput: "false"), + .failure(.notAllowed(value: "false", allowed: ["true"]))) + } + + // MARK: - String + + /// The comma-separated editor this replaces split "a, b" into two + /// elements; one element keeps its commas and spaces. + func testStringInputIsSentExactlyAsEntered() { + let element = Element.string(minLength: nil, maxLength: nil, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "a, b"), .success(.string("a, b"))) + XCTAssertEqual(element.value(fromInput: " padded "), .success(.string(" padded "))) + XCTAssertEqual(element.value(fromInput: ""), .success(.string(""))) + } + + func testStringInputOutsideTheDeclaredLengthIsRefused() { + let element = Element.string(minLength: 2, maxLength: 4, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "ab"), .success(.string("ab"))) + XCTAssertEqual( + element.value(fromInput: "a"), + .failure(.wrongLength(length: 1, minimum: 2, maximum: 4))) + XCTAssertEqual( + element.value(fromInput: "abcde"), + .failure(.wrongLength(length: 5, minimum: 2, maximum: 4))) + XCTAssertEqual( + element.value(fromInput: ""), + .failure(.wrongLength(length: 0, minimum: 2, maximum: 4))) + } + + /// JSON Schema counts characters as Unicode code points: an emoji built + /// from two scalars is two characters, not one grapheme. + func testStringLengthIsCountedInUnicodeScalars() { + let element = Element.string(minLength: nil, maxLength: 1, allowedValues: nil) + let flag = "\u{1F1FA}\u{1F1F8}" + + XCTAssertEqual(flag.count, 1) + XCTAssertEqual( + element.value(fromInput: flag), + .failure(.wrongLength(length: 2, minimum: nil, maximum: 1))) + } + + func testStringInputOutsideTheEnumIsRefused() { + let element = Element.string(minLength: nil, maxLength: nil, allowedValues: ["happy", "sad"]) + + XCTAssertEqual(element.value(fromInput: "sad"), .success(.string("sad"))) + XCTAssertEqual( + element.value(fromInput: "Sad"), + .failure(.notAllowed(value: "Sad", allowed: ["happy", "sad"]))) + } + + // MARK: - Identifier + + func testIdentifierInputIsSentAsBase58() { + let id = Data(repeating: 0x11, count: 32).toBase58String() + + XCTAssertEqual(Element.identifier.value(fromInput: " \(id) "), .success(.string(id))) + } + + func testIdentifierInputThatIsNotBase58IsRefused() { + // 0, O, I and l are not in the base58 alphabet + XCTAssertEqual( + Element.identifier.value(fromInput: "0OIl"), .failure(.invalidBase58("0OIl"))) + XCTAssertEqual(Element.identifier.value(fromInput: ""), .failure(.empty)) + } + + func testIdentifierInputThatIsNotThirtyTwoBytesIsRefused() { + let short = Data(repeating: 0x22, count: 20).toBase58String() + + XCTAssertEqual( + Element.identifier.value(fromInput: short), + .failure(.wrongByteCount(count: 20, minimum: 32, maximum: 32))) + } + + // MARK: - Byte array + + func testByteArrayInputIsSentAsLowercaseHex() { + let element = Element.byteArray(minSize: nil, maxSize: nil) + + XCTAssertEqual(element.value(fromInput: "DEADbeef"), .success(.string("deadbeef"))) + XCTAssertEqual(element.value(fromInput: "0x00ff"), .success(.string("00ff"))) + } + + /// `Data(hexString:)` would drop an odd final digit and accept a `+` + /// sign; the element check must not. + func testByteArrayInputThatIsNotHexIsRefused() { + let element = Element.byteArray(minSize: nil, maxSize: nil) + + XCTAssertEqual(element.value(fromInput: "abc"), .failure(.invalidHex("abc"))) + XCTAssertEqual(element.value(fromInput: "+f"), .failure(.invalidHex("+f"))) + XCTAssertEqual(element.value(fromInput: "zz"), .failure(.invalidHex("zz"))) + // Fullwidth digits are hex digits to `Character`, not to the sanitizer + XCTAssertEqual(element.value(fromInput: "\u{FF10}\u{FF11}"), .failure(.invalidHex("\u{FF10}\u{FF11}"))) + XCTAssertEqual(element.value(fromInput: "0x"), .failure(.empty)) + } + + func testByteArrayInputOutsideTheDeclaredSizeIsRefused() { + let element = Element.byteArray(minSize: 2, maxSize: 3) + + XCTAssertEqual(element.value(fromInput: "aabb"), .success(.string("aabb"))) + XCTAssertEqual( + element.value(fromInput: "aa"), + .failure(.wrongByteCount(count: 1, minimum: 2, maximum: 3))) + XCTAssertEqual( + element.value(fromInput: "aabbccdd"), + .failure(.wrongByteCount(count: 4, minimum: 2, maximum: 3))) + } + + // MARK: - Allowed inputs + + /// A picker offers `allowedInputs`, so every entry must read back as the + /// member it stands for. + func testEveryAllowedInputReadsBackAsItsMember() { + let elements: [(Element, [Value])] = [ + (.integer(minimum: nil, maximum: nil, allowedValues: [3, -1]), [.integer(3), .integer(-1)]), + (.number(minimum: nil, maximum: nil, allowedValues: [2, 0.5, 1e20]), + [.number(2), .number(0.5), .number(1e20)]), + (.boolean(allowedValues: [false, true]), [.boolean(false), .boolean(true)]), + (.string(minLength: nil, maxLength: nil, allowedValues: ["a, b", " x"]), + [.string("a, b"), .string(" x")]) + ] + + for (element, members) in elements { + let inputs = element.allowedInputs ?? [] + XCTAssertEqual(inputs.count, members.count, "\(element)") + XCTAssertEqual(inputs.map { element.value(fromInput: $0) }, members.map { .success($0) }) + } + XCTAssertEqual( + Element.number(minimum: nil, maximum: nil, allowedValues: [2, 0.5]).allowedInputs, + ["2", "0.5"]) + } + + func testElementsWithoutAnEnumOfferNoAllowedInputs() { + XCTAssertNil(Element.integer(minimum: 1, maximum: 2, allowedValues: nil).allowedInputs) + XCTAssertNil(Element.boolean(allowedValues: nil).allowedInputs) + XCTAssertNil(Element.byteArray(minSize: nil, maxSize: nil).allowedInputs) + XCTAssertNil(Element.identifier.allowedInputs) + } + + // MARK: - Whole list + + private func list( + _ element: Element, + minItems: Int? = nil, + maxItems: Int = 8, + uniqueItems: Bool = false + ) -> DocumentTypedArray { + DocumentTypedArray( + path: "scores", element: element, + minItems: minItems, maxItems: maxItems, uniqueItems: uniqueItems) + } + + private let integers = Element.integer(minimum: nil, maximum: nil, allowedValues: nil) + + func testListInputBecomesTypedValuesInRowOrder() throws { + let scores = list(integers, minItems: 1, maxItems: 3) + + XCTAssertEqual( + scores.values(fromInputs: ["3", " 1", "2"]), + .success([.integer(3), .integer(1), .integer(2)])) + + let array = try scores.jsonArray(fromInputs: ["3", "1", "2"]).get() + let data = try JSONSerialization.data(withJSONObject: ["scores": array]) + XCTAssertEqual(String(data: data, encoding: .utf8), #"{"scores":[3,1,2]}"#) + } + + func testEmptyListPassesWhenNoMinItemsIsDeclared() { + XCTAssertEqual(list(integers).values(fromInputs: []), .success([])) + XCTAssertEqual(list(integers, minItems: 0).values(fromInputs: []), .success([])) + } + + func testListWithFewerRowsThanMinItemsIsRefused() { + let scores = list(integers, minItems: 2, maxItems: 4) + + XCTAssertEqual( + scores.values(fromInputs: ["1"]), + .failure(.tooFewElements(path: "scores", count: 1, minimum: 2))) + XCTAssertEqual( + scores.values(fromInputs: []), + .failure(.tooFewElements(path: "scores", count: 0, minimum: 2))) + } + + func testListWithMoreRowsThanMaxItemsIsRefused() { + XCTAssertEqual( + list(integers, maxItems: 2).values(fromInputs: ["1", "2", "3"]), + .failure(.tooManyElements(path: "scores", count: 3, maximum: 2))) + } + + func testListReportsTheFirstBadRowByIndex() { + XCTAssertEqual( + list(integers).values(fromInputs: ["1", "x", "y"]), + .failure(.invalidElement(path: "scores", index: 1, reason: .notAnInteger("x")))) + + // Every element check applies to its row, the range included + let bounded = list(.integer(minimum: 0, maximum: 10, allowedValues: nil)) + XCTAssertEqual( + bounded.values(fromInputs: ["4", "11"]), + .failure(.invalidElement( + path: "scores", index: 1, + reason: .outOfRange(value: "11", minimum: "0", maximum: "10")))) + } + + /// Repeats are judged on the converted values, as consensus judges the + /// stored ones, not on the text typed. + func testRepeatedElementsAreRefusedUnderUniqueItems() { + XCTAssertEqual( + list(integers, uniqueItems: true).values(fromInputs: ["5", "7", " 5"]), + .failure(.repeatedElement(path: "scores", index: 2, firstIndex: 0))) + + let numbers = Element.number(minimum: nil, maximum: nil, allowedValues: nil) + XCTAssertEqual( + list(numbers, uniqueItems: true).values(fromInputs: ["1", "1.0"]), + .failure(.repeatedElement(path: "scores", index: 1, firstIndex: 0))) + + let bytes = Element.byteArray(minSize: nil, maxSize: nil) + XCTAssertEqual( + list(bytes, uniqueItems: true).values(fromInputs: ["AABB", "0xaabb"]), + .failure(.repeatedElement(path: "scores", index: 1, firstIndex: 0))) + + let id = Data(repeating: 0x11, count: 32).toBase58String() + XCTAssertEqual( + list(.identifier, uniqueItems: true).values(fromInputs: [id, " \(id) "]), + .failure(.repeatedElement(path: "scores", index: 1, firstIndex: 0))) + } + + func testRepeatedElementsPassWithoutUniqueItems() { + XCTAssertEqual( + list(integers).values(fromInputs: ["5", "5"]), + .success([.integer(5), .integer(5)])) + } + + /// The count is judged before the rows, and every row before repeats. + func testListChecksTheCountThenTheRowsThenRepeats() { + let scores = list(integers, minItems: 3, maxItems: 4, uniqueItems: true) + + XCTAssertEqual( + scores.values(fromInputs: ["x", "x"]), + .failure(.tooFewElements(path: "scores", count: 2, minimum: 3))) + XCTAssertEqual( + scores.values(fromInputs: ["1", "1", "x"]), + .failure(.invalidElement(path: "scores", index: 2, reason: .notAnInteger("x")))) + } + + func testJSONArrayRefusesWhatValuesRefuses() { + guard case let .failure(error) = list(integers, minItems: 1).jsonArray(fromInputs: []) else { + return XCTFail("an empty list below minItems must be refused") + } + XCTAssertEqual(error, .tooFewElements(path: "scores", count: 0, minimum: 1)) + } + + func testListErrorDescriptionsNameThePropertyAndTheRow() { + let cases: [(ListError, String)] = [ + (.tooFewElements(path: "scores", count: 1, minimum: 2), + "scores: 1 element; the list takes at least 2."), + (.tooManyElements(path: "scores", count: 3, maximum: 2), + "scores: 3 elements; the list takes at most 2."), + (.invalidElement(path: "scores", index: 1, reason: .notAnInteger("x")), + "scores[1]: \"x\" is not a whole number."), + (.repeatedElement(path: "team.leads", index: 2, firstIndex: 0), + "team.leads[2]: repeats team.leads[0], and the elements must be unique.") + ] + + for (error, message) in cases { + XCTAssertEqual(error.localizedDescription, message) + } + } + + // MARK: - JSON form + + func testElementValuesEncodeAsTypedJSONInsideAnArray() throws { + let values: [Value] = [.integer(5), .number(2.5), .boolean(true), .string("a, b")] + let array = values.map(\.jsonValue) + + XCTAssertTrue(JSONSerialization.isValidJSONObject(["list": array])) + let data = try JSONSerialization.data(withJSONObject: ["list": array], options: [.sortedKeys]) + XCTAssertEqual(String(data: data, encoding: .utf8), #"{"list":[5,2.5,true,"a, b"]}"#) + } + + func testErrorDescriptionsNameTheProblem() { + typealias InputError = DocumentTypedArray.ElementInputError + let cases: [(InputError, String)] = [ + (.empty, "Enter a value"), + (.notAnInteger("x"), "\"x\" is not a whole number"), + (.outOfRange(value: "11", minimum: "1", maximum: "10"), "11 is outside the allowed range (1 to 10)"), + (.outOfRange(value: "-1", minimum: "0", maximum: nil), "(at least 0)"), + (.notAllowed(value: "4", allowed: ["1", "5"]), "allowed values: 1, 5"), + (.wrongLength(length: 5, minimum: nil, maximum: 4), "5 characters; the element takes at most 4"), + (.wrongByteCount(count: 20, minimum: 32, maximum: 32), "20 bytes; the element takes exactly 32") + ] + + for (error, fragment) in cases { + let message = error.localizedDescription + XCTAssertTrue(message.contains(fragment), "\(error): \(message)") + } + } +} From 73d35439af73dad240226dea1245d54335ae8c56 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 07:02:34 +0700 Subject: [PATCH 2/2] feat(kotlin-example-app): typed arrays in the document create and replace forms (PV14) The create and replace forms sent every element of a non-byte array as a string split on commas, which consensus refuses for integer, number and boolean elements and which breaks strings containing a comma. A typed array now gets one row per element with an input suited to its kind, and is sent as a JSON array of that kind; an invalid element, count or repeat is refused on the form. The replace form seeds the rows from the stored document, showing byte array elements as hex because the Rust sanitizer tries hex before base64. Co-Authored-By: Claude Opus 5.5 --- .../ui/contracts/CreateDocumentScreen.kt | 174 +++++++++- .../ui/contracts/DocumentActionsScreen.kt | 37 ++- .../ui/contracts/DocumentTypeDetailsScreen.kt | 6 +- .../example/ui/contracts/TypedArrays.kt | 311 ++++++++++++++++++ .../example/ui/contracts/TypedArraysTest.kt | 307 +++++++++++++++++ 5 files changed, 825 insertions(+), 10 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/TypedArrays.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/TypedArraysTest.kt diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/CreateDocumentScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/CreateDocumentScreen.kt index ea9879eedec..137a699e8c7 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/CreateDocumentScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/CreateDocumentScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -20,6 +21,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -30,6 +32,7 @@ import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.KeyboardType @@ -76,6 +79,10 @@ import org.dashfoundation.example.util.truncateMiddle * supplies the wallet handle, owner id, and the wallet's shared signer * handle (like `DocumentWithPriceScreen`). * + * A typed array (protocol version 14: an array declared by an `items` + * schema) gets one row per element, sent as a JSON array of the element's + * own kind; see [TypedArrayEditor]. + * * Byte-array fields are entered as hex and identifier fields as base58; * both ride to Rust as strings, where the schema-driven sanitize step * decodes them to native bytes. The confirmed canonical JSON the FFI @@ -117,6 +124,8 @@ fun CreateDocumentScreen( val textValues = remember { mutableStateMapOf() } val boolValues = remember { mutableStateMapOf() } val touchedBools = remember { mutableStateSetOf() } + // Typed array rows keyed by property name, one form string per element. + val listValues = remember { mutableStateMapOf>() } var isSubmitting by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } @@ -232,6 +241,7 @@ fun CreateDocumentScreen( textValues = textValues, boolValues = boolValues, touchedBools = touchedBools, + listValues = listValues, ) } if (required.isNotEmpty()) { @@ -255,7 +265,7 @@ fun CreateDocumentScreen( val ownerId = owner ?: return@SubmitButton val mgr = manager ?: return@SubmitButton val propertiesJson = try { - buildPropertiesJson(properties, required, textValues, boolValues, touchedBools) + buildPropertiesJson(properties, required, textValues, boolValues, touchedBools, listValues) } catch (e: Exception) { error = "Could not encode document fields: ${e.message}" return@SubmitButton @@ -311,9 +321,11 @@ internal fun DocumentPropertyField( textValues: MutableMap, boolValues: MutableMap, touchedBools: MutableSet, + listValues: MutableMap>, tagPrefix: String = "createDocument.field", ) { val type = prop.stringField("type") + val typedArray = remember(name, prop) { documentTypedArray(name, prop) } val isByteArray = prop.boolField("byteArray") == true val isIdentifier = prop.stringField("contentMediaType")?.contains("identifier") == true val tag = "$tagPrefix.$name" @@ -326,6 +338,14 @@ internal fun DocumentPropertyField( } } when { + typedArray != null -> TypedArrayEditor( + typedArray = typedArray, + rows = listValues[name].orEmpty(), + onRowsChange = { listValues[name] = it }, + enabled = enabled, + tag = tag, + ) + type == "boolean" -> Switch( checked = boolValues[name] ?: false, onCheckedChange = { @@ -386,6 +406,141 @@ internal fun DocumentPropertyField( } } +/** + * The editor for one typed array: a row per element with an input suited to + * the element kind (a picker when the items declare an `enum`, a switch for + * booleans, a number field for integers and numbers, base58 text for + * identifiers, hex text for byte arrays, plain text for strings), a remove + * button per row, and an add button that stops at `maxItems`. Each row shows + * why its text would be refused, using [typedArrayElement]. Rows carry the + * testTags `.` and `..remove`; the add button is + * `.add`. + */ +@Composable +private fun TypedArrayEditor( + typedArray: DocumentTypedArray, + rows: List, + onRowsChange: (List) -> Unit, + enabled: Boolean, + tag: String, +) { + val item = typedArray.items + val options = typedArrayOptions(item) + Column(modifier = Modifier.fillMaxWidth().testTag(tag)) { + rows.forEachIndexed { index, raw -> + val update = { value: String -> + onRowsChange(rows.toMutableList().also { it[index] = value }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + when { + options != null -> AccessiblePicker( + label = "Item ${index + 1}", + options = options, + selected = raw.takeIf { it in options } ?: options.first(), + optionLabel = { it }, + testTag = "$tag.$index", + enabled = enabled, + onSelected = update, + ) + + item is TypedArrayItem.BooleanItem -> Switch( + checked = raw == "true", + onCheckedChange = { update(it.toString()) }, + enabled = enabled, + modifier = Modifier.testTag("$tag.$index"), + ) + + else -> OutlinedTextField( + value = raw, + onValueChange = update, + label = { Text(typedArrayRowHint(item, index)) }, + singleLine = true, + enabled = enabled, + keyboardOptions = when (item) { + is TypedArrayItem.IntegerItem, is TypedArrayItem.NumberItem -> + KeyboardOptions(keyboardType = KeyboardType.Number) + else -> KeyboardOptions.Default + }, + modifier = Modifier.fillMaxWidth().testTag("$tag.$index"), + ) + } + val problem = typedArrayElement(item, raw) as? TypedArrayElement.Invalid + if (problem != null) { + Text( + problem.reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + IconButton( + onClick = { onRowsChange(rows.toMutableList().also { it.removeAt(index) }) }, + enabled = enabled, + modifier = Modifier.testTag("$tag.$index.remove"), + ) { + Icon(Icons.Filled.Close, contentDescription = "Remove item ${index + 1}") + } + } + } + val maxItems = typedArray.maxItems + TextButton( + onClick = { onRowsChange(rows + typedArrayNewRow(item)) }, + enabled = enabled && (maxItems == null || rows.size < maxItems), + modifier = Modifier.testTag("$tag.add"), + ) { + Text("Add item") + } + Text( + "${typedArray.summary} ยท ${rows.size} entered", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** The `enum` of an element kind as form strings, or null when none is declared. */ +internal fun typedArrayOptions(item: TypedArrayItem): List? = when (item) { + is TypedArrayItem.IntegerItem -> item.allowedValues?.map { it.toString() } + is TypedArrayItem.NumberItem -> item.allowedValues?.map { it.toString() } + is TypedArrayItem.BooleanItem -> item.allowedValues?.map { it.toString() } + is TypedArrayItem.StringItem -> item.allowedValues + is TypedArrayItem.ByteArrayItem, TypedArrayItem.IdentifierItem -> null +}?.takeIf { it.isNotEmpty() } + +/** The starting text of a newly added row: the first enum value, or `false`, or blank. */ +internal fun typedArrayNewRow(item: TypedArrayItem): String = + typedArrayOptions(item)?.first() + ?: if (item is TypedArrayItem.BooleanItem) "false" else "" + +private fun typedArrayRowHint(item: TypedArrayItem, index: Int): String { + val what = when (item) { + is TypedArrayItem.IntegerItem -> rangeHint("Integer", item.minimum?.toString(), item.maximum?.toString()) + is TypedArrayItem.NumberItem -> rangeHint("Number", item.minimum?.toString(), item.maximum?.toString()) + is TypedArrayItem.StringItem -> when { + item.minLength != null && item.maxLength != null -> "Text (${item.minLength} to ${item.maxLength} chars)" + item.maxLength != null -> "Text (max ${item.maxLength} chars)" + item.minLength != null -> "Text (min ${item.minLength} chars)" + else -> "Text" + } + is TypedArrayItem.ByteArrayItem -> when { + item.minSize != null && item.minSize == item.maxSize -> "Hex bytes (${item.minSize} bytes)" + item.maxSize != null -> "Hex bytes (max ${item.maxSize} bytes)" + else -> "Hex bytes" + } + TypedArrayItem.IdentifierItem -> "Base58 identifier" + is TypedArrayItem.BooleanItem -> "Boolean" + } + return "Item ${index + 1}: $what" +} + +private fun rangeHint(kind: String, min: String?, max: String?): String = when { + min != null && max != null -> "$kind ($min to $max)" + max != null -> "$kind (max $max)" + min != null -> "$kind (min $min)" + else -> kind +} + internal fun stringHint(prop: JsonObject): String { val min = prop.intField("minLength") val max = prop.intField("maxLength") @@ -415,7 +570,9 @@ internal fun numericHint(prop: JsonObject): String { * `false` for some schemas). Byte-array (hex) and identifier (base58) * fields pass through as strings โ€” the schema-driven sanitize decodes * them Rust-side. `object` fields are parsed so they serialize as nested - * objects rather than a JSON string. Throws on invalid `object` JSON. + * objects rather than a JSON string. A typed array is built from + * [listValues] by [typedArrayJson]. Throws on invalid `object` JSON and on a + * typed array element, count or repeat consensus would refuse. */ internal fun buildPropertiesJson( properties: JsonObject, @@ -423,6 +580,7 @@ internal fun buildPropertiesJson( textValues: Map, boolValues: Map, touchedBools: Set, + listValues: Map> = emptyMap(), ): String = buildJsonObject { for ((name, propEl) in properties) { val prop = propEl as? JsonObject ?: continue @@ -446,8 +604,18 @@ internal fun buildPropertiesJson( } "array" -> { + val typedArray = documentTypedArray(name, prop) val raw = textValues[name]?.trim().orEmpty() - if (raw.isNotEmpty()) { + if (typedArray != null) { + // Typed array: one JSON value per row, of the element's + // own kind. An optional one with no rows is omitted; a + // required one is sent even when empty, so minItems is + // judged here rather than by a paid rejection. + val rows = listValues[name].orEmpty() + if (rows.isNotEmpty() || name in required) { + put(name, typedArrayJson(name, typedArray, rows)) + } + } else if (raw.isNotEmpty()) { if (prop.boolField("byteArray") == true) { // Hex (byte array) or base58 (identifier) โ€” string. put(name, raw) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentActionsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentActionsScreen.kt index 1629dd366a5..89b30ee1db1 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentActionsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentActionsScreen.kt @@ -112,6 +112,10 @@ fun DocumentActionsScreen( // non-blank and is blank at submit is an explicit REMOVE; a field that // was never seeded and stays blank is ABSENT (preserved by the merge). val seededTexts = remember { mutableStateMapOf() } + // Typed array rows, and what the prefill seeded for each: a list seeded + // with elements and emptied at submit is an explicit REMOVE, like a text. + val listValues = remember { mutableStateMapOf>() } + val seededLists = remember { mutableStateMapOf>() } var seededForId by remember { mutableStateOf(null) } var recipient by remember { mutableStateOf(null) } @@ -195,9 +199,12 @@ fun DocumentActionsScreen( textValues.clear() boolValues.clear() touchedBools.clear() - seedReplaceFields(doc.fields, properties, textValues, boolValues, touchedBools) + listValues.clear() + seedReplaceFields(doc.fields, properties, textValues, boolValues, touchedBools, listValues) seededTexts.clear() seededTexts.putAll(textValues) + seededLists.clear() + seededLists.putAll(listValues) seededForId = trimmed } @@ -334,6 +341,7 @@ fun DocumentActionsScreen( textValues = textValues, boolValues = boolValues, touchedBools = touchedBools, + listValues = listValues, tagPrefix = "replaceDocument.field", ) when (lock) { @@ -384,7 +392,8 @@ fun DocumentActionsScreen( val propertiesJson = try { // Rust-side replace is a FULL overwrite // (`set_properties`), and the form can neither - // render composite (object/array) values nor + // render composite values (objects, and arrays + // other than typed arrays) nor // distinguish "left blank" from "clear": overlay // the form's values on the document's current // fields so everything not re-entered keeps its @@ -394,7 +403,14 @@ fun DocumentActionsScreen( val clearedKeys = seededTexts.keys.filter { key -> seededTexts[key].orEmpty().isNotBlank() && textValues[key].orEmpty().isBlank() - }.toSet() + }.toSet() + seededLists.keys.filter { key -> + // A required typed array is sent even when + // emptied (minItems is judged on the form), + // so only an optional one counts as removed. + key !in required && + seededLists[key].orEmpty().isNotEmpty() && + listValues[key].orEmpty().isEmpty() + } // Drive validates required properties AFTER // broadcast and treats their absence as a // paid-invalid transition โ€” reject here so the @@ -409,6 +425,7 @@ fun DocumentActionsScreen( probed?.fields, buildPropertiesJson( properties, required, textValues, boolValues, touchedBools, + listValues, ), clearedKeys = clearedKeys, ) @@ -644,9 +661,11 @@ private fun mergeReplaceProperties( /** * Prefill the replace form's field state from the document's current scalar - * values, so a replace starts from the existing content. Only JSON-primitive - * values are seeded (objects / arrays are left blank for the user to re-enter, - * since their canonical encoding may not round-trip through the string form). + * values, so a replace starts from the existing content. JSON-primitive + * values are seeded, and typed arrays get one row per stored element (see + * [typedArraySeedRows]). Objects and other arrays are left blank for the user + * to re-enter, since their canonical encoding may not round-trip through the + * string form. */ private fun seedReplaceFields( fields: JsonObject, @@ -654,9 +673,15 @@ private fun seedReplaceFields( textValues: MutableMap, boolValues: MutableMap, touchedBools: MutableSet, + listValues: MutableMap>, ) { for ((name, propEl) in properties) { val prop = propEl as? JsonObject ?: continue + val typedArray = documentTypedArray(name, prop) + if (typedArray != null) { + typedArraySeedRows(typedArray.items, fields[name])?.let { listValues[name] = it } + continue + } val value = fields[name] as? JsonPrimitive ?: continue if (prop.stringField("type") == "boolean") { value.content.toBooleanStrictOrNull()?.let { diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt index fa347f08927..77dde84388f 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt @@ -344,6 +344,7 @@ private fun PropertyRow(name: String, property: JsonObject, isRequired: Boolean) property.intField("maxLength")?.let { add("Max: $it") } if (property.stringField("pattern") != null) add("Pattern") if (property.boolField("byteArray") == true) add("Byte Array") + documentTypedArray(name, property)?.let { add(it.summary) } property.stringField("contentMediaType") ?.substringAfterLast('.') ?.let { add(it) } @@ -365,7 +366,10 @@ private fun PropertyRow(name: String, property: JsonObject, isRequired: Boolean) } property.objectField("properties")?.let { subProperties -> subProperties.keys.sorted().forEach { subName -> - val subType = (subProperties[subName] as? JsonObject)?.stringField("type") + val subProperty = subProperties[subName] as? JsonObject + // A nested typed array reads as its element kind and bounds. + val subType = subProperty?.let { documentTypedArray(subName, it)?.summary } + ?: subProperty?.stringField("type") Text( "โ†’ $subName${subType?.let { " ($it)" } ?: ""}", style = MaterialTheme.typography.bodySmall, diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/TypedArrays.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/TypedArrays.kt new file mode 100644 index 00000000000..b5aa5c8060c --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/TypedArrays.kt @@ -0,0 +1,311 @@ +package org.dashfoundation.example.ui.contracts + +import java.math.BigInteger +import java.util.Base64 +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.toHex + +/** + * What every element of a typed array is (protocol version 14). Mirrors the + * `DocumentTypedArrayItem` union wasm-dpp2 exposes, read off the property's + * `items` schema. + */ +internal sealed interface TypedArrayItem { + /** Short name for captions: the schema keyword, or byteArray / identifier. */ + val label: String + + data class IntegerItem( + val minimum: BigInteger? = null, + val maximum: BigInteger? = null, + val allowedValues: List? = null, + ) : TypedArrayItem { + override val label: String get() = "integer" + } + + data class NumberItem( + val minimum: Double? = null, + val maximum: Double? = null, + val allowedValues: List? = null, + ) : TypedArrayItem { + override val label: String get() = "number" + } + + data class BooleanItem(val allowedValues: List? = null) : TypedArrayItem { + override val label: String get() = "boolean" + } + + /** + * A string element. `pattern` is kept for display only: the form does not + * check it, since JSON Schema and Java regular expressions differ, and + * consensus judges it anyway. + */ + data class StringItem( + val minLength: Int? = null, + val maxLength: Int? = null, + val pattern: String? = null, + val allowedValues: List? = null, + ) : TypedArrayItem { + override val label: String get() = "string" + } + + data class ByteArrayItem(val minSize: Int? = null, val maxSize: Int? = null) : TypedArrayItem { + override val label: String get() = "byteArray" + } + + data object IdentifierItem : TypedArrayItem { + override val label: String get() = "identifier" + } +} + +/** + * One typed array property of a document type: a `type: "array"` property + * declared by an `items` schema rather than `byteArray: true`. [path] is the + * dotted path within the document type, for example `reasons`, or + * `team.leads` for one nested in an object property. + */ +internal data class DocumentTypedArray( + val path: String, + val items: TypedArrayItem, + val minItems: Int?, + val maxItems: Int?, + val uniqueItems: Boolean, +) { + /** One-line summary for captions and the type details screen. */ + val summary: String + get() = buildList { + add("List of ${items.label}") + when { + minItems != null && maxItems != null -> add("$minItems to $maxItems items") + maxItems != null -> add("up to $maxItems items") + minItems != null -> add("at least $minItems items") + } + if (uniqueItems) add("unique") + }.joinToString(" ยท ") +} + +/** + * Read [property] as a typed array at [path], or null when it is not one: no + * `items` schema, `byteArray: true` on the property itself (a plain byte + * array), or an element schema this form does not recognise. + */ +internal fun documentTypedArray(path: String, property: JsonObject): DocumentTypedArray? { + if (property.stringField("type") != "array") return null + if (property.boolField("byteArray") == true) return null + val items = property.objectField("items") ?: return null + val item = typedArrayItem(items) ?: return null + return DocumentTypedArray( + path = path, + items = item, + minItems = property.intField("minItems"), + maxItems = property.intField("maxItems"), + uniqueItems = property.boolField("uniqueItems") == true, + ) +} + +/** + * Every typed array a document type [schema] declares, including those nested + * in object properties (dotted paths), sorted by path. Empty for a pre-v14 + * type and for one declaring none. + */ +internal fun documentTypedArrays(schema: JsonObject?): List { + val found = mutableListOf() + fun walk(properties: JsonObject?, prefix: String) { + properties ?: return + for ((name, element) in properties) { + val property = element as? JsonObject ?: continue + val path = if (prefix.isEmpty()) name else "$prefix.$name" + documentTypedArray(path, property)?.let { found += it } + if (property.stringField("type") == "object") { + walk(property.objectField("properties"), path) + } + } + } + walk(schema?.objectField("properties"), "") + return found.sortedBy { it.path } +} + +private fun typedArrayItem(items: JsonObject): TypedArrayItem? = when (items.stringField("type")) { + "integer" -> TypedArrayItem.IntegerItem( + minimum = items.bigIntegerField("minimum"), + maximum = items.bigIntegerField("maximum"), + allowedValues = items.arrayField("enum") + ?.mapNotNull { (it as? JsonPrimitive)?.takeUnless { p -> p.isString }?.content?.toBigIntegerOrNull() }, + ) + "number" -> TypedArrayItem.NumberItem( + minimum = (items["minimum"] as? JsonPrimitive)?.doubleOrNull, + maximum = (items["maximum"] as? JsonPrimitive)?.doubleOrNull, + allowedValues = items.arrayField("enum") + ?.mapNotNull { (it as? JsonPrimitive)?.takeUnless { p -> p.isString }?.doubleOrNull }, + ) + "boolean" -> TypedArrayItem.BooleanItem( + allowedValues = items.arrayField("enum") + ?.mapNotNull { (it as? JsonPrimitive)?.takeUnless { p -> p.isString }?.booleanOrNull }, + ) + "string" -> TypedArrayItem.StringItem( + minLength = items.intField("minLength"), + maxLength = items.intField("maxLength"), + pattern = items.stringField("pattern"), + allowedValues = items.arrayField("enum") + ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }, + ) + "array" -> when { + items.boolField("byteArray") != true -> null + items.stringField("contentMediaType")?.contains("identifier") == true -> + TypedArrayItem.IdentifierItem + else -> TypedArrayItem.ByteArrayItem( + minSize = items.intField("minItems"), + maxSize = items.intField("maxItems"), + ) + } + else -> null +} + +private fun JsonObject.bigIntegerField(key: String): BigInteger? = + (this[key] as? JsonPrimitive)?.takeUnless { it.isString }?.content?.toBigIntegerOrNull() + +/** The outcome of converting one element's form text into its JSON value. */ +internal sealed interface TypedArrayElement { + /** The JSON value to send for the element. */ + data class Valid(val json: JsonPrimitive) : TypedArrayElement + + /** Why the text is not an acceptable element, for the row's error line. */ + data class Invalid(val reason: String) : TypedArrayElement +} + +/** + * Convert the form text [raw] for one element of kind [item] into the JSON + * value the Rust sanitize step accepts: a JSON number for integer and number + * elements, a JSON boolean for boolean ones (sanitize does not turn the + * string "5" into an integer), the string for string elements, the base58 (or + * 64-character hex) string for an identifier, and a lowercase hex string for + * a byte array. The declared bounds and `enum` are checked as a courtesy, so + * the user does not pay for a transition consensus is sure to refuse; + * consensus stays the authority. + */ +internal fun typedArrayElement(item: TypedArrayItem, raw: String): TypedArrayElement { + val text = raw.trim() + return when (item) { + is TypedArrayItem.IntegerItem -> { + val value = text.toBigIntegerOrNull() + ?: return TypedArrayElement.Invalid("not a whole number") + item.minimum?.let { if (value < it) return TypedArrayElement.Invalid("below the minimum $it") } + item.maximum?.let { if (value > it) return TypedArrayElement.Invalid("above the maximum $it") } + if (item.allowedValues != null && value !in item.allowedValues) { + return TypedArrayElement.Invalid("not one of ${item.allowedValues.joinToString(", ")}") + } + TypedArrayElement.Valid(JsonPrimitive(value)) + } + + is TypedArrayItem.NumberItem -> { + val value = text.toDoubleOrNull()?.takeIf { it.isFinite() } + ?: return TypedArrayElement.Invalid("not a number") + item.minimum?.let { if (value < it) return TypedArrayElement.Invalid("below the minimum $it") } + item.maximum?.let { if (value > it) return TypedArrayElement.Invalid("above the maximum $it") } + if (item.allowedValues != null && value !in item.allowedValues) { + return TypedArrayElement.Invalid("not one of ${item.allowedValues.joinToString(", ")}") + } + TypedArrayElement.Valid(JsonPrimitive(value)) + } + + is TypedArrayItem.BooleanItem -> { + val value = text.toBooleanStrictOrNull() + ?: return TypedArrayElement.Invalid("not true or false") + if (item.allowedValues != null && value !in item.allowedValues) { + return TypedArrayElement.Invalid("not one of ${item.allowedValues.joinToString(", ")}") + } + TypedArrayElement.Valid(JsonPrimitive(value)) + } + + is TypedArrayItem.StringItem -> { + // Strings keep their spaces: only the other kinds are trimmed. + val length = raw.codePointCount(0, raw.length) + item.minLength?.let { + if (length < it) return TypedArrayElement.Invalid("shorter than $it characters") + } + item.maxLength?.let { + if (length > it) return TypedArrayElement.Invalid("longer than $it characters") + } + if (item.allowedValues != null && raw !in item.allowedValues) { + return TypedArrayElement.Invalid("not one of ${item.allowedValues.joinToString(", ")}") + } + TypedArrayElement.Valid(JsonPrimitive(raw)) + } + + TypedArrayItem.IdentifierItem -> { + if (Base58.decodeIdentifier(text) == null) { + TypedArrayElement.Invalid("not a base58 identifier") + } else { + TypedArrayElement.Valid(JsonPrimitive(text)) + } + } + + is TypedArrayItem.ByteArrayItem -> { + val hex = text.removePrefix("0x").lowercase() + if (hex.length % 2 != 0 || hex.any { it !in '0'..'9' && it !in 'a'..'f' }) { + return TypedArrayElement.Invalid("not hex bytes") + } + val size = hex.length / 2 + item.minSize?.let { if (size < it) return TypedArrayElement.Invalid("fewer than $it bytes") } + item.maxSize?.let { if (size > it) return TypedArrayElement.Invalid("more than $it bytes") } + TypedArrayElement.Valid(JsonPrimitive(hex)) + } + } +} + +/** + * Convert every row of a typed array into its JSON array, in row order. + * Throws [IllegalArgumentException] naming the first bad row (`name[index]`), + * or a count outside `minItems` / `maxItems`, or a repeated element when + * `uniqueItems` is set. The form surfaces the message instead of broadcasting + * a transition consensus would refuse, which would still be paid for. + */ +internal fun typedArrayJson(name: String, typedArray: DocumentTypedArray, rows: List): JsonArray { + typedArray.minItems?.let { + require(rows.size >= it) { "$name needs at least $it items, has ${rows.size}" } + } + typedArray.maxItems?.let { + require(rows.size <= it) { "$name allows at most $it items, has ${rows.size}" } + } + val elements = rows.mapIndexed { index, raw -> + when (val element = typedArrayElement(typedArray.items, raw)) { + is TypedArrayElement.Valid -> element.json + is TypedArrayElement.Invalid -> throw IllegalArgumentException("$name[$index]: ${element.reason}") + } + } + if (typedArray.uniqueItems) { + val repeated = elements.groupBy { it.content }.filterValues { it.size > 1 }.keys + require(repeated.isEmpty()) { "$name must not repeat an element: ${repeated.joinToString(", ")}" } + } + return JsonArray(elements) +} + +/** + * The form rows for a stored typed array [value] taken from a document's + * canonical JSON, or null when the value is not an array of scalars. Byte + * array elements arrive as base64 and are shown as hex: the Rust sanitize + * step tries hex before base64, so sending back base64 made only of hex + * digits would decode to different bytes. + */ +internal fun typedArraySeedRows(item: TypedArrayItem, value: JsonElement?): List? { + val array = value as? JsonArray ?: return null + return array.map { element -> + val primitive = element as? JsonPrimitive ?: return null + if (item is TypedArrayItem.ByteArrayItem && primitive.isString) { + base64ToHex(primitive.content) ?: primitive.content + } else { + primitive.content + } + } +} + +private fun base64ToHex(text: String): String? = try { + Base64.getDecoder().decode(text).toHex() +} catch (_: IllegalArgumentException) { + null +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/TypedArraysTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/TypedArraysTest.kt new file mode 100644 index 00000000000..23c4b66140f --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/TypedArraysTest.kt @@ -0,0 +1,307 @@ +package org.dashfoundation.example.ui.contracts + +import java.math.BigInteger +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.LenientJson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** + * Pins the protocol-v14 typed array helpers behind the create and replace + * forms ([documentTypedArray], [typedArrayElement], [typedArrayJson], + * [typedArraySeedRows], [buildPropertiesJson]). The Rust sanitize step turns + * base58 and hex strings into identifiers and bytes inside typed array + * elements, but it does not turn the string "5" into an integer, so the form + * must send each element as a JSON value of its own kind. + */ +class TypedArraysTest { + + private val identifierItems = buildJsonObject { + put("type", "array") + put("byteArray", true) + put("minItems", 32) + put("maxItems", 32) + put("contentMediaType", "application/x.dash.dpp.identifier") + } + + /** A charter-like document type with one typed array of every element kind. */ + private val schema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("members") { + put("type", "array") + put("items", identifierItems) + put("maxItems", 16) + put("uniqueItems", true) + put("position", 0) + } + putJsonObject("scores") { + put("type", "array") + putJsonObject("items") { + put("type", "integer") + put("minimum", 0) + put("maximum", 100) + } + put("minItems", 1) + put("maxItems", 8) + put("position", 1) + } + putJsonObject("ratios") { + put("type", "array") + putJsonObject("items") { + put("type", "number") + put("minimum", 0) + put("maximum", 1) + } + put("maxItems", 4) + put("position", 2) + } + putJsonObject("flags") { + put("type", "array") + putJsonObject("items") { put("type", "boolean") } + put("maxItems", 4) + put("position", 3) + } + putJsonObject("tags") { + put("type", "array") + putJsonObject("items") { + put("type", "string") + putJsonArray("enum") { + add("spam") + add("abuse") + } + } + put("maxItems", 2) + put("position", 4) + } + putJsonObject("digests") { + put("type", "array") + putJsonObject("items") { + put("type", "array") + put("byteArray", true) + put("minItems", 4) + put("maxItems", 4) + } + put("maxItems", 4) + put("position", 5) + } + putJsonObject("avatar") { + put("type", "array") + put("byteArray", true) + put("maxItems", 64) + put("position", 6) + } + putJsonObject("team") { + put("type", "object") + putJsonObject("properties") { + putJsonObject("leads") { + put("type", "array") + put("items", identifierItems) + put("maxItems", 4) + put("position", 0) + } + } + put("position", 7) + } + } + } + + private val properties = schema["properties"]!!.jsonObject + + private fun typedArray(name: String): DocumentTypedArray = + documentTypedArray(name, properties[name]!!.jsonObject)!! + + private val identifierA = Base58.encode(ByteArray(32) { 1 }) + private val identifierB = Base58.encode(ByteArray(32) { 2 }) + + @Test + fun shouldReadEveryElementKindWithItsBounds() { + assertEquals( + DocumentTypedArray("members", TypedArrayItem.IdentifierItem, null, 16, true), + typedArray("members"), + ) + assertEquals( + DocumentTypedArray( + "scores", + TypedArrayItem.IntegerItem(BigInteger.ZERO, BigInteger.valueOf(100)), + 1, + 8, + false, + ), + typedArray("scores"), + ) + assertEquals(TypedArrayItem.NumberItem(0.0, 1.0), typedArray("ratios").items) + assertEquals(TypedArrayItem.BooleanItem(), typedArray("flags").items) + assertEquals( + TypedArrayItem.StringItem(allowedValues = listOf("spam", "abuse")), + typedArray("tags").items, + ) + assertEquals(TypedArrayItem.ByteArrayItem(4, 4), typedArray("digests").items) + } + + @Test + fun shouldNotReadAByteArrayPropertyOrAPlainArrayAsATypedArray() { + assertNull(documentTypedArray("avatar", properties["avatar"]!!.jsonObject)) + assertNull(documentTypedArray("plain", buildJsonObject { put("type", "array") })) + assertNull(documentTypedArray("text", buildJsonObject { put("type", "string") })) + } + + @Test + fun shouldListEveryTypedArrayIncludingNestedOnesByDottedPath() { + assertEquals( + listOf("digests", "flags", "members", "ratios", "scores", "tags", "team.leads"), + documentTypedArrays(schema).map { it.path }, + ) + assertTrue(documentTypedArrays(null).isEmpty()) + } + + @Test + fun shouldConvertEachElementKindToAJsonValueOfItsOwnKind() { + fun valid(item: TypedArrayItem, raw: String): JsonPrimitive = + (typedArrayElement(item, raw) as TypedArrayElement.Valid).json + + assertEquals(JsonPrimitive(BigInteger.valueOf(42)), valid(typedArray("scores").items, " 42 ")) + assertEquals(JsonPrimitive(0.5), valid(typedArray("ratios").items, "0.5")) + assertEquals(JsonPrimitive(true), valid(typedArray("flags").items, "true")) + assertEquals(JsonPrimitive("spam"), valid(typedArray("tags").items, "spam")) + assertEquals(JsonPrimitive(identifierA), valid(TypedArrayItem.IdentifierItem, identifierA)) + assertEquals(JsonPrimitive("deadbeef"), valid(typedArray("digests").items, "0xDEADBEEF")) + // Numbers and booleans go out unquoted: sanitize will not parse strings. + assertTrue(!valid(typedArray("scores").items, "7").isString) + assertTrue(!valid(typedArray("flags").items, "false").isString) + } + + @Test + fun shouldRefuseElementTextConsensusWouldRefuse() { + fun reason(item: TypedArrayItem, raw: String): String = + (typedArrayElement(item, raw) as TypedArrayElement.Invalid).reason + + val scores = typedArray("scores").items + assertEquals("not a whole number", reason(scores, "4.5")) + assertEquals("above the maximum 100", reason(scores, "101")) + assertEquals("below the minimum 0", reason(scores, "-1")) + assertEquals("above the maximum 1.0", reason(typedArray("ratios").items, "1.5")) + assertEquals("not true or false", reason(typedArray("flags").items, "yes")) + assertEquals("not one of spam, abuse", reason(typedArray("tags").items, "other")) + assertEquals("not a base58 identifier", reason(TypedArrayItem.IdentifierItem, "0OIl")) + assertEquals("not hex bytes", reason(typedArray("digests").items, "xyz")) + assertEquals("fewer than 4 bytes", reason(typedArray("digests").items, "dead")) + assertEquals( + "longer than 3 characters", + reason(TypedArrayItem.StringItem(maxLength = 3), "four"), + ) + } + + @Test + fun shouldBuildTheArrayAndRefuseCountsRepeatsAndBadRows() { + assertEquals( + buildJsonArray { + add(identifierA) + add(identifierB) + }, + typedArrayJson("members", typedArray("members"), listOf(identifierA, identifierB)), + ) + assertRefused("scores needs at least 1 items, has 0") { + typedArrayJson("scores", typedArray("scores"), emptyList()) + } + assertRefused("tags allows at most 2 items, has 3") { + typedArrayJson("tags", typedArray("tags"), listOf("spam", "spam", "abuse")) + } + assertRefused("members must not repeat an element: $identifierA") { + typedArrayJson("members", typedArray("members"), listOf(identifierA, identifierA)) + } + assertRefused("scores[1]: not a whole number") { + typedArrayJson("scores", typedArray("scores"), listOf("1", "x")) + } + } + + @Test + fun shouldSendTypedArraysFromTheListRowsAndOmitAnEmptyOptionalOne() { + val json = buildPropertiesJson( + properties = properties, + required = setOf("scores"), + textValues = emptyMap(), + boolValues = emptyMap(), + touchedBools = emptySet(), + listValues = mapOf( + "scores" to listOf("3", "99"), + "flags" to listOf("true", "false"), + "tags" to emptyList(), + ), + ) + val sent = LenientJson.parseToJsonElement(json).jsonObject + assertEquals( + buildJsonArray { + add(3) + add(99) + }, + sent["scores"], + ) + assertEquals( + buildJsonArray { + add(true) + add(false) + }, + sent["flags"], + ) + assertEquals(setOf("scores", "flags"), sent.keys) + } + + @Test + fun shouldSeedReplaceRowsFromTheStoredDocumentWithBytesAsHex() { + val stored = buildJsonArray { + add("3q2+7w==") + add("AAECAw==") + } + assertEquals( + listOf("deadbeef", "00010203"), + typedArraySeedRows(typedArray("digests").items, stored), + ) + assertEquals( + listOf("3", "99"), + typedArraySeedRows( + typedArray("scores").items, + buildJsonArray { + add(3) + add(99) + }, + ), + ) + assertNull(typedArraySeedRows(typedArray("scores").items, JsonPrimitive(3))) + assertNull( + typedArraySeedRows( + typedArray("scores").items, + JsonArray(listOf(buildJsonObject { put("a", 1) })), + ), + ) + } + + @Test + fun shouldStartNewRowsOnTheFirstEnumValueOrFalse() { + assertEquals("spam", typedArrayNewRow(typedArray("tags").items)) + assertEquals("false", typedArrayNewRow(typedArray("flags").items)) + assertEquals("", typedArrayNewRow(typedArray("scores").items)) + } + + private fun assertRefused(message: String, block: () -> Unit) { + try { + block() + fail("expected a refusal: $message") + } catch (e: IllegalArgumentException) { + assertEquals(message, e.message) + } + } +}