From 48a2a02ca39c148fa5e4ba5cce19bc006cd8f3f0 Mon Sep 17 00:00:00 2001 From: Prachi Gauriar Date: Wed, 22 Oct 2025 21:29:36 -0400 Subject: [PATCH] Add UserSelection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add the UserSelection type, a generic structure that manages a user’s selection with a fallback to a default value. This type prioritizes explicit user choices over programmatic defaults while maintaining separate tracking of both values, ensuring user preferences are never accidentally overwritten by programmatic updates to defaults. - Update to DevTesting 1.5.0 - Eliminate some uses of Task.sleep calls in tests to improve test reliability - Updates OffsetDateProvider.description to output its duration instead of its duration’s time interval. This should eliminate some flakiness in tests. - Updates the test-all-platforms script to fail fast instead of running all platforms and reporting failures at the end. --- CHANGELOG.md | 8 ++ Package.resolved | 6 +- Package.swift | 2 +- Scripts/test-all-platforms | 16 ++-- .../Date Providers/OffsetDateProvider.swift | 2 +- .../Utility Types/UserSelection.swift | 78 +++++++++++++++++++ .../Concurrency/WithTimeoutTests.swift | 2 +- .../OffsetDateProviderTests.swift | 6 +- .../ContextualBusEventObserverTests.swift | 17 ++-- .../SimulatedURLRequestLoaderTests.swift | 2 +- .../Paging/RandomAccessPagerTests.swift | 2 +- .../Paging/SequentialPagerTests.swift | 2 +- .../Utility Types/UserSelectionTests.swift | 38 +++++++++ 13 files changed, 152 insertions(+), 29 deletions(-) create mode 100644 Sources/DevFoundation/Utility Types/UserSelection.swift create mode 100644 Tests/DevFoundationTests/Utility Types/UserSelectionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index dffa37b..e4a27e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # DevFoundation Changelog +## 1.5.0: October 22, 2025 + +This release adds the `UserSelection` type, a generic structure that manages a user’s selection with +a fallback to a default value. This type prioritizes explicit user choices over programmatic +defaults while maintaining separate tracking of both values, ensuring user preferences are never +accidentally overwritten by programmatic updates to defaults. + + ## 1.4.0: October 8, 2025 - We’ve added `Duration`-based alternatives to all APIs that take a `TimeInterval`. Specifically, diff --git a/Package.resolved b/Package.resolved index fe248f5..84d99b6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "b836221e34ec0f45fe905f2894101fdf79be95fd47c8af481a0b62425b316150", + "originHash" : "8c9c5f6252f63613cd2bd94e26edc1e8a576e4dfda3f1bbdae3b4db9ccf67968", "pins" : [ { "identity" : "devtesting", "kind" : "remoteSourceControl", "location" : "https://github.com/DevKitOrganization/DevTesting", "state" : { - "revision" : "b1e7ff0dd4b4f850fbbf886e192d91771ec1599d", - "version" : "1.3.0" + "revision" : "9dea13f09c19c0521e9ff7b9f14fedb977423b99", + "version" : "1.5.0" } }, { diff --git a/Package.swift b/Package.swift index a342cde..36b18d0 100644 --- a/Package.swift +++ b/Package.swift @@ -32,7 +32,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.6.1"), .package(url: "https://github.com/apple/swift-numerics.git", from: "1.1.0"), - .package(url: "https://github.com/DevKitOrganization/DevTesting", from: "1.3.0"), + .package(url: "https://github.com/DevKitOrganization/DevTesting", from: "1.5.0"), .package(url: "https://github.com/prachigauriar/URLMock.git", from: "1.3.6"), ], targets: [ diff --git a/Scripts/test-all-platforms b/Scripts/test-all-platforms index 4539d6b..ae40af3 100755 --- a/Scripts/test-all-platforms +++ b/Scripts/test-all-platforms @@ -23,14 +23,13 @@ print_error() { # Platforms to test PLATFORMS=( - "iOS Simulator,name=iPhone 16 Pro" + "iOS Simulator,name=iPhone 17 Pro" "macOS" "tvOS Simulator,name=Apple TV 4K" "watchOS Simulator,name=Apple Watch Series 10" ) SCHEME="DevFoundation-Package" -FAILED_PLATFORMS=() print_status "Starting tests on all platforms..." echo @@ -41,19 +40,14 @@ for platform in "${PLATFORMS[@]}"; do if xcodebuild test -scheme "$SCHEME" -destination "platform=$platform"; then print_success "$platform_name tests passed" + echo else print_error "$platform_name tests failed" - FAILED_PLATFORMS+=("$platform_name") + exit 1 fi - echo done # Summary echo "==========================" -if [ ${#FAILED_PLATFORMS[@]} -eq 0 ]; then - print_success "All platform tests passed!" - exit 0 -else - print_error "Tests failed on: ${FAILED_PLATFORMS[*]}" - exit 1 -fi +print_success "All platform tests passed!" +exit 0 diff --git a/Sources/DevFoundation/Date Providers/OffsetDateProvider.swift b/Sources/DevFoundation/Date Providers/OffsetDateProvider.swift index a21dde1..d984e0b 100644 --- a/Sources/DevFoundation/Date Providers/OffsetDateProvider.swift +++ b/Sources/DevFoundation/Date Providers/OffsetDateProvider.swift @@ -35,7 +35,7 @@ struct OffsetDateProvider: DateProvider where Base: DateProvider { extension OffsetDateProvider: CustomStringConvertible { public var description: String { - return "\(base).offset(by: \(offset.timeInterval))" + return "\(base).offset(by: \(offset))" } } diff --git a/Sources/DevFoundation/Utility Types/UserSelection.swift b/Sources/DevFoundation/Utility Types/UserSelection.swift new file mode 100644 index 0000000..f118679 --- /dev/null +++ b/Sources/DevFoundation/Utility Types/UserSelection.swift @@ -0,0 +1,78 @@ +// +// UserSelection.swift +// DevFoundation +// +// Created by Prachi Gauriar on 10/22/25. +// + +import Foundation + +/// A generic structure that manages a user’s selection with a fallback to a default value. +/// +/// `UserSelection` prioritizes explicit user choices over programmatic defaults while maintaining separate tracking +/// of both values. When a user has made an explicit selection, that choice is always preserved and used, regardless +/// of changes to the default value. This ensures user preferences are never accidentally overwritten by programmatic +/// updates to defaults. +/// +/// For example, +/// +/// // Programmatically set an initial default theme +/// var themeSelection = UserSelection(defaultValue: "light") +/// print(themeSelection.value) // "light" +/// +/// // User explicitly selects a theme +/// themeSelection.selectedValue = "dark" +/// print(themeSelection.value) // "dark" +/// +/// // Programmatically update the default theme preference +/// themeSelection.defaultValue = "auto" +/// print(themeSelection.value) // Still "dark" - user’s choice is preserved +/// +/// // User clears their selection to use programmatic default +/// themeSelection.selectedValue = nil +/// print(themeSelection.value) // "auto" - now uses updated programmatic default +/// +/// This pattern is particularly useful when you need to distinguish between user-specified values and +/// programmatically-determined defaults that may change over time, ensuring explicit user choices are never overwritten +/// by updated defaults. +/// +/// ## Protocol Conformance +/// +/// `UserSelection` conditionally conforms to `Codable`, `Equatable`, `Hashable`, and `Sendable` when the wrapped +/// `Value` type also conforms to these protocols, making it suitable for persistence, comparison, hashing, and +/// concurrent programming scenarios. +public struct UserSelection { + /// The default value to use when no selection has been made. + /// + /// This value is always available and serves as the fallback when `selectedValue` is `nil`. + public var defaultValue: Value + + /// The user’s optional selection. + /// + /// When `nil`, the `value` property will return `defaultValue`. When set to a value, the `value` property will + /// return this selection. + public var selectedValue: Value? + + + /// Creates a new user selection with the specified default value. + /// + /// - Parameter defaultValue: The value to use when no selection has been made. + public init(defaultValue: Value) { + self.defaultValue = defaultValue + } + + + /// The effective value, either the user’s selection or the default value. + /// + /// This computed property returns `selectedValue` if it’s not `nil`, otherwise it returns `defaultValue`. + public var value: Value { + selectedValue ?? defaultValue + } +} + + +extension UserSelection: Decodable where Value: Decodable {} +extension UserSelection: Encodable where Value: Encodable {} +extension UserSelection: Equatable where Value: Equatable {} +extension UserSelection: Hashable where Value: Hashable {} +extension UserSelection: Sendable where Value: Sendable {} diff --git a/Tests/DevFoundationTests/Concurrency/WithTimeoutTests.swift b/Tests/DevFoundationTests/Concurrency/WithTimeoutTests.swift index 8b9437d..34e81ce 100644 --- a/Tests/DevFoundationTests/Concurrency/WithTimeoutTests.swift +++ b/Tests/DevFoundationTests/Concurrency/WithTimeoutTests.swift @@ -71,7 +71,7 @@ struct WithTimeoutTests: RandomValueGenerating { await #expect(throws: CancellationError.self) { try await withTimeout(.milliseconds(10)) { operationStarted() - try await Task.sleep(for: .seconds(1)) + try await Task.sleep(for: .milliseconds(500)) operationFinished() } } diff --git a/Tests/DevFoundationTests/Date Providers/OffsetDateProviderTests.swift b/Tests/DevFoundationTests/Date Providers/OffsetDateProviderTests.swift index 2f1c87f..3f83749 100644 --- a/Tests/DevFoundationTests/Date Providers/OffsetDateProviderTests.swift +++ b/Tests/DevFoundationTests/Date Providers/OffsetDateProviderTests.swift @@ -27,10 +27,10 @@ struct OffsetDateProviderTests: RandomValueGenerating { @Test mutating func descriptionIsCorrect() { - let base = MockDateProvider() - let offset = random(TimeInterval.self, in: -1000 ... 1000) + let base = MockDateProvider(now: randomDate()) + let offset = Duration.milliseconds(randomInt(in: 1 ... 1000)) let offsetProvider = base.offset(by: offset) - + print(offsetProvider) #expect(String(describing: offsetProvider) == "\(String(describing: base)).offset(by: \(offset))") } } diff --git a/Tests/DevFoundationTests/Event Bus/ContextualBusEventObserverTests.swift b/Tests/DevFoundationTests/Event Bus/ContextualBusEventObserverTests.swift index 0091fe2..df10ec6 100644 --- a/Tests/DevFoundationTests/Event Bus/ContextualBusEventObserverTests.swift +++ b/Tests/DevFoundationTests/Event Bus/ContextualBusEventObserverTests.swift @@ -36,7 +36,7 @@ struct ContextualBusEventObserverTests: RandomValueGenerating { } eventBus.post(MockBusEvent(string: randomAlphanumericString())) - try await Task.sleep(for: .seconds(1)) + try await Task.sleep(for: .milliseconds(500)) } } @@ -58,7 +58,7 @@ struct ContextualBusEventObserverTests: RandomValueGenerating { string: randomAlphanumericString() ) ) - try await Task.sleep(for: .seconds(1)) + try await Task.sleep(for: .milliseconds(500)) } } @@ -68,6 +68,7 @@ struct ContextualBusEventObserverTests: RandomValueGenerating { let expectedString = randomAlphanumericString() let handlerCount = randomInt(in: 3 ... 5) + let (signalStream, signaler) = AsyncStream.makeStream() try await confirmation("handlers are called", expectedCount: handlerCount) { (didCallHandler) in for i in 0 ..< handlerCount { observer.addHandler(for: MockBusEvent.self) { (event, context) in @@ -75,11 +76,12 @@ struct ContextualBusEventObserverTests: RandomValueGenerating { #expect(context == i) context += 1 didCallHandler() + signaler.yield() } } eventBus.post(MockBusEvent(string: expectedString)) - try await Task.sleep(for: .seconds(1)) + for try await _ in signalStream.prefix(handlerCount) {} } } @@ -90,6 +92,7 @@ struct ContextualBusEventObserverTests: RandomValueGenerating { let expectedString = randomAlphanumericString() let handlerCount = randomInt(in: 3 ... 5) + let (signalStream, signaler) = AsyncStream.makeStream() try await confirmation("handlers are called", expectedCount: handlerCount * 2) { (didCallHandler) in for i in 0 ..< handlerCount { observer.addHandler(for: MockIdentifiableBusEvent.self) { (event, context) in @@ -100,6 +103,7 @@ struct ContextualBusEventObserverTests: RandomValueGenerating { #expect(context == i) context += 1 didCallHandler() + signaler.yield() } observer.addHandler(for: MockIdentifiableBusEvent.self, id: expectedID) { (event, context) in @@ -110,11 +114,12 @@ struct ContextualBusEventObserverTests: RandomValueGenerating { #expect(context == handlerCount + i) context += 1 didCallHandler() + signaler.yield() } } eventBus.post(MockIdentifiableBusEvent(id: expectedID, string: expectedString)) - try await Task.sleep(for: .seconds(1)) + for try await _ in signalStream.prefix(handlerCount * 2) {} } } @@ -127,7 +132,7 @@ struct ContextualBusEventObserverTests: RandomValueGenerating { } observer.removeHandler(handler) eventBus.post(MockBusEvent(string: randomAlphanumericString())) - try await Task.sleep(for: .seconds(1)) + try await Task.sleep(for: .milliseconds(500)) } } @@ -148,7 +153,7 @@ struct ContextualBusEventObserverTests: RandomValueGenerating { string: randomAlphanumericString() ) ) - try await Task.sleep(for: .seconds(1)) + try await Task.sleep(for: .milliseconds(500)) } } diff --git a/Tests/DevFoundationTests/Networking/Simulated URL Request Loader/SimulatedURLRequestLoaderTests.swift b/Tests/DevFoundationTests/Networking/Simulated URL Request Loader/SimulatedURLRequestLoaderTests.swift index 4a1b6de..b865f3c 100644 --- a/Tests/DevFoundationTests/Networking/Simulated URL Request Loader/SimulatedURLRequestLoaderTests.swift +++ b/Tests/DevFoundationTests/Networking/Simulated URL Request Loader/SimulatedURLRequestLoaderTests.swift @@ -117,7 +117,7 @@ struct SimulatedURLRequestLoaderTests: RandomValueGenerating { let (actualData, response) = try await loader.data(for: urlRequest) - // Should return second responder's data since first doesn't match + // Should return second responder’s data since first doesn’t match #expect(actualData == data2) let httpResponse = try #require(response as? HTTPURLResponse) #expect(httpResponse.httpStatusCode == statusCode2) diff --git a/Tests/DevFoundationTests/Paging/RandomAccessPagerTests.swift b/Tests/DevFoundationTests/Paging/RandomAccessPagerTests.swift index e4ffa18..e3d589a 100644 --- a/Tests/DevFoundationTests/Paging/RandomAccessPagerTests.swift +++ b/Tests/DevFoundationTests/Paging/RandomAccessPagerTests.swift @@ -230,7 +230,7 @@ struct RandomAccessPagerTests: RandomValueGenerating { // Add delay to allow both calls to get past cache check mockLoader.loadPagePrologue = { - try await Task.sleep(for: .seconds(0.5)) + try await Task.sleep(for: .milliseconds(500)) } let pager = RandomAccessPager(pageLoader: mockLoader) diff --git a/Tests/DevFoundationTests/Paging/SequentialPagerTests.swift b/Tests/DevFoundationTests/Paging/SequentialPagerTests.swift index a45b3ad..54f9c29 100644 --- a/Tests/DevFoundationTests/Paging/SequentialPagerTests.swift +++ b/Tests/DevFoundationTests/Paging/SequentialPagerTests.swift @@ -153,7 +153,7 @@ struct SequentialPagerTests: RandomValueGenerating { // Add delay to allow both calls to get past cache check mockLoader.loadPagePrologue = { - try await Task.sleep(for: .seconds(0.5)) + try await Task.sleep(for: .milliseconds(500)) } let pager = SequentialPager(pageLoader: mockLoader) diff --git a/Tests/DevFoundationTests/Utility Types/UserSelectionTests.swift b/Tests/DevFoundationTests/Utility Types/UserSelectionTests.swift new file mode 100644 index 0000000..2add052 --- /dev/null +++ b/Tests/DevFoundationTests/Utility Types/UserSelectionTests.swift @@ -0,0 +1,38 @@ +// +// UserSelectionTests.swift +// DevFoundation +// +// Created by Prachi Gauriar on 10/22/25. +// + +import DevFoundation +import DevTesting +import Foundation +import Testing + +struct UserSelectionTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + @Test + mutating func valueReturnsCorrectValueBasedOnSelection() { + // set up the test by creating values + let defaultValue = randomAlphanumericString() + let selectedValue = randomAlphanumericString() + var userSelection = UserSelection(defaultValue: defaultValue) + + // expect defaultValue is returned when selectedValue is nil + #expect(userSelection.value == defaultValue) + + // exercise the test by setting selectedValue + userSelection.selectedValue = selectedValue + + // expect selectedValue is returned when it is non-nil + #expect(userSelection.value == selectedValue) + + // exercise the test by resetting selectedValue to nil + userSelection.selectedValue = nil + + // expect defaultValue is returned when selectedValue is reset to nil + #expect(userSelection.value == defaultValue) + } +}