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", brand: "test-brand", 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) } }