diff options
Diffstat (limited to 'src/app/store.test.ts')
| -rw-r--r-- | src/app/store.test.ts | 363 |
1 files changed, 317 insertions, 46 deletions
diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 7b00d42..dabc80d 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -51,11 +51,21 @@ function fakeSocket(): FakeSocket { return ws; } -function fakeFetchImpl(responses: Record<string, unknown> = {}): typeof fetch { +interface FakeFetchOptions { + models?: readonly string[]; + history?: Record<string, ConversationHistoryResponse>; +} + +function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch { + const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"]; + const history = opts?.history ?? {}; return async (input: string | URL | Request): Promise<Response> => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models }), { status: 200 }); + } const body = - responses[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse); + history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse); return new Response(JSON.stringify(body), { status: 200 }); }; } @@ -64,6 +74,36 @@ function parseSent(ws: FakeSocket): unknown[] { return ws.sent.map((s) => JSON.parse(s)); } +function createFakeStorage(): Storage { + const map = new Map<string, string>(); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem(key: string): string | null { + return map.get(key) ?? null; + }, + key(_index: number): string | null { + return null; + }, + removeItem(key: string) { + map.delete(key); + }, + setItem(key: string, value: string) { + map.set(key, value); + }, + }; +} + +function activeConversationId(store: ReturnType<typeof createAppStore>): string { + const id = store.activeConversationId; + expect(id).not.toBeNull(); + return id as string; +} + describe("createAppStore", () => { it("starts with empty catalog and no selection", () => { const ws = fakeSocket(); @@ -71,6 +111,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -88,6 +129,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -112,6 +154,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -139,6 +182,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -175,6 +219,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -208,6 +253,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -234,6 +280,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -261,6 +308,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); @@ -268,41 +316,45 @@ describe("createAppStore", () => { expect(closeSpy.called).toBe(true); }); - it("exposes chat store with empty initial messages", () => { + it("exposes activeChat with empty initial messages", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); - expect(store.chat).toBeDefined(); - expect(store.chat.messages).toEqual([]); - expect(store.chat.chunks).toEqual([]); - expect(store.chat.error).toBeNull(); + expect(store.activeChat).toBeDefined(); + expect(store.activeChat.messages).toEqual([]); + expect(store.activeChat.chunks).toEqual([]); + expect(store.activeChat.error).toBeNull(); store.dispose(); }); - it("sending a message posts a chat.send on the socket", () => { + it("sending a message from draft creates a tab and posts chat.send", () => { const ws = fakeSocket(); + const storage = createFakeStorage(); const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: storage, }); ws.resolveOpen(); ws.sent.length = 0; - store.chat.send("hello world"); + store.send("hello world"); + + expect(store.tabs).toHaveLength(1); + expect(store.tabs[0]?.title).toBe("hello world"); + expect(store.activeConversationId).not.toBeNull(); const msgs = parseSent(ws); const chatSend = msgs.find((m) => (m as { type: string }).type === "chat.send") as | { type: string; conversationId: string; message: string } | undefined; expect(chatSend).toBeTruthy(); - expect(chatSend?.conversationId).toBe("test-conv"); expect(chatSend?.message).toBe("hello world"); store.dispose(); @@ -313,41 +365,30 @@ describe("createAppStore", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); + store.send("test"); + const convId = activeConversationId(store); + ws.feedServerMessage({ type: "chat.delta", - event: { - type: "turn-start", - conversationId: "test-conv", - turnId: "turn-1", - }, + event: { type: "turn-start", conversationId: convId, turnId: "turn-1" }, }); ws.feedServerMessage({ type: "chat.delta", - event: { - type: "text-delta", - conversationId: "test-conv", - turnId: "turn-1", - delta: "Hello ", - }, + event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "Hello " }, }); ws.feedServerMessage({ type: "chat.delta", - event: { - type: "text-delta", - conversationId: "test-conv", - turnId: "turn-1", - delta: "world", - }, + event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "world" }, }); - expect(store.chat.chunks.length).toBeGreaterThan(0); - const textChunks = store.chat.chunks.filter((c) => c.chunk.type === "text"); + expect(store.activeChat.chunks.length).toBeGreaterThan(0); + const textChunks = store.activeChat.chunks.filter((c) => c.chunk.type === "text"); expect(textChunks).toHaveLength(1); expect((textChunks[0]?.chunk as { type: "text"; text: string }).text).toBe("Hello world"); @@ -359,16 +400,20 @@ describe("createAppStore", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", + localStorage: createFakeStorage(), }); ws.resolveOpen(); + store.send("test"); + const convId = activeConversationId(store); + ws.feedServerMessage({ type: "chat.error", + conversationId: convId, message: "bad request", }); - expect(store.chat.error).toBe("bad request"); + expect(store.activeChat.error).toBe("bad request"); store.dispose(); }); @@ -385,6 +430,11 @@ describe("createAppStore", () => { const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; fetchedUrls.push(url); + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + status: 200, + }); + } return new Response(JSON.stringify(historyResponse), { status: 200 }); }; @@ -392,36 +442,257 @@ describe("createAppStore", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl, - conversationId: "test-conv", httpUrl: "http://localhost:24203", + localStorage: createFakeStorage(), }); ws.resolveOpen(); + store.send("hi"); + const convId = activeConversationId(store); + ws.feedServerMessage({ type: "chat.delta", - event: { - type: "turn-start", - conversationId: "test-conv", - turnId: "turn-1", - }, + event: { type: "turn-start", conversationId: convId, turnId: "turn-1" }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + + expect(store.activeChat.chunks.length).toBeGreaterThan(0); + + store.dispose(); + }); + + it("fetches and exposes the model catalog", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ + models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"], + }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + await new Promise((r) => setTimeout(r, 50)); + + expect(store.models).toEqual([ + "opencode/deepseek-v4-flash", + "openai/gpt-4o", + "anthropic/claude-3", + ]); + + store.dispose(); + }); + + it("default model is flash", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); + + store.dispose(); + }); + + it("draft: sending the first message creates a tab titled from the message", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + expect(store.tabs).toHaveLength(0); + expect(store.activeConversationId).toBeNull(); + + store.send("What is the meaning of life?"); + + expect(store.tabs).toHaveLength(1); + expect(store.tabs[0]?.title).toBe("What is the meaning of life?"); + expect(store.activeConversationId).toBe(store.tabs[0]?.conversationId); + + store.dispose(); + }); + + it("selecting a model updates the active tab", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("hello"); + + store.selectModel("openai/gpt-4o"); + + expect(store.activeModel).toBe("openai/gpt-4o"); + expect(store.tabs[0]?.model).toBe("openai/gpt-4o"); + + store.dispose(); + }); + + it("chat.delta routes to the matching tab only", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), }); + ws.resolveOpen(); + + store.send("first message"); + const convId1 = activeConversationId(store); + + store.newDraft(); + store.send("second message"); + const convId2 = activeConversationId(store); + + expect(convId1).not.toBe(convId2); ws.feedServerMessage({ type: "chat.delta", + event: { type: "turn-start", conversationId: convId1, turnId: "turn-1" }, + }); + ws.feedServerMessage({ + type: "chat.delta", event: { - type: "turn-sealed", - conversationId: "test-conv", + type: "text-delta", + conversationId: convId1, turnId: "turn-1", + delta: "response to first", }, }); - await new Promise((r) => setTimeout(r, 50)); + store.selectTab(convId1); + const textChunks1 = store.activeChat.chunks.filter((c) => c.chunk.type === "text"); + expect(textChunks1).toHaveLength(1); + expect((textChunks1[0]?.chunk as { type: "text"; text: string }).text).toBe( + "response to first", + ); - expect(fetchedUrls.some((u) => u.includes("/conversations/test-conv?sinceSeq="))).toBe(true); + store.selectTab(convId2); + expect(store.activeChat.chunks).toEqual([]); - await new Promise((r) => setTimeout(r, 50)); + store.dispose(); + }); + + it("closing a tab evicts its cache and drops the tab", () => { + const ws = fakeSocket(); + const storage = createFakeStorage(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws.resolveOpen(); + + store.send("first"); + const convId = activeConversationId(store); + expect(store.tabs).toHaveLength(1); + + store.closeTab(convId); + + expect(store.tabs).toHaveLength(0); + expect(store.activeConversationId).toBeNull(); + + store.dispose(); + }); + + it("tabs persist to the injected storage and restore on a new store", () => { + const ws = fakeSocket(); + const storage = createFakeStorage(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws.resolveOpen(); + + store.send("persist me"); + const convId = store.tabs[0]?.conversationId; + const title = store.tabs[0]?.title; + expect(convId).toBeDefined(); + expect(title).toBeDefined(); + + const raw = storage.getItem("dispatch.tabs"); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw as string); + expect(parsed.tabs).toHaveLength(1); + expect(parsed.tabs[0].conversationId).toBe(convId); + expect(parsed.tabs[0].title).toBe(title); + + const ws2 = fakeSocket(); + const store2 = createAppStore({ + socketFactory: () => ws2, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws2.resolveOpen(); + + expect(store2.tabs).toHaveLength(1); + expect(store2.tabs[0]?.conversationId).toBe(convId); + expect(store2.tabs[0]?.title).toBe(title); + expect(store2.activeConversationId).toBe(convId); + + store.dispose(); + store2.dispose(); + }); + + it("newDraft resets to draft mode", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + expect(store.tabs).toHaveLength(1); + + store.newDraft(); + expect(store.activeConversationId).toBeNull(); + + store.dispose(); + }); + + it("selectTab switches active tab", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + const convId1 = activeConversationId(store); + + store.newDraft(); + store.send("second"); + const convId2 = activeConversationId(store); + + store.selectTab(convId1); + expect(store.activeConversationId).toBe(convId1); - expect(store.chat.chunks.length).toBeGreaterThan(0); + store.selectTab(convId2); + expect(store.activeConversationId).toBe(convId2); store.dispose(); }); |
