| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // 🤖 Cross-platform event contract tests for the Reminders app. |
| 5 | // These tests document what a native Reminders implementation must do. |
| 6 | // They will initially fail (the implementation doesn't exist yet); |
| 7 | // as you build the native side, make them pass. |
| 8 | // |
| 9 | // Source of truth: web/static/apps/remind.js |
| 10 | // Event types: remind.add, remind.fire, remind.done |
| 11 | // State shape: { items: [{ id, text, when, ts, sid, fired, firedAt }] } |
| 12 | // Note: `when` is absolute epoch ms (due time); `ts` is add time; `sid` is server schedule id |
| 13 | |
| 14 | final class RemindAppVectorTests: XCTestCase { |
| 15 | // A reminder item as stored locally |
| 16 | struct ReminderItem: Equatable { |
| 17 | let id: String |
| 18 | let text: String |
| 19 | let when: Int64 // absolute epoch ms when the reminder fires |
| 20 | let ts: Int64 // epoch ms when added (send time) |
| 21 | let sid: String // server schedule id ("" if scheduling failed) |
| 22 | let fired: Bool // has the reminder fired? |
| 23 | let firedAt: Int64 // epoch ms when it fired (drives grace-period animation) |
| 24 | } |
| 25 | |
| 26 | // Fold state for tests |
| 27 | struct RemindState: Equatable { |
| 28 | var items: [ReminderItem] = [] |
| 29 | } |
| 30 | |
| 31 | // Helper: apply a fold event to state (mimics the web app's reducers) |
| 32 | func foldRemind(_ state: RemindState, event: [String: Any]) -> RemindState { |
| 33 | var items = state.items |
| 34 | |
| 35 | if let type = event["type"] as? String { |
| 36 | switch type { |
| 37 | case "remind.add": |
| 38 | if let id = event["id"] as? String, |
| 39 | let text = event["text"] as? String, |
| 40 | let when = event["when"] as? Int64, |
| 41 | !id.isEmpty, |
| 42 | !text.isEmpty { |
| 43 | // addItem dedupes by id |
| 44 | if !items.contains(where: { $0.id == id }) { |
| 45 | let ts = (event["ts"] as? Int64) ?? 0 |
| 46 | let sid = (event["sid"] as? String) ?? "" |
| 47 | items.append(ReminderItem(id: id, text: text, when: when, ts: ts, sid: sid, fired: false, firedAt: 0)) |
| 48 | } |
| 49 | // Sort by `when` (soonest first) |
| 50 | items.sort { $0.when < $1.when } |
| 51 | } |
| 52 | |
| 53 | case "remind.fire": |
| 54 | if let id = event["id"] as? String { |
| 55 | let firedAt = (event["ts"] as? Int64) ?? 0 |
| 56 | items = items.map { item in |
| 57 | item.id == id ? ReminderItem(id: item.id, text: item.text, when: item.when, ts: item.ts, sid: item.sid, fired: true, firedAt: firedAt) : item |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | case "remind.done": |
| 62 | if let id = event["id"] as? String { |
| 63 | items.removeAll { $0.id == id } |
| 64 | } |
| 65 | |
| 66 | default: |
| 67 | break |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | return RemindState(items: items) |
| 72 | } |
| 73 | |
| 74 | func testRemindAdd() { |
| 75 | var state = RemindState() |
| 76 | let event: [String: Any] = [ |
| 77 | "type": "remind.add", |
| 78 | "id": "remind-1", |
| 79 | "text": "call plumber", |
| 80 | "when": Int64(1700003600000), |
| 81 | "ts": Int64(1700000000000), |
| 82 | "sid": "sch-abc123", |
| 83 | ] |
| 84 | |
| 85 | state = foldRemind(state, event: event) |
| 86 | |
| 87 | XCTAssertEqual(state.items.count, 1, "should have one reminder") |
| 88 | let item = state.items[0] |
| 89 | XCTAssertEqual(item.id, "remind-1") |
| 90 | XCTAssertEqual(item.text, "call plumber") |
| 91 | XCTAssertEqual(item.when, 1700003600000, "when should be the due time") |
| 92 | XCTAssertEqual(item.ts, 1700000000000, "ts should be the add time") |
| 93 | XCTAssertEqual(item.sid, "sch-abc123", "sid should be the server schedule id") |
| 94 | XCTAssertEqual(item.fired, false, "newly added reminders are not fired") |
| 95 | XCTAssertEqual(item.firedAt, 0, "newly added reminders have no firedAt") |
| 96 | } |
| 97 | |
| 98 | func testRemindFire() { |
| 99 | var state = RemindState(items: [ |
| 100 | ReminderItem(id: "remind-1", text: "call plumber", when: 1700003600000, ts: 1700000000000, sid: "sch-abc123", fired: false, firedAt: 0), |
| 101 | ]) |
| 102 | |
| 103 | let event: [String: Any] = [ |
| 104 | "type": "remind.fire", |
| 105 | "id": "remind-1", |
| 106 | "notice": [ |
| 107 | "title": "⏰ Reminder", |
| 108 | "body": "call plumber", |
| 109 | ], |
| 110 | "ts": Int64(1700003600000), |
| 111 | ] |
| 112 | |
| 113 | state = foldRemind(state, event: event) |
| 114 | |
| 115 | XCTAssertEqual(state.items.count, 1) |
| 116 | let item = state.items[0] |
| 117 | XCTAssertEqual(item.fired, true, "item should be marked fired") |
| 118 | XCTAssertEqual(item.firedAt, 1700003600000, "firedAt should record the fire timestamp") |
| 119 | } |
| 120 | |
| 121 | func testRemindDone() { |
| 122 | var state = RemindState(items: [ |
| 123 | ReminderItem(id: "remind-1", text: "call plumber", when: 1700003600000, ts: 1700000000000, sid: "sch-abc123", fired: false, firedAt: 0), |
| 124 | ]) |
| 125 | |
| 126 | let event: [String: Any] = [ |
| 127 | "type": "remind.done", |
| 128 | "id": "remind-1", |
| 129 | ] |
| 130 | |
| 131 | state = foldRemind(state, event: event) |
| 132 | |
| 133 | XCTAssertEqual(state.items.count, 0, "reminder should be removed") |
| 134 | } |
| 135 | |
| 136 | func testRemindSorted() { |
| 137 | var state = RemindState() |
| 138 | |
| 139 | let events: [[String: Any]] = [ |
| 140 | [ |
| 141 | "type": "remind.add", |
| 142 | "id": "remind-1", |
| 143 | "text": "late reminder", |
| 144 | "when": Int64(1700010000000), |
| 145 | "ts": Int64(1700000000000), |
| 146 | "sid": "sch-abc123", |
| 147 | ], |
| 148 | [ |
| 149 | "type": "remind.add", |
| 150 | "id": "remind-2", |
| 151 | "text": "early reminder", |
| 152 | "when": Int64(1700003600000), |
| 153 | "ts": Int64(1700000100000), |
| 154 | "sid": "sch-def456", |
| 155 | ], |
| 156 | ] |
| 157 | |
| 158 | for event in events { |
| 159 | state = foldRemind(state, event: event) |
| 160 | } |
| 161 | |
| 162 | XCTAssertEqual(state.items.count, 2, "should have two reminders") |
| 163 | XCTAssertEqual(state.items[0].id, "remind-2", "first should be remind-2 (soonest)") |
| 164 | XCTAssertEqual(state.items[1].id, "remind-1", "second should be remind-1 (latest)") |
| 165 | XCTAssertTrue(state.items[0].when < state.items[1].when, "items should be sorted by when") |
| 166 | } |
| 167 | |
| 168 | func testRemindIdempotence() { |
| 169 | var state = RemindState() |
| 170 | |
| 171 | let event: [String: Any] = [ |
| 172 | "type": "remind.add", |
| 173 | "id": "remind-1", |
| 174 | "text": "call plumber", |
| 175 | "when": Int64(1700003600000), |
| 176 | "ts": Int64(1700000000000), |
| 177 | "sid": "sch-abc123", |
| 178 | ] |
| 179 | |
| 180 | // Fold the same event twice (e.g., duplicate delivery on reconnect) |
| 181 | state = foldRemind(state, event: event) |
| 182 | state = foldRemind(state, event: event) |
| 183 | |
| 184 | XCTAssertEqual(state.items.count, 1, "should have exactly one reminder (duplicate add deduped)") |
| 185 | } |
| 186 | |
| 187 | func testRemindGracePeriodPartition() { |
| 188 | // Grace period: reminders are kept visible for ~750ms after firing, |
| 189 | // then tucked into a "Previous reminders" section. This is derived in render |
| 190 | // from Date.now() and firedAt, never by a timer mutating state. |
| 191 | // |
| 192 | // Implementation note: keep the firedAt timestamp with each item; in the UI, |
| 193 | // partition items where fired && firedAt > (now - 750) into "active", |
| 194 | // others into "previous". Unfired items always stay visible. |
| 195 | |
| 196 | let now: Int64 = 1700000000000 |
| 197 | let gracePeriod: Int64 = 750 |
| 198 | |
| 199 | var state = RemindState(items: [ |
| 200 | ReminderItem(id: "remind-1", text: "just fired", when: now, ts: now - 1000, sid: "sch-1", fired: true, firedAt: now - 100), // within grace |
| 201 | ReminderItem(id: "remind-2", text: "fired long ago", when: now - 2000, ts: now - 3000, sid: "sch-2", fired: true, firedAt: now - 1000), // past grace |
| 202 | ReminderItem(id: "remind-3", text: "not yet fired", when: now + 3600000, ts: now - 1000, sid: "sch-3", fired: false, firedAt: 0), |
| 203 | ]) |
| 204 | |
| 205 | // Partition logic |
| 206 | let active = state.items.filter { item in |
| 207 | !item.fired || (item.fired && item.firedAt > now - gracePeriod) |
| 208 | } |
| 209 | let archived = state.items.filter { item in |
| 210 | item.fired && item.firedAt <= now - gracePeriod |
| 211 | } |
| 212 | |
| 213 | XCTAssertEqual(active.count, 2, "should have 2 active reminders (unfired + recently fired)") |
| 214 | XCTAssertEqual(archived.count, 1, "should have 1 archived reminder") |
| 215 | XCTAssertTrue(active.contains { $0.id == "remind-1" }, "recently fired reminder stays active") |
| 216 | XCTAssertTrue(active.contains { $0.id == "remind-3" }, "unfired reminder stays active") |
| 217 | XCTAssertTrue(archived.contains { $0.id == "remind-2" }, "old fired reminder is archived") |
| 218 | } |
| 219 | |
| 220 | func testRemindOfflineScheduling() { |
| 221 | // When scheduling fails (offline), sid is empty. The client falls back to |
| 222 | // best-effort client-side firing (via a ticker). The remind item is still stored |
| 223 | // with sid: "", so any device can detect and handle failed schedules. |
| 224 | |
| 225 | var state = RemindState() |
| 226 | let event: [String: Any] = [ |
| 227 | "type": "remind.add", |
| 228 | "id": "remind-1", |
| 229 | "text": "call plumber", |
| 230 | "when": Int64(1700003600000), |
| 231 | "ts": Int64(1700000000000), |
| 232 | "sid": "", // empty = scheduling failed |
| 233 | ] |
| 234 | |
| 235 | state = foldRemind(state, event: event) |
| 236 | |
| 237 | XCTAssertEqual(state.items.count, 1) |
| 238 | let item = state.items[0] |
| 239 | XCTAssertEqual(item.sid, "", "sid should be empty for offline reminders") |
| 240 | XCTAssertEqual(item.fired, false, "should not be fired yet") |
| 241 | // Implementation note: the UI should render a visual indicator (e.g., a warning) |
| 242 | // and a ticker should fire this client-side at `when`. |
| 243 | } |
| 244 | |
| 245 | func testRemindMultipleItems() { |
| 246 | var state = RemindState() |
| 247 | |
| 248 | let events: [[String: Any]] = [ |
| 249 | ["type": "remind.add", "id": "remind-1", "text": "standup", "when": Int64(1700000000000), "ts": Int64(1699999000000), "sid": "sch-1"], |
| 250 | ["type": "remind.add", "id": "remind-2", "text": "lunch", "when": Int64(1700003600000), "ts": Int64(1699999000000), "sid": "sch-2"], |
| 251 | ["type": "remind.add", "id": "remind-3", "text": "gym", "when": Int64(1700007200000), "ts": Int64(1699999000000), "sid": "sch-3"], |
| 252 | ] |
| 253 | |
| 254 | for event in events { |
| 255 | state = foldRemind(state, event: event) |
| 256 | } |
| 257 | |
| 258 | XCTAssertEqual(state.items.count, 3, "should have three reminders") |
| 259 | XCTAssertEqual(state.items[0].text, "standup", "first should be standup (soonest)") |
| 260 | XCTAssertEqual(state.items[1].text, "lunch") |
| 261 | XCTAssertEqual(state.items[2].text, "gym", "last should be gym (latest)") |
| 262 | } |
| 263 | |
| 264 | func testRemindTextPreservation() { |
| 265 | let specialText = "Renew passport™ — expires 2026-08-01\n$120 fee" |
| 266 | var state = RemindState() |
| 267 | |
| 268 | let event: [String: Any] = [ |
| 269 | "type": "remind.add", |
| 270 | "id": "remind-1", |
| 271 | "text": specialText, |
| 272 | "when": Int64(1700003600000), |
| 273 | "ts": Int64(1700000000000), |
| 274 | "sid": "sch-abc", |
| 275 | ] |
| 276 | |
| 277 | state = foldRemind(state, event: event) |
| 278 | |
| 279 | XCTAssertEqual(state.items[0].text, specialText, "should preserve text exactly, including unicode and newlines") |
| 280 | } |
| 281 | |
| 282 | func testRemindCancelUnfired() { |
| 283 | // Dismissing an unfired reminder should cancel its server schedule (sid). |
| 284 | // The fold just removes it; the UI layer handles the ctx.unschedule(sid) call. |
| 285 | |
| 286 | var state = RemindState(items: [ |
| 287 | ReminderItem(id: "remind-1", text: "call plumber", when: 1700003600000, ts: 1700000000000, sid: "sch-abc123", fired: false, firedAt: 0), |
| 288 | ]) |
| 289 | |
| 290 | let event: [String: Any] = [ |
| 291 | "type": "remind.done", |
| 292 | "id": "remind-1", |
| 293 | ] |
| 294 | |
| 295 | state = foldRemind(state, event: event) |
| 296 | |
| 297 | XCTAssertEqual(state.items.count, 0, "unfired reminder should be removed") |
| 298 | // Implementation note: before folding, the UI should check `fired` and `sid`. |
| 299 | // If unfired and sid is set, send: ctx.unschedule(sid) to cancel the server schedule. |
| 300 | // The fold is just the state update; cancellation is a side effect in the UI. |
| 301 | } |
| 302 | } |
| 303 | |