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) } // MARK: - M3 mosh 生命周期 private func connectedThenMosh() -> SessionMachine { var m = SessionMachine() _ = m.reduce(.connectRequested) _ = m.reduce(.established) return m } @Test("mosh 接管拆 SSH,之后忽略 transportClosed") func moshTakeover() { var m = connectedThenMosh() let e = m.reduce(.moshEstablished) #expect(m.phase == .moshActive) #expect(e.contains(.teardownTransport)) // 底层 SSH 残连断开,不影响 mosh #expect(m.reduce(.transportClosed(reason: "ssh eof")) == []) #expect(m.phase == .moshActive) } @Test("mosh 后台→前台:唤醒脉冲 + 看门狗,收到 healthy 即恢复") func moshSuspendResumeHealthy() { var m = connectedThenMosh() _ = m.reduce(.moshEstablished) #expect(m.reduce(.enteredBackground) == []) #expect(m.phase == .moshParked) let e = m.reduce(.enteredForeground) #expect(m.phase == .moshResuming(attempt: 1)) #expect(e.contains(.nudgeResume)) #expect(e.contains(.scheduleResumeWatchdog(ms: 3000))) // SSP 续上 let e2 = m.reduce(.moshHealthy) #expect(m.phase == .moshActive) #expect(e2.contains(.cancelResumeWatchdog)) } @Test("恢复看门狗:一次超时再脉冲,两次仍失败则兜底全量重建") func moshResumeWatchdogFallback() { var m = SessionMachine(resumeWatchdogMS: 3000, maxResumePulses: 2) _ = m.reduce(.connectRequested) _ = m.reduce(.established) _ = m.reduce(.moshEstablished) _ = m.reduce(.enteredBackground) _ = m.reduce(.enteredForeground) // moshResuming(1) // 第一次看门狗到点 → 再脉冲,进 moshResuming(2) let e1 = m.reduce(.resumeWatchdogFired) #expect(m.phase == .moshResuming(attempt: 2)) #expect(e1.contains(.nudgeResume)) // 第二次看门狗到点 → 兜底全量重建 let e2 = m.reduce(.resumeWatchdogFired) #expect(m.phase == .reconnecting(attempt: 1)) #expect(e2.contains(.teardownMosh)) #expect(e2.contains(.startConnect)) } @Test("mosh 主循环退出转 failed 并拆除") func moshExited() { var m = connectedThenMosh() _ = m.reduce(.moshEstablished) let e = m.reduce(.moshExited(rc: 1)) if case .failed = m.phase {} else { Issue.record("期望 failed,实际 \(m.phase)") } #expect(e.contains(.teardownMosh)) } @Test("mosh 恢复中进后台取消看门狗") func moshResumingToBackground() { var m = connectedThenMosh() _ = m.reduce(.moshEstablished) _ = m.reduce(.enteredBackground) _ = m.reduce(.enteredForeground) // moshResuming(1) #expect(m.reduce(.enteredBackground) == [.cancelResumeWatchdog]) #expect(m.phase == .moshParked) } }