Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
186 changes: 186 additions & 0 deletions TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
@@ -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)")
}
}
Loading