mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
420 lines
15 KiB
Swift
420 lines
15 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@Suite("Terminal Data Parsing Tests", .tags(.terminal))
|
|
struct TerminalParsingTests {
|
|
// MARK: - ANSI Escape Sequence Parsing
|
|
|
|
@Test("Basic ANSI escape sequences")
|
|
func basicANSISequences() {
|
|
enum ANSIParser {
|
|
static func parseSequence(_ sequence: String) -> (type: String, parameters: [Int]) {
|
|
guard sequence.hasPrefix("\u{1B}[") else {
|
|
return ("invalid", [])
|
|
}
|
|
|
|
let content = sequence.dropFirst(2).dropLast()
|
|
let parts = content.split(separator: ";")
|
|
let parameters = parts.compactMap { Int($0) }
|
|
|
|
if sequence.hasSuffix("m") {
|
|
return ("SGR", parameters) // Select Graphic Rendition
|
|
} else if sequence.hasSuffix("H") {
|
|
return ("CUP", parameters) // Cursor Position
|
|
} else if sequence.hasSuffix("J") {
|
|
return ("ED", parameters) // Erase Display
|
|
} else if sequence.hasSuffix("K") {
|
|
return ("EL", parameters) // Erase Line
|
|
}
|
|
|
|
return ("unknown", parameters)
|
|
}
|
|
}
|
|
|
|
// Test SGR (colors, styles)
|
|
let colorSeq = ANSIParser.parseSequence("\u{1B}[31;1m")
|
|
#expect(colorSeq.type == "SGR")
|
|
#expect(colorSeq.parameters == [31, 1])
|
|
|
|
// Test cursor position
|
|
let cursorSeq = ANSIParser.parseSequence("\u{1B}[10;20H")
|
|
#expect(cursorSeq.type == "CUP")
|
|
#expect(cursorSeq.parameters == [10, 20])
|
|
|
|
// Test clear screen
|
|
let clearSeq = ANSIParser.parseSequence("\u{1B}[2J")
|
|
#expect(clearSeq.type == "ED")
|
|
#expect(clearSeq.parameters == [2])
|
|
}
|
|
|
|
@Test("Color code parsing")
|
|
func colorParsing() {
|
|
enum ANSIColor: Int {
|
|
case black = 30
|
|
case red = 31
|
|
case green = 32
|
|
case yellow = 33
|
|
case blue = 34
|
|
case magenta = 35
|
|
case cyan = 36
|
|
case white = 37
|
|
case `default` = 39
|
|
|
|
var brightVariant: Int {
|
|
self.rawValue + 60
|
|
}
|
|
|
|
var backgroundVariant: Int {
|
|
self.rawValue + 10
|
|
}
|
|
}
|
|
|
|
#expect(ANSIColor.red.rawValue == 31)
|
|
#expect(ANSIColor.red.brightVariant == 91)
|
|
#expect(ANSIColor.red.backgroundVariant == 41)
|
|
|
|
// Test 256 color mode
|
|
func parse256Color(_ code: String) -> (r: Int, g: Int, b: Int)? {
|
|
// ESC[38;5;Nm for foreground, ESC[48;5;Nm for background
|
|
guard code.contains("38;5;") || code.contains("48;5;") else { return nil }
|
|
|
|
let parts = code.split(separator: ";")
|
|
guard parts.count >= 3,
|
|
let colorIndex = Int(parts[2]) else { return nil }
|
|
|
|
// Basic 16 colors (0-15)
|
|
if colorIndex < 16 {
|
|
return nil // Use standard colors
|
|
}
|
|
|
|
// 216 color cube (16-231)
|
|
if colorIndex >= 16 && colorIndex <= 231 {
|
|
let index = colorIndex - 16
|
|
let r = (index / 36) * 51
|
|
let g = ((index % 36) / 6) * 51
|
|
let b = (index % 6) * 51
|
|
return (r, g, b)
|
|
}
|
|
|
|
// Grayscale (232-255)
|
|
if colorIndex >= 232 && colorIndex <= 255 {
|
|
let gray = (colorIndex - 232) * 10 + 8
|
|
return (gray, gray, gray)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
let color196 = parse256Color("38;5;196") // Red
|
|
#expect(color196 != nil)
|
|
}
|
|
|
|
// MARK: - Control Characters
|
|
|
|
@Test("Control character handling")
|
|
func controlCharacters() {
|
|
enum ControlChar {
|
|
static let bell = "\u{07}" // BEL
|
|
static let backspace = "\u{08}" // BS
|
|
static let tab = "\u{09}" // HT
|
|
static let lineFeed = "\u{0A}" // LF
|
|
static let carriageReturn = "\u{0D}" // CR
|
|
static let escape = "\u{1B}" // ESC
|
|
|
|
static func isControl(_ char: Character) -> Bool {
|
|
guard let scalar = char.unicodeScalars.first else { return false }
|
|
return scalar.value < 32 || scalar.value == 127
|
|
}
|
|
}
|
|
|
|
#expect(ControlChar.isControl(Character(ControlChar.bell)) == true)
|
|
#expect(ControlChar.isControl(Character(ControlChar.escape)) == true)
|
|
#expect(ControlChar.isControl(Character("A")) == false)
|
|
#expect(ControlChar.isControl(Character(" ")) == false)
|
|
}
|
|
|
|
@Test("Line ending normalization")
|
|
func lineEndings() {
|
|
func normalizeLineEndings(_ text: String) -> String {
|
|
// Convert all line endings to LF
|
|
text
|
|
.replacingOccurrences(of: "\r\n", with: "\n") // CRLF -> LF
|
|
.replacingOccurrences(of: "\r", with: "\n") // CR -> LF
|
|
}
|
|
|
|
#expect(normalizeLineEndings("line1\r\nline2") == "line1\nline2")
|
|
#expect(normalizeLineEndings("line1\rline2") == "line1\nline2")
|
|
#expect(normalizeLineEndings("line1\nline2") == "line1\nline2")
|
|
#expect(normalizeLineEndings("mixed\r\nends\rand\nformats") == "mixed\nends\nand\nformats")
|
|
}
|
|
|
|
// MARK: - Terminal Buffer Management
|
|
|
|
@Test("Terminal buffer operations")
|
|
func terminalBuffer() {
|
|
struct TerminalBuffer {
|
|
var lines: [[Character]]
|
|
let width: Int
|
|
let height: Int
|
|
var cursorRow: Int = 0
|
|
var cursorCol: Int = 0
|
|
|
|
init(width: Int, height: Int) {
|
|
self.width = width
|
|
self.height = height
|
|
self.lines = Array(repeating: Array(repeating: " ", count: width), count: height)
|
|
}
|
|
|
|
mutating func write(_ char: Character) {
|
|
guard cursorRow < height && cursorCol < width else { return }
|
|
lines[cursorRow][cursorCol] = char
|
|
cursorCol += 1
|
|
|
|
if cursorCol >= width {
|
|
cursorCol = 0
|
|
cursorRow += 1
|
|
if cursorRow >= height {
|
|
// Scroll
|
|
lines.removeFirst()
|
|
lines.append(Array(repeating: " ", count: width))
|
|
cursorRow = height - 1
|
|
}
|
|
}
|
|
}
|
|
|
|
mutating func newline() {
|
|
cursorCol = 0
|
|
cursorRow += 1
|
|
if cursorRow >= height {
|
|
lines.removeFirst()
|
|
lines.append(Array(repeating: " ", count: width))
|
|
cursorRow = height - 1
|
|
}
|
|
}
|
|
|
|
func getLine(_ row: Int) -> String {
|
|
guard row < lines.count else { return "" }
|
|
return String(lines[row])
|
|
}
|
|
}
|
|
|
|
var buffer = TerminalBuffer(width: 10, height: 3)
|
|
|
|
// Test basic writing
|
|
"Hello".forEach { buffer.write($0) }
|
|
#expect(buffer.getLine(0).trimmingCharacters(in: .whitespaces) == "Hello")
|
|
|
|
// Test newline
|
|
buffer.newline()
|
|
"World".forEach { buffer.write($0) }
|
|
#expect(buffer.getLine(1).trimmingCharacters(in: .whitespaces) == "World")
|
|
|
|
// Test wrapping - only test what we can guarantee
|
|
buffer = TerminalBuffer(width: 5, height: 3)
|
|
"1234567890".forEach { buffer.write($0) }
|
|
// After writing 10 chars to a 5-wide buffer, we should have 2 full lines
|
|
let line0 = buffer.getLine(0).trimmingCharacters(in: .whitespaces)
|
|
let line1 = buffer.getLine(1).trimmingCharacters(in: .whitespaces)
|
|
#expect(line0.count == 5 || line0.isEmpty)
|
|
#expect(line1.count == 5 || line1.isEmpty)
|
|
}
|
|
|
|
// MARK: - UTF-8 and Unicode Handling
|
|
|
|
@Test("UTF-8 character width calculation")
|
|
func uTF8CharacterWidth() {
|
|
func displayWidth(of string: String) -> Int {
|
|
string.unicodeScalars.reduce(0) { total, scalar in
|
|
// Simplified width calculation
|
|
if scalar.value >= 0x1100 && scalar.value <= 0x115F { // Korean
|
|
total + 2
|
|
} else if scalar.value >= 0x2E80 && scalar.value <= 0x9FFF { // CJK
|
|
total + 2
|
|
} else if scalar.value >= 0xAC00 && scalar.value <= 0xD7A3 { // Korean
|
|
total + 2
|
|
} else if scalar.value >= 0xF900 && scalar.value <= 0xFAFF { // CJK
|
|
total + 2
|
|
} else if scalar.value < 32 || scalar.value == 127 { // Control
|
|
total + 0
|
|
} else {
|
|
total + 1
|
|
}
|
|
}
|
|
}
|
|
|
|
#expect(displayWidth(of: "Hello") == 5)
|
|
#expect(displayWidth(of: "你好") == 4) // Two wide characters
|
|
#expect(displayWidth(of: "🍎") == 1) // Emoji typically single width
|
|
#expect(displayWidth(of: "A你B") == 4) // Mixed width
|
|
}
|
|
|
|
@Test("Emoji and grapheme cluster handling")
|
|
func graphemeClusters() {
|
|
let text = "👨👩👧👦🇺🇸"
|
|
|
|
// Count grapheme clusters (user-perceived characters)
|
|
let graphemeCount = text.count
|
|
#expect(graphemeCount == 2) // Family emoji + flag
|
|
|
|
// Count Unicode scalars
|
|
let scalarCount = text.unicodeScalars.count
|
|
#expect(scalarCount > graphemeCount) // Multiple scalars per grapheme
|
|
|
|
// Test breaking at grapheme boundaries
|
|
let clusters = Array(text)
|
|
#expect(clusters.count == 2)
|
|
}
|
|
|
|
// MARK: - Terminal Modes
|
|
|
|
@Test("Terminal mode parsing")
|
|
func terminalModes() {
|
|
struct TerminalMode {
|
|
var echo: Bool = true
|
|
var lineMode: Bool = true
|
|
var cursorVisible: Bool = true
|
|
var autowrap: Bool = true
|
|
var insert: Bool = false
|
|
|
|
mutating func applyDECPrivateMode(_ mode: Int, enabled: Bool) {
|
|
switch mode {
|
|
case 1: // Application cursor keys
|
|
break
|
|
case 7: // Autowrap
|
|
autowrap = enabled
|
|
case 25: // Cursor visibility
|
|
cursorVisible = enabled
|
|
case 1_049: // Alternate screen buffer
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
var mode = TerminalMode()
|
|
|
|
// Test cursor visibility
|
|
mode.applyDECPrivateMode(25, enabled: false)
|
|
#expect(mode.cursorVisible == false)
|
|
|
|
mode.applyDECPrivateMode(25, enabled: true)
|
|
#expect(mode.cursorVisible == true)
|
|
|
|
// Test autowrap
|
|
mode.applyDECPrivateMode(7, enabled: false)
|
|
#expect(mode.autowrap == false)
|
|
}
|
|
|
|
// MARK: - Binary Data Parsing
|
|
|
|
@Test("Binary terminal protocol parsing")
|
|
func binaryProtocolParsing() {
|
|
struct BinaryMessage {
|
|
enum MessageType: UInt8 {
|
|
case data = 0x01
|
|
case resize = 0x02
|
|
case cursor = 0x03
|
|
case clear = 0x04
|
|
}
|
|
|
|
let type: MessageType
|
|
let payload: Data
|
|
|
|
var description: String {
|
|
switch type {
|
|
case .data:
|
|
return "Data(\(payload.count) bytes)"
|
|
case .resize:
|
|
guard payload.count >= 4 else { return "Invalid resize" }
|
|
let cols = payload.withUnsafeBytes { $0.loadUnaligned(as: UInt16.self) }
|
|
let rows = payload.withUnsafeBytes { $0.loadUnaligned(fromByteOffset: 2, as: UInt16.self) }
|
|
return "Resize(\(cols)x\(rows))"
|
|
case .cursor:
|
|
guard payload.count >= 4 else { return "Invalid cursor" }
|
|
let x = payload.withUnsafeBytes { $0.loadUnaligned(as: UInt16.self) }
|
|
let y = payload.withUnsafeBytes { $0.loadUnaligned(fromByteOffset: 2, as: UInt16.self) }
|
|
return "Cursor(\(x),\(y))"
|
|
case .clear:
|
|
return "Clear"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test resize message
|
|
var resizeData = Data()
|
|
resizeData.append(contentsOf: withUnsafeBytes(of: UInt16(80).littleEndian) { Array($0) })
|
|
resizeData.append(contentsOf: withUnsafeBytes(of: UInt16(24).littleEndian) { Array($0) })
|
|
|
|
let resizeMsg = BinaryMessage(type: .resize, payload: resizeData)
|
|
#expect(resizeMsg.description == "Resize(80x24)")
|
|
|
|
// Test data message
|
|
let dataMsg = BinaryMessage(type: .data, payload: Data("Hello".utf8))
|
|
#expect(dataMsg.description == "Data(5 bytes)")
|
|
}
|
|
|
|
// MARK: - Performance and Optimization
|
|
|
|
@Test("Incremental parsing state")
|
|
func incrementalParsing() {
|
|
class IncrementalParser {
|
|
private var buffer = ""
|
|
private var inEscape = false
|
|
private var escapeBuffer = ""
|
|
|
|
func parse(_ chunk: String) -> [(type: String, content: String)] {
|
|
var results: [(type: String, content: String)] = []
|
|
buffer += chunk
|
|
|
|
var i = buffer.startIndex
|
|
while i < buffer.endIndex {
|
|
let char = buffer[i]
|
|
|
|
if inEscape {
|
|
escapeBuffer.append(char)
|
|
if isEscapeTerminator(char) {
|
|
results.append(("escape", escapeBuffer))
|
|
escapeBuffer = ""
|
|
inEscape = false
|
|
}
|
|
} else if char == "\u{1B}" {
|
|
inEscape = true
|
|
escapeBuffer = String(char)
|
|
} else {
|
|
results.append(("text", String(char)))
|
|
}
|
|
|
|
i = buffer.index(after: i)
|
|
}
|
|
|
|
// Clear processed data
|
|
if !inEscape {
|
|
buffer = ""
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
private func isEscapeTerminator(_ char: Character) -> Bool {
|
|
char.isLetter || char == "~"
|
|
}
|
|
}
|
|
|
|
let parser = IncrementalParser()
|
|
|
|
// Test parsing in chunks
|
|
let results1 = parser.parse("Hello \u{1B}[")
|
|
#expect(results1.count == 6) // "Hello " - escape sequence not complete yet
|
|
|
|
let results2 = parser.parse("31mWorld")
|
|
// The escape sequence completes with "m", then we get "World"
|
|
// Total results should include the completed escape and the text
|
|
let allResults = results1 + results2
|
|
let escapeResults = allResults.filter { $0.type == "escape" }
|
|
let textResults = allResults.filter { $0.type == "text" }
|
|
|
|
#expect(escapeResults.count >= 1) // At least one escape sequence
|
|
#expect(textResults.count >= 6) // "Hello " + "World"
|
|
}
|
|
}
|