213 lines
7.6 KiB
Swift
213 lines
7.6 KiB
Swift
// 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)
|
|
}
|
|
}
|