初始提交:terminalX 可运行态(M0/M1/M1.5 已真机验证)
- M0: libghostty SSH 终端(渲染/输入/连接)+ 白屏修复(OutputGate) + 会话状态机/自动重连 - M1: tsnet 用户态组网 + SSH-over-tsnet(fd 桥),shell 级真机验证;R5(Go+gvisor+C+++Swift 同进程) retire - M1.5: tmux -CC 原生 tab(MVP) - 结构: packages/(TXCore·TXTransport), apps/TerminalX, vendor/(libghostty-spm/libssh2/mbedtls/tsnet-bridge), artifacts/ - 文档: CLAUDE.md + docs/HANDOFF.md(新会话入口) - 环境: 认证代理→依赖 vendor 本地化;Go 在 ~/.local/go;仅模拟器/未签名 - 待续: M2 mosh, tmux 多 pane, M4 安全(host key/SE) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
128
packages/TXCore/Tests/TXCoreTests/SessionMachineTests.swift
Normal file
128
packages/TXCore/Tests/TXCoreTests/SessionMachineTests.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
import Testing
|
||||
@testable import TXCore
|
||||
|
||||
@Suite("会话生命周期状态机")
|
||||
struct SessionMachineTests {
|
||||
|
||||
@Test("正常连接流程")
|
||||
func happyPath() {
|
||||
var m = SessionMachine()
|
||||
#expect(m.reduce(.connectRequested) == [.startConnect])
|
||||
#expect(m.phase == .connecting)
|
||||
#expect(m.reduce(.authenticating) == [])
|
||||
#expect(m.phase == .authenticating)
|
||||
#expect(m.reduce(.established) == [.notify("已连接")])
|
||||
#expect(m.phase == .connected)
|
||||
}
|
||||
|
||||
@Test("认证失败为终态,不自动重连")
|
||||
func authFailedTerminal() {
|
||||
var m = SessionMachine()
|
||||
_ = m.reduce(.connectRequested)
|
||||
let effects = m.reduce(.authFailed(reason: "密码错误"))
|
||||
#expect(m.phase == .failed(reason: "密码错误"))
|
||||
#expect(effects.contains(.teardownTransport))
|
||||
// failed 态收到网络事件不动
|
||||
#expect(m.reduce(.transportClosed(reason: nil)) == [])
|
||||
#expect(m.phase == .failed(reason: "密码错误"))
|
||||
}
|
||||
|
||||
@Test("断线进入退避重连")
|
||||
func disconnectSchedulesReconnect() {
|
||||
var m = SessionMachine()
|
||||
_ = m.reduce(.connectRequested)
|
||||
_ = m.reduce(.established)
|
||||
let effects = m.reduce(.transportClosed(reason: "eof"))
|
||||
#expect(m.phase == .waitingToReconnect(attempt: 1))
|
||||
#expect(effects.contains(.teardownTransport))
|
||||
#expect(effects.contains(.scheduleReconnect(attempt: 1, delayMS: 500)))
|
||||
}
|
||||
|
||||
@Test("重连计时→重连→成功")
|
||||
func reconnectSuccess() {
|
||||
var m = SessionMachine()
|
||||
_ = m.reduce(.connectRequested)
|
||||
_ = m.reduce(.established)
|
||||
_ = m.reduce(.transportClosed(reason: nil))
|
||||
#expect(m.reduce(.reconnectTimerFired) == [.startConnect])
|
||||
#expect(m.phase == .reconnecting(attempt: 1))
|
||||
#expect(m.reduce(.established) == [.notify("已重连")])
|
||||
#expect(m.phase == .connected)
|
||||
}
|
||||
|
||||
@Test("重连反复失败,退避递增,达上限转 failed")
|
||||
func reconnectExhaustion() {
|
||||
var m = SessionMachine(maxReconnectAttempts: 3, baseDelayMS: 500, maxDelayMS: 16000)
|
||||
_ = m.reduce(.connectRequested)
|
||||
_ = m.reduce(.established)
|
||||
|
||||
// 第1次断
|
||||
_ = m.reduce(.transportClosed(reason: nil))
|
||||
#expect(m.phase == .waitingToReconnect(attempt: 1))
|
||||
_ = m.reduce(.reconnectTimerFired) // reconnecting(1)
|
||||
// 第2次断(attempt 2, delay 1000)
|
||||
let e2 = m.reduce(.transportClosed(reason: nil))
|
||||
#expect(m.phase == .waitingToReconnect(attempt: 2))
|
||||
#expect(e2.contains(.scheduleReconnect(attempt: 2, delayMS: 1000)))
|
||||
_ = m.reduce(.reconnectTimerFired) // reconnecting(2)
|
||||
// 第3次断(attempt 3, delay 2000)
|
||||
let e3 = m.reduce(.transportClosed(reason: nil))
|
||||
#expect(m.phase == .waitingToReconnect(attempt: 3))
|
||||
#expect(e3.contains(.scheduleReconnect(attempt: 3, delayMS: 2000)))
|
||||
_ = m.reduce(.reconnectTimerFired) // reconnecting(3)
|
||||
// 第4次断 → 超过 maxReconnectAttempts(3) → failed
|
||||
let e4 = m.reduce(.transportClosed(reason: "gone"))
|
||||
if case .failed = m.phase {} else { Issue.record("期望 failed,实际 \(m.phase)") }
|
||||
#expect(e4.contains(.notify("重连失败,请手动重试")))
|
||||
}
|
||||
|
||||
@Test("退避封顶")
|
||||
func backoffCap() {
|
||||
let m = SessionMachine(baseDelayMS: 500, maxDelayMS: 16000)
|
||||
#expect(m.backoffDelayMS(attempt: 1) == 500)
|
||||
#expect(m.backoffDelayMS(attempt: 2) == 1000)
|
||||
#expect(m.backoffDelayMS(attempt: 5) == 8000)
|
||||
#expect(m.backoffDelayMS(attempt: 6) == 16000)
|
||||
#expect(m.backoffDelayMS(attempt: 10) == 16000) // 封顶
|
||||
}
|
||||
|
||||
@Test("后台冻结与前台恢复")
|
||||
func backgroundResume() {
|
||||
var m = SessionMachine()
|
||||
_ = m.reduce(.connectRequested)
|
||||
_ = m.reduce(.established)
|
||||
#expect(m.reduce(.enteredBackground) == [])
|
||||
#expect(m.phase == .backgroundParked)
|
||||
let effects = m.reduce(.enteredForeground)
|
||||
#expect(m.phase == .reconnecting(attempt: 1))
|
||||
#expect(effects.contains(.startConnect))
|
||||
}
|
||||
|
||||
@Test("等待重连时进入后台取消计时")
|
||||
func backgroundDuringWait() {
|
||||
var m = SessionMachine()
|
||||
_ = m.reduce(.connectRequested)
|
||||
_ = m.reduce(.established)
|
||||
_ = m.reduce(.transportClosed(reason: nil))
|
||||
#expect(m.phase == .waitingToReconnect(attempt: 1))
|
||||
#expect(m.reduce(.enteredBackground) == [.cancelReconnectTimer])
|
||||
#expect(m.phase == .backgroundParked)
|
||||
}
|
||||
|
||||
@Test("主动关闭与手动重试复位")
|
||||
func closeAndRetry() {
|
||||
var m = SessionMachine()
|
||||
_ = m.reduce(.connectRequested)
|
||||
_ = m.reduce(.established)
|
||||
let closeEffects = m.reduce(.closeRequested)
|
||||
#expect(m.phase == .closed)
|
||||
#expect(closeEffects.contains(.teardownTransport))
|
||||
|
||||
// failed 后手动重试
|
||||
var m2 = SessionMachine()
|
||||
_ = m2.reduce(.connectRequested)
|
||||
_ = m2.reduce(.authFailed(reason: "x"))
|
||||
#expect(m2.reduce(.retryRequested) == [.startConnect])
|
||||
#expect(m2.phase == .connecting)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user