From f78d7ee4a7cf6e4c04491fbd6dc240710c605b4d Mon Sep 17 00:00:00 2001 From: Prachi Gauriar Date: Tue, 10 Mar 2026 23:57:20 -0400 Subject: [PATCH] Add editor types for JSON, arrays, and case iterable strings and ints --- App/Sources/App/ContentViewModel.swift | 6 +- .../Core/CodableValueRepresentation.swift | 34 ++ .../Core/ConfigVariable.swift | 39 ++ .../Core/ConfigVariableContent.swift | 352 ++++++++++-- .../Core/ConfigVariableReader.swift | 3 +- .../DevConfiguration/Core/EditorControl.swift | 48 +- .../Core/RegisteredConfigVariable.swift | 14 +- .../ConfigVariableDetailView.swift | 102 +++- .../ConfigVariableDetailViewModel.swift | 42 +- .../ConfigVariableDetailViewModeling.swift | 16 +- .../VariableListItem.swift | 2 +- .../Editor/Data Models/EditorDocument.swift | 12 +- .../Extensions/ConfigContent+Additions.swift | 26 + .../String+NonEmptyTrimmedLines.swift | 17 + .../Resources/Localizable.xcstrings | 20 + .../Testing Support/MockCodableValue.swift | 10 + .../MockNonIterableIntEnum.swift | 11 + .../MockNonIterableStringEnum.swift | 11 + ...ndomValueGenerating+DevConfiguration.swift | 16 +- .../CodableValueRepresentationTests.swift | 81 +++ .../ConfigVariableContentEditorTests.swift | 531 +++++++++++++++--- ...gVariableReaderRawRepresentableTests.swift | 60 +- ...onfigVariableReaderRegistrationTests.swift | 95 +++- .../Unit Tests/Core/EditorControlTests.swift | 44 ++ .../Core/RegisteredConfigVariableTests.swift | 9 +- .../ConfigVariableDetailViewModelTests.swift | 200 +++++++ .../ConfigContent+AdditionsTests.swift | 49 ++ .../String+NonEmptyTrimmedLinesTests.swift | 42 ++ 28 files changed, 1681 insertions(+), 211 deletions(-) create mode 100644 Sources/DevConfiguration/Extensions/String+NonEmptyTrimmedLines.swift create mode 100644 Tests/DevConfigurationTests/Testing Support/MockCodableValue.swift create mode 100644 Tests/DevConfigurationTests/Testing Support/MockNonIterableIntEnum.swift create mode 100644 Tests/DevConfigurationTests/Testing Support/MockNonIterableStringEnum.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Core/CodableValueRepresentationTests.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Core/EditorControlTests.swift create mode 100644 Tests/DevConfigurationTests/Unit Tests/Extensions/String+NonEmptyTrimmedLinesTests.swift diff --git a/App/Sources/App/ContentViewModel.swift b/App/Sources/App/ContentViewModel.swift index 627508c..aa8136e 100644 --- a/App/Sources/App/ContentViewModel.swift +++ b/App/Sources/App/ContentViewModel.swift @@ -38,7 +38,7 @@ final class ContentViewModel { let jsonVariable = ConfigVariable( key: "complexConfig", defaultValue: ComplexConfiguration(field1: "a", field2: 1), - content: .json(representation: .data) + content: .json(representation: .string()) ).metadata(\.displayName, "Complex Config") let intBackedVariable = ConfigVariable(key: "favoriteCardSuit", defaultValue: CardSuit.spades, isSecret: true) @@ -96,7 +96,7 @@ struct ComplexConfiguration: Codable, Hashable, Sendable { } -enum Beatle: String, Codable, Hashable, Sendable { +enum Beatle: String, CaseIterable, Codable, Hashable, Sendable { case john = "John" case paul = "Paul" case george = "George" @@ -104,7 +104,7 @@ enum Beatle: String, Codable, Hashable, Sendable { } -enum CardSuit: Int, Codable, Hashable, Sendable { +enum CardSuit: Int, CaseIterable, Codable, Hashable, Sendable { case spades case hearts case clubs diff --git a/Sources/DevConfiguration/Core/CodableValueRepresentation.swift b/Sources/DevConfiguration/Core/CodableValueRepresentation.swift index 922a6b5..3071f4c 100644 --- a/Sources/DevConfiguration/Core/CodableValueRepresentation.swift +++ b/Sources/DevConfiguration/Core/CodableValueRepresentation.swift @@ -47,6 +47,16 @@ public struct CodableValueRepresentation: Sendable { CodableValueRepresentation(kind: .data) } + /// Whether this representation supports text-based editing in the editor UI. + /// + /// String-backed representations can be edited as text, while data-backed representations cannot. + var supportsTextEditing: Bool { + switch kind { + case .string: true + case .data: false + } + } + /// Reads raw data synchronously from the reader based on this representation. /// @@ -129,6 +139,30 @@ public struct CodableValueRepresentation: Sendable { } + /// Extracts raw `Data` from a ``ConfigContent`` based on this representation. + /// + /// This is the reverse of ``encodeToContent(_:)``. For string-backed representations, this extracts the string and + /// converts it to `Data` using the representation's encoding. For data-backed representations, this extracts the + /// byte array and wraps it in `Data`. + /// + /// - Parameter content: The content to extract data from. + /// - Returns: The raw data, or `nil` if the content doesn't match this representation's expected case. + func data(from content: ConfigContent) -> Data? { + switch kind { + case .string(let encoding): + guard case .string(let string) = content else { + return nil + } + return string.data(using: encoding) + case .data: + guard case .bytes(let bytes) = content else { + return nil + } + return Data(bytes) + } + } + + /// Watches for raw data changes from the reader based on this representation. /// /// Each time the underlying configuration value changes, `onUpdate` is called with the new raw data (or `nil` if the diff --git a/Sources/DevConfiguration/Core/ConfigVariable.swift b/Sources/DevConfiguration/Core/ConfigVariable.swift index 853539f..26d7f44 100644 --- a/Sources/DevConfiguration/Core/ConfigVariable.swift +++ b/Sources/DevConfiguration/Core/ConfigVariable.swift @@ -280,6 +280,28 @@ extension ConfigVariable { } +extension ConfigVariable { + /// Creates a `RawRepresentable & CaseIterable` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/rawRepresentableCaseIterableString()`` automatically. Uses a picker + /// control populated with all cases instead of a free-text field. + /// + /// - Parameters: + /// - key: The configuration key. + /// - defaultValue: The default value to use when variable resolution fails. + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: Value, isSecret: Bool = false) + where Value: RawRepresentable & CaseIterable & Sendable, Value.RawValue == String { + self.init( + key: key, + defaultValue: defaultValue, + content: .rawRepresentableCaseIterableString(), + isSecret: isSecret + ) + } +} + + extension ConfigVariable { /// Creates a `[RawRepresentable]` configuration variable. /// @@ -348,6 +370,23 @@ extension ConfigVariable { } +extension ConfigVariable { + /// Creates a `RawRepresentable & CaseIterable` configuration variable. + /// + /// Content is set to ``ConfigVariableContent/rawRepresentableCaseIterableInt()`` automatically. Uses a picker + /// control populated with all cases instead of a free-text number field. + /// + /// - Parameters: + /// - key: The configuration key. + /// - defaultValue: The default value to use when variable resolution fails. + /// - isSecret: Whether this variable's value should be treated as secret. Defaults to `false`. + public init(key: ConfigKey, defaultValue: Value, isSecret: Bool = false) + where Value: RawRepresentable & CaseIterable & Sendable, Value.RawValue == Int { + self.init(key: key, defaultValue: defaultValue, content: .rawRepresentableCaseIterableInt(), isSecret: isSecret) + } +} + + extension ConfigVariable { /// Creates a `[RawRepresentable]` configuration variable. /// diff --git a/Sources/DevConfiguration/Core/ConfigVariableContent.swift b/Sources/DevConfiguration/Core/ConfigVariableContent.swift index 8dc56be..3a1bec5 100644 --- a/Sources/DevConfiguration/Core/ConfigVariableContent.swift +++ b/Sources/DevConfiguration/Core/ConfigVariableContent.swift @@ -66,13 +66,20 @@ public struct ConfigVariableContent: Sendable where Value: Sendable { let encode: @Sendable (_ value: Value) throws -> ConfigContent /// The editor control to use when editing this variable's value in the editor UI. - public let editorControl: EditorControl + public let editorControl: EditorControl? /// Parses a raw string from the editor UI into a ``ConfigContent`` value. /// /// Returns `nil` if the string cannot be parsed into a valid value for this content type. When `nil` itself, the /// content type does not support editing. let parse: (@Sendable (_ input: String) -> ConfigContent?)? + + /// Validates that a ``ConfigContent`` value can be decoded into a valid instance of the variable's destination type. + /// + /// For primitive types where a successful parse guarantees a valid value, this is `nil`. For types like + /// `RawRepresentable` enums or `Codable` values, this checks that the content actually maps to a valid instance of + /// the destination type. + let validate: (@Sendable (_ content: ConfigContent) -> Bool)? } @@ -109,7 +116,8 @@ extension ConfigVariableContent where Value == Bool { }, encode: { .bool($0) }, editorControl: .toggle, - parse: { Bool($0).map { .bool($0) } } + parse: { Bool($0).map { .bool($0) } }, + validate: nil ) } } @@ -145,8 +153,19 @@ extension ConfigVariableContent where Value == [Bool] { } }, encode: { .boolArray($0) }, - editorControl: .none, - parse: nil + editorControl: .textEditor, + parse: { input in + let lines = input.nonEmptyTrimmedLines + var values: [Bool] = [] + for line in lines { + guard let value = Bool(line) else { + return nil + } + values.append(value) + } + return .boolArray(values) + }, + validate: nil ) } } @@ -183,7 +202,8 @@ extension ConfigVariableContent where Value == Float64 { }, encode: { .double($0) }, editorControl: .decimalField, - parse: { Double($0).map { .double($0) } } + parse: { (try? Float64($0, format: .number, lenient: false)).map { .double($0) } }, + validate: nil ) } } @@ -219,8 +239,19 @@ extension ConfigVariableContent where Value == [Float64] { } }, encode: { .doubleArray($0) }, - editorControl: .none, - parse: nil + editorControl: .textEditor, + parse: { input in + let lines = input.nonEmptyTrimmedLines + var values: [Float64] = [] + for line in lines { + guard let value = try? Float64(line, format: .number) else { + return nil + } + values.append(value) + } + return .doubleArray(values) + }, + validate: nil ) } } @@ -257,7 +288,16 @@ extension ConfigVariableContent where Value == Int { }, encode: { .int($0) }, editorControl: .numberField, - parse: { Int($0).map { .int($0) } } + parse: { + guard + let value = try? Float64($0, format: .number), + let int = Int(exactly: value) + else { + return nil + } + return .int(int) + }, + validate: nil ) } } @@ -293,8 +333,22 @@ extension ConfigVariableContent where Value == [Int] { } }, encode: { .intArray($0) }, - editorControl: .none, - parse: nil + editorControl: .textEditor, + parse: { input in + let lines = input.nonEmptyTrimmedLines + var values: [Int] = [] + for line in lines { + guard + let parsed = try? Float64(line, format: .number), + let value = Int(exactly: parsed) + else { + return nil + } + values.append(value) + } + return .intArray(values) + }, + validate: nil ) } } @@ -331,7 +385,8 @@ extension ConfigVariableContent where Value == String { }, encode: { .string($0) }, editorControl: .textField, - parse: { .string($0) } + parse: { .string($0) }, + validate: nil ) } } @@ -367,8 +422,9 @@ extension ConfigVariableContent where Value == [String] { } }, encode: { .stringArray($0) }, - editorControl: .none, - parse: nil + editorControl: .textEditor, + parse: { .stringArray($0.nonEmptyTrimmedLines) }, + validate: nil ) } } @@ -404,8 +460,9 @@ extension ConfigVariableContent where Value == [UInt8] { } }, encode: { .bytes($0) }, - editorControl: .none, - parse: nil + editorControl: nil, + parse: nil, + validate: nil ) } } @@ -447,8 +504,9 @@ extension ConfigVariableContent where Value == [[UInt8]] { } }, encode: { .byteChunkArray($0) }, - editorControl: .none, - parse: nil + editorControl: nil, + parse: nil, + validate: nil ) } } @@ -497,7 +555,68 @@ extension ConfigVariableContent { }, encode: { .string($0.rawValue) }, editorControl: .textField, - parse: { .string($0) } + parse: { .string($0) }, + validate: { content in + guard case .string(let rawValue) = content else { + return false + } + return Value(rawValue: rawValue) != nil + } + ) + } + + + /// Content for `RawRepresentable & CaseIterable` values. + /// + /// Uses a picker control populated with all cases instead of a free-text field. + public static func rawRepresentableCaseIterableString() -> ConfigVariableContent + where Value: RawRepresentable & CaseIterable & Sendable, Value.RawValue == String { + ConfigVariableContent( + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.string( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchString( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchString( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + }, + encode: { .string($0.rawValue) }, + editorControl: .picker( + options: Value.allCases.map { + .init( + label: $0.rawValue, + content: .string($0.rawValue) + ) + } + ), + parse: nil, + validate: nil ) } @@ -541,8 +660,14 @@ extension ConfigVariableContent { } }, encode: { .stringArray($0.map(\.rawValue)) }, - editorControl: .none, - parse: nil + editorControl: .textEditor, + parse: { .stringArray($0.nonEmptyTrimmedLines) }, + validate: { content in + guard case .stringArray(let strings) = content else { + return false + } + return strings.allSatisfy { Element(rawValue: $0) != nil } + } ) } @@ -586,7 +711,13 @@ extension ConfigVariableContent { }, encode: { .string($0.description) }, editorControl: .textField, - parse: { .string($0) } + parse: { .string($0) }, + validate: { content in + guard case .string(let string) = content else { + return false + } + return Value(configString: string) != nil + } ) } @@ -630,8 +761,14 @@ extension ConfigVariableContent { } }, encode: { .stringArray($0.map(\.description)) }, - editorControl: .none, - parse: nil + editorControl: .textEditor, + parse: { .stringArray($0.nonEmptyTrimmedLines) }, + validate: { content in + guard case .stringArray(let strings) = content else { + return false + } + return strings.allSatisfy { Element(configString: $0) != nil } + } ) } } @@ -680,7 +817,76 @@ extension ConfigVariableContent { }, encode: { .int($0.rawValue) }, editorControl: .numberField, - parse: { Int($0).map { .int($0) } } + parse: { + guard + let value = try? Float64($0, format: .number), + let int = Int(exactly: value) + else { + return nil + } + return .int(int) + }, + validate: { content in + guard case .int(let rawValue) = content else { + return false + } + return Value(rawValue: rawValue) != nil + } + ) + } + + + /// Content for `RawRepresentable & CaseIterable` values. + /// + /// Uses a picker control populated with all cases instead of a free-text number field. + public static func rawRepresentableCaseIterableInt() -> ConfigVariableContent + where Value: RawRepresentable & CaseIterable & Sendable, Value.RawValue == Int { + ConfigVariableContent( + read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + reader.int( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + fetch: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in + try await reader.fetchInt( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) + }, + startWatching: { (reader, key, isSecret, defaultValue, eventBus, fileID, line, continuation) in + try await reader.watchInt( + forKey: key, + as: Value.self, + isSecret: isSecret, + default: defaultValue, + fileID: fileID, + line: line + ) { updates in + for await value in updates { + continuation.yield(value) + } + } + }, + encode: { .int($0.rawValue) }, + editorControl: .picker( + options: Value.allCases.map { (value) in + .init( + label: "\(String(describing: value)) (\(value.rawValue))", + content: .int(value.rawValue) + ) + } + ), + parse: nil, + validate: nil ) } @@ -724,8 +930,27 @@ extension ConfigVariableContent { } }, encode: { .intArray($0.map(\.rawValue)) }, - editorControl: .none, - parse: nil + editorControl: .textEditor, + parse: { input in + let lines = input.nonEmptyTrimmedLines + var values: [Int] = [] + for line in lines { + guard + let parsed = try? Float64(line, format: .number), + let value = Int(exactly: parsed) + else { + return nil + } + values.append(value) + } + return .intArray(values) + }, + validate: { content in + guard case .intArray(let ints) = content else { + return false + } + return ints.allSatisfy { Element(rawValue: $0) != nil } + } ) } @@ -769,7 +994,21 @@ extension ConfigVariableContent { }, encode: { .int($0.configInt) }, editorControl: .numberField, - parse: { Int($0).map { .int($0) } } + parse: { + guard + let value = try? Float64($0, format: .number), + let int = Int(exactly: value) + else { + return nil + } + return .int(int) + }, + validate: { content in + guard case .int(let int) = content else { + return false + } + return Value(configInt: int) != nil + } ) } @@ -813,8 +1052,27 @@ extension ConfigVariableContent { } }, encode: { .intArray($0.map(\.configInt)) }, - editorControl: .none, - parse: nil + editorControl: .textEditor, + parse: { input in + let lines = input.nonEmptyTrimmedLines + var values: [Int] = [] + for line in lines { + guard + let parsed = try? Float64(line, format: .number), + let value = Int(exactly: parsed) + else { + return nil + } + values.append(value) + } + return .intArray(values) + }, + validate: { content in + guard case .intArray(let ints) = content else { + return false + } + return ints.allSatisfy { Element(configInt: $0) != nil } + } ) } } @@ -834,10 +1092,12 @@ extension ConfigVariableContent { decoder: JSONDecoder? = nil, encoder: JSONEncoder? = nil ) -> ConfigVariableContent where Value: Codable { - codable( + return codable( representation: representation, decoder: decoder as (any TopLevelDecoder & Sendable)?, - encoder: encoder as (any TopLevelEncoder & Sendable)? + encoder: encoder as (any TopLevelEncoder & Sendable)?, + editorControl: representation.supportsTextEditing ? .textEditor : nil, + parse: representation.supportsTextEditing ? { @Sendable in ConfigContent.string($0) } : nil ) } @@ -856,7 +1116,9 @@ extension ConfigVariableContent { codable( representation: representation, decoder: decoder as (any TopLevelDecoder & Sendable)?, - encoder: encoder as (any TopLevelEncoder & Sendable)? + encoder: encoder as (any TopLevelEncoder & Sendable)?, + editorControl: nil, + parse: nil ) } @@ -865,7 +1127,9 @@ extension ConfigVariableContent { private static func codable( representation: CodableValueRepresentation, decoder: (any TopLevelDecoder & Sendable)?, - encoder: (any TopLevelEncoder & Sendable)? + encoder: (any TopLevelEncoder & Sendable)?, + editorControl: EditorControl?, + parse: (@Sendable (_ input: String) -> ConfigContent?)? ) -> ConfigVariableContent where Value: Codable { ConfigVariableContent( read: { (reader, key, isSecret, defaultValue, eventBus, fileID, line) in @@ -950,12 +1214,28 @@ extension ConfigVariableContent { } }, encode: { (value) in - let resolvedEncoder = encoder ?? JSONEncoder() + let resolvedEncoder = encoder ?? JSONEncoder.sortedKeys let data = try resolvedEncoder.encode(value) return try representation.encodeToContent(data) }, - editorControl: .none, - parse: nil + editorControl: editorControl, + parse: parse, + validate: { content in + let resolvedDecoder = decoder ?? JSONDecoder() + guard let data = representation.data(from: content) else { + return false + } + return (try? resolvedDecoder.decode(Value.self, from: data)) != nil + } ) } } + + +extension JSONEncoder { + static var sortedKeys: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + return encoder + } +} diff --git a/Sources/DevConfiguration/Core/ConfigVariableReader.swift b/Sources/DevConfiguration/Core/ConfigVariableReader.swift index cafe333..a24f0ec 100644 --- a/Sources/DevConfiguration/Core/ConfigVariableReader.swift +++ b/Sources/DevConfiguration/Core/ConfigVariableReader.swift @@ -177,7 +177,8 @@ extension ConfigVariableReader { metadata: variable.metadata, destinationTypeName: String(describing: Value.self), editorControl: variable.content.editorControl, - parse: variable.content.parse + parse: variable.content.parse, + validate: variable.content.validate ) } } diff --git a/Sources/DevConfiguration/Core/EditorControl.swift b/Sources/DevConfiguration/Core/EditorControl.swift index f85d1b1..71b2522 100644 --- a/Sources/DevConfiguration/Core/EditorControl.swift +++ b/Sources/DevConfiguration/Core/EditorControl.swift @@ -5,24 +5,37 @@ // Created by Prachi Gauriar on 3/7/2026. // +import Configuration + /// Describes which UI control the editor should use to edit a configuration variable's value. /// /// Each ``ConfigVariableContent`` instance has an associated `EditorControl` that tells the editor UI which input /// control to present when the user enables an override. Content factories set this automatically based on the /// variable's value type. public struct EditorControl: Hashable, Sendable { + /// A single option in a picker control. + public struct PickerOption: Hashable, Sendable { + /// The human-readable label for this option. + public let label: String + + /// The configuration content value this option represents. + public let content: ConfigContent + } + + /// The underlying kinds of editor controls. - private enum Kind: Hashable, Sendable { + enum Kind: Hashable, Sendable { case toggle case textField case numberField case decimalField - case none + case textEditor + case picker([PickerOption]) } /// The underlying kind of this editor control. - private let kind: Kind + let kind: Kind } @@ -32,11 +45,13 @@ extension EditorControl { EditorControl(kind: .toggle) } + /// A text field control, used for `String` and string-backed values. public static var textField: EditorControl { EditorControl(kind: .textField) } + /// A number field control, used for `Int` and integer-backed values. /// /// Rejects fractional input. @@ -44,6 +59,7 @@ extension EditorControl { EditorControl(kind: .numberField) } + /// A decimal field control, used for `Float64` values. /// /// Allows fractional input. @@ -51,10 +67,28 @@ extension EditorControl { EditorControl(kind: .decimalField) } - /// No editor control. + + /// A text editor control, used for multi-line content like JSON or arrays. + public static var textEditor: EditorControl { + EditorControl(kind: .textEditor) + } + + + /// A picker control, used for `CaseIterable` types with a fixed set of valid values. + /// + /// - Parameter options: The available picker options with their labels and content values. + public static func picker(options: [PickerOption]) -> EditorControl { + EditorControl(kind: .picker(options)) + } + + + /// The picker options, if this is a picker control. /// - /// The variable is read-only in the editor. - public static var none: EditorControl { - EditorControl(kind: .none) + /// Returns `nil` for non-picker controls. + public var pickerOptions: [PickerOption]? { + guard case .picker(let options) = kind else { + return nil + } + return options } } diff --git a/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift b/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift index 48741c2..79c7536 100644 --- a/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift +++ b/Sources/DevConfiguration/Core/RegisteredConfigVariable.swift @@ -45,7 +45,7 @@ public struct RegisteredConfigVariable: Sendable { } /// The editor control to use when editing this variable's value in the editor UI. - public let editorControl: EditorControl + public let editorControl: EditorControl? /// Parses a raw string from the editor UI into a ``ConfigContent`` value. /// @@ -53,6 +53,11 @@ public struct RegisteredConfigVariable: Sendable { /// editing. let parse: (@Sendable (_ input: String) -> ConfigContent?)? + /// Validates that a ``ConfigContent`` value can be decoded into a valid instance of the variable's destination type. + /// + /// Returns `true` if the content is valid. When `nil`, a successful parse is considered sufficient validation. + let validate: (@Sendable (_ content: ConfigContent) -> Bool)? + /// Creates a new registered config variable. /// @@ -64,14 +69,16 @@ public struct RegisteredConfigVariable: Sendable { /// - destinationTypeName: The name of the variable's Swift value type. /// - editorControl: The editor control to use for this variable. /// - parse: A function that parses a raw string into a ``ConfigContent`` value. + /// - validate: A function that validates a ``ConfigContent`` value against the destination type. init( key: ConfigKey, defaultContent: ConfigContent, isSecret: Bool, metadata: ConfigVariableMetadata, destinationTypeName: String, - editorControl: EditorControl, - parse: (@Sendable (_ input: String) -> ConfigContent?)? + editorControl: EditorControl?, + parse: (@Sendable (_ input: String) -> ConfigContent?)?, + validate: (@Sendable (_ content: ConfigContent) -> Bool)? ) { self.key = key self.defaultContent = defaultContent @@ -80,6 +87,7 @@ public struct RegisteredConfigVariable: Sendable { self.destinationTypeName = Self.normalizedTypeName(destinationTypeName) self.editorControl = editorControl self.parse = parse + self.validate = validate } diff --git a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift index 1a45ea4..1ac8267 100644 --- a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailView.swift @@ -16,6 +16,7 @@ import SwiftUI /// It is generic on its view model protocol, allowing tests to inject mock view models. struct ConfigVariableDetailView: View { @State var viewModel: ViewModel + @FocusState private var isTextEditorFocused: Bool var body: some View { @@ -55,7 +56,7 @@ extension ConfigVariableDetailView { @ViewBuilder private var overrideSection: some View { - if viewModel.editorControl != .none { + if viewModel.editorControl != nil { Section(localizedStringResource("detailView.overrideSection.header")) { LabeledContent(localizedStringResource("detailView.overrideSection.editorOverrideLabel")) { if viewModel.isOverrideEnabled { @@ -90,30 +91,74 @@ extension ConfigVariableDetailView { @ViewBuilder private var overrideControl: some View { - LabeledContent(localizedStringResource("detailView.overrideSection.valueLabel")) { - if viewModel.editorControl == .toggle { - HStack { - Spacer().layoutPriority(0) - Picker( - localizedStringResource("detailView.overrideSection.valuePicker"), - selection: $viewModel.overrideBool - ) { - Text(localized: "detailView.overridenSection.valuePickerFalse").tag(false) - Text(localized: "detailView.overridenSection.valuePickerTrue").tag(true) + if let editorControl = viewModel.editorControl { + switch editorControl.kind { + case .toggle: + LabeledContent(localizedStringResource("detailView.overrideSection.valueLabel")) { + HStack { + Spacer().layoutPriority(0) + Picker( + localizedStringResource("detailView.overrideSection.valuePicker"), + selection: $viewModel.overrideBool + ) { + Text(localized: "detailView.overridenSection.valuePickerFalse").tag(false) + Text(localized: "detailView.overridenSection.valuePickerTrue").tag(true) + } + .pickerStyle(.segmented) } - .pickerStyle(.segmented) } - } else { - TextField( - localizedStringResource("detailView.overrideSection.valueTextField"), - text: $viewModel.overrideText - ) - .onSubmit { viewModel.commitOverrideText() } - .textFieldStyle(.roundedBorder) - .multilineTextAlignment(.trailing) - #if os(iOS) || os(visionOS) - .keyboardType(keyboardType) - #endif + case .picker(options: let pickerOptions): + Picker( + localizedStringResource("detailView.overrideSection.valuePicker"), + selection: $viewModel.overridePickerSelection + ) { + ForEach(pickerOptions, id: \.content) { option in + Text(option.label).tag(option.content) + } + } + case .textEditor: + VStack(alignment: .leading) { + Text(localizedStringResource("detailView.overrideSection.valueLabel")) + TextEditor(text: $viewModel.overrideText) + .focused($isTextEditorFocused) + .font(.caption.monospaced()) + .frame(minHeight: 100) + .border(viewModel.isOverrideTextValid ? Color.clear : Color.red) + .autocorrectionDisabled() + + #if os(iOS) || os(visionOS) + .textInputAutocapitalization(.never) + .keyboardType(.asciiCapable) + #endif + + HStack { + Spacer() + Button(localizedStringResource("detailView.overrideSection.applyButton")) { + viewModel.commitOverrideText() + isTextEditorFocused = false + } + .buttonStyle(.bordered) + .disabled(!viewModel.isOverrideTextValid) + } + } + case .textField, .numberField, .decimalField: + LabeledContent(localizedStringResource("detailView.overrideSection.valueLabel")) { + TextField( + localizedStringResource("detailView.overrideSection.valueTextField"), + text: $viewModel.overrideText + ) + .onSubmit { viewModel.commitOverrideText() } + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + .padding(6) + .background( + RoundedRectangle(cornerRadius: 6) + .stroke(viewModel.isOverrideTextValid ? Color.separator : Color.red) + ) + #if os(iOS) || os(visionOS) + .keyboardType(keyboardType) + #endif + } } } } @@ -178,3 +223,14 @@ extension ConfigVariableDetailView { } #endif + + +extension Color { + static var separator: Color { + #if canImport(UIKit) + Color(UIColor.separator) + #elseif canImport(AppKit) + Color(NSColor.separatorColor) + #endif + } +} diff --git a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift index a32f641..eb5d4a0 100644 --- a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModel.swift @@ -29,7 +29,7 @@ final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { let variableTypeName: String let metadataEntries: [ConfigVariableMetadata.DisplayText] let isSecret: Bool - let editorControl: EditorControl + let editorControl: EditorControl? var overrideText = "" var isSecretRevealed = false @@ -52,9 +52,9 @@ final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { self.editorControl = registeredVariable.editorControl if let content = document.override(forKey: registeredVariable.key) { - self.overrideText = content.displayString + self.overrideText = content.editableString } else if let resolved = document.resolvedValue(forKey: registeredVariable.key) { - self.overrideText = resolved.content.displayString + self.overrideText = resolved.content.editableString } } @@ -95,13 +95,43 @@ final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { } + var overridePickerSelection: ConfigContent { + get { + document.override(forKey: key) ?? registeredVariable.defaultContent + } + set { + document.setOverride(newValue, forKey: key) + } + } + + + var isOverrideTextValid: Bool { + guard let parse = registeredVariable.parse else { + return true + } + + guard let content = parse(overrideText) else { + return false + } + + guard let validate = registeredVariable.validate else { + return true + } + + return validate(content) + } + + func commitOverrideText() { guard let parse = registeredVariable.parse else { return } - let text = overrideText - guard let content = parse(text) else { + guard let content = parse(overrideText) else { + return + } + + if let validate = registeredVariable.validate, !validate(content) { return } @@ -120,7 +150,7 @@ final class ConfigVariableDetailViewModel: ConfigVariableDetailViewModeling { content = registeredVariable.defaultContent } - overrideText = content.displayString + overrideText = content.editableString document.setOverride(content, forKey: key) } } diff --git a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift index 83ed84a..573c76c 100644 --- a/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift +++ b/Sources/DevConfiguration/Editor/Config Variable Detail/ConfigVariableDetailViewModeling.swift @@ -39,24 +39,32 @@ protocol ConfigVariableDetailViewModeling: Observable { var isSecret: Bool { get } /// The editor control to use for this variable's override. - var editorControl: EditorControl { get } + var editorControl: EditorControl? { get } /// Whether the user has enabled an override for this variable. var isOverrideEnabled: Bool { get set } - /// The text value for the override, used with text field and number field controls. + /// The text value for the override, used with text field, number field, and text editor controls. var overrideText: String { get set } /// The boolean value for the override, used with toggle controls. var overrideBool: Bool { get set } + /// The selected content for the override, used with picker controls. + var overridePickerSelection: ConfigContent { get set } + + /// Whether the current override text is valid for the variable's destination type. + /// + /// Always `true` when the variable has no validation or when no override is enabled. + var isOverrideTextValid: Bool { get } + /// Whether the secret value is currently revealed. var isSecretRevealed: Bool { get set } /// Commits the current override text to the document. /// - /// Called when the user submits the text field. Parses the text into a ``ConfigContent`` and sets the override - /// on the document. + /// Called when the user submits the text field or text editor. Parses the text into a ``ConfigContent`` and sets + /// the override on the document. Does nothing if the text is invalid. func commitOverrideText() } diff --git a/Sources/DevConfiguration/Editor/Config Variable List/VariableListItem.swift b/Sources/DevConfiguration/Editor/Config Variable List/VariableListItem.swift index 73ef865..d1d0f2c 100644 --- a/Sources/DevConfiguration/Editor/Config Variable List/VariableListItem.swift +++ b/Sources/DevConfiguration/Editor/Config Variable List/VariableListItem.swift @@ -39,5 +39,5 @@ struct VariableListItem: Hashable, Sendable { let hasOverride: Bool /// The editor control to use when editing this variable's value. - let editorControl: EditorControl + let editorControl: EditorControl? } diff --git a/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift b/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift index 58c7a3b..e02c9fb 100644 --- a/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift +++ b/Sources/DevConfiguration/Editor/Data Models/EditorDocument.swift @@ -218,7 +218,9 @@ extension EditorDocument { /// - Parameter key: The configuration key to resolve. /// - Returns: The resolved value, or `nil` if no value is found. func resolvedValue(forKey key: ConfigKey) -> ResolvedValue? { - guard let registeredVariable = registeredVariables[key] else { return nil } + guard let registeredVariable = registeredVariables[key] else { + return nil + } let expectedType = registeredVariable.defaultContent.configType // Check working copy first @@ -253,7 +255,9 @@ extension EditorDocument { /// - Parameter key: The configuration key to query. /// - Returns: An array of ``ProviderValue`` instances for providers that have a value for the key. func providerValues(forKey key: ConfigKey) -> [ProviderValue] { - guard let registeredVariable = registeredVariables[key] else { return [] } + guard let registeredVariable = registeredVariables[key] else { + return [] + } let expectedType = registeredVariable.defaultContent.configType let resolved = resolvedValue(forKey: key) @@ -318,7 +322,9 @@ extension EditorDocument { /// - key: The configuration key to override. func setOverride(_ content: ConfigContent, forKey key: ConfigKey) { let oldContent = workingCopy[key] - guard oldContent != content else { return } + guard oldContent != content else { + return + } workingCopy[key] = content diff --git a/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift b/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift index 84557f0..17aaafb 100644 --- a/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift +++ b/Sources/DevConfiguration/Extensions/ConfigContent+Additions.swift @@ -86,6 +86,32 @@ extension ConfigContent { } +// MARK: - Editable String + +extension ConfigContent { + /// A string representation suitable for editing in a text field or text editor. + /// + /// For scalar values, this is the same as ``displayString``. For array values, elements are separated by newlines + /// instead of list formatting, making them suitable for a text editor where each line is one element. + var editableString: String { + switch self { + case .bool, .int, .double, .string, .bytes: + displayString + case .boolArray(let value): + value.map { String($0) }.joined(separator: "\n") + case .intArray(let value): + value.map { String($0) }.joined(separator: "\n") + case .doubleArray(let value): + value.map { String($0) }.joined(separator: "\n") + case .stringArray(let value): + value.joined(separator: "\n") + case .byteChunkArray: + displayString + } + } +} + + // MARK: - Codable extension ConfigContent: @retroactive Codable { diff --git a/Sources/DevConfiguration/Extensions/String+NonEmptyTrimmedLines.swift b/Sources/DevConfiguration/Extensions/String+NonEmptyTrimmedLines.swift new file mode 100644 index 0000000..7926798 --- /dev/null +++ b/Sources/DevConfiguration/Extensions/String+NonEmptyTrimmedLines.swift @@ -0,0 +1,17 @@ +// +// String+NonEmptyTrimmedLines.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/10/2026. +// + +import Foundation + +extension String { + /// The non-empty lines of the string, each trimmed of leading and trailing whitespace. + var nonEmptyTrimmedLines: [String] { + split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } +} diff --git a/Sources/DevConfiguration/Resources/Localizable.xcstrings b/Sources/DevConfiguration/Resources/Localizable.xcstrings index 763c403..cfc06f1 100644 --- a/Sources/DevConfiguration/Resources/Localizable.xcstrings +++ b/Sources/DevConfiguration/Resources/Localizable.xcstrings @@ -71,6 +71,16 @@ } } }, + "detailView.overrideSection.applyButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apply" + } + } + } + }, "detailView.overrideSection.editorOverrideLabel" : { "localizations" : { "en" : { @@ -111,6 +121,16 @@ } } }, + "detailView.overrideSection.valuePicker" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Value" + } + } + } + }, "detailView.overrideSection.valueTextField" : { "localizations" : { "en" : { diff --git a/Tests/DevConfigurationTests/Testing Support/MockCodableValue.swift b/Tests/DevConfigurationTests/Testing Support/MockCodableValue.swift new file mode 100644 index 0000000..97ae970 --- /dev/null +++ b/Tests/DevConfigurationTests/Testing Support/MockCodableValue.swift @@ -0,0 +1,10 @@ +// +// MockCodableValue.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/11/2026. +// + +struct MockCodableValue: Codable, Sendable { + let value: String +} diff --git a/Tests/DevConfigurationTests/Testing Support/MockNonIterableIntEnum.swift b/Tests/DevConfigurationTests/Testing Support/MockNonIterableIntEnum.swift new file mode 100644 index 0000000..01b1525 --- /dev/null +++ b/Tests/DevConfigurationTests/Testing Support/MockNonIterableIntEnum.swift @@ -0,0 +1,11 @@ +// +// MockNonIterableIntEnum.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/11/2026. +// + +enum MockNonIterableIntEnum: Int, Sendable { + case a = 0 + case b = 1 +} diff --git a/Tests/DevConfigurationTests/Testing Support/MockNonIterableStringEnum.swift b/Tests/DevConfigurationTests/Testing Support/MockNonIterableStringEnum.swift new file mode 100644 index 0000000..86c4a72 --- /dev/null +++ b/Tests/DevConfigurationTests/Testing Support/MockNonIterableStringEnum.swift @@ -0,0 +1,11 @@ +// +// MockNonIterableStringEnum.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/11/2026. +// + +enum MockNonIterableStringEnum: String, Sendable { + case a + case b +} diff --git a/Tests/DevConfigurationTests/Testing Support/RandomValueGenerating+DevConfiguration.swift b/Tests/DevConfigurationTests/Testing Support/RandomValueGenerating+DevConfiguration.swift index aaa9d97..927af92 100644 --- a/Tests/DevConfigurationTests/Testing Support/RandomValueGenerating+DevConfiguration.swift +++ b/Tests/DevConfigurationTests/Testing Support/RandomValueGenerating+DevConfiguration.swift @@ -113,7 +113,8 @@ extension RandomValueGenerating { metadata: ConfigVariableMetadata? = nil, destinationTypeName: String? = nil, editorControl: EditorControl? = nil, - parse: (@Sendable (_ input: String) -> ConfigContent?)? = nil + parse: (@Sendable (_ input: String) -> ConfigContent?)? = nil, + validate: (@Sendable (_ content: ConfigContent) -> Bool)? = nil ) -> RegisteredConfigVariable { RegisteredConfigVariable( key: key ?? randomConfigKey(), @@ -122,7 +123,8 @@ extension RandomValueGenerating { metadata: metadata ?? ConfigVariableMetadata(), destinationTypeName: destinationTypeName ?? randomAlphanumericString(), editorControl: editorControl ?? .none, - parse: parse + parse: parse, + validate: validate ) } @@ -137,6 +139,16 @@ extension RandomValueGenerating { } + mutating func randomNonIterableIntEnum() -> MockNonIterableIntEnum { + randomElement(in: [MockNonIterableIntEnum.a, .b])! + } + + + mutating func randomNonIterableStringEnum() -> MockNonIterableStringEnum { + randomElement(in: [MockNonIterableStringEnum.a, .b])! + } + + mutating func randomStringArray() -> [String] { return Array(count: randomInt(in: 0 ... 5)) { randomAlphanumericString() } } diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/CodableValueRepresentationTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/CodableValueRepresentationTests.swift new file mode 100644 index 0000000..b00bf86 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Core/CodableValueRepresentationTests.swift @@ -0,0 +1,81 @@ +// +// CodableValueRepresentationTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/11/2026. +// + +import Configuration +import DevTesting +import Foundation +import Testing + +@testable import DevConfiguration + +struct CodableValueRepresentationTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + // MARK: - data(from:) + + @Test + mutating func dataFromStringRepresentationExtractsStringAsUTF8Data() { + // set up + let string = randomAlphanumericString() + let representation = CodableValueRepresentation.string() + + // exercise + let data = representation.data(from: .string(string)) + + // expect + #expect(data == Data(string.utf8)) + } + + + @Test + mutating func dataFromStringRepresentationReturnsNilForNonStringContent() { + // set up + let representation = CodableValueRepresentation.string() + + // exercise and expect + #expect(representation.data(from: .int(randomInt(in: .min ... .max))) == nil) + } + + + @Test + mutating func dataFromDataRepresentationExtractsBytesAsData() { + // set up + let bytes = randomBytes() + let representation = CodableValueRepresentation.data + + // exercise + let data = representation.data(from: .bytes(bytes)) + + // expect + #expect(data == Data(bytes)) + } + + + @Test + mutating func dataFromDataRepresentationReturnsNilForNonBytesContent() { + // set up + let representation = CodableValueRepresentation.data + + // exercise and expect + #expect(representation.data(from: .string(randomAlphanumericString())) == nil) + } + + + // MARK: - supportsTextEditing + + @Test + func supportsTextEditingReturnsTrueForStringRepresentation() { + #expect(CodableValueRepresentation.string().supportsTextEditing) + } + + + @Test + func supportsTextEditingReturnsFalseForDataRepresentation() { + #expect(!CodableValueRepresentation.data.supportsTextEditing) + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableContentEditorTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableContentEditorTests.swift index 1ad5df5..c350c0e 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableContentEditorTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableContentEditorTests.swift @@ -7,6 +7,7 @@ import Configuration import DevTesting +import Foundation import Testing @testable import DevConfiguration @@ -23,48 +24,24 @@ struct ConfigVariableContentEditorTests: RandomValueGenerating { } - @Test - func boolArrayEditorControlIsNone() { - #expect(ConfigVariableContent<[Bool]>.boolArray.editorControl == .none) - } - - @Test func float64EditorControlIsDecimalField() { #expect(ConfigVariableContent.float64.editorControl == .decimalField) } - @Test - func float64ArrayEditorControlIsNone() { - #expect(ConfigVariableContent<[Float64]>.float64Array.editorControl == .none) - } - - @Test func intEditorControlIsNumberField() { #expect(ConfigVariableContent.int.editorControl == .numberField) } - @Test - func intArrayEditorControlIsNone() { - #expect(ConfigVariableContent<[Int]>.intArray.editorControl == .none) - } - - @Test func stringEditorControlIsTextField() { #expect(ConfigVariableContent.string.editorControl == .textField) } - @Test - func stringArrayEditorControlIsNone() { - #expect(ConfigVariableContent<[String]>.stringArray.editorControl == .none) - } - - @Test func bytesEditorControlIsNone() { #expect(ConfigVariableContent<[UInt8]>.bytes.editorControl == .none) @@ -79,32 +56,18 @@ struct ConfigVariableContentEditorTests: RandomValueGenerating { @Test func rawRepresentableStringEditorControlIsTextField() { - let content = ConfigVariableContent.rawRepresentableString() + let content = ConfigVariableContent.rawRepresentableString() #expect(content.editorControl == .textField) } - @Test - func rawRepresentableStringArrayEditorControlIsNone() { - let content = ConfigVariableContent<[TestStringEnum]>.rawRepresentableStringArray() - #expect(content.editorControl == .none) - } - - @Test func rawRepresentableIntEditorControlIsNumberField() { - let content = ConfigVariableContent.rawRepresentableInt() + let content = ConfigVariableContent.rawRepresentableInt() #expect(content.editorControl == .numberField) } - @Test - func rawRepresentableIntArrayEditorControlIsNone() { - let content = ConfigVariableContent<[TestIntEnum]>.rawRepresentableIntArray() - #expect(content.editorControl == .none) - } - - @Test func expressibleByConfigStringEditorControlIsTextField() { let content = ConfigVariableContent.expressibleByConfigString() @@ -112,13 +75,6 @@ struct ConfigVariableContentEditorTests: RandomValueGenerating { } - @Test - func expressibleByConfigStringArrayEditorControlIsNone() { - let content = ConfigVariableContent<[MockConfigStringValue]>.expressibleByConfigStringArray() - #expect(content.editorControl == .none) - } - - @Test func expressibleByConfigIntEditorControlIsNumberField() { let content = ConfigVariableContent.expressibleByConfigInt() @@ -126,23 +82,9 @@ struct ConfigVariableContentEditorTests: RandomValueGenerating { } - @Test - func expressibleByConfigIntArrayEditorControlIsNone() { - let content = ConfigVariableContent<[MockConfigIntValue]>.expressibleByConfigIntArray() - #expect(content.editorControl == .none) - } - - - @Test - func jsonEditorControlIsNone() { - let content = ConfigVariableContent.json() - #expect(content.editorControl == .none) - } - - @Test func propertyListEditorControlIsNone() { - let content = ConfigVariableContent.propertyList() + let content = ConfigVariableContent.propertyList() #expect(content.editorControl == .none) } @@ -172,6 +114,13 @@ struct ConfigVariableContentEditorTests: RandomValueGenerating { } + @Test + func float64ParseReturnsDoubleContentForFormattedInput() { + let parse = ConfigVariableContent.float64.parse + #expect(parse?(Float64(1234.5).formatted()) == .double(1234.5)) + } + + @Test func float64ParseReturnsNilForInvalidInput() { let parse = ConfigVariableContent.float64.parse @@ -187,6 +136,13 @@ struct ConfigVariableContentEditorTests: RandomValueGenerating { } + @Test + func intParseReturnsIntContentForFormattedInput() { + let parse = ConfigVariableContent.int.parse + #expect(parse?(1_234_567.formatted()) == .int(1_234_567)) + } + + @Test func intParseReturnsNilForInvalidInput() { let parse = ConfigVariableContent.int.parse @@ -205,7 +161,7 @@ struct ConfigVariableContentEditorTests: RandomValueGenerating { @Test mutating func rawRepresentableStringParseReturnsStringContent() { - let parse = ConfigVariableContent.rawRepresentableString().parse + let parse = ConfigVariableContent.rawRepresentableString().parse let value = randomAlphanumericString() #expect(parse?(value) == .string(value)) } @@ -213,7 +169,7 @@ struct ConfigVariableContentEditorTests: RandomValueGenerating { @Test mutating func rawRepresentableIntParseReturnsIntContentForValidInput() { - let parse = ConfigVariableContent.rawRepresentableInt().parse + let parse = ConfigVariableContent.rawRepresentableInt().parse let value = randomInt(in: -1000 ... 1000) #expect(parse?(String(value)) == .int(value)) } @@ -236,42 +192,441 @@ struct ConfigVariableContentEditorTests: RandomValueGenerating { @Test - func arrayAndByteContentParseIsNil() { - #expect(ConfigVariableContent<[Bool]>.boolArray.parse == nil) - #expect(ConfigVariableContent<[Float64]>.float64Array.parse == nil) - #expect(ConfigVariableContent<[Int]>.intArray.parse == nil) - #expect(ConfigVariableContent<[String]>.stringArray.parse == nil) + func bytesAndByteChunkArrayParseIsNil() { #expect(ConfigVariableContent<[UInt8]>.bytes.parse == nil) #expect(ConfigVariableContent<[[UInt8]]>.byteChunkArray.parse == nil) - #expect(ConfigVariableContent<[TestStringEnum]>.rawRepresentableStringArray().parse == nil) - #expect(ConfigVariableContent<[TestIntEnum]>.rawRepresentableIntArray().parse == nil) - #expect(ConfigVariableContent<[MockConfigStringValue]>.expressibleByConfigStringArray().parse == nil) - #expect(ConfigVariableContent<[MockConfigIntValue]>.expressibleByConfigIntArray().parse == nil) } @Test - func codableContentParseIsNil() { - #expect(ConfigVariableContent.json().parse == nil) - #expect(ConfigVariableContent.propertyList().parse == nil) + func propertyListParseIsNil() { + #expect(ConfigVariableContent.propertyList().parse == nil) } -} -// MARK: - Test Types + // MARK: - Array Parse -private enum TestStringEnum: String, Sendable { - case a - case b -} + @Test + func boolArrayParseReturnsContentForValidInput() { + let parse = ConfigVariableContent<[Bool]>.boolArray.parse + #expect(parse?("true\nfalse\ntrue") == .boolArray([true, false, true])) + } -private enum TestIntEnum: Int, Sendable { - case a = 0 - case b = 1 -} + @Test + func boolArrayParseReturnsNilForInvalidInput() { + let parse = ConfigVariableContent<[Bool]>.boolArray.parse + #expect(parse?("true\nnotABool") == nil) + } + + + @Test + func float64ArrayParseReturnsContentForValidInput() { + let parse = ConfigVariableContent<[Float64]>.float64Array.parse + #expect(parse?("1.5\n2.5") == .doubleArray([1.5, 2.5])) + } + + + @Test + func float64ArrayParseReturnsContentForFormattedInput() { + let parse = ConfigVariableContent<[Float64]>.float64Array.parse + let input = [Float64(1234.5), Float64(6789.1)].map { $0.formatted() }.joined(separator: "\n") + #expect(parse?(input) == .doubleArray([1234.5, 6789.1])) + } + + + @Test + func float64ArrayParseReturnsNilForInvalidInput() { + let parse = ConfigVariableContent<[Float64]>.float64Array.parse + #expect(parse?("1.5\nnotANumber") == nil) + } + + + @Test + func intArrayParseReturnsContentForValidInput() { + let parse = ConfigVariableContent<[Int]>.intArray.parse + #expect(parse?("1\n2\n3") == .intArray([1, 2, 3])) + } + + + @Test + func intArrayParseReturnsContentForFormattedInput() { + let parse = ConfigVariableContent<[Int]>.intArray.parse + let input = [1_234, 5_678].map { $0.formatted() }.joined(separator: "\n") + #expect(parse?(input) == .intArray([1_234, 5_678])) + } + + + @Test + func intArrayParseReturnsNilForInvalidInput() { + let parse = ConfigVariableContent<[Int]>.intArray.parse + #expect(parse?("1\nnotAnInt") == nil) + } + + + @Test + func stringArrayParseReturnsContent() { + let parse = ConfigVariableContent<[String]>.stringArray.parse + #expect(parse?("a\nb\nc") == .stringArray(["a", "b", "c"])) + } + + + @Test + func rawRepresentableStringArrayParseReturnsContent() { + let parse = ConfigVariableContent<[MockNonIterableStringEnum]>.rawRepresentableStringArray().parse + #expect(parse?("a\nb") == .stringArray(["a", "b"])) + } + + + @Test + func rawRepresentableIntParseReturnsIntContentForFormattedInput() { + let parse = ConfigVariableContent.rawRepresentableInt().parse + #expect(parse?(1_234_567.formatted()) == .int(1_234_567)) + } + + + @Test + func rawRepresentableIntArrayParseReturnsContentForValidInput() { + let parse = ConfigVariableContent<[MockNonIterableIntEnum]>.rawRepresentableIntArray().parse + #expect(parse?("0\n1") == .intArray([0, 1])) + } + + + @Test + func rawRepresentableIntArrayParseReturnsContentForFormattedInput() { + let parse = ConfigVariableContent<[MockNonIterableIntEnum]>.rawRepresentableIntArray().parse + let input = [1_234, 5_678].map { $0.formatted() }.joined(separator: "\n") + #expect(parse?(input) == .intArray([1_234, 5_678])) + } + + + @Test + func rawRepresentableIntArrayParseReturnsNilForInvalidInput() { + let parse = ConfigVariableContent<[MockNonIterableIntEnum]>.rawRepresentableIntArray().parse + #expect(parse?("0\nnotAnInt") == nil) + } + + + @Test + func expressibleByConfigStringArrayParseReturnsContent() { + let parse = ConfigVariableContent<[MockConfigStringValue]>.expressibleByConfigStringArray().parse + #expect(parse?("x\ny") == .stringArray(["x", "y"])) + } + + + @Test + func expressibleByConfigIntParseReturnsIntContentForFormattedInput() { + let parse = ConfigVariableContent.expressibleByConfigInt().parse + #expect(parse?(1_234_567.formatted()) == .int(1_234_567)) + } + + + @Test + func expressibleByConfigIntArrayParseReturnsContentForValidInput() { + let parse = ConfigVariableContent<[MockConfigIntValue]>.expressibleByConfigIntArray().parse + #expect(parse?("10\n20") == .intArray([10, 20])) + } + + + @Test + func expressibleByConfigIntArrayParseReturnsContentForFormattedInput() { + let parse = ConfigVariableContent<[MockConfigIntValue]>.expressibleByConfigIntArray().parse + let input = [1_234, 5_678].map { $0.formatted() }.joined(separator: "\n") + #expect(parse?(input) == .intArray([1_234, 5_678])) + } + + + @Test + func expressibleByConfigIntArrayParseReturnsNilForInvalidInput() { + let parse = ConfigVariableContent<[MockConfigIntValue]>.expressibleByConfigIntArray().parse + #expect(parse?("10\nnotAnInt") == nil) + } + + + @Test + func jsonParseReturnsStringContentForStringRepresentation() { + let parse = ConfigVariableContent.json().parse + #expect(parse?("{\"value\":\"hello\"}") == .string("{\"value\":\"hello\"}")) + } + + + @Test + func jsonParseIsNilForDataRepresentation() { + let parse = ConfigVariableContent.json(representation: .data).parse + #expect(parse == nil) + } + + + // MARK: - Validate + + @Test + func primitiveValidateIsNil() { + #expect(ConfigVariableContent.bool.validate == nil) + #expect(ConfigVariableContent.int.validate == nil) + #expect(ConfigVariableContent.float64.validate == nil) + #expect(ConfigVariableContent.string.validate == nil) + #expect(ConfigVariableContent<[Bool]>.boolArray.validate == nil) + #expect(ConfigVariableContent<[Int]>.intArray.validate == nil) + #expect(ConfigVariableContent<[Float64]>.float64Array.validate == nil) + #expect(ConfigVariableContent<[String]>.stringArray.validate == nil) + } + + + @Test + func rawRepresentableStringValidateReturnsTrueForValidRawValue() { + let validate = ConfigVariableContent.rawRepresentableString().validate + #expect(validate?(.string("a")) == true) + } -private struct TestCodable: Codable, Sendable { - let value: String + @Test + func rawRepresentableStringValidateReturnsFalseForInvalidRawValue() { + let validate = ConfigVariableContent.rawRepresentableString().validate + #expect(validate?(.string("invalid")) == false) + } + + + @Test + func rawRepresentableStringValidateReturnsFalseForNonStringContent() { + let validate = ConfigVariableContent.rawRepresentableString().validate + #expect(validate?(.int(0)) == false) + } + + + @Test + func rawRepresentableIntValidateReturnsTrueForValidRawValue() { + let validate = ConfigVariableContent.rawRepresentableInt().validate + #expect(validate?(.int(0)) == true) + } + + + @Test + func rawRepresentableIntValidateReturnsFalseForInvalidRawValue() { + let validate = ConfigVariableContent.rawRepresentableInt().validate + #expect(validate?(.int(999)) == false) + } + + + @Test + func rawRepresentableIntValidateReturnsFalseForNonIntContent() { + let validate = ConfigVariableContent.rawRepresentableInt().validate + #expect(validate?(.string("0")) == false) + } + + + @Test + func expressibleByConfigStringValidateReturnsTrueForValidString() { + let validate = ConfigVariableContent.expressibleByConfigString().validate + #expect(validate?(.string("hello")) == true) + } + + + @Test + func expressibleByConfigStringValidateReturnsFalseForNonStringContent() { + let validate = ConfigVariableContent.expressibleByConfigString().validate + #expect(validate?(.int(0)) == false) + } + + + @Test + func expressibleByConfigIntValidateReturnsTrueForValidInt() { + let validate = ConfigVariableContent.expressibleByConfigInt().validate + #expect(validate?(.int(42)) == true) + } + + + @Test + func expressibleByConfigIntValidateReturnsFalseForNonIntContent() { + let validate = ConfigVariableContent.expressibleByConfigInt().validate + #expect(validate?(.string("42")) == false) + } + + + @Test + func rawRepresentableStringArrayValidateReturnsTrueForValidElements() { + let validate = ConfigVariableContent<[MockNonIterableStringEnum]>.rawRepresentableStringArray().validate + #expect(validate?(.stringArray(["a", "b"])) == true) + } + + + @Test + func rawRepresentableStringArrayValidateReturnsFalseForInvalidElement() { + let validate = ConfigVariableContent<[MockNonIterableStringEnum]>.rawRepresentableStringArray().validate + #expect(validate?(.stringArray(["a", "invalid"])) == false) + } + + + @Test + func rawRepresentableStringArrayValidateReturnsFalseForNonStringArrayContent() { + let validate = ConfigVariableContent<[MockNonIterableStringEnum]>.rawRepresentableStringArray().validate + #expect(validate?(.string("a")) == false) + } + + + @Test + func rawRepresentableIntArrayValidateReturnsTrueForValidElements() { + let validate = ConfigVariableContent<[MockNonIterableIntEnum]>.rawRepresentableIntArray().validate + #expect(validate?(.intArray([0, 1])) == true) + } + + + @Test + func rawRepresentableIntArrayValidateReturnsFalseForInvalidElement() { + let validate = ConfigVariableContent<[MockNonIterableIntEnum]>.rawRepresentableIntArray().validate + #expect(validate?(.intArray([0, 999])) == false) + } + + + @Test + func rawRepresentableIntArrayValidateReturnsFalseForNonIntArrayContent() { + let validate = ConfigVariableContent<[MockNonIterableIntEnum]>.rawRepresentableIntArray().validate + #expect(validate?(.int(0)) == false) + } + + + @Test + func expressibleByConfigStringArrayValidateReturnsTrueForValidElements() { + let validate = ConfigVariableContent<[MockConfigStringValue]>.expressibleByConfigStringArray().validate + #expect(validate?(.stringArray(["x", "y"])) == true) + } + + + @Test + func expressibleByConfigStringArrayValidateReturnsFalseForNonStringArrayContent() { + let validate = ConfigVariableContent<[MockConfigStringValue]>.expressibleByConfigStringArray().validate + #expect(validate?(.string("x")) == false) + } + + + @Test + func expressibleByConfigIntArrayValidateReturnsTrueForValidElements() { + let validate = ConfigVariableContent<[MockConfigIntValue]>.expressibleByConfigIntArray().validate + #expect(validate?(.intArray([10, 20])) == true) + } + + + @Test + func expressibleByConfigIntArrayValidateReturnsFalseForNonIntArrayContent() { + let validate = ConfigVariableContent<[MockConfigIntValue]>.expressibleByConfigIntArray().validate + #expect(validate?(.int(10)) == false) + } + + + @Test + func codableValidateReturnsTrueForValidJSON() { + let validate = ConfigVariableContent.json().validate + #expect(validate?(.string("{\"value\":\"hello\"}")) == true) + } + + + @Test + func codableValidateReturnsFalseForInvalidJSON() { + let validate = ConfigVariableContent.json().validate + #expect(validate?(.string("not json")) == false) + } + + + @Test + func codableValidateReturnsFalseForNonStringContent() { + let validate = ConfigVariableContent.json().validate + #expect(validate?(.int(0)) == false) + } + + + // MARK: - Updated Editor Controls + + @Test + func boolArrayEditorControlIsTextEditor() { + #expect(ConfigVariableContent<[Bool]>.boolArray.editorControl == .textEditor) + } + + + @Test + func float64ArrayEditorControlIsTextEditor() { + #expect(ConfigVariableContent<[Float64]>.float64Array.editorControl == .textEditor) + } + + + @Test + func intArrayEditorControlIsTextEditor() { + #expect(ConfigVariableContent<[Int]>.intArray.editorControl == .textEditor) + } + + + @Test + func stringArrayEditorControlIsTextEditor() { + #expect(ConfigVariableContent<[String]>.stringArray.editorControl == .textEditor) + } + + + @Test + func rawRepresentableStringArrayEditorControlIsTextEditor() { + let content = ConfigVariableContent<[MockNonIterableStringEnum]>.rawRepresentableStringArray() + #expect(content.editorControl == .textEditor) + } + + + @Test + func rawRepresentableIntArrayEditorControlIsTextEditor() { + let content = ConfigVariableContent<[MockNonIterableIntEnum]>.rawRepresentableIntArray() + #expect(content.editorControl == .textEditor) + } + + + @Test + func expressibleByConfigStringArrayEditorControlIsTextEditor() { + let content = ConfigVariableContent<[MockConfigStringValue]>.expressibleByConfigStringArray() + #expect(content.editorControl == .textEditor) + } + + + @Test + func expressibleByConfigIntArrayEditorControlIsTextEditor() { + let content = ConfigVariableContent<[MockConfigIntValue]>.expressibleByConfigIntArray() + #expect(content.editorControl == .textEditor) + } + + + @Test + func jsonEditorControlIsTextEditorForStringRepresentation() { + let content = ConfigVariableContent.json() + #expect(content.editorControl == .textEditor) + } + + + @Test + func jsonEditorControlIsNilForDataRepresentation() { + let content = ConfigVariableContent.json(representation: .data) + #expect(content.editorControl == nil) + } + + + @Test + func rawRepresentableCaseIterableStringEditorControlIsPicker() { + let content = ConfigVariableContent.rawRepresentableCaseIterableString() + #expect(content.editorControl?.pickerOptions != nil) + } + + + @Test + func rawRepresentableCaseIterableIntEditorControlIsPicker() { + let content = ConfigVariableContent.rawRepresentableCaseIterableInt() + #expect(content.editorControl?.pickerOptions != nil) + } + + + @Test + func caseIterableStringPickerParseAndValidateAreNil() { + let content = ConfigVariableContent.rawRepresentableCaseIterableString() + #expect(content.parse == nil) + #expect(content.validate == nil) + } + + + @Test + func caseIterableIntPickerParseAndValidateAreNil() { + let content = ConfigVariableContent.rawRepresentableCaseIterableInt() + #expect(content.parse == nil) + #expect(content.validate == nil) + } } diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRawRepresentableTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRawRepresentableTests.swift index d3c666d..b79f0a2 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRawRepresentableTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRawRepresentableTests.swift @@ -42,9 +42,9 @@ struct ConfigVariableReaderRawRepresentableTests: RandomValueGenerating { mutating func valueForRawRepresentableStringReturnsProviderValue() { // set up let key = randomConfigKey() - let expectedValue = randomCase(of: MockStringEnum.self)! - var defaultValue: MockStringEnum - repeat { defaultValue = randomCase(of: MockStringEnum.self)! } while defaultValue == expectedValue + let expectedValue = randomNonIterableStringEnum() + var defaultValue: MockNonIterableStringEnum + repeat { defaultValue = randomNonIterableStringEnum() } while defaultValue == expectedValue let variable = ConfigVariable(key: key, defaultValue: defaultValue) setProviderValue(.string(expectedValue.rawValue), forKey: key) @@ -60,7 +60,7 @@ struct ConfigVariableReaderRawRepresentableTests: RandomValueGenerating { mutating func valueForRawRepresentableStringReturnsDefaultWhenKeyNotFound() { // set up let key = randomConfigKey() - let defaultValue = randomCase(of: MockStringEnum.self)! + let defaultValue = randomNonIterableStringEnum() let variable = ConfigVariable(key: key, defaultValue: defaultValue) // exercise @@ -75,9 +75,9 @@ struct ConfigVariableReaderRawRepresentableTests: RandomValueGenerating { mutating func fetchValueForRawRepresentableStringReturnsProviderValue() async throws { // set up let key = randomConfigKey() - let expectedValue = randomCase(of: MockStringEnum.self)! - var defaultValue: MockStringEnum - repeat { defaultValue = randomCase(of: MockStringEnum.self)! } while defaultValue == expectedValue + let expectedValue = randomNonIterableStringEnum() + var defaultValue: MockNonIterableStringEnum + repeat { defaultValue = randomNonIterableStringEnum() } while defaultValue == expectedValue let variable = ConfigVariable(key: key, defaultValue: defaultValue) setProviderValue(.string(expectedValue.rawValue), forKey: key) @@ -93,7 +93,7 @@ struct ConfigVariableReaderRawRepresentableTests: RandomValueGenerating { mutating func fetchValueForRawRepresentableStringReturnsDefaultWhenKeyNotFound() async throws { // set up let key = randomConfigKey() - let defaultValue = randomCase(of: MockStringEnum.self)! + let defaultValue = randomNonIterableStringEnum() let variable = ConfigVariable(key: key, defaultValue: defaultValue) // exercise @@ -108,11 +108,11 @@ struct ConfigVariableReaderRawRepresentableTests: RandomValueGenerating { mutating func watchValueForRawRepresentableStringReceivesUpdates() async throws { // set up let key = randomConfigKey() - let initialValue = randomCase(of: MockStringEnum.self)! - var differentValue: MockStringEnum - repeat { differentValue = randomCase(of: MockStringEnum.self)! } while differentValue == initialValue + let initialValue = randomNonIterableStringEnum() + var differentValue: MockNonIterableStringEnum + repeat { differentValue = randomNonIterableStringEnum() } while differentValue == initialValue let updatedValue = differentValue - let defaultValue = randomCase(of: MockStringEnum.self)! + let defaultValue = randomNonIterableStringEnum() let isSecret = randomBool() let provider = provider let variable = ConfigVariable(key: key, defaultValue: defaultValue) @@ -140,9 +140,9 @@ struct ConfigVariableReaderRawRepresentableTests: RandomValueGenerating { mutating func subscriptRawRepresentableStringReturnsProviderValue() { // set up let key = randomConfigKey() - let expectedValue = randomCase(of: MockStringEnum.self)! - var defaultValue: MockStringEnum - repeat { defaultValue = randomCase(of: MockStringEnum.self)! } while defaultValue == expectedValue + let expectedValue = randomNonIterableStringEnum() + var defaultValue: MockNonIterableStringEnum + repeat { defaultValue = randomNonIterableStringEnum() } while defaultValue == expectedValue let variable = ConfigVariable(key: key, defaultValue: defaultValue) setProviderValue(.string(expectedValue.rawValue), forKey: key) @@ -226,9 +226,9 @@ struct ConfigVariableReaderRawRepresentableTests: RandomValueGenerating { mutating func valueForRawRepresentableIntReturnsProviderValue() { // set up let key = randomConfigKey() - let expectedValue = randomCase(of: MockIntEnum.self)! - var defaultValue: MockIntEnum - repeat { defaultValue = randomCase(of: MockIntEnum.self)! } while defaultValue == expectedValue + let expectedValue = randomNonIterableIntEnum() + var defaultValue: MockNonIterableIntEnum + repeat { defaultValue = randomNonIterableIntEnum() } while defaultValue == expectedValue let variable = ConfigVariable(key: key, defaultValue: defaultValue) setProviderValue(.int(expectedValue.rawValue), forKey: key) @@ -244,7 +244,7 @@ struct ConfigVariableReaderRawRepresentableTests: RandomValueGenerating { mutating func valueForRawRepresentableIntReturnsDefaultWhenKeyNotFound() { // set up let key = randomConfigKey() - let defaultValue = randomCase(of: MockIntEnum.self)! + let defaultValue = randomNonIterableIntEnum() let variable = ConfigVariable(key: key, defaultValue: defaultValue) // exercise @@ -259,9 +259,9 @@ struct ConfigVariableReaderRawRepresentableTests: RandomValueGenerating { mutating func fetchValueForRawRepresentableIntReturnsProviderValue() async throws { // set up let key = randomConfigKey() - let expectedValue = randomCase(of: MockIntEnum.self)! - var defaultValue: MockIntEnum - repeat { defaultValue = randomCase(of: MockIntEnum.self)! } while defaultValue == expectedValue + let expectedValue = randomNonIterableIntEnum() + var defaultValue: MockNonIterableIntEnum + repeat { defaultValue = randomNonIterableIntEnum() } while defaultValue == expectedValue let variable = ConfigVariable(key: key, defaultValue: defaultValue) setProviderValue(.int(expectedValue.rawValue), forKey: key) @@ -277,7 +277,7 @@ struct ConfigVariableReaderRawRepresentableTests: RandomValueGenerating { mutating func fetchValueForRawRepresentableIntReturnsDefaultWhenKeyNotFound() async throws { // set up let key = randomConfigKey() - let defaultValue = randomCase(of: MockIntEnum.self)! + let defaultValue = randomNonIterableIntEnum() let variable = ConfigVariable(key: key, defaultValue: defaultValue) // exercise @@ -292,11 +292,11 @@ struct ConfigVariableReaderRawRepresentableTests: RandomValueGenerating { mutating func watchValueForRawRepresentableIntReceivesUpdates() async throws { // set up let key = randomConfigKey() - let initialValue = randomCase(of: MockIntEnum.self)! - var differentValue: MockIntEnum - repeat { differentValue = randomCase(of: MockIntEnum.self)! } while differentValue == initialValue + let initialValue = randomNonIterableIntEnum() + var differentValue: MockNonIterableIntEnum + repeat { differentValue = randomNonIterableIntEnum() } while differentValue == initialValue let updatedValue = differentValue - let defaultValue = randomCase(of: MockIntEnum.self)! + let defaultValue = randomNonIterableIntEnum() let isSecret = randomBool() let provider = provider let variable = ConfigVariable(key: key, defaultValue: defaultValue) @@ -324,9 +324,9 @@ struct ConfigVariableReaderRawRepresentableTests: RandomValueGenerating { mutating func subscriptRawRepresentableIntReturnsProviderValue() { // set up let key = randomConfigKey() - let expectedValue = randomCase(of: MockIntEnum.self)! - var defaultValue: MockIntEnum - repeat { defaultValue = randomCase(of: MockIntEnum.self)! } while defaultValue == expectedValue + let expectedValue = randomNonIterableIntEnum() + var defaultValue: MockNonIterableIntEnum + repeat { defaultValue = randomNonIterableIntEnum() } while defaultValue == expectedValue let variable = ConfigVariable(key: key, defaultValue: defaultValue) setProviderValue(.int(expectedValue.rawValue), forKey: key) diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift index 7295ff4..2855356 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/ConfigVariableReaderRegistrationTests.swift @@ -67,6 +67,98 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { } + @Test + mutating func registerExpressibleByConfigStringVariableUsesCorrectContent() throws { + // set up + let reader = ConfigVariableReader(namedProviders: [.init(InMemoryProvider(values: [:]))], eventBus: EventBus()) + let key = randomConfigKey() + let variable = ConfigVariable(key: key, defaultValue: MockConfigStringValue(configString: "test")!) + + // exercise + reader.register(variable) + + // expect + let registered = try #require(reader.registeredVariables[key]) + #expect(registered.defaultContent == .string("test")) + #expect(registered.editorControl == .textField) + #expect(registered.validate != nil) + } + + + @Test + mutating func registerExpressibleByConfigIntVariableUsesCorrectContent() throws { + // set up + let reader = ConfigVariableReader(namedProviders: [.init(InMemoryProvider(values: [:]))], eventBus: EventBus()) + let key = randomConfigKey() + let variable = ConfigVariable(key: key, defaultValue: MockConfigIntValue(configInt: 42)!) + + // exercise + reader.register(variable) + + // expect + let registered = try #require(reader.registeredVariables[key]) + #expect(registered.defaultContent == .int(42)) + #expect(registered.editorControl == .numberField) + #expect(registered.validate != nil) + } + + + @Test + mutating func registerCaseIterableStringVariableUsesPickerControl() throws { + // set up + let reader = ConfigVariableReader(namedProviders: [.init(InMemoryProvider(values: [:]))], eventBus: EventBus()) + let key = randomConfigKey() + let variable = ConfigVariable(key: key, defaultValue: MockStringEnum.alpha) + + // exercise + reader.register(variable) + + // expect + let registered = try #require(reader.registeredVariables[key]) + #expect(registered.defaultContent == .string("alpha")) + #expect(registered.editorControl?.pickerOptions != nil) + #expect(registered.parse == nil) + #expect(registered.validate == nil) + } + + + @Test + mutating func registerCaseIterableIntVariableUsesPickerControl() throws { + // set up + let reader = ConfigVariableReader(namedProviders: [.init(InMemoryProvider(values: [:]))], eventBus: EventBus()) + let key = randomConfigKey() + let variable = ConfigVariable(key: key, defaultValue: MockIntEnum.one) + + // exercise + reader.register(variable) + + // expect + let registered = try #require(reader.registeredVariables[key]) + #expect(registered.defaultContent == .int(1)) + #expect(registered.editorControl?.pickerOptions != nil) + #expect(registered.parse == nil) + #expect(registered.validate == nil) + } + + + @Test + mutating func registerCapturesValidateForRawRepresentableStringVariable() throws { + // set up + let reader = ConfigVariableReader(namedProviders: [.init(InMemoryProvider(values: [:]))], eventBus: EventBus()) + let key = randomConfigKey() + let variable = ConfigVariable(key: key, defaultValue: MockNonIterableStringEnum.a) + + // exercise + reader.register(variable) + + // expect validate is non-nil and works correctly + let registered = try #require(reader.registeredVariables[key]) + let validate = try #require(registered.validate) + #expect(validate(.string("a"))) + #expect(!validate(.string("invalid"))) + } + + #if os(macOS) @Test func registerDuplicateKeyHalts() async { @@ -105,7 +197,8 @@ struct ConfigVariableReaderRegistrationTests: RandomValueGenerating { ) }, editorControl: .none, - parse: nil + parse: nil, + validate: nil ) ) diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/EditorControlTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/EditorControlTests.swift new file mode 100644 index 0000000..bc2d3e7 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Core/EditorControlTests.swift @@ -0,0 +1,44 @@ +// +// EditorControlTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/11/2026. +// + +import Configuration +import Testing + +@testable import DevConfiguration + +struct EditorControlTests { + // MARK: - pickerOptions + + @Test + func pickerOptionsReturnsOptionsForPicker() { + // set up + let options: [EditorControl.PickerOption] = [ + .init(label: "On", content: .bool(true)), + .init(label: "Off", content: .bool(false)), + ] + + // exercise + let result = EditorControl.picker(options: options).pickerOptions + + // expect + #expect(result == options) + } + + + @Test( + arguments: [ + EditorControl.toggle, + .textField, + .numberField, + .decimalField, + .textEditor, + ] + ) + func pickerOptionsReturnsNilForNonPickerControls(control: EditorControl) { + #expect(control.pickerOptions == nil) + } +} diff --git a/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift b/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift index 08fb9ce..946fd39 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Core/RegisteredConfigVariableTests.swift @@ -29,7 +29,8 @@ struct RegisteredConfigVariableTests: RandomValueGenerating { metadata: metadata, destinationTypeName: randomAlphanumericString(), editorControl: .none, - parse: nil + parse: nil, + validate: nil ) // expect @@ -72,7 +73,8 @@ struct RegisteredConfigVariableTests: RandomValueGenerating { metadata: ConfigVariableMetadata(), destinationTypeName: input, editorControl: .none, - parse: nil + parse: nil, + validate: nil ) // expect @@ -90,7 +92,8 @@ struct RegisteredConfigVariableTests: RandomValueGenerating { metadata: ConfigVariableMetadata(), destinationTypeName: randomAlphanumericString(), editorControl: .none, - parse: nil + parse: nil, + validate: nil ) // expect diff --git a/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift index 9867905..ba7ea2d 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Editor/Config Variable Detail/ConfigVariableDetailViewModelTests.swift @@ -377,4 +377,204 @@ struct ConfigVariableDetailViewModelTests: RandomValueGenerating { // expect no override is set #expect(!document.hasOverride(forKey: variable.key)) } + + + @Test + mutating func commitOverrideTextDoesNothingWhenValidateFails() { + // set up with a parse that succeeds but a validate that always fails + let variable = randomRegisteredVariable( + defaultContent: .string(randomAlphanumericString()), + editorControl: .textField, + parse: { .string($0) }, + validate: { _ in false } + ) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + viewModel.overrideText = randomAlphanumericString() + + // exercise + viewModel.commitOverrideText() + + // expect no override is set + #expect(!document.hasOverride(forKey: variable.key)) + } + + + @Test + mutating func commitOverrideTextSetsOverrideWhenValidateSucceeds() { + // set up with both parse and validate succeeding + let variable = randomRegisteredVariable( + defaultContent: .string(randomAlphanumericString()), + editorControl: .textField, + parse: { .string($0) }, + validate: { _ in true } + ) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + let inputText = randomAlphanumericString() + viewModel.overrideText = inputText + + // exercise + viewModel.commitOverrideText() + + // expect the parsed content is set as an override + #expect(document.override(forKey: variable.key) == .string(inputText)) + } + + + // MARK: - isOverrideTextValid + + @Test + mutating func isOverrideTextValidReturnsTrueWhenParseIsNil() { + // set up with no parse function + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // exercise and expect + #expect(viewModel.isOverrideTextValid) + } + + + @Test + mutating func isOverrideTextValidReturnsTrueWhenParseSucceedsAndValidateIsNil() { + // set up with parse that succeeds and no validate + let variable = randomRegisteredVariable( + defaultContent: .string(randomAlphanumericString()), + editorControl: .textField, + parse: { .string($0) } + ) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + viewModel.overrideText = randomAlphanumericString() + + // exercise and expect + #expect(viewModel.isOverrideTextValid) + } + + + @Test + mutating func isOverrideTextValidReturnsTrueWhenParseAndValidateSucceed() { + // set up with both parse and validate succeeding + let variable = randomRegisteredVariable( + defaultContent: .string(randomAlphanumericString()), + editorControl: .textField, + parse: { .string($0) }, + validate: { _ in true } + ) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + viewModel.overrideText = randomAlphanumericString() + + // exercise and expect + #expect(viewModel.isOverrideTextValid) + } + + + @Test + mutating func isOverrideTextValidReturnsFalseWhenParseFails() { + // set up with parse that always fails + let variable = randomRegisteredVariable( + defaultContent: .string(randomAlphanumericString()), + editorControl: .textField, + parse: { _ in nil } + ) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + viewModel.overrideText = randomAlphanumericString() + + // exercise and expect + #expect(!viewModel.isOverrideTextValid) + } + + + @Test + mutating func isOverrideTextValidReturnsFalseWhenParseSucceedsButValidateFails() { + // set up with parse succeeding but validate failing + let variable = randomRegisteredVariable( + defaultContent: .string(randomAlphanumericString()), + editorControl: .textField, + parse: { .string($0) }, + validate: { _ in false } + ) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + viewModel.overrideText = randomAlphanumericString() + + // exercise and expect + #expect(!viewModel.isOverrideTextValid) + } + + + // MARK: - overridePickerSelection + + @Test + mutating func overridePickerSelectionReturnsOverrideWhenSet() { + // set up with an override + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + + let overrideContent = ConfigContent.string(randomAlphanumericString()) + document.setOverride(overrideContent, forKey: variable.key) + + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // exercise and expect + #expect(viewModel.overridePickerSelection == overrideContent) + } + + + @Test + mutating func overridePickerSelectionReturnsDefaultContentWhenNoOverride() { + // set up with no override + let defaultContent = ConfigContent.string(randomAlphanumericString()) + let variable = randomRegisteredVariable(defaultContent: defaultContent) + let document = makeDocument(registeredVariables: [variable]) + + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // exercise and expect + #expect(viewModel.overridePickerSelection == defaultContent) + } + + + @Test + mutating func settingOverridePickerSelectionSetsDocumentOverride() { + // set up + let variable = randomRegisteredVariable(defaultContent: .string(randomAlphanumericString())) + let document = makeDocument(registeredVariables: [variable]) + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + let newContent = ConfigContent.string(randomAlphanumericString()) + + // exercise + viewModel.overridePickerSelection = newContent + + // expect the document has the new override + #expect(document.override(forKey: variable.key) == newContent) + } + + + // MARK: - editableString + + @Test + mutating func initUsesEditableStringForOverrideTextWithArrayContent() { + // set up with an array override to verify editableString (newline-separated) is used + let arrayContent = ConfigContent.stringArray(["one", "two", "three"]) + let variable = randomRegisteredVariable(defaultContent: arrayContent) + let document = makeDocument(registeredVariables: [variable]) + document.setOverride(arrayContent, forKey: variable.key) + + // exercise + let viewModel = makeViewModel(document: document, registeredVariable: variable) + + // expect override text uses editableString (newline-separated), not displayString + #expect(viewModel.overrideText == "one\ntwo\nthree") + } } diff --git a/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContent+AdditionsTests.swift b/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContent+AdditionsTests.swift index c484f69..d534682 100644 --- a/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContent+AdditionsTests.swift +++ b/Tests/DevConfigurationTests/Unit Tests/Extensions/ConfigContent+AdditionsTests.swift @@ -78,6 +78,55 @@ struct ConfigContent_AdditionsTests: RandomValueGenerating { } + // MARK: - editableString + + @Test( + arguments: [ + ConfigContent.bool(true), + .int(42), + .double(3.14), + .string("hello"), + .bytes([1, 2, 3]), + ] + ) + func editableStringMatchesDisplayStringForScalars(content: ConfigContent) { + #expect(content.editableString == content.displayString) + } + + + @Test + func editableStringReturnsByteChunkArrayAsDisplayString() { + let content = ConfigContent.byteChunkArray([[1, 2], [3, 4]]) + #expect(content.editableString == content.displayString) + } + + + @Test + func editableStringReturnsNewlineSeparatedBoolArray() { + #expect(ConfigContent.boolArray([true, false, true]).editableString == "true\nfalse\ntrue") + } + + + @Test + func editableStringReturnsNewlineSeparatedIntArray() { + #expect(ConfigContent.intArray([1, 2, 3]).editableString == "1\n2\n3") + } + + + @Test + func editableStringReturnsNewlineSeparatedDoubleArray() { + #expect(ConfigContent.doubleArray([1.5, 2.5]).editableString == "1.5\n2.5") + } + + + @Test + func editableStringReturnsNewlineSeparatedStringArray() { + #expect(ConfigContent.stringArray(["a", "b", "c"]).editableString == "a\nb\nc") + } + + + // MARK: - Codable + @Test func decodingUnknownTypeThrows() throws { // set up diff --git a/Tests/DevConfigurationTests/Unit Tests/Extensions/String+NonEmptyTrimmedLinesTests.swift b/Tests/DevConfigurationTests/Unit Tests/Extensions/String+NonEmptyTrimmedLinesTests.swift new file mode 100644 index 0000000..b6044c0 --- /dev/null +++ b/Tests/DevConfigurationTests/Unit Tests/Extensions/String+NonEmptyTrimmedLinesTests.swift @@ -0,0 +1,42 @@ +// +// String+NonEmptyTrimmedLinesTests.swift +// DevConfiguration +// +// Created by Prachi Gauriar on 3/11/2026. +// + +import Foundation +import Testing + +@testable import DevConfiguration + +struct String_NonEmptyTrimmedLinesTests { + @Test + func emptyStringReturnsEmptyArray() { + #expect("".nonEmptyTrimmedLines == []) + } + + + @Test + func singleLineReturnsTrimmedElement() { + #expect(" hello ".nonEmptyTrimmedLines == ["hello"]) + } + + + @Test + func multipleLinesReturnsTrimmedElements() { + #expect("one\ntwo\nthree".nonEmptyTrimmedLines == ["one", "two", "three"]) + } + + + @Test + func blankLinesAreFilteredOut() { + #expect("one\n\n \ntwo".nonEmptyTrimmedLines == ["one", "two"]) + } + + + @Test + func leadingAndTrailingWhitespaceOnLinesIsTrimmed() { + #expect(" alpha \n beta ".nonEmptyTrimmedLines == ["alpha", "beta"]) + } +}