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