From e09425ba81dd4a900c0be1d42d7ccf62ed66a322 Mon Sep 17 00:00:00 2001 From: Molecule AI App-FE Date: Mon, 11 May 2026 21:53:37 +0000 Subject: [PATCH] test(canvas/chat): add AttachmentViews coverage (16 cases) PendingAttachmentPill: renders name, formatted size (B/KB/MB), aria-label, exactly one button, calls onRemove on click. AttachmentChip: renders name and download glyph, renders size when provided, omits size span when size is undefined, title attribute for tooltip, calls onDownload(attachment) on click, tone=user applies blue-400 class, tone=agent omits blue-400 class, exactly one button. Co-Authored-By: Claude Opus 4.7 --- .../chat/__tests__/AttachmentViews.test.tsx | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 canvas/src/components/tabs/chat/__tests__/AttachmentViews.test.tsx 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..0f966033 --- /dev/null +++ b/canvas/src/components/tabs/chat/__tests__/AttachmentViews.test.tsx @@ -0,0 +1,185 @@ +// @vitest-environment jsdom +/** + * AttachmentViews — pure presentational components for chat attachments. + * + * Covers: + * - PendingAttachmentPill renders file name, formatted size, × button + * - PendingAttachmentPill × button has correct aria-label + * - PendingAttachmentPill calls onRemove when × clicked + * - PendingAttachmentPill renders exactly one button + * - AttachmentChip renders attachment name and download glyph + * - AttachmentChip renders size when provided + * - AttachmentChip omits size span when size is undefined + * - AttachmentChip calls onDownload(attachment) on click + * - AttachmentChip title attribute for hover tooltip + * - AttachmentChip tone=user applies blue accent classes + * - AttachmentChip tone=agent applies surface classes + * - AttachmentChip renders exactly one button + * + * NOTE: No @testing-library/jest-dom import — use textContent / className / + * getAttribute checks to avoid "expect is not defined" errors in this vitest + * configuration. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import React from "react"; + +import { AttachmentChip, PendingAttachmentPill } from "../AttachmentViews"; +import type { ChatAttachment } from "../types"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +// ─── Helpers ──────────────────────────────────────────────────────────────────── + +/** Create a File with actual content so size > 0 in jsdom. */ +function makeFile(name: string, content: string): File { + return new File([content], name, { type: "application/octet-stream" }); +} + +function makeAttachment(name: string, size?: number): ChatAttachment { + return { name, uri: `workspace:/tmp/${name}`, size }; +} + +// ─── PendingAttachmentPill ───────────────────────────────────────────────────── + +describe("PendingAttachmentPill", () => { + it("renders the file name", () => { + const file = makeFile("report.pdf", "PDF content here"); + const { container } = render( + , + ); + expect(container.textContent).toContain("report.pdf"); + }); + + it("renders the formatted file size (KB)", () => { + // 50 KB = 50 * 1024 bytes + const content = "x".repeat(50 * 1024); + const file = makeFile("data.csv", content); + const { container } = render( + , + ); + expect(container.textContent).toContain("50 KB"); + }); + + it("renders 0 B for empty file", () => { + const file = makeFile("empty.txt", ""); + const { container } = render( + , + ); + expect(container.textContent).toContain("0 B"); + }); + + it("renders size in MB for files >= 1 MB", () => { + // 2.5 MB = 2.5 * 1024 * 1024 bytes + const content = "x".repeat(Math.round(2.5 * 1024 * 1024)); + const file = makeFile("video.mp4", content); + const { container } = render( + , + ); + expect(container.textContent).toContain("2.5 MB"); + }); + + it("× button has aria-label with file name", () => { + const file = makeFile("notes.txt", "some content"); + render(); + const btn = screen.getByRole("button"); + expect(btn.getAttribute("aria-label")).toBe("Remove notes.txt"); + }); + + it("calls onRemove when × button is clicked", () => { + const file = makeFile("doc.pdf", "pdf data"); + const onRemove = vi.fn(); + render(); + screen.getByRole("button").click(); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it("renders exactly one button (the × remove button)", () => { + const file = makeFile("img.png", "image bytes"); + const { container } = render( + , + ); + expect(container.querySelectorAll("button")).toHaveLength(1); + }); +}); + +// ─── AttachmentChip ─────────────────────────────────────────────────────────── + +describe("AttachmentChip", () => { + it("renders the attachment name", () => { + const att = makeAttachment("chart.svg", 2048); + const { container } = render( + , + ); + expect(container.textContent).toContain("chart.svg"); + }); + + it("renders size when provided", () => { + const att = makeAttachment("dump.sql", 1024 * 150); // 150 KB + const { container } = render( + , + ); + expect(container.textContent).toContain("150 KB"); + }); + + it("omits size span when attachment.size is undefined", () => { + const att = makeAttachment("notes.md"); // no size + const { container } = render( + , + ); + // The only should be the truncated filename; no size + const spans = Array.from(container.querySelectorAll("span")); + const sizeSpans = spans.filter( + (s) => s.className && s.className.includes("tabular-nums"), + ); + expect(sizeSpans).toHaveLength(0); + }); + + it("has title attribute with download hint", () => { + const att = makeAttachment("readme.txt", 64); + const { container } = render( + , + ); + const btn = container.querySelector("button"); + expect(btn?.getAttribute("title")).toBe("Download readme.txt"); + }); + + it("calls onDownload with the attachment on click", () => { + const att = makeAttachment("export.csv", 8192); + const onDownload = vi.fn(); + const { container } = render( + , + ); + container.querySelector("button")!.click(); + expect(onDownload).toHaveBeenCalledWith(att); + }); + + it("tone=user applies blue accent class", () => { + const att = makeAttachment("photo.jpg", 512); + const { container } = render( + , + ); + const btn = container.querySelector("button")!; + expect(btn.className).toContain("blue-400"); + }); + + it("tone=agent does not apply blue accent class", () => { + const att = makeAttachment("photo.jpg", 512); + const { container } = render( + , + ); + const btn = container.querySelector("button")!; + expect(btn.className).not.toContain("blue-400"); + }); + + it("renders exactly one button", () => { + const att = makeAttachment("icon.svg", 128); + const { container } = render( + , + ); + expect(container.querySelectorAll("button")).toHaveLength(1); + }); +});