| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // Presence wire contract: the "presence" hub frame and the roster's stamped |
| 5 | // presence field decode as the server sends them (presence.go / PR #413), so |
| 6 | // the native dots read the same states the web does. |
| 7 | final class PresenceTests: XCTestCase { |
| 8 | func testPresenceFrameDecodes() throws { |
| 9 | let json = #"{"type":"presence","user_id":"u-alice","presence":"away"}"# |
| 10 | let frame = try JSONDecoder().decode(ChatSocket.Frame.self, from: Data(json.utf8)) |
| 11 | XCTAssertEqual(frame.type, "presence") |
| 12 | XCTAssertEqual(frame.user_id, "u-alice") |
| 13 | XCTAssertEqual(frame.presence, "away") |
| 14 | XCTAssertNil(frame.thread_id) // presence is roster-wide, never thread-scoped |
| 15 | } |
| 16 | |
| 17 | // Older frames (msg/typing/…) carry no presence fields — they must keep |
| 18 | // decoding with the new optionals absent. |
| 19 | func testFramesWithoutPresenceStillDecode() throws { |
| 20 | let json = #"{"type":"msg","thread_id":"t1","id":7,"created_at":1700000000,"blob":"x"}"# |
| 21 | let frame = try JSONDecoder().decode(ChatSocket.Frame.self, from: Data(json.utf8)) |
| 22 | XCTAssertEqual(frame.type, "msg") |
| 23 | XCTAssertNil(frame.user_id) |
| 24 | XCTAssertNil(frame.presence) |
| 25 | } |
| 26 | |
| 27 | // The pure filter rules mirror web/static/ui.js — same cases as ui.test.mjs, |
| 28 | // so web and native can't drift on what "Online only" or a category means. |
| 29 | func testOnlineFilterKeeps() { |
| 30 | // People decide: online keeps, away/offline drop. |
| 31 | XCTAssertTrue(onlineFilterKeeps([.init(agent: false, presence: "online")])) |
| 32 | XCTAssertFalse(onlineFilterKeeps([.init(agent: false, presence: "away")])) |
| 33 | XCTAssertFalse(onlineFilterKeeps([.init(agent: false, presence: "offline")])) |
| 34 | // A chat with ONLY bots falls back to the bot's own status (sidecar health). |
| 35 | XCTAssertTrue(onlineFilterKeeps([.init(agent: true, presence: "online")])) |
| 36 | XCTAssertFalse(onlineFilterKeeps([.init(agent: true, presence: "offline")])) |
| 37 | // In a mixed room only the people count — an always-on bot must not make |
| 38 | // every room it sits in read "someone's online". |
| 39 | XCTAssertFalse(onlineFilterKeeps([ |
| 40 | .init(agent: true, presence: "online"), .init(agent: false, presence: "offline"), |
| 41 | ])) |
| 42 | XCTAssertTrue(onlineFilterKeeps([ |
| 43 | .init(agent: true, presence: "offline"), .init(agent: false, presence: "online"), |
| 44 | ])) |
| 45 | // Notes/app threads (no other members) never match. |
| 46 | XCTAssertFalse(onlineFilterKeeps([])) |
| 47 | } |
| 48 | |
| 49 | func testChatCategory() { |
| 50 | XCTAssertEqual(chatCategory(isApp: true, others: []), .apps) |
| 51 | XCTAssertEqual(chatCategory(isApp: true, others: [.init(agent: true, presence: "online")]), .apps) |
| 52 | XCTAssertEqual(chatCategory(isApp: false, others: [.init(agent: true, presence: "offline")]), .bots) |
| 53 | // Any person in the room makes it a people chat, bots present or not. |
| 54 | XCTAssertEqual(chatCategory(isApp: false, others: [ |
| 55 | .init(agent: true, presence: "online"), .init(agent: false, presence: "offline"), |
| 56 | ]), .people) |
| 57 | XCTAssertEqual(chatCategory(isApp: false, others: []), .people) // your own Notes |
| 58 | } |
| 59 | |
| 60 | // The inline filter chip's copy — one shared derivation, so iOS and Mac say |
| 61 | // the same words for the same flags. |
| 62 | func testChatFilterSummary() { |
| 63 | XCTAssertNil(chatFilterSummary(onlineOnly: false, people: true, bots: true, apps: true)) |
| 64 | XCTAssertEqual(chatFilterSummary(onlineOnly: true, people: true, bots: true, apps: true), |
| 65 | "Online only") |
| 66 | XCTAssertEqual(chatFilterSummary(onlineOnly: false, people: true, bots: true, apps: false), |
| 67 | "Hiding apps") |
| 68 | XCTAssertEqual(chatFilterSummary(onlineOnly: false, people: true, bots: false, apps: false), |
| 69 | "Hiding bots & apps") |
| 70 | XCTAssertEqual(chatFilterSummary(onlineOnly: false, people: false, bots: false, apps: false), |
| 71 | "Hiding people, bots & apps") |
| 72 | XCTAssertEqual(chatFilterSummary(onlineOnly: true, people: true, bots: false, apps: false), |
| 73 | "Online only · hiding bots & apps") |
| 74 | } |
| 75 | |
| 76 | func testPresenceText() { |
| 77 | XCTAssertEqual(presenceText("online"), "Online") |
| 78 | XCTAssertEqual(presenceText("away"), "Away") |
| 79 | XCTAssertEqual(presenceText("offline"), "Offline") |
| 80 | XCTAssertNil(presenceText("")) |
| 81 | XCTAssertNil(presenceText(nil)) |
| 82 | } |
| 83 | |
| 84 | func testRosterEntryCarriesPresence() throws { |
| 85 | let json = #"{"user_id":"u1","label":"Alice","public_key":"pk","presence":"online"}"# |
| 86 | let entry = try JSONDecoder().decode(RosterEntry.self, from: Data(json.utf8)) |
| 87 | XCTAssertEqual(entry.presence, "online") |
| 88 | |
| 89 | // The field is omitempty on the server — absent must decode too. |
| 90 | let legacy = #"{"user_id":"u2","label":"Bob","public_key":"pk2"}"# |
| 91 | let bob = try JSONDecoder().decode(RosterEntry.self, from: Data(legacy.utf8)) |
| 92 | XCTAssertNil(bob.presence) |
| 93 | } |
| 94 | } |
| 95 | |