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:
@@ -0,0 +1,57 @@
|
||||
/// Key-name → terminal byte-string table (T-iOS-11; pure data, no I/O).
|
||||
///
|
||||
/// Byte-for-byte mirror of the web client's `public/keybar.ts` `KEY_MAP` —
|
||||
/// the SINGLE source of truth for every label→bytes lookup on iOS: the KeyBar
|
||||
/// buttons AND the hardware `UIKeyCommand` mapping both resolve through here
|
||||
/// (plan §7 T-iOS-11). Hand-writing an escape sequence anywhere in the App
|
||||
/// layer is a review finding.
|
||||
///
|
||||
/// Gotcha carried over from the web client: Enter is `\r` (0x0D, carriage
|
||||
/// return), NOT `\n` — synthesized input with a linefeed breaks TUIs.
|
||||
public enum KeyByteMap {
|
||||
/// One key the bar/hardware mapping can send. Raw values mirror the web
|
||||
/// `KEY_MAP` property names; declaration order mirrors the web table.
|
||||
public enum Key: String, Sendable, CaseIterable {
|
||||
case esc
|
||||
case escEsc
|
||||
case shiftTab
|
||||
case arrowUp
|
||||
case arrowDown
|
||||
case arrowLeft
|
||||
case arrowRight
|
||||
case enter
|
||||
case ctrlC
|
||||
case ctrlR
|
||||
case ctrlO
|
||||
case ctrlL
|
||||
case ctrlT
|
||||
case ctrlB
|
||||
case ctrlD
|
||||
case tab
|
||||
case slash
|
||||
}
|
||||
|
||||
/// The exact byte string to send as `ClientMessage.input(data:)` for `key`.
|
||||
/// Values are verbatim from public/keybar.ts KEY_MAP (see file doc).
|
||||
public static func bytes(for key: Key) -> String {
|
||||
switch key {
|
||||
case .esc: return "\u{1B}"
|
||||
case .escEsc: return "\u{1B}\u{1B}"
|
||||
case .shiftTab: return "\u{1B}[Z"
|
||||
case .arrowUp: return "\u{1B}[A"
|
||||
case .arrowDown: return "\u{1B}[B"
|
||||
case .arrowLeft: return "\u{1B}[D"
|
||||
case .arrowRight: return "\u{1B}[C"
|
||||
case .enter: return "\r" // NOT "\n" (CLAUDE.md gotcha)
|
||||
case .ctrlC: return "\u{03}"
|
||||
case .ctrlR: return "\u{12}"
|
||||
case .ctrlO: return "\u{0F}"
|
||||
case .ctrlL: return "\u{0C}"
|
||||
case .ctrlT: return "\u{14}"
|
||||
case .ctrlB: return "\u{02}"
|
||||
case .ctrlD: return "\u{04}"
|
||||
case .tab: return "\t"
|
||||
case .slash: return "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Testing
|
||||
@testable import SessionCore
|
||||
|
||||
/// T-iOS-11 · KeyByteMap (plan §7). The key-bar byte table must mirror
|
||||
/// `public/keybar.ts` `KEY_MAP` byte-for-byte — every entry is compared
|
||||
/// against an independently written expectation (raw scalar values, not the
|
||||
/// implementation's own literals) so a typo in either side turns the suite red.
|
||||
@Suite("KeyByteMap")
|
||||
struct KeyByteMapTests {
|
||||
/// Expected byte strings, transcribed key-by-key from public/keybar.ts
|
||||
/// KEY_MAP (the web client's single source of truth). Values are built
|
||||
/// from Unicode scalars so this table cannot share a typo with the
|
||||
/// implementation's string literals.
|
||||
private static let expectedBytesByKey: [KeyByteMap.Key: [UInt8]] = [
|
||||
.esc: [0x1B], // '\x1b'
|
||||
.escEsc: [0x1B, 0x1B], // '\x1b\x1b'
|
||||
.shiftTab: [0x1B, 0x5B, 0x5A], // '\x1b[Z'
|
||||
.arrowUp: [0x1B, 0x5B, 0x41], // '\x1b[A'
|
||||
.arrowDown: [0x1B, 0x5B, 0x42], // '\x1b[B'
|
||||
.arrowLeft: [0x1B, 0x5B, 0x44], // '\x1b[D'
|
||||
.arrowRight: [0x1B, 0x5B, 0x43], // '\x1b[C'
|
||||
.enter: [0x0D], // '\r' — carriage return, NOT '\n'
|
||||
.ctrlC: [0x03],
|
||||
.ctrlR: [0x12],
|
||||
.ctrlO: [0x0F],
|
||||
.ctrlL: [0x0C],
|
||||
.ctrlT: [0x14],
|
||||
.ctrlB: [0x02],
|
||||
.ctrlD: [0x04],
|
||||
.tab: [0x09], // '\t'
|
||||
.slash: [0x2F], // '/'
|
||||
]
|
||||
|
||||
@Test("every key resolves to the exact bytes of public/keybar.ts KEY_MAP")
|
||||
func everyEntryMatchesWebKeyMapByteForByte() {
|
||||
for key in KeyByteMap.Key.allCases {
|
||||
// Arrange
|
||||
let expected = Self.expectedBytesByKey[key]
|
||||
|
||||
// Act
|
||||
let actual = Array(KeyByteMap.bytes(for: key).utf8)
|
||||
|
||||
// Assert
|
||||
#expect(actual == expected, "bytes mismatch for \(key.rawValue)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("the table covers exactly the 17 web KEY_MAP entries — no more, no less")
|
||||
func tableCoversExactlyTheWebKeyMapEntries() {
|
||||
#expect(KeyByteMap.Key.allCases.count == Self.expectedBytesByKey.count)
|
||||
#expect(Set(KeyByteMap.Key.allCases).count == KeyByteMap.Key.allCases.count)
|
||||
}
|
||||
|
||||
@Test("Enter sends carriage return (0x0D), never linefeed (gotcha: \\r not \\n)")
|
||||
func enterIsCarriageReturnNotLinefeed() {
|
||||
#expect(KeyByteMap.bytes(for: .enter) == "\r")
|
||||
#expect(KeyByteMap.bytes(for: .enter) != "\n")
|
||||
}
|
||||
|
||||
@Test("arrow CSI letters: up=A down=B left=D right=C (left/right are the classic swap)")
|
||||
func arrowFinalLettersAreNotSwapped() {
|
||||
#expect(KeyByteMap.bytes(for: .arrowUp).hasSuffix("A"))
|
||||
#expect(KeyByteMap.bytes(for: .arrowDown).hasSuffix("B"))
|
||||
#expect(KeyByteMap.bytes(for: .arrowLeft).hasSuffix("D"))
|
||||
#expect(KeyByteMap.bytes(for: .arrowRight).hasSuffix("C"))
|
||||
}
|
||||
|
||||
@Test("raw values mirror the web KEY_MAP property names (stable lookup identity)")
|
||||
func rawValuesMirrorWebKeyNames() {
|
||||
let expectedNames = [
|
||||
"esc", "escEsc", "shiftTab", "arrowUp", "arrowDown", "arrowLeft",
|
||||
"arrowRight", "enter", "ctrlC", "ctrlR", "ctrlO", "ctrlL", "ctrlT",
|
||||
"ctrlB", "ctrlD", "tab", "slash",
|
||||
]
|
||||
#expect(KeyByteMap.Key.allCases.map(\.rawValue) == expectedNames)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user