From 487943a9f5322af806072bbb8bad45c07b453868 Mon Sep 17 00:00:00 2001 From: Jason Titus <870238+jasontitus@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:11:15 -0700 Subject: [PATCH 1/2] test(sender): add unit-test target covering wire protocol and automation parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a TBDisplaySenderTests unit bundle (hosted by the app for @testable access) with 49 tests over the pure logic that has no hardware dependency: - TBMonitorProtocol: BE32 primitives, packet layout, drainPacket handling of fragmented/contiguous/split feeds, JSON payload round-trips, and parity of the hand-rolled input-event encoder with JSONDecoder — guarding the invariant documented on makeInputEventPacket (PR #123). - TBDiscoveredReceiver: per-transport ip(for:) selection (which IP the sender dials for Thunderbolt vs Network Link), id, shortHostName, displayText. - TBSenderAutomation: parseTransport/parseMode/parsePreset aliases, receiver matching, and the resolveSessionIndex tri-state. The automation parsing helpers move from private to internal so the test bundle can exercise them; behavior is unchanged. project.pbxproj and the shared TBDisplaySender scheme are regenerated via xcodegen so a fresh checkout can run `xcodebuild test` directly. Co-Authored-By: Claude Fable 5 --- .../TBDisplaySenderAutomation.swift | 13 +- .../TBMonitorProtocolTests.swift | 186 ++++++++++++++++++ .../TBReceiverDiscoveryModelTests.swift | 100 ++++++++++ .../TBSenderAutomationParsingTests.swift | 173 ++++++++++++++++ .../TargetBridge.xcodeproj/project.pbxproj | 115 +++++++++++ .../xcschemes/TBDisplaySender.xcscheme | 104 ++++++++++ TargetBridge-Sender/project.yml | 30 +++ 7 files changed, 716 insertions(+), 5 deletions(-) create mode 100644 TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift create mode 100644 TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift create mode 100644 TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift create mode 100644 TargetBridge-Sender/TargetBridge.xcodeproj/xcshareddata/xcschemes/TBDisplaySender.xcscheme diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift index 98df2b5..bcb3b6e 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift @@ -166,7 +166,8 @@ enum TBSenderAutomation { /// - `nil` when the explicit session is invalid. /// - `.some(nil)` when no session was requested and the caller should target all sessions. /// - `.some(index)` with a zero-based index for a specific session. - private static func resolveSessionIndex( + /// - Note: `internal` (not `private`) so the unit-test bundle can exercise the tri-state logic. + static func resolveSessionIndex( _ raw: String?, sessionCount: Int, createDefaultIfNeeded: Bool @@ -201,7 +202,9 @@ enum TBSenderAutomation { return service.discoveredReceivers.first } - private static func matches(_ value: String, _ receiver: TBDiscoveredReceiver) -> Bool { + // The pure parsing helpers below are `internal` (not `private`) so the + // unit-test bundle can exercise them directly. + static func matches(_ value: String, _ receiver: TBDiscoveredReceiver) -> Bool { let needle = value.lowercased() if receiver.id.lowercased() == needle { return true } if receiver.receiverName.lowercased() == needle { return true } @@ -211,7 +214,7 @@ enum TBSenderAutomation { || receiver.networkIP.lowercased() == needle } - private static func parseTransport(_ value: String) -> TBTransportKind { + static func parseTransport(_ value: String) -> TBTransportKind { switch value.lowercased() { case "net", "network", "networklink", "link": return .networkLink @@ -220,7 +223,7 @@ enum TBSenderAutomation { } } - private static func parseMode(_ value: String) -> TBDisplayCaptureSource? { + static func parseMode(_ value: String) -> TBDisplayCaptureSource? { switch value.lowercased() { case "extended", "extend", "extendeddesktop", "ext": return .extendedDesktop @@ -231,7 +234,7 @@ enum TBSenderAutomation { } } - private static func parsePreset(_ value: String) -> TBDisplayCapturePreset? { + static func parsePreset(_ value: String) -> TBDisplayCapturePreset? { if let preset = TBDisplayCapturePreset(rawValue: value) { return preset } switch value.lowercased() { case "1440p", "1440", "standard": return .standard1440p diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift new file mode 100644 index 0000000..6642ce0 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift @@ -0,0 +1,186 @@ +import XCTest +@testable import TargetBridge + +/// Wire-protocol unit tests. These run with no network, no Thunderbolt hardware, +/// and no receiver — they pin down the framing invariants both apps rely on: +/// `[4B BE length][1B type][payload]` where length counts type + payload. +final class TBMonitorProtocolTests: XCTestCase { + + // MARK: - BE32 primitives + + func testBE32RoundTrip() { + let values: [UInt32] = [0, 1, 0xFF, 0x1234_5678, 0x7FFF_FFFF, 0xFFFF_FFFF] + for value in values { + var data = Data() + TBMonitorProtocol.appendBE32(&data, value) + XCTAssertEqual(data.count, 4) + XCTAssertEqual(TBMonitorProtocol.readBE32(data, offset: 0), value, "round trip failed for \(value)") + } + } + + func testAppendBE32IsBigEndian() { + var data = Data() + TBMonitorProtocol.appendBE32(&data, 0x0102_0304) + XCTAssertEqual([UInt8](data), [0x01, 0x02, 0x03, 0x04]) + } + + func testReadBE32HonorsOffset() { + var data = Data() + TBMonitorProtocol.appendBE32(&data, 0xAAAA_AAAA) + TBMonitorProtocol.appendBE32(&data, 0x0000_BEEF) + XCTAssertEqual(TBMonitorProtocol.readBE32(data, offset: 4), 0x0000_BEEF) + } + + // MARK: - Packet framing + + func testMakePacketLayout() { + let packet = TBMonitorProtocol.makePacket(type: .heartbeat, payload: Data([0xAA, 0xBB, 0xCC])) + // length = 1 (type byte) + 3 (payload) = 4 + XCTAssertEqual([UInt8](packet), [0x00, 0x00, 0x00, 0x04, 0x30, 0xAA, 0xBB, 0xCC]) + } + + func testDrainPacketRoundTrip() { + let payload = Data("hello receiver".utf8) + var buffer = TBMonitorProtocol.makePacket(type: .helloReceiver, payload: payload) + + let drained = TBMonitorProtocol.drainPacket(from: &buffer) + XCTAssertNotNil(drained) + XCTAssertEqual(drained?.0, .helloReceiver) + XCTAssertEqual(drained?.1, payload) + XCTAssertTrue(buffer.isEmpty, "drain must consume the packet") + } + + func testDrainPacketEmptyPayload() { + var buffer = TBMonitorProtocol.makePacket(type: .teardown, payload: Data()) + let drained = TBMonitorProtocol.drainPacket(from: &buffer) + XCTAssertEqual(drained?.0, .teardown) + XCTAssertEqual(drained?.1, Data()) + XCTAssertTrue(buffer.isEmpty) + } + + func testDrainPacketWaitsForCompleteHeader() { + var buffer = Data([0x00, 0x00, 0x00, 0x04]) // header missing its 5th byte + XCTAssertNil(TBMonitorProtocol.drainPacket(from: &buffer)) + XCTAssertEqual(buffer.count, 4, "incomplete data must not be consumed") + } + + func testDrainPacketWaitsForCompletePayload() { + let full = TBMonitorProtocol.makePacket(type: .frame, payload: Data(repeating: 0x42, count: 100)) + var buffer = full.prefix(50) as Data + XCTAssertNil(TBMonitorProtocol.drainPacket(from: &buffer)) + XCTAssertEqual(buffer.count, 50, "incomplete data must not be consumed") + } + + func testDrainTwoContiguousPackets() { + var buffer = TBMonitorProtocol.makePacket(type: .cursor, payload: Data([0x01])) + buffer.append(TBMonitorProtocol.makePacket(type: .brightness, payload: Data([0x02, 0x03]))) + + let first = TBMonitorProtocol.drainPacket(from: &buffer) + XCTAssertEqual(first?.0, .cursor) + XCTAssertEqual(first?.1, Data([0x01])) + + let second = TBMonitorProtocol.drainPacket(from: &buffer) + XCTAssertEqual(second?.0, .brightness) + XCTAssertEqual(second?.1, Data([0x02, 0x03])) + + XCTAssertTrue(buffer.isEmpty) + } + + /// Simulates TCP fragmentation: the packet arrives one byte at a time and + /// must only drain once the final byte lands. + func testDrainPacketAcrossSplitFeeds() { + let packet = TBMonitorProtocol.makePacket(type: .clipboard, payload: Data("copy me".utf8)) + var buffer = Data() + + for (index, byte) in packet.enumerated() { + buffer.append(byte) + let drained = TBMonitorProtocol.drainPacket(from: &buffer) + if index < packet.count - 1 { + XCTAssertNil(drained, "must not drain before byte \(packet.count - 1), drained at \(index)") + } else { + XCTAssertEqual(drained?.0, .clipboard) + XCTAssertEqual(drained?.1, Data("copy me".utf8)) + } + } + } + + // MARK: - JSON payloads + + func testJSONPacketRoundTrip() { + let heartbeat = TBMonitorHeartbeat(sequence: 42) + guard var buffer = TBMonitorProtocol.makeJSONPacket(type: .heartbeat, value: heartbeat) else { + XCTFail("encode failed"); return + } + guard let (type, payload) = TBMonitorProtocol.drainPacket(from: &buffer) else { + XCTFail("drain failed"); return + } + XCTAssertEqual(type, .heartbeat) + XCTAssertEqual(TBMonitorProtocol.decodeJSON(TBMonitorHeartbeat.self, from: payload)?.sequence, 42) + } + + // MARK: - Hand-rolled input-event encoder parity + // + // `makeInputEventPacket` documents this invariant: "emits the same JSON shape + // `JSONDecoder` reconstructs into a `TBMonitorInputEvent` (omitted fields + // decode as nil)". These tests guard it, since the receiver's snprintf-based + // emitter mirrors the same shape. + + private func makeEvent( + kind: String, + dx: Int? = nil, + dy: Int? = nil, + scrollX: Int? = nil, + scrollY: Int? = nil, + keyCode: UInt16? = nil + ) -> TBMonitorInputEvent { + TBMonitorInputEvent(kind: kind, dx: dx, dy: dy, scrollX: scrollX, scrollY: scrollY, keyCode: keyCode) + } + + private func assertEncoderParity( + _ event: TBMonitorInputEvent, + file: StaticString = #filePath, + line: UInt = #line + ) { + var buffer = TBMonitorProtocol.makeInputEventPacket(event) + guard let (type, payload) = TBMonitorProtocol.drainPacket(from: &buffer) else { + XCTFail("packet did not drain", file: file, line: line) + return + } + XCTAssertEqual(type, .inputEvent, file: file, line: line) + guard let decoded = TBMonitorProtocol.decodeJSON(TBMonitorInputEvent.self, from: payload) else { + XCTFail("payload did not decode as TBMonitorInputEvent: \(String(decoding: payload, as: UTF8.self))", + file: file, line: line) + return + } + XCTAssertEqual(decoded.kind, event.kind, file: file, line: line) + XCTAssertEqual(decoded.dx, event.dx, file: file, line: line) + XCTAssertEqual(decoded.dy, event.dy, file: file, line: line) + XCTAssertEqual(decoded.scrollX, event.scrollX, file: file, line: line) + XCTAssertEqual(decoded.scrollY, event.scrollY, file: file, line: line) + XCTAssertEqual(decoded.keyCode, event.keyCode, file: file, line: line) + } + + func testInputEventEncoderParityMouseMove() { + assertEncoderParity(makeEvent(kind: "move", dx: 5, dy: -3)) + } + + func testInputEventEncoderParityScroll() { + assertEncoderParity(makeEvent(kind: "scroll", scrollX: -120, scrollY: 42)) + } + + func testInputEventEncoderParityKeyDown() { + assertEncoderParity(makeEvent(kind: "keyDown", keyCode: 0x24)) + } + + func testInputEventEncoderParityAllFields() { + assertEncoderParity(makeEvent(kind: "drag", dx: 1, dy: 2, scrollX: 3, scrollY: 4, keyCode: UInt16.max)) + } + + func testInputEventEncoderParityNoOptionalFields() { + assertEncoderParity(makeEvent(kind: "leftUp")) + } + + func testInputEventEncoderParityExtremeValues() { + assertEncoderParity(makeEvent(kind: "move", dx: Int.min, dy: Int.max)) + } +} diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift new file mode 100644 index 0000000..3ed1c84 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift @@ -0,0 +1,100 @@ +import XCTest +@testable import TargetBridge + +/// Tests for the discovered-receiver value model: per-transport address +/// selection and the human-readable summary. These pin down which IP the +/// sender dials for Thunderbolt vs Network Link — the exact decision that +/// determines whether traffic goes over the bridge or the LAN. +final class TBReceiverDiscoveryModelTests: XCTestCase { + + private func makeReceiver( + serviceName: String = "TargetBridge Test-iMac", + receiverName: String = "Test-iMac", + preferredIP: String = "192.168.1.64", + thunderboltIP: String = "", + networkIP: String = "", + panelSummary: String = "", + version: String = "3.1.0", + supportsHEVCDecode: Bool = true, + hostName: String? = nil + ) -> TBDiscoveredReceiver { + TBDiscoveredReceiver( + serviceName: serviceName, + receiverName: receiverName, + preferredIP: preferredIP, + thunderboltIP: thunderboltIP, + networkIP: networkIP, + panelSummary: panelSummary, + version: version, + supportsHEVCDecode: supportsHEVCDecode, + hostName: hostName + ) + } + + // MARK: - ip(for:) transport selection + + func testThunderboltTransportPrefersThunderboltIP() { + let receiver = makeReceiver(preferredIP: "192.168.1.64", thunderboltIP: "169.254.89.80", networkIP: "192.168.1.64") + XCTAssertEqual(receiver.ip(for: .thunderboltBridge), "169.254.89.80") + } + + func testThunderboltTransportFallsBackToPreferredIP() { + let receiver = makeReceiver(preferredIP: "192.168.1.64", thunderboltIP: "", networkIP: "192.168.1.64") + XCTAssertEqual(receiver.ip(for: .thunderboltBridge), "192.168.1.64") + } + + func testNetworkTransportPrefersNetworkIP() { + let receiver = makeReceiver(preferredIP: "169.254.89.80", thunderboltIP: "169.254.89.80", networkIP: "192.168.1.64") + XCTAssertEqual(receiver.ip(for: .networkLink), "192.168.1.64") + } + + func testNetworkTransportFallsBackToPreferredIP() { + let receiver = makeReceiver(preferredIP: "169.254.89.80", thunderboltIP: "169.254.89.80", networkIP: "") + XCTAssertEqual(receiver.ip(for: .networkLink), "169.254.89.80") + } + + // MARK: - Identity + + func testIDCombinesServiceNameAndPreferredIP() { + let receiver = makeReceiver(serviceName: "TargetBridge Jonathans-iMac", preferredIP: "192.168.1.64") + XCTAssertEqual(receiver.id, "TargetBridge Jonathans-iMac|192.168.1.64") + } + + // MARK: - shortHostName + + func testShortHostNameStripsTrailingDotAndDomain() { + let receiver = makeReceiver(hostName: "Jonathans-iMac.local.") + XCTAssertEqual(receiver.shortHostName, "Jonathans-iMac") + } + + func testShortHostNameNilWhenHostMissingOrEmpty() { + XCTAssertNil(makeReceiver(hostName: nil).shortHostName) + XCTAssertNil(makeReceiver(hostName: "").shortHostName) + } + + // MARK: - displayText + + func testDisplayTextShowsBothTransportsWhenAvailable() { + let receiver = makeReceiver( + thunderboltIP: "169.254.89.80", + networkIP: "192.168.1.64", + hostName: "Jonathans-iMac.local." + ) + XCTAssertEqual(receiver.displayText, "Jonathans-iMac (TB 169.254.89.80 · NET 192.168.1.64)") + } + + func testDisplayTextSingleTransportOnly() { + XCTAssertEqual(makeReceiver(thunderboltIP: "169.254.89.80").displayText, "169.254.89.80") + XCTAssertEqual(makeReceiver(networkIP: "192.168.1.64").displayText, "192.168.1.64") + } + + func testDisplayTextFallsBackToPreferredIPWithoutTransportIPs() { + let receiver = makeReceiver(preferredIP: "192.168.1.64") + XCTAssertEqual(receiver.displayText, "192.168.1.64") + } + + func testDisplayTextAppendsPanelSummary() { + let receiver = makeReceiver(networkIP: "192.168.1.64", panelSummary: "iMac 5K (5120x2880)") + XCTAssertEqual(receiver.displayText, "192.168.1.64 · iMac 5K (5120x2880)") + } +} diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift new file mode 100644 index 0000000..a7889aa --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -0,0 +1,173 @@ +import XCTest +@testable import TargetBridge + +/// Tests for the pure parsing helpers behind the `targetbridge://` URL scheme +/// and `--connect` launch arguments (docs/Automation.md). These decide which +/// transport/mode/preset/session a scripted connect uses, so regressions here +/// silently reroute automation traffic. +@MainActor +final class TBSenderAutomationParsingTests: XCTestCase { + + // MARK: - parseTransport + + func testParseTransportNetworkAliases() { + for alias in ["net", "network", "networklink", "link", "NET", "NetworkLink"] { + XCTAssertEqual(TBSenderAutomation.parseTransport(alias), .networkLink, "alias \(alias)") + } + } + + /// Documents the current permissive behavior: anything that is not a + /// network alias — including typos — selects Thunderbolt Bridge. + func testParseTransportDefaultsToThunderbolt() { + for value in ["tb", "thunderbolt", "", "bogus", "TB"] { + XCTAssertEqual(TBSenderAutomation.parseTransport(value), .thunderboltBridge, "value \(value)") + } + } + + // MARK: - parseMode + + func testParseModeExtendedAliases() { + for alias in ["extended", "extend", "extendeddesktop", "ext", "EXTENDED"] { + XCTAssertEqual(TBSenderAutomation.parseMode(alias), .extendedDesktop, "alias \(alias)") + } + } + + func testParseModeMirrorAliases() { + for alias in ["mirror", "mirrored", "desktopmirror", "Mirror"] { + XCTAssertEqual(TBSenderAutomation.parseMode(alias), .desktopMirror, "alias \(alias)") + } + } + + func testParseModeAcceptsExactRawValues() { + XCTAssertEqual(TBSenderAutomation.parseMode("extendedDesktop"), .extendedDesktop) + XCTAssertEqual(TBSenderAutomation.parseMode("desktopMirror"), .desktopMirror) + } + + func testParseModeRejectsUnknown() { + XCTAssertNil(TBSenderAutomation.parseMode("bogus")) + XCTAssertNil(TBSenderAutomation.parseMode("")) + } + + // MARK: - parsePreset + + func testParsePresetAcceptsExactRawValues() { + XCTAssertEqual(TBSenderAutomation.parsePreset("standard1440p"), .standard1440p) + XCTAssertEqual(TBSenderAutomation.parsePreset("smooth1440p60"), .smooth1440p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("smooth1800p60"), .smooth1800p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("crisp2160p60"), .crisp2160p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("native5k"), .native5k) + } + + func testParsePresetAliases() { + XCTAssertEqual(TBSenderAutomation.parsePreset("1440p"), .standard1440p) + XCTAssertEqual(TBSenderAutomation.parsePreset("standard"), .standard1440p) + XCTAssertEqual(TBSenderAutomation.parsePreset("1440p60"), .smooth1440p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("smooth"), .smooth1440p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("1800p"), .smooth1800p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("4k"), .crisp2160p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("crisp"), .crisp2160p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("5k"), .native5k) + XCTAssertEqual(TBSenderAutomation.parsePreset("5K"), .native5k, "aliases are case-insensitive") + XCTAssertEqual(TBSenderAutomation.parsePreset("native"), .native5k) + XCTAssertEqual(TBSenderAutomation.parsePreset("5120x2880"), .native5k) + } + + func testParsePresetRejectsUnknown() { + XCTAssertNil(TBSenderAutomation.parsePreset("bogus")) + XCTAssertNil(TBSenderAutomation.parsePreset("")) + // Raw values are case-sensitive and "native5k" has no capitalized alias. + XCTAssertNil(TBSenderAutomation.parsePreset("NATIVE5K")) + } + + // MARK: - matches (receiver selection for --receiver ) + + private func makeReceiver() -> TBDiscoveredReceiver { + TBDiscoveredReceiver( + serviceName: "TargetBridge Jonathans-iMac", + receiverName: "Jonathans-iMac", + preferredIP: "192.168.1.64", + thunderboltIP: "169.254.89.80", + networkIP: "192.168.1.64", + panelSummary: "iMac 5K", + version: "3.1.0", + supportsHEVCDecode: true, + hostName: "Jonathans-iMac.local." + ) + } + + func testMatchesByName() { + XCTAssertTrue(TBSenderAutomation.matches("Jonathans-iMac", makeReceiver())) + XCTAssertTrue(TBSenderAutomation.matches("jonathans-imac", makeReceiver()), "name match is case-insensitive") + } + + func testMatchesByShortHostName() { + XCTAssertTrue(TBSenderAutomation.matches("jonathans-imac", makeReceiver())) + } + + func testMatchesByAnyAdvertisedIP() { + XCTAssertTrue(TBSenderAutomation.matches("192.168.1.64", makeReceiver()), "preferred/network IP") + XCTAssertTrue(TBSenderAutomation.matches("169.254.89.80", makeReceiver()), "thunderbolt IP") + } + + func testMatchesByID() { + XCTAssertTrue(TBSenderAutomation.matches("targetbridge jonathans-imac|192.168.1.64", makeReceiver())) + } + + func testDoesNotMatchUnrelatedValue() { + XCTAssertFalse(TBSenderAutomation.matches("other-mac", makeReceiver())) + XCTAssertFalse(TBSenderAutomation.matches("10.0.0.1", makeReceiver())) + } + + // MARK: - resolveSessionIndex tri-state + // + // Returns `nil` = invalid input, `.some(nil)` = target all sessions, + // `.some(index)` = zero-based session index. + + func testNoSessionParamTargetsAllSessionsWhenNotCreating() { + let result: Int?? = TBSenderAutomation.resolveSessionIndex(nil, sessionCount: 3, createDefaultIfNeeded: false) + XCTAssertEqual(result, Int??.some(.none), "absent session + no-create should mean 'all sessions'") + } + + func testNoSessionParamDefaultsToFirstSessionWhenCreating() { + XCTAssertEqual( + TBSenderAutomation.resolveSessionIndex(nil, sessionCount: 0, createDefaultIfNeeded: true), + Int??.some(0) + ) + XCTAssertEqual( + TBSenderAutomation.resolveSessionIndex(nil, sessionCount: 3, createDefaultIfNeeded: true), + Int??.some(0) + ) + } + + func testEmptySessionParamBehavesLikeAbsent() { + XCTAssertEqual( + TBSenderAutomation.resolveSessionIndex("", sessionCount: 2, createDefaultIfNeeded: false), + Int??.some(.none) + ) + } + + func testOneBasedIndexIsConvertedToZeroBased() { + XCTAssertEqual( + TBSenderAutomation.resolveSessionIndex("2", sessionCount: 3, createDefaultIfNeeded: false), + Int??.some(1) + ) + } + + func testOutOfRangeSessionIsInvalid() { + XCTAssertNil(TBSenderAutomation.resolveSessionIndex("4", sessionCount: 3, createDefaultIfNeeded: false)) + } + + func testSessionOneOnEmptyListCreatesDefaultOnlyWhenAllowed() { + XCTAssertEqual( + TBSenderAutomation.resolveSessionIndex("1", sessionCount: 0, createDefaultIfNeeded: true), + Int??.some(0) + ) + XCTAssertNil(TBSenderAutomation.resolveSessionIndex("1", sessionCount: 0, createDefaultIfNeeded: false)) + } + + func testNonNumericAndNonPositiveSessionsAreInvalid() { + XCTAssertNil(TBSenderAutomation.resolveSessionIndex("abc", sessionCount: 3, createDefaultIfNeeded: true)) + XCTAssertNil(TBSenderAutomation.resolveSessionIndex("0", sessionCount: 3, createDefaultIfNeeded: true)) + XCTAssertNil(TBSenderAutomation.resolveSessionIndex("-1", sessionCount: 3, createDefaultIfNeeded: true)) + } +} diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 7a3bdb3..72a9c43 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -11,10 +11,13 @@ 0D17E4E85D8E9CDAB8707EB6 /* audio-relay.json in Resources */ = {isa = PBXBuildFile; fileRef = A3964153C9F34085F90C2C26 /* audio-relay.json */; }; 198725EB3B2C7662DBB0B8D0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1819A55F8D993D9347F0E407 /* Assets.xcassets */; }; 233BAEE5ABED3F316F9D3D12 /* input-dockstation.json in Resources */ = {isa = PBXBuildFile; fileRef = A51B69D318A85FF3061D6ED4 /* input-dockstation.json */; }; + 2C4E1C76CA81F640C700F6BF /* TBMonitorProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9583EB47BCD79FA719EEA77 /* TBMonitorProtocolTests.swift */; }; 337BD561622AA19F43D3B95B /* ReceiverBackedVirtualDisplaySession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C242540F9DE94E1364F106F4 /* ReceiverBackedVirtualDisplaySession.swift */; }; 4290B6572B8CA96E01C56425 /* TBDisplaySenderBuildInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB4DF6C72B5C50C29AE6E45 /* TBDisplaySenderBuildInfo.swift */; }; + 4A8719A8C13A6560F4F0EB0D /* TBSenderAutomationParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */; }; 4B770D1232C53EF66F011F7F /* TBAddonManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E4D00FB492A41B5218CD52 /* TBAddonManifest.swift */; }; 5C0CB53005B6D67362F14719 /* TBDisplaySenderContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186812C77BFF98DBE0118100 /* TBDisplaySenderContentView.swift */; }; + 69C27FB23C2EF4078DD3D2F5 /* TBReceiverDiscoveryModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */; }; 6A54029FE56DEECA996A07B4 /* TBDisplaySenderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */; }; 7F1AE9053DA59169FB761FAA /* de.json in Resources */ = {isa = PBXBuildFile; fileRef = C3658E86F5BE2168F4591491 /* de.json */; }; 7FB34AA1E75B01E7D1EC3E21 /* it.json in Resources */ = {isa = PBXBuildFile; fileRef = 036E14097FA59989FFC456FD /* it.json */; }; @@ -36,11 +39,22 @@ F2274C924BAC479635B44A47 /* TBDisplaySenderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4644CFB05098C895378B648A /* TBDisplaySenderManager.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + F78BF9D660B681542156B1E4 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 378AC1A4A812118F1A10D05E /* Project object */; + proxyType = 1; + remoteGlobalIDString = F472012D8B21AAC5A144C3A6; + remoteInfo = TBDisplaySender; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 036E14097FA59989FFC456FD /* it.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = it.json; sourceTree = ""; }; 06BABA1447D1E8DD50A6C2F0 /* TBReceiverDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBReceiverDiscovery.swift; sourceTree = ""; }; 06D011FF8AF7BB6D26EDC25B /* TBAddonStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBAddonStore.swift; sourceTree = ""; }; 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderService.swift; sourceTree = ""; }; + 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBReceiverDiscoveryModelTests.swift; sourceTree = ""; }; 17FD38CAA4E2CF350CC210D2 /* TBDisplaySender.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TBDisplaySender.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1819A55F8D993D9347F0E407 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 186812C77BFF98DBE0118100 /* TBDisplaySenderContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderContentView.swift; sourceTree = ""; }; @@ -59,12 +73,15 @@ B95D015FB8DF172C7418CB5D /* TBDisplaySenderSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderSettingsView.swift; sourceTree = ""; }; C242540F9DE94E1364F106F4 /* ReceiverBackedVirtualDisplaySession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiverBackedVirtualDisplaySession.swift; sourceTree = ""; }; C3658E86F5BE2168F4591491 /* de.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = de.json; sourceTree = ""; }; + C9583EB47BCD79FA719EEA77 /* TBMonitorProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMonitorProtocolTests.swift; sourceTree = ""; }; C9AA5BB4EE39D2C6FB37B90F /* zh.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = zh.json; sourceTree = ""; }; CD235429FA67D231F565C93E /* TBDisplaySenderApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderApp.swift; sourceTree = ""; }; CEB4DF6C72B5C50C29AE6E45 /* TBDisplaySenderBuildInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderBuildInfo.swift; sourceTree = ""; }; D386D0FCD0F93494220CC3DC /* en.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = en.json; sourceTree = ""; }; DA01317B43B46B1B323A7008 /* TBMonitorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMonitorProtocol.swift; sourceTree = ""; }; DD33D06A2FA6BA7E075A9E46 /* TBDisplaySenderSurfaceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderSurfaceViews.swift; sourceTree = ""; }; + DFB8219C144AA88D16BB4F59 /* TBDisplaySenderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TBDisplaySenderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBSenderAutomationParsingTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -99,6 +116,16 @@ path = TBDisplaySender; sourceTree = ""; }; + 578C8E4277884577DC1F2820 /* TBDisplaySenderTests */ = { + isa = PBXGroup; + children = ( + C9583EB47BCD79FA719EEA77 /* TBMonitorProtocolTests.swift */, + 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */, + ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */, + ); + path = TBDisplaySenderTests; + sourceTree = ""; + }; 67A9FD97DC6A4925CB987EB9 /* TBDisplayShared */ = { isa = PBXGroup; children = ( @@ -115,6 +142,7 @@ isa = PBXGroup; children = ( 17FD38CAA4E2CF350CC210D2 /* TBDisplaySender.app */, + DFB8219C144AA88D16BB4F59 /* TBDisplaySenderTests.xctest */, ); name = Products; sourceTree = ""; @@ -125,6 +153,7 @@ EC5C3CD2E8BF9290331E4183 /* TargetBridge-Shared */, B4FD562D7D8645AB2EE6621C /* TargetBridgeSupport */, 08A7C67DCAA583BD6D8B0603 /* TBDisplaySender */, + 578C8E4277884577DC1F2820 /* TBDisplaySenderTests */, 67A9FD97DC6A4925CB987EB9 /* TBDisplayShared */, 7703C219B31BE7C47F726BA2 /* Products */, ); @@ -162,6 +191,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 72E4A9704015C337033B3918 /* TBDisplaySenderTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6B3F38F57A583A1C40A78FAC /* Build configuration list for PBXNativeTarget "TBDisplaySenderTests" */; + buildPhases = ( + 41C3FAAB0014D20C2CB2D35D /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 750E39CE5F2E49E0F2974C21 /* PBXTargetDependency */, + ); + name = TBDisplaySenderTests; + packageProductDependencies = ( + ); + productName = TBDisplaySenderTests; + productReference = DFB8219C144AA88D16BB4F59 /* TBDisplaySenderTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; F472012D8B21AAC5A144C3A6 /* TBDisplaySender */ = { isa = PBXNativeTarget; buildConfigurationList = 2BBAEA859D7D9F128EE14417 /* Build configuration list for PBXNativeTarget "TBDisplaySender" */; @@ -207,6 +254,7 @@ projectRoot = ""; targets = ( F472012D8B21AAC5A144C3A6 /* TBDisplaySender */, + 72E4A9704015C337033B3918 /* TBDisplaySenderTests */, ); }; /* End PBXProject section */ @@ -251,6 +299,16 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 41C3FAAB0014D20C2CB2D35D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2C4E1C76CA81F640C700F6BF /* TBMonitorProtocolTests.swift in Sources */, + 69C27FB23C2EF4078DD3D2F5 /* TBReceiverDiscoveryModelTests.swift in Sources */, + 4A8719A8C13A6560F4F0EB0D /* TBSenderAutomationParsingTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A5AEAA1457A961B01CAF2C8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -279,6 +337,14 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 750E39CE5F2E49E0F2974C21 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F472012D8B21AAC5A144C3A6 /* TBDisplaySender */; + targetProxy = F78BF9D660B681542156B1E4 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 3324300638A25CFE4B431234 /* Debug */ = { isa = XCBuildConfiguration; @@ -328,6 +394,26 @@ }; name = Release; }; + 76944D69134383E0672FE470 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.targetbridge.sender.tests; + SDKROOT = macosx; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TargetBridge.app/Contents/MacOS/TargetBridge"; + }; + name = Debug; + }; 9AC2A7DAA262FB9F7282A255 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -384,6 +470,26 @@ }; name = Release; }; + B1C4017F7EDFDAB39E9A8A34 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.targetbridge.sender.tests; + SDKROOT = macosx; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TargetBridge.app/Contents/MacOS/TargetBridge"; + }; + name = Release; + }; B8A682D4A10103F0490BB3C4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -468,6 +574,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 6B3F38F57A583A1C40A78FAC /* Build configuration list for PBXNativeTarget "TBDisplaySenderTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 76944D69134383E0672FE470 /* Debug */, + B1C4017F7EDFDAB39E9A8A34 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ }; rootObject = 378AC1A4A812118F1A10D05E /* Project object */; diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/xcshareddata/xcschemes/TBDisplaySender.xcscheme b/TargetBridge-Sender/TargetBridge.xcodeproj/xcshareddata/xcschemes/TBDisplaySender.xcscheme new file mode 100644 index 0000000..07f0863 --- /dev/null +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/xcshareddata/xcschemes/TBDisplaySender.xcscheme @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TargetBridge-Sender/project.yml b/TargetBridge-Sender/project.yml index be70966..1b7fa54 100644 --- a/TargetBridge-Sender/project.yml +++ b/TargetBridge-Sender/project.yml @@ -49,3 +49,33 @@ targets: static let versionDisplay = "\\(marketingVersion) + build \\(buildNumber)" } EOI + + TBDisplaySenderTests: + type: bundle.unit-test + platform: macOS + deploymentTarget: "14.0" + sources: + - path: TBDisplaySenderTests + dependencies: + - target: TBDisplaySender + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.targetbridge.sender.tests + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: minimal + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/TargetBridge.app/Contents/MacOS/TargetBridge" + BUNDLE_LOADER: "$(TEST_HOST)" + +schemes: + TBDisplaySender: + build: + targets: + TBDisplaySender: all + run: + config: Debug + test: + config: Debug + gatherCoverageData: false + targets: + - TBDisplaySenderTests From f37f8678341f88aed14b2b2ae62e8944b2cb0565 Mon Sep 17 00:00:00 2001 From: Jason Titus <870238+jasontitus@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:11:15 -0700 Subject: [PATCH 2/2] ci: run sender unit tests and build maintenance branches - Executes the new TBDisplaySenderTests suite after the sender build. - Triggers CI on 3.1-maint and 3.2-dev in addition to main, so the branches that actually receive PRs get build/test coverage. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79cb62c..a287ede 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI Build Verification on: push: - branches: [ main ] + branches: [ main, 3.1-maint, 3.2-dev ] pull_request: - branches: [ main ] + branches: [ main, 3.1-maint, 3.2-dev ] jobs: build-sender: @@ -23,6 +23,11 @@ jobs: cd TargetBridge-Sender ./scripts/build_targetbridge_sender_app.sh + - name: Run Sender Unit Tests + run: | + cd TargetBridge-Sender + xcodebuild test -project TargetBridge.xcodeproj -scheme TBDisplaySender -destination 'platform=macOS' -derivedDataPath .build/DerivedData + build-receiver: name: Build Receiver (${{ matrix.os }} - ${{ matrix.arch }}) strategy: