T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM) T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field) T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed) T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites) Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
133 lines
5.3 KiB
Swift
133 lines
5.3 KiB
Swift
import SessionCore
|
|
import Testing
|
|
import UIKit
|
|
@testable import WebTerm
|
|
|
|
/// T-iOS-11 · KeyBar component + hardware key commands (plan §7).
|
|
/// The bar's layout mirrors `public/keybar.ts` `KEYBAR_BUTTONS` (order, glyphs,
|
|
/// captions, primary flag) and EVERY label→bytes lookup goes through
|
|
/// `KeyByteMap` — no byte literal exists in the App layer.
|
|
@MainActor
|
|
@Suite("KeyBar")
|
|
struct KeyBarTests {
|
|
@MainActor
|
|
private final class KeyRecorder {
|
|
private(set) var keys: [KeyByteMap.Key] = []
|
|
func record(_ key: KeyByteMap.Key) { keys = keys + [key] }
|
|
}
|
|
|
|
// MARK: - Layout data (mirror of KEYBAR_BUTTONS)
|
|
|
|
@Test("button order mirrors public/keybar.ts KEYBAR_BUTTONS exactly")
|
|
func layoutOrderMirrorsWebKeybarButtons() {
|
|
let expectedOrder: [KeyByteMap.Key] = [
|
|
.esc, .escEsc, .shiftTab, .arrowUp, .arrowDown, .enter, .ctrlC,
|
|
.ctrlR, .ctrlO, .ctrlL, .ctrlT, .ctrlB, .ctrlD, .tab,
|
|
.arrowLeft, .arrowRight, .slash,
|
|
]
|
|
#expect(KeyBarLayout.buttons.map(\.key) == expectedOrder)
|
|
}
|
|
|
|
@Test("glyph labels mirror the web bar; Esc is the only primary button")
|
|
func labelsAndPrimaryFlagMirrorWeb() {
|
|
let expectedLabels = [
|
|
"Esc", "Esc²", "⇧Tab", "↑", "↓", "⏎", "^C", "^R", "^O", "^L",
|
|
"^T", "^B", "^D", "Tab", "←", "→", "/",
|
|
]
|
|
#expect(KeyBarLayout.buttons.map(\.label) == expectedLabels)
|
|
#expect(KeyBarLayout.buttons.filter(\.isPrimary).map(\.key) == [.esc])
|
|
}
|
|
|
|
@Test("every button spec resolves to real bytes through KeyByteMap (single source of truth)")
|
|
func everySpecResolvesThroughKeyByteMap() {
|
|
for spec in KeyBarLayout.buttons {
|
|
#expect(!KeyByteMap.bytes(for: spec.key).isEmpty,
|
|
"no bytes for \(spec.key.rawValue)")
|
|
}
|
|
}
|
|
|
|
// MARK: - KeyBarView (UIKit input accessory)
|
|
|
|
@Test("KeyBarView builds one button per spec with the web title as accessibility label")
|
|
func keyBarViewBuildsOneButtonPerSpec() {
|
|
// Arrange / Act
|
|
let bar = KeyBarView()
|
|
|
|
// Assert
|
|
#expect(bar.keyButtons.count == KeyBarLayout.buttons.count)
|
|
#expect(bar.keyButtons.map(\.accessibilityLabel)
|
|
== KeyBarLayout.buttons.map(\.title))
|
|
}
|
|
|
|
@Test("tapping button i fires onKey with layout key i (bytes resolved downstream via KeyByteMap)")
|
|
func tappingButtonsFiresOnKeyInLayoutOrder() {
|
|
// Arrange
|
|
let bar = KeyBarView()
|
|
let recorder = KeyRecorder()
|
|
bar.onKey = { recorder.record($0) }
|
|
|
|
// Act: tap every button once, in order.
|
|
for button in bar.keyButtons {
|
|
button.sendActions(for: .touchUpInside)
|
|
}
|
|
|
|
// Assert
|
|
#expect(recorder.keys == KeyBarLayout.buttons.map(\.key))
|
|
}
|
|
|
|
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
|
|
|
|
@Test("hardware chords: Esc, Shift+Tab and the 7 Ctrl chords map through KeyByteMap")
|
|
func hardwareChordsCoverEscShiftTabAndCtrlChords() {
|
|
let expected: [(KeyByteMap.Key, String, UIKeyModifierFlags)] = [
|
|
(.esc, UIKeyCommand.inputEscape, []),
|
|
(.shiftTab, "\t", .shift),
|
|
(.ctrlC, "c", .control),
|
|
(.ctrlR, "r", .control),
|
|
(.ctrlO, "o", .control),
|
|
(.ctrlL, "l", .control),
|
|
(.ctrlT, "t", .control),
|
|
(.ctrlB, "b", .control),
|
|
(.ctrlD, "d", .control),
|
|
]
|
|
#expect(HardwareKeyCommands.chords.count == expected.count)
|
|
for (key, input, modifiers) in expected {
|
|
let chord = HardwareKeyCommands.chords.first { $0.key == key }
|
|
#expect(chord?.input == input, "input mismatch for \(key.rawValue)")
|
|
#expect(chord?.modifiers == modifiers, "modifiers mismatch for \(key.rawValue)")
|
|
#expect(!KeyByteMap.bytes(for: key).isEmpty)
|
|
}
|
|
}
|
|
|
|
@Test("build() emits one prioritized UIKeyCommand per chord and key(matching:) round-trips")
|
|
func buildEmitsPrioritizedCommandsAndRoundTrips() {
|
|
// Arrange / Act
|
|
let commands = HardwareKeyCommands.build(
|
|
action: #selector(UIResponder.becomeFirstResponder) // any selector; not invoked
|
|
)
|
|
|
|
// Assert
|
|
#expect(commands.count == HardwareKeyCommands.chords.count)
|
|
for command in commands {
|
|
#expect(command.wantsPriorityOverSystemBehavior)
|
|
let key = HardwareKeyCommands.key(matching: command)
|
|
#expect(key != nil, "no chord matches \(String(describing: command.input))")
|
|
}
|
|
#expect(Set(commands.compactMap { HardwareKeyCommands.key(matching: $0) }).count
|
|
== HardwareKeyCommands.chords.count)
|
|
}
|
|
|
|
@Test("hardware mapping never hijacks plain typing keys or arrows (SwiftTerm owns them — DECCKM)")
|
|
func hardwareMappingExcludesPlainKeysAndArrows() {
|
|
// Arrows must stay SwiftTerm-native: a fixed \u{1B}[A override would
|
|
// break application-cursor-keys mode (vim/htop send \u{1B}OA there).
|
|
// Enter/Tab/"/" are ordinary typing; Esc·Esc is two Esc presses.
|
|
let excluded: Set<KeyByteMap.Key> = [
|
|
.arrowUp, .arrowDown, .arrowLeft, .arrowRight,
|
|
.enter, .tab, .slash, .escEsc,
|
|
]
|
|
let chordKeys = Set(HardwareKeyCommands.chords.map(\.key))
|
|
#expect(chordKeys.isDisjoint(with: excluded))
|
|
}
|
|
}
|