diff --git a/canvas/src/components/tabs/chat/__tests__/AttachmentViews.test.tsx b/canvas/src/components/tabs/chat/__tests__/AttachmentViews.test.tsx new file mode 100644 index 00000000..088e7518 --- /dev/null +++ b/canvas/src/components/tabs/chat/__tests__/AttachmentViews.test.tsx @@ -0,0 +1,167 @@ +// @vitest-environment jsdom +/** + * Tests for AttachmentViews.tsx — PendingAttachmentPill + AttachmentChip. + * + * 16 cases covering: + * - PendingAttachmentPill: name, size, aria-label, onRemove, one-button guard + * - AttachmentChip: name+glyph, size, no-size, title, onDownload, tone=user/agent, one-button guard + * + * Pattern: render the real component, inspect actual DOM output. + * No mocking of the components themselves. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import React from "react"; + +import { + PendingAttachmentPill, + AttachmentChip, +} from "../AttachmentViews"; +import type { ChatAttachment } from "../types"; + +afterEach(cleanup); + +// ─── Shared test fixtures ──────────────────────────────────────────────────── + +const makeFile = (name: string, size: number): File => + new File([new Uint8Array(size)], name, { type: "application/octet-stream" }); + +const makeAttachment = (overrides: Partial = {}): ChatAttachment => ({ + name: "report.pdf", + uri: "workspace:/workspace/report.pdf", + mimeType: "application/pdf", + size: 42_000, + ...overrides, +}); + +// ─── PendingAttachmentPill ─────────────────────────────────────────────────── + +describe("PendingAttachmentPill", () => { + describe("renders", () => { + it("displays the file name", () => { + const file = makeFile("notes.txt", 128); + render(); + expect(screen.getByText("notes.txt")).toBeTruthy(); + }); + + it("displays formatted size in bytes", () => { + // File([], name) gives size 0; pass a Uint8Array to set actual byte size. + const file = new File([new Uint8Array(512)], "tiny.bin"); + render(); + expect(screen.getByText("512 B")).toBeTruthy(); + }); + + it("displays formatted size in KB", () => { + const file = new File([new Uint8Array(5 * 1024)], "medium.zip"); + render(); + expect(screen.getByText("5 KB")).toBeTruthy(); + }); + + it("displays formatted size in MB", () => { + const file = new File([new Uint8Array(Math.floor(1.5 * 1024 * 1024))], "large.tar"); + render(); + // formatSize uses toFixed(1) for MB → "1.5 MB" + expect(screen.getByText("1.5 MB")).toBeTruthy(); + }); + + it('× button has aria-label "Remove "', () => { + const file = makeFile("memo.pdf", 1_000); + render(); + expect(screen.getByRole("button", { name: /remove memo\.pdf/i })).toBeTruthy(); + }); + + it("calls onRemove when × button is clicked", () => { + const onRemove = vi.fn(); + const file = makeFile("photo.png", 999); + render(); + fireEvent.click(screen.getByRole("button", { name: /remove photo\.png/i })); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it("renders exactly one button (no stray click targets)", () => { + const file = makeFile("doc.docx", 20_000); + render(); + const buttons = screen.getAllByRole("button"); + expect(buttons).toHaveLength(1); + }); + }); +}); + +// ─── AttachmentChip ──────────────────────────────────────────────────────── + +describe("AttachmentChip", () => { + let onDownload: ReturnType; + + beforeEach(() => { + onDownload = vi.fn(); + }); + + describe("renders", () => { + it("displays the attachment name", () => { + const att = makeAttachment({ name: "analysis.csv" }); + render(); + expect(screen.getByText("analysis.csv")).toBeTruthy(); + }); + + it("displays the download glyph (SVG icon) inside the button", () => { + const att = makeAttachment(); + render(); + const button = screen.getByRole("button"); + // DownloadGlyph is an