Skip to content

Commit b37ea91

Browse files
committed
Test the advertisement path end to end against the native parser
1 parent 517595d commit b37ea91

1 file changed

Lines changed: 128 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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

Comments
 (0)