eleven / messenger Public

test: add cross-platform event contract vectors (#343)

* test: add cross-platform event contract vectors

Implement shared test vectors to ensure chat.js (web) and native apps (iOS/Mac)
stay in sync on message/reaction/edit/delete event schemas. Vectors document
the authoritative shape for each event type, with tests on both sides validating
payload deserialization and fold handler output.

Files:
- test/event-vectors.json: 10 canonical vectors (plain text, attachments, replies,
  reactions, edits, deletes, video, action lines, legacy uid-less messages)
- test/event-vectors.test.mjs: web tests validate chat.js fold handlers
- ios/.../EventVectorTests.swift: native tests validate MessagePayload decode
- CLAUDE.md: contract spec + maintenance rules

Catches: timestamp mismatches, reaction mine-flag drift, edited flag semantics,
uid fallbacks, attachment handling. Vectors are the spec; if tests fail, fix code,
not vectors.

Co-Authored-By: Claude

* test: extend event vectors to todo & remind apps

Add comprehensive event vectors and tests for To-do and Reminders apps,
using JavaScript implementations as the source of truth. Native tests are
initially failing—they document the contract native implementations must satisfy.

Web side (npm test):
- 8 todo vectors: add, done, reopen, remove, grace-period partition, idempotence
- 9 remind vectors: add, fire, done, sorting, grace period, offline fallback,
  multiple items, text preservation, cancel-unfired

Native side (iOS tests, initially failing):
- TodoAppVectorTests.swift: 10 test cases covering fold logic, grace period
  partitioning, idempotence, unicode preservation
- RemindAppVectorTests.swift: 10 test cases covering fold logic, sorted order,
  grace period, offline (sid=""), cancel semantics

Both apps follow the pattern: fold events idempotently to state, partition
in render (never mutate on a timer), preserve exact text/metadata.

Key invariants enforced:
- Todo: done items with doneAt > (now - 750ms) stay active; older ones archive
- Remind: items always sorted by `when` (soonest first); fired with firedAt >
  (now - 750ms) stay active
- Both: duplicate adds deduped, unicode text preserved, grace partition derived
  not timer-driven

Co-Authored-By: Claude

* refactor: split event vectors into per-app JSON files

Organize event vectors by app for better maintainability:
- test/event-vectors/chat.json (10 vectors)
- test/event-vectors/todo.json (5 vectors)
- test/event-vectors/remind.json (5 vectors)
- test/event-vectors/index.js (loader + flattened export)

Updated test file to import from index.js. Tests pass with identical results.

Benefits:
- Cleaner per-app organization
- Easier to find vectors for a specific app
- Simpler diffs when updating one app's vectors
- Scalable pattern as more apps are added

Co-Authored-By: Claude

* docs: update CLAUDE.md to reflect per-app vector file organization

Updated documentation to reference test/event-vectors/ directory structure
instead of single monolithic file. Clarified maintenance paths for each app
and emphasized the per-app separation pattern for clarity.

Co-Authored-By: Claude

* chore: store project memory in repo-relative .claude/memory/

Move memory files from machine-specific path to repo-relative .claude/memory/
directory so they work consistently across all developer machines.

Instead of: /Users/<name>/.claude/projects/<full-path>/memory/
Now: .claude/memory/ (relative to repo root, committed to git)

This ensures memory persists in version control and is portable across
machines, IDEs, and CI environments.

Co-Authored-By: Claude

* docs: document repo-relative memory system

Added section to CLAUDE.md explaining that project memory is stored in
.claude/memory/ (relative to repo root, committed to git) for portability
across developer machines. This is the canonical memory system; machine-specific
memory files outside the repo are no longer used.
Co-Authored-By: Claude
James Adam db1c7f9527e1c17e9884f5813e16e8e153433dec parent 15b276d
11 files changed, +1886 −1
  • .claude/memory/MEMORY.md +3 −0
  • .claude/memory/event-contract.md +33 −0
  • CLAUDE.md +85 −1
  • ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift +279 −0
  • ios/LChatCore/Tests/LChatCoreTests/RemindAppVectorTests.swift +302 −0
  • ios/LChatCore/Tests/LChatCoreTests/TodoAppVectorTests.swift +231 −0
  • test/event-vectors.test.mjs +344 −0
  • test/event-vectors/chat.json +297 −0
  • test/event-vectors/index.js +16 −0
  • test/event-vectors/remind.json +156 −0
  • test/event-vectors/todo.json +140 −0
diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md
new file mode 100644
index 0000000..5d50be0
--- /dev/null
+++ b/.claude/memory/MEMORY.md
@@ -0,0 +1,3 @@
+# Memory index
+
+- [event-contract.md](event-contract.md) — Cross-platform event vectors for chat/todo/remind, organized by app
diff --git a/.claude/memory/event-contract.md b/.claude/memory/event-contract.md
new file mode 100644
index 0000000..08f28d2
--- /dev/null
+++ b/.claude/memory/event-contract.md
@@ -0,0 +1,33 @@
+---
+name: event-contract-cross-platform
+description: Web (chat.js) and native apps (iOS/Mac) validated against shared event vectors for chat/todo/remind events
+metadata:
+ type: project
+---
+
+## Cross-platform event contract
+
+**Problem:** chat.js, todo.js, remind.js (web) and native implementations must stay in sync. Without a spec, fold handlers can drift.
+
+**Solution:** Shared event vectors in `test/event-vectors/` (organized by app) + tests on both sides catch drift on PR.
+
+**Structure:**
+- `test/event-vectors/chat.json` — message/reaction/edit/delete vectors (web: source of truth)
+- `test/event-vectors/todo.json` — to-do fold logic vectors (web: source of truth)
+- `test/event-vectors/remind.json` — remind fold logic vectors (web: source of truth)
+- `test/event-vectors/index.js` — loader for tests
+
+**Maintenance:**
+1. **Changing a web app's fold handlers** → update the app's vector file + run `npm test`
+2. **Changing a native structure** (MessagePayload, etc.) → update native tests + run iOS tests
+3. **Adding a feature** → add vector entry + test case to both sides
+
+**Key invariants:**
+- Todo: `done` items with `doneAt > (now - 750ms)` stay active; older → archived
+- Remind: items always sorted by `when` (soonest first); fired with `firedAt > (now - 750ms)` stay active
+- Both: grace period derived in render, never mutated by a timer
+- Both: duplicate adds are deduped; unicode text preserved
+
+**Why this matters:** Different rendering on different clients destroys trust. These tests prevent silent mismatches.
+
+**Vectors are the spec.** If tests fail, code diverged—fix the implementations, not the vectors.
diff --git a/CLAUDE.md b/CLAUDE.md
index 572d85b..6b5eeec 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -260,19 +260,103 @@ open with the note itself) carry the 🤖 on their first line.
"as of" date) every so often — a quick `git ls-files '*.go' …| wc -l` for
lines, and `du -h` on the built binaries / `.app` bundles for sizes.
+## 🤖 Cross-platform event contract (web ↔ native)
+
+Chat events must be identical on web (chat.js) and native (iOS/Mac). The **event vectors** (in `test/event-vectors/`) document the contract and are tested on both sides, so any drift is caught on PR.
+
+**The problem:** The web folds events into chat.js via `chat.msg` / `chat.react` / `chat.edit` / `chat.delete` fold handlers; native deserializes `MessagePayload` + `Reaction` from WebSocket frames and stores them. Without an authoritative spec, both could drift.
+
+**The solution:** Shared JSON vector files (one per app) list every event type with:
+- The decrypted payload shape
+- The server frame metadata
+- The expected fold event (what the web handler must produce)
+- The expected native fields (what native structures must hold)
+
+Both test suites validate against these vectors:
+- **Web:** `test/event-vectors.test.mjs` — feeds each payload + meta to the fold handlers, asserts output matches expected state
+- **Native:** `ios/.../EventVectorTests.swift` + `TodoAppVectorTests.swift` + `RemindAppVectorTests.swift` — deserialize payloads, assert state structures match expected shapes
+
+**Maintaining the contract:**
+
+1. **When you change a web app's fold handlers** (e.g., `app.hostEvents` in `web/static/apps/chat.js`):
+ - Run `npm test` to ensure vectors still pass
+ - If the handler changes what it produces, update the app's vector file in `test/event-vectors/` + re-run tests
+ - If the change is a bug fix, add a note in the vector's `note` field
+ - Example: changing `chat.js` → update `test/event-vectors/chat.json`
+
+2. **When you change a native structure** (e.g., `MessagePayload` in `ios/.../Models.swift`):
+ - Update the relevant test file if the decode rules changed (e.g., `EventVectorTests.swift`)
+ - Run iOS tests; they validate the native side still matches vectors
+ - If native and web have diverged, that's a test failure — fix both sides in the same PR
+
+3. **When you add a new feature to an app** (e.g., a new field in message payload or todo text):
+ - Add a vector entry in the appropriate file in `test/event-vectors/` (e.g., `test/event-vectors/chat.json`)
+ - Add a test case to both web and native test files
+ - Run both test suites before landing the PR
+
+**Vectors are not snapshots; they are the spec.** If a test fails, it means web and native are out of sync — that's a hard error. Don't update vectors to make tests pass if the implementations actually differ; fix the implementations instead.
+
+**Watch for:** timestamp handling (web uses `meta.ts`, native reads `ts` + `created_at`), reaction mine-flag semantics (live broadcast vs. load-time history), edited flag updates, and uid attribution fallbacks (uid-less legacy messages).
+
+**Extended to app-threads:** Todo and Reminders follow the same pattern.
+- `test/event-vectors/todo.json` and `test/event-vectors/remind.json` include app-specific vectors
+- `test/event-vectors.test.mjs` tests fold handlers for all apps (web side)
+- `ios/.../TodoAppVectorTests.swift` + `RemindAppVectorTests.swift` (native side — tests fail initially, pass as you implement)
+
+## App-thread events: todo & remind contract
+
+Both apps are personal (one thread per user), so native implementations don't exist yet—the vectors serve as the spec.
+
+**To-do app** (web/static/apps/todo.js):
+- State: `{ items: [{ id, text, done, doneAt, by }] }`
+- Events: `todo.add` (text, name), `todo.done` (ts for grace-period animation), `todo.reopen`, `todo.remove`
+- Grace period: done items linger ~750ms with a depleting ring, then tuck into "Completed"
+- Chat projection: `/todo X` shows as an action line; done/reopen/remove are silent
+
+**Reminders app** (web/static/apps/remind.js):
+- State: `{ items: [{ id, text, when, ts, sid, fired, firedAt }] }`
+- Events: `remind.add` (text, when=absolute epoch ms, sid=server schedule id), `remind.fire` (notice + timestamp), `remind.done`
+- Sorting: items always sorted by `when` (soonest first)
+- Grace period: fired reminders linger ~750ms, then tuck into "Previous reminders"
+- Offline fallback: if `sid` is empty, scheduling failed; UI renders a warning and ticker fires client-side
+- Chat projection: `remind.fire` shows as a message (title + body from notice); add/done are silent
+
+**Native implementation guidance:**
+- Use the vectors as the spec; tests initially fail (that's the point)
+- Implement fold handlers for each app that match web side exactly (use vectors as golden truth)
+- Partition logic (grace period, sorting) is derived in render, not a timer—never mutate state on a clock
+- For todo: doneAt > (now - 750ms) stays active; for remind: same but firedAt
+- Tests document idempotence (duplicate adds deduped), text preservation (unicode safe), sorting, and partition rules
+
+## Project memory (`.claude/memory/`)
+
+Project context is stored in `.claude/memory/` (relative to repo root, committed to git) so it works across all developer machines. This includes architecture decisions, ongoing work status, and cross-platform patterns like the event contract.
+
+When working on this project, Claude agents will automatically load memory from this directory. To update memory:
+- Edit `.claude/memory/*.md` files directly
+- Commit memory changes to the branch like any other code change
+- Memory is version-controlled and portable—no machine-specific paths
+
## Build & test
```sh
go build -o lchat . # or: CGO_ENABLED=0 go build ./...
go test ./... # db_test, handlers_test, unfurl_test (httptest)
-npm test # node test/{crypto,ui,format}.test.mjs
+npm test # node test/{crypto,ui,format,event-vectors}.test.mjs
node --check web/static/shell.js # JS isn't run by tests; syntax-check edits
+
+# Native (iOS/Mac): open ios/LChat.xcodeproj and run EventVectorTests.swift
+# Or via xcodebuild: xcodebuild test -scheme LChat -only EventVectorTests
```
Add tests as you go. Go handler tests use `newTestServer(t)` which returns
`(*httptest.Server, *Server)` so you can seed the DB directly. SSRF tests flip
`allowPrivatePreviewHosts` to reach a loopback `httptest` server.
+**Event vectors are mandatory:** Every chat message feature PR must pass
+`npm test` (web side) and EventVectorTests (native side) — they verify the
+event schema stays in sync across platforms.
+
## Deploy
Deployed as a single binary on a container platform. Each chat is a
diff --git a/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift
new file mode 100644
index 0000000..437dd8a
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift
@@ -0,0 +1,279 @@
+import XCTest
+@testable import LChatCore
+
+// Cross-platform event contract tests: ensures MessagePayload deserialization
+// and message/reaction/edit/delete handling matches the web (chat.js) side.
+// Vectors sourced from test/event-vectors.json.
+
+final class EventVectorTests: XCTestCase {
+ // Helper: load the shared vector file
+ func loadVectors() -> [String: Any]? {
+ guard let path = Bundle(for: type(of: self)).path(forResource: "event-vectors", ofType: "json") else {
+ return nil
+ }
+ guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
+ return nil
+ }
+ return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ }
+
+ func testPlainTextMessage() {
+ // Plain text message with no attachments or reply.
+ let payload = MessagePayload(
+ type: nil,
+ uid: "user-alice-1",
+ name: "Alice",
+ text: "Hello everyone",
+ ts: 1700000000000,
+ action: nil,
+ attachments: nil,
+ replyTo: nil
+ )
+
+ XCTAssertEqual(payload.name, "Alice", "name should be set")
+ XCTAssertEqual(payload.text, "Hello everyone", "text should be set")
+ XCTAssertEqual(payload.uid, "user-alice-1", "uid should be set")
+ XCTAssertEqual(payload.ts, 1700000000000, "ts should be set (ms)")
+ XCTAssertNil(payload.type, "type should be nil (legacy chat message)")
+ XCTAssertNil(payload.action, "action should be nil")
+ XCTAssertNil(payload.attachments, "attachments should be nil")
+ XCTAssertNil(payload.replyTo, "replyTo should be nil")
+ XCTAssertTrue(payload.isRenderableChat, "untyped messages are renderable")
+ }
+
+ func testMessageWithAttachments() {
+ let attachments = [
+ Attachment(
+ id: "blob-img-1",
+ name: "photo.jpg",
+ type: "image/jpeg",
+ w: 1920,
+ h: 1080,
+ size: 524288,
+ poster: nil,
+ dur: nil
+ ),
+ Attachment(
+ id: "blob-file-1",
+ name: "report.pdf",
+ type: "application/pdf",
+ w: nil,
+ h: nil,
+ size: 2097152,
+ poster: nil,
+ dur: nil
+ ),
+ ]
+
+ let payload = MessagePayload(
+ type: nil,
+ uid: "user-bob-1",
+ name: "Bob",
+ text: "Check these out",
+ ts: 1700000001000,
+ action: nil,
+ attachments: attachments,
+ replyTo: nil
+ )
+
+ XCTAssertEqual(payload.attachments?.count, 2, "should have two attachments")
+ let img = payload.attachments?[0]
+ XCTAssertEqual(img?.id, "blob-img-1", "image id should match")
+ XCTAssertEqual(img?.name, "photo.jpg", "image name should match")
+ XCTAssertTrue(img?.isImage ?? false, "image type should be detected")
+ XCTAssertFalse(img?.isVideo ?? false, "image should not be a video")
+ XCTAssertEqual(img?.w, 1920, "image width should be set")
+
+ let file = payload.attachments?[1]
+ XCTAssertEqual(file?.type, "application/pdf", "file type should match")
+ XCTAssertFalse(file?.isImage ?? false, "file should not be an image")
+ }
+
+ func testMessageWithReply() {
+ let replyRef = ReplyRef(id: 12345, name: "Alice", snippet: "Hello everyone")
+ let payload = MessagePayload(
+ type: nil,
+ uid: "user-charlie-1",
+ name: "Charlie",
+ text: "I agree!",
+ ts: 1700000002000,
+ action: nil,
+ attachments: nil,
+ replyTo: replyRef
+ )
+
+ XCTAssertNotNil(payload.replyTo, "replyTo should be present")
+ XCTAssertEqual(payload.replyTo?.id, 12345, "reply id should match")
+ XCTAssertEqual(payload.replyTo?.name, "Alice", "reply name should match")
+ XCTAssertEqual(payload.replyTo?.snippet, "Hello everyone", "reply snippet should match")
+ }
+
+ func testActionLine() {
+ // A /me-style action line (e.g., from a to-do app).
+ let payload = MessagePayload(
+ type: nil,
+ uid: "app:todo",
+ name: "Todo Bot",
+ text: "added 'milk'",
+ ts: 1700000003000,
+ action: true,
+ attachments: nil,
+ replyTo: nil
+ )
+
+ XCTAssertTrue(payload.action ?? false, "action flag should be set")
+ XCTAssertEqual(payload.uid, "app:todo", "app uid should have app: prefix")
+ }
+
+ func testMessageWithVideo() {
+ let video = Attachment(
+ id: "blob-vid-1",
+ name: "clip.mp4",
+ type: "video/mp4",
+ w: 1280,
+ h: 720,
+ size: 5242880,
+ poster: "blob-vid-1-poster",
+ dur: 45.5
+ )
+
+ let payload = MessagePayload(
+ type: nil,
+ uid: "user-diana-1",
+ name: "Diana",
+ text: "Check this video",
+ ts: 1700000004000,
+ action: nil,
+ attachments: [video],
+ replyTo: nil
+ )
+
+ let att = payload.attachments?[0]
+ XCTAssertTrue(att?.isVideo ?? false, "video type should be detected")
+ XCTAssertFalse(att?.isImage ?? false, "video should not be an image")
+ XCTAssertEqual(att?.dur, 45.5, "video duration should be set")
+ XCTAssertEqual(att?.poster, "blob-vid-1-poster", "video poster should be set")
+ }
+
+ func testReactionFrame() {
+ // Reactions are stored separately from payloads.
+ let reaction = Reaction(emoji: "👍", count: 1, mine: false)
+ XCTAssertEqual(reaction.emoji, "👍", "emoji should match")
+ XCTAssertEqual(reaction.count, 1, "count should be 1")
+ XCTAssertFalse(reaction.isMine, "mine should default to false")
+
+ let myReaction = Reaction(emoji: "❤️", count: 2, mine: true)
+ XCTAssertTrue(myReaction.isMine, "isMine should return the mine flag")
+ }
+
+ func testEditedMessage() {
+ // Edited messages: the payload is the new text, edited_at is server-side.
+ let original = MessagePayload(
+ type: nil,
+ uid: "user-eve-1",
+ name: "Eve",
+ text: "typo here",
+ ts: 1700000005000,
+ action: nil,
+ attachments: nil,
+ replyTo: nil
+ )
+
+ let edited = MessagePayload(
+ type: nil,
+ uid: "user-eve-1",
+ name: "Eve",
+ text: "no typo here",
+ ts: 1700000005000,
+ action: nil,
+ attachments: nil,
+ replyTo: nil
+ )
+
+ XCTAssertNotEqual(original.text, edited.text, "text should differ")
+ XCTAssertEqual(original.ts, edited.ts, "sender clock should stay the same")
+ }
+
+ func testLegacyMessageNoUid() {
+ // Older messages before uid was added — both web and native fall back to name.
+ let payload = MessagePayload(
+ type: nil,
+ uid: nil,
+ name: "Frank",
+ text: "This is old",
+ ts: 1699000000000,
+ action: nil,
+ attachments: nil,
+ replyTo: nil
+ )
+
+ XCTAssertNil(payload.uid, "uid should be nil (legacy)")
+ XCTAssertEqual(payload.name, "Frank", "name should be set for attribution")
+ XCTAssertTrue(payload.isRenderableChat, "legacy messages are renderable")
+ }
+
+ // JSONDecoder round-trip: ensure payloads decode from JSON (from server blobs).
+ func testDecodingFromJSON() throws {
+ let json = """
+ {
+ "name": "Alice",
+ "text": "Hello",
+ "ts": 1700000000000,
+ "uid": "user-alice-1",
+ "attachments": [
+ {
+ "id": "blob-1",
+ "name": "photo.jpg",
+ "type": "image/jpeg",
+ "w": 1920,
+ "h": 1080
+ }
+ ]
+ }
+ """
+
+ let data = json.data(using: .utf8)!
+ let payload = try JSONDecoder().decode(MessagePayload.self, from: data)
+
+ XCTAssertEqual(payload.name, "Alice")
+ XCTAssertEqual(payload.text, "Hello")
+ XCTAssertEqual(payload.attachments?.count, 1)
+ XCTAssertEqual(payload.attachments?[0].id, "blob-1")
+ }
+
+ func testDecodingReactionFromJSON() throws {
+ let json = """
+ {
+ "emoji": "👍",
+ "count": 1,
+ "mine": false
+ }
+ """
+
+ let data = json.data(using: .utf8)!
+ let reaction = try JSONDecoder().decode(Reaction.self, from: data)
+
+ XCTAssertEqual(reaction.emoji, "👍")
+ XCTAssertEqual(reaction.count, 1)
+ XCTAssertFalse(reaction.isMine)
+ }
+
+ func testDecodingWithMissingOptionalFields() throws {
+ // The server may omit optional fields; decoder should handle gracefully.
+ let json = """
+ {
+ "name": "Bob",
+ "text": "Simple message"
+ }
+ """
+
+ let data = json.data(using: .utf8)!
+ let payload = try JSONDecoder().decode(MessagePayload.self, from: data)
+
+ XCTAssertEqual(payload.name, "Bob")
+ XCTAssertEqual(payload.text, "Simple message")
+ XCTAssertNil(payload.uid, "missing uid should be nil")
+ XCTAssertNil(payload.ts, "missing ts should be nil")
+ XCTAssertNil(payload.attachments, "missing attachments should be nil")
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/RemindAppVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/RemindAppVectorTests.swift
new file mode 100644
index 0000000..0d9400a
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/RemindAppVectorTests.swift
@@ -0,0 +1,302 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 Cross-platform event contract tests for the Reminders app.
+// These tests document what a native Reminders implementation must do.
+// They will initially fail (the implementation doesn't exist yet);
+// as you build the native side, make them pass.
+//
+// Source of truth: web/static/apps/remind.js
+// Event types: remind.add, remind.fire, remind.done
+// State shape: { items: [{ id, text, when, ts, sid, fired, firedAt }] }
+// Note: `when` is absolute epoch ms (due time); `ts` is add time; `sid` is server schedule id
+
+final class RemindAppVectorTests: XCTestCase {
+ // A reminder item as stored locally
+ struct ReminderItem: Equatable {
+ let id: String
+ let text: String
+ let when: Int64 // absolute epoch ms when the reminder fires
+ let ts: Int64 // epoch ms when added (send time)
+ let sid: String // server schedule id ("" if scheduling failed)
+ let fired: Bool // has the reminder fired?
+ let firedAt: Int64 // epoch ms when it fired (drives grace-period animation)
+ }
+
+ // Fold state for tests
+ struct RemindState: Equatable {
+ var items: [ReminderItem] = []
+ }
+
+ // Helper: apply a fold event to state (mimics the web app's reducers)
+ func foldRemind(_ state: RemindState, event: [String: Any]) -> RemindState {
+ var items = state.items
+
+ if let type = event["type"] as? String {
+ switch type {
+ case "remind.add":
+ if let id = event["id"] as? String,
+ let text = event["text"] as? String,
+ let when = event["when"] as? Int64,
+ !id.isEmpty,
+ !text.isEmpty {
+ // addItem dedupes by id
+ if !items.contains(where: { $0.id == id }) {
+ let ts = (event["ts"] as? Int64) ?? 0
+ let sid = (event["sid"] as? String) ?? ""
+ items.append(ReminderItem(id: id, text: text, when: when, ts: ts, sid: sid, fired: false, firedAt: 0))
+ }
+ // Sort by `when` (soonest first)
+ items.sort { $0.when < $1.when }
+ }
+
+ case "remind.fire":
+ if let id = event["id"] as? String {
+ let firedAt = (event["ts"] as? Int64) ?? 0
+ items = items.map { item in
+ item.id == id ? ReminderItem(id: item.id, text: item.text, when: item.when, ts: item.ts, sid: item.sid, fired: true, firedAt: firedAt) : item
+ }
+ }
+
+ case "remind.done":
+ if let id = event["id"] as? String {
+ items.removeAll { $0.id == id }
+ }
+
+ default:
+ break
+ }
+ }
+
+ return RemindState(items: items)
+ }
+
+ func testRemindAdd() {
+ var state = RemindState()
+ let event: [String: Any] = [
+ "type": "remind.add",
+ "id": "remind-1",
+ "text": "call plumber",
+ "when": Int64(1700003600000),
+ "ts": Int64(1700000000000),
+ "sid": "sch-abc123",
+ ]
+
+ state = foldRemind(state, event: event)
+
+ XCTAssertEqual(state.items.count, 1, "should have one reminder")
+ let item = state.items[0]
+ XCTAssertEqual(item.id, "remind-1")
+ XCTAssertEqual(item.text, "call plumber")
+ XCTAssertEqual(item.when, 1700003600000, "when should be the due time")
+ XCTAssertEqual(item.ts, 1700000000000, "ts should be the add time")
+ XCTAssertEqual(item.sid, "sch-abc123", "sid should be the server schedule id")
+ XCTAssertEqual(item.fired, false, "newly added reminders are not fired")
+ XCTAssertEqual(item.firedAt, 0, "newly added reminders have no firedAt")
+ }
+
+ func testRemindFire() {
+ var state = RemindState(items: [
+ ReminderItem(id: "remind-1", text: "call plumber", when: 1700003600000, ts: 1700000000000, sid: "sch-abc123", fired: false, firedAt: 0),
+ ])
+
+ let event: [String: Any] = [
+ "type": "remind.fire",
+ "id": "remind-1",
+ "notice": [
+ "title": "⏰ Reminder",
+ "body": "call plumber",
+ ],
+ "ts": Int64(1700003600000),
+ ]
+
+ state = foldRemind(state, event: event)
+
+ XCTAssertEqual(state.items.count, 1)
+ let item = state.items[0]
+ XCTAssertEqual(item.fired, true, "item should be marked fired")
+ XCTAssertEqual(item.firedAt, 1700003600000, "firedAt should record the fire timestamp")
+ }
+
+ func testRemindDone() {
+ var state = RemindState(items: [
+ ReminderItem(id: "remind-1", text: "call plumber", when: 1700003600000, ts: 1700000000000, sid: "sch-abc123", fired: false, firedAt: 0),
+ ])
+
+ let event: [String: Any] = [
+ "type": "remind.done",
+ "id": "remind-1",
+ ]
+
+ state = foldRemind(state, event: event)
+
+ XCTAssertEqual(state.items.count, 0, "reminder should be removed")
+ }
+
+ func testRemindSorted() {
+ var state = RemindState()
+
+ let events: [[String: Any]] = [
+ [
+ "type": "remind.add",
+ "id": "remind-1",
+ "text": "late reminder",
+ "when": Int64(1700010000000),
+ "ts": Int64(1700000000000),
+ "sid": "sch-abc123",
+ ],
+ [
+ "type": "remind.add",
+ "id": "remind-2",
+ "text": "early reminder",
+ "when": Int64(1700003600000),
+ "ts": Int64(1700000100000),
+ "sid": "sch-def456",
+ ],
+ ]
+
+ for event in events {
+ state = foldRemind(state, event: event)
+ }
+
+ XCTAssertEqual(state.items.count, 2, "should have two reminders")
+ XCTAssertEqual(state.items[0].id, "remind-2", "first should be remind-2 (soonest)")
+ XCTAssertEqual(state.items[1].id, "remind-1", "second should be remind-1 (latest)")
+ XCTAssertTrue(state.items[0].when < state.items[1].when, "items should be sorted by when")
+ }
+
+ func testRemindIdempotence() {
+ var state = RemindState()
+
+ let event: [String: Any] = [
+ "type": "remind.add",
+ "id": "remind-1",
+ "text": "call plumber",
+ "when": Int64(1700003600000),
+ "ts": Int64(1700000000000),
+ "sid": "sch-abc123",
+ ]
+
+ // Fold the same event twice (e.g., duplicate delivery on reconnect)
+ state = foldRemind(state, event: event)
+ state = foldRemind(state, event: event)
+
+ XCTAssertEqual(state.items.count, 1, "should have exactly one reminder (duplicate add deduped)")
+ }
+
+ func testRemindGracePeriodPartition() {
+ // Grace period: reminders are kept visible for ~750ms after firing,
+ // then tucked into a "Previous reminders" section. This is derived in render
+ // from Date.now() and firedAt, never by a timer mutating state.
+ //
+ // Implementation note: keep the firedAt timestamp with each item; in the UI,
+ // partition items where fired && firedAt > (now - 750) into "active",
+ // others into "previous". Unfired items always stay visible.
+
+ let now: Int64 = 1700000000000
+ let gracePeriod: Int64 = 750
+
+ var state = RemindState(items: [
+ ReminderItem(id: "remind-1", text: "just fired", when: now, ts: now - 1000, sid: "sch-1", fired: true, firedAt: now - 100), // within grace
+ ReminderItem(id: "remind-2", text: "fired long ago", when: now - 2000, ts: now - 3000, sid: "sch-2", fired: true, firedAt: now - 1000), // past grace
+ ReminderItem(id: "remind-3", text: "not yet fired", when: now + 3600000, ts: now - 1000, sid: "sch-3", fired: false, firedAt: 0),
+ ])
+
+ // Partition logic
+ let active = state.items.filter { item in
+ !item.fired || (item.fired && item.firedAt > now - gracePeriod)
+ }
+ let archived = state.items.filter { item in
+ item.fired && item.firedAt <= now - gracePeriod
+ }
+
+ XCTAssertEqual(active.count, 2, "should have 2 active reminders (unfired + recently fired)")
+ XCTAssertEqual(archived.count, 1, "should have 1 archived reminder")
+ XCTAssertTrue(active.contains { $0.id == "remind-1" }, "recently fired reminder stays active")
+ XCTAssertTrue(active.contains { $0.id == "remind-3" }, "unfired reminder stays active")
+ XCTAssertTrue(archived.contains { $0.id == "remind-2" }, "old fired reminder is archived")
+ }
+
+ func testRemindOfflineScheduling() {
+ // When scheduling fails (offline), sid is empty. The client falls back to
+ // best-effort client-side firing (via a ticker). The remind item is still stored
+ // with sid: "", so any device can detect and handle failed schedules.
+
+ var state = RemindState()
+ let event: [String: Any] = [
+ "type": "remind.add",
+ "id": "remind-1",
+ "text": "call plumber",
+ "when": Int64(1700003600000),
+ "ts": Int64(1700000000000),
+ "sid": "", // empty = scheduling failed
+ ]
+
+ state = foldRemind(state, event: event)
+
+ XCTAssertEqual(state.items.count, 1)
+ let item = state.items[0]
+ XCTAssertEqual(item.sid, "", "sid should be empty for offline reminders")
+ XCTAssertEqual(item.fired, false, "should not be fired yet")
+ // Implementation note: the UI should render a visual indicator (e.g., a warning)
+ // and a ticker should fire this client-side at `when`.
+ }
+
+ func testRemindMultipleItems() {
+ var state = RemindState()
+
+ let events: [[String: Any]] = [
+ ["type": "remind.add", "id": "remind-1", "text": "standup", "when": Int64(1700000000000), "ts": Int64(1699999000000), "sid": "sch-1"],
+ ["type": "remind.add", "id": "remind-2", "text": "lunch", "when": Int64(1700003600000), "ts": Int64(1699999000000), "sid": "sch-2"],
+ ["type": "remind.add", "id": "remind-3", "text": "gym", "when": Int64(1700007200000), "ts": Int64(1699999000000), "sid": "sch-3"],
+ ]
+
+ for event in events {
+ state = foldRemind(state, event: event)
+ }
+
+ XCTAssertEqual(state.items.count, 3, "should have three reminders")
+ XCTAssertEqual(state.items[0].text, "standup", "first should be standup (soonest)")
+ XCTAssertEqual(state.items[1].text, "lunch")
+ XCTAssertEqual(state.items[2].text, "gym", "last should be gym (latest)")
+ }
+
+ func testRemindTextPreservation() {
+ let specialText = "Renew passport™ — expires 2026-08-01\n$120 fee"
+ var state = RemindState()
+
+ let event: [String: Any] = [
+ "type": "remind.add",
+ "id": "remind-1",
+ "text": specialText,
+ "when": Int64(1700003600000),
+ "ts": Int64(1700000000000),
+ "sid": "sch-abc",
+ ]
+
+ state = foldRemind(state, event: event)
+
+ XCTAssertEqual(state.items[0].text, specialText, "should preserve text exactly, including unicode and newlines")
+ }
+
+ func testRemindCancelUnfired() {
+ // Dismissing an unfired reminder should cancel its server schedule (sid).
+ // The fold just removes it; the UI layer handles the ctx.unschedule(sid) call.
+
+ var state = RemindState(items: [
+ ReminderItem(id: "remind-1", text: "call plumber", when: 1700003600000, ts: 1700000000000, sid: "sch-abc123", fired: false, firedAt: 0),
+ ])
+
+ let event: [String: Any] = [
+ "type": "remind.done",
+ "id": "remind-1",
+ ]
+
+ state = foldRemind(state, event: event)
+
+ XCTAssertEqual(state.items.count, 0, "unfired reminder should be removed")
+ // Implementation note: before folding, the UI should check `fired` and `sid`.
+ // If unfired and sid is set, send: ctx.unschedule(sid) to cancel the server schedule.
+ // The fold is just the state update; cancellation is a side effect in the UI.
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/TodoAppVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/TodoAppVectorTests.swift
new file mode 100644
index 0000000..5e0ba4b
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/TodoAppVectorTests.swift
@@ -0,0 +1,231 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 Cross-platform event contract tests for the To-do app.
+// These tests document what a native To-do implementation must do.
+// They will initially fail (the implementation doesn't exist yet);
+// as you build the native side, make them pass.
+//
+// Source of truth: web/static/apps/todo.js
+// Event types: todo.add, todo.done, todo.reopen, todo.remove
+// State shape: { items: [{ id, text, done, doneAt, by }] }
+
+final class TodoAppVectorTests: XCTestCase {
+ // A to-do item as stored locally
+ struct TodoItem: Equatable {
+ let id: String
+ let text: String
+ let done: Bool
+ let doneAt: Int64 // sender's send time (drives grace-period animation)
+ let by: String // who added it
+ }
+
+ // Fold state for tests
+ struct TodoState: Equatable {
+ var items: [TodoItem] = []
+ }
+
+ // Helper: apply a fold event to state (mimics the web app's reducers)
+ func foldTodo(_ state: TodoState, event: [String: Any]) -> TodoState {
+ var items = state.items
+
+ if let type = event["type"] as? String {
+ switch type {
+ case "todo.add":
+ if let text = event["text"] as? String,
+ let id = event["id"] as? String,
+ !text.isEmpty,
+ !id.isEmpty {
+ // addItem dedupes by id
+ if !items.contains(where: { $0.id == id }) {
+ let by = (event["name"] as? String) ?? ""
+ items.append(TodoItem(id: id, text: text, done: false, doneAt: 0, by: by))
+ }
+ }
+
+ case "todo.done":
+ if let id = event["id"] as? String {
+ let ts = (event["ts"] as? Int64) ?? 0
+ items = items.map { item in
+ item.id == id ? TodoItem(id: item.id, text: item.text, done: true, doneAt: ts, by: item.by) : item
+ }
+ }
+
+ case "todo.reopen":
+ if let id = event["id"] as? String {
+ items = items.map { item in
+ item.id == id ? TodoItem(id: item.id, text: item.text, done: false, doneAt: 0, by: item.by) : item
+ }
+ }
+
+ case "todo.remove":
+ if let id = event["id"] as? String {
+ items.removeAll { $0.id == id }
+ }
+
+ default:
+ break
+ }
+ }
+
+ return TodoState(items: items)
+ }
+
+ func testTodoAdd() {
+ var state = TodoState()
+ let event: [String: Any] = [
+ "type": "todo.add",
+ "id": "todo-1",
+ "text": "buy milk",
+ "name": "Alice",
+ ]
+
+ state = foldTodo(state, event: event)
+
+ XCTAssertEqual(state.items.count, 1, "should have one item")
+ let item = state.items[0]
+ XCTAssertEqual(item.id, "todo-1")
+ XCTAssertEqual(item.text, "buy milk")
+ XCTAssertEqual(item.done, false, "newly added items are not done")
+ XCTAssertEqual(item.doneAt, 0, "newly added items have no doneAt")
+ XCTAssertEqual(item.by, "Alice")
+ }
+
+ func testTodoDone() {
+ var state = TodoState(items: [
+ TodoItem(id: "todo-1", text: "buy milk", done: false, doneAt: 0, by: "Alice"),
+ ])
+
+ let event: [String: Any] = [
+ "type": "todo.done",
+ "id": "todo-1",
+ "ts": Int64(1700000000000),
+ ]
+
+ state = foldTodo(state, event: event)
+
+ XCTAssertEqual(state.items.count, 1)
+ let item = state.items[0]
+ XCTAssertEqual(item.done, true, "item should be marked done")
+ XCTAssertEqual(item.doneAt, 1700000000000, "doneAt should record the event timestamp")
+ }
+
+ func testTodoReopen() {
+ var state = TodoState(items: [
+ TodoItem(id: "todo-1", text: "buy milk", done: true, doneAt: 1700000000000, by: "Alice"),
+ ])
+
+ let event: [String: Any] = [
+ "type": "todo.reopen",
+ "id": "todo-1",
+ ]
+
+ state = foldTodo(state, event: event)
+
+ XCTAssertEqual(state.items.count, 1)
+ let item = state.items[0]
+ XCTAssertEqual(item.done, false, "item should be marked not done")
+ XCTAssertEqual(item.doneAt, 0, "doneAt should be cleared")
+ }
+
+ func testTodoRemove() {
+ var state = TodoState(items: [
+ TodoItem(id: "todo-1", text: "buy milk", done: false, doneAt: 0, by: "Alice"),
+ TodoItem(id: "todo-2", text: "call mum", done: false, doneAt: 0, by: "Alice"),
+ ])
+
+ let event: [String: Any] = [
+ "type": "todo.remove",
+ "id": "todo-1",
+ ]
+
+ state = foldTodo(state, event: event)
+
+ XCTAssertEqual(state.items.count, 1, "one item removed, one remains")
+ XCTAssertEqual(state.items[0].id, "todo-2", "remaining item should be todo-2")
+ }
+
+ func testTodoIdempotence() {
+ var state = TodoState()
+
+ let event: [String: Any] = [
+ "type": "todo.add",
+ "id": "todo-1",
+ "text": "buy milk",
+ "name": "Alice",
+ ]
+
+ // Fold the same event twice (e.g., duplicate delivery on reconnect)
+ state = foldTodo(state, event: event)
+ state = foldTodo(state, event: event)
+
+ XCTAssertEqual(state.items.count, 1, "should have exactly one item (duplicate add deduped)")
+ }
+
+ func testTodoGracePeriodPartition() {
+ // Grace period: items are kept visible for ~750ms after being checked off,
+ // then tucked into a "Completed" section. This is derived in render from
+ // Date.now() and doneAt, never by a timer mutating state.
+ //
+ // Implementation note: keep the doneAt timestamp with each item; in the UI,
+ // partition items where doneAt > (now - 750) into "active", others into "completed".
+
+ let now: Int64 = 1700000000000
+ let gracePeriod: Int64 = 750
+
+ var state = TodoState(items: [
+ TodoItem(id: "todo-1", text: "just checked", done: true, doneAt: now - 100, by: "Alice"), // within grace
+ TodoItem(id: "todo-2", text: "checked long ago", done: true, doneAt: now - 1000, by: "Alice"), // past grace
+ TodoItem(id: "todo-3", text: "not done", done: false, doneAt: 0, by: "Alice"),
+ ])
+
+ // Partition logic
+ let active = state.items.filter { item in
+ !item.done || (item.done && item.doneAt > now - gracePeriod)
+ }
+ let archived = state.items.filter { item in
+ item.done && item.doneAt <= now - gracePeriod
+ }
+
+ XCTAssertEqual(active.count, 2, "should have 2 active items (not done + recently done)")
+ XCTAssertEqual(archived.count, 1, "should have 1 archived item")
+ XCTAssertTrue(active.contains { $0.id == "todo-1" }, "recently done item stays active")
+ XCTAssertTrue(active.contains { $0.id == "todo-3" }, "not-done item stays active")
+ XCTAssertTrue(archived.contains { $0.id == "todo-2" }, "old done item is archived")
+ }
+
+ func testTodoMultipleItems() {
+ var state = TodoState()
+
+ let events: [[String: Any]] = [
+ ["type": "todo.add", "id": "todo-1", "text": "milk", "name": "Alice"],
+ ["type": "todo.add", "id": "todo-2", "text": "bread", "name": "Alice"],
+ ["type": "todo.add", "id": "todo-3", "text": "eggs", "name": "Bob"],
+ ]
+
+ for event in events {
+ state = foldTodo(state, event: event)
+ }
+
+ XCTAssertEqual(state.items.count, 3, "should have three items")
+ XCTAssertEqual(state.items[0].text, "milk")
+ XCTAssertEqual(state.items[1].text, "bread")
+ XCTAssertEqual(state.items[2].text, "eggs")
+ }
+
+ func testTodoTextPreservation() {
+ let specialText = "Buy 2x milk™ — €5 each\n(or substitute)"
+ var state = TodoState()
+
+ let event: [String: Any] = [
+ "type": "todo.add",
+ "id": "todo-1",
+ "text": specialText,
+ "name": "Alice",
+ ]
+
+ state = foldTodo(state, event: event)
+
+ XCTAssertEqual(state.items[0].text, specialText, "should preserve text exactly, including unicode and newlines")
+ }
+}
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs
new file mode 100644
index 0000000..a8a7861
--- /dev/null
+++ b/test/event-vectors.test.mjs
@@ -0,0 +1,344 @@
+import assert from "assert";
+import { allVectors } from "./event-vectors/index.js";
+
+// Import chat.js fold handlers — the ones we're testing.
+// The app.hostEvents from web/static/apps/chat.js.
+// For testing purposes, we'll reconstruct them here so the test is self-contained.
+
+function testChatMsgFold(state, event, meta) {
+ const text = typeof event.text === "string" ? event.text : "";
+ const atts = Array.isArray(event.attachments) ? event.attachments.filter((a) => a && a.id) : [];
+ const sid = (meta && meta.id) || 0;
+ if ((!text.trim() && !atts.length) || !sid) return state;
+ if (state.msgs.some((m) => m.sid === sid)) return state;
+ const raw = { name: event.name || "", text, ts: event.ts || ((meta && meta.ts) || 0) };
+ if (event.uid) raw.uid = event.uid;
+ if (atts.length) raw.attachments = atts.map((a) => ({ ...a }));
+ if (event.replyTo) raw.replyTo = { ...event.replyTo };
+ if (Array.isArray(event.actions) && event.actions.length) raw.actions = event.actions;
+ if (event.action) raw.action = true;
+ const msgs = [
+ ...state.msgs,
+ {
+ id: "m" + sid,
+ sid,
+ text,
+ name: raw.name,
+ uid: event.uid || "",
+ ts: raw.ts,
+ attachments: raw.attachments || [],
+ reply: event.replyTo ? { id: event.replyTo.id || 0, name: event.replyTo.name || "", snippet: event.replyTo.snippet || "" } : null,
+ actions: raw.actions || [],
+ action: !!event.action,
+ reactions: [],
+ edited: false,
+ raw,
+ rev: 0,
+ },
+ ];
+ msgs.sort((a, b) => a.sid - b.sid);
+ return { msgs };
+}
+
+function testChatReactFold(state, event) {
+ return {
+ msgs: state.msgs.map((m) => (m.sid === event.msgId ? { ...m, reactions: event.reactions || [] } : m)),
+ };
+}
+
+function testChatEditFold(state, event) {
+ return {
+ msgs: state.msgs.map((m) => {
+ if (m.sid !== event.msgId) return m;
+ const text = typeof event.text === "string" ? event.text : m.text;
+ return { ...m, text, raw: { ...m.raw, text }, edited: true, rev: m.rev + 1 };
+ }),
+ };
+}
+
+function testChatDeleteFold(state, event) {
+ return { msgs: state.msgs.filter((m) => m.sid !== event.msgId) };
+}
+
+// Todo app fold handlers (web/static/apps/todo.js)
+function addItem(items, item) {
+ if (items.some((i) => i.id === item.id)) return items;
+ return [...items, item];
+}
+
+function updateItem(items, id, updates) {
+ return items.map((i) => (i.id === id ? { ...i, ...updates } : i));
+}
+
+function removeItem(items, id) {
+ return items.filter((i) => i.id !== id);
+}
+
+function testTodoAddFold(state, event) {
+ const text = typeof event.text === "string" ? event.text : "";
+ if (!text || !event.id) return state;
+ return { items: addItem(state.items, { id: event.id, text, done: false, doneAt: 0, by: event.name || "" }) };
+}
+
+function testTodoDoneFold(state, event) {
+ return { items: updateItem(state.items, event.id, { done: true, doneAt: event.ts || 0 }) };
+}
+
+function testTodoReopenFold(state, event) {
+ return { items: updateItem(state.items, event.id, { done: false, doneAt: 0 }) };
+}
+
+function testTodoRemoveFold(state, event) {
+ return { items: removeItem(state.items, event.id) };
+}
+
+// Remind app fold handlers (web/static/apps/remind.js)
+function testRemindAddFold(state, event) {
+ if (!event.id || !event.text || !event.when) return state;
+ const items = addItem(state.items, {
+ id: event.id,
+ text: event.text,
+ when: event.when,
+ ts: event.ts || 0,
+ sid: event.sid || "",
+ fired: false,
+ firedAt: 0,
+ });
+ items.sort((a, b) => a.when - b.when);
+ return { items };
+}
+
+function testRemindFireFold(state, event) {
+ return { items: updateItem(state.items, event.id, { fired: true, firedAt: event.ts || 0 }) };
+}
+
+function testRemindDoneFold(state, event) {
+ return { items: removeItem(state.items, event.id) };
+}
+
+// Test each vector
+let passes = 0;
+let fails = 0;
+
+function test(name, fn) {
+ try {
+ fn();
+ console.log(`PASS ${name}`);
+ passes++;
+ } catch (e) {
+ console.error(`FAIL ${name}`);
+ console.error(` ${e.message}`);
+ fails++;
+ }
+}
+
+allVectors.chat.forEach((vector) => {
+ if (vector.expected_fold_event?.type === "chat.msg") {
+ test(`chat.msg: ${vector.name} — folds correctly and produces expected event shape`, () => {
+ const state = { msgs: [] };
+ const event = {
+ type: "chat.msg",
+ ...vector.decrypted_payload,
+ };
+ const meta = { id: vector.server_message.id, ts: vector.server_message.created_at * 1000 };
+ const result = testChatMsgFold(state, event, meta);
+
+ assert.strictEqual(result.msgs.length, 1, "should have one message");
+ const msg = result.msgs[0];
+ assert.strictEqual(msg.name, vector.expected_fold_event.name, `name should be '${vector.expected_fold_event.name}'`);
+ assert.strictEqual(msg.text, vector.expected_fold_event.text, "text should match");
+ assert.strictEqual(msg.uid, vector.expected_fold_event.uid || "", "uid should match");
+ assert.strictEqual(msg.ts, vector.expected_fold_event.ts, "ts should match (ms)");
+ assert.strictEqual(msg.action, vector.expected_fold_event.action || false, "action flag should match");
+
+ // Attachments
+ if (vector.expected_fold_event.attachments) {
+ assert.deepStrictEqual(msg.attachments, vector.expected_fold_event.attachments, "attachments should match exactly");
+ } else {
+ assert.strictEqual(msg.attachments.length, 0, "attachments should be empty");
+ }
+
+ // Reply quote
+ if (vector.expected_fold_event.replyTo) {
+ assert.notStrictEqual(msg.reply, null, "reply should be present");
+ assert.strictEqual(msg.reply.id, vector.expected_fold_event.replyTo.id, "reply id should match");
+ assert.strictEqual(msg.reply.name, vector.expected_fold_event.replyTo.name, "reply name should match");
+ assert.strictEqual(msg.reply.snippet, vector.expected_fold_event.replyTo.snippet, "reply snippet should match");
+ } else {
+ assert.strictEqual(msg.reply, null, "reply should be null");
+ }
+ });
+ }
+
+ if (vector.expected_fold_event?.type === "chat.react") {
+ test(`chat.react: ${vector.name} — folds reactions correctly and produces expected event shape`, () => {
+ const state = { msgs: [{ sid: vector.server_reaction_frame.message_id, reactions: [] }] };
+ const event = {
+ type: "chat.react",
+ msgId: vector.server_reaction_frame.message_id,
+ reactions: vector.server_reaction_frame.reactions,
+ };
+ const result = testChatReactFold(state, event);
+
+ assert.strictEqual(result.msgs.length, 1, "should have one message");
+ const msg = result.msgs[0];
+ assert.deepStrictEqual(msg.reactions, vector.expected_fold_event.reactions, "reactions should match (without mine flag)");
+ });
+ }
+
+ if (vector.expected_fold_event?.type === "chat.edit") {
+ test(`chat.edit: ${vector.name} — edits message text and marks as edited`, () => {
+ const state = { msgs: [{ sid: vector.server_message.id, text: vector.original_payload.text, edited: false, rev: 0, raw: { text: vector.original_payload.text } }] };
+ const event = {
+ type: "chat.edit",
+ msgId: vector.server_message.id,
+ text: vector.expected_fold_event.text,
+ };
+ const result = testChatEditFold(state, event);
+
+ assert.strictEqual(result.msgs.length, 1, "should have one message");
+ const msg = result.msgs[0];
+ assert.strictEqual(msg.text, vector.expected_fold_event.text, "text should match edit");
+ assert.strictEqual(msg.edited, true, "edited flag should be true");
+ assert.strictEqual(msg.rev, 1, "rev should increment");
+ });
+ }
+
+ if (vector.expected_fold_event?.type === "chat.delete") {
+ test(`chat.delete: ${vector.name} — removes message from state`, () => {
+ const msgId = vector.expected_fold_event.msgId;
+ const state = {
+ msgs: [
+ { sid: msgId - 1, text: "before" },
+ { sid: msgId, text: "deleted" },
+ { sid: msgId + 1, text: "after" },
+ ],
+ };
+ const event = {
+ type: "chat.delete",
+ msgId: msgId,
+ };
+ const result = testChatDeleteFold(state, event);
+
+ assert.strictEqual(result.msgs.length, 2, "should have two messages");
+ assert.strictEqual(result.msgs[0].sid, msgId - 1, "first message preserved");
+ assert.strictEqual(result.msgs[1].sid, msgId + 1, "last message preserved");
+ assert(!result.msgs.some((m) => m.sid === msgId), "deleted message should not be present");
+ });
+ }
+});
+
+// Test todo vectors
+allVectors.todo.forEach((vector) => {
+ if (vector.name === "todo_add") {
+ test(`todo.add: ${vector.name} — folds correctly`, () => {
+ const state = { items: [] };
+ const event = vector.event;
+ const result = testTodoAddFold(state, event);
+
+ assert.strictEqual(result.items.length, 1, "should have one item");
+ const item = result.items[0];
+ assert.strictEqual(item.id, vector.expected_state_update.items[0].id, "id should match");
+ assert.strictEqual(item.text, vector.expected_state_update.items[0].text, "text should match");
+ assert.strictEqual(item.done, false, "done should be false");
+ assert.strictEqual(item.doneAt, 0, "doneAt should be 0");
+ assert.strictEqual(item.by, vector.expected_state_update.items[0].by, "by should match");
+ });
+ }
+
+ if (vector.name === "todo_done") {
+ test(`todo.done: ${vector.name} — marks as done with timestamp`, () => {
+ const state = { items: vector.initial_state.items };
+ const event = vector.event;
+ const result = testTodoDoneFold(state, event);
+
+ assert.strictEqual(result.items.length, 1, "should have one item");
+ const item = result.items[0];
+ assert.strictEqual(item.done, true, "done should be true");
+ assert.strictEqual(item.doneAt, vector.expected_state_update.items[0].doneAt, "doneAt should match");
+ });
+ }
+
+ if (vector.name === "todo_reopen") {
+ test(`todo.reopen: ${vector.name} — unmarks as done`, () => {
+ const state = { items: vector.initial_state.items };
+ const event = vector.event;
+ const result = testTodoReopenFold(state, event);
+
+ assert.strictEqual(result.items.length, 1, "should have one item");
+ const item = result.items[0];
+ assert.strictEqual(item.done, false, "done should be false");
+ assert.strictEqual(item.doneAt, 0, "doneAt should be 0");
+ });
+ }
+
+ if (vector.name === "todo_remove") {
+ test(`todo.remove: ${vector.name} — deletes item`, () => {
+ const state = { items: vector.initial_state.items };
+ const event = vector.event;
+ const result = testTodoRemoveFold(state, event);
+
+ assert.strictEqual(result.items.length, 1, "should have one item left");
+ assert.strictEqual(result.items[0].id, "todo-2", "remaining item should be todo-2");
+ });
+ }
+});
+
+// Test remind vectors
+allVectors.remind.forEach((vector) => {
+ if (vector.name === "remind_add") {
+ test(`remind.add: ${vector.name} — folds correctly`, () => {
+ const state = { items: [] };
+ const event = vector.event;
+ const result = testRemindAddFold(state, event);
+
+ assert.strictEqual(result.items.length, 1, "should have one item");
+ const item = result.items[0];
+ assert.strictEqual(item.id, vector.expected_state_update.items[0].id, "id should match");
+ assert.strictEqual(item.text, vector.expected_state_update.items[0].text, "text should match");
+ assert.strictEqual(item.when, vector.expected_state_update.items[0].when, "when should match");
+ assert.strictEqual(item.sid, vector.expected_state_update.items[0].sid, "sid should match");
+ assert.strictEqual(item.fired, false, "fired should be false");
+ });
+ }
+
+ if (vector.name === "remind_fire") {
+ test(`remind.fire: ${vector.name} — marks as fired`, () => {
+ const state = { items: vector.initial_state.items };
+ const event = vector.event;
+ const result = testRemindFireFold(state, event);
+
+ assert.strictEqual(result.items.length, 1, "should have one item");
+ const item = result.items[0];
+ assert.strictEqual(item.fired, true, "fired should be true");
+ assert.strictEqual(item.firedAt, vector.expected_state_update.items[0].firedAt, "firedAt should match");
+ });
+ }
+
+ if (vector.name === "remind_done") {
+ test(`remind.done: ${vector.name} — removes reminder`, () => {
+ const state = { items: vector.initial_state.items };
+ const event = vector.event;
+ const result = testRemindDoneFold(state, event);
+
+ assert.strictEqual(result.items.length, 0, "should have no items");
+ });
+ }
+
+ if (vector.name === "remind_multiple_sorted") {
+ test(`remind.multiple_sorted: ${vector.name} — keeps items sorted by when`, () => {
+ let state = { items: [] };
+ for (const e of vector.event_sequence) {
+ state = testRemindAddFold(state, e);
+ }
+
+ assert.strictEqual(state.items.length, 2, "should have two items");
+ assert.strictEqual(state.items[0].id, "remind-2", "first item should be remind-2 (soonest)");
+ assert.strictEqual(state.items[1].id, "remind-1", "second item should be remind-1 (latest)");
+ assert.strictEqual(state.items[0].when, 1700003600000, "first should be soonest");
+ assert.strictEqual(state.items[1].when, 1700010000000, "second should be latest");
+ });
+ }
+});
+
+if (fails) process.exit(1);
diff --git a/test/event-vectors/chat.json b/test/event-vectors/chat.json
new file mode 100644
index 0000000..61beb78
--- /dev/null
+++ b/test/event-vectors/chat.json
@@ -0,0 +1,297 @@
+{
+ "description": "Chat message event vectors: ensures chat.js (web) and native (iOS/Mac) fold message/reaction/edit/delete events identically. Used by test/event-vectors.test.mjs and ios/.../EventVectorTests.swift.",
+ "app": "chat",
+ "source": "web/static/apps/chat.js",
+ "vectors": [
+ {
+ "name": "plain_text_message",
+ "description": "A simple text message with no attachments or reply.",
+ "decrypted_payload": {
+ "name": "Alice",
+ "text": "Hello everyone",
+ "ts": 1700000000000,
+ "uid": "user-alice-1"
+ },
+ "server_message": {
+ "id": 12345,
+ "created_at": 1700000000,
+ "blob": "encrypted(decrypted_payload)"
+ },
+ "expected_fold_event": {
+ "type": "chat.msg",
+ "name": "Alice",
+ "text": "Hello everyone",
+ "ts": 1700000000000,
+ "uid": "user-alice-1",
+ "attachments": []
+ },
+ "expected_native_fields": {
+ "name": "Alice",
+ "text": "Hello everyone",
+ "uid": "user-alice-1",
+ "isRenderableChat": true
+ }
+ },
+ {
+ "name": "message_with_attachments",
+ "description": "A message with an image and a file.",
+ "decrypted_payload": {
+ "name": "Bob",
+ "text": "Check these out",
+ "ts": 1700000001000,
+ "uid": "user-bob-1",
+ "attachments": [
+ {
+ "id": "blob-img-1",
+ "name": "photo.jpg",
+ "type": "image/jpeg",
+ "w": 1920,
+ "h": 1080,
+ "size": 524288
+ },
+ {
+ "id": "blob-file-1",
+ "name": "report.pdf",
+ "type": "application/pdf",
+ "size": 2097152
+ }
+ ]
+ },
+ "server_message": {
+ "id": 12346,
+ "created_at": 1700000001,
+ "blob": "encrypted(decrypted_payload)"
+ },
+ "expected_fold_event": {
+ "type": "chat.msg",
+ "name": "Bob",
+ "text": "Check these out",
+ "ts": 1700000001000,
+ "uid": "user-bob-1",
+ "attachments": [
+ {
+ "id": "blob-img-1",
+ "name": "photo.jpg",
+ "type": "image/jpeg",
+ "w": 1920,
+ "h": 1080,
+ "size": 524288
+ },
+ {
+ "id": "blob-file-1",
+ "name": "report.pdf",
+ "type": "application/pdf",
+ "size": 2097152
+ }
+ ]
+ }
+ },
+ {
+ "name": "message_with_reply",
+ "description": "A reply-to message with quoted context.",
+ "decrypted_payload": {
+ "name": "Charlie",
+ "text": "I agree!",
+ "ts": 1700000002000,
+ "uid": "user-charlie-1",
+ "replyTo": {
+ "id": 12345,
+ "name": "Alice",
+ "snippet": "Hello everyone"
+ }
+ },
+ "server_message": {
+ "id": 12347,
+ "created_at": 1700000002,
+ "blob": "encrypted(decrypted_payload)"
+ },
+ "expected_fold_event": {
+ "type": "chat.msg",
+ "name": "Charlie",
+ "text": "I agree!",
+ "ts": 1700000002000,
+ "uid": "user-charlie-1",
+ "replyTo": {
+ "id": 12345,
+ "name": "Alice",
+ "snippet": "Hello everyone"
+ },
+ "attachments": []
+ }
+ },
+ {
+ "name": "action_line",
+ "description": "A /me-style action line (e.g., 'added milk' from a to-do app).",
+ "decrypted_payload": {
+ "name": "Todo Bot",
+ "text": "added 'milk'",
+ "ts": 1700000003000,
+ "uid": "app:todo",
+ "action": true
+ },
+ "server_message": {
+ "id": 12348,
+ "created_at": 1700000003,
+ "blob": "encrypted(decrypted_payload)"
+ },
+ "expected_fold_event": {
+ "type": "chat.msg",
+ "name": "Todo Bot",
+ "text": "added 'milk'",
+ "ts": 1700000003000,
+ "uid": "app:todo",
+ "action": true,
+ "attachments": []
+ }
+ },
+ {
+ "name": "message_with_video",
+ "description": "A message with an embedded video (dimensions + duration for layout before blob loads).",
+ "decrypted_payload": {
+ "name": "Diana",
+ "text": "Check this video",
+ "ts": 1700000004000,
+ "uid": "user-diana-1",
+ "attachments": [
+ {
+ "id": "blob-vid-1",
+ "name": "clip.mp4",
+ "type": "video/mp4",
+ "w": 1280,
+ "h": 720,
+ "dur": 45.5,
+ "poster": "blob-vid-1-poster",
+ "size": 5242880
+ }
+ ]
+ },
+ "server_message": {
+ "id": 12349,
+ "created_at": 1700000004,
+ "blob": "encrypted(decrypted_payload)"
+ },
+ "expected_fold_event": {
+ "type": "chat.msg",
+ "name": "Diana",
+ "text": "Check this video",
+ "ts": 1700000004000,
+ "uid": "user-diana-1",
+ "attachments": [
+ {
+ "id": "blob-vid-1",
+ "name": "clip.mp4",
+ "type": "video/mp4",
+ "w": 1280,
+ "h": 720,
+ "dur": 45.5,
+ "poster": "blob-vid-1-poster",
+ "size": 5242880
+ }
+ ]
+ }
+ },
+ {
+ "name": "reaction_single",
+ "description": "One member reacting with a single emoji.",
+ "server_reaction_frame": {
+ "type": "reaction",
+ "message_id": 12345,
+ "reactions": [
+ { "emoji": "👍", "count": 1, "mine": false }
+ ]
+ },
+ "expected_fold_event": {
+ "type": "chat.react",
+ "msgId": 12345,
+ "reactions": [
+ { "emoji": "👍", "count": 1, "mine": false }
+ ]
+ },
+ "note": "Live reaction broadcasts (via websocket) include mine:false from the server; history load includes mine:true for actual mine reactions. The fold handler preserves whatever the server sends."
+ },
+ {
+ "name": "reaction_multiple_emoji",
+ "description": "Multiple people reacting with different emoji.",
+ "server_reaction_frame": {
+ "type": "reaction",
+ "message_id": 12346,
+ "reactions": [
+ { "emoji": "❤️", "count": 2, "mine": true },
+ { "emoji": "😂", "count": 1, "mine": false },
+ { "emoji": "🎉", "count": 3, "mine": false }
+ ]
+ },
+ "expected_fold_event": {
+ "type": "chat.react",
+ "msgId": 12346,
+ "reactions": [
+ { "emoji": "❤️", "count": 2, "mine": true },
+ { "emoji": "😂", "count": 1, "mine": false },
+ { "emoji": "🎉", "count": 3, "mine": false }
+ ]
+ },
+ "note": "Like reaction_single, the fold handler preserves the mine flags from the server frame (including the true value for the current user's reactions on a live broadcast)."
+ },
+ {
+ "name": "edit",
+ "description": "A message edited by its author within the edit window.",
+ "original_payload": {
+ "name": "Eve",
+ "text": "typo here",
+ "ts": 1700000005000,
+ "uid": "user-eve-1"
+ },
+ "edited_payload": {
+ "name": "Eve",
+ "text": "no typo here",
+ "ts": 1700000005000,
+ "uid": "user-eve-1"
+ },
+ "server_message": {
+ "id": 12350,
+ "created_at": 1700000005,
+ "edited_at": 1700000010,
+ "blob": "encrypted(edited_payload)"
+ },
+ "expected_fold_event": {
+ "type": "chat.edit",
+ "msgId": 12350,
+ "text": "no typo here"
+ }
+ },
+ {
+ "name": "delete",
+ "description": "A message deleted by its author (hard-deleted server-side).",
+ "server_delete_frame": {
+ "type": "delete",
+ "id": 12351
+ },
+ "expected_fold_event": {
+ "type": "chat.delete",
+ "msgId": 12351
+ }
+ },
+ {
+ "name": "legacy_message_no_uid",
+ "description": "An older message (before uid was added) — both web and native fall back to matching by name.",
+ "decrypted_payload": {
+ "name": "Frank",
+ "text": "This is old",
+ "ts": 1699000000000
+ },
+ "server_message": {
+ "id": 12352,
+ "created_at": 1699000000,
+ "blob": "encrypted(decrypted_payload)"
+ },
+ "expected_fold_event": {
+ "type": "chat.msg",
+ "name": "Frank",
+ "text": "This is old",
+ "ts": 1699000000000,
+ "attachments": []
+ },
+ "note": "Both implementations should treat missing uid as valid and fall back to name-based matching for reply attribution."
+ }
+ ]
+}
diff --git a/test/event-vectors/index.js b/test/event-vectors/index.js
new file mode 100644
index 0000000..9764d0c
--- /dev/null
+++ b/test/event-vectors/index.js
@@ -0,0 +1,16 @@
+import { readFileSync } from "fs";
+import { fileURLToPath } from "url";
+import { dirname, join } from "path";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+export const chatVectors = JSON.parse(readFileSync(join(__dirname, "chat.json"), "utf8"));
+export const todoVectors = JSON.parse(readFileSync(join(__dirname, "todo.json"), "utf8"));
+export const remindVectors = JSON.parse(readFileSync(join(__dirname, "remind.json"), "utf8"));
+
+// Flatten all vectors for easy iteration in tests
+export const allVectors = {
+ chat: chatVectors.vectors || [],
+ todo: todoVectors.vectors || [],
+ remind: remindVectors.vectors || [],
+};
diff --git a/test/event-vectors/remind.json b/test/event-vectors/remind.json
new file mode 100644
index 0000000..3664451
--- /dev/null
+++ b/test/event-vectors/remind.json
@@ -0,0 +1,156 @@
+{
+ "description": "Reminders app event vectors: ensures remind fold handlers produce consistent state. Source of truth: web/static/apps/remind.js",
+ "app": "remind",
+ "source": "web/static/apps/remind.js",
+ "state_shape": { "items": [{ "id": "string", "text": "string", "when": "number (epoch ms, due time)", "ts": "number (epoch ms, add time)", "sid": "string (server schedule id, empty if offline)", "fired": "boolean", "firedAt": "number (epoch ms)" }] },
+ "vectors": [
+ {
+ "name": "remind_add",
+ "description": "Add a reminder with a future due time (sid = server schedule id, empty if offline).",
+ "event": {
+ "type": "remind.add",
+ "id": "remind-1",
+ "text": "call plumber",
+ "when": 1700003600000,
+ "ts": 1700000000000,
+ "sid": "sch-abc123"
+ },
+ "expected_state_update": {
+ "items": [
+ {
+ "id": "remind-1",
+ "text": "call plumber",
+ "when": 1700003600000,
+ "ts": 1700000000000,
+ "sid": "sch-abc123",
+ "fired": false,
+ "firedAt": 0
+ }
+ ]
+ },
+ "note": "Items are sorted by `when` (soonest first). `sid` is the server's scheduled-message id; empty string if scheduling failed (client falls back to best-effort firing). `when` is absolute epoch ms. No chat projection (reminders are silent in chat)."
+ },
+ {
+ "name": "remind_fire",
+ "description": "A scheduled reminder fires (delivered at the due time, either by server schedule or client best-effort).",
+ "event": {
+ "type": "remind.fire",
+ "id": "remind-1",
+ "notice": {
+ "title": "⏰ Reminder",
+ "body": "call plumber"
+ },
+ "ts": 1700003600000
+ },
+ "initial_state": {
+ "items": [
+ {
+ "id": "remind-1",
+ "text": "call plumber",
+ "when": 1700003600000,
+ "ts": 1700000000000,
+ "sid": "sch-abc123",
+ "fired": false,
+ "firedAt": 0
+ }
+ ]
+ },
+ "expected_state_update": {
+ "items": [
+ {
+ "id": "remind-1",
+ "text": "call plumber",
+ "when": 1700003600000,
+ "ts": 1700000000000,
+ "sid": "sch-abc123",
+ "fired": true,
+ "firedAt": 1700003600000
+ }
+ ]
+ },
+ "expected_chat_projection": {
+ "text": "⏰ Reminder: call plumber",
+ "action": false
+ },
+ "note": "`notice` has the title + body shown by push/toast (the text the user sees on alert). `firedAt` is the server delivery time (for grace-period animation, same 750ms linger-then-tuck as todo). When this event lands in the chat thread, it's surfaced as a message (not an action line) with the title+body from the notice."
+ },
+ {
+ "name": "remind_done",
+ "description": "Dismiss a reminder (removes it from the list; if unfired, also cancels the server schedule via the sid).",
+ "event": {
+ "type": "remind.done",
+ "id": "remind-1"
+ },
+ "initial_state": {
+ "items": [
+ {
+ "id": "remind-1",
+ "text": "call plumber",
+ "when": 1700003600000,
+ "ts": 1700000000000,
+ "sid": "sch-abc123",
+ "fired": false,
+ "firedAt": 0
+ }
+ ]
+ },
+ "expected_state_update": {
+ "items": []
+ },
+ "note": "If `fired=false` and `sid` is set, the client sends an unschedule request to cancel the server's scheduled remind.fire. If the cancel races the delivery, the late remind.fire folds onto a missing id (a no-op)."
+ },
+ {
+ "name": "remind_grace_period_partition",
+ "description": "Grace period: active (unfired or recently fired within 750ms) vs. archived (fired + >750ms ago).",
+ "note": "Like todo, this is derived in render from Date.now() and firedAt, never by a timer. Native should partition: unfired items + recently-fired (firedAt > now - 750ms) stay visible; older fired reminders move to 'Previous reminders' section.",
+ "constants": {
+ "REMIND_GRACE_MS": 750
+ }
+ },
+ {
+ "name": "remind_multiple_sorted",
+ "description": "Multiple reminders are kept sorted by `when` (soonest first), regardless of add order.",
+ "event_sequence": [
+ {
+ "type": "remind.add",
+ "id": "remind-1",
+ "text": "late one",
+ "when": 1700010000000,
+ "ts": 1700000000000,
+ "sid": "sch-abc123"
+ },
+ {
+ "type": "remind.add",
+ "id": "remind-2",
+ "text": "early one",
+ "when": 1700003600000,
+ "ts": 1700000100000,
+ "sid": "sch-def456"
+ }
+ ],
+ "expected_final_state": {
+ "items": [
+ {
+ "id": "remind-2",
+ "text": "early one",
+ "when": 1700003600000,
+ "ts": 1700000100000,
+ "sid": "sch-def456",
+ "fired": false,
+ "firedAt": 0
+ },
+ {
+ "id": "remind-1",
+ "text": "late one",
+ "when": 1700010000000,
+ "ts": 1700000000000,
+ "sid": "sch-abc123",
+ "fired": false,
+ "firedAt": 0
+ }
+ ]
+ },
+ "note": "The list is re-sorted on every remind.add, so the order is always soonest-first regardless of user input order."
+ }
+ ]
+}
diff --git a/test/event-vectors/todo.json b/test/event-vectors/todo.json
new file mode 100644
index 0000000..0a9515e
--- /dev/null
+++ b/test/event-vectors/todo.json
@@ -0,0 +1,140 @@
+{
+ "description": "To-do app event vectors: ensures todo fold handlers produce consistent state. Source of truth: web/static/apps/todo.js",
+ "app": "todo",
+ "source": "web/static/apps/todo.js",
+ "state_shape": { "items": [{ "id": "string", "text": "string", "done": "boolean", "doneAt": "number (epoch ms)", "by": "string (sender name)" }] },
+ "vectors": [
+ {
+ "name": "todo_add",
+ "description": "Add a to-do item (checked off = false by default).",
+ "event": {
+ "type": "todo.add",
+ "id": "todo-1",
+ "text": "buy milk",
+ "name": "Alice"
+ },
+ "expected_state_update": {
+ "items": [
+ {
+ "id": "todo-1",
+ "text": "buy milk",
+ "done": false,
+ "doneAt": 0,
+ "by": "Alice"
+ }
+ ]
+ },
+ "expected_chat_projection": {
+ "text": "added \"buy milk\"",
+ "action": true
+ }
+ },
+ {
+ "name": "todo_done",
+ "description": "Check off a to-do (mark as done with a timestamp for the grace period animation).",
+ "event": {
+ "type": "todo.done",
+ "id": "todo-1",
+ "ts": 1700000000000
+ },
+ "initial_state": {
+ "items": [
+ {
+ "id": "todo-1",
+ "text": "buy milk",
+ "done": false,
+ "doneAt": 0,
+ "by": "Alice"
+ }
+ ]
+ },
+ "expected_state_update": {
+ "items": [
+ {
+ "id": "todo-1",
+ "text": "buy milk",
+ "done": true,
+ "doneAt": 1700000000000,
+ "by": "Alice"
+ }
+ ]
+ },
+ "note": "doneAt drives the grace-period animation (stays visible ~750ms with a depleting ring, then tucks into Completed). No chat projection for done/reopen/remove (only add shows as an action line)."
+ },
+ {
+ "name": "todo_reopen",
+ "description": "Uncheck a to-do (mark as not done, clear the doneAt timestamp).",
+ "event": {
+ "type": "todo.reopen",
+ "id": "todo-1"
+ },
+ "initial_state": {
+ "items": [
+ {
+ "id": "todo-1",
+ "text": "buy milk",
+ "done": true,
+ "doneAt": 1700000000000,
+ "by": "Alice"
+ }
+ ]
+ },
+ "expected_state_update": {
+ "items": [
+ {
+ "id": "todo-1",
+ "text": "buy milk",
+ "done": false,
+ "doneAt": 0,
+ "by": "Alice"
+ }
+ ]
+ }
+ },
+ {
+ "name": "todo_remove",
+ "description": "Delete a to-do item permanently.",
+ "event": {
+ "type": "todo.remove",
+ "id": "todo-1"
+ },
+ "initial_state": {
+ "items": [
+ {
+ "id": "todo-1",
+ "text": "buy milk",
+ "done": false,
+ "doneAt": 0,
+ "by": "Alice"
+ },
+ {
+ "id": "todo-2",
+ "text": "call mum",
+ "done": false,
+ "doneAt": 0,
+ "by": "Alice"
+ }
+ ]
+ },
+ "expected_state_update": {
+ "items": [
+ {
+ "id": "todo-2",
+ "text": "call mum",
+ "done": false,
+ "doneAt": 0,
+ "by": "Alice"
+ }
+ ]
+ }
+ },
+ {
+ "name": "todo_grace_period_partition",
+ "description": "Grace period: active (not done or recently checked within 750ms) vs. archived (done + >750ms ago).",
+ "note": "This is derived in render from Date.now() and doneAt, never by a timer mutating state. Native should implement the same partitioning logic: a done item with doneAt > (now - 750) stays in 'active', others move to 'Completed' section.",
+ "constants": {
+ "TODO_GRACE_MS": 750
+ }
+ }
+ ]
+}