diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..70b4edb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Build + run: swift build + + - name: Test with coverage + run: swift test --enable-code-coverage -Xcc -fprofile-instr-generate -Xcc -fcoverage-mapping + + - name: Export coverage to lcov + run: | + BIN=$(swift build --show-bin-path) + xcrun llvm-cov export \ + "$BIN/FaketoothPackageTests.xctest/Contents/MacOS/FaketoothPackageTests" \ + -instr-profile "$(find .build -name default.profdata -type f)" \ + -format lcov > coverage.lcov + + - name: Upload coverage report artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.lcov + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.lcov diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..070c0d1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,144 @@ +name: Release + +on: + push: + branches: [master] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve version and create release + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + # --- Determine base version and commit range ----------------------- + + LATEST_TAG=$(git tag --list 'v*' --sort=-v:refname | head -n1) + + if [ -z "$LATEST_TAG" ]; then + BASE_VERSION="0.0.0" + RANGE="HEAD" + else + BASE_VERSION="${LATEST_TAG#v}" + RANGE="${LATEST_TAG}..HEAD" + fi + + COMMITS=$(git log --format="%H %s" "$RANGE") + + if [ -z "$COMMITS" ]; then + echo "No new commits since ${LATEST_TAG}. Skipping." + exit 0 + fi + + # --- Analyse commits (Conventional Commits) ------------------------ + + BUMP="none" + FEAT_LOG="" + FIX_LOG="" + PERF_LOG="" + DOCS_LOG="" + OTHER_LOG="" + + while IFS= read -r line; do + HASH="${line%% *}" + MSG="${line#* }" + SHORT_HASH="${HASH:0:7}" + + # Detect breaking changes (! suffix or BREAKING CHANGE trailer) + if echo "$MSG" | grep -qE '^[a-z]+(\(.+\))?!:'; then + BUMP="major" + elif echo "$MSG" | grep -qi 'BREAKING CHANGE'; then + BUMP="major" + fi + + # Extract type and description + TYPE=$(echo "$MSG" | sed -nE 's/^([a-z]+)(\(.+\))?(!)? *:.*/\1/p') + DESC=$(echo "$MSG" | sed -nE 's/^[a-z]+(\(.+\))?(!)? *: *(.*)/\3/p') + + if [ -z "$TYPE" ]; then + TYPE="other" + DESC="$MSG" + fi + + ENTRY="- ${DESC} (\`${SHORT_HASH}\`)" + + case "$TYPE" in + feat) + [ "$BUMP" != "major" ] && BUMP="minor" + FEAT_LOG="${FEAT_LOG}${ENTRY}"$'\n' + ;; + fix) + [ "$BUMP" = "none" ] && BUMP="patch" + FIX_LOG="${FIX_LOG}${ENTRY}"$'\n' + ;; + perf) + [ "$BUMP" = "none" ] && BUMP="patch" + PERF_LOG="${PERF_LOG}${ENTRY}"$'\n' + ;; + docs) + [ "$BUMP" = "none" ] && BUMP="patch" + DOCS_LOG="${DOCS_LOG}${ENTRY}"$'\n' + ;; + *) + [ "$BUMP" = "none" ] && BUMP="patch" + OTHER_LOG="${OTHER_LOG}${ENTRY}"$'\n' + ;; + esac + done <<< "$COMMITS" + + if [ "$BUMP" = "none" ]; then + echo "No version-bumping commits (feat/fix/perf/breaking). Skipping." + exit 0 + fi + + # --- Calculate new version ----------------------------------------- + + IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE_VERSION" + + case "$BUMP" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + esac + + NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}" + echo "Bump: ${BUMP} ${BASE_VERSION} → ${NEW_TAG}" + + # --- Build changelog ------------------------------------------------ + + BODY="## What's Changed" + + [ -n "$FEAT_LOG" ] && BODY="${BODY}"$'\n\n'"### Features"$'\n'"${FEAT_LOG}" + [ -n "$FIX_LOG" ] && BODY="${BODY}"$'\n\n'"### Bug Fixes"$'\n'"${FIX_LOG}" + [ -n "$PERF_LOG" ] && BODY="${BODY}"$'\n\n'"### Performance"$'\n'"${PERF_LOG}" + [ -n "$DOCS_LOG" ] && BODY="${BODY}"$'\n\n'"### Documentation"$'\n'"${DOCS_LOG}" + [ -n "$OTHER_LOG" ] && BODY="${BODY}"$'\n\n'"### Other Changes"$'\n'"${OTHER_LOG}" + + if [ -n "$LATEST_TAG" ]; then + BODY="${BODY}"$'\n'"**Full Changelog**: https://github.com/${REPO}/compare/${LATEST_TAG}...${NEW_TAG}" + fi + + # --- Tag and release ------------------------------------------------ + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git tag "$NEW_TAG" + git push origin "$NEW_TAG" + + gh release create "$NEW_TAG" \ + --title "$NEW_TAG" \ + --notes "$BODY" + + echo "Released ${NEW_TAG}" diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml deleted file mode 100644 index 5dbdb4f..0000000 --- a/.github/workflows/swift.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Swift - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - - runs-on: macos-latest - - steps: - - uses: actions/checkout@v2 - - name: Build - run: swift build -v - - name: Run tests - run: swift test -v diff --git a/.gitignore b/.gitignore index 95c4320..f32f41e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /Packages /*.xcodeproj xcuserdata/ + +.claude/settings.local.json diff --git a/Tests/FaketoothTests/CBCentralManagerTest+Fallbacks.swift b/Tests/FaketoothTests/CBCentralManagerTest+Fallbacks.swift new file mode 100644 index 0000000..5fc4a8e --- /dev/null +++ b/Tests/FaketoothTests/CBCentralManagerTest+Fallbacks.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import Faketooth + +extension CBCentralManagerTest { + + /// When simulatedPeripherals is nil, scanForPeripherals should fall through + /// to the original CoreBluetooth implementation without crashing. + func testScanForPeripheralsFallbackToBluetooth() { + // Ensure simulation is off + XCTAssertNil(CBCentralManager.simulatedPeripherals) + + // Should not crash — falls through to real CoreBluetooth + centralManager.scanForPeripherals(withServices: nil, options: nil) + + // isScanning should reflect real CoreBluetooth state (not the faketooth state) + // We just verify no crash occurred + } + + func testStopScanFallbackToBluetooth() { + XCTAssertNil(CBCentralManager.simulatedPeripherals) + + // Should not crash + centralManager.stopScan() + } + + func testRetrievePeripheralsFallbackToBluetooth() { + XCTAssertNil(CBCentralManager.simulatedPeripherals) + + // Should return real CoreBluetooth result (likely empty) + let result = centralManager.retrievePeripherals(withIdentifiers: [.testPeripheral]) + XCTAssertTrue(result.isEmpty, "No real Bluetooth peripherals expected in test environment") + } + + func testConnectPeripheralFallbackToBluetooth() { + let peripheral = faketoothSetupPeripheral() + XCTAssertNil(CBCentralManager.simulatedPeripherals) + + // Should not crash — falls through to real CoreBluetooth connect + centralManager.connect(peripheral, options: nil) + } + + func testCancelPeripheralConnectionFallbackToBluetooth() { + let peripheral = faketoothSetupPeripheral() + XCTAssertNil(CBCentralManager.simulatedPeripherals) + + // Should not crash + centralManager.cancelPeripheralConnection(peripheral) + } +} diff --git a/Tests/FaketoothTests/CBCentralManagerTest.swift b/Tests/FaketoothTests/CBCentralManagerTest.swift index e9544d3..a48bf79 100644 --- a/Tests/FaketoothTests/CBCentralManagerTest.swift +++ b/Tests/FaketoothTests/CBCentralManagerTest.swift @@ -153,20 +153,7 @@ final class CBCentralManagerTest: XCTestCase { XCTAssertFalse(CBCentralManager.isSimulated) } - // MARK: Test fallbacks to CoreBluetooth implementation - - func testFallbackToBluetoothScanForPeripherals() { - - let expectattion = XCTestExpectation(description: "Fallback to Bluetooth scanForPeripherals() method") - expectattion.isInverted = true - - centralManagerDelegate.onDidDiscoverPeripheral = { _, _, _ in - expectattion.fulfill() - } - centralManager.scanForPeripherals(withServices: nil, options: nil) - - wait(for: [expectattion], timeout: Double(FaketoothSettings.delay.scanForPeripheralDelayInSeconds) + 0.1) - } + // MARK: Faketooth private methods func testFaketoothMethods() { diff --git a/Tests/FaketoothTests/FaketoothCharacteristicTest.swift b/Tests/FaketoothTests/FaketoothCharacteristicTest.swift new file mode 100644 index 0000000..a1f3722 --- /dev/null +++ b/Tests/FaketoothTests/FaketoothCharacteristicTest.swift @@ -0,0 +1,227 @@ +import XCTest +@testable import Faketooth + +final class FaketoothCharacteristicTest: XCTestCase { + + override func tearDown() { + XCTestCase.faketoothTearDown() + } + + // MARK: - Initialization + + func testInitWithUUIDAndProperties() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read, .write], + valueProducer: nil, + valueHandler: nil + ) + + XCTAssertEqual(characteristic.uuid, .testCharacteristic) + XCTAssertEqual(characteristic.properties, [.read, .write]) + XCTAssertNil(characteristic.descriptors) + } + + func testInitWithDescriptors() { + let descriptor = FaketoothDescriptor( + uuid: .configDescriptor, + valueProducer: nil, + valueHandler: nil + ) + + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read], + descriptors: [descriptor], + valueProducer: nil, + valueHandler: nil + ) + + XCTAssertEqual(characteristic.descriptors?.count, 1) + XCTAssertEqual(characteristic.descriptors?.first?.uuid, .configDescriptor) + } + + func testInitRegistersDescriptorCharacteristic() { + let descriptor = FaketoothDescriptor( + uuid: .configDescriptor, + valueProducer: nil, + valueHandler: nil + ) + + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read], + descriptors: [descriptor], + valueProducer: nil, + valueHandler: nil + ) + + // The descriptor's characteristic should be set to the containing characteristic + XCTAssertEqual(descriptor.characteristic, characteristic) + } + + // MARK: - Value Producer + + func testValueReturnsProducerResult() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read], + valueProducer: { return "produced".data(using: .utf8) }, + valueHandler: nil + ) + + let value = characteristic.value + XCTAssertNotNil(value) + XCTAssertEqual(String(data: value!, encoding: .utf8), "produced") + } + + func testValueReturnsSetValueOverProducer() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read], + valueProducer: { return "produced".data(using: .utf8) }, + valueHandler: nil + ) + + characteristic.setValue("explicit".data(using: .utf8)) + + let value = characteristic.value + XCTAssertNotNil(value) + XCTAssertEqual(String(data: value!, encoding: .utf8), "explicit") + } + + func testValueReturnsNilWithNoProducerAndNoSetValue() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read], + valueProducer: nil, + valueHandler: nil + ) + + XCTAssertNil(characteristic.value) + } + + // MARK: - Value Handler + + func testSetValueCallsHandler() { + let expectation = XCTestExpectation(description: "Handler called") + var receivedData: Data? + + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.write], + valueProducer: nil, + valueHandler: { data in + receivedData = data + expectation.fulfill() + } + ) + + characteristic.setValue("handled".data(using: .utf8)) + + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(String(data: receivedData!, encoding: .utf8), "handled") + } + + func testSetValueWithoutHandler() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.write], + valueProducer: nil, + valueHandler: nil + ) + + // Should not crash when handler is nil + characteristic.setValue("test".data(using: .utf8)) + + XCTAssertEqual(String(data: characteristic.value!, encoding: .utf8), "test") + } + + // MARK: - Service Association + + func testServiceAssociation() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read], + valueProducer: nil, + valueHandler: nil + ) + + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: [characteristic] + ) + + // Service init registers itself on the characteristic + XCTAssertEqual(characteristic.service, service) + } + + // MARK: - Notification State + + func testIsNotifyingDefaultFalse() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read, .notify], + valueProducer: nil, + valueHandler: nil + ) + + XCTAssertFalse(characteristic.isNotifying) + } + + func testSetIsNotifyingTrue() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read, .notify], + valueProducer: nil, + valueHandler: nil + ) + + characteristic.setIsNotifying(true) + XCTAssertTrue(characteristic.isNotifying) + + // Clean up + characteristic.setIsNotifying(false) + } + + func testSetIsNotifyingFalseStopsTimer() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read, .notify], + valueProducer: nil, + valueHandler: nil + ) + + characteristic.setIsNotifying(true) + XCTAssertTrue(characteristic.isNotifying) + + characteristic.setIsNotifying(false) + XCTAssertFalse(characteristic.isNotifying) + } + + // MARK: - Properties + + func testReadProperty() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read], + valueProducer: nil, + valueHandler: nil + ) + XCTAssertTrue(characteristic.properties.contains(.read)) + XCTAssertFalse(characteristic.properties.contains(.write)) + } + + func testMultipleProperties() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read, .write, .notify], + valueProducer: nil, + valueHandler: nil + ) + XCTAssertTrue(characteristic.properties.contains(.read)) + XCTAssertTrue(characteristic.properties.contains(.write)) + XCTAssertTrue(characteristic.properties.contains(.notify)) + } +} diff --git a/Tests/FaketoothTests/FaketoothDescriptorTest.swift b/Tests/FaketoothTests/FaketoothDescriptorTest.swift new file mode 100644 index 0000000..2633f69 --- /dev/null +++ b/Tests/FaketoothTests/FaketoothDescriptorTest.swift @@ -0,0 +1,147 @@ +import XCTest +@testable import Faketooth + +final class FaketoothDescriptorTest: XCTestCase { + + // MARK: - Initialization + + func testInitWithUUID() { + let descriptor = FaketoothDescriptor( + uuid: .configDescriptor, + valueProducer: nil, + valueHandler: nil + ) + + XCTAssertEqual(descriptor.uuid, .configDescriptor) + } + + // MARK: - Value Producer + + func testValueReturnsProducerResult() { + let descriptor = FaketoothDescriptor( + uuid: .configDescriptor, + valueProducer: { return "produced-desc" as NSString }, + valueHandler: nil + ) + + let value = descriptor.value as? NSString + XCTAssertEqual(value, "produced-desc") + } + + func testValueReturnsSetValueOverProducer() { + let descriptor = FaketoothDescriptor( + uuid: .configDescriptor, + valueProducer: { return "produced" as NSString }, + valueHandler: nil + ) + + descriptor.setValue("explicit".data(using: .utf8)) + + let value = descriptor.value as? Data + XCTAssertNotNil(value) + XCTAssertEqual(String(data: value!, encoding: .utf8), "explicit") + } + + func testValueReturnsNilWithNoProducerAndNoSetValue() { + let descriptor = FaketoothDescriptor( + uuid: .configDescriptor, + valueProducer: nil, + valueHandler: nil + ) + + XCTAssertNil(descriptor.value) + } + + // MARK: - Value Handler + + func testSetValueCallsHandler() { + let expectation = XCTestExpectation(description: "Handler called") + + let descriptor = FaketoothDescriptor( + uuid: .configDescriptor, + valueProducer: nil, + valueHandler: { value in + expectation.fulfill() + guard let data = value as? Data else { + XCTFail("Expected Data value") + return + } + XCTAssertEqual(String(data: data, encoding: .utf8), "handled") + } + ) + + descriptor.setValue("handled".data(using: .utf8)) + + wait(for: [expectation], timeout: 1.0) + } + + func testSetValueWithoutHandler() { + let descriptor = FaketoothDescriptor( + uuid: .configDescriptor, + valueProducer: nil, + valueHandler: nil + ) + + // Should not crash when handler is nil + descriptor.setValue("test".data(using: .utf8)) + + let value = descriptor.value as? Data + XCTAssertNotNil(value) + XCTAssertEqual(String(data: value!, encoding: .utf8), "test") + } + + // MARK: - Characteristic Association + + func testCharacteristicAssociation() { + let descriptor = FaketoothDescriptor( + uuid: .configDescriptor, + valueProducer: nil, + valueHandler: nil + ) + + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read], + descriptors: [descriptor], + valueProducer: nil, + valueHandler: nil + ) + + // Characteristic init sets itself on its descriptors + XCTAssertEqual(descriptor.characteristic, characteristic) + } + + func testSetCharacteristic() { + let descriptor = FaketoothDescriptor( + uuid: .configDescriptor, + valueProducer: nil, + valueHandler: nil + ) + + XCTAssertNil(descriptor.characteristic) + + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read], + valueProducer: nil, + valueHandler: nil + ) + + descriptor.setCharacteristic(characteristic) + XCTAssertEqual(descriptor.characteristic, characteristic) + } + + // MARK: - Value Producer with Data + + func testValueProducerReturnsData() { + let expectedData = Data([0x01, 0x02, 0x03]) + let descriptor = FaketoothDescriptor( + uuid: .configDescriptor, + valueProducer: { return expectedData as NSData }, + valueHandler: nil + ) + + let value = descriptor.value as? NSData + XCTAssertEqual(value, expectedData as NSData) + } +} diff --git a/Tests/FaketoothTests/FaketoothPeripheralTest.swift b/Tests/FaketoothTests/FaketoothPeripheralTest.swift index 2811f18..bbcf2b8 100644 --- a/Tests/FaketoothTests/FaketoothPeripheralTest.swift +++ b/Tests/FaketoothTests/FaketoothPeripheralTest.swift @@ -100,6 +100,10 @@ final class FaketoothPeripheralTest: XCTestCase { } XCTAssertEqual(string, "this is only a test") } + + peripheral.writeValue("this is only a test".data(using: .utf8)!, for: characteristic, type: .withResponse) + + wait(for: [expectValue, expectWrite], timeout: 1.0) } func testReadValueForDescriptor() { @@ -131,6 +135,360 @@ final class FaketoothPeripheralTest: XCTestCase { wait(for: [expectation], timeout: 1.0) } + // MARK: - Service Discovery Tests + + func testDiscoverServices() { + let peripheral = faketoothSetupPeripheral() + peripheral.delegate = peripheralDelegate + + faketoothSetup(peripherals: [peripheral]) + + let expectation = XCTestExpectation(description: "Discover services") + peripheralDelegate.onDidDiscoverServices = { error in + expectation.fulfill() + XCTAssertNil(error) + XCTAssertNotNil(peripheral.services) + XCTAssertEqual(peripheral.services?.count, 1) + XCTAssertEqual(peripheral.services?.first?.uuid, .testService) + } + + peripheral.discoverServices(nil) + + wait(for: [expectation], timeout: 1.0) + } + + func testDiscoverCharacteristicsForService() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read], + descriptors: nil, + valueProducer: nil, + valueHandler: nil + ) + + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: [characteristic] + ) + + let peripheral = FaketoothPeripheral( + identifier: .testPeripheral, + name: "Test", + services: [service], + advertisementData: nil + ) + peripheral.delegate = peripheralDelegate + + faketoothSetup(peripherals: [peripheral]) + + let expectation = XCTestExpectation(description: "Discover characteristics") + peripheralDelegate.onDidDiscoverCharacteristicsForService = { discoveredService, error in + expectation.fulfill() + XCTAssertNil(error) + XCTAssertEqual(discoveredService.uuid, .testService) + XCTAssertEqual(discoveredService.characteristics?.count, 1) + XCTAssertEqual(discoveredService.characteristics?.first?.uuid, .testCharacteristic) + } + + peripheral.discoverCharacteristics(nil, for: service) + + wait(for: [expectation], timeout: 1.0) + } + + func testDiscoverIncludedServicesForService() { + let includedService = FaketoothService( + uuid: CBUUID(string: "FFF1"), + isPrimary: false, + characteristics: [] + ) + + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: [], + includedServices: [includedService] + ) + + let peripheral = FaketoothPeripheral( + identifier: .testPeripheral, + name: "Test", + services: [service], + advertisementData: nil + ) + peripheral.delegate = peripheralDelegate + + faketoothSetup(peripherals: [peripheral]) + + let expectation = XCTestExpectation(description: "Discover included services") + peripheralDelegate.onDidDiscoverIncludedServicesForService = { discoveredService, error in + expectation.fulfill() + XCTAssertNil(error) + XCTAssertEqual(discoveredService.uuid, .testService) + } + + peripheral.discoverIncludedServices(nil, for: service) + + wait(for: [expectation], timeout: 1.0) + } + + func testDiscoverDescriptorsForCharacteristic() { + let descriptor = FaketoothDescriptor( + uuid: .configDescriptor, + valueProducer: nil, + valueHandler: nil + ) + + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read], + descriptors: [descriptor], + valueProducer: nil, + valueHandler: nil + ) + + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: [characteristic] + ) + + let peripheral = FaketoothPeripheral( + identifier: .testPeripheral, + name: "Test", + services: [service], + advertisementData: nil + ) + peripheral.delegate = peripheralDelegate + + faketoothSetup(peripherals: [peripheral]) + + let expectation = XCTestExpectation(description: "Discover descriptors") + peripheralDelegate.onDidDiscoverDescriptorsForCharacteristic = { discoveredCharacteristic, error in + expectation.fulfill() + XCTAssertNil(error) + XCTAssertEqual(discoveredCharacteristic.uuid, .testCharacteristic) + } + + peripheral.discoverDescriptors(for: characteristic) + + wait(for: [expectation], timeout: 1.0) + } + + // MARK: - Notification Tests + + func testSetNotifyValueEnabled() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read, .notify], + descriptors: nil, + valueProducer: { return "notify-value".data(using: .utf8) }, + valueHandler: nil + ) + + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: [characteristic] + ) + + let peripheral = FaketoothPeripheral( + identifier: .testPeripheral, + name: "Test", + services: [service], + advertisementData: nil + ) + peripheral.delegate = peripheralDelegate + + faketoothSetup(peripherals: [peripheral]) + + let expectation = XCTestExpectation(description: "Notification state updated") + peripheralDelegate.onDidUpdateNotificationStateForCharacteristic = { char, error in + expectation.fulfill() + XCTAssertNil(error) + XCTAssertTrue(char.isNotifying) + } + + peripheral.setNotifyValue(true, for: characteristic) + + wait(for: [expectation], timeout: 1.0) + + // Clean up: disable notifications to stop timer + characteristic.setIsNotifying(false) + } + + func testNotificationTimerFiresUpdates() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read, .notify], + descriptors: nil, + valueProducer: { return "timer-value".data(using: .utf8) }, + valueHandler: nil + ) + + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: [characteristic] + ) + + let peripheral = FaketoothPeripheral( + identifier: .testPeripheral, + name: "Test", + services: [service], + advertisementData: nil + ) + peripheral.delegate = peripheralDelegate + + faketoothSetup(peripherals: [peripheral]) + + // Enable notifications which starts a 0.25s timer + characteristic.setIsNotifying(true) + + let expectation = XCTestExpectation(description: "Notification timer fires") + peripheralDelegate.onDidUpdateValueForCharacteristic = { char, error in + expectation.fulfill() + } + + // Timer fires every 0.25s, so wait up to 1s + wait(for: [expectation], timeout: 1.0) + + // Clean up + characteristic.setIsNotifying(false) + } + + // MARK: - Write Type Tests + + func testWriteValueWithoutResponse() { + let expectHandler = XCTestExpectation(description: "Handler called") + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read, .writeWithoutResponse], + descriptors: nil, + valueProducer: nil, + valueHandler: { data in + expectHandler.fulfill() + } + ) + + let peripheral = faketoothSetupPeripheral(characteristics: [characteristic]) + peripheral.delegate = peripheralDelegate + + faketoothSetup(peripherals: [peripheral]) + + // With .withoutResponse, the delegate should NOT be called + let expectNoDelegateCall = XCTestExpectation(description: "Delegate should not be called") + expectNoDelegateCall.isInverted = true + peripheralDelegate.onDidWriteValueForCharacteristic = { _, _ in + expectNoDelegateCall.fulfill() + } + + peripheral.writeValue("test".data(using: .utf8)!, for: characteristic, type: .withoutResponse) + + wait(for: [expectHandler], timeout: 1.0) + wait(for: [expectNoDelegateCall], timeout: 0.5) + } + + func testMaximumWriteValueLength() { + let peripheral = faketoothSetupPeripheral() + faketoothSetup(peripherals: [peripheral]) + + let length = peripheral.maximumWriteValueLength(for: .withResponse) + XCTAssertEqual(length, 20) + + let lengthWithoutResponse = peripheral.maximumWriteValueLength(for: .withoutResponse) + XCTAssertEqual(lengthWithoutResponse, 20) + } + + // MARK: - Peripheral Property Tests + + func testPeripheralProperties() { + let peripheral = faketoothSetupPeripheral(name: "My BLE Device") + faketoothSetup(peripherals: [peripheral]) + + XCTAssertEqual(peripheral.identifier as UUID, .testPeripheral) + XCTAssertEqual(peripheral.name, "My BLE Device") + XCTAssertEqual(peripheral.state, .disconnected) + } + + func testPeripheralStateTransitions() { + let peripheral = faketoothSetupPeripheral() + faketoothSetup(peripherals: [peripheral]) + + XCTAssertEqual(peripheral.state, .disconnected) + + peripheral.setState(.connecting) + XCTAssertEqual(peripheral.state, .connecting) + + peripheral.setState(.connected) + XCTAssertEqual(peripheral.state, .connected) + + peripheral.setState(.disconnecting) + XCTAssertEqual(peripheral.state, .disconnecting) + + peripheral.setState(.disconnected) + XCTAssertEqual(peripheral.state, .disconnected) + } + + func testAdvertisementDataAutoGenerated() { + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: [] + ) + + let peripheral = FaketoothPeripheral( + identifier: .testPeripheral, + name: "AutoAdv", + services: [service], + advertisementData: nil + ) + + let advData = peripheral.advertisementData + XCTAssertEqual(advData[CBAdvertisementDataLocalNameKey] as? String, "AutoAdv") + + let serviceUUIDs = advData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] + XCTAssertNotNil(serviceUUIDs) + XCTAssertTrue(serviceUUIDs?.contains(.testService) ?? false) + } + + func testAdvertisementDataCustom() { + let customData: [String: Any] = [ + CBAdvertisementDataLocalNameKey: "CustomName", + CBAdvertisementDataManufacturerDataKey: Data([0x01, 0x02]) + ] + + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: [] + ) + + let peripheral = FaketoothPeripheral( + identifier: .testPeripheral, + name: "Test", + services: [service], + advertisementData: customData + ) + faketoothSetup(peripherals: [peripheral]) + + XCTAssertEqual(peripheral.advertisementData[CBAdvertisementDataLocalNameKey] as? String, "CustomName") + XCTAssertNotNil(peripheral.advertisementData[CBAdvertisementDataManufacturerDataKey]) + } + + func testAdvertisementDataNilReturnsEmptyDictionary() { + let peripheral = faketoothSetupPeripheral() + faketoothSetup(peripherals: [peripheral]) + + // When advertisementData is nil, the getter returns an empty dictionary + // (since faketoothSetupPeripheral passes nil, auto-generation creates data with name+serviceUUIDs) + let advData = peripheral.advertisementData + XCTAssertNotNil(advData) + XCTAssertFalse(advData.isEmpty) + } + + // MARK: - Descriptor Tests (continued) + func testWriteValueForDescriptor() { let expectValue = XCTestExpectation(description: "Descriptor's value handler is not called") @@ -204,4 +562,44 @@ class FaketoothPeripheralDelegate: NSObject, CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didWriteValueFor descriptor: CBDescriptor, error: Error?) { onDidWriteValueForDescriptor?(descriptor, error) } + + // MARK: peripheral(_:didDiscoverServices:) + + var onDidDiscoverServices: ((_ error: Error?) -> Void)? + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + onDidDiscoverServices?(error) + } + + // MARK: peripheral(_:didDiscoverCharacteristicsFor:error:) + + var onDidDiscoverCharacteristicsForService: ((_ service: CBService, _ error: Error?) -> Void)? + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + onDidDiscoverCharacteristicsForService?(service, error) + } + + // MARK: peripheral(_:didDiscoverIncludedServicesFor:error:) + + var onDidDiscoverIncludedServicesForService: ((_ service: CBService, _ error: Error?) -> Void)? + + func peripheral(_ peripheral: CBPeripheral, didDiscoverIncludedServicesFor service: CBService, error: Error?) { + onDidDiscoverIncludedServicesForService?(service, error) + } + + // MARK: peripheral(_:didDiscoverDescriptorsFor:error:) + + var onDidDiscoverDescriptorsForCharacteristic: ((_ characteristic: CBCharacteristic, _ error: Error?) -> Void)? + + func peripheral(_ peripheral: CBPeripheral, didDiscoverDescriptorsFor characteristic: CBCharacteristic, error: Error?) { + onDidDiscoverDescriptorsForCharacteristic?(characteristic, error) + } + + // MARK: peripheral(_:didUpdateNotificationStateFor:error:) + + var onDidUpdateNotificationStateForCharacteristic: ((_ characteristic: CBCharacteristic, _ error: Error?) -> Void)? + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + onDidUpdateNotificationStateForCharacteristic?(characteristic, error) + } } diff --git a/Tests/FaketoothTests/FaketoothServiceTest.swift b/Tests/FaketoothTests/FaketoothServiceTest.swift new file mode 100644 index 0000000..23a8252 --- /dev/null +++ b/Tests/FaketoothTests/FaketoothServiceTest.swift @@ -0,0 +1,164 @@ +import XCTest +@testable import Faketooth + +final class FaketoothServiceTest: XCTestCase { + + // MARK: - Initialization + + func testInitWithUUIDAndCharacteristics() { + let characteristic = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read], + valueProducer: nil, + valueHandler: nil + ) + + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: [characteristic] + ) + + XCTAssertEqual(service.uuid, .testService) + XCTAssertTrue(service.isPrimary) + XCTAssertEqual(service.characteristics?.count, 1) + XCTAssertNil(service.includedServices) + } + + func testInitWithIncludedServices() { + let includedService = FaketoothService( + uuid: CBUUID(string: "FFF1"), + isPrimary: false, + characteristics: [] + ) + + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: [], + includedServices: [includedService] + ) + + XCTAssertEqual(service.includedServices?.count, 1) + XCTAssertEqual(service.includedServices?.first?.uuid, CBUUID(string: "FFF1")) + } + + func testIsPrimaryFalse() { + let service = FaketoothService( + uuid: .testService, + isPrimary: false, + characteristics: [] + ) + + XCTAssertFalse(service.isPrimary) + } + + // MARK: - Characteristic Registration + + func testCharacteristicServiceRegistration() { + let char1 = FaketoothCharacteristic( + uuid: .testCharacteristic, + properties: [.read], + valueProducer: nil, + valueHandler: nil + ) + + let char2 = FaketoothCharacteristic( + uuid: CBUUID(string: "FFF2"), + properties: [.write], + valueProducer: nil, + valueHandler: nil + ) + + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: [char1, char2] + ) + + // Both characteristics should have their service set + XCTAssertEqual(char1.service, service) + XCTAssertEqual(char2.service, service) + } + + // MARK: - Peripheral Association + + func testPeripheralAssociation() { + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: [] + ) + + let peripheral = FaketoothPeripheral( + identifier: .testPeripheral, + name: "Test", + services: [service], + advertisementData: nil + ) + + // Peripheral init sets itself on its services + XCTAssertEqual(service.peripheral, peripheral) + } + + func testSetPeripheral() { + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: [] + ) + + XCTAssertNil(service.peripheral) + + // Create peripheral with the service to avoid KVO dealloc crash + let peripheral = FaketoothPeripheral( + identifier: .testPeripheral, + name: "Test", + services: [service], + advertisementData: nil + ) + + // init already set peripheral, verify it + XCTAssertEqual(service.peripheral, peripheral) + + // Now test setPeripheral with a different service + let otherService = FaketoothService( + uuid: CBUUID(string: "FFF2"), + isPrimary: false, + characteristics: [] + ) + otherService.setPeripheral(peripheral) + XCTAssertEqual(otherService.peripheral, peripheral) + } + + // MARK: - Multiple Characteristics + + func testMultipleCharacteristics() { + let characteristics = (0..<3).map { i in + FaketoothCharacteristic( + uuid: CBUUID(string: String(format: "FFF%d", i)), + properties: [.read], + valueProducer: nil, + valueHandler: nil + ) + } + + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: characteristics + ) + + XCTAssertEqual(service.characteristics?.count, 3) + } + + func testEmptyCharacteristics() { + let service = FaketoothService( + uuid: .testService, + isPrimary: true, + characteristics: [] + ) + + XCTAssertEqual(service.characteristics?.count, 0) + } +} diff --git a/Tests/FaketoothTests/FaketoothSettingsTest.swift b/Tests/FaketoothTests/FaketoothSettingsTest.swift new file mode 100644 index 0000000..3378387 --- /dev/null +++ b/Tests/FaketoothTests/FaketoothSettingsTest.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import Faketooth + +final class FaketoothSettingsTest: XCTestCase { + + override func tearDown() { + // Reset to defaults + FaketoothSettings.delay = FaketoothDelaySettings( + scanForPeripheralDelayInSeconds: 1.0, + connectPeripheralDelayInSeconds: 1.0, + cancelPeripheralConnectionDelayInSeconds: 0.5, + discoverServicesDelayInSeconds: 0.1, + discoverCharacteristicsDelayInSeconds: 0.1, + discoverIncludedServicesDelayInSeconds: 0.1, + discoverDescriptorsForCharacteristicDelayInSeconds: 0.1, + readValueForCharacteristicDelayInSeconds: 0.1, + writeValueForCharacteristicDelayInSeconds: 0.1, + readValueForDescriptorDelayInSeconds: 0.1, + writeValueForDescriptorDelayInSeconds: 0.1, + setNotifyValueForCharacteristicDelayInSeconds: 0.1 + ) + } + + func testCustomDelaySettings() { + let custom = FaketoothDelaySettings( + scanForPeripheralDelayInSeconds: 2.0, + connectPeripheralDelayInSeconds: 3.0, + cancelPeripheralConnectionDelayInSeconds: 1.5, + discoverServicesDelayInSeconds: 0.5, + discoverCharacteristicsDelayInSeconds: 0.5, + discoverIncludedServicesDelayInSeconds: 0.5, + discoverDescriptorsForCharacteristicDelayInSeconds: 0.5, + readValueForCharacteristicDelayInSeconds: 0.2, + writeValueForCharacteristicDelayInSeconds: 0.2, + readValueForDescriptorDelayInSeconds: 0.3, + writeValueForDescriptorDelayInSeconds: 0.3, + setNotifyValueForCharacteristicDelayInSeconds: 0.4 + ) + + FaketoothSettings.delay = custom + + let delay = FaketoothSettings.delay + XCTAssertEqual(delay.scanForPeripheralDelayInSeconds, 2.0) + XCTAssertEqual(delay.connectPeripheralDelayInSeconds, 3.0) + XCTAssertEqual(delay.cancelPeripheralConnectionDelayInSeconds, 1.5) + XCTAssertEqual(delay.discoverServicesDelayInSeconds, 0.5) + XCTAssertEqual(delay.discoverCharacteristicsDelayInSeconds, 0.5) + XCTAssertEqual(delay.discoverIncludedServicesDelayInSeconds, 0.5) + XCTAssertEqual(delay.discoverDescriptorsForCharacteristicDelayInSeconds, 0.5) + XCTAssertEqual(delay.readValueForCharacteristicDelayInSeconds, 0.2) + XCTAssertEqual(delay.writeValueForCharacteristicDelayInSeconds, 0.2) + XCTAssertEqual(delay.readValueForDescriptorDelayInSeconds, 0.3) + XCTAssertEqual(delay.writeValueForDescriptorDelayInSeconds, 0.3) + XCTAssertEqual(delay.setNotifyValueForCharacteristicDelayInSeconds, 0.4) + } + + func testDelaySettingsAreApplied() { + // Verify that setting delay actually affects the global settings + let original = FaketoothSettings.delay + + let modified = FaketoothDelaySettings( + scanForPeripheralDelayInSeconds: 5.0, + connectPeripheralDelayInSeconds: original.connectPeripheralDelayInSeconds, + cancelPeripheralConnectionDelayInSeconds: original.cancelPeripheralConnectionDelayInSeconds, + discoverServicesDelayInSeconds: original.discoverServicesDelayInSeconds, + discoverCharacteristicsDelayInSeconds: original.discoverCharacteristicsDelayInSeconds, + discoverIncludedServicesDelayInSeconds: original.discoverIncludedServicesDelayInSeconds, + discoverDescriptorsForCharacteristicDelayInSeconds: original.discoverDescriptorsForCharacteristicDelayInSeconds, + readValueForCharacteristicDelayInSeconds: original.readValueForCharacteristicDelayInSeconds, + writeValueForCharacteristicDelayInSeconds: original.writeValueForCharacteristicDelayInSeconds, + readValueForDescriptorDelayInSeconds: original.readValueForDescriptorDelayInSeconds, + writeValueForDescriptorDelayInSeconds: original.writeValueForDescriptorDelayInSeconds, + setNotifyValueForCharacteristicDelayInSeconds: original.setNotifyValueForCharacteristicDelayInSeconds + ) + + FaketoothSettings.delay = modified + XCTAssertEqual(FaketoothSettings.delay.scanForPeripheralDelayInSeconds, 5.0) + } +} diff --git a/Tests/FaketoothTests/Mocks/MockCentralManager.swift b/Tests/FaketoothTests/Mocks/MockCentralManager.swift new file mode 100644 index 0000000..6cc7f02 --- /dev/null +++ b/Tests/FaketoothTests/Mocks/MockCentralManager.swift @@ -0,0 +1,2 @@ +import Foundation +import CoreBluetooth diff --git a/Tests/FaketoothTests/Mocks/MockPeripheral.swift b/Tests/FaketoothTests/Mocks/MockPeripheral.swift index 308a9e4..830c6ee 100644 --- a/Tests/FaketoothTests/Mocks/MockPeripheral.swift +++ b/Tests/FaketoothTests/Mocks/MockPeripheral.swift @@ -1,22 +1,2 @@ -// -// File.swift -// -// -// Created by Max Rozdobudko on 10/12/20. -// - import Foundation @testable import Faketooth - -class MockPeripheral : CBPeripheral { - - init(onlyForTests: Bool) { - - } - - func setup() { - // adds observer as removing it is one of phases of clean up CBPeripheral instance - addObserver(self, forKeyPath: "delegate", options: [], context: nil) - } - -}