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
11 files changed,
+1886
−1
.claude/memory/MEMORY.md+3 −0.claude/memory/event-contract.md+33 −0CLAUDE.md+85 −1ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift+279 −0ios/LChatCore/Tests/LChatCoreTests/RemindAppVectorTests.swift+302 −0ios/LChatCore/Tests/LChatCoreTests/TodoAppVectorTests.swift+231 −0test/event-vectors.test.mjs+344 −0test/event-vectors/chat.json+297 −0test/event-vectors/index.js+16 −0test/event-vectors/remind.json+156 −0test/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 |
| + } |
| + } |
| + ] |
| +} |