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) + } + } +} 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)") + } + } +}