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: - Dynamic Type (T-iOS-34 · the bar tracks the keycap it draws) /// Measured height of every keycap, in layout order (the mic key last when /// present) — the only honest way to ask "did the keycaps really re-render". private func keycapHeights(_ bar: KeyBarView) -> [CGFloat] { (bar.keyButtons + [bar.voiceButton].compactMap { $0 }) .map { $0.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height } } @Test("changing the text size re-renders every keycap and re-measures the bar") func contentSizeCategoryChangeRebuildsTheKeycaps() { // Arrange: a bar born at the standard size, mic key included. let bar = KeyBarView( voice: KeyBarVoiceHandlers(onDown: {}, onUp: {}), contentSizeCategory: .large ) let standardHeight = bar.intrinsicContentSize.height let standardKeycaps = keycapHeights(bar) // Act: the user drags the text-size slider to the maximum. bar.apply(contentSizeCategory: .accessibilityExtraExtraExtraLarge) // Assert: EVERY keycap grew (mic included) and the bar grew to hold them. let largestKeycaps = keycapHeights(bar) #expect(bar.contentSizeCategory == .accessibilityExtraExtraExtraLarge) #expect(bar.intrinsicContentSize.height > standardHeight) #expect(bar.frame.height == bar.intrinsicContentSize.height) #expect(standardKeycaps.count == KeyBarLayout.buttons.count + 1) #expect(zip(standardKeycaps, largestKeycaps).allSatisfy { $1 > $0 }) #expect(largestKeycaps.allSatisfy { $0 <= bar.intrinsicContentSize.height }) } /// The runtime path, not just the direct call: the user changes the text size /// in Settings while a terminal is open, UIKit pushes a new trait collection, /// and the bar must re-measure itself — otherwise it keeps whatever height it /// was born with until the terminal is reopened. @Test("a trait-collection change re-lays-out the bar (no reopen needed)") func traitChangePropagatesToTheBar() { // Arrange: a bar inside a real window (trait propagation needs one). let bar = KeyBarView(contentSizeCategory: .large) let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) window.addSubview(bar) window.layoutIfNeeded() let before = bar.intrinsicContentSize.height // Act: what UIKit does when the system text size changes. window.traitOverrides.preferredContentSizeCategory = .accessibilityExtraExtraExtraLarge window.layoutIfNeeded() // Assert #expect(bar.contentSizeCategory == .accessibilityExtraExtraExtraLarge) #expect(bar.intrinsicContentSize.height > before) } @Test("re-applying the same text size is a no-op (trait callbacks fire for anything)") func reapplyingTheSameCategoryChangesNothing() { // Arrange let bar = KeyBarView(contentSizeCategory: .large) let before = keycapHeights(bar) let beforeHeight = bar.intrinsicContentSize.height // Act bar.apply(contentSizeCategory: .large) // Assert #expect(bar.contentSizeCategory == .large) #expect(keycapHeights(bar) == before) #expect(bar.intrinsicContentSize.height == beforeHeight) } /// The HIG floor is enforced by a constraint, not by the glyph: at the small /// text sizes a keycap's own content is only ~37pt tall, so without this the /// bar would shrink below a tappable target. Asserted structurally because /// `systemLayoutSizeFitting` reports the CONTENT size (it is what the /// clipping test needs) and would not show the constraint at all. @Test("every keycap carries the 44pt HIG hit-target floor, at every text size") func keycapsCarryTheHitTargetFloor() { for category in [UIContentSizeCategory.extraSmall, .large, .accessibilityExtraExtraExtraLarge] { let bar = KeyBarView( voice: KeyBarVoiceHandlers(onDown: {}, onUp: {}), contentSizeCategory: category ) for button in bar.keyButtons + [bar.voiceButton].compactMap({ $0 }) { #expect(button.constraints.contains { $0.firstAttribute == .height && $0.relation == .greaterThanOrEqual && $0.constant == DS.Layout.minHitTarget }) } #expect(bar.intrinsicContentSize.height >= DS.Layout.minHitTarget) } } // 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 = [ .arrowUp, .arrowDown, .arrowLeft, .arrowRight, .enter, .tab, .slash, .escEsc, ] let chordKeys = Set(HardwareKeyCommands.chords.map(\.key)) #expect(chordKeys.isDisjoint(with: excluded)) } }