diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 00:21:04 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 00:21:04 +0900 |
| commit | 2faccf3482922385c2de47f210e48a8d2968762f (patch) | |
| tree | 215c4d6e690bc542e24b8ede2b55ea7381c3998a /src/features/chat/ui.test.ts | |
| parent | 312ed5a9fe0d7f2660a66d61baf429b8e021ffb1 (diff) | |
| download | dispatch-web-2faccf3482922385c2de47f210e48a8d2968762f.tar.gz dispatch-web-2faccf3482922385c2de47f210e48a8d2968762f.zip | |
Slice 2 wave 2: IndexedDB cache adapter + chat feature
- adapters/idb: createIdbChunkStore implements the ConversationChunkStore port
over IndexedDB (compound [conversationId,seq] key, idempotent append, meta
store for lastAccess); 8 tests with fake-indexeddb
- features/chat: createChatStore (runes-thin over the core/chunks reducer, all
effects injected via ChatTransport/HistorySync/ConversationCache ports) +
ChatView/Composer svelte-thin UI; folds chat.delta, syncs on turn-sealed,
hydrates from cache then catches up; 25 tests
Verified green: svelte-check 0/0, vitest 202, biome clean, build ok.
Diffstat (limited to 'src/features/chat/ui.test.ts')
| -rw-r--r-- | src/features/chat/ui.test.ts | 228 |
1 files changed, 228 insertions, 0 deletions
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts new file mode 100644 index 0000000..c4793a0 --- /dev/null +++ b/src/features/chat/ui.test.ts @@ -0,0 +1,228 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { RenderedChunk } from "../../core/chunks"; +import ChatView from "./ui/ChatView.svelte"; +import Composer from "./ui/Composer.svelte"; + +describe("ChatView", () => { + it("renders a message's text chunk", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "text", text: "Hello world" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("Hello world")).toBeInTheDocument(); + expect(screen.getByText("assistant")).toBeInTheDocument(); + }); + + it("renders multiple chunks", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("Hi there")).toBeInTheDocument(); + expect(screen.getByText("Hello!")).toBeInTheDocument(); + }); + + it("renders tool-call chunks", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "read_file", + input: { path: "/tmp/test.txt" }, + }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("read_file")).toBeInTheDocument(); + const pre = screen.getByText((content, element) => { + return element?.tagName === "PRE" && content.includes("/tmp/test.txt"); + }); + expect(pre).toBeInTheDocument(); + }); + + it("renders tool-result chunks", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "tc1", + toolName: "read_file", + content: "file contents here", + isError: false, + }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("file contents here")).toBeInTheDocument(); + }); + + it("renders error chunks with alert role", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "error", message: "Something failed" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Something failed"); + }); + + it("renders error chunks with code", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("Rate limited")).toBeInTheDocument(); + expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument(); + }); + + it("renders system chunks", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "system", + chunk: { type: "system", text: "System context loaded" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("System context loaded")).toBeInTheDocument(); + }); + + it("marks provisional chunks", () => { + const chunks: RenderedChunk[] = [ + { + seq: null, + role: "assistant", + chunk: { type: "text", text: "Streaming..." }, + provisional: true, + }, + ]; + + render(ChatView, { props: { chunks } }); + + const article = screen.getByText("Streaming...").closest("article"); + expect(article).toHaveClass("message--provisional"); + }); + + it("renders empty transcript", () => { + render(ChatView, { props: { chunks: [] } }); + + const log = screen.getByRole("log"); + expect(log).toBeInTheDocument(); + expect(log.children).toHaveLength(0); + }); +}); + +describe("Composer", () => { + it("calls onSend with the typed text and clears", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + + render(Composer, { props: { onSend } }); + + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "Hello world"); + + const sendButton = screen.getByRole("button", { name: "Send" }); + await user.click(sendButton); + + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith("Hello world"); + expect(textarea).toHaveValue(""); + }); + + it("does not call onSend with empty text", async () => { + const onSend = vi.fn(); + const _user = userEvent.setup(); + + render(Composer, { props: { onSend } }); + + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).toBeDisabled(); + + expect(onSend).not.toHaveBeenCalled(); + }); + + it("trims whitespace before sending", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + + render(Composer, { props: { onSend } }); + + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, " hello "); + + const sendButton = screen.getByRole("button", { name: "Send" }); + await user.click(sendButton); + + expect(onSend).toHaveBeenCalledWith("hello"); + }); + + it("sends on Enter key (without Shift)", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + + render(Composer, { props: { onSend } }); + + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "Test message{Enter}"); + + expect(onSend).toHaveBeenCalledWith("Test message"); + }); + + it("does not send on Shift+Enter", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + + render(Composer, { props: { onSend } }); + + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2"); + + expect(onSend).not.toHaveBeenCalled(); + }); +}); |
