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.
This commit is contained in:
Yaojia Wang
2026-07-30 16:46:20 +02:00
parent 284cfd193a
commit 5cc755b0b6
14 changed files with 1120 additions and 102 deletions

View File

@@ -75,6 +75,102 @@ struct KeyBarTests {
#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")