|
| 1 | +// |
| 2 | +// IBeaconPluginTests.swift |
| 3 | +// BluetoothExplorerPluginEngineTests |
| 4 | +// |
| 5 | +// Exercises the advertisement (manufacturer-data) path end to end: the bundled Embedded Swift |
| 6 | +// iBeacon module runs under the interpreter and its output is diffed field-by-field against |
| 7 | +// NativeIBeaconParser. This is the only coverage of `bleplug_parse_manufacturer`. |
| 8 | +// |
| 9 | + |
| 10 | +import Foundation |
| 11 | +import Testing |
| 12 | +import Bluetooth |
| 13 | +@testable import BluetoothExplorerPluginEngine |
| 14 | + |
| 15 | +@Suite("iBeacon plugin") |
| 16 | +struct IBeaconPluginTests { |
| 17 | + |
| 18 | + private static let pluginID = "org.pureswift.plugin.ibeacon" |
| 19 | + |
| 20 | + /// Build an iBeacon manufacturer-data payload (company id already stripped by the host). |
| 21 | + private func payload( |
| 22 | + uuid: [UInt8] = (0..<16).map { UInt8($0 + 1) }, |
| 23 | + major: UInt16 = 1, |
| 24 | + minor: UInt16 = 2, |
| 25 | + measuredPower: Int8 = -59 |
| 26 | + ) -> Data { |
| 27 | + var bytes: [UInt8] = [0x02, 0x15] |
| 28 | + bytes += uuid |
| 29 | + bytes += [UInt8(major >> 8), UInt8(major & 0xFF)] |
| 30 | + bytes += [UInt8(minor >> 8), UInt8(minor & 0xFF)] |
| 31 | + bytes.append(UInt8(bitPattern: measuredPower)) |
| 32 | + return Data(bytes) |
| 33 | + } |
| 34 | + |
| 35 | + private func loadPlugin() throws -> any ParserPlugin { |
| 36 | + let result = PluginLoader.loadBundled(from: PluginEngineResources.bundle) |
| 37 | + #expect(result.failures.isEmpty, "load failures: \(result.failures.map(\.message))") |
| 38 | + let loaded = try #require(result.loaded.first { $0.manifest.identifier == Self.pluginID }) |
| 39 | + return loaded.plugin |
| 40 | + } |
| 41 | + |
| 42 | + @Test("Bundled module decodes an advertisement under the interpreter") |
| 43 | + func decodesAdvertisement() async throws { |
| 44 | + let plugin = try loadPlugin() |
| 45 | + let request = ParseRequest(kind: .manufacturerData, companyID: 0x004C, payload: payload()) |
| 46 | + let result = try #require(await plugin.parse(request)) |
| 47 | + |
| 48 | + #expect(result.title == "iBeacon") |
| 49 | + #expect(result.fields.count == 4) |
| 50 | + #expect(result.fields.first(where: { $0.key == "major" })?.value == .uint(1)) |
| 51 | + #expect(result.fields.first(where: { $0.key == "minor" })?.value == .uint(2)) |
| 52 | + #expect(result.fields.first(where: { $0.key == "tx_power" })?.value == .int(-59)) |
| 53 | + #expect(result.fields.first(where: { $0.key == "tx_power" })?.unit == "dBm") |
| 54 | + |
| 55 | + // The proximity UUID must survive the envelope's big-endian byte order unchanged. |
| 56 | + let expectedUUID = UUID(uuid: (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)) |
| 57 | + #expect(result.fields.first(where: { $0.key == "uuid" })?.value == .uuid(expectedUUID)) |
| 58 | + } |
| 59 | + |
| 60 | + @Test("WASM output matches the native parser field for field") |
| 61 | + func matchesNativeParser() async throws { |
| 62 | + let plugin = try loadPlugin() |
| 63 | + let native = NativeIBeaconParser() |
| 64 | + |
| 65 | + let cases: [(UInt16, UInt16, Int8)] = [ |
| 66 | + (0, 0, 0), |
| 67 | + (1, 2, -59), |
| 68 | + (0x1234, 0xABCD, -100), |
| 69 | + (UInt16.max, UInt16.max, 127), |
| 70 | + (256, 1, -128) |
| 71 | + ] |
| 72 | + for (major, minor, power) in cases { |
| 73 | + let request = ParseRequest( |
| 74 | + kind: .manufacturerData, |
| 75 | + companyID: 0x004C, |
| 76 | + payload: payload(major: major, minor: minor, measuredPower: power) |
| 77 | + ) |
| 78 | + let wasmResult = try #require(await plugin.parse(request), "wasm declined \(major)/\(minor)") |
| 79 | + let nativeResult = try #require(await native.parse(request)) |
| 80 | + |
| 81 | + #expect(wasmResult.title == nativeResult.title, "major \(major)") |
| 82 | + #expect(wasmResult.fields.count == nativeResult.fields.count, "major \(major)") |
| 83 | + for (wasmField, nativeField) in zip(wasmResult.fields, nativeResult.fields) { |
| 84 | + #expect(wasmField.key == nativeField.key) |
| 85 | + #expect(wasmField.label == nativeField.label) |
| 86 | + #expect(wasmField.value == nativeField.value, "field \(wasmField.key), major \(major)") |
| 87 | + #expect(wasmField.unit == nativeField.unit) |
| 88 | + } |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + @Test("Non-iBeacon Apple payloads are declined, like the native parser") |
| 93 | + func declinesNonBeacon() async throws { |
| 94 | + let plugin = try loadPlugin() |
| 95 | + let native = NativeIBeaconParser() |
| 96 | + |
| 97 | + // Apple uses this company id for many formats; only 0x02/0x15 is an iBeacon. |
| 98 | + let notBeacons: [Data] = [ |
| 99 | + Data(), // empty |
| 100 | + Data([0x02]), // truncated |
| 101 | + Data([0x10, 0x05, 0x01, 0x02, 0x03]), // continuity/handoff-style payload |
| 102 | + Data([0x02, 0x15] + [UInt8](repeating: 0, count: 10)), // right type, wrong length |
| 103 | + Data([0x02, 0x16] + [UInt8](repeating: 0, count: 21)) // wrong length byte |
| 104 | + ] |
| 105 | + for data in notBeacons { |
| 106 | + let request = ParseRequest(kind: .manufacturerData, companyID: 0x004C, payload: data) |
| 107 | + #expect(await plugin.parse(request) == nil, "wasm accepted \(data as NSData)") |
| 108 | + #expect(await native.parse(request) == nil, "native accepted \(data as NSData)") |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + @Test("Routing sends Apple manufacturer data to the plugin, other companies nowhere") |
| 113 | + func routing() async throws { |
| 114 | + let plugin = try loadPlugin() |
| 115 | + let registry = ParserRegistry(plugins: [plugin]) |
| 116 | + |
| 117 | + let apple = ParseRequest(kind: .manufacturerData, companyID: 0x004C, payload: payload()) |
| 118 | + #expect(await registry.decodeAll(apple).count == 1) |
| 119 | + |
| 120 | + // A different company must not reach the plugin at all. |
| 121 | + let other = ParseRequest(kind: .manufacturerData, companyID: 0x0075, payload: payload()) |
| 122 | + #expect(await registry.decodeAll(other).isEmpty) |
| 123 | + |
| 124 | + // Same bytes arriving as a characteristic value must not route here either. |
| 125 | + let wrongKind = ParseRequest(kind: .characteristic, uuid: .bit16(0x2A19), payload: payload()) |
| 126 | + #expect(await registry.decodeAll(wrongKind).isEmpty) |
| 127 | + } |
| 128 | +} |
0 commit comments