test(watchos): cover localized chat status fallback

This commit is contained in:
Vincent Koc
2026-07-12 08:45:32 +02:00
parent 0b3e98a76e
commit ff6f4af69a
5 changed files with 181 additions and 95 deletions

View File

@@ -0,0 +1,66 @@
import Foundation
import OpenClawKit
import Testing
struct WatchChatStatusLocalizationTests {
@Test func `snapshot parser keeps known status codes`() throws {
let snapshot = try #require(WatchAppSnapshotMessage.parsePayload(Self.payload(
chatStatusCode: OpenClawWatchChatStatusCode.connectIPhone.rawValue,
chatStatusText: "Legacy status")))
#expect(snapshot.chatStatusCode == .connectIPhone)
#expect(snapshot.chatStatusText == "Legacy status")
}
@Test func `snapshot parser preserves legacy fallback for unknown status codes`() throws {
let snapshot = try #require(WatchAppSnapshotMessage.parsePayload(Self.payload(
chatStatusCode: "futureStatus",
chatStatusText: "Future status from iPhone")))
#expect(snapshot.chatStatusCode == nil)
#expect(snapshot.chatStatusText == "Future status from iPhone")
}
@Test func `watch renders known status locally before legacy text`() {
let rendered = WatchAppSnapshotMessage.localizedChatStatusText(
statusCode: .noMessages,
legacyText: "English status from iPhone",
chatCount: 0,
hasAppSnapshot: true)
#expect(rendered == String(localized: "No chat messages yet"))
}
@Test func `watch renders legacy text when status code is unknown`() throws {
let snapshot = try #require(WatchAppSnapshotMessage.parsePayload(Self.payload(
chatStatusCode: "futureStatus",
chatStatusText: "Future status from iPhone")))
let rendered = WatchAppSnapshotMessage.localizedChatStatusText(
statusCode: snapshot.chatStatusCode,
legacyText: snapshot.chatStatusText,
chatCount: 0,
hasAppSnapshot: true)
#expect(rendered == "Future status from iPhone")
}
private static func payload(
chatStatusCode: String,
chatStatusText: String) -> [String: Any]
{
[
"type": OpenClawWatchPayloadType.appSnapshot.rawValue,
"gatewayStatusText": "Connected",
"gatewayConnected": true,
"agentName": "Main",
"sessionKey": "main",
"talkStatusText": "Off",
"talkEnabled": false,
"talkListening": false,
"talkSpeaking": false,
"pendingApprovalCount": 0,
"chatStatusCode": chatStatusCode,
"chatStatusText": chatStatusText,
]
}
}

View File

