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
This commit is contained in:
132
ios/App/WebTermTests/KeyBarTests.swift
Normal file
132
ios/App/WebTermTests/KeyBarTests.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user