From 54f426e4f0e9f3054164e26bd43980882ca28d32 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 00:48:18 +0530 Subject: [PATCH] feat: add MockAdapter passing the conformance suite Co-Authored-By: Claude Opus 4.8 (1M context) --- .../iios-messaging-ui/src/adapters/mock.ts | 168 ++++++++++++++++++ .../iios-messaging-ui/src/conformance.test.ts | 8 + 2 files changed, 176 insertions(+) create mode 100644 packages/iios-messaging-ui/src/adapters/mock.ts create mode 100644 packages/iios-messaging-ui/src/conformance.test.ts diff --git a/packages/iios-messaging-ui/src/adapters/mock.ts b/packages/iios-messaging-ui/src/adapters/mock.ts new file mode 100644 index 0000000..fbd31b8 --- /dev/null +++ b/packages/iios-messaging-ui/src/adapters/mock.ts @@ -0,0 +1,168 @@ +import type { MessagingAdapter } from '../adapter'; +import type { + Attachment, + Conversation, + Membership, + Message, + MessageEvent, + Person, + SendOpts, + Unsubscribe, +} from '../types'; + +const ME = 'me'; + +export const MOCK_PEOPLE: Person[] = [ + { id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' }, + { id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' }, + { id: 'pp_priya', name: 'Priya Nair', kind: 'staff' }, + { id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' }, + { id: 'cust_globex', name: 'Globex Homes (Client)', kind: 'customer' }, +]; + +interface MockThread { + threadId: string; + membership: Membership; + subject: string | null; + participants: string[]; + messages: Message[]; +} + +const nameById = new Map(MOCK_PEOPLE.map((p) => [p.id, p.name])); + +/** + * In-memory adapter for demos and tests. State is PER INSTANCE — the CRM's version + * used a module-level Map, which leaks between tests. Each `new MockAdapter()` is + * fully isolated. + */ +export class MockAdapter implements MessagingAdapter { + private seq = 100; + private threads = new Map(); + private listeners = new Map void>>(); + + constructor(private readonly now: () => string = () => new Date().toISOString()) { + const seededAt = this.now(); + this.threads.set('th_mock_1', { + threadId: 'th_mock_1', + membership: 'dm', + subject: null, + participants: [ME, 'pp_sofia'], + messages: [ + { + id: 'm1', + actorId: 'pp_sofia', + text: 'Can you review the Henderson estimate?', + at: seededAt, + reactions: [], + }, + ], + }); + this.threads.set('th_mock_2', { + threadId: 'th_mock_2', + membership: 'group', + subject: 'Storm response — East side', + participants: [ME, 'pp_dan', 'pp_priya'], + messages: [ + { id: 'm2', actorId: 'pp_dan', text: 'Crew is rolling out at 7.', at: seededAt, reactions: [] }, + ], + }); + } + + currentActorId(): string { + return ME; + } + + async listConversations(): Promise { + return [...this.threads.values()].map((t) => { + const last = t.messages[t.messages.length - 1]; + const others = t.participants.filter((p) => p !== ME); + return { + threadId: t.threadId, + title: t.subject || others.map((id) => nameById.get(id) ?? id).join(', ') || 'Conversation', + subject: t.subject, + membership: t.membership, + participants: [...t.participants], + unread: 0, + ...(last ? { lastMessage: last.text, lastAt: last.at } : {}), + }; + }); + } + + async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> { + const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group'); + const threadId = `th_mock_${this.seq++}`; + this.threads.set(threadId, { + threadId, + membership, + subject: p.subject ?? null, + participants: [ME, ...p.participantIds], + messages: [], + }); + return { threadId }; + } + + async history(threadId: string): Promise { + return [...(this.threads.get(threadId)?.messages ?? [])]; + } + + async send(threadId: string, content: string, opts?: SendOpts): Promise { + const t = this.threads.get(threadId); + if (!t) throw new Error(`Unknown thread: ${threadId}`); + const message: Message = { + id: `m_${this.seq++}`, + actorId: ME, + text: content, + at: this.now(), + reactions: [], + ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}), + ...(opts?.attachment ? { attachment: opts.attachment } : {}), + }; + t.messages = [...t.messages, message]; + this.emit(threadId, { kind: 'message', message }); + return message; + } + + async react(threadId: string, messageId: string, emoji: string): Promise { + const t = this.threads.get(threadId); + if (!t) return; + let next: Message | undefined; + t.messages = t.messages.map((m) => { + if (m.id !== messageId) return m; + const existing = (m.reactions ?? []).find((r) => r.emoji === emoji); + const reactions = existing + ? (m.reactions ?? []).filter((r) => r.emoji !== emoji) + : [...(m.reactions ?? []), { emoji, count: 1, mine: true }]; + next = { ...m, reactions }; + return next; + }); + if (next) this.emit(threadId, { kind: 'reaction', messageId, reactions: next.reactions ?? [] }); + } + + async upload(file: File): Promise { + return { url: `mock://uploads/${file.name}`, mime: file.type, name: file.name }; + } + + subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe { + if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set()); + this.listeners.get(threadId)!.add(cb); + return () => { + this.listeners.get(threadId)?.delete(cb); + }; + } + + sendTyping(): void { + // No-op: nobody is typing back in a mock. + } + + async markRead(): Promise { + // No-op: the mock has no second party to report a read. + } + + isConnected(): boolean { + return true; + } + + private emit(threadId: string, e: MessageEvent): void { + this.listeners.get(threadId)?.forEach((cb) => cb(e)); + } +} diff --git a/packages/iios-messaging-ui/src/conformance.test.ts b/packages/iios-messaging-ui/src/conformance.test.ts new file mode 100644 index 0000000..bef335a --- /dev/null +++ b/packages/iios-messaging-ui/src/conformance.test.ts @@ -0,0 +1,8 @@ +import { runAdapterConformance } from './conformance'; +import { MockAdapter } from './adapters/mock'; + +runAdapterConformance({ + makeAdapter: () => new MockAdapter(), + seededThreadId: 'th_mock_1', + openWith: ['pp_sofia'], +});