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