主要改动: - 新增 fetchLatestFirmware/upgradeFirmware/tryReportPendingUpgrade 三段 OTA 接口,对齐 expo bindDeviceWithOrchestration 行为 - bind 仅做硬绑+getVersion,软绑/对账上报/拉最新固件搬到 fetchLatestFirmware 内部串;软绑失败自动硬解回滚 - UpgradeRecord 复合主键 (sn, firmwareId) 持久化 + sn 级 in-flight 互斥 - DuooomiBleSDKDelegate 新增 didUpdateUpgradeRecords,集成方 可观察对账上报结果(reported/outcome 状态) - DuooomiBleSDK 主类按职责拆 5 个 extension 文件,公开 API 不变 - TargetFirmware 简化为只取必要字段 + 新增 forceUpgrade - scanNamePrefix 改为必填 String - demo 秘钥移至本地 DemoSecrets.swift (gitignored) - README 重写:完整 OTA 流程示例 + 上报状态判定表 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
7.0 KiB
Swift
159 lines
7.0 KiB
Swift
import XCTest
|
||
@testable import DuooomiBleSDK
|
||
|
||
final class PlayModeTests: XCTestCase {
|
||
func testRawValues() {
|
||
XCTAssertEqual(PlayMode.single.rawValue, 0)
|
||
XCTAssertEqual(PlayMode.loop.rawValue, 1)
|
||
}
|
||
|
||
func testInitFromRawValue() {
|
||
XCTAssertEqual(PlayMode(rawValue: 0), .single)
|
||
XCTAssertEqual(PlayMode(rawValue: 1), .loop)
|
||
XCTAssertNil(PlayMode(rawValue: 2))
|
||
}
|
||
|
||
func testCodableRoundTrip() throws {
|
||
let data = try JSONEncoder().encode(PlayMode.loop)
|
||
XCTAssertEqual(String(data: data, encoding: .utf8), "1")
|
||
let decoded = try JSONDecoder().decode(PlayMode.self, from: Data("0".utf8))
|
||
XCTAssertEqual(decoded, .single)
|
||
}
|
||
}
|
||
|
||
final class DeviceInfoLoopTests: XCTestCase {
|
||
private func decode(_ json: String) throws -> DeviceInfo {
|
||
try JSONDecoder().decode(DeviceInfo.self, from: Data(json.utf8))
|
||
}
|
||
|
||
func testLoopMissingDecodesToNil() throws {
|
||
let json = #"{"name":"d1","size":"360","brand":"duomi","powerlevel":80,"allspace":1024,"freespace":512}"#
|
||
let info = try decode(json)
|
||
XCTAssertNil(info.loop)
|
||
}
|
||
|
||
func testLoopZeroDecodesToSingle() throws {
|
||
let json = #"{"name":"d1","size":"360","brand":"duomi","powerlevel":80,"allspace":1024,"freespace":512,"loop":0}"#
|
||
let info = try decode(json)
|
||
XCTAssertEqual(info.loop, .single)
|
||
}
|
||
|
||
func testLoopOneDecodesToLoop() throws {
|
||
let json = #"{"name":"d1","size":"360","brand":"duomi","powerlevel":80,"allspace":1024,"freespace":512,"loop":1}"#
|
||
let info = try decode(json)
|
||
XCTAssertEqual(info.loop, .loop)
|
||
}
|
||
|
||
func testLoopInvalidValueDecodesToNil() throws {
|
||
let json = #"{"name":"d1","size":"360","brand":"duomi","powerlevel":80,"allspace":1024,"freespace":512,"loop":7}"#
|
||
let info = try decode(json)
|
||
XCTAssertNil(info.loop)
|
||
}
|
||
}
|
||
|
||
final class BindPayloadEncodingTests: XCTestCase {
|
||
private static let fixedTime = "2026-05-07T16:58:00+08:00"
|
||
|
||
private func encode(_ payload: BindPayload) throws -> [String: Any] {
|
||
let data = try JSONEncoder().encode(payload)
|
||
return try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
|
||
}
|
||
|
||
func testBindPayloadWithoutLoopOmitsKey() throws {
|
||
let payload = BindPayload(type: 0x0F, userId: "u1", loop: nil, time: Self.fixedTime)
|
||
let dict = try encode(payload)
|
||
XCTAssertEqual(dict["type"] as? Int, 0x0F)
|
||
XCTAssertEqual(dict["userId"] as? String, "u1")
|
||
XCTAssertEqual(dict["time"] as? String, Self.fixedTime)
|
||
XCTAssertNil(dict["loop"], "loop key MUST be omitted when nil (not encoded as null)")
|
||
XCTAssertEqual(dict.count, 3)
|
||
}
|
||
|
||
func testBindPayloadWithLoopIncludesValue() throws {
|
||
let payload = BindPayload(type: 0x0F, userId: "u1", loop: 1, time: Self.fixedTime)
|
||
let dict = try encode(payload)
|
||
XCTAssertEqual(dict["type"] as? Int, 0x0F)
|
||
XCTAssertEqual(dict["userId"] as? String, "u1")
|
||
XCTAssertEqual(dict["loop"] as? Int, 1)
|
||
XCTAssertEqual(dict["time"] as? String, Self.fixedTime)
|
||
XCTAssertEqual(dict.count, 4)
|
||
}
|
||
|
||
func testBindPayloadWithLoopZeroIncludesValue() throws {
|
||
let payload = BindPayload(type: 0x0F, userId: "u1", loop: 0, time: Self.fixedTime)
|
||
let dict = try encode(payload)
|
||
XCTAssertEqual(dict["loop"] as? Int, 0)
|
||
}
|
||
}
|
||
|
||
final class SetPlayModeGuardTests: XCTestCase {
|
||
private func makeSDK() -> DuooomiBleSDK {
|
||
DuooomiBleSDK(config: DuooomiBleConfig(apiKey: "test", owner: "test-owner", scanNamePrefix: "Duooomi-"))
|
||
}
|
||
|
||
func testSetPlayModeFailsWhenNotConnected() {
|
||
let sdk = makeSDK()
|
||
let exp = expectation(description: "completion")
|
||
sdk.setPlayMode(.loop, userId: "u1") { result in
|
||
switch result {
|
||
case .success:
|
||
XCTFail("expected failure when not connected")
|
||
case .failure(let error):
|
||
XCTAssertTrue("\(error)".lowercased().contains("connect"),
|
||
"expected notConnected-style error, got \(error)")
|
||
}
|
||
exp.fulfill()
|
||
}
|
||
wait(for: [exp], timeout: 1.0)
|
||
}
|
||
}
|
||
|
||
final class BindPayloadByteRegressionTests: XCTestCase {
|
||
private static let fixedTime = "2026-05-07T16:58:00+08:00"
|
||
|
||
/// 关键回归:bind / unbind 在 loop=nil 时报文必须完全不含 "loop" key —— 不能输出 "loop":null。
|
||
/// 不假设 JSONEncoder key 顺序(Swift 不同版本顺序可能不同),用 substring + dict 断言锁语义。
|
||
func testBindPayloadNilLoopOmitsKeyAndNull() throws {
|
||
let payload = BindPayload(type: 0x0F, userId: "abc", loop: nil, time: Self.fixedTime)
|
||
let data = try JSONEncoder().encode(payload)
|
||
let json = String(data: data, encoding: .utf8) ?? ""
|
||
// 字面层:JSON 文本不允许出现 "loop" 或 "null"
|
||
XCTAssertFalse(json.contains("loop"), "loop key MUST be absent; got \(json)")
|
||
XCTAssertFalse(json.contains("null"), "null literal MUST be absent; got \(json)")
|
||
// 结构层:反序列化后只有 type / userId / time,无第四个 key
|
||
let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
|
||
XCTAssertEqual(dict.count, 3)
|
||
XCTAssertEqual(dict["type"] as? Int, 15)
|
||
XCTAssertEqual(dict["userId"] as? String, "abc")
|
||
XCTAssertEqual(dict["time"] as? String, Self.fixedTime)
|
||
XCTAssertNil(dict["loop"])
|
||
}
|
||
|
||
/// setPlayMode 报文:loop=1 必须以整数形式出现(不是 "1" 字符串)。
|
||
func testSetPlayModePayloadIncludesLoopOne() throws {
|
||
let payload = BindPayload(type: 0x0F, userId: "abc", loop: 1, time: Self.fixedTime)
|
||
let data = try JSONEncoder().encode(payload)
|
||
let json = String(data: data, encoding: .utf8) ?? ""
|
||
// 字面层子串:必须出现 "loop":1(数字,无引号)
|
||
XCTAssertTrue(json.contains(#""loop":1"#), "expected literal substring \"loop\":1 in \(json)")
|
||
// 结构层
|
||
let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
|
||
XCTAssertEqual(dict.count, 4)
|
||
XCTAssertEqual(dict["type"] as? Int, 15)
|
||
XCTAssertEqual(dict["userId"] as? String, "abc")
|
||
XCTAssertEqual(dict["loop"] as? Int, 1)
|
||
XCTAssertEqual(dict["time"] as? String, Self.fixedTime)
|
||
}
|
||
|
||
/// setPlayMode 报文:loop=0 边界情况,必须以整数 0 出现(不是被当作 "default" 省略)。
|
||
func testSetPlayModePayloadIncludesLoopZero() throws {
|
||
let payload = BindPayload(type: 0x0F, userId: "abc", loop: 0, time: Self.fixedTime)
|
||
let data = try JSONEncoder().encode(payload)
|
||
let json = String(data: data, encoding: .utf8) ?? ""
|
||
XCTAssertTrue(json.contains(#""loop":0"#), "expected literal substring \"loop\":0 in \(json)")
|
||
let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
|
||
XCTAssertEqual(dict["loop"] as? Int, 0)
|
||
XCTAssertEqual(dict["time"] as? String, Self.fixedTime)
|
||
}
|
||
}
|