feat: add MockAdapter passing the conformance suite
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, MockThread>();
|
||||
private listeners = new Map<string, Set<(e: MessageEvent) => 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<Conversation[]> {
|
||||
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<Message[]> {
|
||||
return [...(this.threads.get(threadId)?.messages ?? [])];
|
||||
}
|
||||
|
||||
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
|
||||
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<void> {
|
||||
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<Attachment> {
|
||||
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<void> {
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { runAdapterConformance } from './conformance';
|
||||
import { MockAdapter } from './adapters/mock';
|
||||
|
||||
runAdapterConformance({
|
||||
makeAdapter: () => new MockAdapter(),
|
||||
seededThreadId: 'th_mock_1',
|
||||
openWith: ['pp_sofia'],
|
||||
});
|
||||
Reference in New Issue
Block a user