| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // 🤖 Cross-platform event contract tests for the To-do app. |
| 5 | // These tests document what a native To-do 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/todo.js |
| 10 | // Event types: todo.add, todo.done, todo.reopen, todo.remove |
| 11 | // State shape: { items: [{ id, text, done, doneAt, by }] } |
| 12 | |
| 13 | final class TodoAppVectorTests: XCTestCase { |
| 14 | // A to-do item as stored locally |
| 15 | struct TodoItem: Equatable { |
| 16 | let id: String |
| 17 | let text: String |
| 18 | let done: Bool |
| 19 | let doneAt: Int64 // sender's send time (drives grace-period animation) |
| 20 | let by: String // who added it |
| 21 | } |
| 22 | |
| 23 | // Fold state for tests |
| 24 | struct TodoState: Equatable { |
| 25 | var items: [TodoItem] = [] |
| 26 | } |
| 27 | |
| 28 | // Helper: apply a fold event to state (mimics the web app's reducers) |
| 29 | func foldTodo(_ state: TodoState, event: [String: Any]) -> TodoState { |
| 30 | var items = state.items |
| 31 | |
| 32 | if let type = event["type"] as? String { |
| 33 | switch type { |
| 34 | case "todo.add": |
| 35 | if let text = event["text"] as? String, |
| 36 | let id = event["id"] as? String, |
| 37 | !text.isEmpty, |
| 38 | !id.isEmpty { |
| 39 | // addItem dedupes by id |
| 40 | if !items.contains(where: { $0.id == id }) { |
| 41 | let by = (event["name"] as? String) ?? "" |
| 42 | items.append(TodoItem(id: id, text: text, done: false, doneAt: 0, by: by)) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | case "todo.done": |
| 47 | if let id = event["id"] as? String { |
| 48 | let ts = (event["ts"] as? Int64) ?? 0 |
| 49 | items = items.map { item in |
| 50 | item.id == id ? TodoItem(id: item.id, text: item.text, done: true, doneAt: ts, by: item.by) : item |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | case "todo.reopen": |
| 55 | if let id = event["id"] as? String { |
| 56 | items = items.map { item in |
| 57 | item.id == id ? TodoItem(id: item.id, text: item.text, done: false, doneAt: 0, by: item.by) : item |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | case "todo.remove": |
| 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 TodoState(items: items) |
| 72 | } |
| 73 | |
| 74 | func testTodoAdd() { |
| 75 | var state = TodoState() |
| 76 | let event: [String: Any] = [ |
| 77 | "type": "todo.add", |
| 78 | "id": "todo-1", |
| 79 | "text": "buy milk", |
| 80 | "name": "Alice", |
| 81 | ] |
| 82 | |
| 83 | state = foldTodo(state, event: event) |
| 84 | |
| 85 | XCTAssertEqual(state.items.count, 1, "should have one item") |
| 86 | let item = state.items[0] |
| 87 | XCTAssertEqual(item.id, "todo-1") |
| 88 | XCTAssertEqual(item.text, "buy milk") |
| 89 | XCTAssertEqual(item.done, false, "newly added items are not done") |
| 90 | XCTAssertEqual(item.doneAt, 0, "newly added items have no doneAt") |
| 91 | XCTAssertEqual(item.by, "Alice") |
| 92 | } |
| 93 | |
| 94 | func testTodoDone() { |
| 95 | var state = TodoState(items: [ |
| 96 | TodoItem(id: "todo-1", text: "buy milk", done: false, doneAt: 0, by: "Alice"), |
| 97 | ]) |
| 98 | |
| 99 | let event: [String: Any] = [ |
| 100 | "type": "todo.done", |
| 101 | "id": "todo-1", |
| 102 | "ts": Int64(1700000000000), |
| 103 | ] |
| 104 | |
| 105 | state = foldTodo(state, event: event) |
| 106 | |
| 107 | XCTAssertEqual(state.items.count, 1) |
| 108 | let item = state.items[0] |
| 109 | XCTAssertEqual(item.done, true, "item should be marked done") |
| 110 | XCTAssertEqual(item.doneAt, 1700000000000, "doneAt should record the event timestamp") |
| 111 | } |
| 112 | |
| 113 | func testTodoReopen() { |
| 114 | var state = TodoState(items: [ |
| 115 | TodoItem(id: "todo-1", text: "buy milk", done: true, doneAt: 1700000000000, by: "Alice"), |
| 116 | ]) |
| 117 | |
| 118 | let event: [String: Any] = [ |
| 119 | "type": "todo.reopen", |
| 120 | "id": "todo-1", |
| 121 | ] |
| 122 | |
| 123 | state = foldTodo(state, event: event) |
| 124 | |
| 125 | XCTAssertEqual(state.items.count, 1) |
| 126 | let item = state.items[0] |
| 127 | XCTAssertEqual(item.done, false, "item should be marked not done") |
| 128 | XCTAssertEqual(item.doneAt, 0, "doneAt should be cleared") |
| 129 | } |
| 130 | |
| 131 | func testTodoRemove() { |
| 132 | var state = TodoState(items: [ |
| 133 | TodoItem(id: "todo-1", text: "buy milk", done: false, doneAt: 0, by: "Alice"), |
| 134 | TodoItem(id: "todo-2", text: "call mum", done: false, doneAt: 0, by: "Alice"), |
| 135 | ]) |
| 136 | |
| 137 | let event: [String: Any] = [ |
| 138 | "type": "todo.remove", |
| 139 | "id": "todo-1", |
| 140 | ] |
| 141 | |
| 142 | state = foldTodo(state, event: event) |
| 143 | |
| 144 | XCTAssertEqual(state.items.count, 1, "one item removed, one remains") |
| 145 | XCTAssertEqual(state.items[0].id, "todo-2", "remaining item should be todo-2") |
| 146 | } |
| 147 | |
| 148 | func testTodoIdempotence() { |
| 149 | var state = TodoState() |
| 150 | |
| 151 | let event: [String: Any] = [ |
| 152 | "type": "todo.add", |
| 153 | "id": "todo-1", |
| 154 | "text": "buy milk", |
| 155 | "name": "Alice", |
| 156 | ] |
| 157 | |
| 158 | // Fold the same event twice (e.g., duplicate delivery on reconnect) |
| 159 | state = foldTodo(state, event: event) |
| 160 | state = foldTodo(state, event: event) |
| 161 | |
| 162 | XCTAssertEqual(state.items.count, 1, "should have exactly one item (duplicate add deduped)") |
| 163 | } |
| 164 | |
| 165 | func testTodoGracePeriodPartition() { |
| 166 | // Grace period: items are kept visible for ~750ms after being checked off, |
| 167 | // then tucked into a "Completed" section. This is derived in render from |
| 168 | // Date.now() and doneAt, never by a timer mutating state. |
| 169 | // |
| 170 | // Implementation note: keep the doneAt timestamp with each item; in the UI, |
| 171 | // partition items where doneAt > (now - 750) into "active", others into "completed". |
| 172 | |
| 173 | let now: Int64 = 1700000000000 |
| 174 | let gracePeriod: Int64 = 750 |
| 175 | |
| 176 | var state = TodoState(items: [ |
| 177 | TodoItem(id: "todo-1", text: "just checked", done: true, doneAt: now - 100, by: "Alice"), // within grace |
| 178 | TodoItem(id: "todo-2", text: "checked long ago", done: true, doneAt: now - 1000, by: "Alice"), // past grace |
| 179 | TodoItem(id: "todo-3", text: "not done", done: false, doneAt: 0, by: "Alice"), |
| 180 | ]) |
| 181 | |
| 182 | // Partition logic |
| 183 | let active = state.items.filter { item in |
| 184 | !item.done || (item.done && item.doneAt > now - gracePeriod) |
| 185 | } |
| 186 | let archived = state.items.filter { item in |
| 187 | item.done && item.doneAt <= now - gracePeriod |
| 188 | } |
| 189 | |
| 190 | XCTAssertEqual(active.count, 2, "should have 2 active items (not done + recently done)") |
| 191 | XCTAssertEqual(archived.count, 1, "should have 1 archived item") |
| 192 | XCTAssertTrue(active.contains { $0.id == "todo-1" }, "recently done item stays active") |
| 193 | XCTAssertTrue(active.contains { $0.id == "todo-3" }, "not-done item stays active") |
| 194 | XCTAssertTrue(archived.contains { $0.id == "todo-2" }, "old done item is archived") |
| 195 | } |
| 196 | |
| 197 | func testTodoMultipleItems() { |
| 198 | var state = TodoState() |
| 199 | |
| 200 | let events: [[String: Any]] = [ |
| 201 | ["type": "todo.add", "id": "todo-1", "text": "milk", "name": "Alice"], |
| 202 | ["type": "todo.add", "id": "todo-2", "text": "bread", "name": "Alice"], |
| 203 | ["type": "todo.add", "id": "todo-3", "text": "eggs", "name": "Bob"], |
| 204 | ] |
| 205 | |
| 206 | for event in events { |
| 207 | state = foldTodo(state, event: event) |
| 208 | } |
| 209 | |
| 210 | XCTAssertEqual(state.items.count, 3, "should have three items") |
| 211 | XCTAssertEqual(state.items[0].text, "milk") |
| 212 | XCTAssertEqual(state.items[1].text, "bread") |
| 213 | XCTAssertEqual(state.items[2].text, "eggs") |
| 214 | } |
| 215 | |
| 216 | func testTodoTextPreservation() { |
| 217 | let specialText = "Buy 2x milk™ — €5 each\n(or substitute)" |
| 218 | var state = TodoState() |
| 219 | |
| 220 | let event: [String: Any] = [ |
| 221 | "type": "todo.add", |
| 222 | "id": "todo-1", |
| 223 | "text": specialText, |
| 224 | "name": "Alice", |
| 225 | ] |
| 226 | |
| 227 | state = foldTodo(state, event: event) |
| 228 | |
| 229 | XCTAssertEqual(state.items[0].text, specialText, "should preserve text exactly, including unicode and newlines") |
| 230 | } |
| 231 | } |
| 232 | |