@@ -537,52 +537,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
}
private static func parseAppSnapshotPayload(_ payload: [String: Any]) -> WatchAppSnapshotMessage? {
guard let type = payload["type"] as? String,
type == WatchPayloadType.appSnapshot.rawValue
else {
return nil
}
let gatewayStatusText = (payload["gatewayStatusText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentName = (payload["agentName"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentAvatarURL = (payload["agentAvatarUrl"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let agentAvatarText = (payload["agentAvatarText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let sessionKey = (payload["sessionKey"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
let talkStatusText = (payload["talkStatusText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let pendingApprovalCount = (payload["pendingApprovalCount"] as? Int)
?? (payload["pendingApprovalCount"] as? NSNumber)?.intValue
?? 0
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let chatItems = (payload["chatItems"] as? [Any])?.compactMap(Self.parseChatItem)
let chatStatusCode = (payload["chatStatusCode"] as? String)
.flatMap(OpenClawWatchChatStatusCode.init(rawValue:))
let chatStatusText = (payload["chatStatusText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchAppSnapshotMessage(
gatewayStatusText: gatewayStatusText.isEmpty ? "Unknown" : gatewayStatusText,
gatewayConnected: Self.boolValue(payload["gatewayConnected"]),
agentName: agentName.isEmpty ? "Main" : agentName,
agentAvatarURL: agentAvatarURL?.isEmpty == false ? agentAvatarURL : nil,
agentAvatarText: agentAvatarText?.isEmpty == false ? agentAvatarText : nil,
sessionKey: sessionKey.isEmpty ? "main" : sessionKey,
gatewayStableID: gatewayStableID,
talkStatusText: talkStatusText.isEmpty ? "Off" : talkStatusText,
talkEnabled: Self.boolValue(payload["talkEnabled"]),
talkListening: Self.boolValue(payload["talkListening"]),
talkSpeaking: Self.boolValue(payload["talkSpeaking"]),
pendingApprovalCount: max(0, pendingApprovalCount),
chatItems: chatItems,
chatStatusCode: chatStatusCode,
chatStatusText: chatStatusText?.isEmpty == false ? chatStatusText : nil,
sentAtMs: sentAtMs,
snapshotId: snapshotId)
WatchAppSnapshotMessage.parsePayload(payload)
}
private static func parseChatCompletionPayload(
@@ -603,34 +558,6 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
sentAtMs: sentAtMs)
}
private static func parseChatItem(_ item: Any) -> WatchChatItem? {
guard let dict = item as? [String: Any] else { return nil }
guard let id = (dict["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!id.isEmpty
else {
return nil
}
let trimmedRole = (dict["role"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let text, !text.isEmpty else { return nil }
let timestampMs = (dict["timestampMs"] as? NSNumber)?.int64Value
return WatchChatItem(
id: id,
role: trimmedRole.isEmpty ? "assistant" : trimmedRole,
text: text,
timestampMs: timestampMs)
}
private static func boolValue(_ value: Any?) -> Bool {
if let bool = value as? Bool {
return bool
}
if let number = value as? NSNumber {
return number.boolValue
}
return false
}
private static func encodeAppSnapshotRequestPayload(
_ request: WatchAppSnapshotRequestMessage) -> [String: Any]
{

View File

@@ -87,6 +87,113 @@ struct WatchAppSnapshotMessage: Codable, Equatable {
var chatStatusText: String?
var sentAtMs: Int64?
var snapshotId: String?
static func parsePayload(_ payload: [String: Any]) -> Self? {
guard let type = payload["type"] as? String,
type == WatchPayloadType.appSnapshot.rawValue
else {
return nil
}
let gatewayStatusText = (payload["gatewayStatusText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentName = (payload["agentName"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentAvatarURL = (payload["agentAvatarUrl"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let agentAvatarText = (payload["agentAvatarText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let sessionKey = (payload["sessionKey"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
let talkStatusText = (payload["talkStatusText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let pendingApprovalCount = (payload["pendingApprovalCount"] as? Int)
?? (payload["pendingApprovalCount"] as? NSNumber)?.intValue
?? 0
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
let snapshotId = (payload["snapshotId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let chatItems = (payload["chatItems"] as? [Any])?.compactMap(Self.parseChatItem)
let chatStatusCode = (payload["chatStatusCode"] as? String)
.flatMap(OpenClawWatchChatStatusCode.init(rawValue:))
let chatStatusText = (payload["chatStatusText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return Self(
gatewayStatusText: gatewayStatusText.isEmpty ? "Unknown" : gatewayStatusText,
gatewayConnected: Self.boolValue(payload["gatewayConnected"]),
agentName: agentName.isEmpty ? "Main" : agentName,
agentAvatarURL: agentAvatarURL?.isEmpty == false ? agentAvatarURL : nil,
agentAvatarText: agentAvatarText?.isEmpty == false ? agentAvatarText : nil,
sessionKey: sessionKey.isEmpty ? "main" : sessionKey,
gatewayStableID: gatewayStableID,
talkStatusText: talkStatusText.isEmpty ? "Off" : talkStatusText,
talkEnabled: Self.boolValue(payload["talkEnabled"]),
talkListening: Self.boolValue(payload["talkListening"]),
talkSpeaking: Self.boolValue(payload["talkSpeaking"]),
pendingApprovalCount: max(0, pendingApprovalCount),
chatItems: chatItems,
chatStatusCode: chatStatusCode,
chatStatusText: chatStatusText?.isEmpty == false ? chatStatusText : nil,
sentAtMs: sentAtMs,
snapshotId: snapshotId)
}
static func localizedChatStatusText(
statusCode: OpenClawWatchChatStatusCode?,
legacyText: String?,
chatCount: Int,
hasAppSnapshot: Bool) -> String
{
if let statusCode {
return switch statusCode {
case .connectIPhone:
String(localized: "Connect iPhone chat to read messages")
case .noMessages:
String(localized: "No chat messages yet")
case .unavailable:
String(localized: "Chat unavailable")
}
}
if let legacyText, !legacyText.isEmpty {
return legacyText
}
if chatCount > 0 {
return String(
AttributedString(localized: "^[\(chatCount) recent message](inflect: true)").characters)
}
return hasAppSnapshot
? String(localized: "No messages synced")
: String(localized: "Waiting for iPhone")
}
private static func parseChatItem(_ item: Any) -> WatchChatItem? {
guard let dict = item as? [String: Any] else { return nil }
guard let id = (dict["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!id.isEmpty
else {
return nil
}
let trimmedRole = (dict["role"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let text, !text.isEmpty else { return nil }
let timestampMs = (dict["timestampMs"] as? NSNumber)?.int64Value
return WatchChatItem(
id: id,
role: trimmedRole.isEmpty ? "assistant" : trimmedRole,
text: text,
timestampMs: timestampMs)
}
private static func boolValue(_ value: Any?) -> Bool {
if let bool = value as? Bool {
return bool
}
if let number = value as? NSNumber {
return number.boolValue
}
return false
}
}
typealias WatchChatCompletionMessage = OpenClawWatchChatCompletionMessage

View File

@@ -538,27 +538,11 @@ private struct WatchControlSurfaceView: View {
}
private var chatStatusText: String {
if let statusCode = store.appSnapshot?.chatStatusCode {
return switch statusCode {
case .connectIPhone:
String(localized: "Connect iPhone chat to read messages")
case .noMessages:
String(localized: "No chat messages yet")
case .unavailable:
String(localized: "Chat unavailable")
}
}
if let status = store.appSnapshot?.chatStatusText, !status.isEmpty {
return status
}
if self.chatCount > 0 {
let count = self.chatCount
return String(
AttributedString(localized: "^[\(count) recent message](inflect: true)").characters)
}
return self.store.hasAppSnapshot
? String(localized: "No messages synced")
: String(localized: "Waiting for iPhone")
WatchAppSnapshotMessage.localizedChatStatusText(
statusCode: self.store.appSnapshot?.chatStatusCode,
legacyText: self.store.appSnapshot?.chatStatusText,
chatCount: self.chatCount,
hasAppSnapshot: self.store.hasAppSnapshot)
}
private var chatSendStatusText: String? {

View File

@@ -421,6 +421,8 @@ targets:
group: ShareExtension
- path: WatchApp/Sources/WatchVoiceTurnTracker.swift
group: WatchApp/Sources
- path: WatchApp/Sources/WatchInboxMessages.swift
group: WatchApp/Sources
dependencies:
- package: OpenClawKit
settings: