Files
web-terminal/ios/App/WebTermTests/KeyBarTests.swift
Yaojia Wang 5cc755b0b6 fix(ios,android): close the acceptance gaps the review found, add Android CI
- T-iOS-34's stated acceptance is "最大字号不破版" and it was failing: the key bar
  froze its height at 52pt while an AX5 keycap needs 108.24pt, so caps clipped —
  recorded as a withKnownIssue rather than fixed. Height now derives from the
  content size category (and tracks live changes via registerForTraitChanges);
  the known-issue marker is gone, replaced by positive assertions including a
  12-category no-clip sweep and a guard that stays red if anyone writes the
  constant back. Honest tradeoff: the keycap font is clamped at .accessibility2,
  the same policy the design system already applies to dense content, because an
  unclamped AX5 bar would eat the terminal. A test pins the clamp so the two
  cannot drift.

- Thumbnails silently 401'd on a token-gated host: the pipeline built its own
  transport with no token source, and by design never throws, so every preview
  degraded to a placeholder with no signal. Assembled from AppEnvironment now.

- Android had zero CI while the token wave shipped 24 files of secret-handling
  code. The instrumented leg is workflow_dispatch-only and says why in the file:
  no one has ever seen it green on a runner, and a required leg nobody trusts
  just produces a false green.

- Android persisted a validated token before the host probe succeeded, stranding
  a secret for a host that never paired.

App bundle 534 -> 550 on both simulators, zero known issues. Android 687 -> 691.
2026-07-30 16:46:20 +02:00

229 lines
9.8 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: - 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<KeyByteMap.Key> = [
.arrowUp, .arrowDown, .arrowLeft, .arrowRight,
.enter, .tab, .slash, .escEsc,
]
let chordKeys = Set(HardwareKeyCommands.chords.map(\.key))
#expect(chordKeys.isDisjoint(with: excluded))
}
}