From c579d70e2bfbecffe6179e640dad1cc0383026d2 Mon Sep 17 00:00:00 2001 From: Andreas Grosam Date: Sat, 6 Dec 2025 14:16:55 +0100 Subject: [PATCH] Add UserDefaultsStoreMock and SwiftUI environment-based store injection Introduces UserDefaultsStoreMock for side-effect-free testing and SwiftUI previews. AppSettingValues now supports configurable store injection via SwiftUI environment, enabling isolated testing without touching UserDefaults.standard. Changes: - Add UserDefaultsStoreMock with OSAllocatedUnfairLock for thread-safety (iOS 17+) - Add @Entry userDefaultsStore environment value for store injection - Add .userDefaultsStore() view modifier for mock store configuration - Relax Attribute+Publisher constraints to support broader store types - Add observer() function to __Settings_Container protocol - Make AppSettingValues configurable via @MainActor-isolated store - Add comprehensive examples and documentation Breaking Changes: None Other Changes: - Add automated release workflow with PR label enforcement --- .github/workflows/pr-label-check.yml | 34 ++ .github/workflows/release.yml | 314 ++++++++++++++++++ CONTRIBUTING.md | 10 +- .../MacroSetting/Attribute+Publisher.swift | 2 +- .../MacroSettings/UserDefaultsStore.swift | 2 + Sources/Settings/SwiftUI/AppSetting.swift | 51 ++- .../UserDefaultsStoreEnvironment.swift | 54 +++ Sources/SettingsClient/main.swift | 31 +- Sources/SettingsMock/UserDefaultsMock.swift | 34 +- .../UserDefaultProxyTest6.swift | 2 +- Tests/SwiftUITests/AppSettingTests.swift | 1 + 11 files changed, 510 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/pr-label-check.yml create mode 100644 .github/workflows/release.yml create mode 100644 Sources/Settings/SwiftUI/UserDefaultsStoreEnvironment.swift diff --git a/.github/workflows/pr-label-check.yml b/.github/workflows/pr-label-check.yml new file mode 100644 index 0000000..28db182 --- /dev/null +++ b/.github/workflows/pr-label-check.yml @@ -0,0 +1,34 @@ +name: PR Label Check + +on: + pull_request: + types: [opened, labeled, unlabeled, synchronize] + +jobs: + check-label: + runs-on: ubuntu-latest + steps: + - name: Check for required label + uses: actions/github-script@v7 + with: + script: | + const labels = context.payload.pull_request.labels.map(label => label.name); + const validLabels = ['breaking', 'enhancement', 'bug', 'documentation', 'not-included-in-release']; + const hasValidLabel = labels.some(label => validLabels.includes(label)); + + if (!hasValidLabel) { + core.setFailed( + '❌ Pull request must have exactly one label: breaking, enhancement, bug, documentation, or not-included-in-release.\n' + + 'This label determines the version bump and changelog category.' + ); + } else { + const matchingLabels = labels.filter(label => validLabels.includes(label)); + if (matchingLabels.length > 1) { + core.setFailed( + `❌ Pull request has multiple category labels: ${matchingLabels.join(', ')}.\n` + + 'Please use only ONE label: breaking, enhancement, bug, documentation, or not-included-in-release.' + ); + } else { + console.log(`✅ Pull request has valid label: ${matchingLabels[0]}`); + } + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2a59d93 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,314 @@ +name: Create Release + +on: + workflow_dispatch: + inputs: + dry-run: + description: 'Dry run (show what would be released without creating it)' + required: false + type: boolean + default: false + +permissions: + contents: write + pull-requests: read + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for proper tag detection + + - name: Get latest tag + id: get-latest-tag + run: | + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -z "$LATEST_TAG" ]; then + echo "No existing tags found, using 0.0.0 as baseline" + LATEST_TAG="0.0.0" + fi + echo "latest-tag=$LATEST_TAG" >> $GITHUB_OUTPUT + echo "Latest tag: $LATEST_TAG" + + - name: Get merged PRs since last tag + id: get-prs + uses: actions/github-script@v7 + with: + script: | + const latestTag = '${{ steps.get-latest-tag.outputs.latest-tag }}'; + + // Get commit of latest tag (or beginning of repo) + let sinceDate; + if (latestTag !== '0.0.0') { + try { + const tagData = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${latestTag}` + }); + + // Handle both lightweight and annotated tags + let commitSha = tagData.data.object.sha; + if (tagData.data.object.type === 'tag') { + // Annotated tag - need to get the tag object first + const tagObject = await github.rest.git.getTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_sha: commitSha + }); + commitSha = tagObject.data.object.sha; + } + + // Now get the commit + const commitData = await github.rest.repos.getCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: commitSha + }); + sinceDate = commitData.data.commit.committer.date; + } catch (error) { + console.log(`Error getting tag ${latestTag}: ${error.message}`); + console.log('Falling back to repository start date'); + // Fallback to first commit + const commits = await github.rest.repos.listCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 1 + }); + sinceDate = commits.data[0].commit.committer.date; + } + } else { + // Get first commit date + const commits = await github.rest.repos.listCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 1 + }); + sinceDate = commits.data[0].commit.committer.date; + } + + // Get all merged PRs since that date + const prs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'closed', + sort: 'updated', + direction: 'desc', + per_page: 100 + }); + + const mergedPRs = prs.data.filter(pr => + pr.merged_at && new Date(pr.merged_at) > new Date(sinceDate) + ); + + // Categorize PRs by label + const breaking = []; + const enhancements = []; + const bugs = []; + const documentation = []; + + for (const pr of mergedPRs) { + const labels = pr.labels.map(l => l.name); + if (labels.includes('not-included-in-release')) continue; + + const entry = `- ${pr.title} (#${pr.number})`; + + if (labels.includes('breaking')) { + breaking.push(entry); + } else if (labels.includes('enhancement')) { + enhancements.push(entry); + } else if (labels.includes('bug')) { + bugs.push(entry); + } else if (labels.includes('documentation')) { + documentation.push(entry); + } + } + + // Determine version bump + let bumpType = 'none'; + if (breaking.length > 0) { + bumpType = 'major'; + } else if (enhancements.length > 0) { + bumpType = 'minor'; + } else if (bugs.length > 0) { + bumpType = 'patch'; + } + + core.setOutput('breaking', JSON.stringify(breaking)); + core.setOutput('enhancements', JSON.stringify(enhancements)); + core.setOutput('bugs', JSON.stringify(bugs)); + core.setOutput('documentation', JSON.stringify(documentation)); + core.setOutput('bump-type', bumpType); + core.setOutput('has-changes', bumpType !== 'none' ? 'true' : 'false'); + + console.log(`Found ${mergedPRs.length} merged PRs since ${latestTag}`); + console.log(`Breaking: ${breaking.length}, Enhancements: ${enhancements.length}, Bugs: ${bugs.length}, Docs: ${documentation.length}`); + console.log(`Version bump: ${bumpType}`); + + - name: Calculate new version + id: calc-version + run: | + LATEST_TAG="${{ steps.get-latest-tag.outputs.latest-tag }}" + BUMP_TYPE="${{ steps.get-prs.outputs.bump-type }}" + + # Split into components (no 'v' prefix removal needed) + VERSION=$LATEST_TAG + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + + # Bump version based on change type + case $BUMP_TYPE in + major) + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + ;; + minor) + MINOR=$((MINOR + 1)) + PATCH=0 + ;; + patch) + PATCH=$((PATCH + 1)) + ;; + none) + echo "No version bump needed" + NEW_VERSION="" + ;; + esac + + if [ "$BUMP_TYPE" != "none" ]; then + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" + echo "new-version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "version-number=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "New version: $NEW_VERSION" + fi + + - name: Generate changelog entry + id: changelog + if: steps.get-prs.outputs.has-changes == 'true' + run: | + BREAKING='${{ steps.get-prs.outputs.breaking }}' + ENHANCEMENTS='${{ steps.get-prs.outputs.enhancements }}' + BUGS='${{ steps.get-prs.outputs.bugs }}' + DOCS='${{ steps.get-prs.outputs.documentation }}' + VERSION="${{ steps.calc-version.outputs.version-number }}" + DATE=$(date +%Y-%m-%d) + + # Create changelog entry + CHANGELOG="## [$VERSION] - $DATE"$'\n' + + # Parse JSON arrays and add sections + BREAKING_ITEMS=$(echo "$BREAKING" | jq -r '.[]' 2>/dev/null || echo "") + if [ -n "$BREAKING_ITEMS" ]; then + CHANGELOG="$CHANGELOG"$'\n'"### Breaking Changes"$'\n'"$BREAKING_ITEMS"$'\n' + fi + + ENHANCEMENT_ITEMS=$(echo "$ENHANCEMENTS" | jq -r '.[]' 2>/dev/null || echo "") + if [ -n "$ENHANCEMENT_ITEMS" ]; then + CHANGELOG="$CHANGELOG"$'\n'"### Added"$'\n'"$ENHANCEMENT_ITEMS"$'\n' + fi + + BUG_ITEMS=$(echo "$BUGS" | jq -r '.[]' 2>/dev/null || echo "") + if [ -n "$BUG_ITEMS" ]; then + CHANGELOG="$CHANGELOG"$'\n'"### Fixed"$'\n'"$BUG_ITEMS"$'\n' + fi + + DOC_ITEMS=$(echo "$DOCS" | jq -r '.[]' 2>/dev/null || echo "") + if [ -n "$DOC_ITEMS" ]; then + CHANGELOG="$CHANGELOG"$'\n'"### Documentation"$'\n'"$DOC_ITEMS"$'\n' + fi + + # Save to file and output + echo "$CHANGELOG" > /tmp/new_changelog.md + echo "changelog-entry<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + echo "Generated changelog:" + cat /tmp/new_changelog.md + + - name: Update CHANGELOG.md + if: steps.get-prs.outputs.has-changes == 'true' && inputs.dry-run == false + run: | + # Read existing changelog + if [ -f CHANGELOG.md ]; then + # Insert new entry after the header (after line containing "## [") + awk '/^## \[/ && !done {print "'"$(cat /tmp/new_changelog.md)"'"; print ""; done=1} {print}' CHANGELOG.md > /tmp/updated_changelog.md + mv /tmp/updated_changelog.md CHANGELOG.md + else + echo "# Changelog" > CHANGELOG.md + echo "" >> CHANGELOG.md + echo "All notable changes to this project will be documented in this file." >> CHANGELOG.md + echo "" >> CHANGELOG.md + echo "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)," >> CHANGELOG.md + echo "and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)." >> CHANGELOG.md + echo "" >> CHANGELOG.md + cat /tmp/new_changelog.md >> CHANGELOG.md + fi + + echo "Updated CHANGELOG.md:" + head -n 30 CHANGELOG.md + + - name: Commit and tag + if: steps.get-prs.outputs.has-changes == 'true' && inputs.dry-run == false + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add CHANGELOG.md + git commit -m "Release ${{ steps.calc-version.outputs.new-version }}" + git tag -a ${{ steps.calc-version.outputs.new-version }} -m "Release ${{ steps.calc-version.outputs.new-version }}" + git push --follow-tags + + - name: Create GitHub Release + if: steps.get-prs.outputs.has-changes == 'true' && inputs.dry-run == false + uses: actions/github-script@v7 + with: + script: | + const changelogEntry = `${{ steps.changelog.outputs.changelog-entry }}`; + + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: '${{ steps.calc-version.outputs.new-version }}', + name: '${{ steps.calc-version.outputs.new-version }}', + body: changelogEntry, + draft: false, + prerelease: false + }); + + - name: Dry run summary + if: inputs.dry-run == true + run: | + echo "### 🔍 Dry Run - No changes made" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${{ steps.get-prs.outputs.has-changes }}" == "true" ]; then + echo "**New version would be:** ${{ steps.calc-version.outputs.new-version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Changelog entry:**" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat /tmp/new_changelog.md >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + else + echo "No changes to release (only documentation or not-included-in-release PRs)" >> $GITHUB_STEP_SUMMARY + fi + + - name: Release summary + if: steps.get-prs.outputs.has-changes == 'true' && inputs.dry-run == false + run: | + echo "### ✅ Release Created" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ steps.calc-version.outputs.new-version }}" >> $GITHUB_STEP_SUMMARY + echo "**Type:** ${{ steps.get-prs.outputs.bump-type }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "[View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.calc-version.outputs.new-version }})" >> $GITHUB_STEP_SUMMARY + + - name: No changes summary + if: steps.get-prs.outputs.has-changes == 'false' + run: | + echo "### â„šī¸ No Release Needed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "No PRs with breaking, enhancement, or bug labels found since last release." >> $GITHUB_STEP_SUMMARY + echo "Only documentation or not-included-in-release changes were merged." >> $GITHUB_STEP_SUMMARY diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index acf7072..c6eab6c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,15 @@ Feature suggestions are welcome! Please: 3. **Ensure all tests pass**: `swift test` 4. **Follow Swift style guidelines**: Use SwiftLint if available 5. **Update documentation** if you're changing APIs -6. **Write clear commit messages** describing your changes +6. **Write a clear PR title** that will serve as the changelog entry +7. **Add a label** to categorize the change: + - `breaking` - Breaking API changes (major version bump) + - `enhancement` - New features or improvements (minor version bump) + - `bug` - Bug fixes (patch version bump) + - `documentation` - Documentation-only changes (no version bump) + - `not-included-in-release` - Explicitly exclude from release (e.g., internal refactoring, experiments) + +**Note**: Pull requests will be squash-merged, so multiple commits in your branch will become a single commit on `main`. By default, all merged PRs are included in releases unless marked with `not-included-in-release`. The PR title and label determine the changelog entry and version bump. #### Development Setup diff --git a/Sources/Settings/MacroSetting/Attribute+Publisher.swift b/Sources/Settings/MacroSetting/Attribute+Publisher.swift index 19af158..b1d90a3 100644 --- a/Sources/Settings/MacroSetting/Attribute+Publisher.swift +++ b/Sources/Settings/MacroSetting/Attribute+Publisher.swift @@ -2,7 +2,7 @@ import Foundation import Combine extension __Attribute -where Container.Store: UserDefaults, Value: Sendable { +where Value: Sendable { public static var publisher: AnyPublisher { let publisher = AsyncStreamPublisher(Self.stream) diff --git a/Sources/Settings/MacroSettings/UserDefaultsStore.swift b/Sources/Settings/MacroSettings/UserDefaultsStore.swift index 1da3061..930a639 100644 --- a/Sources/Settings/MacroSettings/UserDefaultsStore.swift +++ b/Sources/Settings/MacroSettings/UserDefaultsStore.swift @@ -29,6 +29,8 @@ public protocol UserDefaultsStore { func set(_ url: URL?, forKey: String) func register(defaults: [String: Any]) + + func dictionaryRepresentation() -> [String: Any] func observer( forKey: String, diff --git a/Sources/Settings/SwiftUI/AppSetting.swift b/Sources/Settings/SwiftUI/AppSetting.swift index 5c766dc..6afdedc 100644 --- a/Sources/Settings/SwiftUI/AppSetting.swift +++ b/Sources/Settings/SwiftUI/AppSetting.swift @@ -2,7 +2,7 @@ import SwiftUI import Combine -/// A default container for UserDefaults that uses the standard store with no prefix. +/// A default container for UserDefaults that uses a configurable store. /// /// This container provides a convenient way to define app-wide settings without /// needing to create a custom container. Extend this type with `@Setting` @@ -34,9 +34,26 @@ import Combine /// } /// } /// ``` +/// +/// ## Store Configuration +/// +/// By default, `AppSettingValues` uses `UserDefaults.standard`. You can configure +/// a different store at runtime: +/// +/// ```swift +/// // Use a mock store for testing +/// AppSettingValues.configureStore(UserDefaultsStoreMock()) +/// +/// // Reset to standard +/// AppSettingValues.resetStore() +/// ``` +@MainActor public struct AppSettingValues: __Settings_Container { - public static var store: UserDefaults { - UserDefaults.standard + + fileprivate static var currentStore: any UserDefaultsStore = UserDefaults.standard + + public static var store: any UserDefaultsStore { + currentStore } } @@ -88,17 +105,39 @@ public struct AppSettingValues: __Settings_Container { /// } /// ``` /// +/// ## Store Configuration +/// +/// By default, `AppSetting` uses `UserDefaults.standard`. You can configure +/// a different store at runtime using SwiftUI environment value `userDefaultsStore`: +/// +/// ```swift +/// @main +/// struct ViewAppApp: App { +/// var body: some Scene { +/// WindowGroup { +/// MainView() +/// .environment( +/// \.userDefaultsStore, +/// UserDefaultsStoreMock() +/// ) +/// } +/// } +/// } +/// ``` +/// /// See the documentation on `AppSettingValues` for additional details. /// /// The property wrapper observes the specific UserDefaults key and triggers /// view updates when the value changes, whether from the current view or elsewhere /// in the app. +@MainActor @propertyWrapper -public struct AppSetting: DynamicProperty -where Attribute.Value: Sendable, Attribute.Container.Store: UserDefaults { +public struct AppSetting: @MainActor DynamicProperty +where Attribute.Value: Sendable { @State private var value: Attribute.Value @State private var cancellable: AnyCancellable? + @Environment(\.userDefaultsStore) private var environmentStore /// The current UserDefaults value. public var wrappedValue: Attribute.Value { @@ -173,6 +212,8 @@ where Attribute.Value: Sendable, Attribute.Container.Store: UserDefaults { /// Called by SwiftUI to set up the publisher subscription. public mutating func update() { + AppSettingValues.currentStore = self.environmentStore + if cancellable == nil { cancellable = Attribute.publisher .catch { _ in Just(Attribute.read()) } diff --git a/Sources/Settings/SwiftUI/UserDefaultsStoreEnvironment.swift b/Sources/Settings/SwiftUI/UserDefaultsStoreEnvironment.swift new file mode 100644 index 0000000..d66b1cf --- /dev/null +++ b/Sources/Settings/SwiftUI/UserDefaultsStoreEnvironment.swift @@ -0,0 +1,54 @@ +#if canImport(SwiftUI) +import SwiftUI +import Foundation + +/// Environment key for providing a `UserDefaultsStore` to SwiftUI views. +/// +/// This allows you to inject a custom store (like `UserDefaultsStoreMock`) for testing +/// and previews, while using the real `UserDefaults.standard` in production. +struct UserDefaultsStoreKey: EnvironmentKey { + static var defaultValue: any UserDefaultsStore { UserDefaults.standard } +} + +extension EnvironmentValues { + /// The `UserDefaultsStore` used by `@AppSetting` property wrappers in the current view hierarchy. + /// + /// By default, this is `UserDefaults.standard`. You can override it for testing or previews: + /// + /// ```swift + /// struct ContentView_Previews: PreviewProvider { + /// static var previews: some View { + /// ContentView() + /// .userDefaultsStore(UserDefaultsStoreMock()) + /// } + /// } + /// ``` + @Entry public var userDefaultsStore: any UserDefaultsStore = UserDefaults.standard +} + +extension View { + /// Sets the `UserDefaultsStore` for this view and its children. + /// + /// Use this modifier to provide a mock store for testing or previews: + /// + /// ```swift + /// struct SettingsView_Previews: PreviewProvider { + /// static var previews: some View { + /// let mockStore = UserDefaultsStoreMock() + /// mockStore.set("TestUser", forKey: "username") + /// mockStore.set(true, forKey: "darkMode") + /// + /// return SettingsView() + /// .userDefaultsStore(mockStore) + /// } + /// } + /// ``` + /// + /// - Parameter store: The `UserDefaultsStore` to use for `@AppSetting` properties. + /// - Returns: A view that uses the specified store. + public func userDefaultsStore(_ store: some UserDefaultsStore) -> some View { + environment(\.userDefaultsStore, store) + } +} + +#endif diff --git a/Sources/SettingsClient/main.swift b/Sources/SettingsClient/main.swift index 118caa2..2575ffa 100644 --- a/Sources/SettingsClient/main.swift +++ b/Sources/SettingsClient/main.swift @@ -105,7 +105,7 @@ final class UserProfileObserver { } -func main() async throws { +func main1() async throws { // let userProfileObserver = UserProfileObserver() AppSettings2.Profiles.userProfile = .init(id: UUID(), user: "John Appleseed", image: nil) @@ -128,4 +128,31 @@ final class ViewModel { } -try await main() +try await main1() + +// MARK: - App +import SwiftUI + +// @main +struct SettingsView: View { + @Environment(\.userDefaultsStore) var settings + + var body: some View { + Text(verbatim: "\(settings.dictionaryRepresentation())") + } +} + +struct ProductionApp: App { + var body: some Scene { + WindowGroup { + Text("Test") + SettingsView() + } + .environment(\.userDefaultsStore, UserDefaultsStoreMock()) + } +} + +// #Preview { +// SettingsView() +// .environment(\.userDefaultsStore, UserDefaultsStoreMock(store: ["user": "John"])) +// } diff --git a/Sources/SettingsMock/UserDefaultsMock.swift b/Sources/SettingsMock/UserDefaultsMock.swift index fc74f01..bc2ad54 100644 --- a/Sources/SettingsMock/UserDefaultsMock.swift +++ b/Sources/SettingsMock/UserDefaultsMock.swift @@ -1,9 +1,7 @@ import Foundation -import Synchronization +import os import Settings - -@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable { public static let standard: UserDefaultsStoreMock = .init(store: [:]) @@ -26,16 +24,16 @@ public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable sanitized[k] = copy } } - state = Mutex(.init(store: sanitized)) + state = OSAllocatedUnfairLock(initialState: State(store: sanitized)) super.init() } override public init() { - state = Mutex(.init()) + state = OSAllocatedUnfairLock(initialState: State()) super.init() } - let state: Mutex + let state: OSAllocatedUnfairLock public func reset() { @@ -78,11 +76,9 @@ public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable } private func readEffectiveValue(forKey key: String) -> Any? { - var result: Any? - state.withLock { state in - result = state.values[key] ?? state.defaults[key] + state.withLockUnchecked { state in + state.values[key] ?? state.defaults[key] } - return result } private func number(from any: Any) -> NSNumber? { @@ -105,12 +101,12 @@ public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable return } var oldEffective: Any? - state.withLock { state in + state.withLockUnchecked { state in oldEffective = state.values[key] ?? state.defaults[key] } let shouldNotify = !isEqualPlist(oldEffective, stored) if shouldNotify { willChangeValue(forKey: key) } - state.withLock { state in + state.withLockUnchecked { state in state.values[key] = stored } if shouldNotify { didChangeValue(forKey: key) } @@ -118,7 +114,7 @@ public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable // Removing a value restores the effective value to the default for the key (if any). var oldEffective: Any? var newEffective: Any? - state.withLock { state in + state.withLockUnchecked { state in oldEffective = state.values[key] ?? state.defaults[key] newEffective = state.defaults[key] } @@ -201,7 +197,7 @@ public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable var hasUserValue = false var oldEffective: Any? var newEffective: Any? - state.withLock { state in + state.withLockUnchecked { state in hasUserValue = state.values[key] != nil oldEffective = state.values[key] ?? state.defaults[key] newEffective = hasUserValue ? state.values[key] : copy @@ -209,7 +205,7 @@ public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable let shouldNotify = !hasUserValue && !isEqualPlist(oldEffective, newEffective) if shouldNotify { willChangeValue(forKey: key) } - state.withLock { state in + state.withLockUnchecked { state in state.defaults[key] = copy } if shouldNotify { didChangeValue(forKey: key) } @@ -223,6 +219,14 @@ public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable public override func setValue(_ value: Any?, forKey key: String) { set(value, forKey: key) } + + public func dictionaryRepresentation() -> [String : Any] { + state.withLockUnchecked { state in + var values = state.values + values.merge(state.defaults, uniquingKeysWith: { lhs, rhs in lhs }) + return values + } + } } extension UserDefaultsStoreMock { diff --git a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest6.swift b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest6.swift index e850323..a447841 100644 --- a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest6.swift +++ b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest6.swift @@ -85,7 +85,7 @@ struct UserDefaultProxyTest6 { Attr.write(value: 42) try await expectation2.await(timeout: .seconds(2), clock: .continuous) - try await Task.sleep(for: .milliseconds(10)) + try await Task.sleep(for: .milliseconds(100)) Attr.write(value: 99) try await expectation3.await(timeout: .seconds(2), clock: .continuous) diff --git a/Tests/SwiftUITests/AppSettingTests.swift b/Tests/SwiftUITests/AppSettingTests.swift index f6d7029..4c0f960 100644 --- a/Tests/SwiftUITests/AppSettingTests.swift +++ b/Tests/SwiftUITests/AppSettingTests.swift @@ -34,6 +34,7 @@ extension TestSettingValues { @Setting static var optionalString: String? } +@MainActor @Suite("AppSetting Property Wrapper Tests", .serialized) struct AppSettingTests {