diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6fed9ba..633ecfe 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { - "name": "Swift 6.2", - "image": "swift:6.2", + "name": "Swift 6.3", + "image": "swift:6.3", "features": { "ghcr.io/devcontainers/features/common-utils:2": { "installZsh": "false", @@ -24,7 +24,7 @@ "lldb.library": "/usr/lib/liblldb.so" }, "extensions": [ - "sswg.swift-lang" + "swift-server.swift" ] } }, diff --git a/.devcontainer/swift-6.1/devcontainer.json b/.devcontainer/swift-6.1/devcontainer.json index bdb65e1..23821a4 100644 --- a/.devcontainer/swift-6.1/devcontainer.json +++ b/.devcontainer/swift-6.1/devcontainer.json @@ -24,7 +24,7 @@ "lldb.library": "/usr/lib/liblldb.so" }, "extensions": [ - "sswg.swift-lang" + "swift-server.swift" ] } }, diff --git a/.devcontainer/swift-6.2-nightly/devcontainer.json b/.devcontainer/swift-6.2-nightly/devcontainer.json deleted file mode 100644 index b5bd73c..0000000 --- a/.devcontainer/swift-6.2-nightly/devcontainer.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "Swift 6.2 Nightly", - "image": "swiftlang/swift:nightly-6.2-noble", - "features": { - "ghcr.io/devcontainers/features/common-utils:2": { - "installZsh": "false", - "username": "vscode", - "upgradePackages": "false" - }, - "ghcr.io/devcontainers/features/git:1": { - "version": "os-provided", - "ppa": "false" - } - }, - "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", - "runArgs": [ - "--cap-add=SYS_PTRACE", - "--security-opt", - "seccomp=unconfined" - ], - // Configure tool-specific properties. - "customizations": { - // Configure properties specific to VS Code. - "vscode": { - // Set *default* container specific settings.json values on container create. - "settings": { - "lldb.library": "/usr/lib/liblldb.so" - }, - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "sswg.swift-lang" - ] - } - }, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. - "remoteUser": "root" -} \ No newline at end of file diff --git a/.devcontainer/swift-6.2/devcontainer.json b/.devcontainer/swift-6.2/devcontainer.json index 6fed9ba..f04399b 100644 --- a/.devcontainer/swift-6.2/devcontainer.json +++ b/.devcontainer/swift-6.2/devcontainer.json @@ -24,7 +24,7 @@ "lldb.library": "/usr/lib/liblldb.so" }, "extensions": [ - "sswg.swift-lang" + "swift-server.swift" ] } }, diff --git a/.devcontainer/swift-6.1-nightly/devcontainer.json b/.devcontainer/swift-6.3/devcontainer.json similarity index 85% rename from .devcontainer/swift-6.1-nightly/devcontainer.json rename to .devcontainer/swift-6.3/devcontainer.json index 7949dc9..80941c7 100644 --- a/.devcontainer/swift-6.1-nightly/devcontainer.json +++ b/.devcontainer/swift-6.3/devcontainer.json @@ -1,6 +1,6 @@ { - "name": "Swift 6.1 Nightly", - "image": "swiftlang/swift:nightly-6.1-noble", + "name": "Swift 6.3", + "image": "swift:6.3", "features": { "ghcr.io/devcontainers/features/common-utils:2": { "installZsh": "false", @@ -24,9 +24,9 @@ "lldb.library": "/usr/lib/liblldb.so" }, "extensions": [ - "sswg.swift-lang" + "swift-server.swift" ] } }, "remoteUser": "root" -} \ No newline at end of file +} diff --git a/.github/workflows/SundialKitStream.yml b/.github/workflows/SundialKitStream.yml index 6de6493..ac2c581 100644 --- a/.github/workflows/SundialKitStream.yml +++ b/.github/workflows/SundialKitStream.yml @@ -1,75 +1,155 @@ name: SundialKitStream on: push: - branches-ignore: - - '*WIP' + branches: + - main + tags: + - 'v[0-9]*.[0-9]*.[0-9]*' + paths-ignore: + - '**.md' + - 'Docs/**' + - 'LICENSE' + - '.github/ISSUE_TEMPLATE/**' + pull_request: + branches: + - main + - 'v[0-9]*.[0-9]*.[0-9]*' + paths-ignore: + - '**.md' + - 'Docs/**' + - 'LICENSE' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + env: PACKAGE_NAME: SundialKitStream jobs: + configure: + name: Configure Build Matrix + runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' || !contains(github.event.head_commit.message, 'ci skip') }} + outputs: + full-matrix: ${{ steps.set-matrix.outputs.full-matrix }} + ubuntu-os: ${{ steps.set-matrix.outputs.ubuntu-os }} + ubuntu-swift: ${{ steps.set-matrix.outputs.ubuntu-swift }} + ubuntu-type: ${{ steps.set-matrix.outputs.ubuntu-type }} + steps: + - name: Determine build matrix + id: set-matrix + run: | + if [[ "${{ github.ref }}" == "refs/heads/main" \ + || "${{ github.event_name }}" == "pull_request" \ + || "${{ github.ref }}" == refs/tags/* ]]; then + echo "full-matrix=true" >> "$GITHUB_OUTPUT" + echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-swift=[{"version":"6.1"},{"version":"6.2"},{"version":"6.3"}]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-type=["","wasm","wasm-embedded"]' >> "$GITHUB_OUTPUT" + else + echo "full-matrix=false" >> "$GITHUB_OUTPUT" + echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-swift=[{"version":"6.3"}]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-type=[""]' >> "$GITHUB_OUTPUT" + fi + build-ubuntu: name: Build on Ubuntu + needs: [configure] runs-on: ubuntu-latest container: ${{ matrix.swift.nightly && format('swiftlang/swift:nightly-{0}-{1}', matrix.swift.version, matrix.os) || format('swift:{0}-{1}', matrix.swift.version, matrix.os) }} - if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false matrix: - os: [noble, jammy] - swift: - - version: "6.1" - - version: "6.2" - - version: "6.1" - nightly: true - - version: "6.2" - nightly: true + os: ${{ fromJSON(needs.configure.outputs.ubuntu-os) }} + swift: ${{ fromJSON(needs.configure.outputs.ubuntu-swift) }} + type: ${{ fromJSON(needs.configure.outputs.ubuntu-type) }} + exclude: + - swift: {version: "6.1"} + type: wasm + - swift: {version: "6.1"} + type: wasm-embedded steps: - - uses: actions/checkout@v4 - - uses: brightdigit/swift-build@v1.4.0 + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build with: scheme: ${{ env.PACKAGE_NAME }} - - uses: sersoft-gmbh/swift-coverage-action@v4 + type: ${{ matrix.type }} + - name: Install curl + if: steps.build.outputs.contains-code-coverage == 'true' + run: | + apt-get update -q + apt-get install -y curl + - uses: sersoft-gmbh/swift-coverage-action@v5 id: coverage-files + if: steps.build.outputs.contains-code-coverage == 'true' with: fail-on-empty-output: true - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 with: fail_ci_if_error: true - flags: swift-${{ matrix.swift.version }}-${{ matrix.os }}${{ matrix.swift.nightly && '-nightly' || '' }} + flags: swift-${{ matrix.swift.version }},ubuntu verbose: true token: ${{ secrets.CODECOV_TOKEN }} files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + build-macos: name: Build on macOS + needs: [configure] runs-on: ${{ matrix.runs-on }} - if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + if: ${{ !cancelled() && needs.configure.result == 'success' }} strategy: fail-fast: false matrix: include: - # SPM Build Matrix - Xcode 16.x+ (Swift 6.1+) - - runs-on: macos-26 - xcode: "/Applications/Xcode_26.1.app" + # SPM Build — no platform type; matrix.type evaluates to '' by design - runs-on: macos-26 - xcode: "/Applications/Xcode_26.0.app" + xcode: "/Applications/Xcode_26.4.app" + + steps: + - uses: actions/checkout@v6 + + - name: Build and Test + id: build + uses: brightdigit/swift-build@v1 + with: + scheme: ${{ env.PACKAGE_NAME }} + xcode: ${{ matrix.xcode }} + + # Common Coverage Steps + - name: Process Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + + - name: Upload Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} + + build-macos-full: + name: Build on macOS (Full) + needs: [configure] + if: ${{ !cancelled() && needs.configure.result == 'success' && needs.configure.outputs.full-matrix == 'true' }} + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + include: + # SPM Build (older Xcode) - runs-on: macos-15 xcode: "/Applications/Xcode_16.4.app" - - runs-on: macos-15 - xcode: "/Applications/Xcode_16.3.app" - - # iOS Build Matrix - Xcode 16.x+ (Swift 6.1+) - - type: ios - runs-on: macos-26 - xcode: "/Applications/Xcode_26.1.app" - deviceName: "iPhone 17 Pro" - osVersion: "26.1" - download-platform: true + # iOS Build Matrix - type: ios runs-on: macos-26 - xcode: "/Applications/Xcode_26.0.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "iPhone 17 Pro" - osVersion: "26.0.1" + osVersion: "26.4" download-platform: true - type: ios @@ -78,25 +158,12 @@ jobs: deviceName: "iPhone 16e" osVersion: "18.5" - - type: ios - runs-on: macos-15 - xcode: "/Applications/Xcode_16.3.app" - deviceName: "iPhone 16" - osVersion: "18.4" - - # watchOS Build Matrix - Xcode 16.x+ (Swift 6.1+) - - type: watchos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.1.app" - deviceName: "Apple Watch Ultra 3 (49mm)" - osVersion: "26.0" - download-platform: true - + # watchOS Build Matrix - type: watchos runs-on: macos-26 - xcode: "/Applications/Xcode_26.0.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "Apple Watch Ultra 3 (49mm)" - osVersion: "26.0" + osVersion: "26.4" download-platform: true - type: watchos @@ -105,17 +172,20 @@ jobs: deviceName: "Apple Watch Series 10 (46mm)" osVersion: "11.5" - - type: watchos - runs-on: macos-15 - xcode: "/Applications/Xcode_16.3.app" - deviceName: "Apple Watch Series 10 (42mm)" - osVersion: "11.4" + # tvOS Build Matrix + - type: tvos + runs-on: macos-26 + xcode: "/Applications/Xcode_26.4.app" + deviceName: "Apple TV" + osVersion: "26.4" + download-platform: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build and Test - uses: brightdigit/swift-build@v1.4.0 + id: build + uses: brightdigit/swift-build@v1 with: scheme: ${{ env.PACKAGE_NAME }} type: ${{ matrix.type }} @@ -124,32 +194,108 @@ jobs: osVersion: ${{ matrix.osVersion }} download-platform: ${{ matrix.download-platform }} - # Coverage Steps + # Common Coverage Steps - name: Process Coverage - uses: sersoft-gmbh/swift-coverage-action@v4 + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 - name: Upload Coverage - uses: codecov/codecov-action@v4 + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} + build-windows: + name: Build on Windows + needs: [configure] + if: ${{ !cancelled() && needs.configure.outputs.full-matrix == 'true' }} + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + runs-on: [windows-2022, windows-2025] + swift: + - version: swift-6.3-release + build: 6.3-RELEASE + - version: swift-6.2-release + build: 6.2-RELEASE + - version: swift-6.1-release + build: 6.1-RELEASE + steps: + - uses: actions/checkout@v6 + - name: Enable git symlinks + shell: pwsh + run: git config --global core.symlinks true + - uses: brightdigit/swift-build@v1 + id: build + with: + windows-swift-version: ${{ matrix.swift.version }} + windows-swift-build: ${{ matrix.swift.build }} + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + fail_ci_if_error: true + flags: swift-${{ matrix.swift.version }},windows + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + os: windows + swift_project: SundialKitStream + + build-android: + name: Build for Android + needs: [configure] + if: ${{ !cancelled() && needs.configure.outputs.full-matrix == 'true' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + swift: + - version: "6.2" + - version: "6.3" + android-api-level: [33, 36] + steps: + - uses: actions/checkout@v6 + - name: Free disk space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + android: false + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + - uses: brightdigit/swift-build@v1 + id: build + with: + type: android + android-swift-version: ${{ matrix.swift.version }} + android-api-level: ${{ matrix.android-api-level }} + android-run-tests: true + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + fail_ci_if_error: true + flags: android-api${{ matrix.android-api-level }}-swift${{ matrix.swift.version }} + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + lint: name: Linting - if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + # build-windows, build-macos-full, and build-android are skipped on feature branches; + # !failure() allows this job to proceed past skipped (not failed) dependencies. + if: ${{ !cancelled() && !failure() && (github.event_name == 'pull_request' || !contains(github.event.head_commit.message, 'ci skip')) }} runs-on: ubuntu-latest - needs: [build-ubuntu, build-macos] - env: - LINT_MODE: STRICT + needs: [build-ubuntu, build-macos, build-windows, build-macos-full, build-android] steps: - - uses: actions/checkout@v4 - - name: Install mise - uses: jdx/mise-action@v2 + - uses: actions/checkout@v6 + - uses: jdx/mise-action@v4 with: - version: 2024.11.0 - install: true cache: true - name: Lint - run: | - set -e - ./Scripts/lint.sh + run: ./Scripts/lint.sh + env: + LINT_MODE: STRICT diff --git a/.swift-format b/.swift-format index 5e103e9..5c31a3e 100644 --- a/.swift-format +++ b/.swift-format @@ -29,7 +29,7 @@ "BeginDocumentationCommentWithOneLineSummary" : false, "DoNotUseSemicolons" : true, "DontRepeatTypeInStaticProperties" : true, - "FileScopedDeclarationPrivacy" : true, + "FileScopedDeclarationPrivacy" : false, "FullyIndirectEnum" : true, "GroupNumericLiterals" : true, "IdentifiersMustBeASCII" : true, @@ -67,4 +67,4 @@ "spacesAroundRangeFormationOperators" : false, "tabWidth" : 2, "version" : 1 -} +} \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml index 326f480..a0f98a8 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -52,6 +52,7 @@ opt_in_rules: - nslocalizedstring_require_bundle - number_separator - object_literal + - one_declaration_per_file - operator_usage_whitespace - optional_enum_case_matching - overridden_super_call @@ -115,7 +116,6 @@ excluded: - .build - Mint - Examples - - Sources/MistKit/Generated indentation_width: indentation_width: 2 file_name: @@ -130,4 +130,4 @@ disabled_rules: - trailing_comma - opening_brace - pattern_matching_keywords - - todo \ No newline at end of file + - todo diff --git a/Package.resolved b/Package.resolved index 6a0c3f6..8190310 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "bb26fa541c8043161a229c70d629895a66f222ad1353a6f5a22506d5b8fa4241", + "originHash" : "5b6fbee2654bf63e8896a2e7672e30374319207327f7e2b85ce6ce24f78994aa", "pins" : [ { "identity" : "sundialkit", "kind" : "remoteSourceControl", "location" : "https://github.com/brightdigit/SundialKit.git", "state" : { - "revision" : "ff0e3f28e61107d26405c05ec1fa9637dbce05ed", - "version" : "2.0.0-alpha.1" + "revision" : "c74d7f224d797214b5c53c29ece1aaec65950c63", + "version" : "2.0.0-alpha.2" } } ], diff --git a/Package.swift b/Package.swift index 5b09d31..300c30f 100644 --- a/Package.swift +++ b/Package.swift @@ -59,7 +59,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/brightdigit/SundialKit.git", from: "2.0.0-alpha.1") + .package(url: "https://github.com/brightdigit/SundialKit.git", from: "2.0.0-alpha.2") ], targets: [ .target( diff --git a/Sources/SundialKitStream/AsyncStream+Continuation.swift b/Sources/SundialKitStream/AsyncStream+Continuation.swift index 45a8ed0..d724ffa 100644 --- a/Sources/SundialKitStream/AsyncStream+Continuation.swift +++ b/Sources/SundialKitStream/AsyncStream+Continuation.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/ConnectivityObserver+Lifecycle.swift b/Sources/SundialKitStream/ConnectivityObserver+Lifecycle.swift index 2941d01..4ac939a 100644 --- a/Sources/SundialKitStream/ConnectivityObserver+Lifecycle.swift +++ b/Sources/SundialKitStream/ConnectivityObserver+Lifecycle.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/ConnectivityObserver+Messaging.swift b/Sources/SundialKitStream/ConnectivityObserver+Messaging.swift index 0eabb10..37f0dde 100644 --- a/Sources/SundialKitStream/ConnectivityObserver+Messaging.swift +++ b/Sources/SundialKitStream/ConnectivityObserver+Messaging.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/ConnectivityObserver+Streams.swift b/Sources/SundialKitStream/ConnectivityObserver+Streams.swift index e1844f7..31dc8f5 100644 --- a/Sources/SundialKitStream/ConnectivityObserver+Streams.swift +++ b/Sources/SundialKitStream/ConnectivityObserver+Streams.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/ConnectivityObserver.swift b/Sources/SundialKitStream/ConnectivityObserver.swift index 8d54393..fbf81c8 100644 --- a/Sources/SundialKitStream/ConnectivityObserver.swift +++ b/Sources/SundialKitStream/ConnectivityObserver.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/ConnectivityState.swift b/Sources/SundialKitStream/ConnectivityState.swift index 14fcbc1..8c6f09f 100644 --- a/Sources/SundialKitStream/ConnectivityState.swift +++ b/Sources/SundialKitStream/ConnectivityState.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/ConnectivityStateManager+Updates.swift b/Sources/SundialKitStream/ConnectivityStateManager+Updates.swift index db3a2ff..cc0fb7d 100644 --- a/Sources/SundialKitStream/ConnectivityStateManager+Updates.swift +++ b/Sources/SundialKitStream/ConnectivityStateManager+Updates.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/ConnectivityStateManager.swift b/Sources/SundialKitStream/ConnectivityStateManager.swift index 3dcae04..09cb98d 100644 --- a/Sources/SundialKitStream/ConnectivityStateManager.swift +++ b/Sources/SundialKitStream/ConnectivityStateManager.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/MessageDispatcher.swift b/Sources/SundialKitStream/MessageDispatcher.swift index 87d17b7..bab9b78 100644 --- a/Sources/SundialKitStream/MessageDispatcher.swift +++ b/Sources/SundialKitStream/MessageDispatcher.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/MessageDistributor.swift b/Sources/SundialKitStream/MessageDistributor.swift index aefa427..e12c209 100644 --- a/Sources/SundialKitStream/MessageDistributor.swift +++ b/Sources/SundialKitStream/MessageDistributor.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/MessageHandling.swift b/Sources/SundialKitStream/MessageHandling.swift index 7ce6065..22a08a8 100644 --- a/Sources/SundialKitStream/MessageHandling.swift +++ b/Sources/SundialKitStream/MessageHandling.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/MessageRouter.swift b/Sources/SundialKitStream/MessageRouter.swift index a58bc79..dcd4628 100644 --- a/Sources/SundialKitStream/MessageRouter.swift +++ b/Sources/SundialKitStream/MessageRouter.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/NetworkObserver+Handlers.swift b/Sources/SundialKitStream/NetworkObserver+Handlers.swift new file mode 100644 index 0000000..a1f6453 --- /dev/null +++ b/Sources/SundialKitStream/NetworkObserver+Handlers.swift @@ -0,0 +1,87 @@ +// +// NetworkObserver+Handlers.swift +// SundialKitStream +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Network) + import Foundation + import SundialKitNetwork + + @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) + extension NetworkObserver { + // MARK: - Internal Handlers + + internal func handlePathUpdate(_ path: MonitorType.PathType) { + currentPath = path + + // Notify all active path stream subscribers + for continuation in pathContinuations.values { + continuation.yield(path) + } + for continuation in pathStatusContinuations.values { + continuation.yield(path.pathStatus) + } + for continuation in isExpensiveContinuations.values { + continuation.yield(path.isExpensive) + } + for continuation in isConstrainedContinuations.values { + continuation.yield(path.isConstrained) + } + } + + internal func handlePingStatusUpdate(_ status: PingType.StatusType) { + currentPingStatus = status + + // Notify all active ping status stream subscribers + for continuation in pingStatusContinuations.values { + continuation.yield(status) + } + } + + // MARK: - Continuation Removal + + internal func removePathContinuation(id: UUID) { + pathContinuations.removeValue(forKey: id) + } + + internal func removePathStatusContinuation(id: UUID) { + pathStatusContinuations.removeValue(forKey: id) + } + + internal func removeIsExpensiveContinuation(id: UUID) { + isExpensiveContinuations.removeValue(forKey: id) + } + + internal func removeIsConstrainedContinuation(id: UUID) { + isConstrainedContinuations.removeValue(forKey: id) + } + + internal func removePingStatusContinuation(id: UUID) { + pingStatusContinuations.removeValue(forKey: id) + } + } +#endif diff --git a/Sources/SundialKitStream/NetworkObserver+Init.swift b/Sources/SundialKitStream/NetworkObserver+Init.swift index 2680f93..408bf9d 100644 --- a/Sources/SundialKitStream/NetworkObserver+Init.swift +++ b/Sources/SundialKitStream/NetworkObserver+Init.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,27 +27,26 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import SundialKitNetwork +#if canImport(Network) + public import Network + public import SundialKitNetwork -// MARK: - Convenience Initializers -@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) -extension NetworkObserver where PingType == NeverPing { - /// Creates `NetworkObserver` without ping - public init(monitor: MonitorType) { - self.init(monitor: monitor, pingOrNil: nil) + // MARK: - Convenience Initializers + @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) + extension NetworkObserver where PingType == NeverPing { + /// Creates `NetworkObserver` without ping + public init(monitor: MonitorType) { + self.init(monitor: monitor, pingOrNil: nil) + } } -} -@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) -extension NetworkObserver { - /// Creates `NetworkObserver` with ping - public init(monitor: MonitorType, ping: PingType) { - self.init(monitor: monitor, pingOrNil: ping) + @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) + extension NetworkObserver { + /// Creates `NetworkObserver` with ping + public init(monitor: MonitorType, ping: PingType) { + self.init(monitor: monitor, pingOrNil: ping) + } } -} - -#if canImport(Network) - public import Network @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) extension NetworkObserver where MonitorType == NWPathMonitor, PingType == NeverPing { diff --git a/Sources/SundialKitStream/NetworkObserver.swift b/Sources/SundialKitStream/NetworkObserver.swift index 41146c3..7e04ab2 100644 --- a/Sources/SundialKitStream/NetworkObserver.swift +++ b/Sources/SundialKitStream/NetworkObserver.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -29,185 +29,193 @@ public import Foundation public import SundialKitCore -public import SundialKitNetwork - -/// Actor-based network connectivity observer providing AsyncStream APIs -/// -/// `NetworkObserver` monitors network connectivity status using Swift concurrency. -/// This is the modern async/await replacement for the Combine-based observer. -/// -/// ## Example Usage -/// -/// ```swift -/// import SundialKitStream -/// import SundialKitNetwork -/// -/// let observer = NetworkObserver( -/// monitor: NWPathMonitorAdapter(), -/// ping: nil -/// ) -/// -/// await observer.start(queue: .global()) -/// -/// // AsyncSequence API -/// for await status in observer.pathStatusStream { -/// print("Network status: \(status)") -/// } -/// ``` -/// -@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) -public actor NetworkObserver { - // MARK: - Private Properties - - private let ping: PingType? - private let monitor: MonitorType - private var currentPath: MonitorType.PathType? - private var currentPingStatus: PingType.StatusType? - private var pathContinuations: [UUID: AsyncStream.Continuation] = [:] - private var pingStatusContinuations: [UUID: AsyncStream.Continuation] = [:] - - /// Stream of path status changes - public var pathStatusStream: AsyncStream { - AsyncStream { continuation in - Task { - for await path in pathUpdates() { - continuation.yield(path.pathStatus) + +#if canImport(Network) + public import SundialKitNetwork + + /// Actor-based network connectivity observer providing AsyncStream APIs + /// + /// `NetworkObserver` monitors network connectivity status using Swift concurrency. + /// This is the modern async/await replacement for the Combine-based observer. + /// + /// ## Example Usage + /// + /// ```swift + /// import SundialKitStream + /// import SundialKitNetwork + /// + /// let observer = NetworkObserver( + /// monitor: NWPathMonitorAdapter(), + /// ping: nil + /// ) + /// + /// await observer.start(queue: .global()) + /// + /// // AsyncSequence API + /// for await status in observer.pathStatusStream { + /// print("Network status: \(status)") + /// } + /// ``` + /// + @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) + public actor NetworkObserver { + // MARK: - Internal Properties + + internal let ping: PingType? + internal let monitor: MonitorType + internal var currentPath: MonitorType.PathType? + internal var currentPingStatus: PingType.StatusType? + internal var pathContinuations: [UUID: AsyncStream.Continuation] = [:] + internal var pathStatusContinuations: [UUID: AsyncStream.Continuation] = [:] + internal var isExpensiveContinuations: [UUID: AsyncStream.Continuation] = [:] + internal var isConstrainedContinuations: [UUID: AsyncStream.Continuation] = [:] + internal var pingStatusContinuations: [UUID: AsyncStream.Continuation] = + [:] + + /// Stream of path status changes + public var pathStatusStream: AsyncStream { + // .unbounded: connectivity state changes must never be dropped under back-pressure + AsyncStream(bufferingPolicy: .unbounded) { continuation in + let id = UUID() + pathStatusContinuations[id] = continuation + if let currentPath { + continuation.yield(currentPath.pathStatus) + } + continuation.onTermination = { [weak self] _ in + Task { await self?.removePathStatusContinuation(id: id) } } - continuation.finish() } } - } - /// Stream of expensive state changes - public var isExpensiveStream: AsyncStream { - AsyncStream { continuation in - Task { - for await path in pathUpdates() { - continuation.yield(path.isExpensive) + /// Stream of expensive state changes + public var isExpensiveStream: AsyncStream { + // .unbounded: connectivity state changes must never be dropped under back-pressure + AsyncStream(bufferingPolicy: .unbounded) { continuation in + let id = UUID() + isExpensiveContinuations[id] = continuation + if let currentPath { + continuation.yield(currentPath.isExpensive) + } + continuation.onTermination = { [weak self] _ in + Task { await self?.removeIsExpensiveContinuation(id: id) } } - continuation.finish() } } - } - /// Stream of constrained state changes - public var isConstrainedStream: AsyncStream { - AsyncStream { continuation in - Task { - for await path in pathUpdates() { - continuation.yield(path.isConstrained) + /// Stream of constrained state changes + public var isConstrainedStream: AsyncStream { + // .unbounded: connectivity state changes must never be dropped under back-pressure + AsyncStream(bufferingPolicy: .unbounded) { continuation in + let id = UUID() + isConstrainedContinuations[id] = continuation + if let currentPath { + continuation.yield(currentPath.isConstrained) + } + continuation.onTermination = { [weak self] _ in + Task { await self?.removeIsConstrainedContinuation(id: id) } } - continuation.finish() } } - } - - // MARK: - Initialization - - internal init(monitor: MonitorType, pingOrNil: PingType?) { - self.monitor = monitor - self.ping = pingOrNil - - // Setup callback from monitor - monitor.onPathUpdate { [weak self] path in - Task { await self?.handlePathUpdate(path) } - } - } - - // MARK: - Public API - - /// Starts monitoring network connectivity - /// - Parameter queue: The dispatch queue for network monitoring - public func start(queue: DispatchQueue) { - monitor.start(queue: queue) - } - /// Cancels network monitoring - public func cancel() { - monitor.cancel() + // MARK: - Initialization - // Finish all active streams - for continuation in pathContinuations.values { - continuation.finish() - } - pathContinuations.removeAll() + internal init(monitor: MonitorType, pingOrNil: PingType?) { + self.monitor = monitor + self.ping = pingOrNil - for continuation in pingStatusContinuations.values { - continuation.finish() + // Setup callback from monitor + monitor.onPathUpdate { [weak self] path in + Task { await self?.handlePathUpdate(path) } + } } - pingStatusContinuations.removeAll() - } - /// Current network path snapshot - public func getCurrentPath() -> MonitorType.PathType? { - currentPath - } - - /// Current ping status snapshot - public func getCurrentPingStatus() -> PingType.StatusType? { - currentPingStatus - } + // MARK: - Public API - // MARK: - AsyncStream APIs + #if canImport(Dispatch) + /// Starts monitoring network connectivity + /// - Parameter queue: The dispatch queue for network monitoring + public func start(queue: DispatchQueue) { + monitor.start(queue: queue) + } + #endif - /// Stream of path updates - public func pathUpdates() -> AsyncStream { - AsyncStream { continuation in - let id = UUID() - pathContinuations[id] = continuation + /// Cancels network monitoring + public func cancel() { + monitor.cancel() - // Send current value immediately if available - if let currentPath = currentPath { - continuation.yield(currentPath) + // Finish all active streams + for continuation in pathContinuations.values { + continuation.finish() } + pathContinuations.removeAll() - continuation.onTermination = { [weak self] _ in - Task { await self?.removePathContinuation(id: id) } + for continuation in pathStatusContinuations.values { + continuation.finish() } - } - } + pathStatusContinuations.removeAll() - /// Stream of ping status updates - public func pingStatusUpdates() -> AsyncStream { - AsyncStream { continuation in - let id = UUID() - pingStatusContinuations[id] = continuation + for continuation in isExpensiveContinuations.values { + continuation.finish() + } + isExpensiveContinuations.removeAll() - // Send current value immediately if available - if let currentPingStatus = currentPingStatus { - continuation.yield(currentPingStatus) + for continuation in isConstrainedContinuations.values { + continuation.finish() } + isConstrainedContinuations.removeAll() - continuation.onTermination = { [weak self] _ in - Task { await self?.removePingStatusContinuation(id: id) } + for continuation in pingStatusContinuations.values { + continuation.finish() } + pingStatusContinuations.removeAll() } - } - // MARK: - Internal Handlers - private func handlePathUpdate(_ path: MonitorType.PathType) { - currentPath = path + /// Current network path snapshot + public func getCurrentPath() -> MonitorType.PathType? { + currentPath + } - // Notify all active path stream subscribers - for continuation in pathContinuations.values { - continuation.yield(path) + /// Current ping status snapshot + public func getCurrentPingStatus() -> PingType.StatusType? { + currentPingStatus } - } - private func handlePingStatusUpdate(_ status: PingType.StatusType) { - currentPingStatus = status + // MARK: - AsyncStream APIs - // Notify all active ping status stream subscribers - for continuation in pingStatusContinuations.values { - continuation.yield(status) + /// Stream of path updates + public func pathUpdates() -> AsyncStream { + // .unbounded: connectivity state changes must never be dropped under back-pressure + AsyncStream(bufferingPolicy: .unbounded) { continuation in + let id = UUID() + pathContinuations[id] = continuation + + // Send current value immediately if available + if let currentPath = currentPath { + continuation.yield(currentPath) + } + + continuation.onTermination = { [weak self] _ in + Task { await self?.removePathContinuation(id: id) } + } + } } - } - private func removePathContinuation(id: UUID) { - pathContinuations.removeValue(forKey: id) - } + /// Stream of ping status updates + public func pingStatusUpdates() -> AsyncStream { + // .unbounded: connectivity state changes must never be dropped under back-pressure + AsyncStream(bufferingPolicy: .unbounded) { continuation in + let id = UUID() + pingStatusContinuations[id] = continuation + + // Send current value immediately if available + if let currentPingStatus = currentPingStatus { + continuation.yield(currentPingStatus) + } - private func removePingStatusContinuation(id: UUID) { - pingStatusContinuations.removeValue(forKey: id) + continuation.onTermination = { [weak self] _ in + Task { await self?.removePingStatusContinuation(id: id) } + } + } + } } -} +#endif diff --git a/Sources/SundialKitStream/StateHandling+MessageHandling.swift b/Sources/SundialKitStream/StateHandling+MessageHandling.swift index 8368f0e..96e2e1e 100644 --- a/Sources/SundialKitStream/StateHandling+MessageHandling.swift +++ b/Sources/SundialKitStream/StateHandling+MessageHandling.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/StateHandling.swift b/Sources/SundialKitStream/StateHandling.swift index 0813ba7..1524dcf 100644 --- a/Sources/SundialKitStream/StateHandling.swift +++ b/Sources/SundialKitStream/StateHandling.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/StreamContinuationManager+Messages.swift b/Sources/SundialKitStream/StreamContinuationManager+Messages.swift index a293e9d..c2103f9 100644 --- a/Sources/SundialKitStream/StreamContinuationManager+Messages.swift +++ b/Sources/SundialKitStream/StreamContinuationManager+Messages.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/StreamContinuationManager+State.swift b/Sources/SundialKitStream/StreamContinuationManager+State.swift index badc3ca..996668a 100644 --- a/Sources/SundialKitStream/StreamContinuationManager+State.swift +++ b/Sources/SundialKitStream/StreamContinuationManager+State.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/StreamContinuationManager.swift b/Sources/SundialKitStream/StreamContinuationManager.swift index a690b0c..214b1a0 100644 --- a/Sources/SundialKitStream/StreamContinuationManager.swift +++ b/Sources/SundialKitStream/StreamContinuationManager.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/StreamContinuationRegistry.swift b/Sources/SundialKitStream/StreamContinuationRegistry.swift index 1f98ae8..e4d2c7f 100644 --- a/Sources/SundialKitStream/StreamContinuationRegistry.swift +++ b/Sources/SundialKitStream/StreamContinuationRegistry.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/SundialLogger+Fallback.swift b/Sources/SundialKitStream/SundialLogger+Fallback.swift new file mode 100644 index 0000000..1409b2b --- /dev/null +++ b/Sources/SundialKitStream/SundialLogger+Fallback.swift @@ -0,0 +1,112 @@ +// +// SundialLogger+Fallback.swift +// SundialKitStream +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if !canImport(os.log) + // MARK: - Fallback for non-Apple platforms (Linux, Windows) + + /// Print-based logging fallback for platforms without OSLog + /// + /// Provides the same API as SundialLogger but uses print() for output + internal enum SundialLogger { + /// Fallback logger that prints to stdout + internal struct FallbackLogger { + internal let subsystem: String + internal let category: String + + internal func error(_ message: String) { + print("[\(subsystem):\(category)] ERROR: \(message)") + } + + internal func info(_ message: String) { + print("[\(subsystem):\(category)] INFO: \(message)") + } + + internal func debug(_ message: String) { + print("[\(subsystem):\(category)] DEBUG: \(message)") + } + } + + /// Core protocols and types + internal static let core = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Core", + category: "core" + ) + + /// Network monitoring (PathMonitor, NetworkPing) + internal static let network = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Network", + category: "network" + ) + + /// WatchConnectivity abstractions + internal static let connectivity = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Connectivity", + category: "connectivity" + ) + + /// Stream-based observers (actor-based AsyncStream APIs) + internal static let stream = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Stream", + category: "stream" + ) + + /// Combine-based observers (@MainActor with publishers) + internal static let combine = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Combine", + category: "combine" + ) + + /// Binary message encoding/decoding + internal static let binary = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Binary", + category: "binary" + ) + + /// Messagable protocol and message decoding + internal static let messagable = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Messagable", + category: "messagable" + ) + + /// Test infrastructure + internal static let test = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Tests", + category: "tests" + ) + + /// Create a custom logger for specific categories + /// - Parameters: + /// - subsystem: Reverse DNS notation subsystem identifier + /// - category: Category within the subsystem + /// - Returns: Configured FallbackLogger instance + internal static func custom(subsystem: String, category: String) -> FallbackLogger { + FallbackLogger(subsystem: subsystem, category: category) + } + } +#endif diff --git a/Sources/SundialKitStream/SundialLogger.swift b/Sources/SundialKitStream/SundialLogger.swift index c8a7f7d..42e8d35 100644 --- a/Sources/SundialKitStream/SundialLogger.swift +++ b/Sources/SundialKitStream/SundialLogger.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -31,11 +31,7 @@ import Foundation #if canImport(os.log) import os.log -#endif - -// swiftlint:disable file_types_order -#if canImport(os.log) /// Unified logging infrastructure for SundialKit /// /// Provides subsystem-based structured logging using OSLog/Logger framework. @@ -99,87 +95,4 @@ import Foundation Logger(subsystem: subsystem, category: category) } } -#else - // MARK: - Fallback for non-Apple platforms (Linux, Windows) - - /// Print-based logging fallback for platforms without OSLog - /// - /// Provides the same API as SundialLogger but uses print() for output - internal enum SundialLogger { - /// Fallback logger that prints to stdout - internal struct FallbackLogger { - internal let subsystem: String - internal let category: String - - internal func error(_ message: String) { - print("[\(subsystem):\(category)] ERROR: \(message)") - } - - internal func info(_ message: String) { - print("[\(subsystem):\(category)] INFO: \(message)") - } - - internal func debug(_ message: String) { - print("[\(subsystem):\(category)] DEBUG: \(message)") - } - } - - /// Core protocols and types - internal static let core = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Core", - category: "core" - ) - - /// Network monitoring (PathMonitor, NetworkPing) - internal static let network = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Network", - category: "network" - ) - - /// WatchConnectivity abstractions - internal static let connectivity = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Connectivity", - category: "connectivity" - ) - - /// Stream-based observers (actor-based AsyncStream APIs) - internal static let stream = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Stream", - category: "stream" - ) - - /// Combine-based observers (@MainActor with publishers) - internal static let combine = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Combine", - category: "combine" - ) - - /// Binary message encoding/decoding - internal static let binary = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Binary", - category: "binary" - ) - - /// Messagable protocol and message decoding - internal static let messagable = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Messagable", - category: "messagable" - ) - - /// Test infrastructure - internal static let test = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Tests", - category: "tests" - ) - - /// Create a custom logger for specific categories - /// - Parameters: - /// - subsystem: Reverse DNS notation subsystem identifier - /// - category: Category within the subsystem - /// - Returns: Configured FallbackLogger instance - internal static func custom(subsystem: String, category: String) -> FallbackLogger { - FallbackLogger(subsystem: subsystem, category: category) - } - } #endif -// swiftlint:enable file_types_order diff --git a/Tests/SundialKitStreamTests/MockNetworkPing.swift b/Tests/SundialKitStreamTests/MockNetworkPing.swift index a863eb6..39058aa 100644 --- a/Tests/SundialKitStreamTests/MockNetworkPing.swift +++ b/Tests/SundialKitStreamTests/MockNetworkPing.swift @@ -5,43 +5,45 @@ // Created by Leo Dion on 11/14/25. // -import Foundation - -@testable import SundialKitCore -@testable import SundialKitNetwork -@testable import SundialKitStream - -internal final class MockNetworkPing: NetworkPing, @unchecked Sendable { - internal struct StatusType: Sendable, Equatable { - internal let value: String - } - - internal private(set) var lastShouldPingStatus: PathStatus? - internal let id: UUID - internal let timeInterval: TimeInterval - internal var shouldPingResponse: Bool - internal var onPingHandler: ((StatusType) -> Void)? - - internal init( - id: UUID = UUID(), - timeInterval: TimeInterval = 1.0, - shouldPingResponse: Bool = true - ) { - self.id = id - self.timeInterval = timeInterval - self.shouldPingResponse = shouldPingResponse - } - - internal func shouldPing(onStatus status: PathStatus) -> Bool { - lastShouldPingStatus = status - return shouldPingResponse - } - - internal func onPing(_ closure: @escaping (StatusType) -> Void) { - onPingHandler = closure - } - - internal func sendPingStatus(_ status: StatusType) { - onPingHandler?(status) +#if canImport(Network) + import Foundation + + @testable import SundialKitCore + @testable import SundialKitNetwork + @testable import SundialKitStream + + internal final class MockNetworkPing: NetworkPing, @unchecked Sendable { + internal struct StatusType: Sendable, Equatable { + internal let value: String + } + + internal private(set) var lastShouldPingStatus: PathStatus? + internal let id: UUID + internal let timeInterval: TimeInterval + internal var shouldPingResponse: Bool + internal var onPingHandler: ((StatusType) -> Void)? + + internal init( + id: UUID = UUID(), + timeInterval: TimeInterval = 1.0, + shouldPingResponse: Bool = true + ) { + self.id = id + self.timeInterval = timeInterval + self.shouldPingResponse = shouldPingResponse + } + + internal func shouldPing(onStatus status: PathStatus) -> Bool { + lastShouldPingStatus = status + return shouldPingResponse + } + + internal func onPing(_ closure: @escaping (StatusType) -> Void) { + onPingHandler = closure + } + + internal func sendPingStatus(_ status: StatusType) { + onPingHandler?(status) + } } -} +#endif diff --git a/Tests/SundialKitStreamTests/MockPath.swift b/Tests/SundialKitStreamTests/MockPath.swift index d7d2c01..a282ea4 100644 --- a/Tests/SundialKitStreamTests/MockPath.swift +++ b/Tests/SundialKitStreamTests/MockPath.swift @@ -5,22 +5,24 @@ // Created by Leo Dion on 11/14/25. // -@testable import SundialKitCore -@testable import SundialKitNetwork -@testable import SundialKitStream +#if canImport(Network) + @testable import SundialKitCore + @testable import SundialKitNetwork + @testable import SundialKitStream -internal struct MockPath: NetworkPath { - internal let isConstrained: Bool - internal let isExpensive: Bool - internal let pathStatus: PathStatus + internal struct MockPath: NetworkPath { + internal let isConstrained: Bool + internal let isExpensive: Bool + internal let pathStatus: PathStatus - internal init( - isConstrained: Bool = false, - isExpensive: Bool = false, - pathStatus: PathStatus = .unknown - ) { - self.isConstrained = isConstrained - self.isExpensive = isExpensive - self.pathStatus = pathStatus + internal init( + isConstrained: Bool = false, + isExpensive: Bool = false, + pathStatus: PathStatus = .unknown + ) { + self.isConstrained = isConstrained + self.isExpensive = isExpensive + self.pathStatus = pathStatus + } } -} +#endif diff --git a/Tests/SundialKitStreamTests/MockPathMonitor.swift b/Tests/SundialKitStreamTests/MockPathMonitor.swift index b0439c3..c7390c0 100644 --- a/Tests/SundialKitStreamTests/MockPathMonitor.swift +++ b/Tests/SundialKitStreamTests/MockPathMonitor.swift @@ -27,47 +27,49 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +#if canImport(Network) && canImport(Dispatch) + import Foundation -@testable import SundialKitCore -@testable import SundialKitNetwork -@testable import SundialKitStream + @testable import SundialKitCore + @testable import SundialKitNetwork + @testable import SundialKitStream -// MARK: - Mock Implementations + // MARK: - Mock Implementations -internal final class MockPathMonitor: PathMonitor, @unchecked Sendable { - internal typealias PathType = MockPath + internal final class MockPathMonitor: PathMonitor, @unchecked Sendable { + internal typealias PathType = MockPath - internal let id: UUID - internal private(set) var pathUpdate: ((MockPath) -> Void)? - internal private(set) var dispatchQueueLabel: String? - internal private(set) var isCancelled = false + internal let id: UUID + internal private(set) var pathUpdate: ((MockPath) -> Void)? + internal private(set) var dispatchQueueLabel: String? + internal private(set) var isCancelled = false - internal init(id: UUID = UUID()) { - self.id = id - } + internal init(id: UUID = UUID()) { + self.id = id + } - internal func onPathUpdate(_ handler: @escaping (MockPath) -> Void) { - pathUpdate = handler - } + internal func onPathUpdate(_ handler: @escaping (MockPath) -> Void) { + pathUpdate = handler + } - internal func start(queue: DispatchQueue) { - dispatchQueueLabel = queue.label - // Immediately send an initial path - pathUpdate?( - .init( - isConstrained: false, - isExpensive: false, - pathStatus: .satisfied(.wiredEthernet) + internal func start(queue: DispatchQueue) { + dispatchQueueLabel = queue.label + // Immediately send an initial path + pathUpdate?( + .init( + isConstrained: false, + isExpensive: false, + pathStatus: .satisfied(.wiredEthernet) + ) ) - ) - } + } - internal func cancel() { - isCancelled = true - } + internal func cancel() { + isCancelled = true + } - internal func sendPath(_ path: MockPath) { - pathUpdate?(path) + internal func sendPath(_ path: MockPath) { + pathUpdate?(path) + } } -} +#endif diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift b/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift index 6e4d475..e664071 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift @@ -27,117 +27,119 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +#if canImport(Network) + import Foundation + import Testing -@testable import SundialKitCore -@testable import SundialKitNetwork -@testable import SundialKitStream + @testable import SundialKitCore + @testable import SundialKitNetwork + @testable import SundialKitStream -extension NetworkObserverTests { - @Suite("Edge Cases and State Tests") - internal struct EdgeCasesTests { - // MARK: - Current State Tests + extension NetworkObserverTests { + @Suite("Edge Cases and State Tests") + internal struct EdgeCasesTests { + // MARK: - Current State Tests - @Test("getCurrentPath returns latest path") - internal func getCurrentPathSnapshot() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("getCurrentPath returns latest path") + internal func getCurrentPathSnapshot() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - // Before start - var currentPath = await observer.getCurrentPath() - #expect(currentPath == nil) + // Before start + var currentPath = await observer.getCurrentPath() + #expect(currentPath == nil) - // After start - await observer.start(queue: .global()) + // After start + await observer.start(queue: .global()) - // Give time for async path update from start() - try? await Task.sleep(for: .milliseconds(10)) + // Give time for async path update from start() + try? await Task.sleep(for: .milliseconds(10)) - currentPath = await observer.getCurrentPath() - #expect(currentPath?.pathStatus == .satisfied(.wiredEthernet)) + currentPath = await observer.getCurrentPath() + #expect(currentPath?.pathStatus == .satisfied(.wiredEthernet)) - // After update - let newPath = MockPath(pathStatus: .satisfied(.wifi)) - monitor.sendPath(newPath) + // After update + let newPath = MockPath(pathStatus: .satisfied(.wifi)) + monitor.sendPath(newPath) - // Give time for async update - try? await Task.sleep(for: .milliseconds(10)) + // Give time for async update + try? await Task.sleep(for: .milliseconds(10)) - currentPath = await observer.getCurrentPath() - #expect(currentPath?.pathStatus == .satisfied(.wifi)) - } + currentPath = await observer.getCurrentPath() + #expect(currentPath?.pathStatus == .satisfied(.wifi)) + } - @Test("getCurrentPingStatus returns nil when no ping configured") - internal func getCurrentPingStatusWithoutPing() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("getCurrentPingStatus returns nil when no ping configured") + internal func getCurrentPingStatusWithoutPing() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) + await observer.start(queue: .global()) - let pingStatus = await observer.getCurrentPingStatus() - #expect(pingStatus == nil) - } + let pingStatus = await observer.getCurrentPingStatus() + #expect(pingStatus == nil) + } - // MARK: - Stream Cleanup Tests + // MARK: - Stream Cleanup Tests - @Test("Cancel finishes all active path streams") - internal func cancelFinishesPathStreams() async throws { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("Cancel finishes all active path streams") + internal func cancelFinishesPathStreams() async throws { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) + await observer.start(queue: .global()) - let stream = await observer.pathUpdates() - var iterator = stream.makeAsyncIterator() + let stream = await observer.pathUpdates() + var iterator = stream.makeAsyncIterator() - // Get first value - _ = await iterator.next() + // Get first value + _ = await iterator.next() - // Cancel observer - await observer.cancel() + // Cancel observer + await observer.cancel() - // Try to get next value - should complete - let nextValue = await iterator.next() - #expect(nextValue == nil) - } + // Try to get next value - should complete + let nextValue = await iterator.next() + #expect(nextValue == nil) + } - @Test("Stream iteration completes after cancel") - internal func streamCompletesAfterCancel() async throws { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) - - await observer.start(queue: .global()) - - let capture = TestValueCapture() - - try await confirmation("Received initial status", expectedCount: 1) { confirm in - Task { @Sendable in - let stream = await observer.pathStatusStream - var count = 0 - for await _ in stream { - count += 1 - if count == 1 { - confirm() - } else { - // Should not receive values after cancel - await capture.set(boolValue: true) + @Test("Stream iteration completes after cancel") + internal func streamCompletesAfterCancel() async throws { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) + + await observer.start(queue: .global()) + + let capture = TestValueCapture() + + try await confirmation("Received initial status", expectedCount: 1) { confirm in + Task { @Sendable in + let stream = await observer.pathStatusStream + var count = 0 + for await _ in stream { + count += 1 + if count == 1 { + confirm() + } else { + // Should not receive values after cancel + await capture.set(boolValue: true) + } } } - } - // Wait for initial value confirmation - try await Task.sleep(for: .milliseconds(50)) + // Wait for initial value confirmation + try await Task.sleep(for: .milliseconds(50)) - // Cancel observer - await observer.cancel() + // Cancel observer + await observer.cancel() - // Give time to verify no additional values are received - try await Task.sleep(for: .milliseconds(100)) - } + // Give time to verify no additional values are received + try await Task.sleep(for: .milliseconds(100)) + } - let receivedAfterCancel = await capture.boolValue - #expect(receivedAfterCancel != true) + let receivedAfterCancel = await capture.boolValue + #expect(receivedAfterCancel != true) + } } } -} +#endif diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift b/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift index d6ff4bb..826123f 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift @@ -27,115 +27,117 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +#if canImport(Network) + import Foundation + import Testing -@testable import SundialKitCore -@testable import SundialKitNetwork -@testable import SundialKitStream + @testable import SundialKitCore + @testable import SundialKitNetwork + @testable import SundialKitStream -extension NetworkObserverTests { - @Suite("Initialization and Lifecycle Tests") - internal struct InitializationTests { - // MARK: - Initialization Tests + extension NetworkObserverTests { + @Suite("Initialization and Lifecycle Tests") + internal struct InitializationTests { + // MARK: - Initialization Tests - @Test("NetworkObserver initializes with monitor only") - internal func initializationWithMonitorOnly() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("NetworkObserver initializes with monitor only") + internal func initializationWithMonitorOnly() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - let currentPath = await observer.getCurrentPath() - let currentPingStatus = await observer.getCurrentPingStatus() + let currentPath = await observer.getCurrentPath() + let currentPingStatus = await observer.getCurrentPingStatus() - #expect(currentPath == nil) - #expect(currentPingStatus == nil) - } + #expect(currentPath == nil) + #expect(currentPingStatus == nil) + } - @Test("NetworkObserver initializes with monitor and ping") - internal func initializationWithMonitorAndPing() async { - let monitor = MockPathMonitor() - let ping = MockNetworkPing() - let observer = NetworkObserver(monitor: monitor, ping: ping) + @Test("NetworkObserver initializes with monitor and ping") + internal func initializationWithMonitorAndPing() async { + let monitor = MockPathMonitor() + let ping = MockNetworkPing() + let observer = NetworkObserver(monitor: monitor, ping: ping) - let currentPath = await observer.getCurrentPath() - let currentPingStatus = await observer.getCurrentPingStatus() + let currentPath = await observer.getCurrentPath() + let currentPingStatus = await observer.getCurrentPingStatus() - #expect(currentPath == nil) - #expect(currentPingStatus == nil) - } + #expect(currentPath == nil) + #expect(currentPingStatus == nil) + } - // MARK: - Start/Cancel Tests + // MARK: - Start/Cancel Tests - @Test("Start monitoring begins path updates") - internal func startMonitoring() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("Start monitoring begins path updates") + internal func startMonitoring() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) + await observer.start(queue: .global()) - #expect(monitor.dispatchQueueLabel != nil) - #expect(monitor.isCancelled == false) + #expect(monitor.dispatchQueueLabel != nil) + #expect(monitor.isCancelled == false) - // Give time for async path update from start() - try? await Task.sleep(for: .milliseconds(10)) + // Give time for async path update from start() + try? await Task.sleep(for: .milliseconds(10)) - // Should receive initial path from start() - let currentPath = await observer.getCurrentPath() - #expect(currentPath != nil) - #expect(currentPath?.pathStatus == .satisfied(.wiredEthernet)) - } + // Should receive initial path from start() + let currentPath = await observer.getCurrentPath() + #expect(currentPath != nil) + #expect(currentPath?.pathStatus == .satisfied(.wiredEthernet)) + } - @Test("Cancel stops monitoring and finishes streams") - internal func cancelMonitoring() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("Cancel stops monitoring and finishes streams") + internal func cancelMonitoring() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) - await observer.cancel() + await observer.start(queue: .global()) + await observer.cancel() - #expect(monitor.isCancelled == true) - } + #expect(monitor.isCancelled == true) + } - @Test("Path updates before start are not tracked") - internal func pathUpdatesBeforeStart() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("Path updates before start are not tracked") + internal func pathUpdatesBeforeStart() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - // Don't call start() - let currentPath = await observer.getCurrentPath() - #expect(currentPath == nil) - } + // Don't call start() + let currentPath = await observer.getCurrentPath() + #expect(currentPath == nil) + } - @Test("Multiple start calls use latest queue") - internal func multipleStartCalls() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("Multiple start calls use latest queue") + internal func multipleStartCalls() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) - let firstLabel = monitor.dispatchQueueLabel + await observer.start(queue: .global()) + let firstLabel = monitor.dispatchQueueLabel - await observer.start(queue: .main) - let secondLabel = monitor.dispatchQueueLabel + await observer.start(queue: .main) + let secondLabel = monitor.dispatchQueueLabel - #expect(firstLabel != nil) - #expect(secondLabel != nil) - // Labels should be different since we used different queues - } + #expect(firstLabel != nil) + #expect(secondLabel != nil) + // Labels should be different since we used different queues + } - // MARK: - Ping Integration Tests + // MARK: - Ping Integration Tests - @Test("Ping status updates are not tracked without ping initialization") - internal func pingStatusWithoutPing() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("Ping status updates are not tracked without ping initialization") + internal func pingStatusWithoutPing() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) + await observer.start(queue: .global()) - let currentPingStatus = await observer.getCurrentPingStatus() - #expect(currentPingStatus == nil) - } + let currentPingStatus = await observer.getCurrentPingStatus() + #expect(currentPingStatus == nil) + } - // Note: Full ping integration testing would require NetworkMonitor-level tests - // since NetworkObserver doesn't directly manage ping lifecycle + // Note: Full ping integration testing would require NetworkMonitor-level tests + // since NetworkObserver doesn't directly manage ping lifecycle + } } -} +#endif diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift b/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift index 3298962..36ed25b 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift @@ -27,194 +27,196 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing - -@testable import SundialKitCore -@testable import SundialKitNetwork -@testable import SundialKitStream - -extension NetworkObserverTests { - @Suite("Stream Tests") - internal struct StreamTests { - // MARK: - Path Updates Stream Tests - - @Test("pathUpdates stream receives initial and subsequent paths") - internal func pathUpdatesStream() async throws { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) - - await observer.start(queue: .global()) - - let stream = await observer.pathUpdates() - var iterator = stream.makeAsyncIterator() - - // Should receive initial path - let firstPath = await iterator.next() - #expect(firstPath?.pathStatus == .satisfied(.wiredEthernet)) - - // Send new path update - let newPath = MockPath( - isConstrained: true, - isExpensive: true, - pathStatus: .satisfied(.cellular) - ) - monitor.sendPath(newPath) - - // Give time for async delivery - try await Task.sleep(for: .milliseconds(10)) - - let secondPath = await iterator.next() - #expect(secondPath?.pathStatus == .satisfied(.cellular)) - #expect(secondPath?.isConstrained == true) - #expect(secondPath?.isExpensive == true) - } +#if canImport(Network) + import Foundation + import Testing - @Test("pathStatusStream extracts status from paths") - internal func pathStatusStream() async throws { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @testable import SundialKitCore + @testable import SundialKitNetwork + @testable import SundialKitStream - await observer.start(queue: .global()) + extension NetworkObserverTests { + @Suite("Stream Tests") + internal struct StreamTests { + // MARK: - Path Updates Stream Tests - try await confirmation("Received path status", expectedCount: 2) { receivedStatus in - let capture = TestValueCapture() + @Test("pathUpdates stream receives initial and subsequent paths") + internal func pathUpdatesStream() async throws { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - Task { @Sendable in - let stream = await observer.pathStatusStream - for await status in stream { - await capture.append(pathStatus: status) - receivedStatus() - let count = await capture.pathStatuses.count - if count >= 2 { break } - } - } + await observer.start(queue: .global()) - // Wait briefly for initial status - try await Task.sleep(for: .milliseconds(10)) + let stream = await observer.pathUpdates() + var iterator = stream.makeAsyncIterator() + + // Should receive initial path + let firstPath = await iterator.next() + #expect(firstPath?.pathStatus == .satisfied(.wiredEthernet)) // Send new path update - let newPath = MockPath(pathStatus: .unsatisfied(.localNetworkDenied)) + let newPath = MockPath( + isConstrained: true, + isExpensive: true, + pathStatus: .satisfied(.cellular) + ) monitor.sendPath(newPath) // Give time for async delivery - try await Task.sleep(for: .milliseconds(50)) + try await Task.sleep(for: .milliseconds(10)) - let statuses = await capture.pathStatuses - #expect(statuses.count == 2) - #expect(statuses[0] == .satisfied(.wiredEthernet)) - #expect(statuses[1] == .unsatisfied(.localNetworkDenied)) + let secondPath = await iterator.next() + #expect(secondPath?.pathStatus == .satisfied(.cellular)) + #expect(secondPath?.isConstrained == true) + #expect(secondPath?.isExpensive == true) } - } - @Test("isExpensiveStream tracks expensive status") - internal func isExpensiveStream() async throws { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("pathStatusStream extracts status from paths") + internal func pathStatusStream() async throws { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) + await observer.start(queue: .global()) - try await confirmation("Received expensive status", expectedCount: 2) { receivedValue in - let capture = TestValueCapture() + try await confirmation("Received path status", expectedCount: 2) { receivedStatus in + let capture = TestValueCapture() - Task { @Sendable in - let stream = await observer.isExpensiveStream - for await value in stream { - await capture.append(boolValue: value) - receivedValue() - let count = await capture.boolValues.count - if count >= 2 { break } + Task { @Sendable in + let stream = await observer.pathStatusStream + for await status in stream { + await capture.append(pathStatus: status) + receivedStatus() + let count = await capture.pathStatuses.count + if count >= 2 { break } + } } - } - // Wait briefly for initial value - try await Task.sleep(for: .milliseconds(10)) + // Wait briefly for initial status + try await Task.sleep(for: .milliseconds(10)) - // Send expensive path - let expensivePath = MockPath(isExpensive: true, pathStatus: .satisfied(.cellular)) - monitor.sendPath(expensivePath) + // Send new path update + let newPath = MockPath(pathStatus: .unsatisfied(.localNetworkDenied)) + monitor.sendPath(newPath) - // Give time for async delivery - try await Task.sleep(for: .milliseconds(50)) + // Give time for async delivery + try await Task.sleep(for: .milliseconds(50)) - let values = await capture.boolValues - #expect(values.count == 2) - #expect(values[0] == false) - #expect(values[1] == true) + let statuses = await capture.pathStatuses + #expect(statuses.count == 2) + #expect(statuses[0] == .satisfied(.wiredEthernet)) + #expect(statuses[1] == .unsatisfied(.localNetworkDenied)) + } } - } - @Test("isConstrainedStream tracks constrained status") - internal func isConstrainedStream() async throws { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("isExpensiveStream tracks expensive status") + internal func isExpensiveStream() async throws { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) + await observer.start(queue: .global()) - try await confirmation("Received constrained status", expectedCount: 2) { receivedValue in - let capture = TestValueCapture() + try await confirmation("Received expensive status", expectedCount: 2) { receivedValue in + let capture = TestValueCapture() - Task { @Sendable in - let stream = await observer.isConstrainedStream - for await value in stream { - await capture.append(boolValue: value) - receivedValue() - let count = await capture.boolValues.count - if count >= 2 { break } + Task { @Sendable in + let stream = await observer.isExpensiveStream + for await value in stream { + await capture.append(boolValue: value) + receivedValue() + let count = await capture.boolValues.count + if count >= 2 { break } + } } + + // Wait briefly for initial value + try await Task.sleep(for: .milliseconds(10)) + + // Send expensive path + let expensivePath = MockPath(isExpensive: true, pathStatus: .satisfied(.cellular)) + monitor.sendPath(expensivePath) + + // Give time for async delivery + try await Task.sleep(for: .milliseconds(50)) + + let values = await capture.boolValues + #expect(values.count == 2) + #expect(values[0] == false) + #expect(values[1] == true) } + } - // Wait briefly for initial value - try await Task.sleep(for: .milliseconds(10)) + @Test("isConstrainedStream tracks constrained status") + internal func isConstrainedStream() async throws { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - // Send constrained path - let constrainedPath = MockPath(isConstrained: true, pathStatus: .satisfied(.wifi)) - monitor.sendPath(constrainedPath) + await observer.start(queue: .global()) - // Give time for async delivery - try await Task.sleep(for: .milliseconds(50)) + try await confirmation("Received constrained status", expectedCount: 2) { receivedValue in + let capture = TestValueCapture() + + Task { @Sendable in + let stream = await observer.isConstrainedStream + for await value in stream { + await capture.append(boolValue: value) + receivedValue() + let count = await capture.boolValues.count + if count >= 2 { break } + } + } + + // Wait briefly for initial value + try await Task.sleep(for: .milliseconds(10)) - let values = await capture.boolValues - #expect(values.count == 2) - #expect(values[0] == false) - #expect(values[1] == true) + // Send constrained path + let constrainedPath = MockPath(isConstrained: true, pathStatus: .satisfied(.wifi)) + monitor.sendPath(constrainedPath) + + // Give time for async delivery + try await Task.sleep(for: .milliseconds(50)) + + let values = await capture.boolValues + #expect(values.count == 2) + #expect(values[0] == false) + #expect(values[1] == true) + } } - } - // MARK: - Multiple Subscribers Tests + // MARK: - Multiple Subscribers Tests - @Test("Multiple path update subscribers receive same updates") - internal func multiplePathSubscribers() async throws { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("Multiple path update subscribers receive same updates") + internal func multiplePathSubscribers() async throws { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) + await observer.start(queue: .global()) - // Create two subscribers - let stream1 = await observer.pathUpdates() - let stream2 = await observer.pathUpdates() + // Create two subscribers + let stream1 = await observer.pathUpdates() + let stream2 = await observer.pathUpdates() - var iterator1 = stream1.makeAsyncIterator() - var iterator2 = stream2.makeAsyncIterator() + var iterator1 = stream1.makeAsyncIterator() + var iterator2 = stream2.makeAsyncIterator() - // Both should receive initial path - let path1First = await iterator1.next() - let path2First = await iterator2.next() + // Both should receive initial path + let path1First = await iterator1.next() + let path2First = await iterator2.next() - #expect(path1First?.pathStatus == .satisfied(.wiredEthernet)) - #expect(path2First?.pathStatus == .satisfied(.wiredEthernet)) + #expect(path1First?.pathStatus == .satisfied(.wiredEthernet)) + #expect(path2First?.pathStatus == .satisfied(.wiredEthernet)) - // Send new path - let newPath = MockPath(pathStatus: .satisfied(.cellular)) - monitor.sendPath(newPath) + // Send new path + let newPath = MockPath(pathStatus: .satisfied(.cellular)) + monitor.sendPath(newPath) - try await Task.sleep(for: .milliseconds(10)) + try await Task.sleep(for: .milliseconds(10)) - let path1Second = await iterator1.next() - let path2Second = await iterator2.next() + let path1Second = await iterator1.next() + let path2Second = await iterator2.next() - #expect(path1Second?.pathStatus == .satisfied(.cellular)) - #expect(path2Second?.pathStatus == .satisfied(.cellular)) + #expect(path1Second?.pathStatus == .satisfied(.cellular)) + #expect(path2Second?.pathStatus == .satisfied(.cellular)) + } } } -} +#endif diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests.swift b/Tests/SundialKitStreamTests/NetworkObserverTests.swift index b72a232..c071fa8 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests.swift @@ -29,5 +29,8 @@ import Testing -@Suite("NetworkObserver Tests") +@Suite( + "NetworkObserver Tests", + .enabled(if: SupportedModule([.dispatch, .network]).isSupported) +) internal enum NetworkObserverTests {} diff --git a/Tests/SundialKitStreamTests/SupportedModule.swift b/Tests/SundialKitStreamTests/SupportedModule.swift new file mode 100644 index 0000000..55f4489 --- /dev/null +++ b/Tests/SundialKitStreamTests/SupportedModule.swift @@ -0,0 +1,54 @@ +// +// SupportedModule.swift +// SundialKitStream +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal struct SupportedModule: OptionSet, Hashable { + internal static let dispatch: Self = .init(rawValue: 1) + internal static let network: Self = .init(rawValue: 2) + + private static let supported: Self = { + var values: [Self] = [] + #if canImport(Network) + values.append(.network) + #endif + #if canImport(Dispatch) + values.append(.dispatch) + #endif + return .init(values) + }() + + internal let rawValue: Int + + internal var isSupported: Bool { + self.isSubset(of: Self.supported) + } + + internal init(rawValue: Int) { + self.rawValue = rawValue + } +}