// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import TermindApplication import TermindDomain import TermindPresentation import TermindRemoteInfrastructure import Testing @testable import Termind nonisolated private struct InertSerialConnection: SerialPortConnection { func write(_: Data) async throws {} func close() async {} } nonisolated private struct InertSerialService: SerialPortServing { func portUpdates() -> AsyncStream<[SerialPortDescriptor]> { AsyncStream { $0.finish() } } func open( _: SerialConnectionConfiguration, receive _: @escaping @Sendable (Data) -> Void, disconnected _: @escaping @Sendable (String) -> Void, progress _: @escaping @Sendable (SerialPortOpenStage) -> Void ) async throws -> any SerialPortConnection { InertSerialConnection() } } nonisolated private struct EmittingSerialService: SerialPortServing { let data: Data func portUpdates() -> AsyncStream<[SerialPortDescriptor]> { AsyncStream { $0.finish() } } func open( _: SerialConnectionConfiguration, receive: @escaping @Sendable (Data) -> Void, disconnected _: @escaping @Sendable (String) -> Void, progress _: @escaping @Sendable (SerialPortOpenStage) -> Void ) async throws -> any SerialPortConnection { receive(data) return InertSerialConnection() } } nonisolated private final class RecordingSerialConnection: SerialPortConnection, @unchecked Sendable { private let lock = NSLock() private var storedWrites: [Data] = [] var writes: [Data] { lock.withLock { storedWrites } } func write(_ data: Data) async throws { lock.withLock { storedWrites.append(data) } } func close() async {} } nonisolated private struct RecordingSerialService: SerialPortServing { let connection: RecordingSerialConnection func portUpdates() -> AsyncStream<[SerialPortDescriptor]> { AsyncStream { $0.finish() } } func open( _: SerialConnectionConfiguration, receive _: @escaping @Sendable (Data) -> Void, disconnected _: @escaping @Sendable (String) -> Void, progress _: @escaping @Sendable (SerialPortOpenStage) -> Void ) async throws -> any SerialPortConnection { connection } } @Suite struct SerialFeatureTests { @Test func defaultConfigurationIs9600EightNOneWithRawCarriageReturn() { var configuration = SerialConnectionConfiguration() configuration.devicePath = "/dev/ttyUSB0" #expect(configuration.isValid) #expect(configuration.summary == "9600 8-N-1") #expect(configuration.flowControl == .none) #expect(configuration.enterKey == .carriageReturn) #expect(!configuration.localEcho) #expect(configuration.charset == .utf8) #expect(configuration.transformInteractiveInput(Data("a\r".utf8)) == Data("a\r".utf8)) } @Test func enterMappingChangesOnlyCarriageReturnBytes() { var configuration = SerialConnectionConfiguration() configuration.enterKey = .lineFeed #expect( configuration.transformInteractiveInput(Data([0x41, 0x0D, 0x42, 0x0A])) == Data([0x41, 0x0A, 0x42, 0x0A])) configuration.enterKey = .carriageReturnLineFeed #expect(configuration.transformInteractiveInput(Data([0x0D])) == Data([0x0D, 0x0A])) } @Test func validationAcceptsCustomAbsolutePathsAndRejectsBadBaudRates() { var configuration = SerialConnectionConfiguration(devicePath: " /dev/ttyACM0 ") #expect(configuration.isValid) #expect(configuration.normalizedDevicePath == "/dev/ttyACM0") configuration.baudRate = 0 #expect(!configuration.isValid) configuration.baudRate = 4_000_001 #expect(!configuration.isValid) } @MainActor @Test func editableComboKeepsTypedValuesAndPresetMetadataDistinct() { let option = SerialComboOption(value: "/dev/cu.usbserial-01", label: "USB UART — /dev/cu.usbserial-01") #expect(option.id == option.value) #expect(option.label.contains(option.value)) let rates = SerialConnectionConfiguration.commonBaudRates.map(String.init) #expect(rates.contains("115200")) #expect(rates.contains("4000000")) } @Test func discoveryCollapsesCalloutAndDialInAliasesWithoutHidingCustomTTYPaths() { let ports = SerialPortDiscovery.deduplicated([ SerialPortDescriptor(path: "/dev/tty.usbserial-01", name: "USB UART"), SerialPortDescriptor(path: "/dev/cu.usbserial-01", name: "USB UART"), SerialPortDescriptor(path: "/dev/cu.usbserial-01", name: "USB UART"), SerialPortDescriptor(path: "/dev/ttyUSB0", name: "ttyUSB0"), ]) #expect(ports.map(\.path) == ["/dev/cu.usbserial-01", "/dev/ttyUSB0"]) } @MainActor @Test func serialConnectionUsesTheSharedConnectionJourney() { let journey = ConnectionJourney() journey.beginSerial(name: "USB UART", address: "/dev/cu.usbserial-01") #expect(journey.purpose == .serialSession) #expect(journey.phase == .connecting) #expect(journey.endpoints.count == 2) #expect(journey.destination?.name == "USB UART") #expect(journey.destination?.address == "/dev/cu.usbserial-01") journey.fail("Device busy") #expect(journey.phase == .failed("Device busy")) } @MainActor @Test func serialReceiveBypassesInvalidUTF8WithoutDisconnecting() async { let configuration = SerialConnectionConfiguration(devicePath: "/dev/ttyUSB0", charset: .utf8) let session = SerialSessionModel( configuration: configuration, service: EmittingSerialService(data: Data([0x41, 0xFF, 0x42])) ) session.prepareForReopen() session.connectIfNeeded() for _ in 0..<10 { await Task.yield() } #expect(session.status == .connected) session.disconnect() } @MainActor @Test func serialSendBypassesUnrepresentableUTF8WithoutDisconnecting() async { let connection = RecordingSerialConnection() let configuration = SerialConnectionConfiguration( devicePath: "/dev/ttyUSB0", charset: .isoLatin1) let session = SerialSessionModel( configuration: configuration, service: RecordingSerialService(connection: connection) ) session.prepareForReopen() session.connectIfNeeded() for _ in 0..<10 { await Task.yield() } session.handleTerminalWrite(Data("café🙂".utf8), interactive: false) for _ in 0..<10 { await Task.yield() } #expect(connection.writes == [Data([0x63, 0x61, 0x66, 0xE9]) + Data("🙂".utf8)]) #expect(session.status == .connected) session.disconnect() } @MainActor @Test func serialPaneParticipatesInSessionsAndFindButCannotBeDuplicated() { let configuration = SerialConnectionConfiguration( devicePath: "/dev/ttyUSB0", deviceName: "USB UART" ) let pane = Pane.serialSession(configuration: configuration, service: InertSerialService()) let workspace = Workspace(pane: pane) let window = WindowModel(workspaces: [workspace]) window.select(workspace.id) #expect(pane.kind == .serialSession) #expect(pane.serialSession?.title == "USB UART") #expect(window.sessionPanes.map(\.pane.id) == [pane.id]) #expect(window.serialSessionPanes.map(\.pane.id) == [pane.id]) #expect(window.canFindInTerminal) #expect(window.canSplitSelected) #expect(!window.canDuplicateSelected) } }