Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
16 changes: 5 additions & 11 deletions Scripts/test-all-platforms
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ struct OffsetDateProvider<Base>: DateProvider where Base: DateProvider {

extension OffsetDateProvider: CustomStringConvertible {
public var description: String {
return "\(base).offset(by: \(offset.timeInterval))"
return "\(base).offset(by: \(offset))"
}
}

Expand Down
78 changes: 78 additions & 0 deletions Sources/DevFoundation/Utility Types/UserSelection.swift
Original file line number Diff line number Diff line change
@@ -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<Value> {
/// 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 {}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand All @@ -58,7 +58,7 @@ struct ContextualBusEventObserverTests: RandomValueGenerating {
string: randomAlphanumericString()
)
)
try await Task.sleep(for: .seconds(1))
try await Task.sleep(for: .milliseconds(500))
}
}

Expand All @@ -68,18 +68,20 @@ struct ContextualBusEventObserverTests: RandomValueGenerating {
let expectedString = randomAlphanumericString()
let handlerCount = randomInt(in: 3 ... 5)

let (signalStream, signaler) = AsyncStream<Void>.makeStream()
try await confirmation("handlers are called", expectedCount: handlerCount) { (didCallHandler) in
for i in 0 ..< handlerCount {
observer.addHandler(for: MockBusEvent.self) { (event, context) in
#expect(event.string == expectedString)
#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) {}
}
}

Expand All @@ -90,6 +92,7 @@ struct ContextualBusEventObserverTests: RandomValueGenerating {
let expectedString = randomAlphanumericString()
let handlerCount = randomInt(in: 3 ... 5)

let (signalStream, signaler) = AsyncStream<Void>.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
Expand All @@ -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
Expand All @@ -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) {}
}
}

Expand All @@ -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))
}
}

Expand All @@ -148,7 +153,7 @@ struct ContextualBusEventObserverTests: RandomValueGenerating {
string: randomAlphanumericString()
)
)
try await Task.sleep(for: .seconds(1))
try await Task.sleep(for: .milliseconds(500))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 responders data since first doesnt match
#expect(actualData == data2)
let httpResponse = try #require(response as? HTTPURLResponse)
#expect(httpResponse.httpStatusCode == statusCode2)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Tests/DevFoundationTests/Paging/SequentialPagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions Tests/DevFoundationTests/Utility Types/UserSelectionTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}