diff options
| author | Adam Malczewski <[email protected]> | 2026-06-06 22:24:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-06 22:24:20 +0900 |
| commit | f1409cd46d5a3cfb9002cbcdfd4ab947ac6846aa (patch) | |
| tree | a035b2e2d2cb122cdd54c841d87e45965746e2c5 /src/app | |
| parent | e1c8cf3257cb33457aa882c548f5195ecc0f9854 (diff) | |
| download | dispatch-web-f1409cd46d5a3cfb9002cbcdfd4ab947ac6846aa.tar.gz dispatch-web-f1409cd46d5a3cfb9002cbcdfd4ab947ac6846aa.zip | |
Slice 1 follow-up: component-render interaction tests (CR-1/CR-2)
- vite: add @testing-library/svelte's svelteTesting() plugin so component
render()/mount() resolves Svelte's browser build under vitest/jsdom
- dep: @testing-library/user-event for realistic interaction tests
- app: 7 component-render tests driving App.svelte through a fake socket
(catalog render, subscribe-on-click, unsub/sub ordering, aria-current,
error banner, action invoke)
Verified green: svelte-check 0/0, vitest 91 passed, biome clean, vite build ok.
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.test.ts | 259 |
1 files changed, 259 insertions, 0 deletions
diff --git a/src/app/App.test.ts b/src/app/App.test.ts new file mode 100644 index 0000000..ce37586 --- /dev/null +++ b/src/app/App.test.ts @@ -0,0 +1,259 @@ +import type { SurfaceServerMessage } from "@dispatch/ui-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { WebSocketLike } from "../adapters/ws"; +import App from "./App.svelte"; +import { createAppStore } from "./store.svelte"; + +interface FakeSocket extends WebSocketLike { + sent: string[]; + resolveOpen(): void; + feedMessage(data: SurfaceServerMessage): void; +} + +function fakeSocket(): FakeSocket { + let onopen: (() => void) | null = null; + let onmessage: ((ev: { data: string }) => void) | null = null; + const sent: string[] = []; + + const ws: FakeSocket = { + send(data: string) { + sent.push(data); + }, + close() {}, + get onopen() { + return onopen; + }, + set onopen(fn) { + onopen = fn; + }, + get onmessage() { + return onmessage; + }, + set onmessage(fn) { + onmessage = fn; + }, + get onclose() { + return null; + }, + set onclose(_fn) {}, + resolveOpen() { + onopen?.(); + }, + feedMessage(msg: SurfaceServerMessage) { + onmessage?.({ data: JSON.stringify(msg) }); + }, + sent, + }; + return ws; +} + +function sentMessages(ws: FakeSocket) { + return ws.sent.map((s) => JSON.parse(s)); +} + +describe("App component interaction tests", () => { + it("renders empty state when catalog is empty", () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + expect(screen.getByText("No surfaces available")).toBeInTheDocument(); + + store.dispose(); + }); + + it("renders a catalog button per entry after a catalog message", () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + render(App, { props: { store } }); + + const buttons = screen.getAllByRole("button"); + expect(buttons).toHaveLength(2); + expect(buttons[0]).toHaveTextContent("Surface One"); + expect(buttons[1]).toHaveTextContent("Surface Two"); + + store.dispose(); + }); + + it("clicking a catalog entry subscribes and renders its surface", async () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + }); + + render(App, { props: { store } }); + + const user = userEvent.setup(); + const button = screen.getByRole("button", { name: /Surface One/ }); + ws.sent.length = 0; + await user.click(button); + + const msgs = sentMessages(ws); + const subscribe = msgs.find( + (m: { type: string; surfaceId: string }) => m.type === "subscribe" && m.surfaceId === "s1", + ); + expect(subscribe).toBeTruthy(); + + ws.feedMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [{ kind: "stat", label: "Tokens", value: "1,234" }], + }, + }); + + expect(await screen.findByRole("heading", { name: "Surface One" })).toBeInTheDocument(); + expect(await screen.findByText("Tokens")).toBeInTheDocument(); + expect(await screen.findByText("1,234")).toBeInTheDocument(); + + store.dispose(); + }); + + it("clicking a different entry unsubscribes the previous then subscribes the new", async () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + render(App, { props: { store } }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /Surface One/ })); + ws.sent.length = 0; + + await user.click(screen.getByRole("button", { name: /Surface Two/ })); + + const msgs = sentMessages(ws) as Array<{ type: string; surfaceId: string }>; + const unsubIdx = msgs.findIndex((m) => m.type === "unsubscribe" && m.surfaceId === "s1"); + const subIdx = msgs.findIndex((m) => m.type === "subscribe" && m.surfaceId === "s2"); + expect(unsubIdx).toBeGreaterThanOrEqual(0); + expect(subIdx).toBeGreaterThanOrEqual(0); + expect(unsubIdx).toBeLessThan(subIdx); + + store.dispose(); + }); + + it("selected catalog button reflects aria-current", async () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + render(App, { props: { store } }); + + const user = userEvent.setup(); + const btn1 = screen.getByRole("button", { name: /Surface One/ }); + const btn2 = screen.getByRole("button", { name: /Surface Two/ }); + + await user.click(btn1); + expect(btn1).toHaveAttribute("aria-current", "true"); + expect(btn2).not.toHaveAttribute("aria-current"); + + await user.click(btn2); + expect(btn2).toHaveAttribute("aria-current", "true"); + expect(btn1).not.toHaveAttribute("aria-current"); + + store.dispose(); + }); + + it("an error message renders the alert banner", () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "error", + message: "Something went wrong", + }); + + render(App, { props: { store } }); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Something went wrong"); + + store.dispose(); + }); + + it("invoking a field action sends an invoke", async () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + }); + + render(App, { props: { store } }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /Surface One/ })); + + ws.feedMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [ + { + kind: "toggle", + label: "Dark Mode", + value: false, + action: { actionId: "toggle-dark" }, + }, + ], + }, + }); + + ws.sent.length = 0; + const checkbox = await screen.findByRole("checkbox", { name: "Dark Mode" }); + await user.click(checkbox); + + const msgs = sentMessages(ws); + const invoke = msgs.find( + (m: { type: string; surfaceId: string; actionId: string; payload: unknown }) => + m.type === "invoke" && + m.surfaceId === "s1" && + m.actionId === "toggle-dark" && + m.payload === true, + ); + expect(invoke).toBeTruthy(); + + store.dispose(); + }); +}); |
