diff --git a/Sources/ExarotonWebSocket/Models.swift b/Sources/ExarotonWebSocket/Models.swift index 0613055..3c02b5b 100644 --- a/Sources/ExarotonWebSocket/Models.swift +++ b/Sources/ExarotonWebSocket/Models.swift @@ -7,6 +7,18 @@ import Foundation +/// exaroton 服务器状态码(https://developers.exaroton.com/ → "Server status")。 +/// +/// ``` +/// 0 = OFFLINE 1 = ONLINE 2 = STARTING 3 = STOPPING 4 = RESTARTING +/// 5 = SAVING 6 = LOADING 7 = CRASHED 8 = PENDING 9 = TRANSFERRING +/// 10 = PREPARING +/// ``` +/// +/// ⚠️ **必须覆盖全部官方取值**:这个枚举参与 WebSocket `status` 消息的解码, +/// 少一个值就意味着"整个状态消息解码失败 → 状态更新被丢掉"(实测漏了 9 = TRANSFERRING, +/// 服务器启动/停止期间的迁移阶段状态会卡住不动)。 +/// 另外解码是**宽容**的:将来 exaroton 新增状态码时退化成 `.UNKNOWN`,而不是让整条消息失败。 public enum ServerStatus: Int, CaseIterable, Codable { case OFFLINE = 0 case ONLINE = 1 @@ -17,7 +29,16 @@ public enum ServerStatus: Int, CaseIterable, Codable { case LOADING = 6 case CRASHED = 7 case PENDING = 8 + case TRANSFERRING = 9 case PREPARING = 10 + + /// 未知/未来新增的状态码。调用方应把它当成"过渡态"处理(不允许动作,等状态稳定)。 + case UNKNOWN = -1 + + public init(from decoder: any Decoder) throws { + let raw = try decoder.singleValueContainer().decode(Int.self) + self = ServerStatus(rawValue: raw) ?? .UNKNOWN + } } public struct Server: Codable, Identifiable { diff --git a/Tests/ExarotonWebSocketTests/ServerStatusDecodingTests.swift b/Tests/ExarotonWebSocketTests/ServerStatusDecodingTests.swift new file mode 100644 index 0000000..0df042a --- /dev/null +++ b/Tests/ExarotonWebSocketTests/ServerStatusDecodingTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import ExarotonWebSocket + +/// 状态码解码:文档里的每个取值都必须能解出来。 +/// +/// 回归背景:枚举曾漏掉 `9 = TRANSFERRING`,导致服务器启动/停止迁移期间的 +/// `status` 消息**整条解码失败**,上层看到的就是"状态卡住不动"。 +@Suite(.tags(.unit, .websocket, .codec)) +final class ServerStatusDecodingTests { + + private func serverJSON(status: Int) -> Data { + Data(""" + {"id":"X","name":"demo","address":"demo.exaroton.me","motd":"hi","status":\(status), + "players":{"max":20,"count":0,"list":[]}, + "software":{"id":"1","name":"Paper","version":"1.0"},"shared":false} + """.utf8) + } + + @Test(arguments: [ + (0, ServerStatus.OFFLINE), + (1, ServerStatus.ONLINE), + (2, ServerStatus.STARTING), + (3, ServerStatus.STOPPING), + (4, ServerStatus.RESTARTING), + (5, ServerStatus.SAVING), + (6, ServerStatus.LOADING), + (7, ServerStatus.CRASHED), + (8, ServerStatus.PENDING), + (9, ServerStatus.TRANSFERRING), + (10, ServerStatus.PREPARING), + ]) + func decodesEveryDocumentedCode(raw: Int, expected: ServerStatus) throws { + let server = try JSONDecoder().decode(Server.self, from: serverJSON(status: raw)) + #expect(server.status == expected) + #expect(server.status.rawValue == raw) + } + + /// 将来 exaroton 新增状态码时不能把整条消息炸掉,退化成 `.UNKNOWN`。 + @Test + func unknownCodeDegradesInsteadOfThrowing() throws { + let server = try JSONDecoder().decode(Server.self, from: serverJSON(status: 99)) + #expect(server.status == .UNKNOWN) + } +}