Files
web-terminal/ios/App/WebTermTests/KeyBarTests.swift
Yaojia Wang 5098643355 feat(ios): W3 UI layer + integration CI + ntfy docs
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
2026-07-05 00:13:14 +02:00

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 labelbytes 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))
}
}