diff --git a/canvas/src/components/mobile/__tests__/MobileChat.test.tsx b/canvas/src/components/mobile/__tests__/MobileChat.test.tsx new file mode 100644 index 00000000..9b89df4c --- /dev/null +++ b/canvas/src/components/mobile/__tests__/MobileChat.test.tsx @@ -0,0 +1,323 @@ +// @vitest-environment jsdom +/** + * MobileChat — mobile message thread + composer + sub-tabs. + * + * Per spec §04: wired to /workspaces/:id/a2a (method message/send). + * Slimmer surface than desktop ChatTab: no attachments, no topology overlay. + * + * NOTE: No @testing-library/jest-dom — use DOM APIs. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render } from "@testing-library/react"; +import React from "react"; + +import { MobileChat } from "../MobileChat"; + +// ─── Mock store ─────────────────────────────────────────────────────────────── + +const mockAgentId = "ws-chat-test"; +const mockOnBack = vi.fn(); + +// Module-level mutable state for the mock store. +const mockStoreState = { + nodes: [] as Array<{ + id: string; + position: { x: number; y: number }; + data: Record; + width?: number; + height?: number; + }>, + agentMessages: {} as Record>, +}; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + vi.fn((sel) => sel(mockStoreState)), + { getState: () => mockStoreState }, + ), + summarizeWorkspaceCapabilities: vi.fn((data: Record) => { + const agentCard = data.agentCard as Record | null; + const skills = Array.isArray(agentCard?.skills) + ? (agentCard.skills as Array>).map( + (s) => String(s.name || s.id || ""), + ).filter(Boolean) + : []; + return { + runtime: (typeof data.runtime === "string" && data.runtime) + ? data.runtime + : (typeof agentCard?.runtime === "string" ? String(agentCard.runtime) : null), + skills, + skillCount: skills.length, + currentTask: String(data.currentTask ?? ""), + hasActiveTask: String(data.currentTask ?? "").trim().length > 0, + }; + }), +})); + +// ─── Mock API ───────────────────────────────────────────────────────────────── + +const { mockApiPost } = vi.hoisted(() => ({ + mockApiPost: vi.fn().mockResolvedValue({ result: { parts: [] } }), +})); + +vi.mock("@/lib/api", () => ({ + api: { post: mockApiPost }, +})); + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const onlineNode = { + id: mockAgentId, + position: { x: 0, y: 0 }, + data: { + name: "Chat Agent", + status: "online", + tier: 2, + agentCard: { + runtime: "claude-code", + skills: [{ name: "web-search" }], + }, + currentTask: "", + activeTasks: 0, + collapsed: false, + role: "agent", + lastErrorRate: 0, + lastSampleError: "", + url: "", + parentId: null, + runtime: "claude-code", + needsRestart: false, + }, +}; + +const offlineNode = { + id: "ws-offline", + position: { x: 0, y: 0 }, + data: { + name: "Offline Agent", + status: "offline", + tier: 1, + agentCard: null, + currentTask: "", + activeTasks: 0, + collapsed: false, + role: "agent", + lastErrorRate: 0, + lastSampleError: "", + url: "", + parentId: null, + runtime: "claude-code", + needsRestart: false, + }, +}; + +const degradedNode = { + id: "ws-degraded", + position: { x: 0, y: 0 }, + data: { + name: "Degraded Agent", + status: "degraded", + tier: 3, + agentCard: null, + currentTask: "", + activeTasks: 0, + collapsed: false, + role: "agent", + lastErrorRate: 0, + lastSampleError: "", + url: "", + parentId: null, + runtime: "claude-code", + needsRestart: false, + }, +}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function renderChat(agentId: string, dark = false) { + return render( + , + ); +} + +// ─── Setup / teardown ───────────────────────────────────────────────────────── + +beforeEach(() => { + mockOnBack.mockClear(); + mockStoreState.nodes = []; + mockStoreState.agentMessages = {}; + mockApiPost.mockClear(); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// ─── Not found ─────────────────────────────────────────────────────────────── + +describe("MobileChat — agent not found", () => { + it('renders "Agent not found." when node is absent', () => { + mockStoreState.nodes = [onlineNode]; + const { container } = renderChat("nonexistent-id"); + expect(container.textContent ?? "").toContain("Agent not found."); + }); +}); + +// ─── Header ────────────────────────────────────────────────────────────────── + +describe("MobileChat — header", () => { + beforeEach(() => { + mockStoreState.nodes = [onlineNode]; + }); + + it("renders Back button with aria-label", () => { + const { container } = renderChat(mockAgentId); + const backBtn = container.querySelector('[aria-label="Back"]'); + expect(backBtn).toBeTruthy(); + }); + + it("Back button calls onBack", () => { + const { container } = renderChat(mockAgentId); + const backBtn = container.querySelector('[aria-label="Back"]') as HTMLButtonElement; + backBtn.click(); + expect(mockOnBack).toHaveBeenCalledTimes(1); + }); + + it("renders agent name in header", () => { + const { container } = renderChat(mockAgentId); + expect(container.textContent ?? "").toContain("Chat Agent"); + }); + + it("renders a More button", () => { + const { container } = renderChat(mockAgentId); + const moreBtn = container.querySelector('[aria-label="More"]'); + expect(moreBtn).toBeTruthy(); + }); + + it("renders footer with agentId", () => { + const { container } = renderChat(mockAgentId); + expect(container.textContent ?? "").toContain(mockAgentId); + }); +}); + +// ─── Composer ──────────────────────────────────────────────────────────────── + +describe("MobileChat — composer", () => { + beforeEach(() => { + mockStoreState.nodes = [onlineNode]; + }); + + it("renders a textarea for message input", () => { + const { container } = renderChat(mockAgentId); + const textarea = container.querySelector("textarea"); + expect(textarea).toBeTruthy(); + }); + + it("textarea has placeholder text", () => { + const { container } = renderChat(mockAgentId); + const textarea = container.querySelector("textarea") as HTMLTextAreaElement; + expect(textarea.placeholder).toBeTruthy(); + expect(textarea.placeholder).toContain("Send a message"); + }); + + it("renders a Send button with aria-label", () => { + const { container } = renderChat(mockAgentId); + const sendBtn = container.querySelector('[aria-label="Send"]'); + expect(sendBtn).toBeTruthy(); + }); + + it("Send button is disabled when textarea is empty (no draft)", () => { + const { container } = renderChat(mockAgentId); + const sendBtn = container.querySelector('[aria-label="Send"]') as HTMLButtonElement; + expect(sendBtn.disabled).toBe(true); + }); +}); + +// ─── Tabs ───────────────────────────────────────────────────────────────────── + +describe("MobileChat — tabs", () => { + beforeEach(() => { + mockStoreState.nodes = [onlineNode]; + }); + + it("renders My Chat and Agent Comms tab labels", () => { + const { container } = renderChat(mockAgentId); + const text = container.textContent ?? ""; + expect(text).toContain("My Chat"); + expect(text).toContain("Agent Comms"); + }); + + it("defaults to My Chat tab", () => { + const { container } = renderChat(mockAgentId); + // My Chat is the default; if there are no messages it should show the empty state + expect(container.textContent ?? "").toContain("My Chat"); + }); +}); + +// ─── Empty state ───────────────────────────────────────────────────────────── + +describe("MobileChat — empty state", () => { + beforeEach(() => { + mockStoreState.nodes = [onlineNode]; + }); + + it('shows "Send a message to start chatting." when no messages', () => { + const { container } = renderChat(mockAgentId); + expect(container.textContent ?? "").toContain("Send a message to start chatting."); + }); + + it("shows no messages when agentMessages[agentId] is absent (undefined)", () => { + // Explicitly set to empty to simulate no stored messages + mockStoreState.agentMessages = {}; + const { container } = renderChat(mockAgentId); + expect(container.textContent ?? "").toContain("Send a message to start chatting."); + }); +}); + +// ─── Agent status ──────────────────────────────────────────────────────────── + +describe("MobileChat — agent status", () => { + it("renders composer for online agent", () => { + mockStoreState.nodes = [onlineNode]; + const { container } = renderChat(mockAgentId); + expect(container.querySelector("textarea")).toBeTruthy(); + }); + + it("renders composer for offline agent (with status text)", () => { + mockStoreState.nodes = [offlineNode]; + const { container } = renderChat("ws-offline"); + const textarea = container.querySelector("textarea") as HTMLTextAreaElement; + // Offline agent: textarea should be disabled + expect(textarea.disabled).toBe(true); + }); + + it("renders composer for degraded agent", () => { + mockStoreState.nodes = [degradedNode]; + const { container } = renderChat("ws-degraded"); + expect(container.querySelector("textarea")).toBeTruthy(); + }); + + it("offline agent shows agent name", () => { + mockStoreState.nodes = [offlineNode]; + const { container } = renderChat("ws-offline"); + expect(container.textContent ?? "").toContain("Offline Agent"); + }); +}); + +// ─── Dark mode ─────────────────────────────────────────────────────────────── + +describe("MobileChat — dark mode", () => { + beforeEach(() => { + mockStoreState.nodes = [onlineNode]; + }); + + it("renders without crashing in dark mode", () => { + const { container } = renderChat(mockAgentId, true); + expect(container.querySelector('[aria-label="Back"]')).toBeTruthy(); + }); +}); diff --git a/canvas/src/components/mobile/__tests__/MobileDetail.test.tsx b/canvas/src/components/mobile/__tests__/MobileDetail.test.tsx new file mode 100644 index 00000000..3fac2344 --- /dev/null +++ b/canvas/src/components/mobile/__tests__/MobileDetail.test.tsx @@ -0,0 +1,367 @@ +// @vitest-environment jsdom +/** + * MobileDetail — agent detail page with tabbed content (Overview/Activity/Config/Memory). + * + * Per spec §03: tabbed agent detail page. MobileChat (MR !717) was also tested here. + * + * NOTE: No @testing-library/jest-dom — use DOM APIs. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render } from "@testing-library/react"; +import React from "react"; + +import { MobileDetail } from "../MobileDetail"; + +// ─── Mock store ─────────────────────────────────────────────────────────────── + +const mockNodeId = "ws-detail-test"; +const mockOnBack = vi.fn(); +const mockOnChat = vi.fn(); + +// Module-level mutable state for the mock store. +// Tests mutate this between cases to control what the component sees. +const mockStoreState = { + nodes: [] as Array<{ + id: string; + position: { x: number; y: number }; + data: Record; + width?: number; + height?: number; + }>, +}; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + vi.fn((sel) => sel(mockStoreState)), + { getState: () => mockStoreState }, + ), + summarizeWorkspaceCapabilities: vi.fn((data: Record) => { + const agentCard = data.agentCard as Record | null; + const skills = Array.isArray(agentCard?.skills) + ? (agentCard.skills as Array>).map( + (s) => String(s.name || s.id || ""), + ).filter(Boolean) + : []; + return { + runtime: (typeof data.runtime === "string" && data.runtime) + ? data.runtime + : (typeof agentCard?.runtime === "string" ? String(agentCard.runtime) : null), + skills, + skillCount: skills.length, + currentTask: String(data.currentTask ?? ""), + hasActiveTask: String(data.currentTask ?? "").trim().length > 0, + }; + }), +})); + +// Stub the API so DetailActivity doesn't attempt real network calls. +vi.mock("@/lib/api", () => ({ api: { get: vi.fn().mockResolvedValue([]) } })); + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const onlineNode = { + id: mockNodeId, + position: { x: 100, y: 200 }, + data: { + name: "Test Agent", + status: "online", + tier: 2, + agentCard: { + runtime: "claude-code", + skills: [ + { name: "web-search", id: "skill-1" }, + { name: "code-review", id: "skill-2" }, + { name: "file-ops", id: "skill-3" }, + ], + }, + currentTask: "Reviewing PR #717", + activeTasks: 3, + collapsed: false, + role: "agent", + lastErrorRate: 0, + lastSampleError: "", + url: "", + parentId: null, + runtime: "claude-code", + needsRestart: false, + }, + width: 240, + height: 130, +}; + +const failedNode = { + id: "ws-failed", + position: { x: 0, y: 0 }, + data: { + name: "Failed Worker", + status: "failed", + tier: 4, + agentCard: null, + currentTask: "", + activeTasks: 0, + collapsed: false, + role: "agent", + lastErrorRate: 0.8, + lastSampleError: "Connection refused", + url: "", + parentId: null, + runtime: "external", + needsRestart: false, + }, +}; + +const offlineNode = { + id: "ws-offline", + position: { x: 0, y: 0 }, + data: { + name: "Offline Bot", + status: "offline", + tier: 1, + agentCard: null, + currentTask: "", + activeTasks: 0, + collapsed: false, + role: "agent", + lastErrorRate: 0, + lastSampleError: "", + url: "", + parentId: null, + runtime: "claude-code", + needsRestart: false, + }, +}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function renderDetail(agentId: string, dark = false) { + return render( + , + ); +} + +// ─── Setup / teardown ───────────────────────────────────────────────────────── + +beforeEach(() => { + mockOnBack.mockClear(); + mockOnChat.mockClear(); + mockStoreState.nodes = []; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// ─── Not found ──────────────────────────────────────────────────────────────── + +describe("MobileDetail — agent not found", () => { + it('renders "Agent not found." when no node matches agentId', () => { + mockStoreState.nodes = [onlineNode]; + const { container } = renderDetail("nonexistent-id"); + expect(container.textContent ?? "").toContain("Agent not found."); + }); + + it("does not render any tab buttons when agent not found", () => { + mockStoreState.nodes = []; + const { container } = renderDetail("ghost-agent"); + expect(container.querySelectorAll("button").length).toBe(0); + }); +}); + +// ─── Hero render ───────────────────────────────────────────────────────────── + +describe("MobileDetail — hero section", () => { + beforeEach(() => { + mockStoreState.nodes = [onlineNode]; + }); + + it("renders the agent name as an h1", () => { + const { container } = renderDetail(mockNodeId); + const h1 = container.querySelector("h1"); + expect(h1).toBeTruthy(); + expect(h1!.textContent).toBe("Test Agent"); + }); + + it("renders agent tag below the name", () => { + const { container } = renderDetail(mockNodeId); + // Tag appears in the hero section, styled differently from the name + expect(container.textContent ?? "").toContain("claude-code"); + }); + + it("renders a Back button with aria-label", () => { + const { container } = renderDetail(mockNodeId); + const backBtn = container.querySelector('[aria-label="Back"]'); + expect(backBtn).toBeTruthy(); + }); + + it("Back button calls onBack", () => { + const { container } = renderDetail(mockNodeId); + const backBtn = container.querySelector('[aria-label="Back"]') as HTMLButtonElement; + backBtn.click(); + expect(mockOnBack).toHaveBeenCalledTimes(1); + }); + + it("renders a More button", () => { + const { container } = renderDetail(mockNodeId); + const moreBtn = container.querySelector('[aria-label="More"]'); + expect(moreBtn).toBeTruthy(); + }); + + it("renders Chat CTA with icon text", () => { + const { container } = renderDetail(mockNodeId); + expect(container.textContent ?? "").toContain("Open chat"); + }); + + it("Chat CTA calls onChat", () => { + const { container } = renderDetail(mockNodeId); + const chatBtn = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.includes("Open chat"), + ); + expect(chatBtn).toBeTruthy(); + (chatBtn as HTMLButtonElement).click(); + expect(mockOnChat).toHaveBeenCalledTimes(1); + }); +}); + +// ─── Pill stats ─────────────────────────────────────────────────────────────── + +describe("MobileDetail — pill stats", () => { + beforeEach(() => { + mockStoreState.nodes = [onlineNode]; + }); + + it("renders TIER pill with the agent tier", () => { + const { container } = renderDetail(mockNodeId); + expect(container.textContent ?? "").toContain("TIER"); + }); + + it("renders RUNTIME pill", () => { + const { container } = renderDetail(mockNodeId); + expect(container.textContent ?? "").toContain("RUNTIME"); + }); + + it("renders SKILLS pill with count", () => { + const { container } = renderDetail(mockNodeId); + // 3 skills in the agentCard fixture + expect(container.textContent ?? "").toContain("SKILLS"); + }); + + it("renders STATUS pill", () => { + const { container } = renderDetail(mockNodeId); + expect(container.textContent ?? "").toContain("STATUS"); + }); + + it("STATUS pill shows agent status value", () => { + const { container } = renderDetail(mockNodeId); + // online status from the fixture + expect(container.textContent ?? "").toContain("online"); + }); + + it("renders all 4 pills for online agent", () => { + const { container } = renderDetail(mockNodeId); + // Count the pill container divs — each PillStat is a div with specific inline styles + // We verify by content: TIER, RUNTIME, SKILLS, STATUS should all be present + const text = container.textContent ?? ""; + expect(text).toContain("TIER"); + expect(text).toContain("RUNTIME"); + expect(text).toContain("SKILLS"); + expect(text).toContain("STATUS"); + }); +}); + +// ─── Tabs ───────────────────────────────────────────────────────────────────── + +describe("MobileDetail — tab switching", () => { + beforeEach(() => { + mockStoreState.nodes = [onlineNode]; + }); + + it("renders all 4 tab buttons", () => { + const { container } = renderDetail(mockNodeId); + const text = container.textContent ?? ""; + expect(text).toContain("Overview"); + expect(text).toContain("Activity"); + expect(text).toContain("Config"); + expect(text).toContain("Memory"); + }); + + it("defaults to Overview tab", () => { + const { container } = renderDetail(mockNodeId); + // DetailOverview renders ID, Tier, Runtime, Active tasks, Skills, Origin rows + expect(container.textContent ?? "").toContain("ID"); + expect(container.textContent ?? "").toContain("Tier"); + }); + + it("Overview tab shows agent ID", () => { + const { container } = renderDetail(mockNodeId); + expect(container.textContent ?? "").toContain(mockNodeId); + }); + + it("Overview tab shows active tasks count", () => { + const { container } = renderDetail(mockNodeId); + // onlineNode has activeTasks: 3 + expect(container.textContent ?? "").toContain("Active tasks"); + expect(container.textContent ?? "").toContain("3"); + }); + + it("Overview tab shows skill count", () => { + const { container } = renderDetail(mockNodeId); + // 3 skills in agentCard + expect(container.textContent ?? "").toContain("Skills"); + expect(container.textContent ?? "").toContain("3 loaded"); + }); + + it("Config tab button is findable and is a button element", () => { + const { container } = renderDetail(mockNodeId); + const configTab = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Config", + ); + expect(configTab).toBeTruthy(); + expect((configTab as HTMLButtonElement).type).toBe("button"); + }); + + it("Memory tab button is findable and is a button element", () => { + const { container } = renderDetail(mockNodeId); + const memoryTab = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Memory", + ); + expect(memoryTab).toBeTruthy(); + expect((memoryTab as HTMLButtonElement).type).toBe("button"); + }); +}); + +// ─── Status rendering ───────────────────────────────────────────────────────── + +describe("MobileDetail — status rendering", () => { + it("renders failed status for failed agent", () => { + mockStoreState.nodes = [failedNode]; + const { container } = renderDetail("ws-failed"); + expect(container.textContent ?? "").toContain("Failed Worker"); + expect(container.textContent ?? "").toContain("failed"); + }); + + it("renders offline status for offline agent", () => { + mockStoreState.nodes = [offlineNode]; + const { container } = renderDetail("ws-offline"); + expect(container.textContent ?? "").toContain("Offline Bot"); + expect(container.textContent ?? "").toContain("offline"); + }); +}); + +// ─── Dark mode ─────────────────────────────────────────────────────────────── + +describe("MobileDetail — dark mode", () => { + beforeEach(() => { + mockStoreState.nodes = [onlineNode]; + }); + + it("renders without crashing in dark mode", () => { + const { container } = renderDetail(mockNodeId, true); + expect(container.querySelector("h1")?.textContent).toBe("Test Agent"); + }); +}); diff --git a/canvas/src/components/mobile/__tests__/MobileHome.test.tsx b/canvas/src/components/mobile/__tests__/MobileHome.test.tsx new file mode 100644 index 00000000..0c49df2b --- /dev/null +++ b/canvas/src/components/mobile/__tests__/MobileHome.test.tsx @@ -0,0 +1,245 @@ +// @vitest-environment jsdom +/** + * MobileHome — workspace agent list + filter chips + spawn FAB. + * + * Per spec §01: live store data, filter by status, spawn FAB. + * + * NOTE: No @testing-library/jest-dom — use DOM APIs. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render } from "@testing-library/react"; +import React from "react"; + +import { MobileHome } from "../MobileHome"; + +// ─── Mock store ─────────────────────────────────────────────────────────────── + +const mockOnOpen = vi.fn(); +const mockOnSpawn = vi.fn(); + +const mockStoreState = { + nodes: [] as Array<{ + id: string; + position: { x: number; y: number }; + data: Record; + width?: number; + height?: number; + }>, +}; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + vi.fn((sel) => sel(mockStoreState)), + { getState: () => mockStoreState }, + ), + summarizeWorkspaceCapabilities: vi.fn((data: Record) => { + const agentCard = data.agentCard as Record | null; + const skills = Array.isArray(agentCard?.skills) + ? (agentCard.skills as Array>).map( + (s) => String(s.name || s.id || ""), + ).filter(Boolean) + : []; + return { + runtime: (typeof data.runtime === "string" && data.runtime) + ? data.runtime + : (typeof agentCard?.runtime === "string" ? String(agentCard.runtime) : null), + skills, + skillCount: skills.length, + currentTask: String(data.currentTask ?? ""), + hasActiveTask: String(data.currentTask ?? "").trim().length > 0, + }; + }), +})); + +// ─── Fixtures ─────────────────────────────────────────────────────────────── + +function makeNode(overrides: Partial> = {}) { + return { + id: `ws-${Math.random().toString(36).slice(2, 7)}`, + position: { x: 0, y: 0 }, + data: { + name: "Agent", + status: "online", + tier: 2, + agentCard: null, + currentTask: "", + activeTasks: 0, + collapsed: false, + role: "agent", + lastErrorRate: 0, + lastSampleError: "", + url: "", + parentId: null, + runtime: "claude-code", + needsRestart: false, + ...overrides, + }, + }; +} + +const onlineAgent = makeNode({ name: "Online Agent", status: "online", tier: 2 }); +const failedAgent = makeNode({ name: "Failed Agent", status: "failed", tier: 4 }); +const pausedAgent = makeNode({ name: "Paused Agent", status: "paused", tier: 1 }); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function renderHome(overrides: Partial<{ + dark: boolean; + density: "compact" | "regular"; + workspaceLabel: string; + username: string; +}> = {}) { + return render( + , + ); +} + +// ─── Setup / teardown ───────────────────────────────────────────────────────── + +beforeEach(() => { + mockOnOpen.mockClear(); + mockOnSpawn.mockClear(); + mockStoreState.nodes = []; +}); + +afterEach(() => { + cleanup(); +}); + +// ─── Structure ─────────────────────────────────────────────────────────────── + +describe("MobileHome — page structure", () => { + it('renders "Agents" heading', () => { + mockStoreState.nodes = [onlineAgent]; + const { container } = renderHome(); + const h1 = container.querySelector("h1"); + expect(h1).toBeTruthy(); + expect(h1!.textContent).toBe("Agents"); + }); + + it("renders WorkspacePill with agent count", () => { + mockStoreState.nodes = [onlineAgent, failedAgent]; + const { container } = renderHome(); + // WorkspacePill renders the agent count somewhere in the DOM + expect(container.textContent ?? "").toContain("2"); + }); + + it('shows "live" suffix in subheading', () => { + mockStoreState.nodes = [onlineAgent]; + const { container } = renderHome(); + // Single agent → "1 workspace · live" (singular) + expect(container.textContent ?? "").toContain("workspace"); + expect(container.textContent ?? "").toContain("live"); + }); + + it("renders FilterChips row", () => { + mockStoreState.nodes = [onlineAgent]; + const { container } = renderHome(); + // FilterChips renders buttons for "All", "Online", "Issues", "Paused" + const text = container.textContent ?? ""; + expect(text).toContain("All"); + expect(text).toContain("Online"); + expect(text).toContain("Issues"); + }); + + it("renders Workspace section label", () => { + mockStoreState.nodes = [onlineAgent]; + const { container } = renderHome(); + expect(container.textContent ?? "").toContain("Workspace"); + }); + + it("renders spawn FAB with aria-label", () => { + mockStoreState.nodes = [onlineAgent]; + const { container } = renderHome(); + const fab = container.querySelector('[aria-label="Spawn new agent"]'); + expect(fab).toBeTruthy(); + }); + + it("FAB calls onSpawn", () => { + mockStoreState.nodes = [onlineAgent]; + const { container } = renderHome(); + const fab = container.querySelector('[aria-label="Spawn new agent"]') as HTMLButtonElement; + fab.click(); + expect(mockOnSpawn).toHaveBeenCalledTimes(1); + }); + + it("shows username when provided", () => { + mockStoreState.nodes = [onlineAgent]; + const { container } = renderHome({ username: "alice@example.com" }); + expect(container.textContent ?? "").toContain("alice@example.com"); + }); + + it("omits username when not provided", () => { + mockStoreState.nodes = [onlineAgent]; + const { container } = renderHome(); + expect(container.querySelector('[style*="letter-spacing"]')?.textContent).not.toContain("@"); + }); + + it("renders with custom workspaceLabel", () => { + mockStoreState.nodes = [onlineAgent]; + const { container } = renderHome({ workspaceLabel: "Production" }); + expect(container.textContent ?? "").toContain("Production"); + }); +}); + +// ─── Agent list ───────────────────────────────────────────────────────────── + +describe("MobileHome — agent list", () => { + it("renders agent cards when nodes are present", () => { + mockStoreState.nodes = [onlineAgent, failedAgent, pausedAgent]; + const { container } = renderHome(); + expect(container.textContent ?? "").toContain("Online Agent"); + expect(container.textContent ?? "").toContain("Failed Agent"); + expect(container.textContent ?? "").toContain("Paused Agent"); + }); + + it("shows 'No agents match this filter.' when filter returns empty", () => { + mockStoreState.nodes = [onlineAgent]; + const { container } = renderHome(); + // By default filter is "all" — all agents match + expect(container.textContent ?? "").not.toContain("No agents match"); + // If we could set filter to something that filters everything out... + // (filter is internal state, we test the "all" default) + expect(container.querySelectorAll("button").length).toBeGreaterThan(0); + }); + + it("renders no agents when node list is empty", () => { + mockStoreState.nodes = []; + const { container } = renderHome(); + // Should show "0 workspaces" and "No agents match this filter." + expect(container.textContent ?? "").toContain("0 workspace"); + }); +}); + +// ─── Agent count display ────────────────────────────────────────────────────── + +describe("MobileHome — agent count", () => { + it("shows singular 'workspace' when count is 1", () => { + mockStoreState.nodes = [onlineAgent]; + const { container } = renderHome(); + expect(container.textContent ?? "").toContain("1 workspace"); + }); + + it("shows plural 'workspaces' when count is > 1", () => { + mockStoreState.nodes = [onlineAgent, failedAgent]; + const { container } = renderHome(); + expect(container.textContent ?? "").toContain("2 workspaces"); + }); +}); + +// ─── Dark mode ─────────────────────────────────────────────────────────────── + +describe("MobileHome — dark mode", () => { + it("renders without crashing in dark mode", () => { + mockStoreState.nodes = [onlineAgent]; + const { container } = renderHome({ dark: true }); + expect(container.querySelector("h1")?.textContent).toBe("Agents"); + }); +}); diff --git a/canvas/src/components/mobile/__tests__/MobileMe.test.tsx b/canvas/src/components/mobile/__tests__/MobileMe.test.tsx new file mode 100644 index 00000000..cdda9712 --- /dev/null +++ b/canvas/src/components/mobile/__tests__/MobileMe.test.tsx @@ -0,0 +1,212 @@ +// @vitest-environment jsdom +/** + * MobileMe — theme, accent, and density preferences. + * + * Per spec: theme + accent + density settings for mobile. + * + * NOTE: No @testing-library/jest-dom — use DOM APIs. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render } from "@testing-library/react"; +import React from "react"; + +import { MobileMe } from "../MobileMe"; + +// ─── Mock theme provider ─────────────────────────────────────────────────────── + +const mockSetTheme = vi.fn(); +const mockSetAccent = vi.fn(); +const mockSetDensity = vi.fn(); + +vi.mock("@/lib/theme-provider", () => ({ + useTheme: vi.fn(() => ({ + theme: "system", + resolvedTheme: "light", + setTheme: mockSetTheme, + })), +})); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function renderMe(overrides: Partial<{ + dark: boolean; + accent: string; + density: "compact" | "regular"; +}> = {}) { + return render( + , + ); +} + +// ─── Setup / teardown ───────────────────────────────────────────────────────── + +beforeEach(() => { + mockSetTheme.mockClear(); + mockSetAccent.mockClear(); + mockSetDensity.mockClear(); +}); + +afterEach(() => { + cleanup(); +}); + +// ─── Structure ─────────────────────────────────────────────────────────────── + +describe("MobileMe — page structure", () => { + it('renders "Me" heading', () => { + const { container } = renderMe(); + const h1 = container.querySelector("h1"); + expect(h1).toBeTruthy(); + expect(h1!.textContent).toBe("Me"); + }); + + it("renders theme section label", () => { + const { container } = renderMe(); + expect(container.textContent ?? "").toContain("Theme"); + }); + + it("renders theme options: System, Light, Dark", () => { + const { container } = renderMe(); + const text = container.textContent ?? ""; + expect(text).toContain("System"); + expect(text).toContain("Light"); + expect(text).toContain("Dark"); + }); + + it("renders accent section label", () => { + const { container } = renderMe(); + expect(container.textContent ?? "").toContain("Accent"); + }); + + it("renders all 5 accent color swatches", () => { + const { container } = renderMe(); + const swatches = container.querySelectorAll("button[aria-label]"); + // 5 accent swatches + theme buttons + density buttons = more than 5 + // We verify the accent swatches by checking aria-labels + const accentLabels = Array.from(swatches) + .map((b) => b.getAttribute("aria-label") ?? "") + .filter((l) => l.startsWith("Set accent")); + expect(accentLabels.length).toBe(5); + }); + + it("renders density section label", () => { + const { container } = renderMe(); + expect(container.textContent ?? "").toContain("Density"); + }); + + it("renders density options: Regular, Compact", () => { + const { container } = renderMe(); + const text = container.textContent ?? ""; + expect(text).toContain("Regular"); + expect(text).toContain("Compact"); + }); + + it("renders version footer", () => { + const { container } = renderMe(); + expect(container.textContent ?? "").toContain("Mobile design preview"); + }); +}); + +// ─── Theme selection ────────────────────────────────────────────────────────── + +describe("MobileMe — theme selection", () => { + it("renders System as the active theme (from mock)", () => { + const { container } = renderMe(); + // The theme buttons are rendered; System is active in our mock + // We verify the buttons exist and are findable + const buttons = Array.from(container.querySelectorAll("button")); + const themeButtons = buttons.filter( + (b) => ["System", "Light", "Dark"].includes(b.textContent?.trim() ?? ""), + ); + expect(themeButtons.length).toBe(3); + }); + + it("calls setTheme when a theme button is clicked", () => { + const { container } = renderMe(); + const darkBtn = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Dark", + ); + expect(darkBtn).toBeTruthy(); + darkBtn!.click(); + expect(mockSetTheme).toHaveBeenCalledWith("dark"); + }); +}); + +// ─── Accent selection ──────────────────────────────────────────────────────── + +describe("MobileMe — accent selection", () => { + it("renders accent buttons with aria-label", () => { + const { container } = renderMe(); + const swatches = container.querySelectorAll("button[aria-label]"); + const accentSwatches = Array.from(swatches).filter( + (b) => (b.getAttribute("aria-label") ?? "").startsWith("Set accent"), + ); + expect(accentSwatches.length).toBe(5); + }); + + it("calls setAccent with the correct color", () => { + const { container } = renderMe(); + const swatch = Array.from(container.querySelectorAll("button[aria-label]")).find( + (b) => b.getAttribute("aria-label") === "Set accent #3b6fe0", + ); + expect(swatch).toBeTruthy(); + swatch!.click(); + expect(mockSetAccent).toHaveBeenCalledWith("#3b6fe0"); + }); +}); + +// ─── Density selection ──────────────────────────────────────────────────────── + +describe("MobileMe — density selection", () => { + it("renders density buttons", () => { + const { container } = renderMe(); + const buttons = Array.from(container.querySelectorAll("button")); + const densityButtons = buttons.filter( + (b) => ["Regular", "Compact"].includes(b.textContent?.trim() ?? ""), + ); + expect(densityButtons.length).toBe(2); + }); + + it("calls setDensity when Compact is clicked", () => { + const { container } = renderMe({ density: "regular" }); + const compactBtn = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Compact", + ); + expect(compactBtn).toBeTruthy(); + compactBtn!.click(); + expect(mockSetDensity).toHaveBeenCalledWith("compact"); + }); + + it("calls setDensity when Regular is clicked", () => { + const { container } = renderMe({ density: "compact" }); + const regularBtn = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Regular", + ); + expect(regularBtn).toBeTruthy(); + regularBtn!.click(); + expect(mockSetDensity).toHaveBeenCalledWith("regular"); + }); +}); + +// ─── Dark mode ─────────────────────────────────────────────────────────────── + +describe("MobileMe — dark mode", () => { + it("renders without crashing in dark mode", () => { + const { container } = renderMe({ dark: true }); + expect(container.querySelector("h1")?.textContent).toBe("Me"); + }); + + it("renders theme, accent, and density sections in dark mode", () => { + const { container } = renderMe({ dark: true }); + const text = container.textContent ?? ""; + expect(text).toContain("Theme"); + expect(text).toContain("Accent"); + expect(text).toContain("Density"); + }); +});